Merge remote-tracking branch 'origin/main' into openclaw/fix-issue-126115-in-openclaw

This commit is contained in:
RoboClaw
2026-08-19 04:05:13 +00:00
188 changed files with 6605 additions and 2165 deletions
+62
View File
@@ -811,6 +811,65 @@ jobs:
}
node scripts/check-openclaw-package-tarball.mjs "$package"
npm_12_install_sh:
name: npm 12 install.sh acceptance
needs: [resolve_package, package_integrity]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
actions: read
contents: read
steps:
- name: Checkout package workflow ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.workflow_ref }}
fetch-depth: 1
persist-credentials: false
- name: Setup Node 24
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Download package-under-test artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.resolve_package.outputs.package_artifact_id }}
path: .artifacts/docker-e2e-package
run-id: ${{ needs.resolve_package.outputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Run install.sh with npm 12
env:
EXPECTED_PACKAGE_SHA256: ${{ needs.resolve_package.outputs.package_sha256 }}
EXPECTED_PACKAGE_VERSION: ${{ needs.resolve_package.outputs.package_version }}
shell: bash
run: |
set -euo pipefail
package="$PWD/.artifacts/docker-e2e-package/openclaw-current.tgz"
[[ "$(sha256sum "$package" | awk '{print $1}')" == "$EXPECTED_PACKAGE_SHA256" ]]
npm_tool="$RUNNER_TEMP/openclaw-npm12-tool"
install_home="$RUNNER_TEMP/openclaw-npm12-home"
install_prefix="$RUNNER_TEMP/openclaw-npm12-prefix"
mkdir -p "$install_home" "$install_prefix"
npm install -g --prefix "$npm_tool" npm@12.0.2
export PATH="$npm_tool/bin:$install_prefix/bin:$PATH"
[[ "$(npm --version)" == "12.0.2" ]]
HOME="$install_home" \
NPM_CONFIG_CACHE="$RUNNER_TEMP/openclaw-npm12-cache" \
NPM_CONFIG_PREFIX="$install_prefix" \
OPENCLAW_VERSION="$package" \
bash scripts/install.sh --install-method npm --no-prompt --no-onboard
source scripts/docker/install-sh-common/version-parse.sh
installed_version="$(extract_openclaw_semver "$("$install_prefix/bin/openclaw" --version)")"
[[ "$installed_version" == "$EXPECTED_PACKAGE_VERSION" ]] || {
echo "Installed OpenClaw version $installed_version differs from expected $EXPECTED_PACKAGE_VERSION." >&2
exit 1
}
guard="$install_prefix/lib/node_modules/openclaw/dist/openclaw-install-guard"
[[ ! -e "$guard" ]]
docker_acceptance:
name: Docker product acceptance (artifact-only)
needs: [resolve_package, package_integrity]
@@ -951,6 +1010,7 @@ jobs:
[
resolve_package,
package_integrity,
npm_12_install_sh,
docker_acceptance,
docker_acceptance_registry,
package_telegram,
@@ -965,6 +1025,7 @@ jobs:
DOCKER_ARTIFACT_RESULT: ${{ needs.docker_acceptance.result }}
DOCKER_REGISTRY_RESULT: ${{ needs.docker_acceptance_registry.result }}
PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }}
NPM_12_INSTALL_RESULT: ${{ needs.npm_12_install_sh.result }}
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
TELEGRAM_ENABLED: ${{ needs.resolve_package.outputs.telegram_enabled }}
@@ -988,6 +1049,7 @@ jobs:
for item in \
"resolve_package=${RESOLVE_RESULT}" \
"package_integrity=${PACKAGE_INTEGRITY_RESULT}" \
"npm_12_install_sh=${NPM_12_INSTALL_RESULT}" \
"docker_acceptance=${docker_result}" \
"package_telegram=${PACKAGE_TELEGRAM_RESULT}"
do
+173
View File
@@ -19797,6 +19797,17 @@
}
]
},
{
"id": "native.apple.b7a51f5ac6098ec5",
"source": "%@ is not currently discoverable on this network.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.23ad69a52fe7c550",
"source": "%@ min",
@@ -23606,6 +23617,17 @@
}
]
},
{
"id": "native.apple.c767224da1135c79",
"source": "Change the primary Gateway?",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.68f28e54baab5743",
"source": "Change the search or readiness filter.",
@@ -26247,6 +26269,17 @@
}
]
},
{
"id": "native.apple.23f447855b25395a",
"source": "Could Not Change Primary Gateway",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.2e44cb00b99f5c81",
"source": "Could Not Open Gateway Window",
@@ -26482,6 +26515,28 @@
}
]
},
{
"id": "native.apple.6c192cb704c2f643",
"source": "Could not save the active gateway selection.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.839637d118fd48d8",
"source": "Could not save the paired gateway.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.3fafd1c7d790f37e",
"source": "Could not save the watch device credential",
@@ -28124,6 +28179,10 @@
"kind": "ui-call",
"path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift"
},
{
"kind": "ui-named-argument",
"path": "apps/ios/Sources/RootTabs.swift"
},
{
"kind": "ui-modifier",
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift"
@@ -29325,6 +29384,17 @@
}
]
},
{
"id": "native.apple.cd811b1e4af50d99",
"source": "Failed to build the gateway URL.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.192cb36afc99c87d",
"source": "Failed: \\(reason)",
@@ -30138,6 +30208,17 @@
}
]
},
{
"id": "native.apple.a89c5ab50baafc61",
"source": "Gateway Setup Not Supported",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.384fcc93f505c5c1",
"source": "Gateway URL",
@@ -30457,6 +30538,17 @@
}
]
},
{
"id": "native.apple.a6ec7e8d8d11e84a",
"source": "Gateway reconnect failed",
"surface": "apple",
"sites": [
{
"kind": "ui-named-argument",
"path": "apps/ios/Sources/RootTabs.swift"
}
]
},
{
"id": "native.apple.6f2661cb894de16f",
"source": "Gateway recovery failed.",
@@ -34325,6 +34417,10 @@
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/RootSidebar.swift"
},
{
"kind": "ui-named-argument",
"path": "apps/ios/Sources/RootTabs.swift"
}
]
},
@@ -35290,6 +35386,17 @@
}
]
},
{
"id": "native.apple.67798128e396e0ef",
"source": "No paired gateway is available to reconnect.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.c137581496211b45",
"source": "No phase status",
@@ -43339,6 +43446,17 @@
}
]
},
{
"id": "native.apple.bb03dba3e7eb1ee8",
"source": "Sending test notification…",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DebugSettings.swift"
}
]
},
{
"id": "native.apple.19c12c52941ddf52",
"source": "Sent with foreground app connections to this gateway. Changes apply on the next reconnect; Share extension delivery is not yet supported.",
@@ -46007,6 +46125,17 @@
}
]
},
{
"id": "native.apple.0edae9804e36af9f",
"source": "Test notification queued.",
"surface": "apple",
"sites": [
{
"kind": "ui-call",
"path": "apps/macos/Sources/OpenClaw/DebugSettings.swift"
}
]
},
{
"id": "native.apple.1a8953b851a377c3",
"source": "Test remote",
@@ -47098,6 +47227,17 @@
}
]
},
{
"id": "native.apple.768fb431d51d4190",
"source": "This gateway is unavailable.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.39e226bc9f837cb8",
"source": "This gateway is using unsupported auth",
@@ -47153,6 +47293,28 @@
}
]
},
{
"id": "native.apple.47e70eedcab1a81c",
"source": "This paired gateway has an invalid saved endpoint.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.bb5f5e891aeadb25",
"source": "This paired gateway is no longer available.",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift"
}
]
},
{
"id": "native.apple.c6f29415067807fd",
"source": "This permanently deletes the session and its transcript.",
@@ -50732,6 +50894,17 @@
}
]
},
{
"id": "native.apple.8e781789ba7a7b6a",
"source": "an unencrypted private-network connection",
"surface": "apple",
"sites": [
{
"kind": "conditional-branch",
"path": "apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift"
}
]
},
{
"id": "native.apple.a7cc4e836193eab6",
"source": "announce",
@@ -58,6 +58,7 @@ struct SettingsProTab: View {
@State var manualGatewayPortText = ""
@State var manualGatewayContextPath: String?
@State var setupStatusText: String?
@State var gatewayActionStatusText: String?
@State var setupAttemptID: UUID?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@@ -128,25 +128,32 @@ extension SettingsProTab {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
guard !self.isReconnectingGateway else { return }
self.isReconnectingGateway = true
self.gatewayActionStatusText = nil
defer { self.isReconnectingGateway = false }
await self.gatewayController.connectActiveGateway()
if case let .failed(message) = await self.gatewayController.connectActiveGateway() {
self.gatewayActionStatusText = message
}
}
func switchGateway(to entry: GatewaySettingsStore.GatewayRegistryEntry) async {
guard self.connectingGateway == nil else { return }
self.connectingGateway = .gateway(entry.id)
self.setupStatusText = String(
self.gatewayActionStatusText = String(
format: String(localized: "Switching to %@…"),
entry.name)
defer {
self.connectingGateway = nil
self.refreshGatewayRegistry()
}
if let failure = await self.gatewayController.switchToGateway(stableID: entry.stableID) {
self.setupStatusText = failure
return
switch await self.gatewayController.switchToGateway(stableID: entry.stableID) {
case .accepted:
self.gatewayActionStatusText = nil
self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false)
case let .failed(message):
self.gatewayActionStatusText = message
case .superseded:
self.gatewayActionStatusText = nil
}
self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false)
}
func forgetGateway(_ entry: GatewaySettingsStore.GatewayRegistryEntry) async {
@@ -347,6 +347,8 @@ extension SettingsProTab {
.font(OpenClawType.body)
}
.disabled(self.isRefreshingGateway)
} footer: {
self.gatewayActionStatusView
}
self.gatewaySetupCard
@@ -831,6 +833,17 @@ extension SettingsProTab {
{
Task { await self.runDiagnostics() }
}
self.gatewayActionStatusView
}
}
@ViewBuilder
var gatewayActionStatusView: some View {
if let gatewayActionStatusText {
Text(verbatim: gatewayActionStatusText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
@@ -31,6 +31,12 @@ private func defaultGatewayTCPReachabilityProbe(
@MainActor
@Observable
final class GatewayConnectionController {
enum ConnectionAttemptResult: Equatable {
case accepted
case failed(String)
case superseded
}
enum DiscoveredGatewayConnectionAvailability: Equatable {
case available
case secureTransportRequired
@@ -254,9 +260,13 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
}
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
/// Direct setup callers keep their existing diagnostic contract while registered
/// reconnect and switch actions consume the closed attempt result below.
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
await self.connectDiscoveredGateway(gateway)
if case let .failed(message) = await self.connectDiscoveredGateway(gateway) {
return message
}
return nil
}
func discoveredGatewayConnectionAvailability(
@@ -276,21 +286,23 @@ final class GatewayConnectionController {
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
forceReconnect: Bool = false) async -> ConnectionAttemptResult
{
let availability = self.discoveredGatewayConnectionAvailability(gateway)
guard availability.canConnect else { return availability.guidanceText }
guard availability.canConnect else {
return .failed(availability.guidanceText ?? String(localized: "This gateway is unavailable."))
}
let connectAttempt = self.beginConnectAttempt()
self.pendingConnectionStableID = gateway.stableID
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
await self.waitForPendingForgetCleanup(stableID: gateway.stableID)
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return .superseded }
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway", allowAutoReconnect: false)
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
return "Missing instanceId (node.instanceId). Try restarting the app."
return .failed("Missing instanceId (node.instanceId). Try restarting the app.")
}
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
let target = if let serviceEndpointResolver {
@@ -298,9 +310,9 @@ final class GatewayConnectionController {
} else {
await self.resolveServiceEndpoint(gateway.endpoint)
}
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return .superseded }
guard let target else {
return "Failed to resolve the discovered gateway endpoint."
return .failed("Failed to resolve the discovered gateway endpoint.")
}
let stableID = gateway.stableID
@@ -313,15 +325,17 @@ final class GatewayConnectionController {
if tlsRequired, stored == nil {
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
else { return "Failed to build TLS URL for trust verification." }
else { return .failed("Failed to build TLS URL for trust verification.") }
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
guard let probeResult = await self.probeTLSFingerprint(
host: target.host,
port: target.port,
url: url,
queueLabel: "gateway.tls.discovered")
else { return nil }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
else { return .superseded }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else {
return .superseded
}
switch probeResult {
case let .fingerprint(fp):
self.pendingTrustConnect = GatewayPendingTrustConnect(
@@ -340,14 +354,14 @@ final class GatewayConnectionController {
fingerprintSha256: fp,
isManual: false)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return nil
return .accepted
case let .failure(failure):
let message = self.tlsProbeFailureMessage(
failure,
host: target.host,
port: target.port)
self.appModel?.gatewayStatusText = message
return message
return .failed(message)
}
}
@@ -359,7 +373,7 @@ final class GatewayConnectionController {
host: target.host,
port: target.port,
useTLS: tlsParams?.required == true)
else { return "Failed to build discovered gateway URL." }
else { return .failed("Failed to build discovered gateway URL.") }
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
kind: .discovered,
@@ -369,10 +383,10 @@ final class GatewayConnectionController {
useTLS: true,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else {
return "Could not save the paired gateway."
return .failed("Could not save the paired gateway.")
}
self.didAutoConnect = true
self.startAutoConnect(
let didStart = self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
@@ -383,30 +397,31 @@ final class GatewayConnectionController {
forceReconnect: forceReconnect,
suppressionGeneration: connectAttempt.suppressionLease.generation,
expectedGeneration: connectAttempt.gatewayGeneration)
return nil
return didStart ? .accepted : .superseded
}
@discardableResult
func connectManual(
host: String,
port: Int,
useTLS: Bool,
contextPath: String? = nil,
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
forceReconnect: Bool = false) async -> ConnectionAttemptResult
{
let connectAttempt = self.beginConnectAttempt()
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
self.requestLocalNetworkAccess(reason: "connect_manual", allowAutoReconnect: false)
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = Self.resolvedManualPort(host: host, port: port)
else { return }
else { return .failed(String(localized: "This paired gateway has an invalid saved endpoint.")) }
let stableID = self.manualStableID(
host: host,
port: resolvedPort,
contextPath: contextPath)
self.pendingConnectionStableID = stableID
await self.waitForPendingForgetCleanup(stableID: stableID)
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return .superseded }
let instanceId = GatewaySettingsStore.currentInstanceID()
let storedCredentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
@@ -431,15 +446,17 @@ final class GatewayConnectionController {
port: resolvedPort,
useTLS: true,
contextPath: contextPath)
else { return }
else { return .failed(String(localized: "Failed to build the gateway URL.")) }
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
guard let probeResult = await self.probeTLSFingerprint(
host: host,
port: resolvedPort,
url: url,
queueLabel: "gateway.tls.manual")
else { return }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
else { return .superseded }
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else {
return .superseded
}
switch probeResult {
case let .fingerprint(fp):
self.pendingTrustConnect = GatewayPendingTrustConnect(
@@ -458,13 +475,14 @@ final class GatewayConnectionController {
fingerprintSha256: fp,
isManual: true)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return
return .accepted
case let .failure(failure):
self.appModel?.gatewayStatusText = self.tlsProbeFailureMessage(
let message = self.tlsProbeFailureMessage(
failure,
host: host,
port: resolvedPort)
return
self.appModel?.gatewayStatusText = message
return .failed(message)
}
}
@@ -476,7 +494,7 @@ final class GatewayConnectionController {
port: resolvedPort,
useTLS: tlsParams?.required == true,
contextPath: contextPath)
else { return }
else { return .failed(String(localized: "Failed to build the gateway URL.")) }
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
kind: .manual,
@@ -486,9 +504,11 @@ final class GatewayConnectionController {
useTLS: resolvedUseTLS && tlsParams != nil,
contextPath: contextPath,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else { return }
guard self.persistActiveGateway(registryEntry) else {
return .failed(String(localized: "Could not save the paired gateway."))
}
self.didAutoConnect = true
self.startAutoConnect(
let didStart = self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
@@ -499,72 +519,66 @@ final class GatewayConnectionController {
forceReconnect: forceReconnect,
suppressionGeneration: connectAttempt.suppressionLease.generation,
expectedGeneration: connectAttempt.gatewayGeneration)
return didStart ? .accepted : .superseded
}
func connectActiveGateway() async {
@discardableResult
func connectActiveGateway() async -> ConnectionAttemptResult {
self.requestLocalNetworkAccess(reason: "connect_active_gateway", allowAutoReconnect: false)
guard let active = GatewaySettingsStore.activeGatewayEntry() else { return }
switch active.kind {
case .manual:
guard let host = active.host, let port = active.port else { return }
await self.connectManual(
host: host,
port: port,
useTLS: active.useTLS,
contextPath: active.contextPath,
forceReconnect: true)
case .discovered:
if let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, active.stableID)
}) {
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
return
}
guard let fallback = self.mostRecentlyConnectedManualGateway() else { return }
guard let host = fallback.host, let port = fallback.port else { return }
await self.connectManual(
host: host,
port: port,
useTLS: fallback.useTLS,
contextPath: fallback.contextPath,
forceReconnect: true)
guard let active = GatewaySettingsStore.activeGatewayEntry() else {
return .failed(String(localized: "No paired gateway is available to reconnect."))
}
return await self.connectRegisteredGateway(active, allowManualFallback: true, activate: false)
}
/// Returns `nil` after initiating a switch, or a user-facing discovery failure.
func switchToGateway(stableID: String) async -> String? {
@discardableResult
func switchToGateway(stableID: String) async -> ConnectionAttemptResult {
guard let stableID = GatewayStableIdentifier.exact(stableID) else {
return "This paired gateway is no longer available."
return .failed(String(localized: "This paired gateway is no longer available."))
}
guard let entry = GatewaySettingsStore.loadGatewayRegistry().entries.first(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
}) else {
return "This paired gateway is no longer available."
return .failed(String(localized: "This paired gateway is no longer available."))
}
return await self.connectRegisteredGateway(entry, allowManualFallback: false, activate: true)
}
private func connectRegisteredGateway(
_ entry: GatewaySettingsStore.GatewayRegistryEntry,
allowManualFallback: Bool,
activate: Bool) async -> ConnectionAttemptResult
{
switch entry.kind {
case .manual:
guard let host = entry.host, let port = entry.port else {
return "This paired gateway has an invalid saved endpoint."
return .failed(String(localized: "This paired gateway has an invalid saved endpoint."))
}
// Switching intentionally persists the user's selection at initiation, matching connect flows.
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
return "Could not save the active gateway selection."
if activate, !GatewaySettingsStore.setActiveGateway(stableID: entry.stableID) {
return .failed(String(localized: "Could not save the active gateway selection."))
}
await self.connectManual(
return await self.connectManual(
host: host,
port: port,
useTLS: entry.useTLS,
contextPath: entry.contextPath,
forceReconnect: true)
return nil
case .discovered:
guard let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
GatewayStableIdentifier.matches($0.stableID, entry.stableID)
}) else {
return "\(entry.name) is not currently discoverable on this network."
if allowManualFallback, let fallback = self.mostRecentlyConnectedManualGateway() {
return await self.connectRegisteredGateway(
fallback,
allowManualFallback: false,
activate: true)
}
return .failed(String(
format: String(localized: "%@ is not currently discoverable on this network."),
entry.name))
}
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
return "Could not save the active gateway selection."
if activate, !GatewaySettingsStore.setActiveGateway(stableID: entry.stableID) {
return .failed(String(localized: "Could not save the active gateway selection."))
}
return await self.connectDiscoveredGateway(gateway, forceReconnect: true)
}
@@ -1530,7 +1530,9 @@ extension OnboardingWizardView {
switch GatewaySettingsStore.activeGatewayEntry()?.kind {
case .discovered:
await self.gatewayController.connectActiveGateway()
if case let .failed(message) = await self.gatewayController.connectActiveGateway() {
self.setConnectionFailure(message)
}
case .manual, .none:
// connectActiveGateway() replays the persisted endpoint and credentials,
// so token/host/port edits made on this screen would be ignored and
+24 -3
View File
@@ -49,6 +49,7 @@ struct RootTabs: View {
@State private var presentedSheet: PresentedSheet?
@State private var showGatewayProblemDetails: Bool = false
@State private var gatewayToastDragOffset: CGFloat = 0
@State private var gatewayRetryFailure: String?
// Swipe-up hides the toast only until the next problem report.
@State private var isGatewayToastSwipeDismissed: Bool = false
@State private var showOnboarding: Bool = false
@@ -527,17 +528,32 @@ struct RootTabs: View {
// Stable container so the toast's move/opacity transition animates
// when the gateway problem appears or clears outside withAnimation.
ZStack(alignment: .top) {
if let gatewayProblem = self.activeGatewayProblemToast {
if let gatewayRetryFailure {
OpenClawNoticeBanner(
icon: "wifi.exclamationmark",
title: "Gateway reconnect failed",
message: .verbatim(gatewayRetryFailure),
ownerLabel: "Needs attention",
tint: OpenClawBrand.warn,
secondaryActionTitle: "Dismiss",
onSecondaryAction: { self.gatewayRetryFailure = nil })
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
} else if let gatewayProblem = self.activeGatewayProblemToast {
self.gatewayProblemToast(gatewayProblem)
}
}
.animation(self.gatewayToastAnimation, value: self.gatewayRetryFailure)
.animation(self.gatewayToastAnimation, value: self.activeGatewayProblemToast)
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, self.activeGatewayProblemToast == nil ? 58 : 132)
.safeAreaPadding(
.top,
self.activeGatewayProblemToast == nil && self.gatewayRetryFailure == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -1096,7 +1112,12 @@ extension RootTabs {
} else if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
return
} else if problem.retryable {
Task { await self.gatewayController.connectActiveGateway() }
self.gatewayRetryFailure = nil
Task {
if case let .failed(message) = await self.gatewayController.connectActiveGateway() {
self.gatewayRetryFailure = message
}
}
} else {
self.selectSidebarDestination(.gateway)
}
@@ -2471,8 +2471,9 @@ private func waitUntil(
controller.clearPendingTrustPrompt()
probe.results.continuation.yield(.fingerprint("stale-fingerprint"))
probe.results.continuation.finish()
await connectTask.value
let result = await connectTask.value
#expect(result == .superseded)
#expect(controller.pendingTrustPrompt == nil)
}
@@ -2505,10 +2506,10 @@ private func waitUntil(
startDiscovery: false,
forceReconnectReset: { _ in })
let failure = await controller.switchToGateway(stableID: stableID)
let result = await controller.switchToGateway(stableID: stableID)
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(failure == nil)
#expect(result == .accepted)
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
#expect(appModel.activeGatewayConnectConfig?.url == URL(string: "ws://127.0.0.1:1"))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == stableID)
@@ -2531,13 +2532,36 @@ private func waitUntil(
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let failure = await controller.switchToGateway(stableID: discoveredID)
let result = await controller.switchToGateway(stableID: discoveredID)
#expect(failure == "Kitchen Gateway is not currently discoverable on this network.")
#expect(result == .failed("Kitchen Gateway is not currently discoverable on this network."))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == activeID)
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor
func `reconnect to active undiscoverable gateway returns failure without queuing connection`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let discoveredID = "bonjour|missing-active"
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: discoveredID,
kind: .discovered,
name: "Kitchen Gateway",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: nil), activate: true)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let result = await controller.connectActiveGateway()
#expect(result == .failed("Kitchen Gateway is not currently discoverable on this network."))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == discoveredID)
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor func `chat cache remains isolated when active gateway switches`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -129,9 +129,15 @@ enum DashboardGatewayCatalog {
enum DashboardPrimaryGatewayError: LocalizedError, Equatable {
case notPromotable
case passwordUnsupported
var errorDescription: String? {
"This Gateway cannot be set as primary."
switch self {
case .notPromotable:
"This Gateway cannot be set as primary."
case .passwordUnsupported:
"Password authentication is not supported by the Mac app's primary Gateway connection. Use a token instead."
}
}
}
@@ -162,6 +168,23 @@ struct DashboardPrimaryGatewayAdapter {
GatewayTLSStore.loadFingerprint(stableID: $0)
}
}
try self.apply(url: endpoint.config.url, token: token, tlsFingerprint: tlsFingerprint)
}
func apply(link: GatewayConnectDeepLink) throws {
if link.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty != nil {
throw DashboardPrimaryGatewayError.passwordUnsupported
}
guard let url = link.websocketURL else {
throw DashboardPrimaryGatewayError.notPromotable
}
try self.apply(
url: url,
token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
tlsFingerprint: nil)
}
private func apply(url: URL, token: String?, tlsFingerprint: String?) throws {
let previous = (
transport: self.state.remoteTransport,
url: self.state.remoteUrl,
@@ -169,10 +192,10 @@ struct DashboardPrimaryGatewayAdapter {
mode: self.state.connectionMode,
tlsFingerprint: self.currentTLSFingerprint())
self.state.remoteTransport = .direct
self.state.remoteUrl = endpoint.config.url.absoluteString
self.state.remoteUrl = url.absoluteString
// Promotion intentionally moves the saved token into gateway.remote.token,
// matching the existing Settings connection flow.
self.state.remoteToken = token
self.state.remoteToken = token ?? ""
self.state.connectionMode = .remote
guard self.persist(self.state, tlsFingerprint) else {
self.state.remoteTransport = previous.transport
@@ -184,3 +207,32 @@ struct DashboardPrimaryGatewayAdapter {
}
}
}
@MainActor
struct DashboardGatewaySetupCoordinator {
let adapter: DashboardPrimaryGatewayAdapter
let confirm: (_ title: String, _ message: String) -> Bool
let presentError: (_ title: String, _ message: String) -> Void
let openConnectionSettings: () -> Void
func handle(_ link: GatewayConnectDeepLink) {
if link.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty != nil {
self.presentError(
"Gateway Setup Not Supported",
DashboardPrimaryGatewayError.passwordUnsupported.localizedDescription)
return
}
let endpoint = "\(link.host):\(link.port)"
let transport = link.tls ? "TLS" : "an unencrypted private-network connection"
guard self.confirm(
"Change the primary Gateway?",
"Connect the Mac app directly to \(endpoint) using \(transport)?")
else { return }
do {
try self.adapter.apply(link: link)
self.openConnectionSettings()
} catch {
self.presentError("Could Not Change Primary Gateway", error.localizedDescription)
}
}
}
@@ -1024,6 +1024,27 @@ extension DashboardManager {
}
}
func handleGatewaySetup(_ link: GatewayConnectDeepLink) {
NSApp.activate(ignoringOtherApps: true)
let coordinator = DashboardGatewaySetupCoordinator(
adapter: DashboardPrimaryGatewayAdapter(state: AppStateStore.shared),
confirm: { title, message in
let alert = DashboardWindowController.makeGatewaySetupAlert(title: title, message: message)
return alert.runModal() == .alertFirstButtonReturn
},
presentError: { title, message in
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.runModal()
},
openConnectionSettings: {
AppNavigationActions.openSettings(tab: .connection)
})
coordinator.handle(link)
}
func openOrFocusDashboard(for target: DashboardGatewayTarget) {
Task { await self.performOpenOrFocusDashboard(for: target) }
}
@@ -148,4 +148,14 @@ extension DashboardWindowController {
alert.addButton(withTitle: "Cancel")
return alert
}
static func makeGatewaySetupAlert(title: String, message: String) -> NSAlert {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "Change Gateway")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert
}
}
@@ -8,6 +8,11 @@ enum DashboardNotificationsRequest: String {
case sendTest = "send-test"
}
struct DashboardNotificationsSnapshot: Encodable, Equatable {
let permission: String
let test: TestNotificationOutcome?
}
@MainActor
final class DashboardNotificationsMessageHandler: NSObject, WKScriptMessageHandler {
weak var owner: DashboardWindowController?
@@ -57,32 +62,50 @@ extension DashboardWindowController {
switch request {
case .status:
Task { await self.publishNotificationsStatus() }
Task { await self.refreshNotificationsPermission() }
case .requestPermission:
Task {
_ = await PermissionManager.ensure([.notifications], interactive: true)
await self.publishNotificationsStatus()
await self.refreshNotificationsPermission()
}
case .sendTest:
Task {
_ = await NotificationManager().send(
title: "OpenClaw",
body: "Test notification",
sound: nil)
guard self.notificationTestOutcome != .pending else { return }
self.notificationTestOutcome = .pending
await self.publishNotificationsStatus()
self.notificationTestOutcome = await TestNotificationAction.send()
await self.publishNotificationsStatus()
}
}
}
static func notificationsSnapshot(
permission: String,
testOutcome: TestNotificationOutcome?) -> DashboardNotificationsSnapshot
{
DashboardNotificationsSnapshot(permission: permission, test: testOutcome)
}
private func refreshNotificationsPermission() async {
guard PermissionManager.notificationCenterAvailable else { return }
let settings = await UNUserNotificationCenter.current().notificationSettings()
self.notificationPermission = Self.notificationsPermissionLabel(for: settings.authorizationStatus)
await self.publishNotificationsStatus()
}
private func publishNotificationsStatus() async {
// Honest absence beats a fabricated status when the process is unbundled.
guard PermissionManager.notificationCenterAvailable else { return }
let settings = await UNUserNotificationCenter.current().notificationSettings()
let permission = Self.notificationsPermissionLabel(for: settings.authorizationStatus)
let snapshot = Self.notificationsSnapshot(
permission: self.notificationPermission,
testOutcome: self.notificationTestOutcome)
guard let data = try? JSONEncoder().encode(snapshot),
let json = String(data: data, encoding: .utf8)
else { return }
// Keep a global snapshot so late subscribers can read status without a bridge round-trip.
_ = try? await self.webView.evaluateJavaScript(
"""
window.__OPENCLAW_NATIVE_NOTIFICATIONS__ = {permission:"\(permission)"};
window.__OPENCLAW_NATIVE_NOTIFICATIONS__ = \(json);
window.dispatchEvent(new CustomEvent('openclaw:native-notifications-status', \
{detail:window.__OPENCLAW_NATIVE_NOTIFICATIONS__}));
""")
@@ -88,6 +88,8 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private(set) var currentURL: URL
var auth: DashboardWindowAuth
var gatewaySnapshot: DashboardGatewaySnapshot?
var notificationPermission = "notDetermined"
var notificationTestOutcome: TestNotificationOutcome?
let tlsParams: GatewayTLSParams?
private let dashboardFrameAutosaveName: String
private let updater: UpdaterProviding?
@@ -60,8 +60,8 @@ enum DebugActions {
}
}
static func sendTestNotification() async {
_ = await NotificationManager().send(title: "OpenClaw", body: "Test notification", sound: nil)
static func sendTestNotification() async -> TestNotificationOutcome {
await TestNotificationAction.send()
}
static func sendDebugVoice() async -> Result<String, DebugActionError> {
@@ -17,6 +17,7 @@ struct DebugSettings: View {
@State private var debugSendInFlight = false
@State private var debugSendStatus: String?
@State private var debugSendError: String?
@State private var testNotificationOutcome: TestNotificationOutcome?
@State private var portCheckInFlight = false
@State private var portReports: [DebugActions.PortReport] = []
@State private var portKillStatus: String?
@@ -460,9 +461,26 @@ struct DebugSettings: View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Button("Send Test Notification") {
Task { await DebugActions.sendTestNotification() }
Task { await self.sendTestNotification() }
}
.buttonStyle(.bordered)
.disabled(self.testNotificationOutcome == .pending)
if let testNotificationOutcome {
switch testNotificationOutcome {
case .pending:
ProgressView("Sending test notification…")
.controlSize(.small)
case .sent:
Text("Test notification queued.")
.font(.caption)
.foregroundStyle(.secondary)
case let .error(message):
Text(message)
.font(.caption)
.foregroundStyle(.red)
}
}
Button("Open Agent Events") {
DebugActions.openAgentEventsWindow()
@@ -715,6 +733,13 @@ struct DebugSettings: View {
}
}
@MainActor
private func sendTestNotification() async {
guard self.testNotificationOutcome != .pending else { return }
self.testNotificationOutcome = .pending
self.testNotificationOutcome = await DebugActions.sendTestNotification()
}
private func revealApp() {
let url = Bundle.main.bundleURL
NSWorkspace.shared.activateFileViewerSelecting([url])
+16 -6
View File
@@ -48,15 +48,22 @@ final class DeepLinkHandler {
static let shared = DeepLinkHandler()
private var lastPromptAt: Date = .distantPast
private let gatewaySetup: @MainActor (GatewayConnectDeepLink) -> Void
/// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
/// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
/// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
init(gatewaySetup: @escaping @MainActor (GatewayConnectDeepLink) -> Void = { link in
DashboardManager.shared.handleGatewaySetup(link)
}) {
self.gatewaySetup = gatewaySetup
}
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
deepLinkLogger.debug("ignored deep link \(Self.invalidRouteMetadata(url), privacy: .public)")
return
}
switch route {
@@ -69,14 +76,17 @@ final class DeepLinkHandler {
return
}
await self.handleAgent(link: link, originalURL: url)
case .gateway:
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
return
}
case let .gateway(link):
self.gatewaySetup(link)
}
}
static func invalidRouteMetadata(_ url: URL) -> String {
let scheme = url.scheme?.lowercased() ?? "missing"
let route = url.host?.lowercased() ?? "missing"
return "scheme=\(scheme) route=\(route)"
}
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
@@ -24,6 +24,7 @@ struct MenuContent: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var browserControlEnabled = true
@State private var testNotificationPending = false
@AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey, store: AppDefaults.standard)
private var appLogLevelRaw: String = Logger.Level.info.rawValue
@@ -325,10 +326,11 @@ struct MenuContent: View {
Label("Send Debug Voice Text", systemImage: "waveform.circle")
}
Button {
Task { await DebugActions.sendTestNotification() }
Task { await self.sendTestNotification() }
} label: {
Label("Send Test Notification", systemImage: "bell")
}
.disabled(self.testNotificationPending)
Divider()
if self.state.connectionMode == .local {
Button {
@@ -594,6 +596,27 @@ struct MenuContent: View {
alert.runModal()
}
@MainActor
private func sendTestNotification() async {
guard !self.testNotificationPending else { return }
self.testNotificationPending = true
let outcome = await DebugActions.sendTestNotification()
self.testNotificationPending = false
let alert = NSAlert()
alert.messageText = "Test Notification"
switch outcome {
case .pending:
return
case .sent:
alert.informativeText = "The notification request was queued."
alert.alertStyle = .informational
case let .error(message):
alert.informativeText = message
alert.alertStyle = .warning
}
alert.runModal()
}
@MainActor
private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else {
@@ -68,3 +68,46 @@ struct NotificationManager {
}
}
}
enum TestNotificationOutcome: Encodable, Equatable {
case pending
case sent
case error(String)
private enum State: String, Encodable {
case pending
case sent
case error
}
private enum CodingKeys: String, CodingKey {
case state
case message
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .pending:
try container.encode(State.pending, forKey: .state)
case .sent:
try container.encode(State.sent, forKey: .state)
case let .error(message):
try container.encode(State.error, forKey: .state)
try container.encode(message, forKey: .message)
}
}
}
@MainActor
enum TestNotificationAction {
static func send() async -> TestNotificationOutcome {
let sent = await NotificationManager().send(
title: "OpenClaw",
body: "Test notification",
sound: nil)
return sent
? .sent
: .error("Notification could not be sent. Check System Settings → Notifications and try again.")
}
}
@@ -586,6 +586,171 @@ struct DashboardPrimaryGatewayAdapterTests {
#expect(state.connectionMode == .local)
#expect(persistedFingerprints == [profileFingerprint, previousFingerprint])
}
@Test func `deep link applies direct endpoint and clears omitted token and pin`() throws {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "ws://127.0.0.1:18789"
state.remoteToken = "stale-token"
state.connectionMode = .local
var persistedFingerprints: [String?] = []
let adapter = DashboardPrimaryGatewayAdapter(
state: state,
currentTLSFingerprint: { String(repeating: "b", count: 64) },
persist: { _, fingerprint in
persistedFingerprints.append(fingerprint)
return true
})
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 8443,
tls: true,
bootstrapToken: nil,
token: nil,
password: nil)
try adapter.apply(link: link)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "wss://gateway.example:8443")
#expect(state.remoteToken.isEmpty)
#expect(state.connectionMode == .remote)
#expect(persistedFingerprints == [nil])
}
@Test func `deep link password is rejected without mutation`() throws {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "wss://previous.example:443"
state.remoteToken = "previous-token"
state.connectionMode = .local
let adapter = DashboardPrimaryGatewayAdapter(state: state)
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 443,
tls: true,
bootstrapToken: nil,
token: "fixture-token",
password: "fixture-password")
#expect(throws: DashboardPrimaryGatewayError.passwordUnsupported) {
try adapter.apply(link: link)
}
#expect(state.remoteUrl == "wss://previous.example:443")
#expect(state.remoteToken == "previous-token")
}
}
@MainActor
struct DashboardGatewaySetupCoordinatorTests {
@Test func `cancel prompts once and preserves primary state without credential disclosure`() {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteUrl = "wss://previous.example:443"
state.remoteToken = "previous-token"
state.connectionMode = .local
let token = "fixture-token"
let link = GatewayConnectDeepLink(
host: "192.168.1.20",
port: 18789,
tls: false,
bootstrapToken: nil,
token: token,
password: nil)
var prompts: [(String, String)] = []
var openedSettings = 0
var persistCount = 0
let coordinator = DashboardGatewaySetupCoordinator(
adapter: DashboardPrimaryGatewayAdapter(
state: state,
currentTLSFingerprint: { String(repeating: "a", count: 64) },
persist: { _, _ in
persistCount += 1
return true
}),
confirm: { title, message in
prompts.append((title, message))
return false
},
presentError: { _, _ in Issue.record("unexpected error") },
openConnectionSettings: { openedSettings += 1 })
coordinator.handle(link)
#expect(prompts.count == 1)
#expect(!prompts[0].0.contains(token))
#expect(!prompts[0].1.contains(token))
#expect(prompts[0].1.contains("unencrypted private-network connection"))
#expect(!prompts[0].1.localizedCaseInsensitiveContains("loopback"))
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "wss://previous.example:443")
#expect(state.remoteToken == "previous-token")
#expect(state.connectionMode == .local)
#expect(persistCount == 0)
#expect(openedSettings == 0)
}
@Test func `accept applies primary and opens connection settings`() {
let state = AppState(preview: true)
var persistedFingerprints: [String?] = []
var openedSettings = 0
let adapter = DashboardPrimaryGatewayAdapter(
state: state,
persist: { _, fingerprint in
persistedFingerprints.append(fingerprint)
return true
})
let coordinator = DashboardGatewaySetupCoordinator(
adapter: adapter,
confirm: { _, _ in true },
presentError: { _, _ in Issue.record("unexpected error") },
openConnectionSettings: { openedSettings += 1 })
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 443,
tls: true,
bootstrapToken: nil,
token: "fixture-token",
password: nil)
coordinator.handle(link)
#expect(state.remoteUrl == "wss://gateway.example:443")
#expect(state.remoteToken == "fixture-token")
#expect(persistedFingerprints == [nil])
#expect(openedSettings == 1)
}
@Test func `password route visibly rejects before prompting or mutation`() {
let state = AppState(preview: true)
state.remoteUrl = "wss://previous.example:443"
var promptCount = 0
var errors: [(String, String)] = []
let coordinator = DashboardGatewaySetupCoordinator(
adapter: DashboardPrimaryGatewayAdapter(state: state),
confirm: { _, _ in
promptCount += 1
return true
},
presentError: { errors.append(($0, $1)) },
openConnectionSettings: { Issue.record("unexpected settings open") })
let password = "fixture-password"
let link = GatewayConnectDeepLink(
host: "gateway.example",
port: 443,
tls: true,
bootstrapToken: nil,
token: nil,
password: password)
coordinator.handle(link)
#expect(promptCount == 0)
#expect(errors.count == 1)
#expect(!errors[0].0.contains(password))
#expect(!errors[0].1.contains(password))
#expect(state.remoteUrl == "wss://previous.example:443")
}
}
private enum DashboardGatewayTestTLS {
@@ -1,3 +1,4 @@
import Foundation
import Testing
import UserNotifications
@testable import OpenClaw
@@ -29,4 +30,41 @@ struct DashboardNotificationsBridgeTests {
#expect(DashboardWindowController.notificationsPermissionLabel(
for: .notDetermined) == "notDetermined")
}
@Test func `permission and test send outcome remain independent bridge facts`() {
let failed = DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .error("Open System Settings and try again."))
let refreshed = DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .error("Open System Settings and try again."))
#expect(failed.permission == "granted")
#expect(failed.test == .error("Open System Settings and try again."))
#expect(refreshed == failed)
}
@Test func `bridge exposes pending and queued test send states`() {
#expect(DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .pending).test == .pending)
#expect(DashboardWindowController.notificationsSnapshot(
permission: "granted",
testOutcome: .sent).test == .sent)
}
@Test func `bridge encodes closed wire states and error-only messages`() throws {
let pending = try self.testSnapshotJSON(.pending)
let error = try self.testSnapshotJSON(.error("Open System Settings and try again."))
#expect(pending["state"] as? String == "pending")
#expect(pending["message"] == nil)
#expect(error["state"] as? String == "error")
#expect(error["message"] as? String == "Open System Settings and try again.")
}
private func testSnapshotJSON(_ snapshot: TestNotificationOutcome) throws -> [String: Any] {
let data = try JSONEncoder().encode(snapshot)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
}
@@ -1,3 +1,4 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@@ -75,3 +76,35 @@ struct DeepLinkAgentPolicyTests {
#expect(res.channel == .webchat)
}
}
@MainActor
struct DeepLinkGatewayRoutingTests {
@Test func `valid gateway route is forwarded once even while app is paused`() async throws {
let state = AppStateStore.shared
let wasPaused = state.isPaused
defer { state.isPaused = wasPaused }
state.isPaused = true
var routed: [GatewayConnectDeepLink] = []
let handler = DeepLinkHandler(gatewaySetup: { routed.append($0) })
let url = try #require(URL(
string: "openclaw://gateway?host=gateway.example&port=443&tls=1&token=route-token"))
await handler.handle(url: url)
#expect(routed.count == 1)
#expect(routed.first?.host == "gateway.example")
}
@Test func `invalid route metadata omits query credentials`() throws {
let token = "fixture-route-token"
let password = "fixture-route-password"
let url = try #require(URL(
string: "openclaw://gateway?host=attacker.example&tls=0&token=\(token)&password=\(password)"))
let metadata = DeepLinkHandler.invalidRouteMetadata(url)
#expect(!metadata.contains(token))
#expect(!metadata.contains(password))
#expect(!metadata.contains("?"))
}
}
@@ -1257,21 +1257,25 @@ public struct ProgressCardPutParams: Codable, Sendable {
public let sessionkey: String
public let markdown: String?
public let plan: [ProgressCardStep]?
public let expectedrevision: Int?
public init(
sessionkey: String,
markdown: String? = nil,
plan: [ProgressCardStep]? = nil)
plan: [ProgressCardStep]? = nil,
expectedrevision: Int? = nil)
{
self.sessionkey = sessionkey
self.markdown = markdown
self.plan = plan
self.expectedrevision = expectedrevision
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case markdown
case plan
case expectedrevision = "expectedRevision"
}
}
+20
View File
@@ -1762,5 +1762,25 @@
{
"source": "Portals",
"target": "门户"
},
{
"source": "Channels",
"target": "渠道"
},
{
"source": "Docker VM Runtime",
"target": "Docker VM 运行时"
},
{
"source": "Updating",
"target": "更新"
},
{
"source": "Onboarding (CLI)",
"target": "引导设置(CLI"
},
{
"source": "CLI Automation",
"target": "CLI 自动化"
}
]
+4 -3
View File
@@ -466,9 +466,10 @@ Use `Package Acceptance` when the question is "does this installable OpenClaw pa
1. `resolve_package` checks out `workflow_ref`, resolves one package candidate, writes `.artifacts/docker-e2e-package/openclaw-current.tgz`, writes `.artifacts/docker-e2e-package/package-candidate.json`, uploads both as the `package-under-test` artifact, and prints the source, workflow ref, package ref, version, SHA-256, and profile in the GitHub step summary.
2. `package_integrity` downloads the `package-under-test` artifact and enforces the public package tarball contract with `scripts/check-openclaw-package-tarball.mjs`.
3. `docker_acceptance` calls `openclaw-live-and-e2e-checks-reusable.yml` with the resolved package source SHA (falling back to `workflow_ref`) and `package_artifact_name=package-under-test`. The reusable workflow downloads that artifact, validates the tarball inventory, prepares package-digest Docker images when needed, and runs the selected Docker lanes against that package instead of packing the workflow checkout. When a profile selects multiple targeted `docker_lanes`, the reusable workflow prepares the package and shared images once, then fans those lanes out as parallel targeted Docker jobs with unique artifacts.
4. `package_telegram` optionally calls `NPM Telegram Beta E2E`. It runs when `telegram_mode` is not `none` and installs the same `package-under-test` artifact when Package Acceptance resolved one; standalone Telegram dispatch can still install a published npm spec.
5. `summary` fails the workflow if package resolution, integrity, Docker acceptance, or the optional Telegram lane failed. The `advisory` input downgrades acceptance failures to warnings for advisory callers.
3. `npm_12_install_sh` installs that exact artifact through the public Linux installer under npm 12 in an isolated home/prefix, then verifies the CLI version and lifecycle-completion guard.
4. `docker_acceptance` calls `openclaw-live-and-e2e-checks-reusable.yml` with the resolved package source SHA (falling back to `workflow_ref`) and `package_artifact_name=package-under-test`. The reusable workflow downloads that artifact, validates the tarball inventory, prepares package-digest Docker images when needed, and runs the selected Docker lanes against that package instead of packing the workflow checkout. When a profile selects multiple targeted `docker_lanes`, the reusable workflow prepares the package and shared images once, then fans those lanes out as parallel targeted Docker jobs with unique artifacts.
5. `package_telegram` optionally calls `NPM Telegram Beta E2E`. It runs when `telegram_mode` is not `none` and installs the same `package-under-test` artifact when Package Acceptance resolved one; standalone Telegram dispatch can still install a published npm spec.
6. `summary` fails the workflow if package resolution, integrity, npm 12 installer acceptance, Docker acceptance, or the optional Telegram lane failed. The `advisory` input downgrades acceptance failures to warnings for advisory callers.
### Candidate sources
+2
View File
@@ -40,6 +40,8 @@ openclaw uninstall --dry-run
## Notes
Uninstall reports each requested scope and exits nonzero if any requested cleanup fails or is blocked. A failed gateway service inspection, stop, or uninstall blocks state and workspace mutation, but independent macOS app cleanup is still attempted. After service teardown is safe, other permitted scopes continue so failures can be reported together. On non-macOS systems, `--app` reports that the scope is not applicable.
- Run `openclaw backup create` first for a restorable snapshot before removing
state or workspaces.
- Before removing state, `--state` requires exclusive state ownership. If an
+1 -1
View File
@@ -21,7 +21,7 @@ Experimental features are preview surfaces behind explicit flags. They need more
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.entries.*.experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.143.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) |
| Code Mode | `tools.codeMode.enabled` | You want compact code-orchestrated access to a hidden OpenClaw tool catalog | [Code Mode](/tools/code-mode) |
| Cloud workers | `cloudWorkers.desktop` | You want to watch or control desktop-capable cloud worker environments from the Control UI | [Cloud Worker Desktop](/gateway/cloud-workers#desktop-interactive) |
| Cloud workers | `cloudWorkers.desktop` | You want to watch or control desktop-capable cloud worker environments from the Control UI | [Cloud Worker Desktop](/gateway/cloud-workers#desktop) |
| Swarm | `tools.swarm.enabled` | You want Code Mode scripts to orchestrate bounded groups of sub-agents in parallel | [Swarm](/tools/swarm) |
## Control UI Labs
+4 -1
View File
@@ -88,6 +88,10 @@
"source": "/reference/openclaw-sdk-api-design",
"destination": "/gateway/external-apps"
},
{
"source": "/reference/wizard",
"destination": "/start/wizard-cli-reference"
},
{
"source": "/reference/maturity-scorecard",
"destination": "/maturity/scorecard"
@@ -1979,7 +1983,6 @@
"group": "Technical reference",
"pages": [
"agent-runtime-architecture",
"reference/wizard",
"reference/token-use",
"reference/secretref-credential-surface",
"reference/openclaw-ai",
+2 -2
View File
@@ -13,7 +13,7 @@ Access authenticate every request before it reaches OpenClaw. The Gateway keeps
needed; `cloudflared` dials out from the host.
This is one supported remote-access topology alongside [Tailscale](/gateway/tailscale)
and an [SSH tunnel](/gateway/remote#ssh-tunnel-cli--tools). Choose it when you want a
and an [SSH tunnel](/gateway/remote). Choose it when you want a
stable public HTTPS URL and identity-provider SSO in front of the Control UI.
## Before you begin
@@ -97,7 +97,7 @@ Access protects every route on the hostname, including the ones nodes use. Pick
- **Exempt the self-authenticating routes.** Allow `/j/*` and `/__openclaw__/worker`
without Access identity, and keep WebSocket upgrade enabled on the worker route. Both
enforce their own short-lived credentials, so they do not depend on Access. See
[Nodes](/nodes#edge-routing).
[Nodes](/nodes#gateway-deployments-that-cannot-host-nodes).
- **Use an Access service token.** Add a Service Auth policy and give the node
`gateway.cloudflareAccess.clientId` / `clientSecret`. See [Node CLI](/cli/node).
+111 -41
View File
@@ -1,12 +1,80 @@
---
summary: "Shared Docker VM runtime steps for long-lived OpenClaw Gateway hosts"
doc-schema-version: 1
read_when:
- You are deploying OpenClaw on a cloud VM with Docker
- You need the shared binary bake, persistence, and update flow
- You need the shared setup, binary bake, persistence, and update flow
title: "Docker VM runtime"
---
Shared runtime steps for VM-based Docker installs such as GCP, Hetzner, and similar VPS providers.
Use this runtime flow after provisioning a VM and installing Docker. Provider
guides such as [GCP](/install/gcp) and [Hetzner](/install/hetzner) own VM
creation, firewall rules, SSH access, and the tunnel back to your laptop. This
page owns the Docker setup shared by those hosts.
## Before you begin
You need:
- A Debian or Ubuntu VM with Docker Engine and Docker Compose v2
- At least 2 GB RAM for a source image build; 4 GB is more reliable
- The OpenClaw source checkout on the VM
- Provider and model credentials for onboarding
- An SSH-only or otherwise restricted provider firewall; do not expose the
Gateway port directly to the public Internet
From the VM:
```bash
git clone https://github.com/openclaw/openclaw.git
cd openclaw
docker --version
docker compose version
```
## Prepare persistent host state
The maintained setup script defaults state to the current VM user's home:
```bash
export OPENCLAW_CONFIG_DIR="$HOME/.openclaw"
export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace"
export OPENCLAW_AUTH_PROFILE_SECRET_DIR="$HOME/.openclaw-auth-profile-secrets"
```
Override those paths before setup if your VM uses a dedicated data disk. Keep
all three directories in backups. The auth-profile secret directory contains
the local encryption key for OAuth-backed auth profile token material, so it
must persist but remain separate from `OPENCLAW_CONFIG_DIR`.
## Run the maintained Docker setup
```bash
./scripts/docker/setup.sh
```
The script creates the host directories, builds `openclaw:local`, runs
onboarding, generates a Gateway token, synchronizes `.env`, and starts the
Gateway through the repository's `docker-compose.yml`. The Compose file pins
container-side state to `/home/node/.openclaw` while using the host paths above
as bind-mount sources.
To use an official prebuilt image instead of building from source:
```bash
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
./scripts/docker/setup.sh
```
For unattended setup, provider SecretRefs, extra mounts, sandbox setup, and all
supported environment variables, use the full [Docker guide](/install/docker).
<Warning>
`OPENCLAW_GATEWAY_BIND=lan` is the normal container setting: `loopback` would
limit the Gateway to the container's own network namespace. Keep the published
host port private with the cloud firewall, then reach it through the SSH tunnel
from the provider guide.
</Warning>
## Bake required binaries into the image
@@ -24,7 +92,7 @@ These are examples, not a complete list. Docker Compose builds the repo-root
`Dockerfile`, so extend that file rather than creating a standalone example or
replacing its contents. The repository Dockerfile has required
`workspace-deps`, build, runtime-assets, and final runtime stages. Its manifest
extraction covers the `packages/*` and selected `extensions/*` workspaces before
extraction covers the `packages/*` and selected plugin workspaces before
`pnpm install --frozen-lockfile`.
For Debian packages, prefer the existing build argument:
@@ -37,7 +105,6 @@ For downloaded release binaries such as `gog`, `goplaces`, or `wacli`, add the
download and install commands to the repo-root `Dockerfile` final runtime stage,
after its package-install blocks and before `USER node`. Preserve the existing
non-root uid 1000 setup, `tini` entrypoint, health check, and `openclaw` symlink.
Then rebuild and restart the containers.
<Note>
The repository Dockerfile digest-pins its Node and Bun base images. Keep those
@@ -46,16 +113,16 @@ references. For ARM-based VMs, choose `arm64` release assets for extra binaries;
for reproducible builds, use versioned asset URLs and verify their checksums.
</Note>
## Build and launch
Rebuild the customized image without repeating onboarding:
```bash
docker compose build
docker compose up -d openclaw-gateway
OPENCLAW_SKIP_ONBOARDING=1 ./scripts/docker/setup.sh
```
If the build fails with `Killed` or exit code 137 during `pnpm install --frozen-lockfile`, the VM is out of memory. Use a larger machine class before retrying.
If the build fails with `Killed` or exit code 137 during dependency installation
or bundling, the VM is out of memory. Resize it before retrying.
Verify binaries:
Verify baked binaries:
```bash
docker compose exec openclaw-gateway which gog
@@ -63,53 +130,56 @@ docker compose exec openclaw-gateway which goplaces
docker compose exec openclaw-gateway which wacli
```
Expected output:
```text
/usr/local/bin/gog
/usr/local/bin/goplaces
/usr/local/bin/wacli
```
Verify the gateway is up:
## Verify and administer the Gateway
```bash
docker compose logs -f openclaw-gateway
docker compose ps
docker compose logs --tail=100 openclaw-gateway
curl -fsS http://127.0.0.1:18789/healthz
docker compose run --rm openclaw-cli dashboard --no-open
```
`/healthz` returning a 200 response confirms the gateway process is listening and healthy; the built-in image `HEALTHCHECK` polls the same endpoint.
`/healthz` returning a 200 response confirms that the Gateway process is
listening. The image `HEALTHCHECK` polls the same endpoint. If the Control UI
requires device approval:
```bash
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
```
## What persists where
OpenClaw runs in Docker, but Docker is not the source of truth. All long-lived state must survive restarts, rebuilds, and reboots.
OpenClaw runs in Docker, but the container filesystem is not the source of
truth. Long-lived state must survive restarts, rebuilds, and reboots.
| Component | Location | Persistence mechanism | Notes |
| ---------------------- | ------------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json` |
| Channel/provider creds | `/home/node/.openclaw/credentials/` | Host volume mount | Channel and provider credential material |
| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents/<agentId>/agent/auth-profiles.json` (OAuth, API keys) |
| Legacy OAuth key file | `/home/node/.config/openclaw/` | Host volume mount | Read-only compat for pre-migration OAuth sidecars; `openclaw doctor --fix` migrates these into `auth-profiles.json` |
| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| Plugin packages | `/home/node/.openclaw/npm`, `/home/node/.openclaw/git` | Host volume mount | Downloadable plugin package roots |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
| Component | Container location | Persistence mechanism | Notes |
| -------------------- | ----------------------------------- | --------------------------- | -------------------------------------------------------------------------- |
| Gateway state/config | `/home/node/.openclaw/` | `OPENCLAW_CONFIG_DIR` mount | Includes `openclaw.json`, shared state, and installed plugin package roots |
| Agent workspace | `/home/node/.openclaw/workspace/` | Workspace mount | Code and agent artifacts |
| Channel credentials | `/home/node/.openclaw/credentials/` | Config mount | Channel credential material |
| Model auth profiles | `/home/node/.openclaw/agents/` | Config mount | `agents/<agentId>/agent/auth-profiles.json` |
| Auth-profile key | `/home/node/.config/openclaw/` | Secret-directory mount | Encryption key material; keep separate from the config mount |
| Skill state | `/home/node/.openclaw/skills/` | Config mount | Skill-level state |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node and OS packages | Container filesystem | Docker image | Rebuilt with the image; do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to replace after mounted state is verified |
## Updates
## Update OpenClaw
To update OpenClaw on the VM:
For a source-built image:
```bash
git pull
docker compose build
docker compose up -d
git pull --ff-only
OPENCLAW_SKIP_ONBOARDING=1 ./scripts/docker/setup.sh
docker compose run --rm openclaw-cli doctor --json
```
For a pinned or prebuilt image, update `OPENCLAW_IMAGE` to the intended tag or
digest before rerunning the setup script. Routine image upgrades run startup-safe
migrations against the mounted state; see [Upgrading container images](/install/docker#upgrading-container-images)
for recovery when a migration cannot complete automatically.
## Related
- [Docker](/install/docker)
+109 -259
View File
@@ -1,72 +1,67 @@
---
summary: "Run OpenClaw Gateway 24/7 on a GCP Compute Engine VM (Docker) with durable state"
summary: "Run OpenClaw Gateway 24/7 on a GCP Compute Engine VM with Docker"
doc-schema-version: 1
read_when:
- You want OpenClaw running 24/7 on GCP
- You want a production-grade, always-on Gateway on your own VM
- You want full control over persistence, binaries, and restart behavior
- You want a persistent Gateway on a Compute Engine VM
- You need GCP provisioning, firewall, or SSH tunnel guidance
title: "GCP"
---
Run a persistent OpenClaw Gateway on a GCP Compute Engine VM using Docker, with durable state, baked-in binaries, and safe restart behavior.
Run a persistent OpenClaw Gateway on a Debian Compute Engine VM. This page
covers GCP provisioning, network access, and machine operations; the shared
[Docker VM runtime](/install/docker-vm-runtime) page owns container setup,
persistence, custom binaries, verification, and updates.
Pricing varies by machine type and region; pick the smallest VM that fits your workload and scale up if you hit OOMs.
The Gateway can be accessed via SSH port forwarding from your laptop, or via direct port exposure if you manage firewalling and tokens yourself.
This guide uses Debian on GCP Compute Engine. Ubuntu also works; map packages accordingly. For the generic Docker flow, see [Docker](/install/docker).
Pricing varies by machine type and region. Start with at least 2 GB RAM for a
source build and resize if the build is OOM-killed.
## What you need
- GCP account (`e2-micro` is free-tier eligible)
- `gcloud` CLI, or the [Cloud Console](https://console.cloud.google.com)
- A GCP project with billing enabled
- The `gcloud` CLI or the [Cloud Console](https://console.cloud.google.com)
- SSH access from your laptop
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials (WhatsApp QR, Telegram bot token, Gmail OAuth)
- ~20-30 minutes
- Model and optional channel credentials
- About 20 minutes
## Quick path
1. Create a GCP project, enable billing and the Compute Engine API
2. Create a Compute Engine VM (`e2-small`, Debian 12, 20GB)
3. SSH into the VM, install Docker
4. Clone the OpenClaw repository
5. Create persistent host directories
6. Configure `.env` and `docker-compose.yml`
7. Bake required binaries, build, and launch
## Provision the VM
<Steps>
<Step title="Install gcloud CLI (or use Console)">
Install from [cloud.google.com/sdk/docs/install](https://cloud.google.com/sdk/docs/install), then:
<Step title="Initialize gcloud">
Install the CLI from
[cloud.google.com/sdk/docs/install](https://cloud.google.com/sdk/docs/install),
then authenticate:
```bash
gcloud init
gcloud auth login
```
Or do every step below through the [Cloud Console](https://console.cloud.google.com) web UI instead.
You can perform the same steps in the Cloud Console.
</Step>
<Step title="Create a GCP project">
<Step title="Create the project">
```bash
gcloud projects create my-openclaw-project --name="OpenClaw Gateway"
gcloud config set project my-openclaw-project
gcloud services enable compute.googleapis.com
```
Enable billing at [console.cloud.google.com/billing](https://console.cloud.google.com/billing) (required for Compute Engine).
Console equivalent: IAM & Admin > Create Project, enable billing, then APIs & Services > Enable APIs > "Compute Engine API" > Enable.
Enable billing in the
[Billing console](https://console.cloud.google.com/billing). Compute Engine
will not start without it.
</Step>
<Step title="Create the VM">
| Type | Specs | Cost | Notes |
| --------- | ------------------------ | ------------------ | --------------------------------------------- |
| e2-medium | 2 vCPU, 4GB RAM | ~$25/mo | Most reliable for local Docker builds |
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Minimum recommended for a Docker build |
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | Often fails with Docker build OOM (exit 137) |
<Step title="Choose a machine">
| Type | Specs | Notes |
| --------- | ------------------------ | ------------------------------------------- |
| e2-medium | 2 vCPU, 4 GB RAM | Most reliable for local source image builds |
| e2-small | 2 vCPU, 2 GB RAM | Minimum recommended for a source build |
| e2-micro | 2 shared vCPU, 1 GB RAM | Often fails source builds with exit 137 |
Create a Debian 12 VM:
```bash
gcloud compute instances create openclaw-gateway \
@@ -79,265 +74,118 @@ This guide uses Debian on GCP Compute Engine. Ubuntu also works; map packages ac
</Step>
<Step title="SSH into the VM">
<Step title="Review firewall access">
Keep TCP 18789 closed to the public Internet. The SSH tunnel below needs
only SSH access to the VM:
```bash
gcloud compute firewall-rules list \
--format='table(name,network,direction,sourceRanges.list():label=SOURCE_RANGES,allowed[].map().firewall_rule().list():label=ALLOW)'
```
Restrict SSH source ranges to your administrative network when possible.
If you intentionally expose the Gateway through a reverse proxy or tailnet,
follow [Gateway security](/gateway/security) rather than adding a broad
`0.0.0.0/0` rule for port 18789.
</Step>
<Step title="Connect over SSH">
```bash
gcloud compute ssh openclaw-gateway --zone=us-central1-a
```
Console: click "SSH" next to the VM in the Compute Engine dashboard.
SSH key propagation can take 1-2 minutes after VM creation; wait and retry if connection is refused.
SSH key propagation can take a minute or two after VM creation. Wait and
retry if the first connection is refused.
</Step>
<Step title="Install Docker (on the VM)">
<Step title="Install Docker">
On the VM:
```bash
sudo apt-get update
sudo apt-get install -y git curl ca-certificates
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
```
Log out and back in for the group change to take effect, then SSH back in:
```bash
sudo usermod -aG docker "$USER"
exit
```
Reconnect so the group change takes effect, then verify the installation:
```bash
gcloud compute ssh openclaw-gateway --zone=us-central1-a
```
Verify:
```bash
docker --version
docker compose version
```
</Step>
<Step title="Clone the OpenClaw repository">
```bash
git clone https://github.com/openclaw/openclaw.git
cd openclaw
```
This guide builds a custom image so any binaries you bake in survive restarts.
</Step>
<Step title="Create persistent host directories">
Docker containers are ephemeral; all long-lived state must live on the host.
```bash
mkdir -p ~/.openclaw
mkdir -p ~/.openclaw/workspace
```
</Step>
<Step title="Configure environment variables">
Create `.env` in the repository root:
```bash
OPENCLAW_IMAGE=openclaw:latest
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_BIND=lan
OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_CONFIG_DIR=/home/$USER/.openclaw
OPENCLAW_WORKSPACE_DIR=/home/$USER/.openclaw/workspace
GOG_KEYRING_PASSWORD=
XDG_CONFIG_HOME=/home/node/.openclaw
```
Set `OPENCLAW_GATEWAY_TOKEN` to manage the stable gateway token through
`.env`; otherwise configure `gateway.auth.token` before relying on clients
across restarts. If neither is set, OpenClaw uses a runtime-only token for
that startup. Generate a keyring password for `GOG_KEYRING_PASSWORD`:
```bash
openssl rand -hex 32
```
**Do not commit this file.** It holds container/runtime env such as
`OPENCLAW_GATEWAY_TOKEN`. Stored provider OAuth/API-key auth lives in the
mounted `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
</Step>
<Step title="Docker Compose configuration">
Create or update `docker-compose.yml`:
```yaml
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE}
build: .
restart: unless-stopped
env_file:
- .env
environment:
HOME: /home/node
NODE_ENV: production
TERM: xterm-256color
OPENCLAW_STATE_DIR: /home/node/.openclaw
OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
OPENCLAW_CONFIG_DIR: /home/node/.openclaw
OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace
OPENCLAW_GATEWAY_BIND: ${OPENCLAW_GATEWAY_BIND}
OPENCLAW_GATEWAY_PORT: ${OPENCLAW_GATEWAY_PORT}
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
GOG_KEYRING_PASSWORD: ${GOG_KEYRING_PASSWORD}
XDG_CONFIG_HOME: ${XDG_CONFIG_HOME}
PATH: /home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
ports:
# Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT}:18789"
command:
[
"node",
"dist/index.js",
"gateway",
"--bind",
"${OPENCLAW_GATEWAY_BIND}",
"--port",
"${OPENCLAW_GATEWAY_PORT}",
"--allow-unconfigured",
]
openclaw-cli:
image: ${OPENCLAW_IMAGE}
network_mode: "service:openclaw-gateway"
env_file:
- .env
environment:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_STATE_DIR: /home/node/.openclaw
OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
OPENCLAW_CONFIG_DIR: /home/node/.openclaw
OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
GOG_KEYRING_PASSWORD: ${GOG_KEYRING_PASSWORD}
XDG_CONFIG_HOME: ${XDG_CONFIG_HOME}
PATH: /home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
stdin_open: true
tty: true
init: true
entrypoint: ["node", "dist/index.js"]
depends_on:
- openclaw-gateway
```
`--allow-unconfigured` is only for bootstrap convenience, not a substitute for real gateway configuration. Still set auth (`gateway.auth.token` or password) and a safe bind mode for your deployment.
The `.env` paths are host-side bind-mount sources. Both services override
those variables with `/home/node/...` paths inside the container so the
non-root `node` user never tries to write to a host-only path.
</Step>
<Step title="Shared Docker VM runtime steps">
Follow the shared runtime guide for the common Docker host flow:
- [Bake required binaries into the image](/install/docker-vm-runtime#bake-required-binaries-into-the-image)
- [Build and launch](/install/docker-vm-runtime#build-and-launch)
- [What persists where](/install/docker-vm-runtime#what-persists-where)
- [Updates](/install/docker-vm-runtime#updates)
</Step>
<Step title="GCP-specific launch notes">
If the build fails with `Killed` or `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` at minimum, or `e2-medium` for more reliable first builds.
When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing:
```bash
docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins '["http://127.0.0.1:18789"]' --strict-json
```
Replace `18789` with your configured port if you changed it.
</Step>
<Step title="Access from your laptop">
Create an SSH tunnel to forward the Gateway port:
```bash
gcloud compute ssh openclaw-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789
```
Open `http://127.0.0.1:18789/` in your browser.
Reprint a clean dashboard link:
```bash
docker compose run --rm openclaw-cli dashboard --no-open
```
If the UI prompts for shared-secret auth, paste the configured token or
password into Control UI settings (this Docker flow writes a token by
default; use your configured password instead if you switched to password
auth).
If Control UI shows `unauthorized` or `disconnected (1008): pairing required`, approve the browser device:
```bash
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
```
See [Docker VM Runtime](/install/docker-vm-runtime#what-persists-where) for the shared persistence map and [update flow](/install/docker-vm-runtime#updates).
</Step>
</Steps>
## Configure the Docker runtime
On the VM, follow [Docker VM runtime](/install/docker-vm-runtime) from
**Before you begin** through **Verify and administer the Gateway**. The
maintained setup script uses these GCP host paths by default:
```bash
export OPENCLAW_CONFIG_DIR="$HOME/.openclaw"
export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace"
export OPENCLAW_AUTH_PROFILE_SECRET_DIR="$HOME/.openclaw-auth-profile-secrets"
```
If a source build ends with `Killed`, `ResourceExhausted`, or exit code 137,
resize the VM before retrying.
## Access the Control UI
From your laptop, open an SSH tunnel and leave it running:
```bash
gcloud compute ssh openclaw-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789
```
Open `http://127.0.0.1:18789/`. Paste the Gateway token from the VM's `.env`
when prompted. To reprint the dashboard URL or approve a browser device, run on
the VM:
```bash
cd openclaw
docker compose run --rm openclaw-cli dashboard --no-open
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
```
## Troubleshooting
**SSH connection refused**
### SSH connection refused
SSH key propagation can take 1-2 minutes after VM creation. Wait and retry.
Wait one or two minutes for SSH key propagation, then retry. Check the VM is
running and that an ingress firewall rule allows TCP 22 from your current
network.
**OS Login issues**
Check your OS Login profile:
### OS Login issues
```bash
gcloud compute os-login describe-profile
```
Ensure your account has the required IAM permissions (Compute OS Login or Compute OS Admin Login).
Ensure your account has Compute OS Login or Compute OS Admin Login permission.
**Out of memory (OOM)**
If the Docker build fails with `Killed` and `exit code 137`, the VM was OOM-killed:
### Resize after an out-of-memory build
```bash
# Stop the VM first
gcloud compute instances stop openclaw-gateway --zone=us-central1-a
# Change machine type
gcloud compute instances set-machine-type openclaw-gateway \
--zone=us-central1-a \
--machine-type=e2-small
# Start the VM
--machine-type=e2-medium
gcloud compute instances start openclaw-gateway --zone=us-central1-a
```
## Service accounts (security best practice)
## Use a deployment service account
For personal use, your default user account works fine. For automation or CI/CD, create a dedicated service account with minimal permissions:
For personal setup, your user account is enough. Automation should use a
dedicated service account with the narrowest role that works:
```bash
gcloud iam service-accounts create openclaw-deploy \
@@ -348,16 +196,18 @@ gcloud projects add-iam-policy-binding my-openclaw-project \
--role="roles/compute.instanceAdmin.v1"
```
Avoid the Owner role for automation; use the narrowest role that works. See [Understanding roles](https://cloud.google.com/iam/docs/understanding-roles).
Avoid the Owner role. See
[Understanding roles](https://cloud.google.com/iam/docs/understanding-roles).
## Next steps
- Set up messaging channels: [Channels](/channels)
- Pair local devices as nodes: [Nodes](/nodes)
- Configure the Gateway: [Gateway configuration](/gateway/configuration)
- [Channels](/channels)
- [Nodes](/nodes)
- [Gateway configuration](/gateway/configuration)
- [Docker VM Runtime](/install/docker-vm-runtime#update-openclaw)
## Related
- [Install overview](/install)
- [Azure](/install/azure)
- [Docker VM Runtime](/install/docker-vm-runtime)
- [VPS hosting](/vps)
+107 -205
View File
@@ -1,253 +1,155 @@
---
summary: "Run OpenClaw Gateway 24/7 on a cheap Hetzner VPS (Docker) with durable state and baked-in binaries"
summary: "Run OpenClaw Gateway 24/7 on a Hetzner VPS with Docker"
doc-schema-version: 1
read_when:
- You want OpenClaw running 24/7 on a cloud VPS (not your laptop)
- You want a production-grade, always-on Gateway on your own VPS
- You want full control over persistence, binaries, and restart behavior
- You are running OpenClaw in Docker on Hetzner or a similar provider
- You want OpenClaw running 24/7 on a Hetzner VPS
- You need Hetzner provisioning, firewall, or SSH tunnel guidance
- You want a persistent Docker Gateway on a cloud VM
title: "Hetzner"
---
Run a persistent OpenClaw Gateway on a Hetzner VPS using Docker, with durable state, baked-in binaries, and safe restart behavior.
Run a persistent OpenClaw Gateway on a Debian or Ubuntu Hetzner VPS. This page
covers Hetzner provisioning, host security, and access; the shared
[Docker VM runtime](/install/docker-vm-runtime) page owns container setup,
persistence, custom binaries, verification, and updates.
Hetzner pricing changes; pick the smallest Debian/Ubuntu VPS that fits and scale up if you hit OOMs.
The Gateway can be accessed via SSH port forwarding from your laptop, or via direct port exposure if you manage firewalling and tokens yourself.
Security model reminder:
- Company-shared agents are fine when everyone is in the same trust boundary and the runtime is business-only.
- Keep strict separation: dedicated VPS/runtime + dedicated accounts; no personal Apple/Google/browser/password-manager profiles on that host.
- If users are adversarial to each other, split by gateway/host/OS user.
See [Security](/gateway/security) and [VPS hosting](/vps).
This guide assumes Ubuntu or Debian on Hetzner. On another Linux VPS, map packages accordingly. For the generic Docker flow, see [Docker](/install/docker).
Treat the VPS as stateful infrastructure. Keep personal browser, Apple, Google,
and password-manager profiles off a company-shared runtime. If users do not
share one trust boundary, split them across Gateways, hosts, or OS users. See
[Gateway security](/gateway/security) and [VPS hosting](/vps).
## What you need
- Hetzner VPS with root access
- A Hetzner VPS with root access
- SSH access from your laptop
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials (WhatsApp QR, Telegram bot token, Gmail OAuth)
- ~20 minutes
- A Hetzner Cloud Firewall or host firewall
- Model and optional channel credentials
- About 20 minutes
## Quick path
1. Provision Hetzner VPS
2. Install Docker
3. Clone the OpenClaw repository
4. Create persistent host directories
5. Configure `.env` and `docker-compose.yml`
6. Bake required binaries into the image
7. `docker compose up -d`
8. Verify persistence and Gateway access
## Provision and secure the VPS
<Steps>
<Step title="Provision the VPS">
Create an Ubuntu or Debian VPS in Hetzner, then connect as root:
<Step title="Create the server">
In Hetzner Cloud, create a Debian or Ubuntu server with at least 2 GB RAM
for a source image build. Add your SSH key during provisioning.
Connect as root:
```bash
ssh root@YOUR_VPS_IP
ssh root@<vps-ip>
```
Treat the VPS as stateful, not disposable infrastructure.
</Step>
<Step title="Install Docker (on the VPS)">
<Step title="Restrict inbound traffic">
Attach a Hetzner Cloud Firewall that allows TCP 22 from your administrative
network. Do not add a public inbound rule for TCP 18789; the tunnel below
reaches that port through SSH.
If you also use UFW on the host, allow SSH before enabling it:
```bash
apt-get update
apt-get install -y ufw
ufw allow OpenSSH
ufw enable
ufw status verbose
```
If you intentionally publish the Gateway through a reverse proxy or
tailnet, follow [Gateway security](/gateway/security) instead of opening the
container port directly to `0.0.0.0/0`.
</Step>
<Step title="Install Docker">
```bash
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://get.docker.com | sh
```
Verify:
```bash
docker --version
docker compose version
```
</Step>
<Step title="Clone the OpenClaw repository">
```bash
git clone https://github.com/openclaw/openclaw.git
cd openclaw
```
This guide builds a custom image so any binaries you bake in survive restarts.
</Step>
<Step title="Create persistent host directories">
Docker containers are ephemeral; all long-lived state must live on the host.
```bash
mkdir -p /root/.openclaw/workspace
# Set ownership to the container user (uid 1000):
chown -R 1000:1000 /root/.openclaw
```
</Step>
<Step title="Configure environment variables">
Create `.env` in the repository root:
```bash
OPENCLAW_IMAGE=openclaw:latest
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_BIND=lan
OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_CONFIG_DIR=/root/.openclaw
OPENCLAW_WORKSPACE_DIR=/root/.openclaw/workspace
GOG_KEYRING_PASSWORD=
XDG_CONFIG_HOME=/home/node/.openclaw
```
Set `OPENCLAW_GATEWAY_TOKEN` to manage the stable gateway token through
`.env`; otherwise configure `gateway.auth.token` before relying on clients
across restarts. If neither is set, OpenClaw uses a runtime-only token for
that startup. Generate a keyring password for `GOG_KEYRING_PASSWORD`:
```bash
openssl rand -hex 32
```
**Do not commit this file.** It holds container/runtime env such as
`OPENCLAW_GATEWAY_TOKEN`. Stored provider OAuth/API-key auth lives in the
mounted `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
</Step>
<Step title="Docker Compose configuration">
Create or update `docker-compose.yml`:
```yaml
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE}
build: .
restart: unless-stopped
env_file:
- .env
environment:
HOME: /home/node
NODE_ENV: production
TERM: xterm-256color
OPENCLAW_STATE_DIR: /home/node/.openclaw
OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
OPENCLAW_CONFIG_DIR: /home/node/.openclaw
OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace
OPENCLAW_GATEWAY_BIND: ${OPENCLAW_GATEWAY_BIND}
OPENCLAW_GATEWAY_PORT: ${OPENCLAW_GATEWAY_PORT}
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
GOG_KEYRING_PASSWORD: ${GOG_KEYRING_PASSWORD}
XDG_CONFIG_HOME: ${XDG_CONFIG_HOME}
PATH: /home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
ports:
# Recommended: keep the Gateway loopback-only on the VPS; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT}:18789"
command:
[
"node",
"dist/index.js",
"gateway",
"--bind",
"${OPENCLAW_GATEWAY_BIND}",
"--port",
"${OPENCLAW_GATEWAY_PORT}",
"--allow-unconfigured",
]
```
The `.env` paths are host-side bind-mount sources. The service overrides
them with `/home/node/...` paths inside the container so the non-root
`node` user never tries to write to a host-only path.
`--allow-unconfigured` is only for bootstrap convenience, not a substitute for real gateway configuration. Still set auth (`gateway.auth.token` or password) and a safe bind mode for your deployment.
</Step>
<Step title="Shared Docker VM runtime steps">
Follow the shared runtime guide for the common Docker host flow:
- [Bake required binaries into the image](/install/docker-vm-runtime#bake-required-binaries-into-the-image)
- [Build and launch](/install/docker-vm-runtime#build-and-launch)
- [What persists where](/install/docker-vm-runtime#what-persists-where)
- [Updates](/install/docker-vm-runtime#updates)
</Step>
<Step title="Hetzner-specific access">
After the shared build and launch steps, open the tunnel.
**Prerequisite:** ensure your VPS sshd config allows TCP forwarding. If you
hardened your SSH config, check `/etc/ssh/sshd_config` and set:
```text
AllowTcpForwarding local
```
`local` allows `ssh -L` local forwards from your laptop while blocking
remote forwards from the server. Setting it to `no` fails the tunnel with:
`channel 3: open failed: administratively prohibited: open failed`
After confirming TCP forwarding is enabled, restart the SSH service
(`systemctl restart ssh`) and run the tunnel from your laptop:
```bash
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_VPS_IP
```
Open `http://127.0.0.1:18789/` and paste the configured shared secret.
This guide uses the gateway token by default; use your configured password
instead if you switched to password auth.
</Step>
</Steps>
The shared persistence map lives in [Docker VM Runtime](/install/docker-vm-runtime#what-persists-where).
## Configure the Docker runtime
## Infrastructure as Code (Terraform)
On the VPS, follow [Docker VM runtime](/install/docker-vm-runtime) from
**Before you begin** through **Verify and administer the Gateway**. The
maintained setup script uses these root-owned host paths by default:
For teams preferring infrastructure-as-code workflows, a community-maintained Terraform setup provides:
```bash
export OPENCLAW_CONFIG_DIR="$HOME/.openclaw"
export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace"
export OPENCLAW_AUTH_PROFILE_SECRET_DIR="$HOME/.openclaw-auth-profile-secrets"
```
- Modular Terraform configuration with remote state management
- Automated provisioning via cloud-init
- Deployment scripts (bootstrap, deploy, backup/restore)
- Security hardening (firewall, UFW, SSH-only access)
- SSH tunnel configuration for gateway access
If a source build ends with `Killed` or exit code 137, resize the server before
retrying. See the shared guide for binary baking, the complete persistence map,
and the update command.
**Repositories:**
## Access the Control UI
- Infrastructure: [openclaw-terraform-hetzner](https://github.com/andreesg/openclaw-terraform-hetzner)
- Docker config: [openclaw-docker-config](https://github.com/andreesg/openclaw-docker-config)
First confirm the VPS SSH daemon allows local port forwarding. In
`/etc/ssh/sshd_config`, use:
This approach complements the Docker setup above with reproducible deployments, version-controlled infrastructure, and automated disaster recovery.
```text
AllowTcpForwarding local
```
`local` permits `ssh -L` from your laptop while blocking remote forwards from
the server. After changing it, validate and restart SSH:
```bash
sshd -t
systemctl restart ssh
```
From your laptop, open the tunnel and leave it running:
```bash
ssh -N -L 18789:127.0.0.1:18789 root@<vps-ip>
```
Open `http://127.0.0.1:18789/` and paste the Gateway token from the VPS `.env`.
To reprint the dashboard URL or approve a browser device, run on the VPS:
```bash
cd openclaw
docker compose run --rm openclaw-cli dashboard --no-open
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
```
If the tunnel fails with `administratively prohibited`, recheck
`AllowTcpForwarding` and the SSH service configuration. A cloud firewall only
needs to admit SSH; it does not need to admit port 18789.
## Infrastructure as code
For teams that prefer Terraform, community-maintained projects provide remote
state, cloud-init provisioning, deployment and backup scripts, firewall
hardening, and SSH tunnel setup:
- [openclaw-terraform-hetzner](https://github.com/andreesg/openclaw-terraform-hetzner)
- [openclaw-docker-config](https://github.com/andreesg/openclaw-docker-config)
<Note>
Community-maintained. For issues or contributions, see the repository links above.
These repositories are community-maintained. Report issues and contribute in
their respective repositories.
</Note>
## Next steps
- Set up messaging channels: [Channels](/channels)
- Configure the Gateway: [Gateway configuration](/gateway/configuration)
- Keep OpenClaw up to date: [Updating](/install/updating)
- [Channels](/channels)
- [Gateway configuration](/gateway/configuration)
- [Updating](/install/docker-vm-runtime#update-openclaw)
## Related
- [Install overview](/install)
- [Fly.io](/install/fly)
- [Docker VM Runtime](/install/docker-vm-runtime)
- [Docker](/install/docker)
- [VPS hosting](/vps)
+6 -2
View File
@@ -17,6 +17,10 @@ OpenClaw ships three installer scripts, served from `openclaw.ai`.
All three support Node **22.22.3+, 24.15+, or 25.9+**. On macOS and Linux, `install.sh` provisions Node 26 when needed, while the rootless `install-cli.sh` downloads Node 24.15.0 (Node 22.22.3 on ARMv7). On Windows, winget/Chocolatey/Scoop install the supported Node LTS line, and the portable fallback downloads Node 26.
Before changing packages, every installer probes the exact npm executable it will use. npm 11.15 and earlier installs normally; npm 11.16 and later, including npm 12, receives `--allow-scripts` for only the npm-resolved OpenClaw candidate identity. An unreadable npm version stops before package mutation, and a remaining `dist/openclaw-install-guard` makes the install fail instead of reporting a lifecycle-skipped package as successful.
Install-method switches verify the replacement before retiring the current owner. Source wrappers use a same-directory atomic replacement; when an npm shim shares that path, the installer moves only an identity-matched source wrapper aside and restores it if npm installation, lifecycle checks, or candidate verification fails. On upgrades, `install.sh` and `install.ps1` run `openclaw doctor --fix`; repair or final verification failure exits nonzero, and the success banner appears only after those steps complete.
## Quick commands
<Tabs>
@@ -87,7 +91,7 @@ Recommended for most interactive installs on macOS/Linux/WSL.
<Step title="Post-install tasks">
- Resolves the just-installed `openclaw` binary for follow-up commands
- For an unconfigured install, starts onboarding before doctor or gateway probes. With `--no-onboard` or no TTY, it prints the command to finish setup later.
- For a configured install, refreshes and restarts a loaded gateway service best-effort and runs doctor. Upgrades update plugins when possible, or print the manual command in a headless prompt-enabled run.
- For a configured install, refreshes and restarts a loaded gateway service best-effort and runs repair Doctor. Upgrade repair failures are fatal; plugin update failures remain warnings.
- When `--verify` runs, it checks the installed version and checks gateway health only after configuration exists.
</Step>
@@ -316,7 +320,7 @@ by default, plus git-checkout installs under the same prefix flow.
<Step title="Post-install tasks">
- Adds needed bin directory to user PATH when possible
- Refreshes a loaded gateway service best-effort (`openclaw gateway install --force`, then restart)
- Runs `openclaw doctor --non-interactive` on upgrades and git installs (best effort)
- Runs `openclaw doctor --fix --non-interactive` on upgrades and git installs; failure prevents an upgrade-success result
</Step>
<Step title="Handle failures">
+2
View File
@@ -13,6 +13,8 @@ Two paths:
## Easy path (CLI still installed)
The command attempts independent requested cleanup scopes and returns a nonzero status if any scope fails or is blocked. Service teardown remains the safety gate for state and workspace deletion; if that gate fails, those data scopes are preserved while app cleanup is still attempted. Partial cleanup is reported explicitly and is never followed by an unconditional completion result.
Recommended: use the built-in uninstaller:
```bash
+2
View File
@@ -74,6 +74,8 @@ See [Release channels](/install/development-channels) for channel semantics.
## Switch between npm and git installs
Installer-driven switches verify the replacement before the working owner is retired. Source wrappers are published atomically; same-path npm shim transitions use an identity-checked backup that is restored on failure, so a failed candidate leaves the previous command runnable. The `openclaw update` command prints its final success result only after post-core convergence and requested restart health checks succeed.
Use channels to change the install type. The updater keeps your state, config,
credentials, and workspace in `~/.openclaw`; it only changes which OpenClaw
code install the CLI and gateway use.
+40
View File
@@ -1,5 +1,6 @@
---
summary: "Choose and configure Google Meet, Microsoft Teams, or Zoom meeting participation"
doc-schema-version: 1
read_when:
- You want an OpenClaw agent to join a video meeting
- You are choosing between the Google Meet, Microsoft Teams meetings, and Zoom meetings plugins
@@ -45,6 +46,45 @@ durable notes globally. An explicitly selected `transcribe` session retains its
bounded live-caption tail without writing durable rows. Caption availability
still depends on the meeting platform, account, language, and host policy.
## Configure Teams or Zoom
The Teams and Zoom plugins share the same configuration shape for their common
meeting runtime. Add an entry only when you need to override a default. This
example selects the normal agent path, changes the guest display name, and runs
Chrome on a paired node:
```json5
{
plugins: {
entries: {
"teams-meetings": {
config: {
defaultMode: "agent",
chrome: { guestName: "OpenClaw Agent" },
chromeNode: { node: "meeting-node" },
},
},
},
},
}
```
Use `"zoom-meetings"` as the entry id for Zoom. Omit `chromeNode` to run
Chrome on the Gateway host.
| Setting | Purpose |
| -------------------------------- | --------------------------------------------------------------------------------- |
| `defaultMode` | `agent` (default), `bidi`, or `transcribe` |
| `chrome.guestName` | Guest display name entered on the platform prejoin page |
| `chrome.browserProfile` | OpenClaw browser profile used for the meeting |
| `chrome.audioBackend` | `auto` (default), `blackhole-2ch`, or `pipewire-pulse` |
| `chromeNode.node` | Paired node id, name, or IP that owns Chrome and the native virtual-audio backend |
| `realtime.transcriptionProvider` | Realtime transcription provider used by `agent` mode |
| `realtime.voiceProvider` | Realtime voice provider used by `bidi` mode |
| `realtime.model` | Optional realtime model override |
| `realtime.agentId` | Agent consulted in `agent` mode |
| `realtime.toolPolicy` | `safe-read-only`, `owner`, or `none` |
## Prepare Chrome and audio
Chrome can run on the Gateway host or on a paired node. A remote Chrome node must allow `browser.proxy` plus the platform command:
+21 -10
View File
@@ -1,5 +1,6 @@
---
summary: "Plugin SDK subpath catalog: which imports live where, grouped by area"
doc-schema-version: 1
read_when:
- Choosing the right plugin-sdk subpath for a plugin import
- Auditing bundled-plugin subpaths and helper surfaces
@@ -7,8 +8,10 @@ title: "Plugin SDK subpaths"
---
The plugin SDK contains narrow public subpaths and repository-only bundled
helpers under `openclaw/plugin-sdk/`. This page catalogs both and labels
private-local entries explicitly. Three files define the boundary:
helpers under `openclaw/plugin-sdk/`. This page catalogs every typed public
subpath and selected private-local entries that clarify the package boundary;
it is not an inventory of every internal runtime helper. Three files define
the boundary:
- `scripts/lib/plugin-sdk-entrypoints.json`: the maintained entrypoint inventory
the build compiles.
@@ -26,14 +29,15 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
## Plugin entry
| Subpath | Key exports |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plugin-sdk/plugin-entry` | `definePluginEntry` |
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema`, `buildJsonChannelConfigSchema`, `resolveTailscalePublishedHost` |
| `plugin-sdk/provider-entry` | Private-local after July 2026; `defineSingleProviderPluginEntry` |
| `plugin-sdk/migration` | Private-local after July 2026; Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` |
| `plugin-sdk/migration-runtime` | Private-local after July 2026; Runtime migration helpers such as `copyMigrationFileItem`, `resolvePlannedMigrationTargets`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
| `plugin-sdk/health` | Doctor health-check registration, detection, repair, selection, severity, and finding types for bundled health consumers |
| Subpath | Key exports |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plugin-sdk/plugin-entry` | `definePluginEntry` |
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema`, `buildJsonChannelConfigSchema`, `resolveTailscalePublishedHost` |
| `plugin-sdk/provider-entry` | Private-local after July 2026; `defineSingleProviderPluginEntry` |
| `plugin-sdk/migration` | Private-local after July 2026; Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` |
| `plugin-sdk/migration-runtime` | Private-local after July 2026; Runtime migration helpers such as `copyMigrationFileItem`, `resolvePlannedMigrationTargets`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
| `plugin-sdk/health` | Doctor health-check registration, detection, repair, selection, severity, and finding types for bundled health consumers |
| `plugin-sdk/channel-entry-contract` | Bundled channel entry and setup-entry contracts, feature declarations, and lazy module-loading helpers |
### Compatibility and private-local helpers
@@ -125,6 +129,7 @@ new code; see the per-row notes below.
| `plugin-sdk/channel-targets` | Private-local after July 2026; Target parsing helpers; route comparison callers should use `plugin-sdk/channel-route` |
| `plugin-sdk/channel-contract` | Channel contract types |
| `plugin-sdk/channel-feedback` | Feedback/reaction wiring |
| `plugin-sdk/reply-payload` | Reply payload types, normalization, content/media inspection, chunked send helpers, reasoning detection, and reply fan-out |
</Accordion>
Later-window channel compatibility subpaths remain public only through their
@@ -203,6 +208,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/ssrf-dispatcher` | Private-local after July 2026; Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, `SsrFBlockedError` and `GuardedFetchRedirectError`, SSRF policy helpers, and loopback/private host classification |
| `plugin-sdk/secret-input` | Secret input parsing helpers |
| `plugin-sdk/secret-input-runtime` | Secret input normalization, SecretRef coercion, and configured secret resolution helpers |
| `plugin-sdk/secret-ref-readonly` | Closed available/missing/blocked resolution and provider-policy checks for read-only env SecretRefs |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers and raw websocket/body coercion |
| `plugin-sdk/webhook-request-guards` | Request body size/timeout helpers, canonical Gateway browser-origin acceptance via `resolveAcceptedBrowserOrigin`, and `runDetachedWebhookWork` for tracked post-ack processing |
@@ -236,6 +242,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers |
| `plugin-sdk/config-contracts` | Focused config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types, plus the dependency-light runtime helper `resolveGatewayPublicOrigin(cfg)` which returns the normalized `gateway.publicOrigin` (bare http(s) origin, optional reverse-proxy path, no query/hash) or `undefined` when unset, for building links back to the Gateway |
| `plugin-sdk/config-runtime` | Deprecated broad config compatibility facade; prefer `plugin-config-runtime`, `config-mutation`, `runtime-config-snapshot`, and other focused config subpaths |
| `plugin-sdk/plugin-config-runtime` | Deprecated compatibility facade for runtime plugin-config helpers; new plugins use `api.pluginConfig` plus focused config contracts, snapshots, and mutation helpers |
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |
| `plugin-sdk/message-tool-delivery-hints` | Private-local after July 2026; Shared message-tool delivery metadata hint strings |
@@ -306,6 +313,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, trace-context, and low-cardinality dimension normalization helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, unknown-value coercion, shared error classification helpers, `PlatformMessageNotDispatchedError`, `isApprovalNotFoundError` |
| `plugin-sdk/fetch-runtime` | Private-local after July 2026; Wrapped fetch, proxy, EnvHttpProxyAgent option, and pinned lookup helpers |
| `plugin-sdk/proxy-capture` | Debug proxy capture configuration, SQLite-backed capture storage, HTTP/WebSocket capture events, and capture lifecycle helpers |
| `plugin-sdk/runtime-fetch` | Private-local after July 2026; Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
| `plugin-sdk/inline-image-data-url-runtime` | Private-local after July 2026; Inline image data URL sanitizer and signature sniffing helpers without the broad media runtime surface |
| `plugin-sdk/response-limit-runtime` | Private-local after July 2026; Byte-, idle-, and deadline-bounded response-body readers without the broad media runtime surface |
@@ -331,6 +339,9 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer`, `saveMediaStream`, and `saveMediaSource` (local path or HTTP(S) URL into managed media with core's SSRF, byte, redirect, and timeout limits) |
| `plugin-sdk/media-generation-runtime` | Private-local after July 2026; Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Deprecated compatibility facade for media-understanding provider types and helpers; new providers register through the injected plugin API and keep request helpers plugin-owned |
| `plugin-sdk/media-understanding-runtime` | Channel audio preflight/echo helpers plus image, video, audio, and structured media-understanding runtime functions |
| `plugin-sdk/computer-use` | Computer Use v2 action and snapshot schemas, JSON parsers, validation, capability descriptors, and provider registration |
| `plugin-sdk/native-command-config-runtime` | Dependency-light native command and skill enablement config checks |
| `plugin-sdk/text-chunking` | Outbound text and offset-preserving range chunking, markdown chunking/render helpers, quote-aware HTML tag tokenization, markdown table conversion, directive-tag stripping, and safe-text utilities |
| `plugin-sdk/speech` | Private-local after July 2026; Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports |
| `plugin-sdk/speech-core` | Private-local after July 2026; Shared speech provider types, registry, directive, normalization, and speech helper exports |
+29 -72
View File
@@ -1,90 +1,47 @@
---
summary: "Microsoft Teams meetings plugin: join work or consumer meetings as a Chrome browser guest"
doc-schema-version: 1
read_when:
- You want an OpenClaw agent to join a Microsoft Teams meeting
- You are configuring Chrome or virtual audio for Teams meeting talk-back
- You need Teams-specific guest policy or manual-action guidance
title: "Microsoft Teams meetings plugin"
---
The `teams-meetings` plugin joins Microsoft Teams links as a guest in the OpenClaw Chrome profile. It accepts work links under `teams.microsoft.com/l/meetup-join/...` and consumer links under `teams.live.com/meet/...`. It does not create meetings, dial in, call Microsoft Graph, or capture audio/video recordings.
The `teams-meetings` plugin joins work links under
`teams.microsoft.com/l/meetup-join/...` and consumer links under
`teams.live.com/meet/...` as a guest in the OpenClaw Chrome profile. It does not
create meetings, dial in, call Microsoft Graph, or capture audio/video
recordings.
## Setup
Use [Meeting plugins](/plugins/meeting-plugins) for shared installation, modes,
Chrome and virtual-audio setup, transcripts, remote-node requirements, and
verification.
Talk-back uses the shared [meeting-plugin audio setup](/plugins/meeting-plugins#prepare-chrome-and-audio): `BlackHole 2ch` plus SoX on macOS, or PipeWire-Pulse plus `pactl`/`pacat`/`parec` on Linux.
## Handle Teams policy and manual actions
```bash
openclaw plugins install @openclaw/teams-meetings
openclaw gateway restart
brew install blackhole-2ch sox
sudo reboot
system_profiler SPAudioDataType | grep -i BlackHole
command -v sox
```
The browser adapter dismisses the app interstitial, fills the guest name, turns
the camera off, configures the microphone for the selected mode, and clicks the
join button. It recognizes the consumer launcher and Chrome's
`BlackHole 2ch (Virtual)` labels. In-call state uses the hang-up control.
On Linux, verify the desktop user's PipeWire-Pulse session instead:
Tenant policy may require sign-in, email verification, organizer admission, or
a browser device-permission decision. The plugin reports these as
`manualAction`; complete the requested step in the same OpenClaw Chrome profile,
then retry status or speech. It does not bypass tenant policy.
```bash
pactl info
command -v pactl pacat parec
```
The consumer web client has been live-validated through the interstitial,
guest-name entry, microphone/camera toggles, lobby admission, media permissions,
in-call detection, live captions, BlackHole routing, leave, and post-call
detection. Work tenants can impose additional admission or leave-confirmation
policy.
The plugin is enabled by default after installation. Add an entry only to customize it, then check setup:
## Tool and Gateway surface
```json5
{
plugins: {
entries: {
"teams-meetings": {
config: {
defaultMode: "agent",
chrome: { guestName: "OpenClaw Agent" },
},
},
},
},
}
```
Run `openclaw plugins disable teams-meetings` if you do not want the plugin active.
```bash
openclaw teamsmeetings setup
openclaw teamsmeetings join 'https://teams.microsoft.com/l/meetup-join/...'
```
Use `chromeNode.node` to run Chrome and its native virtual-audio backend on a paired macOS or Linux node. The node must allow `teamsmeetings.chrome` and `browser.proxy`; backend setup and generated commands resolve on that node, not on the Gateway host.
## Modes
| Mode | Behavior |
| ------------ | --------------------------------------------------------------------------- |
| `agent` | Realtime transcription consults the configured OpenClaw agent; TTS replies. |
| `bidi` | A realtime voice model listens and replies directly. |
| `transcribe` | Observe-only join with live-caption transcript snapshots. |
Teams live captions are enabled after admission in every mode so OpenClaw can
persist speaker-attributed notes. The `transcript` action still returns the
bounded live buffer only for `transcribe` sessions. On leave, OpenClaw stores
the durable transcript and derived summary in the shared state database; list
or export them with [`openclaw transcripts`](/cli/transcripts).
Automatic notes are enabled by default. Set `transcripts.enabled: false` to
disable durable notes globally; explicit `transcribe` mode still exposes only
its bounded live tail.
## Guest join limits
The browser adapter dismisses the app interstitial, fills the guest name, turns the camera off, configures the microphone for the selected mode, and clicks the join button. In-call state uses the hang-up control; lobby, tenant sign-in, and device-permission states return explicit manual-action reasons. Consumer meeting launcher redirects and the `BlackHole 2ch (Virtual)` labels shown by Chrome are supported.
Teams tenant policy can require sign-in, email verification, or organizer admission. Complete that step in the OpenClaw Chrome profile, then retry status or speech. The plugin does not bypass tenant policy.
The consumer Teams web client has been live-validated for the app interstitial, guest-name entry, prejoin microphone/camera toggles, join, lobby admission, media permissions, in-call detection, live captions, BlackHole input/output routing, leave, and post-call detection. Work tenants can impose different sign-in, email-verification, admission, and leave-confirmation policy; complete any reported manual action in the OpenClaw Chrome profile.
## Tool and gateway surface
The `teams_meetings` agent tool supports `join`, `leave`, `status`, `transcript`, and `speak`. Gateway methods use the `teamsmeetings.*` prefix. The node command is `teamsmeetings.chrome`.
The `teams_meetings` tool supports `join`, `leave`, `status`, `transcript`, and
`speak`. Gateway methods use `teamsmeetings.*`; the node command is
`teamsmeetings.chrome`.
## Related
- [Meeting plugins overview](/plugins/meeting-plugins)
- [Meeting plugins](/plugins/meeting-plugins)
- [Microsoft Teams channel](/channels/msteams)
+29 -72
View File
@@ -1,89 +1,46 @@
---
summary: "Zoom meetings plugin: join meetings as a Chrome browser guest"
doc-schema-version: 1
read_when:
- You want an OpenClaw agent to join a Zoom meeting
- You are configuring Chrome or virtual audio for Zoom meeting talk-back
- You need Zoom-specific guest policy or manual-action guidance
title: "Zoom meetings plugin"
---
The `zoom-meetings` plugin joins Zoom meeting links as a guest through the Zoom Web App in the OpenClaw Chrome profile. It accepts meeting links under `zoom.us/j/...` and account subdomains such as `example.zoom.us/j/...`. It does not create meetings, dial in, use the Zoom Meeting SDK, or capture audio/video recordings.
The `zoom-meetings` plugin joins `zoom.us/j/...` links and account subdomains
such as `example.zoom.us/j/...` as a guest through the Zoom Web App in the
OpenClaw Chrome profile. It does not create meetings, dial in, use the Zoom
Meeting SDK, or capture audio/video recordings.
## Setup
Use [Meeting plugins](/plugins/meeting-plugins) for shared installation, modes,
Chrome and virtual-audio setup, transcripts, remote-node requirements, and
verification.
Talk-back uses the shared [meeting-plugin audio setup](/plugins/meeting-plugins#prepare-chrome-and-audio): `BlackHole 2ch` plus SoX on macOS, or PipeWire-Pulse plus `pactl`/`pacat`/`parec` on Linux.
## Handle Zoom policy and manual actions
```bash
openclaw plugins install @openclaw/zoom-meetings
openclaw gateway restart
brew install blackhole-2ch sox
sudo reboot
system_profiler SPAudioDataType | grep -i BlackHole
command -v sox
```
The browser adapter chooses **Join from browser**, fills the guest name, turns
the camera off, configures the microphone, and clicks **Join**. It grants the
`app.zoom.us` origin microphone and speaker-selection permissions before
navigation. In-call state uses Zoom's Leave control.
On Linux, verify the desktop user's PipeWire-Pulse session instead:
Zoom can disable browser join or require authentication, email verification, a
passcode, CAPTCHA completion, host admission, or browser device permissions.
The plugin reports these as `manualAction`; complete the requested step in the
same OpenClaw Chrome profile, then retry status or speech. It does not bypass
Zoom policy.
```bash
pactl info
command -v pactl pacat parec
```
The Zoom Web App has been live-validated with an official test meeting through
the interstitial, iframe guest-name entry, microphone/camera controls, browser
and macOS media permissions, in-call detection, live captions, and host-ended
detection. Lobby and authentication states retain text fallbacks when no stable
DOM identifier is available.
The plugin is enabled by default after installation. Add an entry only to customize it, then check setup:
## Tool and Gateway surface
```json5
{
plugins: {
entries: {
"zoom-meetings": {
config: {
defaultMode: "agent",
chrome: { guestName: "OpenClaw Agent" },
},
},
},
},
}
```
Run `openclaw plugins disable zoom-meetings` if you do not want the plugin active.
```bash
openclaw zoommeetings setup
openclaw zoommeetings join 'https://zoom.us/j/1234567890'
```
Use `chromeNode.node` to run Chrome and its native virtual-audio backend on a paired macOS or Linux node. The node must allow `zoommeetings.chrome` and `browser.proxy`; backend setup and generated commands resolve on that node, not on the Gateway host.
## Modes
| Mode | Behavior |
| ------------ | --------------------------------------------------------------------------- |
| `agent` | Realtime transcription consults the configured OpenClaw agent; TTS replies. |
| `bidi` | A realtime voice model listens and replies directly. |
| `transcribe` | Observe-only join with live-caption transcript snapshots. |
Zoom live captions are enabled after admission in every mode so OpenClaw can
persist meeting notes. The `transcript` action still returns the bounded live
buffer only for `transcribe` sessions. On leave, OpenClaw stores the durable
transcript and derived summary in the shared state database; list or export
them with [`openclaw transcripts`](/cli/transcripts).
Automatic notes are enabled by default. Set `transcripts.enabled: false` to
disable durable notes globally; explicit `transcribe` mode still exposes only
its bounded live tail.
## Guest join limits
The browser adapter chooses **Join from browser**, fills the guest name, turns the camera off, configures the microphone for the selected mode, and clicks **Join**. Zoom Web App runs under `app.zoom.us`; the plugin grants that origin microphone and speaker-selection permissions before navigation. In-call state uses Zoom's Leave control. Lobby, sign-in, passcode, CAPTCHA, and device-permission states return explicit manual-action reasons.
Zoom host and account policy can disable browser join, require authentication or email verification, show a CAPTCHA, or require host admission. Complete that step in the OpenClaw Chrome profile, then retry status or speech. The plugin does not bypass Zoom policy.
The Zoom Web App has been live-validated with an official Zoom test meeting for the app interstitial, iframe guest-name entry, prejoin microphone and camera controls, join, browser and macOS media permissions, in-call detection, live-caption enablement, and host-ended detection. Lobby and authentication states depend on host policy and retain text fallbacks when no stable DOM identifier is available.
## Tool and gateway surface
The `zoom_meetings` agent tool supports `join`, `leave`, `status`, `transcript`, and `speak`. Gateway methods use the `zoommeetings.*` prefix. The node command is `zoommeetings.chrome`.
The `zoom_meetings` tool supports `join`, `leave`, `status`, `transcript`, and
`speak`. Gateway methods use `zoommeetings.*`; the node command is
`zoommeetings.chrome`.
## Related
- [Meeting plugins overview](/plugins/meeting-plugins)
- [Meeting plugins](/plugins/meeting-plugins)
+7 -273
View File
@@ -1,279 +1,13 @@
---
summary: "Full reference for CLI onboarding: every step, flag, and config field"
read_when:
- Looking up a specific onboarding step or flag
- Automating onboarding with non-interactive mode
- Debugging onboarding behavior
summary: "Redirect to /start/wizard-cli-reference"
doc-schema-version: 1
title: "Onboarding reference"
sidebarTitle: "Onboarding Reference"
redirect: /start/wizard-cli-reference
---
This is the full reference for `openclaw onboard`.
For a high-level overview, see [Onboarding (CLI)](/start/wizard). For step-by-step
behavior and outputs, see [CLI setup reference](/start/wizard-cli-reference).
This page has moved to [CLI setup reference](/start/wizard-cli-reference).
## Flow details (local mode)
## Related
<Steps>
<Step title="Reset (optional)">
- Reset is owned by the `--reset` command flag, not by the interactive
**Setup mode** menu. Without it, re-running onboarding keeps existing
config and reuses it as defaults.
- `--reset-scope` controls what `--reset` removes: `config` (config file
only), `config+creds+sessions` (default), or `full` (also removes the
workspace).
- Before reset, the command validates TTY availability and rejectable CLI
options, including the full-reset workspace target. Non-interactive setup
also requires `--accept-risk` at this point.
- Migration import options (`--flow import`, `--import-from`,
`--import-source`, and `--import-secrets`) cannot be combined with
`--reset`; run the import without `--reset`.
- Interactive classic setup moves state to Trash (never deletes directly)
before showing its risk acknowledgement. Declining that later prompt
cancels setup but does not undo the reset.
</Step>
<Step title="Risk acknowledgement">
- First run (or any run before `wizard.securityAcknowledgedAt` is set)
asks you to confirm you understand that agents are powerful and full
system access is risky.
- `--non-interactive` requires `--accept-risk` explicitly; without it,
onboarding exits with an error instead of prompting.
- Interactive runs get a confirm prompt instead of the flag; declining
cancels setup.
</Step>
<Step title="Workspace">
- Default `~/.openclaw/workspace` (configurable).
- Seeds the workspace files needed for the agent bootstrap ritual.
- Full workspace layout + backup guide: [Agent workspace](/concepts/agent-workspace)
</Step>
<Step title="Model/Auth">
- **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
- **Anthropic Claude CLI**: preferred local path when a Claude CLI sign-in already exists; OpenClaw still supports Anthropic setup-token auth as an alternative.
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
- On a fresh setup with no primary model, sets `agents.defaults.model` to `openai/gpt-5.6-sol` through the Codex runtime.
- **OpenAI Code (Codex) subscription (device pairing)**: browser pairing flow with a short-lived device code.
- On a fresh setup with no primary model, sets `agents.defaults.model` to `openai/gpt-5.6-sol` through the Codex runtime.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- On a fresh setup with no primary model, sets `agents.defaults.model` to `openai/gpt-5.6-sol`. The bare direct-API `openai/gpt-5.6` alias remains supported and resolves to the same tier.
- Adding or reauthenticating OpenAI preserves an existing explicit primary model, including `openai/gpt-5.5`. If the account does not expose GPT-5.6, select `openai/gpt-5.5` explicitly; OpenClaw does not silently downgrade the model.
- **xAI OAuth**: device-code browser sign-in with no localhost callback required, so it works over SSH/Docker/VPS too (`--auth-choice xai-oauth`).
- **xAI API key**: prompts for `XAI_API_KEY` (`--auth-choice xai-api-key`).
- `--auth-choice xai-device-code` still works as a manual-only compatibility alias for the same xAI OAuth device-code flow; use `xai-oauth` for new scripts.
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
- **Ollama**: offers **Cloud + Local**, **Cloud only**, or **Local only** first. `Cloud only` prompts for `OLLAMA_API_KEY` and uses `https://ollama.com`; the host-backed modes prompt for the Ollama base URL (default `http://127.0.0.1:11434`), discover available models, and auto-pull the selected local model when needed; `Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
- More detail: [Ollama](/providers/ollama)
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- **MiniMax**: config is auto-written; hosted default is `MiniMax-M3`.
API-key setup uses `minimax/...`, and OAuth setup uses
`minimax-portal/...`.
- More detail: [MiniMax](/providers/minimax)
- **StepFun**: config is auto-written for StepFun standard or Step Plan on China or global endpoints.
- Standard currently defaults to `step-3.5-flash`; Step Plan also includes `step-3.5-flash-2603`.
- More detail: [StepFun](/providers/stepfun)
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written.
- **Kimi Coding**: config is auto-written.
- More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- **Custom Provider**: works with OpenAI-compatible, OpenAI Responses-compatible, or Anthropic-compatible endpoints. Non-interactive flags: `--auth-choice custom-api-key`, `--custom-base-url`, `--custom-model-id`, `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`), `--custom-provider-id` (optional; auto-derived from the base URL), `--custom-compatibility openai|openai-responses|anthropic` (default `openai`), `--custom-image-input` / `--custom-text-input` (override inferred vision-model detection).
- **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually). For best quality and lower prompt-injection risk, choose the strongest latest-generation model available in your provider stack.
- Onboarding runs a model check and warns if the configured model is unknown or missing auth.
- API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`); the referenced env var must already be set, or onboarding fails fast.
- Auth profiles live in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth). `~/.openclaw/credentials/oauth.json` is legacy import-only.
- More detail: [OAuth](/concepts/oauth)
<Note>
Headless/server tip: complete OAuth on a machine with a browser, then copy
that agent's `auth-profiles.json` (for example
`~/.openclaw/agents/<agentId>/agent/auth-profiles.json`, or the matching
`$OPENCLAW_STATE_DIR/...` path) to the gateway host. `credentials/oauth.json`
is only a legacy import source.
</Note>
</Step>
<Step title="Gateway">
- Port (default **18789**), bind, auth mode, tailscale exposure.
- Auth recommendation: keep **Token** even for loopback so local WS clients must authenticate.
- In token mode, interactive setup offers:
- **Generate/store plaintext token** (default)
- **Use SecretRef** (opt-in)
- Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, `exec`, and `store` providers for onboarding probe/dashboard bootstrap.
- If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth.
- In password mode, interactive setup also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Requires a non-empty env var in the onboarding process environment.
- Cannot be combined with `--gateway-token`.
- Disable auth only if you fully trust every local process.
- Non-loopback binds still require auth.
</Step>
<Step title="Channels">
- [WhatsApp](/channels/whatsapp): optional QR login.
- [Telegram](/channels/telegram): bot token.
- [Discord](/channels/discord): bot token.
- [Google Chat](/channels/googlechat): service account JSON + webhook audience.
- [Mattermost](/channels/mattermost) (plugin): bot token + base URL.
- [Signal](/channels/signal) (plugin): optional `signal-cli` install + account config.
- [iMessage](/channels/imessage): `imsg` CLI path + Messages DB access; use an SSH wrapper when the Gateway runs off-Mac.
- Discord, Feishu, Microsoft Teams, QQ Bot, Slack, and other channels ship as
plugins onboarding can install for you. Full catalog: [Channels](/channels).
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
</Step>
<Step title="Web search">
- Pick a supported provider such as Brave, Codex (Hosted Search), DuckDuckGo, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Ollama Web Search, Parallel, Perplexity, SearXNG, or Tavily (or skip).
- API-backed providers can use env vars or existing config for quick setup; key-free providers use their provider-specific prerequisites instead.
- Skip with `--skip-search`.
- Configure later: `openclaw configure --section web`.
</Step>
<Step title="Skills (recommended)">
- Reads the available skills and checks requirements.
- Lets you choose a node manager: **npm / pnpm / bun**.
- Auto-installs optional dependencies for trusted bundled skills (some use Homebrew on macOS).
- Skips skills whose Homebrew, uv, or Go installer prerequisite is unavailable, groups them with manual setup guidance, and points you at `openclaw doctor` once the prerequisite is installed.
</Step>
<Step title="Daemon install">
- macOS: LaunchAgent
- Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).
- Linux (and Windows via WSL2): systemd user unit
- Onboarding attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
- Native Windows: Scheduled Task first; if task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the Gateway immediately.
- **Runtime selection:** Node is required because the canonical runtime state store uses `node:sqlite`. Legacy Bun services are migrated to Node during repair.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly.
</Step>
<Step title="Health check">
- Starts the Gateway (if needed) and runs `openclaw health`.
- Tip: `openclaw status --deep` adds the live gateway health probe to status output, including channel probes when supported (requires a reachable gateway).
</Step>
<Step title="Finish">
- Summary + next steps, including the **How do you want to hatch your agent?** prompt for Terminal, Browser, or later.
</Step>
</Steps>
<Note>
If no GUI is detected, onboarding prints SSH port-forward instructions for the Control UI instead of opening a browser.
If the Control UI assets are missing, onboarding attempts to build them; fallback is `pnpm ui:build` (auto-installs UI deps).
</Note>
## Non-interactive mode
Use `--non-interactive --accept-risk` to automate or script onboarding (the
flag is the required risk acknowledgement; onboarding exits with an error
without it):
```bash
openclaw onboard --non-interactive --accept-risk \
--mode local \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback \
--install-daemon \
--daemon-runtime node \
--skip-skills
```
Add `--json` for a machine-readable summary.
Gateway token SecretRef in non-interactive mode:
```bash
export OPENCLAW_GATEWAY_TOKEN="your-token"
openclaw onboard --non-interactive --accept-risk --skip-health \
--mode local \
--auth-choice skip \
--gateway-auth token \
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN
```
`--gateway-token` and `--gateway-token-ref-env` are mutually exclusive.
<Note>
`--json` does **not** imply non-interactive mode. Use `--non-interactive --accept-risk` (and `--workspace`) for scripts.
</Note>
Provider-specific command examples live in [CLI Automation](/start/wizard-cli-automation#provider-specific-examples).
Use this reference page for flag semantics and step ordering.
### Add agent (non-interactive)
```bash
openclaw agents add work \
--workspace ~/.openclaw/workspace-work \
--model openai/gpt-5.6-sol \
--bind whatsapp:biz \
--non-interactive \
--json
```
`main` is a reserved agent id and cannot be used for `openclaw agents add`.
## Gateway wizard RPC
The Gateway exposes the onboarding flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`).
Clients (macOS app, Control UI) can render steps without re-implementing onboarding logic.
## Signal setup (signal-cli)
Onboarding detects whether `signal-cli` is on `PATH` and, if missing, offers to install it:
- Linux x86-64: downloads the official native GraalVM build from the `signal-cli` GitHub releases and stores it under `~/.openclaw/tools/signal-cli/<version>/`.
- macOS and other architectures: installs via Homebrew instead.
- Native Windows: not supported yet; run onboarding inside WSL2 to get the Linux install path.
- Writes `channels.signal.transport.cliPath` with `kind: "managed-native"` either way.
## What the wizard writes
Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.skipBootstrap` when `--skip-bootstrap` is passed
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (onboarding preserves explicit values and otherwise leaves it unset, so the `"main"` default keeps all direct messages across channels in the agent's rolling main session—the personal-agent default. For shared or multi-user inboxes, use `"per-channel-peer"`; `openclaw security audit` recommends isolation when it detects multi-user DM traffic. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.matrix.*`, `channels.signal.*`, `channels.imessage.*`
- Channel DM allowlists when you opt in during the channel prompts. Discord, Matrix, Microsoft Teams, and Slack resolve names to IDs when possible; other channels take IDs directly (for example numeric Telegram sender IDs or WhatsApp phone numbers).
- `skills.install.nodeManager`
- `setup --node-manager` accepts `npm`, `pnpm`, or `bun`.
- Manual config can still use `yarn` by setting `skills.install.nodeManager` directly.
- `wizard.lastRunAt`
- `wizard.lastRunVersion`
- `wizard.lastRunCommit`
- `wizard.lastRunCommand`
- `wizard.lastRunMode`
- `wizard.securityAcknowledgedAt`
`openclaw agents add` writes `agents.entries.*` and optional `bindings`.
WhatsApp credentials go under `~/.openclaw/credentials/whatsapp/<accountId>/`.
Active sessions and transcripts are stored in
`~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite`. The
`~/.openclaw/agents/<agentId>/sessions/` directory is used for legacy migration
inputs and archive/support artifacts.
Some channels are delivered as plugins. When you pick one during setup, onboarding
will prompt to install it (npm or a local path) before it can be configured.
## Related docs
- Onboarding overview: [Onboarding (CLI)](/start/wizard)
- CLI setup reference: [CLI setup reference](/start/wizard-cli-reference)
- macOS app onboarding: [Onboarding](/start/onboarding)
- Config reference: [Gateway configuration](/gateway/configuration)
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [iMessage](/channels/imessage)
- Skills: [Skills](/tools/skills), [Skills config](/tools/skills-config)
- [Onboarding (CLI)](/start/wizard)
- [CLI Automation](/start/wizard-cli-automation)
+45 -6
View File
@@ -1,5 +1,6 @@
---
summary: "Step-by-step behavior for openclaw onboard: what each step does, config it writes, and internals"
doc-schema-version: 1
read_when:
- You need detailed behavior for a specific openclaw onboard step
- You are debugging onboarding results or integrating onboarding clients
@@ -42,8 +43,9 @@ not install or modify anything on the remote host.
directly and skip this menu.
- Re-running the wizard does not wipe anything unless you pass `--reset`.
Reset is a command flag, not a setup-mode choice.
- `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to
also remove the workspace. Before moving state to Trash, the command
- `--reset-scope` accepts `config` (config only),
`config+creds+sessions` (default), or `full` (also removes the workspace).
Before moving state to Trash, the command
validates TTY availability and rejectable CLI options. Non-interactive
setup also requires `--accept-risk` before reset. Interactive classic
setup performs reset before showing its risk acknowledgement; declining
@@ -54,6 +56,17 @@ not install or modify anything on the remote host.
- Without `--reset`, invalid config or legacy keys stop the wizard and ask
you to run `openclaw doctor` before continuing.
</Step>
<Step title="Risk acknowledgment">
- The first run asks you to acknowledge that agents are powerful and full
system access is risky. The wizard stores the acknowledgment in
`wizard.securityAcknowledgedAt`, so reruns do not ask again.
- Interactive runs show a confirmation prompt; declining cancels setup.
- `--non-interactive` requires `--accept-risk` and exits with an error when
the flag is missing.
- Interactive classic setup performs `--reset` before this prompt. Declining
after a reset does not restore state already moved to Trash.
</Step>
<Step title="Workspace">
- Default `~/.openclaw/workspace` (configurable).
@@ -74,6 +87,10 @@ not install or modify anything on the remote host.
- In token mode, interactive setup offers:
- **Generate/store plaintext token** (default)
- **Use SecretRef** (opt-in)
- QuickStart reuses an existing `gateway.auth.token` SecretRef from an
`env`, `file`, `exec`, or `store` provider for its probe and dashboard
handoff. An unresolved configured ref stops onboarding with remediation
guidance instead of silently weakening Gateway auth.
- In password mode, interactive setup also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Requires a non-empty env var in the onboarding process environment.
@@ -90,11 +107,15 @@ not install or modify anything on the remote host.
- [Mattermost](/channels/mattermost): bot token + base URL
- [Signal](/channels/signal): optional `signal-cli` install + account config
- [iMessage](/channels/imessage): `imsg` CLI path + Messages DB access; use an SSH wrapper when the Gateway runs off-Mac
- Other bundled or separately installed channel plugins can add their own
onboarding steps. See the complete [channel catalog](/channels).
- DM security: default is pairing. First DM sends a code; approve via
`openclaw pairing approve <channel> <code>` or use allowlists.
</Step>
<Step title="Web search">
- Pick a provider (Brave, DuckDuckGo, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Ollama Web Search, Perplexity, SearXNG, Tavily) or skip.
- Pick a provider (Brave, Codex Hosted Search, DuckDuckGo, Exa, Firecrawl,
Gemini, Grok, Kimi, MiniMax Search, Ollama Web Search, Parallel,
Perplexity, SearXNG, or Tavily) or skip.
- Skip this step with `--skip-search`; reconfigure later with `openclaw configure --section web`.
</Step>
@@ -118,6 +139,12 @@ not install or modify anything on the remote host.
- If task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately.
- Scheduled Tasks remain preferred because they provide better supervisor status.
- Runtime selection: Node is required because OpenClaw's canonical runtime state store uses `node:sqlite`.
- A SecretRef-managed `gateway.auth.token` is validated without copying its
resolved plaintext value into supervisor service metadata. An unresolved
token ref blocks daemon installation with remediation guidance.
- If both `gateway.auth.token` and `gateway.auth.password` exist while
`gateway.auth.mode` is unset, daemon installation blocks until you choose
a mode explicitly.
</Step>
<Step title="Health check">
@@ -184,6 +211,11 @@ instead of exiting. Explicit `--auth-choice` runs still fail fast for automation
<Accordion title="Anthropic Claude CLI">
Preferred local path in interactive onboarding/configure; reuses an existing Claude CLI sign-in when available.
</Accordion>
<Accordion title="Anthropic setup token">
Supports the long-lived token created by `claude setup-token`. Choose
**Anthropic setup-token** during onboarding, or manage it later with
[`openclaw models auth`](/cli/models#auth-profiles).
</Accordion>
<Accordion title="OpenAI Code subscription (OAuth)">
Browser flow; paste `code#state`.
@@ -325,6 +357,8 @@ Credential storage mode:
- Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**.
- Password mode: plaintext or SecretRef.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- The named environment variable must be non-empty in the onboarding process.
`--gateway-token` and `--gateway-token-ref-env` are mutually exclusive.
- Existing plaintext setups continue to work unchanged.
<Note>
@@ -341,12 +375,14 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.skipBootstrap` when `--skip-bootstrap` is passed
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `agents.defaults.model` and provider config when the selected provider needs it
- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (onboarding preserves explicit values and otherwise leaves it unset, so the `main` default keeps all direct messages across channels in the agent's rolling main session—the personal-agent default. For shared or multi-user inboxes, use `per-channel-peer`; `openclaw security audit` recommends isolation when it detects multi-user DM traffic)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.matrix.*`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Discord, iMessage, Signal, Slack, Telegram, WhatsApp) when you opt in during prompts; Discord and Slack also resolve entered names to IDs
- Channel allowlists when you opt in during prompts. Discord, Matrix,
Microsoft Teams, and Slack resolve names to IDs when possible; other channels
accept their native IDs directly.
- `skills.install.nodeManager`
- The `setup --node-manager` flag accepts `npm`, `pnpm`, or `bun`.
- Manual config can still set `skills.install.nodeManager: "yarn"` later.
@@ -387,7 +423,10 @@ openclaw onboard --non-interactive --accept-risk --skip-health \
--anthropic-api-key "$ANTHROPIC_API_KEY"
```
Full flag reference and provider-specific examples: [`openclaw onboard`](/cli/onboard), [CLI automation](/start/wizard-cli-automation).
`--mode` defaults to `local`. `--json` changes output format but does not imply
non-interactive mode. For complete flag semantics and Gateway SecretRef
examples, see [`openclaw onboard`](/cli/onboard). Provider-specific scripts live
in [CLI automation](/start/wizard-cli-automation).
## Gateway wizard RPC
@@ -339,7 +339,10 @@ export async function resolveCodexAppServerPreparedAuthHandoff(params: {
'Codex remote-exec cloud placement requires prepared OpenAI auth. Configure an OpenAI API-key, OAuth, or token profile and use appServer.homeScope="agent"; ambient credentials and native Codex auth are not allowed.',
);
}
if (params.authRequirement === "api-key" && !usesNativeHome) {
if (usesNativeHome) {
return { nativeAuthProfile: true };
}
if (params.authRequirement === "api-key") {
const apiKey = params.resolvedApiKey?.trim();
if (!apiKey) {
throw new Error("Prepared Codex API-key route is missing its resolved API key.");
@@ -357,10 +360,7 @@ export async function resolveCodexAppServerPreparedAuthHandoff(params: {
agentDir: params.agentDir,
config: params.config,
});
if (
usesNativeHome ||
(params.authRequirement !== "subscription" && !params.requirePreparedAuth)
) {
if (params.authRequirement !== "subscription" && !params.requirePreparedAuth) {
return { authProfileId, nativeAuthProfile };
}
if (!authProfileId || (params.authRequirement === "subscription" && !nativeAuthProfile)) {
@@ -11,7 +11,7 @@ const AGENT_DIR = "/tmp/openclaw-codex-auth-matrix";
const SUBSCRIPTION_REQUIRED_ERROR = "subscription profile required";
const SUBSCRIPTION_UNUSABLE_ERROR = "subscription profile unusable";
type StoredProfileKind = "oauth" | "api_key" | "none";
type StoredProfileKind = "oauth" | "api_key" | "unusable" | "none";
type AuthRequirement = "subscription" | "api-key" | undefined;
function buildStore(kind: StoredProfileKind): AuthProfileStore {
@@ -34,10 +34,11 @@ function buildStore(kind: StoredProfileKind): AuthProfileStore {
order: { openai: ["openai:default"] },
};
}
const key = kind === "unusable" ? "" : "matrix-api-key";
return {
version: 1,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "matrix-api-key" },
"openai:default": { type: "api_key", provider: "openai", key },
},
order: { openai: ["openai:default"] },
};
@@ -128,13 +129,13 @@ const HANDOFF_MATRIX: {
homeScope: "user",
authRequirement: "api-key",
storedProfile: "api_key",
expected: { outcome: "native", nativeAuthProfile: false },
expected: { outcome: "native", nativeAuthProfile: true },
},
{
homeScope: "user",
authRequirement: "api-key",
storedProfile: "none",
expected: { outcome: "native", nativeAuthProfile: false },
expected: { outcome: "native", nativeAuthProfile: true },
},
{
homeScope: "user",
@@ -146,13 +147,19 @@ const HANDOFF_MATRIX: {
homeScope: "user",
authRequirement: "subscription",
storedProfile: "api_key",
expected: { outcome: "native", nativeAuthProfile: false },
expected: { outcome: "native", nativeAuthProfile: true },
},
{
homeScope: "user",
authRequirement: "subscription",
storedProfile: "unusable",
expected: { outcome: "native", nativeAuthProfile: true },
},
{
homeScope: "user",
authRequirement: "subscription",
storedProfile: "none",
expected: { outcome: "native", nativeAuthProfile: false },
expected: { outcome: "native", nativeAuthProfile: true },
},
{
homeScope: "user",
@@ -164,13 +171,13 @@ const HANDOFF_MATRIX: {
homeScope: "user",
authRequirement: undefined,
storedProfile: "api_key",
expected: { outcome: "native", nativeAuthProfile: false },
expected: { outcome: "native", nativeAuthProfile: true },
},
{
homeScope: "user",
authRequirement: undefined,
storedProfile: "none",
expected: { outcome: "native", nativeAuthProfile: false },
expected: { outcome: "native", nativeAuthProfile: true },
},
];
@@ -211,10 +218,11 @@ describe("Codex app-server auth requirement matrix", () => {
});
return;
}
expect(resolved).toEqual({
authProfileId,
nativeAuthProfile: expected.nativeAuthProfile,
});
expect(resolved).toEqual(
homeScope === "user"
? { nativeAuthProfile: true }
: { authProfileId, nativeAuthProfile: expected.nativeAuthProfile },
);
expect(resolved).not.toHaveProperty("preparedAuth");
},
);
@@ -53,6 +53,13 @@ function guardianActionCommand(action: JsonObject | undefined): string | undefin
return argv.length > 0 ? argv.join(" ") : readString(action, "program");
}
function normalizeApprovalReviewStatus(status: string | undefined): string | undefined {
return status === "inProgress" ? "in_progress" : status === "timedOut" ? "timed_out" : status;
}
const GUARDIAN_TIMEOUT_WARNING =
"Automatic approval review timed out while evaluating the requested approval.";
export function projectNormalizedToolItem(params: {
phase: "start" | "result";
item: CodexThreadItem | undefined;
@@ -96,6 +103,7 @@ export function projectNormalizedToolItem(params: {
export class CodexEventProjection {
private reviewCount = 0;
private pendingGuardianWarning: string | undefined;
constructor(
private readonly threadId: string,
@@ -114,28 +122,97 @@ export class CodexEventProjection {
this.reviewCount += 1;
const review = isJsonObject(params.review) ? params.review : undefined;
const action = isJsonObject(params.action) ? params.action : undefined;
const reviewId = readString(params, "reviewId");
const targetItemId = readNullableString(params, "targetItemId");
const reviewStatus = review ? readString(review, "status") : undefined;
const status = normalizeApprovalReviewStatus(reviewStatus);
const riskLevel = review ? readString(review, "riskLevel") : undefined;
const userAuthorization = review ? readString(review, "userAuthorization") : undefined;
const rationale = review ? readNullableString(review, "rationale") : undefined;
// Codex emits the routine warning immediately before its structured terminal fact.
// Exact byte equality consumes only that duplicate; every other warning is flushed.
const expectedWarning =
status === "timed_out"
? GUARDIAN_TIMEOUT_WARNING
: rationale &&
riskLevel &&
userAuthorization &&
(status === "approved" || status === "denied")
? `Automatic approval review ${status} (risk: ${riskLevel}, authorization: ${userAuthorization}): ${rationale}`
: undefined;
const warningMatchesReview =
Boolean(targetItemId) && Boolean(reviewId) && this.pendingGuardianWarning === expectedWarning;
if (warningMatchesReview) {
this.pendingGuardianWarning = undefined;
} else {
this.flushPendingGuardianWarning();
}
this.emitAgentEvent({
stream: "codex_app_server.guardian",
data: {
method,
phase: method.endsWith("/started") ? "started" : "completed",
reviewId: readString(params, "reviewId"),
targetItemId: readNullableString(params, "targetItemId"),
reviewId,
targetItemId,
decisionSource: readString(params, "decisionSource"),
status: review ? readString(review, "status") : undefined,
riskLevel: review ? readString(review, "riskLevel") : undefined,
userAuthorization: review ? readString(review, "userAuthorization") : undefined,
rationale: review ? readNullableString(review, "rationale") : undefined,
status: reviewStatus,
riskLevel,
userAuthorization,
rationale,
actionType: action ? readString(action, "type") : undefined,
command: guardianActionCommand(action),
},
});
if (reviewId && targetItemId && status) {
const approvalReview: JsonObject = {
id: reviewId,
label: "Guardian",
status,
...(riskLevel ? { riskLevel } : {}),
...(userAuthorization ? { userAuthorization } : {}),
...(rationale ? { rationale } : {}),
};
const approvalReviewOutcome = this.toolTranscript.recordToolApprovalReview(
targetItemId,
reviewId,
status,
approvalReview,
);
this.emitAgentEvent({
stream: "tool",
data: {
phase: "review",
toolCallId: targetItemId,
hideFromChannelProgress: true,
approvalReviewOutcome,
review: approvalReview,
},
});
}
}
handleGuardianWarning(params: JsonObject): void {
this.flushPendingGuardianWarning();
const message = readString(params, "message");
if (message) {
this.pendingGuardianWarning = message;
return;
}
this.emitAgentEvent({
stream: "codex_app_server.guardian",
data: { phase: "warning", message: readString(params, "message") },
data: { phase: "warning", message },
});
}
flushPendingGuardianWarning(): void {
const pending = this.pendingGuardianWarning;
if (!pending) {
return;
}
this.pendingGuardianWarning = undefined;
this.emitAgentEvent({
stream: "codex_app_server.guardian",
data: { phase: "warning", message: pending },
});
}
@@ -214,6 +291,13 @@ export class CodexEventProjection {
}
const { item } = params;
const { name, status, args, meta, event } = projection;
const approvalReviewOutcome =
params.phase === "result"
? this.toolTranscript.finalizeToolApprovalReviews(item.id)
: undefined;
if (event && approvalReviewOutcome) {
event.data.approvalReviewOutcome = approvalReviewOutcome;
}
this.toolTranscript.recordTrajectoryEvent({ phase: params.phase, item, name, args, status });
if (params.phase === "result") {
this.toolProgress.recordNativeToolError({ item, name, meta, status });
@@ -36,11 +36,12 @@ import {
type ToolTranscriptResultInput,
} from "./event-projector-tool-progress.js";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
import type {
CodexDynamicToolCallOutputContentItem,
CodexThreadItem,
JsonObject,
JsonValue,
import {
isJsonObject,
type CodexDynamicToolCallOutputContentItem,
type CodexThreadItem,
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import { sanitizeCodexToolArguments } from "./tool-progress-normalization.js";
@@ -64,6 +65,24 @@ const CODE_MODE_NATIVE_PATCH_SOURCE_RE =
/^\s*(?:\/\/[^\r\n]*\r?\n\s*)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+tools\.apply_patch\(\s*("(?:\\[\s\S]|[^"\\])*")\s*\)\s*;?\s*text\(\s*\1\s*\)\s*;?\s*$/u;
const CODE_MODE_NATIVE_PATCH_RESULT_RE =
/^\s*Script (completed|failed)\s*\r?\nWall time\s+\d+(?:\.\d+)?\s+seconds\s*\r?\nOutput:\s*([\s\S]*?)\s*$/iu;
const MAX_TOOL_APPROVAL_REVIEWS = 16;
type ToolApprovalReviewOutcome = "approved" | "denied" | "reviewing";
type ToolApprovalReviewState = {
reviews: JsonObject[];
denied: boolean;
/** `null` means more unresolved IDs existed than the bounded set could retain. */
unresolvedReviewIds: Set<string> | null;
};
function toolApprovalReviewOutcome(state: ToolApprovalReviewState): ToolApprovalReviewOutcome {
return state.denied
? "denied"
: state.unresolvedReviewIds === null || state.unresolvedReviewIds.size > 0
? "reviewing"
: "approved";
}
function readCodeModeNativePatchInput(source: unknown): string | undefined {
if (typeof source !== "string") {
@@ -129,6 +148,7 @@ export class CodexToolTranscriptProjection {
private readonly afterToolCallObservedItemIds = new Set<string>();
private readonly nativeMcpAppResultDetails = new Map<string, unknown>();
private readonly nativeMcpAppResultDetailsAttempted = new Set<string>();
private readonly approvalReviewsByCallId = new Map<string, ToolApprovalReviewState>();
private readonly rawNativeToolOutputByCallId = new Map<string, string>();
private readonly codeModeNativePatchInputsByCallId = new Map<string, string>();
@@ -149,6 +169,44 @@ export class CodexToolTranscriptProjection {
return this.messages;
}
recordToolApprovalReview(
toolCallId: string,
reviewId: string,
status: string,
review: JsonObject,
): ToolApprovalReviewOutcome {
const state = this.approvalReviewsByCallId.get(toolCallId) ?? {
reviews: [],
denied: false,
unresolvedReviewIds: new Set<string>(),
};
state.reviews = [
...state.reviews.filter((candidate) => candidate.id !== reviewId),
review,
].slice(-MAX_TOOL_APPROVAL_REVIEWS);
state.denied ||= ["denied", "timed_out", "aborted"].includes(status);
const unresolved = state.unresolvedReviewIds;
if (status === "in_progress") {
state.unresolvedReviewIds =
unresolved && (unresolved.size < MAX_TOOL_APPROVAL_REVIEWS || unresolved.has(reviewId))
? unresolved.add(reviewId)
: null;
} else {
unresolved?.delete(reviewId);
}
this.approvalReviewsByCallId.set(toolCallId, state);
return toolApprovalReviewOutcome(state);
}
finalizeToolApprovalReviews(toolCallId: string): ToolApprovalReviewOutcome | undefined {
const state = this.approvalReviewsByCallId.get(toolCallId);
if (!state) {
return undefined;
}
state.unresolvedReviewIds = new Set();
return toolApprovalReviewOutcome(state);
}
recordDynamicToolCall(params: { callId: string; tool: string; arguments?: JsonValue }): void {
this.recordToolCall({
id: params.callId,
@@ -345,7 +403,25 @@ export class CodexToolTranscriptProjection {
}
async recordNativeToolResultWithDetails(item: CodexThreadItem | undefined): Promise<void> {
this.recordNativeToolResult(item, await this.prepareNativeMcpAppResultDetails(item));
const preparedDetails = await this.prepareNativeMcpAppResultDetails(item);
const approvalReviewState = item ? this.approvalReviewsByCallId.get(item.id) : undefined;
// The terminal tool result is the durable owner for its reviews. Live
// review events disappear with the run snapshot; details survive history.
const reviewDetails = approvalReviewState
? {
approvalReviews: approvalReviewState.reviews,
approvalReviewOutcome: toolApprovalReviewOutcome(approvalReviewState),
}
: undefined;
const details = reviewDetails
? isJsonObject(preparedDetails)
? { ...preparedDetails, ...reviewDetails }
: {
...(preparedDetails !== undefined ? { toolDetails: preparedDetails } : {}),
...reviewDetails,
}
: preparedDetails;
this.recordNativeToolResult(item, details);
}
private async prepareNativeMcpAppResultDetails(
@@ -18,94 +18,317 @@ import {
registerCodexEventProjectorTestLifecycle();
function guardianWarning(message: string, threadId = THREAD_ID): ProjectorNotification {
return { method: "guardianWarning", params: { threadId, message } } as ProjectorNotification;
}
function guardianReview(params: {
id: string;
status: string;
target?: string | null;
phase?: "started" | "completed";
riskLevel?: string;
userAuthorization?: string;
rationale?: string | null;
action?: Record<string, unknown>;
}): ProjectorNotification {
const phase = params.phase ?? "completed";
return forCurrentTurn(`item/autoApprovalReview/${phase}`, {
reviewId: params.id,
targetItemId: params.target === undefined ? "cmd-1" : params.target,
...(phase === "completed" ? { decisionSource: "agent" } : {}),
review: {
status: params.status,
...(params.riskLevel ? { riskLevel: params.riskLevel } : {}),
...(params.userAuthorization ? { userAuthorization: params.userAuthorization } : {}),
...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
},
action: params.action ?? {
type: "execve",
source: "shell",
program: "/bin/printf",
argv: ["printf", "hello"],
cwd: "/tmp",
},
});
}
function commandItem(phase: "started" | "completed", id = "cmd-1"): ProjectorNotification {
const completed = phase === "completed";
return forCurrentTurn(`item/${phase}`, {
item: {
type: "commandExecution",
id,
command: "printf hello",
cwd: "/tmp",
status: completed ? "completed" : "inProgress",
commandActions: [],
...(completed ? { aggregatedOutput: "hello", exitCode: 0 } : {}),
},
});
}
describe("CodexAppServerEventProjector reasoning and guardian projection", () => {
it("projects guardian review lifecycle details into agent events", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
await projector.handleNotification(commandItem("started"));
await projector.handleNotification(
forCurrentTurn("item/autoApprovalReview/started", {
reviewId: "review-1",
targetItemId: "cmd-1",
review: { status: "inProgress" },
action: {
type: "execve",
source: "shell",
program: "/bin/printf",
argv: ["printf", "hello"],
cwd: "/tmp",
},
}),
guardianReview({ id: "review-1", status: "inProgress", phase: "started" }),
);
await projector.handleNotification(
forCurrentTurn("item/autoApprovalReview/completed", {
reviewId: "review-1",
targetItemId: "cmd-1",
decisionSource: "agent",
review: {
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Benign local probe.",
},
action: {
type: "execve",
source: "shell",
program: "/bin/printf",
argv: ["printf", "hello"],
cwd: "/tmp",
},
guardianWarning(
"Automatic approval review approved (risk: low, authorization: high): Benign local probe.",
),
);
await projector.handleNotification(
guardianReview({
id: "review-1",
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Benign local probe.",
}),
);
await projector.handleNotification(commandItem("completed"));
const started = findAgentEvent(onAgentEvent, {
stream: "codex_app_server.guardian",
phase: "started",
}).data;
expect(started.reviewId).toBe("review-1");
expect(started.targetItemId).toBe("cmd-1");
expect(started.status).toBe("inProgress");
expect(started.actionType).toBe("execve");
expect(started).toMatchObject({
reviewId: "review-1",
targetItemId: "cmd-1",
status: "inProgress",
});
const completed = findAgentEvent(onAgentEvent, {
stream: "codex_app_server.guardian",
phase: "completed",
}).data;
expect(completed.reviewId).toBe("review-1");
expect(completed.targetItemId).toBe("cmd-1");
expect(completed.decisionSource).toBe("agent");
expect(completed.status).toBe("approved");
expect(completed.riskLevel).toBe("low");
expect(completed.userAuthorization).toBe("high");
expect(completed.rationale).toBe("Benign local probe.");
expect(completed.actionType).toBe("execve");
expect(completed.command).toBe("printf hello");
expect(completed).toMatchObject({
reviewId: "review-1",
targetItemId: "cmd-1",
status: "approved",
command: "printf hello",
});
const toolReviews = onAgentEvent.mock.calls
.map(([event]) => event)
.filter(
(event) =>
event?.stream === "tool" &&
event.data?.phase === "review" &&
event.data?.toolCallId === "cmd-1",
);
expect(toolReviews.map((event) => event.data.review)).toEqual([
{
id: "review-1",
label: "Guardian",
status: "in_progress",
},
{
id: "review-1",
label: "Guardian",
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Benign local probe.",
},
]);
expect(toolReviews[0]?.data.approvalReviewOutcome).toBe("reviewing");
expect(toolReviews[1]?.data.approvalReviewOutcome).toBe("approved");
expect(toolReviews.every((event) => event.data.hideFromChannelProgress === true)).toBe(true);
const result = projector.buildResult(buildEmptyToolTelemetry());
const toolResult = result.messagesSnapshot.find((message) => message.role === "toolResult");
expect(requireRecord(toolResult, "reviewed tool result").details).toMatchObject({
approvalReviews: [{ id: "review-1", status: "approved" }],
approvalReviewOutcome: "approved",
});
});
it("correlates identical parallel routine warnings with two distinct command reviews", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
const warning =
"Automatic approval review approved (risk: low, authorization: high): Safe command.";
for (const [index, command] of ["printf first", "printf second"].entries()) {
const itemId = `cmd-${index + 1}`;
await projector.handleNotification(guardianWarning(warning));
await projector.handleNotification(
guardianReview({
id: `review-${index + 1}`,
target: itemId,
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Safe command.",
action: { type: "command", source: "shell", command, cwd: "/tmp" },
}),
);
}
const events = onAgentEvent.mock.calls.map(([event]) => event);
expect(
projector.buildResult(buildEmptyToolTelemetry()).didSendDeterministicApprovalPrompt,
).toBe(false);
events.filter(
(event) => event.stream === "codex_app_server.guardian" && event.data.phase === "warning",
),
).toEqual([]);
expect(
events
.filter((event) => event.stream === "tool" && event.data.phase === "review")
.map((event) => event.data.review.id),
).toEqual(["review-1", "review-2"]);
});
it("flushes routine warnings at targetless, unrelated, and projector-finalization boundaries", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
const approvedWarning =
"Automatic approval review approved (risk: low, authorization: high): Network call.";
const deniedWarning =
"Automatic approval review denied (risk: high, authorization: low): Unsafe command.";
const timeoutWarning =
"Automatic approval review timed out while evaluating the requested approval.";
await projector.handleNotification(guardianWarning(approvedWarning));
expect(onAgentEvent).not.toHaveBeenCalled();
await projector.handleNotification(
guardianReview({
id: "review-network",
target: null,
status: "approved",
riskLevel: "low",
userAuthorization: "high",
rationale: "Network call.",
action: {
type: "networkAccess",
target: "https://example.invalid",
host: "example.invalid",
protocol: "https",
port: 443,
},
}),
);
await projector.handleNotification(guardianWarning(deniedWarning));
await projector.handleNotification(
forCurrentTurn("item/plan/delta", { itemId: "plan-1", delta: "continue" }),
);
await projector.handleNotification(guardianWarning(timeoutWarning));
projector.buildResult(buildEmptyToolTelemetry());
const warnings = onAgentEvent.mock.calls
.map(([event]) => event)
.filter(
(event) => event.stream === "codex_app_server.guardian" && event.data.phase === "warning",
);
expect(warnings.map((event) => event.data.message)).toEqual([
approvedWarning,
deniedWarning,
timeoutWarning,
]);
expect(
onAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "tool" && event.data.phase === "review"),
).toEqual([]);
});
it.each([
{ firstStatus: "denied", liveOutcome: "denied", persistedOutcome: "denied" },
{ firstStatus: "inProgress", liveOutcome: "reviewing", persistedOutcome: "approved" },
])("bounds rows without losing a $liveOutcome aggregate", async (scenario) => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
await projector.handleNotification(commandItem("started", "cmd-many-reviews"));
for (let index = 0; index < 18; index += 1) {
const status = index === 0 ? scenario.firstStatus : "approved";
await projector.handleNotification(
guardianReview({
id: `review-${index}`,
target: "cmd-many-reviews",
status,
...(status === "inProgress" ? { phase: "started" as const } : {}),
riskLevel: status === "approved" ? "low" : "high",
userAuthorization: status === "approved" ? "high" : "low",
rationale: `${status} ${index}.`,
}),
);
}
const reviewEvents = onAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "tool" && event.data.phase === "review");
expect(reviewEvents.at(-1)?.data.approvalReviewOutcome).toBe(scenario.liveOutcome);
await projector.handleNotification(commandItem("completed", "cmd-many-reviews"));
const result = projector.buildResult(buildEmptyToolTelemetry());
const toolResult = result.messagesSnapshot.find((message) => message.role === "toolResult");
const details = requireRecord(toolResult, "bounded review tool result").details as {
approvalReviews: Array<{ id: string }>;
approvalReviewOutcome: string;
};
expect(details.approvalReviews).toHaveLength(16);
expect(details.approvalReviews.map((review) => review.id)).toEqual(
Array.from({ length: 16 }, (_, index) => `review-${index + 2}`),
);
expect(details.approvalReviewOutcome).toBe(scenario.persistedOutcome);
expect(
onAgentEvent.mock.calls
.map(([event]) => event)
.find((event) => event.stream === "tool" && event.data.phase === "result")?.data
.approvalReviewOutcome,
).toBe(scenario.persistedOutcome);
});
it.each([
{
status: "timedOut",
normalizedStatus: "timed_out",
warning: "Automatic approval review timed out while evaluating the requested approval.",
rationale: "Automatic approval review timed out while evaluating the requested approval.",
},
{ status: "aborted", normalizedStatus: "aborted", warning: undefined, rationale: null },
])("keeps a targeted $normalizedStatus review command-owned", async (terminal) => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
if (terminal.warning) {
await projector.handleNotification(guardianWarning(terminal.warning));
}
await projector.handleNotification(
guardianReview({
id: `review-${terminal.normalizedStatus}`,
target: "cmd-terminal",
status: terminal.status,
rationale: terminal.rationale,
}),
);
const events = onAgentEvent.mock.calls.map(([event]) => event);
expect(
events.filter(
(event) => event.stream === "codex_app_server.guardian" && event.data.phase === "warning",
),
).toEqual([]);
expect(
events.find((event) => event.stream === "tool" && event.data.phase === "review")?.data,
).toMatchObject({
approvalReviewOutcome: "denied",
review: { status: terminal.normalizedStatus },
});
});
it("projects thread-scoped guardian warnings", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
await projector.handleNotification({
method: "guardianWarning",
params: { threadId: "thread-other", message: "Wrong thread." },
} as ProjectorNotification);
await projector.handleNotification({
method: "guardianWarning",
params: {
threadId: THREAD_ID,
message: "Guardian rejection limit reached; ending turn as interrupted.",
},
} as ProjectorNotification);
await projector.handleNotification(guardianWarning("Wrong thread.", "thread-other"));
await projector.handleNotification(
guardianWarning("Guardian rejection limit reached; ending turn as interrupted."),
);
projector.buildResult(buildEmptyToolTelemetry());
const warning = findAgentEvent(onAgentEvent, {
stream: "codex_app_server.guardian",
phase: "warning",
}).data;
expect(warning.message).toBe("Guardian rejection limit reached; ending turn as interrupted.");
expect(onAgentEvent).toHaveBeenCalledTimes(1);
const warnings = onAgentEvent.mock.calls.map(([event]) => event.data.message);
expect(warnings).toEqual(["Guardian rejection limit reached; ending turn as interrupted."]);
});
it("projects reasoning end, plan updates, compaction state, and tool metadata", async () => {
@@ -235,6 +235,13 @@ export class CodexAppServerEventProjector {
} else if (!isCodexNotificationForTurn(params, this.threadId, this.turnId)) {
return;
}
if (
notification.method !== "guardianWarning" &&
notification.method !== "item/autoApprovalReview/started" &&
notification.method !== "item/autoApprovalReview/completed"
) {
this.eventProjection.flushPendingGuardianWarning();
}
this.nativeToolLifecycleProjector.handleNotification(notification);
this.assistantProjection.handleNotification(notification.method, params);
@@ -337,6 +344,7 @@ export class CodexAppServerEventProjector {
toolTelemetry: CodexAppServerToolTelemetry,
options?: { yieldDetected?: boolean },
): EmbeddedRunAttemptResult & { terminalTurnId: string } {
this.eventProjection.flushPendingGuardianWarning();
return buildCodexAttemptResult({
runParams: this.params,
turnId: this.turnId,
@@ -4,6 +4,7 @@ import { initializeGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import * as appServerPolicy from "./app-server-policy.js";
import { applyCodexAppServerAuthProfile } from "./auth-bridge.js";
import * as bindingConnection from "./binding-connection.js";
import { prepareCodexAttemptConnection } from "./run-attempt-connection.js";
import {
@@ -153,6 +154,61 @@ describe("prepareCodexAttemptConnection", () => {
expect(connection.disableLoginShell).toBe(true);
});
it("keeps a user-home subscription on native account verification", async () => {
const sessionFile = path.join(tempDir, "user-home-native-auth.jsonl");
const workspaceDir = path.join(tempDir, "workspace-user-home-native-auth");
const params = createParams(sessionFile, workspaceDir);
const runtimePlan = createCodexRuntimePlanFixture();
params.runtimePlan = {
...runtimePlan,
auth: {
...runtimePlan.auth,
providerForAuth: "openai",
authProfileProviderForAuth: "openai",
forwardedAuthProfileId: "openai:unusable",
selectedAuthMode: "subscription",
modelRoute: {
provider: "openai",
modelId: "gpt-5.4-codex",
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authRequirement: "subscription",
requestTransportOverrides: "none",
},
},
};
params.authProfileStore = {
version: 1,
profiles: {
"openai:unusable": { type: "api_key", provider: "openai", key: "" },
},
};
registerCodexTestSessionIdentity(sessionFile, params.sessionId, params.sessionKey);
const connection = await prepareCodexAttemptConnection({
params,
options: {
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: { homeScope: "user" } },
},
});
const request = vi.fn(async () => ({ account: { type: "chatgpt" } }));
expect(connection.startupAuthProfileId).toBeUndefined();
expect(connection.startupPreparedAuth).toBeUndefined();
expect(connection.startupClientAuthProfileId).toBeNull();
await expect(
applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir: connection.agentDir,
authProfileId: connection.startupClientAuthProfileId,
authRequirement: connection.startupAuthRequirement,
}),
).resolves.toBeUndefined();
expect(request).toHaveBeenCalledExactlyOnceWith("account/read", { refreshToken: false });
expect(request).not.toHaveBeenCalledWith("account/login/start", expect.anything());
});
it.each([
{ name: "fresh thread", existingThread: false },
{ name: "unchanged resumed thread", existingThread: true },
@@ -224,6 +224,9 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
}).appServer;
const initialStartupBindingHadInactiveThreadBootstrap =
isInactiveThreadBootstrapBinding(startupBinding);
const appServerHomeScope = resolveCodexAppServerHomeScope({
appServer: pluginConfig.appServer,
});
const preparedAuthRoute = usesSupervisionConnection
? undefined
: params.runtimePlan?.auth.modelRoute;
@@ -257,7 +260,7 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
authProfileId: resolvedStartupAuthProfileId,
authProfileStore: params.authProfileStore,
agentDir,
homeScope: resolveCodexAppServerHomeScope({ appServer: pluginConfig.appServer }),
homeScope: appServerHomeScope,
requirePreparedAuth: isCodexRemoteExecPlacementSandbox(sandbox),
config: params.config,
subscriptionProfileRequiredError:
@@ -270,7 +273,9 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu
preparedAuth: startupPreparedAuth,
} = authHandoff;
const startupClientAuthProfileId =
usesSupervisionConnection || startupPreparedAuth?.kind === "api-key"
usesSupervisionConnection ||
appServerHomeScope === "user" ||
startupPreparedAuth?.kind === "api-key"
? null
: startupAuthProfileId;
const resolveReviewerPolicyContext = (binding: CodexAppServerThreadBinding | undefined) => {
+1
View File
@@ -44,6 +44,7 @@ export async function startQaGatewayAccount(
config: ctx.cfg as CoreConfig,
message,
buildContext,
...(channelRuntime ? { channelRuntime } : {}),
});
const captureInboundError = (error: unknown) => {
inboundError ??= error instanceof Error ? error : new Error(String(error));
+6 -7
View File
@@ -22,6 +22,7 @@ import {
type QaBusMessage,
} from "./bus-client.js";
import { sendQaChannelMediaBatch } from "./outbound.js";
import type { PluginRuntime } from "./runtime-api.js";
import { getQaChannelRuntime } from "./runtime.js";
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
@@ -266,8 +267,9 @@ export async function handleQaInbound(params: {
config: CoreConfig;
message: QaBusMessage;
buildContext?: typeof buildChannelInboundEventContext;
channelRuntime?: PluginRuntime["channel"];
}) {
const runtime = getQaChannelRuntime();
const channelRuntime = params.channelRuntime ?? getQaChannelRuntime().channel;
const inbound = params.message;
const target = buildQaTarget({
chatType: inbound.conversation.kind,
@@ -297,12 +299,9 @@ export async function handleQaInbound(params: {
});
const isGroup = inbound.conversation.kind !== "direct";
const wasMentioned = isGroup
? runtime.channel.mentions.matchesMentionPatterns(
? channelRuntime.mentions.matchesMentionPatterns(
inbound.text,
runtime.channel.mentions.buildMentionRegexes(
params.config as OpenClawConfig,
route.agentId,
),
channelRuntime.mentions.buildMentionRegexes(params.config as OpenClawConfig, route.agentId),
)
: undefined;
const groupConfig = isGroup
@@ -422,7 +421,7 @@ export async function handleQaInbound(params: {
},
});
await runtime.channel.inbound.dispatch({
await channelRuntime.inbound.dispatch({
cfg: params.config as OpenClawConfig,
channel: params.channelId,
accountId: params.account.accountId,
@@ -1885,7 +1885,6 @@ describe("Anthropic provider", () => {
expect(result.stopReason).toBe("error");
// Keep salient transport fields while replacing the cycle, so the terminal
// diagnostic remains actionable without stranding the stream.
expect(result.errorMessage).toBeTruthy();
expect(result.errorMessage).toBe('{"code":"ECONNRESET","self":"[Circular]"}');
});
@@ -408,7 +408,6 @@ describe("projectProviderError", () => {
expect(projected.stopReason).toBe("error");
expect(projected.errorMessage).toBe("[Unserializable]");
expect(projected.errorMessage.length).toBeLessThanOrEqual(4096);
});
it("caps descriptor reads from hostile objects", () => {
@@ -1,68 +0,0 @@
{
"provider": {
"eventTrace": [
{
"type": "error",
"reason": "error"
}
],
"terminal": {
"role": "assistant",
"content": [],
"api": "anthropic-messages",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"usage": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 0,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0
}
},
"stopReason": "error"
},
"errorFields": {
"errorMessage": "429 {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"synthetic Anthropic rejection\"}}"
}
},
"transport": {
"eventTrace": [
{
"type": "error",
"reason": "error"
}
],
"terminal": {
"role": "assistant",
"content": [],
"api": "anthropic-messages",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"usage": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 0,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0
}
},
"stopReason": "error"
},
"errorFields": {
"errorMessage": "HTTP 429: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"synthetic Anthropic rejection\"}}; Retry-After: 2 seconds"
}
}
}
@@ -1,71 +0,0 @@
{
"provider": {
"eventTrace": [
{
"type": "error",
"reason": "error"
}
],
"terminal": {
"role": "assistant",
"content": [],
"api": "openai-completions",
"provider": "openai",
"model": "gpt-5.5",
"usage": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 0,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0
}
},
"stopReason": "error"
},
"errorFields": {
"errorMessage": "429: {\"code\":\"rate_limit_exceeded\",\"type\":\"rate_limit_error\"}"
}
},
"transport": {
"eventTrace": [
{
"type": "error",
"reason": "error"
}
],
"terminal": {
"role": "assistant",
"content": [],
"api": "openai-completions",
"provider": "openai",
"model": "gpt-5.5",
"usage": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 0,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0
}
},
"stopReason": "error"
},
"errorFields": {
"errorMessage": "synthetic OpenAI rejection",
"errorCode": "rate_limit_exceeded",
"errorType": "rate_limit_error",
"errorBody": "{\"code\":\"rate_limit_exceeded\",\"type\":\"rate_limit_error\"}"
}
}
}
@@ -43,6 +43,7 @@ export const ProgressCardPutParamsSchema = closedObject({
sessionKey: NonEmptyString,
markdown: Type.Optional(Type.String()),
plan: Type.Optional(Type.Array(ProgressCardStepSchema, { maxItems: PROGRESS_CARD_MAX_STEPS })),
expectedRevision: Type.Optional(Type.Integer({ minimum: 1 })),
});
export type ProgressCardPutParams = Static<typeof ProgressCardPutParamsSchema>;
+80 -8
View File
@@ -33,7 +33,13 @@ ensure_home_env
# Register paths in the caller: command substitutions run in a subshell, so
# array mutations inside a helper would not reach this shell.
TMPFILES=()
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
cleanup_tmpfiles() {
if [[ -n "$WRAPPER_BACKUP_PATH" && ( -e "$WRAPPER_BACKUP_PATH" || -L "$WRAPPER_BACKUP_PATH" ) ]]; then
rm -f "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
mv "$WRAPPER_BACKUP_PATH" "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
fi
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
@@ -1273,6 +1279,62 @@ npm_config_has_raw_key() {
return 1
}
npm_lifecycle_allow_arg() {
local npm_cmd="$1" spec="$2" npm_cwd="${3:-$PWD}" version=""
version="$("$npm_cmd" --version 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
if [[ ! "$version" =~ ^[vV]?([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
log "ERROR: unable to determine npm version; no package changes were made"
return 1
fi
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
if (( major < 12 && (major != 11 || minor < 16) )); then return 0; fi
local identity="$spec" normalized=""
normalized="$(to_lowercase_ascii "$identity")"
if [[ "$normalized" == openclaw@* ]]; then identity="${identity#*@}"; normalized="$(to_lowercase_ascii "$identity")"; fi
if [[ "$normalized" == npm:* ]]; then
local alias_target="${identity#*:}"
if [[ "$alias_target" == @*/*@* ]]; then identity="${alias_target%@*}"
elif [[ "$alias_target" == *@* ]]; then identity="${alias_target%%@*}"
else identity="$alias_target"; fi
elif [[ "$identity" != *"://"* && "$identity" != /* && "$identity" != ./* && "$identity" != ../* && ! "$identity" =~ ^(file|github|git\+|npm): && ! "$identity" =~ \.(tgz|tar\.gz)$ ]]; then
identity="openclaw"
fi
if [[ "$identity" == /* ]]; then
# shellcheck disable=SC2016 # JavaScript source must not expand in the installer shell.
identity="$("$(node_bin)" -e '
const path = require("node:path");
const relative = path.relative(process.argv[1], process.argv[2]) || ".";
process.stdout.write(path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`);
' "$npm_cwd" "$identity")" || return 1
fi
[[ -n "$identity" && "$identity" != *,* ]] || { log "ERROR: npm cannot allow lifecycle scripts for ${spec}"; return 1; }
printf '%s\n' "--allow-scripts=${identity}"
}
publish_executable_wrapper() {
local target="$1" target_dir="" temp="" backup=""
target_dir="${target%/*}"
mkdir -p "$target_dir"
temp="$(mktemp "${target_dir}/.openclaw-wrapper.XXXXXX")" || return 1
TMPFILES+=("$temp")
cat > "$temp"
chmod +x "$temp"
if [[ -z "$WRAPPER_BACKUP_PATH" && ( -e "$target" || -L "$target" ) ]]; then
backup="$(mktemp "${target}.backup.XXXXXX")" || return 1
rm -f "$backup" || return 1
mv "$target" "$backup" || return 1
WRAPPER_BACKUP_TARGET="$target"
WRAPPER_BACKUP_PATH="$backup"
fi
mv -f "$temp" "$target"
}
commit_wrapper_backup() {
[[ -z "$WRAPPER_BACKUP_PATH" ]] || rm -f "$WRAPPER_BACKUP_PATH" || return 1
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
}
install_openclaw() {
local requested="${OPENCLAW_VERSION:-latest}"
if is_openclaw_source_package_install_spec "$requested"; then
@@ -1304,17 +1366,29 @@ install_openclaw() {
fi
require_openclaw_version_compatible "$resolved_requested"
fi
local install_spec="openclaw@${resolved_requested}"
if [[ "$resolved_requested" == *"://"* || "$resolved_requested" == /* || "$resolved_requested" == ./* || "$resolved_requested" == ../* || "$resolved_requested" =~ ^(file|github|git\+|npm): || "$resolved_requested" =~ \.(tgz|tar\.gz)$ ]]; then
install_spec="$resolved_requested"
fi
local npm_cmd="" lifecycle_arg=""
npm_cmd="$(npm_bin)"
local npm_cwd="$PWD"
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$install_spec" "$npm_cwd")" || return 1
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"version\":\"${requested}\"}"
log "Installing OpenClaw (${requested})..."
if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
fix_npm_prefix_if_needed
fi
local installed_entry
local installed_entry install_guard
installed_entry="$(node_dir)/lib/node_modules/openclaw/dist/entry.js"
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" || [[ ! -f "$installed_entry" ]]; then
install_guard="$(node_dir)/lib/node_modules/openclaw/dist/openclaw-install-guard"
local npm_install_args=(install -g --prefix "$(node_dir)" "${npm_args[@]}")
[[ -z "$lifecycle_arg" ]] || npm_install_args+=("$lifecycle_arg")
npm_install_args+=("$install_spec")
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; then
log "npm install openclaw@${resolved_requested} did not produce a usable package; retrying once"
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" || [[ ! -f "$installed_entry" ]]; then
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; then
emit_json '{"event":"error","message":"npm install did not produce a usable OpenClaw package"}'
log "ERROR: npm install did not produce a usable OpenClaw package"
return 1
@@ -1322,13 +1396,11 @@ install_openclaw() {
fi
mkdir -p "${PREFIX}/bin"
rm -f "${PREFIX}/bin/openclaw"
cat > "${PREFIX}/bin/openclaw" <<EOF
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "$(node_dir)/lib/node_modules/openclaw/dist/entry.js" "\$@"
EOF
chmod +x "${PREFIX}/bin/openclaw"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"version\":\"${requested}\"}"
}
@@ -1541,12 +1613,11 @@ install_openclaw_from_git() {
emit_json '{"event":"step","name":"cli-build","status":"ok"}'
mkdir -p "${PREFIX}/bin"
cat > "${PREFIX}/bin/openclaw" <<EOF
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "${repo_dir}/dist/entry.js" "\$@"
EOF
chmod +x "${PREFIX}/bin/openclaw"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"method\":\"git\"}"
}
@@ -1636,6 +1707,7 @@ main() {
[[ -z "$installed_version" ]]; then
fail "Installed OpenClaw CLI did not return a version successfully from ${PREFIX}/bin/openclaw."
fi
commit_wrapper_backup
refresh_gateway_service_if_loaded
emit_json "{\"event\":\"done\",\"ok\":true,\"version\":\"${installed_version//\"/\\\"}\"}"
+308 -28
View File
@@ -988,10 +988,11 @@ function Invoke-CommandFromWindowsSafeDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$CommandPath,
[string[]]$Arguments = @()
[string[]]$Arguments = @(),
[string]$WorkingDirectory
)
$safeDir = Get-WindowsCommandSafeDirectory
$safeDir = if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { Get-WindowsCommandSafeDirectory } else { $WorkingDirectory }
$pushedLocation = $false
try {
if (-not [string]::IsNullOrWhiteSpace($safeDir)) {
@@ -1007,8 +1008,15 @@ function Invoke-CommandFromWindowsSafeDirectory {
}
function Invoke-NpmCommand {
param([string[]]$Arguments = @())
Invoke-CommandFromWindowsSafeDirectory -CommandPath (Get-NpmCommandPath) -Arguments $Arguments
param(
[string[]]$Arguments = @(),
[string]$CommandPath,
[string]$WorkingDirectory
)
if ([string]::IsNullOrWhiteSpace($CommandPath)) {
$CommandPath = Get-NpmCommandPath
}
Invoke-CommandFromWindowsSafeDirectory -CommandPath $CommandPath -Arguments $Arguments -WorkingDirectory $WorkingDirectory
}
function Invoke-CorepackCommand {
@@ -1416,6 +1424,131 @@ function Write-NpmInstallFailureDetails {
}
}
function Resolve-NpmLifecyclePathIdentity {
param(
[string]$Identity,
[string]$NpmCwd
)
$isAbsolute = (
[System.IO.Path]::IsPathRooted($Identity) -or
$Identity -match '^[A-Za-z]:[\\/]' -or
$Identity -match '^\\\\'
)
if (-not $isAbsolute -or [string]::IsNullOrWhiteSpace($NpmCwd)) {
return $Identity
}
$separator = [System.IO.Path]::DirectorySeparatorChar
$basePath = $NpmCwd.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $separator
$baseUri = New-Object -TypeName System.Uri -ArgumentList $basePath
$targetUri = New-Object -TypeName System.Uri -ArgumentList $Identity
if (-not $baseUri.IsFile -or -not $targetUri.IsFile) {
return $Identity
}
$relative = [System.Uri]::UnescapeDataString($baseUri.MakeRelativeUri($targetUri).ToString())
$relative = $relative.Replace([char]'/', $separator)
if ($relative -ne "." -and $relative -ne ".." -and -not $relative.StartsWith("..$separator")) {
$relative = ".$separator$relative"
}
return $relative
}
function Get-NpmLifecycleAllowArgument {
param(
[string]$NpmCommand,
[string]$InstallSpec,
[string]$NpmCwd
)
$versionOutput = @(Invoke-NpmCommand -CommandPath $NpmCommand -WorkingDirectory $NpmCwd -Arguments @("--version") 2>$null)
if ($LASTEXITCODE -ne 0 -or $versionOutput.Count -eq 0) {
throw "Unable to determine npm version; no package changes were made."
}
$version = $versionOutput[-1].ToString().Trim()
if ($version -notmatch '^[vV]?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)([-+][0-9A-Za-z.-]+)?$') {
throw "Unable to determine npm version; no package changes were made."
}
$major = [int]$Matches["major"]
$minor = [int]$Matches["minor"]
if ($major -lt 12 -and ($major -ne 11 -or $minor -lt 16)) {
return $null
}
$identity = $InstallSpec.Trim()
if ($identity.StartsWith("openclaw@", [System.StringComparison]::OrdinalIgnoreCase)) {
$identity = $identity.Substring("openclaw@".Length)
}
if ($identity.StartsWith("npm:", [System.StringComparison]::OrdinalIgnoreCase)) {
$target = $identity.Substring("npm:".Length)
if ($target.StartsWith("@")) {
$slash = $target.IndexOf("/")
$versionAt = if ($slash -ge 0) { $target.IndexOf("@", $slash + 1) } else { -1 }
$identity = if ($versionAt -ge 0) { $target.Substring(0, $versionAt) } else { $target }
} else {
$versionAt = $target.IndexOf("@")
$identity = if ($versionAt -ge 0) { $target.Substring(0, $versionAt) } else { $target }
}
} elseif (
$identity -notmatch '^(https?|file|git\+|github:)' -and
$identity -notmatch '^[A-Za-z]:[\\/]' -and
$identity -notmatch '^\\\\' -and
$identity -notmatch '^\.\.?[\\/]' -and
$identity -notmatch '\.(tgz|tar\.gz)$'
) {
$identity = "openclaw"
}
if ($identity -match '^/' -or $identity -match '^[A-Za-z]:[\\/]' -or $identity -match '^\\\\') {
$identity = Resolve-NpmLifecyclePathIdentity -Identity $identity -NpmCwd $NpmCwd
}
if ([string]::IsNullOrWhiteSpace($identity) -or $identity.Contains(",")) {
throw "npm cannot allow lifecycle scripts for install target '$InstallSpec'."
}
return "--allow-scripts=$identity"
}
function Test-NpmLifecycleCompleted {
param(
[string]$NpmCommand,
[string]$NpmCwd
)
$rootOutput = @(Invoke-NpmCommand -CommandPath $NpmCommand -WorkingDirectory $NpmCwd -Arguments @("root", "-g") 2>$null)
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -eq 0) {
return $false
}
$npmRoot = $rootOutput[-1].ToString().Trim()
if ([string]::IsNullOrWhiteSpace($npmRoot)) {
return $false
}
$entryPath = Join-Path $npmRoot "openclaw\dist\entry.js"
$guardPath = Join-Path $npmRoot "openclaw\dist\openclaw-install-guard"
return (Test-Path -LiteralPath $entryPath -PathType Leaf) -and -not (Test-Path -LiteralPath $guardPath)
}
function Format-OpenClawGitWrapper {
param([string]$EntryPath)
return "@echo off`r`nnode `"$EntryPath`" %*`r`n"
}
function Publish-TextFileAtomically {
param(
[string]$Path,
[string]$Contents
)
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$temporaryPath = Join-Path $directory (".openclaw-wrapper-" + [guid]::NewGuid().ToString("N") + ".cmd")
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($temporaryPath, $Contents, $encoding)
try {
if (Test-Path -LiteralPath $Path) {
[System.IO.File]::Replace($temporaryPath, $Path, $null)
} else {
[System.IO.File]::Move($temporaryPath, $Path)
}
} finally {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
}
}
}
function Install-OpenClaw {
if ([string]::IsNullOrWhiteSpace($Tag)) {
$Tag = "latest"
@@ -1435,14 +1568,17 @@ function Install-OpenClaw {
$packageName = "openclaw"
}
$installSpec = Resolve-NpmOpenClawInstallSpec -PackageName $packageName -RequestedTag $Tag
$npmCommand = Get-NpmCommandPath
$npmCwd = Get-WindowsCommandSafeDirectory
$lifecycleArgument = Get-NpmLifecycleAllowArgument -NpmCommand $npmCommand -InstallSpec $installSpec -NpmCwd $npmCwd
Write-Host "[*] Installing OpenClaw ($installSpec)..." -ForegroundColor Yellow
$freshnessArgs = @("--min-release-age=0")
$minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global") 2>$null)
$minReleaseAge = (Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("config", "get", "min-release-age", "--global") 2>$null)
$minReleaseAgeStatus = $LASTEXITCODE
if (Test-NpmConfigRawKey -Key "min-release-age") {
$freshnessArgs = @("--min-release-age=0")
} elseif ($minReleaseAgeStatus -ne 0 -or -not $minReleaseAge -or $minReleaseAge.Trim() -eq "null" -or $minReleaseAge.Trim() -eq "undefined") {
$beforeValue = (Invoke-NpmCommand -Arguments @("config", "get", "before", "--global") 2>$null)
$beforeValue = (Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("config", "get", "before", "--global") 2>$null)
if ($LASTEXITCODE -eq 0 -and $beforeValue -and $beforeValue.Trim() -ne "null" -and $beforeValue.Trim() -ne "undefined") {
$freshnessArgs = @("--before=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))")
}
@@ -1462,12 +1598,13 @@ function Install-OpenClaw {
try {
# Resolve cache roots before the install so failure reporting cannot create a newer npm log.
$npmDebugLogRoots = @(Get-NpmDebugLogRootCandidates)
$npmInstallArguments = @("install", "-g") + $freshnessArgs + @("$installSpec")
$npmOutput = Invoke-NpmCommand -Arguments $npmInstallArguments 2>&1
$lifecycleArguments = if ($lifecycleArgument) { @($lifecycleArgument) } else { @() }
$npmInstallArguments = @("install", "-g") + $freshnessArgs + $lifecycleArguments + @("$installSpec")
$npmOutput = Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments $npmInstallArguments 2>&1
$npmInstallStatus = $LASTEXITCODE
if ($npmInstallStatus -ne 0) {
Write-Host "[!] npm install failed; retrying once" -ForegroundColor Yellow
$npmOutput = Invoke-NpmCommand -Arguments $npmInstallArguments 2>&1
$npmOutput = Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments $npmInstallArguments 2>&1
$npmInstallStatus = $LASTEXITCODE
}
if ($npmInstallStatus -ne 0) {
@@ -1483,6 +1620,10 @@ function Install-OpenClaw {
Write-NpmInstallFailureDetails -Output $npmOutput -CacheRoots $npmDebugLogRoots
return $false
}
if (-not (Test-NpmLifecycleCompleted -NpmCommand $npmCommand -NpmCwd $npmCwd)) {
Write-Host "[!] npm install did not produce a usable OpenClaw package; lifecycle scripts may not have completed." -ForegroundColor Red
return $false
}
} finally {
$env:NPM_CONFIG_LOGLEVEL = $prevLogLevel
$env:NPM_CONFIG_UPDATE_NOTIFIER = $prevUpdateNotifier
@@ -1713,9 +1854,14 @@ function Install-OpenClawFromGit {
if (-not (Test-Path $binDir)) {
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
}
node $entryPath --version 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "[!] Git replacement failed CLI verification" -ForegroundColor Red
return $false
}
$cmdPath = Join-Path $binDir "openclaw.cmd"
$cmdContents = "@echo off`r`nnode ""$entryPath"" %*`r`n"
Set-Content -Path $cmdPath -Value $cmdContents -NoNewline
$cmdContents = Format-OpenClawGitWrapper -EntryPath $entryPath
Publish-TextFileAtomically -Path $cmdPath -Contents $cmdContents
if (Add-ToUserPath $binDir) {
Write-Host "[!] Added $binDir to user PATH (restart terminal if command not found)" -ForegroundColor Yellow
@@ -1730,10 +1876,12 @@ function Install-OpenClawFromGit {
function Run-Doctor {
Write-Host "[*] Running doctor to migrate settings..." -ForegroundColor Yellow
try {
Invoke-OpenClawCommand doctor --non-interactive
Invoke-OpenClawCommand doctor --fix --non-interactive
Write-Host "[OK] Migration complete" -ForegroundColor Green
return $true
} catch {
Write-Host "[!] Migration failed; continuing. Run: openclaw doctor --non-interactive" -ForegroundColor Yellow
Write-Host "[!] Migration failed: $($_.Exception.Message)" -ForegroundColor Red
return $false
}
}
@@ -1800,6 +1948,86 @@ function Remove-LegacySubmodule {
}
}
function Test-PreviousGitWrapper {
$wrapper = Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd"
if (-not (Test-Path -LiteralPath $wrapper -PathType Leaf)) { return $false }
return ([System.IO.File]::ReadAllText($wrapper) -match '^@echo off\r?\nnode ".+[\\/]dist[\\/]entry\.js" %\*\r?\n?$')
}
function Remove-PreviousGitWrapper {
if (Test-PreviousGitWrapper) {
$wrapper = Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd"
Remove-Item -LiteralPath $wrapper -Force
Write-Host "[OK] Previous git wrapper retired" -ForegroundColor Green
}
}
function Remove-PreviousNpmOwner {
param([string]$GitWrapper)
$npmCommand = Get-NpmCommandPath
$rootOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("root", "-g") 2>$null)
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -eq 0) { throw "Could not resolve the previous npm owner." }
$packageRoot = Join-Path $rootOutput[-1].ToString().Trim() "openclaw"
$packageJson = Join-Path $packageRoot "package.json"
if (-not (Test-Path -LiteralPath $packageJson)) { return }
$package = Get-Content -LiteralPath $packageJson -Raw | ConvertFrom-Json
if ($package.name -ne "openclaw") { throw "Refusing to retire a package whose identity is not openclaw." }
$prefixOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("config", "get", "prefix") 2>$null)
$npmShim = if ($prefixOutput.Count -gt 0) { Join-Path $prefixOutput[-1].ToString().Trim() "openclaw.cmd" } else { $null }
if ($npmShim -and [System.IO.Path]::GetFullPath($npmShim) -eq [System.IO.Path]::GetFullPath($GitWrapper)) {
Remove-Item -LiteralPath $packageRoot -Recurse -Force
} else {
Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("uninstall", "-g", "openclaw") | Out-Null
if ($LASTEXITCODE -ne 0) { throw "npm could not retire the previous OpenClaw package." }
}
Write-Host "[OK] Previous npm install retired" -ForegroundColor Green
}
function Start-NpmShimBackup {
param(
[string]$Path,
[string]$ExpectedLauncher
)
$backupPath = Join-Path (Split-Path -Parent $Path) (".openclaw-shim-backup-" + [guid]::NewGuid().ToString("N"))
[System.IO.File]::Move($Path, $backupPath)
return [pscustomobject]@{ Path = $Path; BackupPath = $backupPath; ExpectedLauncher = $ExpectedLauncher }
}
function Test-NpmOpenClawCmdShim {
param(
[string]$Path,
[string]$ExpectedLauncher
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
$item = Get-Item -LiteralPath $Path -Force
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $item.Length -gt 16384) { return $false }
$contents = [System.IO.File]::ReadAllText($Path)
if (-not $contents.StartsWith("@ECHO off`r`nGOTO start`r`n", [System.StringComparison]::Ordinal)) { return $false }
$targetMatch = [regex]::Match($contents, '"%(?:~dp0|dp0%)\\(?<target>[^"]+?)"\s+%\*')
if (-not $targetMatch.Success) { return $false }
$resolvedTarget = [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $Path) $targetMatch.Groups["target"].Value))
return [string]::Equals($resolvedTarget, [System.IO.Path]::GetFullPath($ExpectedLauncher), [System.StringComparison]::OrdinalIgnoreCase)
}
function Restore-NpmShimBackup {
param([object]$Backup)
if (-not $Backup -or -not (Test-Path -LiteralPath $Backup.BackupPath -PathType Leaf)) { return }
if (Test-Path -LiteralPath $Backup.Path) {
if (-not (Test-NpmOpenClawCmdShim -Path $Backup.Path -ExpectedLauncher $Backup.ExpectedLauncher)) {
throw "Refusing to replace an unrelated file while restoring $($Backup.Path)."
}
Remove-Item -LiteralPath $Backup.Path -Force
}
[System.IO.File]::Move($Backup.BackupPath, $Backup.Path)
}
function Complete-NpmShimBackup {
param([object]$Backup)
if ($Backup -and (Test-Path -LiteralPath $Backup.BackupPath -PathType Leaf)) {
Remove-Item -LiteralPath $Backup.BackupPath -Force
}
}
# Main installation flow
function Main {
if ($InstallMethod -ne "npm" -and $InstallMethod -ne "git") {
@@ -1849,11 +2077,12 @@ function Main {
# Step 2: OpenClaw
if ($InstallMethod -eq "git") {
$hadNpmOwner = $false
try {
$npmCommand = Get-NpmCommandPath
if ($npmCommand) {
Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw") 2>$null | Out-Null
Write-Host "[OK] Removed npm global install if present" -ForegroundColor Green
Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("list", "-g", "openclaw") 2>$null | Out-Null
$hadNpmOwner = ($LASTEXITCODE -eq 0)
}
} catch { }
$finalGitDir = $GitDir
@@ -1862,16 +2091,65 @@ function Main {
Fail-Install
return
}
} else {
$gitWrapper = Join-Path (Join-Path $env:USERPROFILE ".local\\bin") "openclaw.cmd"
if (Test-Path $gitWrapper) {
Remove-Item -Force $gitWrapper
Write-Host "[OK] Removed git wrapper (switching to npm)" -ForegroundColor Green
if ($hadNpmOwner) {
Remove-PreviousNpmOwner -GitWrapper (Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd")
}
$npmInstallResults = @(Install-OpenClaw)
if (-not (Test-BooleanSuccessResult -Results $npmInstallResults)) {
Fail-Install
return
} else {
$hadGitWrapper = Test-PreviousGitWrapper
$npmShimBackup = $null
try {
$npmCommand = Get-NpmCommandPath
$npmCwd = Get-WindowsCommandSafeDirectory
$prefixOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("config", "get", "prefix") 2>$null)
$npmPrefix = if ($prefixOutput.Count -gt 0) { $prefixOutput[-1].ToString().Trim() } else { $null }
$previousGitWrapper = Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd"
if ($hadGitWrapper) {
foreach ($npmBin in (Get-NpmGlobalBinCandidates -NpmPrefix $npmPrefix)) {
$candidate = Join-Path $npmBin "openclaw.cmd"
if ([string]::Equals([System.IO.Path]::GetFullPath($candidate), [System.IO.Path]::GetFullPath($previousGitWrapper), [System.StringComparison]::OrdinalIgnoreCase)) {
$rootOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("root", "-g") 2>$null)
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -eq 0) {
Fail-Install
return
}
$expectedNpmLauncher = Join-Path $rootOutput[-1].ToString().Trim() "openclaw\openclaw.mjs"
$npmShimBackup = Start-NpmShimBackup -Path $previousGitWrapper -ExpectedLauncher $expectedNpmLauncher
break
}
}
}
$npmInstallResults = @(Install-OpenClaw)
if (-not (Test-BooleanSuccessResult -Results $npmInstallResults)) {
Fail-Install
return
}
if ($hadGitWrapper) {
$npmCandidate = if ($npmShimBackup) { $npmShimBackup.Path } else {
$candidatePath = $null
foreach ($npmBin in (Get-NpmGlobalBinCandidates -NpmPrefix $npmPrefix)) {
$candidate = Join-Path $npmBin "openclaw.cmd"
if (Test-Path -LiteralPath $candidate -PathType Leaf) { $candidatePath = $candidate; break }
}
$candidatePath
}
if (-not $npmCandidate -or -not (Test-Path -LiteralPath $npmCandidate -PathType Leaf)) {
Fail-Install
return
}
& $npmCandidate --version 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail-Install
return
}
Complete-NpmShimBackup -Backup $npmShimBackup
$npmShimBackup = $null
Remove-PreviousGitWrapper
}
} finally {
if ($npmShimBackup) {
Restore-NpmShimBackup -Backup $npmShimBackup
}
}
}
@@ -1885,7 +2163,11 @@ function Main {
# Step 3: Run doctor for migrations if upgrading or git install
if ($isUpgrade -or $InstallMethod -eq "git") {
Run-Doctor
$doctorResults = @(Run-Doctor)
if (-not (Test-BooleanSuccessResult -Results $doctorResults)) {
Fail-Install
return
}
}
$installedVersion = $null
@@ -1961,9 +2243,7 @@ function Main {
}
if ($isUpgrade) {
Write-Host "Upgrade complete. Run " -NoNewline
Write-Host "openclaw doctor" -ForegroundColor Cyan -NoNewline
Write-Host " to check for additional migrations."
Write-Host "Upgrade complete." -ForegroundColor Green
} else {
if ($NoOnboard) {
Write-Host "Skipping onboard (requested). Run " -NoNewline
+289 -126
View File
@@ -32,7 +32,14 @@ NODE_SUPPORTED_VERSION_LABEL="22.22.3+, 24.15.0+, or 25.9.0+"
ORIGINAL_PATH="${PATH:-}"
TMPFILES=()
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
cleanup_tmpfiles() {
if [[ "$(type -t restore_openclaw_bin_backup 2>/dev/null || true)" == "function" ]]; then
restore_openclaw_bin_backup || true
fi
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
@@ -697,13 +704,62 @@ cleanup_legacy_submodules() {
fi
}
cleanup_npm_openclaw_paths() {
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
if [[ -z "$npm_root" || "$npm_root" != *node_modules* ]]; then
begin_openclaw_bin_backup() {
local target="$1" candidate="$2" discard="${3:-0}" backup=""
[[ -z "$OPENCLAW_BIN_BACKUP_PATH" ]] || return 0
[[ -e "$target" || -L "$target" ]] || return 0
backup="$(mktemp "${target}.openclaw-backup.XXXXXX")" || return 1
rm -f "$backup" || return 1
OPENCLAW_BIN_BACKUP_TARGET="$target"
OPENCLAW_BIN_BACKUP_PATH="$backup"
OPENCLAW_BIN_BACKUP_CANDIDATE="$candidate"
OPENCLAW_BIN_BACKUP_DISCARD="$discard"
if ! mv "$target" "$backup"; then
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
return 1
fi
rm -rf "$npm_root"/.openclaw-* "$npm_root"/openclaw 2>/dev/null || true
}
is_npm_openclaw_shim() {
local target="$1" launcher="$2"
if [[ -L "$target" ]]; then
local link_target=""
link_target="$(readlink "$target" 2>/dev/null || true)"
[[ "$link_target" == "$launcher" || "$link_target" == *"/node_modules/openclaw/openclaw.mjs" ]]
return
fi
[[ -f "$target" ]] && grep -Fq "/node_modules/openclaw/openclaw.mjs" "$target"
}
restore_openclaw_bin_backup() {
local target="$OPENCLAW_BIN_BACKUP_TARGET" backup="$OPENCLAW_BIN_BACKUP_PATH"
[[ -n "$backup" && ( -e "$backup" || -L "$backup" ) ]] || return 0
if [[ -e "$target" || -L "$target" ]]; then
is_npm_openclaw_shim "$target" "$OPENCLAW_BIN_BACKUP_CANDIDATE" || return 1
rm -f "$target" || return 1
fi
mv "$backup" "$target" || return 1
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
}
commit_openclaw_bin_backup() {
local backup="$OPENCLAW_BIN_BACKUP_PATH"
[[ -n "$backup" ]] || return 0
if [[ "$OPENCLAW_BIN_BACKUP_DISCARD" == "1" ]]; then
rm -f "$backup" || return 1
else
ui_info "Preserved previous openclaw command at ${backup}"
fi
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
}
extract_openclaw_conflict_path() {
@@ -736,23 +792,23 @@ cleanup_openclaw_bin_conflict() {
;;
esac
fi
if [[ -L "$bin_path" ]]; then
local target=""
target="$(readlink "$bin_path" 2>/dev/null || true)"
if [[ "$target" == *"/node_modules/openclaw/"* ]]; then
rm -f "$bin_path"
ui_info "Removed stale openclaw symlink at ${bin_path}"
return 0
fi
return 1
fi
local backup=""
backup="${bin_path}.bak-$(date +%Y%m%d-%H%M%S)"
if mv "$bin_path" "$backup"; then
ui_info "Moved existing openclaw binary to ${backup}"
return 0
fi
return 1
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
[[ -n "$npm_root" ]] || return 1
begin_openclaw_bin_backup "$bin_path" "${npm_root%/}/openclaw/openclaw.mjs" 0 || return 1
ui_info "Moved existing openclaw command aside for npm retry"
}
cleanup_npm_stale_rename_dirs() {
local npm_root="" stale="" found=0
npm_root="$(npm root -g 2>/dev/null || true)"
[[ -n "$npm_root" && "$npm_root" == *node_modules* ]] || return 1
for stale in "$npm_root"/.openclaw-*; do
[[ -d "$stale" && ! -L "$stale" ]] || continue
found=1
rm -rf "$stale" || return 1
done
(( found == 0 )) || ui_info "Removed interrupted npm rename directories"
}
npm_log_indicates_missing_build_tools() {
@@ -982,49 +1038,109 @@ npm_config_has_raw_key() {
return 1
}
npm_lifecycle_allow_arg() {
local npm_cmd="$1" spec="$2" npm_cwd="${3:-$PWD}" version=""
version="$("$npm_cmd" --version 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
if [[ ! "$version" =~ ^[vV]?([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "Unable to determine npm version from ${npm_cmd}; no package changes were made." >&2
return 1
fi
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
if (( major < 12 && (major != 11 || minor < 16) )); then
return 0
fi
local identity="$spec" normalized=""
normalized="$(to_lowercase_ascii "$identity")"
if [[ "$normalized" == openclaw@* ]]; then
identity="${identity#*@}"
normalized="$(to_lowercase_ascii "$identity")"
fi
if [[ "$normalized" == npm:* ]]; then
local alias_target="${identity#*:}"
if [[ "$alias_target" == @*/*@* ]]; then identity="${alias_target%@*}"
elif [[ "$alias_target" == *@* ]]; then identity="${alias_target%%@*}"
else identity="$alias_target"; fi
elif ! is_explicit_package_install_spec "$identity" && [[ "$identity" != /* && "$identity" != ./* && "$identity" != ../* && ! "$identity" =~ \.(tgz|tar\.gz)$ ]]; then
identity="openclaw"
fi
if [[ "$identity" == /* ]]; then
# shellcheck disable=SC2016 # JavaScript source must not expand in the installer shell.
identity="$(node -e '
const path = require("node:path");
const relative = path.relative(process.argv[1], process.argv[2]) || ".";
process.stdout.write(path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`);
' "$npm_cwd" "$identity")" || return 1
fi
if [[ -z "$identity" || "$identity" == *,* ]]; then
echo "npm cannot allow lifecycle scripts for install target: ${spec}" >&2
return 1
fi
printf '%s\n' "--allow-scripts=${identity}"
}
verify_npm_lifecycle_completed() {
local npm_cmd="$1" npm_root=""
npm_root="$("$npm_cmd" root -g 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
[[ -n "$npm_root" ]] || { echo "Unable to resolve npm global root after install." >&2; return 1; }
[[ ! -e "${npm_root%/}/openclaw/dist/openclaw-install-guard" ]] || {
echo "OpenClaw lifecycle scripts did not complete; refusing installer success." >&2
return 1
}
}
run_npm_global_install() {
local spec="$1"
local log="$2"
local npm_cmd="" lifecycle_arg=""
npm_cmd="$(npm_command_path npm)" || { echo "npm not found on PATH; no package changes were made." >&2; return 1; }
local npm_cwd="$PWD"
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$spec" "$npm_cwd")" || return 1
local freshness_flag="--min-release-age=0"
local min_release_age=""
min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before npm config get min-release-age --global 2>/dev/null || true)"
if npm_config_has_raw_key npm "min-release-age"; then
min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$npm_cmd" config get min-release-age --global 2>/dev/null || true)"
if npm_config_has_raw_key "$npm_cmd" "min-release-age"; then
freshness_flag="--min-release-age=0"
elif [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then
local before_value=""
before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age npm config get before --global 2>/dev/null || true)"
before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" config get before --global 2>/dev/null || true)"
if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then
freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')"
fi
fi
local -a cmd
cmd=(env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age npm --loglevel "$NPM_LOGLEVEL")
cmd=(env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" --loglevel "$NPM_LOGLEVEL")
if [[ -n "$NPM_SILENT_FLAG" ]]; then
cmd+=("$NPM_SILENT_FLAG")
fi
cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")
cmd+=(--no-fund --no-audit "$freshness_flag" install -g)
[[ -z "$lifecycle_arg" ]] || cmd+=("$lifecycle_arg")
cmd+=("$spec")
local cmd_display=""
printf -v cmd_display '%q ' "${cmd[@]}"
LAST_NPM_INSTALL_CMD="${cmd_display% }"
local install_status=0
if [[ "$VERBOSE" == "1" ]]; then
"${cmd[@]}" < /dev/null 2>&1 | tee "$log"
return $?
fi
if [[ -n "$GUM" ]] && gum_is_tty; then
"${cmd[@]}" < /dev/null 2>&1 | tee "$log" || install_status=$?
elif [[ -n "$GUM" ]] && gum_is_tty; then
local cmd_quoted=""
local log_quoted=""
printf -v cmd_quoted '%q ' "${cmd[@]}"
printf -v log_quoted '%q' "$log"
run_with_spinner "Installing OpenClaw package" bash -c "${cmd_quoted}>${log_quoted} 2>&1"
return $?
run_with_spinner "Installing OpenClaw package" bash -c "${cmd_quoted}>${log_quoted} 2>&1" || install_status=$?
else
ui_info "Installing OpenClaw package"
"${cmd[@]}" < /dev/null >"$log" 2>&1 || install_status=$?
fi
(( install_status == 0 )) || return "$install_status"
}
ui_info "Installing OpenClaw package"
"${cmd[@]}" < /dev/null >"$log" 2>&1
run_verified_npm_global_install() {
local npm_cmd=""
npm_cmd="$(npm_command_path npm)" || return 1
run_npm_global_install "$1" "$2" && verify_npm_lifecycle_completed "$npm_cmd"
}
extract_npm_debug_log_path() {
@@ -1110,12 +1226,12 @@ install_openclaw_npm() {
local spec="$1"
local log
mktempfile log
if ! run_npm_global_install "$spec" "$log"; then
if ! run_verified_npm_global_install "$spec" "$log"; then
local attempted_build_tool_fix=false
if auto_install_build_tools_for_npm_failure "$log"; then
attempted_build_tool_fix=true
ui_info "Retrying npm install after build tools setup"
if run_npm_global_install "$spec" "$log"; then
if run_verified_npm_global_install "$spec" "$log"; then
ui_success "OpenClaw npm package installed"
return 0
fi
@@ -1134,8 +1250,8 @@ install_openclaw_npm() {
if grep -q "ENOTEMPTY: directory not empty, rename .*openclaw" "$log"; then
ui_warn "npm left stale directory; cleaning and retrying"
cleanup_npm_openclaw_paths
if run_npm_global_install "$spec" "$log"; then
cleanup_npm_stale_rename_dirs || return 1
if run_verified_npm_global_install "$spec" "$log"; then
ui_success "OpenClaw npm package installed"
return 0
fi
@@ -1145,7 +1261,7 @@ install_openclaw_npm() {
local conflict=""
conflict="$(extract_openclaw_conflict_path "$log" || true)"
if [[ -n "$conflict" ]] && cleanup_openclaw_bin_conflict "$conflict"; then
if run_npm_global_install "$spec" "$log"; then
if run_verified_npm_global_install "$spec" "$log"; then
ui_success "OpenClaw npm package installed"
return 0
fi
@@ -2323,7 +2439,7 @@ ensure_openclaw_bin_link() {
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
local launcher="${npm_root}/openclaw/openclaw.mjs"
if [[ -z "$npm_root" || ! -x "$launcher" ]]; then
if [[ -z "$npm_root" || ! -x "$launcher" ]] || ! "$launcher" --version >/dev/null 2>&1; then
return 1
fi
local npm_bin=""
@@ -2331,12 +2447,18 @@ ensure_openclaw_bin_link() {
if [[ -z "$npm_bin" ]]; then
return 1
fi
mkdir -p "$npm_bin"
if [[ ! -x "${npm_bin}/openclaw" ]]; then
ln -sf "$launcher" "${npm_bin}/openclaw"
ui_info "Created openclaw bin link at ${npm_bin}/openclaw"
mkdir -p "$npm_bin" || return 1
local target="${npm_bin}/openclaw" temp=""
if [[ -e "$target" || -L "$target" ]]; then
is_npm_openclaw_shim "$target" "$launcher" || return 1
fi
"${npm_bin}/openclaw" --version >/dev/null 2>&1
temp="$(mktemp "${npm_bin}/.openclaw-link.XXXXXX")" || return 1
TMPFILES+=("$temp")
rm -f "$temp" || return 1
ln -s "$launcher" "$temp" || return 1
mv -f "$temp" "$target" || return 1
ui_info "Published openclaw bin link at ${target}"
"$target" --version >/dev/null 2>&1
}
# Check for existing OpenClaw installation
@@ -2711,8 +2833,8 @@ ensure_user_local_bin_on_path() {
}
npm_global_bin_dir() {
local prefix=""
prefix="$(bounded_probe_output "npm prefix -g" npm prefix -g || true)"
local npm_cmd="${1:-npm}" prefix=""
prefix="$(bounded_probe_output "npm prefix -g" "$npm_cmd" prefix -g || true)"
if [[ -n "$prefix" ]]; then
if [[ "$prefix" == /* ]]; then
echo "${prefix%/}/bin"
@@ -2720,7 +2842,7 @@ npm_global_bin_dir() {
fi
fi
prefix="$(bounded_probe_output "npm config get prefix" npm config get prefix || true)"
prefix="$(bounded_probe_output "npm config get prefix" "$npm_cmd" config get prefix || true)"
if [[ -n "$prefix" && "$prefix" != "undefined" && "$prefix" != "null" ]]; then
if [[ "$prefix" == /* ]]; then
echo "${prefix%/}/bin"
@@ -3093,6 +3215,17 @@ resolve_installed_openclaw_bin() {
resolve_openclaw_bin
}
publish_executable_wrapper() {
local target="$1" target_dir="" temp=""
target_dir="${target%/*}"
mkdir -p "$target_dir"
temp="$(mktemp "${target_dir}/.openclaw-wrapper.XXXXXX")" || return 1
TMPFILES+=("$temp")
cat > "$temp"
chmod +x "$temp"
mv -f "$temp" "$target"
}
install_openclaw_from_git() {
local repo_dir="$1"
local repo_url="https://github.com/openclaw/openclaw.git"
@@ -3162,15 +3295,18 @@ install_openclaw_from_git() {
ui_error "Node.js runtime not found after build"
return 1
fi
if ! "$node_bin" "${repo_dir}/dist/entry.js" --version >/dev/null 2>&1; then
ui_error "Git replacement failed CLI verification"
return 1
fi
printf -v node_bin_quoted "%q" "$node_bin"
printf -v entry_path_quoted "%q" "${repo_dir}/dist/entry.js"
cat > "$HOME/.local/bin/openclaw" <<EOF
publish_executable_wrapper "$HOME/.local/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec ${node_bin_quoted} ${entry_path_quoted} "\$@"
EOF
chmod +x "$HOME/.local/bin/openclaw"
ui_success "OpenClaw wrapper installed to \$HOME/.local/bin/openclaw"
ui_info "This checkout uses pnpm — run pnpm install (or corepack pnpm install) for deps"
}
@@ -3192,7 +3328,7 @@ to_lowercase_ascii() {
is_explicit_package_install_spec() {
local value="${1:-}"
[[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]]
[[ "$value" == *"://"* || "$value" == *"#"* || "$value" == /* || "$value" == ./* || "$value" == ../* || "$value" =~ \.(tgz|tar\.gz)$ || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]]
}
is_openclaw_source_package_install_spec() {
@@ -3287,13 +3423,18 @@ install_openclaw() {
if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then
ui_warn "npm install did not produce a usable OpenClaw package; retrying"
cleanup_npm_openclaw_paths
if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then
ui_error "npm install did not produce a usable OpenClaw package"
restore_openclaw_bin_backup || ui_error "Could not restore the previous openclaw command"
return 1
fi
fi
if ! commit_openclaw_bin_backup; then
restore_openclaw_bin_backup || true
return 1
fi
ui_success "OpenClaw installed"
}
@@ -3310,7 +3451,7 @@ run_doctor() {
return 0
fi
local doctor_exit=0
run_quiet_step "Running doctor" "$claw" doctor --non-interactive || doctor_exit=$?
run_quiet_step "Running doctor" "$claw" doctor --fix --non-interactive || doctor_exit=$?
if (( doctor_exit == 130 )); then
abort_install_int
fi
@@ -3515,6 +3656,51 @@ verify_installation() {
ui_success "Install verify complete"
}
retire_npm_owner_after_git_install() {
local wrapper="$HOME/.local/bin/openclaw" npm_cmd="" npm_root="" npm_bin="" package_root="" package_name=""
npm_cmd="$(npm_command_path npm)" || return 1
npm_root="$("$npm_cmd" root -g 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
package_root="${npm_root%/}/openclaw"
[[ -n "$npm_root" && -f "$package_root/package.json" ]] || return 0
package_name="$(node -e 'const p=require(process.argv[1]); process.stdout.write(String(p.name || ""))' "$package_root/package.json" 2>/dev/null || true)"
[[ "$package_name" == "openclaw" ]] || return 1
npm_bin="$(npm_global_bin_dir "$npm_cmd" || true)"
if [[ "${npm_bin%/}/openclaw" == "$wrapper" ]]; then
rm -rf "$package_root" || return 1
else
"$npm_cmd" uninstall -g openclaw >/dev/null 2>&1 || return 1
fi
ui_success "Previous npm install retired"
}
is_installer_git_wrapper() {
local wrapper="${1:-$HOME/.local/bin/openclaw}" first="" second="" third="" fourth=""
[[ -f "$wrapper" && ! -L "$wrapper" ]] || return 1
IFS= read -r first < "$wrapper" || return 1
second="$(sed -n '2p' "$wrapper")"; third="$(sed -n '3p' "$wrapper")"; fourth="$(sed -n '4p' "$wrapper")"
[[ "$first" == "#!/usr/bin/env bash" && "$second" == "set -euo pipefail" && -z "$fourth" ]] || return 1
case "$third" in "exec "*"/dist/entry.js \"\$@\"") return 0 ;; *) return 1 ;; esac
}
prepare_git_wrapper_backup_for_npm() {
local npm_cmd="" npm_root="" npm_bin="" target="" launcher=""
npm_cmd="$(npm_command_path npm)" || return 1
npm_root="$("$npm_cmd" root -g 2>/dev/null || true)"
npm_bin="$(npm_global_bin_dir "$npm_cmd" || true)"
[[ -n "$npm_root" && -n "$npm_bin" ]] || return 1
target="${npm_bin%/}/openclaw"
is_installer_git_wrapper "$target" || return 0
launcher="${npm_root%/}/openclaw/openclaw.mjs"
begin_openclaw_bin_backup "$target" "$launcher" 1
}
retire_git_wrapper_after_npm_install() {
local wrapper="$HOME/.local/bin/openclaw"
is_installer_git_wrapper "$wrapper" || return 0
rm -f "$wrapper" || return 1
ui_success "Previous git wrapper retired"
}
# Main installation flow
main() {
if [[ "$HELP" == "1" ]]; then
@@ -3599,11 +3785,9 @@ main() {
local final_git_dir=""
if [[ "$INSTALL_METHOD" == "git" ]]; then
# Clean up npm global install if switching to git
local had_npm_owner=false
if npm list -g openclaw &>/dev/null; then
ui_info "Removing npm global install (switching to git)"
npm uninstall -g openclaw 2>/dev/null || true
ui_success "npm global install removed"
had_npm_owner=true
fi
local repo_dir="$GIT_DIR"
@@ -3612,14 +3796,10 @@ main() {
fi
final_git_dir="$repo_dir"
install_openclaw_from_git "$repo_dir"
else
# Clean up git wrapper if switching to npm
if [[ -x "$HOME/.local/bin/openclaw" ]]; then
ui_info "Removing git wrapper (switching to npm)"
rm -f "$HOME/.local/bin/openclaw"
ui_success "git wrapper removed"
if [[ "$had_npm_owner" == "true" ]]; then
retire_npm_owner_after_git_install || return $?
fi
else
# Step 3: Git (required for npm installs that may fetch from git or apply patches)
if ! check_git; then
install_git
@@ -3629,7 +3809,15 @@ main() {
fix_npm_permissions
# Step 5: OpenClaw
prepare_git_wrapper_backup_for_npm || return $?
install_openclaw
local npm_candidate=""
npm_candidate="$(resolve_installed_openclaw_bin || true)"
if [[ -z "$npm_candidate" ]] || ! "$npm_candidate" --version >/dev/null 2>&1; then
ui_error "npm replacement failed verification"
return 1
fi
retire_git_wrapper_after_npm_install || return $?
fi
ui_stage "Finalizing setup"
@@ -3655,42 +3843,15 @@ main() {
refresh_gateway_service_if_loaded
fi
local installed_version
installed_version=$(resolve_openclaw_version)
echo ""
if [[ -n "$installed_version" ]]; then
ui_celebrate "🦞 OpenClaw installed successfully (${installed_version})!"
else
ui_celebrate "🦞 OpenClaw installed successfully!"
fi
if [[ "$is_upgrade" == "true" ]]; then
local update_messages=(
"Leveled up! New skills unlocked. You're welcome."
"Fresh code, same lobster. Miss me?"
"Back and better. Did you even notice I was gone?"
"Update complete. I learned some new tricks while I was out."
"Upgraded! Now with 23% more sass."
"I've evolved. Try to keep up. 🦞"
"New version, who dis? Oh right, still me but shinier."
"Patched, polished, and ready to pinch. Let's go."
"The lobster has molted. Harder shell, sharper claws."
"Update done! Check the changelog or just trust me, it's good."
"Reborn from the boiling waters of npm. Stronger now."
"I went away and came back smarter. You should try it sometime."
"Update complete. The bugs feared me, so they left."
"New version installed. Old version sends its regards."
"Firmware fresh. Brain wrinkles: increased."
"I've seen things you wouldn't believe. Anyway, I'm updated."
"Back online. The changelog is long but our friendship is longer."
"Upgraded! Peter fixed stuff. Blame him if it breaks."
"Molting complete. Please don't look at my soft shell phase."
"Version bump! Same chaos energy, fewer crashes (probably)."
)
local update_message
update_message="${update_messages[RANDOM % ${#update_messages[@]}]}"
echo -e "${MUTED}${update_message}${NC}"
else
local installed_version=""
if [[ "$is_upgrade" != "true" ]]; then
installed_version="$(resolve_openclaw_version)"
echo ""
if [[ -n "$installed_version" ]]; then
ui_celebrate "🦞 OpenClaw installed successfully (${installed_version})!"
else
ui_celebrate "🦞 OpenClaw installed successfully!"
fi
local completion_messages=(
"Ahh nice, I like it here. Got any snacks? "
"Home sweet home. Don't worry, I won't rearrange the furniture."
@@ -3706,8 +3867,8 @@ main() {
local completion_message
completion_message="${completion_messages[RANDOM % ${#completion_messages[@]}]}"
echo -e "${MUTED}${completion_message}${NC}"
echo ""
fi
echo ""
if [[ "$INSTALL_METHOD" == "git" && -n "$final_git_dir" ]]; then
local user_claw
@@ -3745,7 +3906,6 @@ main() {
ui_info "No TTY; run ${user_claw} onboard to finish setup"
fi
elif [[ "$is_upgrade" == "true" ]]; then
ui_info "Upgrade complete"
if has_controlling_tty || [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
local claw="${OPENCLAW_BIN:-}"
if [[ -z "$claw" ]]; then
@@ -3756,49 +3916,38 @@ main() {
warn_openclaw_not_found
return 0
fi
local -a doctor_args=()
local -a doctor_args=("--fix")
if [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
doctor_args+=("--non-interactive")
fi
ui_info "Running openclaw doctor"
local doctor_ok=0
local doctor_exit=0
if (( ${#doctor_args[@]} )); then
if [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" doctor "${doctor_args[@]}" </dev/null || doctor_exit=$?
else
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" doctor </dev/tty || doctor_exit=$?
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" doctor "${doctor_args[@]}" </dev/tty || doctor_exit=$?
fi
if (( doctor_exit == 130 )); then
abort_install_int
fi
# Clear dashboard flag if the doctor was cancelled or failed,
# since the upgrade did not complete successfully.
if (( doctor_exit != 0 )); then
should_open_dashboard=false
fi
if (( doctor_exit == 0 )); then
doctor_ok=1
fi
if (( doctor_ok )); then
should_open_dashboard=true
ui_info "Updating plugins"
OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true
else
ui_warn "Doctor failed; skipping plugin updates"
return "$doctor_exit"
fi
should_open_dashboard=true
ui_info "Updating plugins"
OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true
else
if run_doctor; then
should_open_dashboard=true
fi
run_doctor || return $?
should_open_dashboard=true
local user_claw
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "No TTY; run ${user_claw} plugins update --all manually"
fi
else
ui_info "Config already present; running doctor"
if run_doctor; then
should_open_dashboard=true
fi
run_doctor || return $?
should_open_dashboard=true
ui_info "Config already present; skipping onboarding"
fi
@@ -3823,10 +3972,24 @@ main() {
fi
fi
if [[ "$is_upgrade" == "true" ]]; then
VERIFY_INSTALL=1
fi
if ! verify_installation "$config_present"; then
exit 1
fi
if [[ "$is_upgrade" == "true" ]]; then
installed_version="$(resolve_openclaw_version)"
echo ""
if [[ -n "$installed_version" ]]; then
ui_celebrate "🦞 OpenClaw installed successfully (${installed_version})!"
else
ui_celebrate "🦞 OpenClaw installed successfully!"
fi
ui_info "Upgrade complete"
fi
if [[ "$should_open_dashboard" == "true" ]]; then
maybe_open_dashboard
fi
+13 -1
View File
@@ -3063,6 +3063,17 @@ function resolvePromptSnapshotFixtureTargets(changedPath: string) {
return ["test/scripts/prompt-snapshots.test.ts"];
}
function resolvePackageFixtureTargets(changedPath: string, cwd: string) {
const match = /^packages\/([^/]+)\/test\/fixtures\/([^/]+)\/.+$/u.exec(changedPath);
const packageName = match?.[1];
const fixtureFamily = match?.[2];
if (!packageName || !fixtureFamily) {
return null;
}
const owner = `packages/${packageName}/src/${fixtureFamily}.test.ts`;
return fs.existsSync(path.join(cwd, owner)) ? [owner] : null;
}
function resolveAppcastTargets(changedPath: string) {
return changedPath === "appcast.xml" ? APPCAST_TEST_TARGETS : null;
}
@@ -3079,7 +3090,8 @@ function resolvePreciseChangedTestTargets(
: null) ??
resolveToolingTestTargets(changedPath, cwd) ??
resolveAppcastTargets(changedPath) ??
resolvePromptSnapshotFixtureTargets(changedPath);
resolvePromptSnapshotFixtureTargets(changedPath) ??
resolvePackageFixtureTargets(changedPath, cwd);
if (mappedTargets) {
return mappedTargets;
}
@@ -59,7 +59,6 @@ describe("compaction failure real-behavior proof", () => {
// The caller must see the failure and keep the transcript intact.
// No compaction placeholder is returned, so messages are not rotated away.
expect(messages).toHaveLength(3);
expect(messages.map((message) => message.content)).toEqual([
"first user request",
"second user request",
@@ -894,7 +894,6 @@ describe("streamWithIdleTimeout", () => {
results.push(chunk);
}
expect(results).toHaveLength(3);
expect(results).toEqual(chunks);
});
@@ -1,3 +1,4 @@
import { getGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { createAgentHarnessTaskRuntimeScope } from "../../../tasks/agent-harness-task-runtime-scope.js";
import type { ToolOutcomeObserver } from "../../agent-tools.before-tool-call.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
@@ -381,6 +382,7 @@ export async function dispatchEmbeddedRunAttempt(input: {
? {
agentHarnessTaskRuntimeScope: createAgentHarnessTaskRuntimeScope({
requesterSessionKey: params.sessionKey,
gatewayContextResolver: getGatewayContextResolver(params.admittedRunContext),
}),
}
: {}),
-1
View File
@@ -784,7 +784,6 @@ describe("createModelExecAutoReviewer", () => {
Array.from({ length: 24 }, () => Promise.resolve(reviewer(input))),
);
expect(decisions).toHaveLength(24);
expect(decisions).toEqual(
Array.from({ length: 24 }, () =>
expect.objectContaining({ decision: "allow-once", risk: "low" }),
@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { isPidAlive } from "../shared/pid-alive.js";
import { killPidIfAlive } from "../test-utils/process-tree.js";
import { OpenClawStdioClientTransport } from "./mcp-stdio-transport.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -46,9 +47,7 @@ describe.skipIf(process.platform === "win32")("OpenClaw stdio process-group owne
expect(isPidAlive(descendantPid)).toBe(false);
} finally {
await transport.forceClose();
if (descendantPid && isPidAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
killPidIfAlive(descendantPid || undefined);
}
},
);
@@ -83,9 +82,7 @@ describe.skipIf(process.platform === "win32")("OpenClaw stdio process-group owne
await vi.waitFor(() => expect(isPidAlive(descendantPid)).toBe(false));
} finally {
await transport.forceClose();
if (descendantPid && isPidAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
killPidIfAlive(descendantPid || undefined);
}
},
);
-1
View File
@@ -378,7 +378,6 @@ describe("provider attribution", () => {
OPENCLAW_VERSION: "2026.3.22",
});
expect(policy).toBeDefined();
expect(policy).toEqual({
provider: "nvidia",
enabledByDefault: true,
@@ -5,6 +5,7 @@ import type {
GatewayRequestContext,
GatewayRequestHandlers,
} from "../../../gateway/server-methods/types.js";
import { withPluginRuntimeGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { dispatchSubagentAnnounceAgent } from "./subagent-announce-delivery.runtime.js";
function createContext(handlers: GatewayRequestHandlers): GatewayRequestContext {
@@ -59,4 +60,37 @@ describe("subagent announce Gateway instance dispatch", () => {
),
).resolves.toEqual({ runId: "announce-run", status: "ok", summary: "delivered" });
});
it("delivers through a lifecycle-fenced instance resolver scope", async () => {
const context = createContext({
agent: ({ respond }) => respond(true, { raw: true }),
});
const idempotencyKey = "scoped-subagent-announce";
context.dedupe.set(`agent:${idempotencyKey}`, {
ts: Date.now(),
ok: true,
payload: { runId: "scoped-announce-run", status: "ok", summary: "delivered" },
});
await expect(
withPluginRuntimeGatewayContextResolver(
() => context,
() =>
dispatchSubagentAnnounceAgent(
{
message: "Process one completed child result.",
idempotencyKey,
},
{
expectFinal: true,
forceSyntheticClient: true,
},
),
),
).resolves.toEqual({
runId: "scoped-announce-run",
status: "ok",
summary: "delivered",
});
});
});
@@ -1894,6 +1894,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
getRuntimeConfig: () => ({}) as never,
});
const ownerContext = { owner: "gateway-a" } as never;
const resolveGatewayContext = () => ownerContext;
const result = await deliverSubagentAnnouncement({
requesterSessionKey: "agent:main:slack:channel:C123:thread:171.222",
targetRequesterSessionKey: "agent:main:slack:channel:C123:thread:171.222",
@@ -1912,6 +1914,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expectsCompletionMessage: true,
bestEffortDeliver: true,
directIdempotencyKey: "announce-local-dispatch",
resolveGatewayContext,
});
expectDeliveryPath(result, "direct");
@@ -1936,10 +1939,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
idempotencyKey: "announce-local-dispatch",
},
timeoutMs: 120_000,
resolveGatewayContext,
});
// Instance-bound dispatch owns context resolution, so the caller's resolver
// is deliberately not forwarded; asserting it here would only prove the mock.
expect(dispatchOptions).not.toHaveProperty("resolveGatewayContext");
});
it("does not dispatch child-derived completion after source lifecycle ownership changes", async () => {
@@ -99,6 +99,7 @@ export async function deliverSubagentAnnouncement(params: {
directIdempotencyKey: string;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
signal?: AbortSignal;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<SubagentAnnounceDeliveryResult> {
const sourceOwnerChanged = () => params.isSourceSessionEffectsAllowed?.() === false;
if (sourceOwnerChanged()) {
@@ -258,6 +259,7 @@ export async function deliverSubagentAnnouncement(params: {
onDeliveryResult: params.onDeliveryResult,
signal: params.signal,
bestEffortDeliver: params.bestEffortDeliver,
resolveGatewayContext: params.resolveGatewayContext,
});
},
});
@@ -68,14 +68,12 @@ import {
} from "./subagent-announce-origin.js";
import { resolveRequesterStoreKey } from "./subagent-requester-store-key.js";
// No resolveGatewayContext: this dispatch is bound to the Gateway instance,
// which supplies its own context. The instance runtime forwards a fixed option
// allowlist, so a caller-supplied resolver here would be silently ignored.
async function runAnnounceAgentCall(params: {
agentParams: Record<string, unknown>;
delegatedToolPolicyHandoff?: SubagentCompletionToolHandoffRegistration;
expectFinal?: boolean;
timeoutMs?: number;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<unknown> {
return await dispatchSubagentAnnounceAgent(params.agentParams, {
expectFinal: params.expectFinal,
@@ -84,6 +82,7 @@ async function runAnnounceAgentCall(params: {
),
delegatedToolPolicyHandoff: params.delegatedToolPolicyHandoff,
timeoutMs: params.timeoutMs,
resolveGatewayContext: params.resolveGatewayContext,
});
}
@@ -108,6 +107,7 @@ export async function sendSubagentAnnounceDirectly(params: {
requesterIsSubagent: boolean;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
signal?: AbortSignal;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<SubagentAnnounceDeliveryResult> {
if (params.signal?.aborted) {
return {
@@ -372,6 +372,7 @@ export async function sendSubagentAnnounceDirectly(params: {
: undefined,
expectFinal: true,
timeoutMs: announceTimeoutMs,
resolveGatewayContext: params.resolveGatewayContext,
});
},
});
@@ -7,6 +7,7 @@
import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
import { getRuntimeConfig } from "../../../config/config.js";
import { logWarn } from "../../../logger.js";
import { getSharedGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { isCronSessionKey } from "../../../sessions/session-key-utils.js";
import {
type DeliveryContext,
@@ -451,6 +452,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: {
attemptIndex === 0 ? wakeKeyBase : `${wakeKeyBase}:retry-${attemptIndex}`,
),
signal: params.signal,
resolveGatewayContext: getSharedGatewayContextResolver(settledBatch),
});
} catch (error) {
// A transport exception can arrive after gateway admission. Replay the
@@ -193,6 +193,7 @@ export async function runSubagentAnnounceFlow(params: {
bestEffortDeliver?: boolean;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
onBeforeDeleteChildSession?: () => boolean;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<SubagentAnnounceFlowOutcome> {
let announceOutcome: SubagentAnnounceFlowOutcome = "retryable";
const expectsCompletionMessage = params.expectsCompletionMessage === true;
@@ -589,6 +590,7 @@ export async function runSubagentAnnounceFlow(params: {
directIdempotencyKey,
onDeliveryResult: reportDeliveryResult,
signal: params.signal,
resolveGatewayContext: params.resolveGatewayContext,
});
reportDeliveryResult(delivery);
announceOutcome = delivery.disposition ?? (delivery.delivered ? "delivered" : "retryable");
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
bindGatewayContextResolver,
getGatewayContextResolver,
getSharedGatewayContextResolver,
} from "../../../plugins/runtime/gateway-request-scope.js";
import { createSubagentRunRecord } from "../../subagent-test-fixtures.test-helpers.js";
@@ -19,4 +20,17 @@ describe("subagent Gateway context binding", () => {
expect(getGatewayContextResolver(successor)?.()).toBe(context);
expect(getGatewayContextResolver(restored)).toBeUndefined();
});
it("refuses to select one Gateway for a mixed-owner settle batch", () => {
const first = createSubagentRunRecord({ runId: "run-first" });
const second = createSubagentRunRecord({ runId: "run-second" });
const firstContext = { owner: "gateway-a" } as never;
const secondContext = { owner: "gateway-b" } as never;
bindGatewayContextResolver(first, () => firstContext);
bindGatewayContextResolver(second, () => secondContext);
expect(getGatewayContextResolver(first)?.()).toBe(firstContext);
expect(getGatewayContextResolver(second)?.()).toBe(secondContext);
expect(getSharedGatewayContextResolver([first, second])).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { getGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { defaultRuntime } from "../../../runtime.js";
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
import {
@@ -606,6 +607,7 @@ export const startSubagentAnnounceCleanupFlow = (
params.persist(runId);
}
},
resolveGatewayContext: getGatewayContextResolver(entry),
};
runDetachedCleanupAttempt(context, {
runId,
+8 -2
View File
@@ -23,7 +23,10 @@ import {
import { withReplyDispatcher } from "./dispatch-dispatcher.js";
import type { CommandSessionMetadataChange } from "./reply/command-session-metadata.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js";
import type {
DispatchFromConfigResult,
DispatchReplyFromConfig,
} from "./reply/dispatch-from-config.types.js";
import type {
InternalGetReplyFromConfig,
InternalGetReplyOptions,
@@ -200,6 +203,7 @@ export async function dispatchInboundMessage(params: {
toolsAllow?: string[];
replyOptions?: InternalDispatchReplyOptions;
replyResolver?: InternalGetReplyFromConfig;
dispatchReplyFromConfig?: DispatchReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
replyPayloadRunState?: ReplyPayloadRunState;
/** Observe-only turns run the agent without entering outbound hook stages. */
@@ -240,7 +244,7 @@ export async function dispatchInboundMessage(params: {
measureDiagnosticsTimelineSpan(
"auto_reply.dispatch_reply_from_config",
() =>
dispatchReplyFromConfig({
(params.dispatchReplyFromConfig ?? dispatchReplyFromConfig)({
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
@@ -269,6 +273,7 @@ type BufferedInboundDispatcherParams = {
toolsAllow?: string[];
replyOptions?: InternalDispatchReplyOptions;
replyResolver?: InternalGetReplyFromConfig;
dispatchReplyFromConfig?: DispatchReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
};
@@ -348,6 +353,7 @@ async function dispatchInboundMessageWithBufferedDispatcherCore(
dispatcher,
toolsAllow: params.toolsAllow,
replyResolver: params.replyResolver,
dispatchReplyFromConfig: params.dispatchReplyFromConfig,
replyOptions: {
...params.replyOptions,
...replyOptions,
@@ -39,7 +39,10 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { recordMessageToolRunOutcome } from "../../infra/message-tool-run-outcome-store.js";
import { logSessionTurnCreated } from "../../logging/diagnostic.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { bindGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
import {
bindGatewayContextResolver,
getPluginRuntimeGatewayRequestScope,
} from "../../plugins/runtime/gateway-request-scope.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -527,7 +530,9 @@ async function executeAgentTurnInternal(
};
const runId = params.opts?.runId ?? crypto.randomUUID();
const admittedRunContext: { current?: AdmittedRunContext } = {};
const gatewayContextResolver = readChannelContextGatewayContextResolver(params.sessionCtx);
const gatewayContextResolver =
readChannelContextGatewayContextResolver(params.sessionCtx) ??
getPluginRuntimeGatewayRequestScope()?.resolveGatewayContext;
const preparedRunAdmission = prepareChannelRunAdmission({
cfg: resolveQueuedReplyRuntimeConfig(params.followupRun.run.config),
runId,
@@ -500,7 +500,6 @@ describe("resetReplyRunSession", () => {
});
// The boundary reset keeps both the logical id and transcript in place.
expect(rotatedSessionId).toBeDefined();
expect(rotatedSessionId).toBe("old-session");
await fs.access(oldTranscriptPath);
});
@@ -860,7 +860,6 @@ describe("followup queue collect routing", () => {
await drainRecordedQueue(key, runFollowup, done);
expect(calls).toHaveLength(3);
expect(calls.map((call) => call.originatingTo)).toEqual([
"channel:B",
"channel:C",
@@ -169,7 +169,6 @@ describe("followup prompt metadata carrier", () => {
"---\nQueued #1\n[media attached: /tmp/a.png (image/png)]\nfirst",
"---\nQueued #2\n[media attached: /tmp/b.pdf (application/pdf)]\nsecond",
].join("\n\n");
expect(calls).toHaveLength(2);
expect(calls.map((run) => run.prompt)).toEqual([expectedPrompt, expectedPrompt]);
expect(calls.map((run) => run.media)).toEqual([
[
@@ -685,7 +685,6 @@ describe("channel ingress queue", () => {
await queue.enqueue("good-2", { text: "world" });
const pending = await queue.listPending();
expect(pending).toHaveLength(2);
expect(pending.map((r) => r.id).toSorted()).toEqual(["good-1", "good-2"]);
});
});
+1
View File
@@ -625,6 +625,7 @@ async function dispatchChannelTurnWithDeliveryOwner(
},
onError: delivery.onError,
},
dispatchReplyFromConfig: params.dispatchReplyFromConfig,
toolsAllow: params.toolsAllow,
replyOptions,
replyResolver: params.replyResolver,
+6 -1
View File
@@ -4,7 +4,10 @@ import type {
TurnAdoptionLifecycle,
} from "../../auto-reply/get-reply-options.types.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { DispatchFromConfigResult } from "../../auto-reply/reply/dispatch-from-config.types.js";
import type {
DispatchFromConfigResult,
DispatchReplyFromConfig,
} from "../../auto-reply/reply/dispatch-from-config.types.js";
import type { GetReplyFromConfig } from "../../auto-reply/reply/get-reply.types.js";
import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/history.types.js";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
@@ -328,6 +331,8 @@ export type AssembledChannelTurn = {
toolsAllow?: string[];
replyOptions?: ChannelTurnReplyOptions;
replyResolver?: GetReplyFromConfig;
/** Instance-bound reply dispatcher supplied by the owning plugin runtime. */
dispatchReplyFromConfig?: DispatchReplyFromConfig;
sessionInitRetry?: {
delaysMs: readonly number[];
signal?: AbortSignal;
+67 -6
View File
@@ -1211,6 +1211,7 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(logs).toContain(message);
expect(logs).not.toContain("Gateway: restarted and verified.");
expect(logs).not.toContain("Update Result: OK");
};
const mockGatewayProbe = (version: string, connId: string) => {
@@ -2038,6 +2039,7 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalled();
expectNoSideEffects(serviceRestart, runDaemonRestart);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getLogOutput()).not.toContain("Update Result: OK");
expect(
requireValue(spawn.mock.invocationCallOrder[0], "post-core update process order"),
).toBeLessThan(
@@ -5311,6 +5313,7 @@ describe("update-cli", () => {
"utf-8",
);
await fs.writeFile(serviceEntrypoint, "export {};\n", "utf-8");
const canonicalGitRoot = await fs.realpath(gitRoot);
mockPackageInstallStatus(packageRoot);
pathExists.mockImplementation(async (candidate: string) => candidate === gitRoot);
mockRunningManagedGateway(["node", serviceEntrypoint, "gateway", "run"]);
@@ -5328,7 +5331,7 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalledTimes(1);
expect(runGatewayUpdate).toHaveBeenCalledTimes(1);
const updateCall = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(updateCall?.cwd).toBe(gitRoot);
expect(updateCall?.cwd).toBe(canonicalGitRoot);
expect(updateCall?.beforeGitMutation).toEqual(expect.any(Function));
});
@@ -5349,6 +5352,7 @@ describe("update-cli", () => {
"utf-8",
);
await fs.writeFile(packageEntrypoint, "export {};\n", "utf-8");
const canonicalGitRoot = await fs.realpath(gitRoot);
mockPackageInstallStatus(packageRoot);
pathExists.mockImplementation(async (candidate: string) => candidate === gitRoot);
mockRunningManagedGateway(["node", packageEntrypoint, "gateway", "run"]);
@@ -5366,7 +5370,7 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalledTimes(1);
expect(runGatewayUpdate).toHaveBeenCalledTimes(1);
const updateCall = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(updateCall?.cwd).toBe(gitRoot);
expect(updateCall?.cwd).toBe(canonicalGitRoot);
expect(updateCall?.beforeGitMutation).toEqual(expect.any(Function));
});
@@ -5381,12 +5385,13 @@ describe("update-cli", () => {
const checkoutAlias = path.join(root, "checkout-alias");
await Promise.all([fs.mkdir(targetRoot), fs.mkdir(replacementRoot)]);
await fs.symlink(targetRoot, checkoutAlias, "dir");
const publishedRoot = await fs.realpath(checkoutAlias);
mockPackageInstallStatus(packageRoot);
mockFileBackedPathExists();
mockNoopPostUpdatePluginConvergence();
vi.mocked(runGatewayUpdate).mockImplementationOnce(async (options) => {
expect(options?.cwd).toBe(targetRoot);
return makeOkUpdateResult({ mode: "git", root: targetRoot });
expect(options?.cwd).toBe(publishedRoot);
return makeOkUpdateResult({ mode: "git", root: publishedRoot });
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
@@ -5414,13 +5419,67 @@ describe("update-cli", () => {
});
const installCall = packageInstallCommandCall();
expect(installCall?.[0]).toContain(targetRoot);
expect(installCall?.[0]).toContain(publishedRoot);
expect(installCall?.[0]).not.toContain(checkoutAlias);
expect(installCall?.[1].cwd).toBe(targetRoot);
expect(installCall?.[1].cwd).toBe(publishedRoot);
await expect(fs.readdir(replacementRoot)).resolves.toEqual([]);
},
);
it("preserves the package and shim when package-to-Git staged activation fails", async () => {
const root = await createTrackedTempDir("openclaw-update-package-to-git-fail-");
const prefix = path.join(root, "prefix");
const nodeModules = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(nodeModules, "openclaw");
const shim = path.join(prefix, "bin", "openclaw");
const gitRoot = path.join(root, "git-root");
await writeOpenClawPackageFixture(packageRoot, "2026.4.20", {
entrySource: "export {};\n",
inventory: true,
});
await fs.mkdir(path.dirname(shim), { recursive: true });
await fs.writeFile(shim, "old package shim\n", { mode: 0o755 });
await fs.mkdir(path.join(gitRoot, ".git"), { recursive: true });
await fs.writeFile(
path.join(gitRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.8.18" }),
"utf8",
);
const packageBefore = await fs.readFile(path.join(packageRoot, "package.json"), "utf8");
const shimBefore = await fs.readFile(shim, "utf8");
mockPackageInstallStatus(packageRoot);
mockFileBackedPathExists();
mockGitUpdateAfterMutation(makeOkUpdateResult({ mode: "git", root: gitRoot }));
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") {
return commandResult({ code: 1, stderr: "candidate verification fixture failure" });
}
return commandResult();
});
await withEnvAsync({ OPENCLAW_GIT_DIR: gitRoot }, async () => {
await updateCommand({ channel: "dev", yes: true, restart: false });
});
const installCalls = commandCalls().filter(
([argv]) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g",
);
expect(installCalls).toHaveLength(2);
expect(installCalls.every(([argv]) => argv.includes("--prefix"))).toBe(true);
await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toBe(
packageBefore,
);
await expect(fs.readFile(shim, "utf8")).resolves.toBe(shimBefore);
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("does not stop or restart a managed gateway owned by another git checkout", async () => {
const otherRoot = await createTrackedTempDir("openclaw-update-other-service-root-");
const otherEntrypoint = path.join(otherRoot, "dist", "index.js");
@@ -5464,6 +5523,8 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalledTimes(1);
expectNoSideEffects(serviceRestart, runDaemonRestart);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getLogOutput()).toContain("Update Result: ERROR");
expect(getErrorOutput()).not.toContain("Update failed during plugin post-update sync.");
});
it("restarts a stopped git service when the fresh plugin doctor cannot run", async () => {
+97 -22
View File
@@ -1,11 +1,13 @@
import fs from "node:fs/promises";
import path from "node:path";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import { hasNodeErrorCode } from "../../infra/path-guards.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
import {
createGlobalInstallEnv,
globalInstallArgs,
resolveGlobalInstallTarget,
resolveNpmLifecyclePolicyGate,
resolvePnpmGlobalDirFromGlobalRoot,
} from "../../infra/update-global.js";
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
@@ -17,10 +19,13 @@ import {
type OpenClawDatabaseSchemaPreflight,
} from "../../state/openclaw-database-preflight.js";
import type { OpenClawSchemaVersions } from "../../state/openclaw-schema-versions.js";
import { splitShellArgs } from "../../utils/shell-argv.js";
import { createUpdateProgress, printResult } from "./progress.js";
import {
createGlobalCommandRunner,
DEFAULT_PACKAGE_NAME,
ensureGitCheckout,
readPackageName,
resolveGitInstallDir,
resolveGlobalManager,
runUpdateStep,
@@ -30,6 +35,82 @@ import { UpdateCommandAbort, type PreManagedServiceStop } from "./update-command
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
export async function retireStandaloneGitWrapper(params: {
previousRoot: string;
platform?: NodeJS.Platform;
searchDirs?: readonly string[];
}): Promise<{ error?: string }> {
const platform = params.platform ?? process.platform;
const wrapperName = platform === "win32" ? "openclaw.cmd" : "openclaw";
const searchDirs = params.searchDirs ?? (process.env.PATH ?? "").split(path.delimiter);
const expectedEntry =
platform === "win32"
? path.win32.join(params.previousRoot, "dist", "entry.js")
: path.join(params.previousRoot, "dist", "entry.js");
const seen = new Set<string>();
for (const directory of searchDirs) {
if (!directory) {
continue;
}
const wrapperPath = path.resolve(directory, wrapperName);
if (seen.has(wrapperPath)) {
continue;
}
seen.add(wrapperPath);
let stat;
try {
stat = await fs.lstat(wrapperPath);
} catch (error) {
if (hasNodeErrorCode(error, "ENOENT")) {
continue;
}
return { error: `Could not inspect ${wrapperPath}: ${String(error)}` };
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size > 4096 ||
(platform !== "win32" && (stat.mode & 0o111) === 0)
) {
continue;
}
let contents: string;
try {
contents = await fs.readFile(wrapperPath, "utf8");
} catch (error) {
return { error: `Could not inspect ${wrapperPath}: ${String(error)}` };
}
const lines = contents.trimEnd().split(/\r?\n/u);
const matchesWindows =
platform === "win32" &&
lines.length === 2 &&
lines[0] === "@echo off" &&
lines[1] === `node "${expectedEntry}" %*`;
const execArgs =
platform === "win32" || lines.length !== 3 ? null : splitShellArgs(lines[2] ?? "");
const matchesPosix =
platform !== "win32" &&
lines[0] === "#!/usr/bin/env bash" &&
lines[1] === "set -euo pipefail" &&
execArgs?.length === 4 &&
execArgs[0] === "exec" &&
execArgs[2] === expectedEntry &&
execArgs[3] === "$@";
if (!matchesWindows && !matchesPosix) {
continue;
}
try {
await fs.unlink(wrapperPath);
} catch (error) {
return { error: `Could not retire ${wrapperPath}: ${String(error)}` };
}
}
return {};
}
type BeforeGitMutation = (target: {
schemaVersions?: OpenClawSchemaVersions;
metadataUnreadable?: string;
@@ -216,31 +297,25 @@ export async function updateGitInstall(params: {
if (!installTarget) {
throw new Error("global install target missing after package-to-Git preflight");
}
const installLocation =
installTarget.manager === "pnpm"
? resolvePnpmGlobalDirFromGlobalRoot(installTarget.globalRoot)
: null;
const installStep = await runUpdateStep({
name: "global install",
argv: globalInstallArgs(
installTarget,
updateRoot,
undefined,
installLocation,
updateRoot,
npmLifecycleGate.policy ?? undefined,
),
cwd: updateRoot,
env: installEnv,
const packageName =
(await readPackageName(installTarget.packageRoot ?? params.root)) ?? DEFAULT_PACKAGE_NAME;
const packageUpdate = await runGlobalPackageUpdateSteps({
installTarget,
installSpec: updateRoot,
packageName,
packageRoot: installTarget.packageRoot,
runCommand,
runStep: (stepParams) => runUpdateStep({ ...stepParams, progress: params.progress }),
timeoutMs: effectiveTimeout,
progress: params.progress,
env: installEnv,
installCwd: updateRoot,
});
steps.push(installStep);
steps.push(...packageUpdate.steps);
const failedStep = installStep.exitCode !== 0 ? installStep : null;
return {
...updateResult,
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
status: packageUpdate.failedStep ? "error" : "ok",
reason: packageUpdate.failedStep?.name,
steps,
durationMs: Date.now() - params.startedAt,
};
@@ -1,28 +1,295 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
const mocks = vi.hoisted(() => ({
completePluginUpdate: vi.fn(),
markSentinelFailure: vi.fn(async () => undefined),
printResult: vi.fn(),
readConfig: vi.fn(),
restart: vi.fn(async () => undefined),
restartService: vi.fn(async () => true),
restoreWindowsAutoStart: vi.fn(async () => true),
tryInstallCompletion: vi.fn(async () => undefined),
tryWriteCompletionCache: vi.fn(async () => undefined),
updatePlugins: vi.fn(),
writeSentinel: vi.fn(async () => undefined),
}));
vi.mock("./progress.js", () => ({ printResult: vi.fn() }));
vi.mock("./progress.js", () => ({ printResult: mocks.printResult }));
vi.mock("../../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../config/config.js")>()),
readConfigFileSnapshot: mocks.readConfig,
}));
vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => callback(),
}));
vi.mock("./update-command-config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-config.js")>()),
persistRequestedUpdateChannel: async (params: { configSnapshot: unknown }) =>
params.configSnapshot,
restoreDroppedPreUpdateChannels: (snapshot: unknown) => ({
snapshot,
changed: false,
authoredChannels: [],
}),
}));
vi.mock("./update-command-fresh-doctor.js", () => ({
completePostCorePluginUpdate: mocks.completePluginUpdate,
}));
vi.mock("./update-command-plugins.js", () => ({
updatePluginsAfterCoreUpdate: mocks.updatePlugins,
}));
vi.mock("./shared.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./shared.js")>()),
tryWriteCompletionCache: mocks.tryWriteCompletionCache,
}));
vi.mock("./update-command-service.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-service.js")>()),
maybeRestartService: mocks.restartService,
maybeRestartServiceAfterFailedMutableUpdate: mocks.restart,
restoreWindowsTaskAutoStartOrExit: mocks.restoreWindowsAutoStart,
tryInstallShellCompletion: mocks.tryInstallCompletion,
}));
vi.mock("./update-command-post-core.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-post-core.js")>()),
markControlPlaneUpdateRestartSentinelFailureBestEffort: mocks.markSentinelFailure,
writeControlPlaneUpdateRestartSentinelBestEffort: mocks.writeSentinel,
}));
import { retireStandaloneGitWrapper } from "./update-command-git.js";
import { finishUpdate } from "./update-command-post-update.js";
type FinishUpdateParams = Parameters<typeof finishUpdate>[0];
const validConfigSnapshot = {
valid: true,
parsed: {},
config: {},
runtimeConfig: {},
sourceConfig: {},
warnings: [],
issues: [],
legacyIssues: [],
};
const successfulPluginUpdate = {
status: "ok",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: false, outcomes: [] },
integrityDrifts: [],
warnings: [],
};
async function finishSuccessfulPackageSwitch(params: {
previousRoot: string;
packageRoot: string;
}): Promise<void> {
await finishUpdate({
result: {
status: "ok",
mode: "npm",
root: params.packageRoot,
steps: [],
durationMs: 1,
},
root: params.packageRoot,
previousInstallRoot: params.previousRoot,
installKindChanged: true,
configSnapshot: validConfigSnapshot,
requestedChannel: null,
storedChannel: null,
channel: "stable",
downgradeRisk: true,
shouldRestart: false,
opts: {},
showProgress: false,
controlPlaneUpdateSentinelMeta: {},
preUpdatePluginInstallRecords: {},
startedAt: Date.now(),
updateStepTimeoutMs: 1_000,
} as unknown as FinishUpdateParams);
}
describe("retireStandaloneGitWrapper", () => {
it("removes only the installer wrapper for the previous checkout", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wrapper-retire-"));
const oldRoot = path.join(home, "old checkout");
const unrelatedWrapper = path.join(home, "earlier", "openclaw");
const wrapper = path.join(home, ".local", "bin", "openclaw");
const secondWrapper = path.join(home, "legacy", "bin", "openclaw");
const oldWrapperContents = `#!/usr/bin/env bash\nset -euo pipefail\nexec /usr/bin/node ${oldRoot.replaceAll(" ", "\\ ")}/dist/entry.js "$@"\n`;
await Promise.all([
fs.mkdir(path.dirname(unrelatedWrapper), { recursive: true }),
fs.mkdir(path.dirname(wrapper), { recursive: true }),
fs.mkdir(path.dirname(secondWrapper), { recursive: true }),
]);
await fs.writeFile(unrelatedWrapper, "#!/usr/bin/env bash\necho unrelated\n", { mode: 0o755 });
await Promise.all([
fs.writeFile(wrapper, oldWrapperContents, { mode: 0o755 }),
fs.writeFile(secondWrapper, oldWrapperContents, { mode: 0o755 }),
]);
try {
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "linux",
searchDirs: [
path.dirname(unrelatedWrapper),
path.dirname(wrapper),
path.dirname(secondWrapper),
],
}),
).resolves.toEqual({});
await expect(fs.readFile(unrelatedWrapper, "utf8")).resolves.toContain("unrelated");
await expect(fs.stat(wrapper)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(secondWrapper)).rejects.toMatchObject({ code: "ENOENT" });
await fs.writeFile(
wrapper,
"#!/usr/bin/env node\nimport '../lib/node_modules/openclaw/openclaw.mjs';\n",
{ mode: 0o755 },
);
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "linux",
searchDirs: [path.dirname(wrapper)],
}),
).resolves.toEqual({});
await expect(fs.stat(wrapper)).resolves.toBeDefined();
} finally {
await fs.rm(home, { recursive: true, force: true });
}
});
it("removes only the exact PowerShell installer wrapper on Windows", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wrapper-retire-win-"));
const oldRoot = "C:\\Users\\operator\\openclaw";
const wrapper = path.join(home, ".local", "bin", "openclaw.cmd");
await fs.mkdir(path.dirname(wrapper), { recursive: true });
await fs.writeFile(
wrapper,
`@echo off\r\nnode "${path.win32.join(oldRoot, "dist", "entry.js")}" %*\r\n`,
);
try {
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "win32",
searchDirs: [path.dirname(wrapper)],
}),
).resolves.toEqual({});
await expect(fs.stat(wrapper)).rejects.toMatchObject({ code: "ENOENT" });
await fs.writeFile(wrapper, "@echo off\r\necho unrelated\r\n");
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "win32",
searchDirs: [path.dirname(wrapper)],
}),
).resolves.toEqual({});
await expect(fs.readFile(wrapper, "utf8")).resolves.toContain("unrelated");
} finally {
await fs.rm(home, { recursive: true, force: true });
}
});
});
describe("successful update finalization ordering", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readConfig.mockResolvedValue(validConfigSnapshot);
mocks.updatePlugins.mockResolvedValue(successfulPluginUpdate);
mocks.completePluginUpdate.mockResolvedValue({
pluginUpdate: successfulPluginUpdate,
configSnapshot: validConfigSnapshot,
});
vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never);
vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined);
});
it("retires the wrapper before persisting and printing success", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-finalize-order-"));
const previousRoot = path.join(home, "old-root");
const wrapper = path.join(home, ".local", "bin", "openclaw");
await fs.mkdir(path.dirname(wrapper), { recursive: true });
await fs.writeFile(
wrapper,
`#!/usr/bin/env bash\nset -euo pipefail\nexec /usr/bin/node ${previousRoot}/dist/entry.js "$@"\n`,
{ mode: 0o755 },
);
const previousPath = process.env.PATH;
process.env.PATH = path.dirname(wrapper);
const unlink = vi.spyOn(fs, "unlink");
try {
await finishSuccessfulPackageSwitch({
previousRoot,
packageRoot: path.join(home, "package"),
});
expect(mocks.writeSentinel).toHaveBeenCalledTimes(2);
expect(unlink.mock.invocationCallOrder[0]).toBeLessThan(
mocks.writeSentinel.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY,
);
expect(mocks.writeSentinel.mock.invocationCallOrder[1]).toBeLessThan(
mocks.printResult.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
} finally {
unlink.mockRestore();
process.env.PATH = previousPath;
await fs.rm(home, { recursive: true, force: true });
}
});
it("marks and prints an error without persisting success when retirement fails", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-finalize-failure-"));
const previousRoot = path.join(home, "old-root");
const wrapper = path.join(home, ".local", "bin", "openclaw");
await fs.mkdir(path.dirname(wrapper), { recursive: true });
await fs.writeFile(
wrapper,
`#!/usr/bin/env bash\nset -euo pipefail\nexec /usr/bin/node ${previousRoot}/dist/entry.js "$@"\n`,
{ mode: 0o755 },
);
const previousPath = process.env.PATH;
process.env.PATH = path.dirname(wrapper);
const unlink = vi.spyOn(fs, "unlink").mockRejectedValueOnce(new Error("unlink denied"));
try {
await finishSuccessfulPackageSwitch({
previousRoot,
packageRoot: path.join(home, "package"),
});
expect(mocks.writeSentinel).toHaveBeenCalledTimes(1);
expect(mocks.markSentinelFailure).toHaveBeenCalledWith(
expect.objectContaining({ reason: "wrapper-retirement-failed" }),
);
expect(mocks.printResult).toHaveBeenCalledWith(
expect.objectContaining({ status: "error", reason: "wrapper-retirement-failed" }),
expect.any(Object),
);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
} finally {
unlink.mockRestore();
process.env.PATH = previousPath;
await fs.rm(home, { recursive: true, force: true });
}
});
});
function failedResult(recovery: UpdateRunResult["recovery"]): UpdateRunResult {
return {
status: "error",
@@ -29,6 +29,7 @@ import {
restoreDroppedPreUpdateChannels,
} from "./update-command-config.js";
import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js";
import { retireStandaloneGitWrapper } from "./update-command-git.js";
import { withOwnedManagedUpdateEnv } from "./update-command-managed-context.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
@@ -87,6 +88,7 @@ function pickUpdateQuip(): string {
export async function finishUpdate(params: {
result: UpdateRunResult;
root: string;
previousInstallRoot?: string;
installKindChanged: boolean;
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
requestedChannel: UpdateChannel | null;
@@ -105,7 +107,7 @@ export async function finishUpdate(params: {
updateStepTimeoutMs: number;
invocationCwd?: string;
}): Promise<void> {
if (!params.opts.json || params.result.status !== "ok") {
if (params.result.status !== "ok") {
printResult(params.result, { ...params.opts, hideSteps: params.showProgress });
}
@@ -343,11 +345,7 @@ export async function finishUpdate(params: {
jsonMode: Boolean(params.opts.json),
});
}
if (params.opts.json) {
defaultRuntime.writeJson(resultWithPostUpdate);
} else {
defaultRuntime.error(theme.error("Update failed during plugin post-update sync."));
}
printResult(resultWithPostUpdate, { ...params.opts, hideSteps: params.showProgress });
defaultRuntime.exit(1);
return;
}
@@ -490,15 +488,36 @@ export async function finishUpdate(params: {
return;
}
if (params.installKindChanged && resultWithPostUpdate.mode !== "git") {
const retirement = await retireStandaloneGitWrapper({
previousRoot: params.previousInstallRoot ?? params.root,
});
if (retirement.error) {
defaultRuntime.error(retirement.error);
await markControlPlaneUpdateRestartSentinelFailureBestEffort({
meta: params.controlPlaneUpdateSentinelMeta,
reason: "wrapper-retirement-failed",
jsonMode: Boolean(params.opts.json),
});
const failedResult: UpdateRunResult = {
...resultWithPostUpdate,
status: "error",
reason: "wrapper-retirement-failed",
};
printResult(failedResult, { ...params.opts, hideSteps: params.showProgress });
defaultRuntime.exit(1);
return;
}
}
await writeControlPlaneUpdateRestartSentinelBestEffort({
meta: params.controlPlaneUpdateSentinelMeta,
result: resultWithPostUpdate,
jsonMode: Boolean(params.opts.json),
});
printResult(resultWithPostUpdate, { ...params.opts, hideSteps: params.showProgress });
if (!params.opts.json) {
defaultRuntime.log(theme.muted(pickUpdateQuip()));
} else {
defaultRuntime.writeJson(resultWithPostUpdate);
}
}
+1
View File
@@ -663,6 +663,7 @@ async function updateCommandInternal(
await finishUpdate({
result,
root,
previousInstallRoot: discoveredRoot,
installKindChanged: switchToGit || switchToPackage,
configSnapshot: finalizationConfigSnapshot,
requestedChannel,
+7 -11
View File
@@ -5,10 +5,8 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
const resolveCleanupPlanForDryRun = vi.fn();
export const resolveCleanupPlanForRemoval = vi.fn();
const removePath = vi.fn();
export const removePath = vi.fn();
export const listAgentSessionDirs = vi.fn();
export const prepareLegacyWorkspaceStateReset = vi.fn();
export const removeLegacyWorkspaceStateForReset = vi.fn();
export const removeStateAndLinkedPaths = vi.fn();
export const removeWorkspaceDirs = vi.fn();
const gatewayServiceState = vi.hoisted(() => ({
@@ -20,11 +18,6 @@ const gatewayServiceState = vi.hoisted(() => ({
export const gatewayService = gatewayServiceState;
const cleanupConfigState = vi.hoisted(() => ({ isNixMode: false }));
vi.mock("../agents/workspace-legacy-state.js", () => ({
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
}));
vi.mock("../config/config.js", () => ({
get isNixMode() {
return cleanupConfigState.isNixMode;
@@ -66,10 +59,8 @@ export function resetCleanupCommandMocks() {
resolveCleanupPlanForRemoval.mockResolvedValue(cleanupPlan);
removePath.mockResolvedValue({ ok: true });
listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]);
prepareLegacyWorkspaceStateReset.mockImplementation((workspaceDir: string) => ({ workspaceDir }));
removeLegacyWorkspaceStateForReset.mockResolvedValue({ removedPaths: [], warnings: [] });
removeStateAndLinkedPaths.mockResolvedValue(true);
removeWorkspaceDirs.mockResolvedValue(undefined);
removeWorkspaceDirs.mockResolvedValue([]);
gatewayService.isLoaded.mockReset().mockResolvedValue(true);
gatewayService.stop.mockReset().mockResolvedValue(undefined);
gatewayService.uninstall.mockReset().mockResolvedValue(undefined);
@@ -89,3 +80,8 @@ export function cleanupCommandLogMessages(runtime: RuntimeEnv): string[] {
const calls = (runtime.log as MockFn<(...args: unknown[]) => void>).mock.calls;
return calls.map((call) => String(call[0]));
}
export function cleanupCommandErrorMessages(runtime: RuntimeEnv): string[] {
const calls = (runtime.error as MockFn<(...args: unknown[]) => void>).mock.calls;
return calls.map((call) => String(call[0]));
}
+12 -2
View File
@@ -125,6 +125,7 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
nixMode: false,
preservesWorkspace: false,
serviceChecks: 1,
aggregatesFailure: false,
run: (runtime: ReturnType<typeof createNonExitingRuntime>) =>
resetCommand(runtime, { scope: "full", yes: true, nonInteractive: true }),
},
@@ -133,6 +134,7 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
nixMode: true,
preservesWorkspace: false,
serviceChecks: 0,
aggregatesFailure: false,
run: (runtime: ReturnType<typeof createNonExitingRuntime>) =>
resetCommand(runtime, { scope: "full", yes: true, nonInteractive: true }),
},
@@ -141,12 +143,13 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
nixMode: false,
preservesWorkspace: true,
serviceChecks: 0,
aggregatesFailure: true,
run: (runtime: ReturnType<typeof createNonExitingRuntime>) =>
uninstallCommand(runtime, { state: true, yes: true, nonInteractive: true }),
},
])(
"refuses $command until the SQLite owner exits",
async ({ nixMode, preservesWorkspace, run, serviceChecks }) => {
async ({ aggregatesFailure, nixMode, preservesWorkspace, run, serviceChecks }) => {
const state = await createOpenClawTestState({
prefix: "openclaw-cleanup-live-state-",
layout: "split",
@@ -173,7 +176,14 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
const blockedRuntime = createNonExitingRuntime();
vi.spyOn(blockedRuntime, "log").mockImplementation(() => {});
vi.spyOn(blockedRuntime, "error").mockImplementation(() => {});
await expect(run(blockedRuntime)).rejects.toThrow(/Gateway|state directory/i);
if (aggregatesFailure) {
await expect(run(blockedRuntime)).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(blockedRuntime.error).toHaveBeenCalledWith(
expect.stringMatching(/Gateway|state directory/i),
);
} else {
await expect(run(blockedRuntime)).rejects.toThrow(/Gateway|state directory/i);
}
expect(gatewayService.isLoaded).toHaveBeenCalledTimes(serviceChecks);
expect(owner.exitCode).toBeNull();
await expect(fs.readFile(markerPath, "utf8")).resolves.toBe("preserved");
-1
View File
@@ -43,7 +43,6 @@ export async function resolveCleanupPlanForRemoval(runtime: RuntimeEnv) {
runtime.error(
`Cannot safely remove OpenClaw state because workspace configuration could not be resolved: ${issueSummary}. Fix the configuration and retry.`,
);
runtime.exit(1);
return undefined;
}
return buildCleanupPlanForConfig(snapshot.runtimeConfig);
+17
View File
@@ -297,6 +297,23 @@ describe("cleanup path removals", () => {
expect(stateRemoved).toBe(true);
});
it("returns failure when any linked dry-run target is unsafe", async () => {
const runtime = createRuntimeMock();
await expect(
removeStateAndLinkedPaths(
{
stateDir: "/tmp/openclaw-cleanup/state",
configPath: path.parse(process.cwd()).root,
oauthDir: "/tmp/openclaw-cleanup/oauth",
configInsideState: false,
oauthInsideState: false,
},
runtime,
{ dryRun: true },
),
).resolves.toBe(false);
});
it("keeps the canonical state lock visible until state removal completes", async () => {
const runtime = createRuntimeMock();
const tmpRoot = await fs.realpath(tempDirs.make("openclaw-cleanup-lock-visible-"));
+13 -10
View File
@@ -410,13 +410,13 @@ export async function removeStateAndLinkedPaths(
dryRun: true,
label: cleanup.stateDir,
});
if (!cleanup.configInsideState) {
await removePath(cleanup.configPath, runtime, { dryRun: true, label: cleanup.configPath });
}
if (!cleanup.oauthInsideState) {
await removePath(cleanup.oauthDir, runtime, { dryRun: true, label: cleanup.oauthDir });
}
return stateRemoval.ok;
const configRemoval = cleanup.configInsideState
? { ok: true }
: await removePath(cleanup.configPath, runtime, { dryRun: true, label: cleanup.configPath });
const oauthRemoval = cleanup.oauthInsideState
? { ok: true }
: await removePath(cleanup.oauthDir, runtime, { dryRun: true, label: cleanup.oauthDir });
return stateRemoval.ok && configRemoval.ok && oauthRemoval.ok;
}
if (isUnsafeRemovalTarget(requestedStateDir)) {
runtime.error(`Refusing to remove unsafe path: ${shortenHomeInString(cleanup.stateDir)}`);
@@ -516,6 +516,7 @@ export async function removeWorkspaceDirs(
runtime: RuntimeEnv,
opts?: {
dryRun?: boolean;
preserveWorkspace?: boolean;
removeStateRows?: boolean;
removeWorkspace?: (workspace: string) => Promise<boolean>;
},
@@ -539,9 +540,11 @@ export async function removeWorkspaceDirs(
const statePlan = opts?.removeStateRows
? await attempt(stateLabel, () => prepareWorkspaceStateDeletion(workspace))
: undefined;
const result = opts?.removeWorkspace
? { ok: (await attempt(workspace, () => opts.removeWorkspace!(workspace))) === true }
: await removePath(workspace, runtime, { dryRun: opts?.dryRun, label: workspace });
const result = opts?.preserveWorkspace
? { ok: true }
: opts?.removeWorkspace
? { ok: (await attempt(workspace, () => opts.removeWorkspace!(workspace))) === true }
: await removePath(workspace, runtime, { dryRun: opts?.dryRun, label: workspace });
if (!result.ok) {
failures.add(workspace);
continue;
-1
View File
@@ -1504,7 +1504,6 @@ describe("maybeRepairLegacyCronStore", () => {
});
const jobs = await readPersistedJobs(storePath);
expect(jobs).toHaveLength(2);
expect(jobs.map((job) => job.id)).toEqual(["legacy-job", "legacy-only"]);
expect(requirePersistedJob(jobs, 0).name).toBe("SQLite wins");
expect(requirePersistedJob(jobs, 1).name).toBe("Legacy only");
-1
View File
@@ -74,7 +74,6 @@ describe("applyLocalSetupWorkspaceConfig", () => {
const result = applyLocalSetupWorkspaceConfig(baseConfig, "/tmp/workspace");
expect(result.agents?.list).toHaveLength(2);
expect(result.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]);
expect(result.bindings).toEqual(baseConfig.bindings);
});
+1
View File
@@ -143,6 +143,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
? await resolveCleanupPlanForDryRun()
: await resolveCleanupPlanForRemoval(runtime);
if (!cleanupPlan) {
runtime.exit(1);
return;
}
const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } =
+119 -24
View File
@@ -1,11 +1,11 @@
// Uninstall command tests cover cleanup flow, prompts, and runtime messages.
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupCommandLogMessages,
cleanupCommandErrorMessages,
createCleanupCommandRuntime,
gatewayService,
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
removePath,
removeStateAndLinkedPaths,
removeWorkspaceDirs,
resetCleanupCommandMocks,
@@ -49,7 +49,6 @@ describe("uninstallCommand", () => {
expect(removeStateAndLinkedPaths).not.toHaveBeenCalled();
expect(removeWorkspaceDirs).not.toHaveBeenCalled();
expect(prepareLegacyWorkspaceStateReset).not.toHaveBeenCalled();
expect(cleanupCommandLogMessages(runtime)).not.toContain(
"CLI still installed. Remove via npm/pnpm if desired.",
);
@@ -100,6 +99,26 @@ describe("uninstallCommand", () => {
expect(removeWorkspaceDirs).toHaveBeenCalledOnce();
});
it("attempts app cleanup when service teardown blocks local data", async () => {
const platform = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
gatewayService.stop.mockRejectedValue(new Error("stop failed"));
try {
await expect(
uninstallCommand(runtime, { all: true, yes: true, nonInteractive: true }),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removePath).toHaveBeenCalledWith(
"/Applications/OpenClaw.app",
runtime,
expect.any(Object),
);
expect(cleanupCommandErrorMessages(runtime)).toContain(
"State and workspace cleanup blocked because gateway service teardown failed.",
);
} finally {
platform.mockRestore();
}
});
it("removes an unloaded service definition before deleting user data", async () => {
gatewayService.isLoaded.mockResolvedValue(false);
@@ -163,12 +182,7 @@ describe("uninstallCommand", () => {
);
});
it("previews retired workspace files during state-only uninstall", async () => {
removeLegacyWorkspaceStateForReset.mockResolvedValueOnce({
removedPaths: ["/tmp/.openclaw/workspace/openclaw-workspace-state.json"],
warnings: [],
});
it("cleans retired workspace state without removing state-only workspaces", async () => {
await uninstallCommand(runtime, {
state: true,
yes: true,
@@ -176,14 +190,10 @@ describe("uninstallCommand", () => {
dryRun: true,
});
expect(prepareLegacyWorkspaceStateReset).toHaveBeenCalledWith("/tmp/.openclaw/workspace");
expect(removeLegacyWorkspaceStateForReset).toHaveBeenCalledWith(
{ workspaceDir: "/tmp/.openclaw/workspace" },
{ dryRun: true },
);
expect(cleanupCommandLogMessages(runtime)).toContain(
"[dry-run] remove /tmp/.openclaw/workspace/openclaw-workspace-state.json",
);
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: true,
preserveWorkspace: true,
});
});
it("does not preserve workspace dirs when workspace removal is selected", async () => {
@@ -237,16 +247,101 @@ describe("uninstallCommand", () => {
it("removes workspace rows when combined state removal fails", async () => {
removeStateAndLinkedPaths.mockResolvedValueOnce(false);
await uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
});
await expect(
uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: false,
removeStateRows: true,
});
});
it.each([
{
failure: "returns failures",
arrange: () => removeWorkspaceDirs.mockResolvedValueOnce(["retired state failed"]),
},
{
failure: "throws",
arrange: () => removeWorkspaceDirs.mockRejectedValueOnce(new Error("retired state failed")),
},
])(
"continues state and app cleanup when retired workspace cleanup $failure",
async ({ arrange }) => {
const platform = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
arrange();
try {
await expect(
uninstallCommand(runtime, {
state: true,
app: true,
yes: true,
nonInteractive: true,
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removeStateAndLinkedPaths).toHaveBeenCalledOnce();
expect(removePath).toHaveBeenCalledWith(
"/Applications/OpenClaw.app",
runtime,
expect.any(Object),
);
expect(cleanupCommandErrorMessages(runtime).join("\n")).toContain("retired state");
} finally {
platform.mockRestore();
}
},
);
it("fails when workspace cleanup returns failures", async () => {
removeWorkspaceDirs.mockResolvedValueOnce(["/tmp/.openclaw/workspace"]);
await expect(
uninstallCommand(runtime, { workspace: true, yes: true, nonInteractive: true }),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(cleanupCommandErrorMessages(runtime)).toContain(
"Workspace cleanup incomplete: /tmp/.openclaw/workspace",
);
});
it("blocks workspace cleanup after a thrown state ownership failure", async () => {
removeStateAndLinkedPaths.mockRejectedValueOnce(new Error("state is live"));
await expect(
uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removeWorkspaceDirs).not.toHaveBeenCalled();
expect(cleanupCommandErrorMessages(runtime)).toContain(
"Workspace cleanup blocked because state cleanup could not safely complete.",
);
});
it("reports app cleanup failure and non-macOS inapplicability", async () => {
const platform = vi.spyOn(process, "platform", "get");
platform.mockReturnValue("darwin");
removePath.mockResolvedValueOnce({ ok: false });
await expect(
uninstallCommand(runtime, { app: true, yes: true, nonInteractive: true }),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
resetCleanupCommandMocks();
silenceCleanupCommandRuntime(runtime);
platform.mockReturnValue("linux");
await uninstallCommand(runtime, { app: true, yes: true, nonInteractive: true });
expect(cleanupCommandLogMessages(runtime)).toContain(
"macOS app cleanup is not applicable on this platform.",
);
platform.mockRestore();
});
});
+78 -39
View File
@@ -8,16 +8,12 @@ import {
stylePromptMessage,
stylePromptTitle,
} from "../../packages/terminal-core/src/prompt-style.js";
import {
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../agents/workspace-legacy-state.js";
import { formatCliCommand } from "../cli/command-format.js";
import { isNixMode } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHomeDir, shortenHomeInString } from "../utils.js";
import { resolveHomeDir } from "../utils.js";
import { resolveCleanupPlanForDryRun, resolveCleanupPlanForRemoval } from "./cleanup-plan.js";
import { removePath, removeStateAndLinkedPaths, removeWorkspaceDirs } from "./cleanup-utils.js";
@@ -101,14 +97,16 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return stopped;
}
async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) {
async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean): Promise<boolean> {
if (process.platform !== "darwin") {
return;
runtime.log("macOS app cleanup is not applicable on this platform.");
return true;
}
await removePath("/Applications/OpenClaw.app", runtime, {
const result = await removePath("/Applications/OpenClaw.app", runtime, {
dryRun,
label: "/Applications/OpenClaw.app",
});
return result.ok;
}
function logBackupRecommendation(runtime: RuntimeEnv) {
@@ -181,6 +179,9 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
const dryRun = Boolean(opts.dryRun);
let stateRemoved = false;
let workspaceBlocked = false;
let failed = false;
let serviceSafe = true;
const removesLocalData = scopes.has("state") || scopes.has("workspace");
if (removesLocalData) {
@@ -193,57 +194,92 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
} else if (!(await stopAndUninstallService(runtime))) {
// Service removal may prevent relaunch even when runtime termination is
// uncertain; preserve mutable user data until teardown can be verified.
runtime.exit(1);
return;
serviceSafe = false;
failed = true;
}
}
const cleanupPlan = removesLocalData
? dryRun
? await resolveCleanupPlanForDryRun()
: await resolveCleanupPlanForRemoval(runtime)
: undefined;
if (removesLocalData && !cleanupPlan) {
return;
let cleanupPlan;
if (removesLocalData && serviceSafe) {
try {
cleanupPlan = dryRun
? await resolveCleanupPlanForDryRun()
: await resolveCleanupPlanForRemoval(runtime);
} catch (error) {
runtime.error(`Failed to prepare local data cleanup: ${formatErrorMessage(error)}`);
}
if (!cleanupPlan) {
failed = true;
}
} else if (removesLocalData) {
runtime.error("State and workspace cleanup blocked because gateway service teardown failed.");
}
if (scopes.has("state") && cleanupPlan) {
const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } =
cleanupPlan;
if (!scopes.has("workspace")) {
for (const workspaceDir of workspaceDirs) {
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { dryRun });
for (const removedPath of legacyCleanup.removedPaths) {
if (dryRun) {
runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`);
}
}
for (const warning of legacyCleanup.warnings) {
runtime.error(warning);
try {
const legacyFailures = await removeWorkspaceDirs(workspaceDirs, runtime, {
dryRun,
preserveWorkspace: true,
});
if (legacyFailures.length > 0) {
runtime.error(`Retired workspace state cleanup incomplete: ${legacyFailures.join(", ")}`);
failed = true;
}
} catch (error) {
runtime.error(`Retired workspace state cleanup failed: ${formatErrorMessage(error)}`);
failed = true;
}
}
// Preserve workspaces when state-only uninstall is requested; workspace scope removes them explicitly.
stateRemoved = await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun, preservePaths: scopes.has("workspace") ? [] : workspaceDirs },
);
try {
stateRemoved = await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun, preservePaths: scopes.has("workspace") ? [] : workspaceDirs },
);
} catch (error) {
runtime.error(`State cleanup failed: ${formatErrorMessage(error)}`);
workspaceBlocked = true;
}
failed ||= !stateRemoved;
}
if (scopes.has("workspace") && cleanupPlan) {
await removeWorkspaceDirs(cleanupPlan.workspaceDirs, runtime, {
dryRun,
removeStateRows: !scopes.has("state") || !stateRemoved,
});
if (scopes.has("workspace") && cleanupPlan && workspaceBlocked) {
runtime.error("Workspace cleanup blocked because state cleanup could not safely complete.");
} else if (scopes.has("workspace") && cleanupPlan) {
try {
const workspaceFailures = await removeWorkspaceDirs(cleanupPlan.workspaceDirs, runtime, {
dryRun,
removeStateRows: !scopes.has("state") || !stateRemoved,
});
if (workspaceFailures.length > 0) {
runtime.error(`Workspace cleanup incomplete: ${workspaceFailures.join(", ")}`);
failed = true;
}
} catch (error) {
runtime.error(`Workspace cleanup failed: ${formatErrorMessage(error)}`);
failed = true;
}
}
if (scopes.has("app")) {
await removeMacApp(runtime, dryRun);
try {
const appRemoved = await removeMacApp(runtime, dryRun);
if (!appRemoved) {
failed = true;
}
} catch (error) {
runtime.error(`App cleanup failed: ${formatErrorMessage(error)}`);
failed = true;
}
}
runtime.log("CLI still installed. Remove via npm/pnpm if desired.");
if (!failed) {
runtime.log("CLI still installed. Remove via npm/pnpm if desired.");
}
if (scopes.has("state") && !scopes.has("workspace") && cleanupPlan) {
const home = resolveHomeDir();
@@ -251,4 +287,7 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
runtime.log("Tip: workspaces were preserved. Re-run with --workspace to remove them.");
}
}
if (failed) {
runtime.exit(1);
}
}
+50
View File
@@ -310,6 +310,56 @@ describe("applyModelDefaults catalog seeding", () => {
expect(model.input).toEqual(["text", "image"]);
});
it("copies frozen catalog metadata before downstream normalization", async () => {
const supportedReasoningEfforts = Object.freeze(["low", "high"]);
const compat = Object.freeze({ supportedReasoningEfforts });
const frozenRegistry = {
plugins: [
{
id: "openai",
modelCatalog: {
providers: {
openai: {
models: [
Object.freeze({
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
reasoning: true,
compat,
}),
],
},
},
},
},
],
// SAFETY: minimal frozen manifest record reproducing production registry ownership.
} as never;
const { applyModelDefaults } = await import("./defaults.js");
const cfg = applyModelDefaults(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6" } as never],
},
},
},
},
{ manifestRegistry: frozenRegistry },
);
const model = expectDefined(
cfg.models?.providers?.openai?.models?.[0],
"materialized model entry",
);
expect(() => {
model.compat!.supportedReasoningEfforts = ["low"];
}).not.toThrow();
expect(compat.supportedReasoningEfforts).toEqual(["low", "high"]);
});
it("falls back to generic defaults when no catalog row matches", async () => {
const { applyModelDefaults } = await import("./defaults.js");
const cfg = applyModelDefaults(
+1 -1
View File
@@ -206,7 +206,7 @@ function buildManifestCatalogModelLookup(
}
}
}
return index.get(keyFor(providerId, modelId));
return structuredClone(index.get(keyFor(providerId, modelId)));
};
}
@@ -67,7 +67,6 @@ describe("conversation registry", () => {
});
const conversations = listConversations({ agentId: "main", storePath }, { channel: "reef" });
expect(conversations).toHaveLength(2);
expect(conversations.map((entry) => entry.target).toSorted()).toEqual([
"reef:peer-a",
"reef:peer-b",

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