Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
c642e1da24 fix(gateway): guard node approval policy writes 2026-06-01 01:52:02 +01:00
1211 changed files with 10274 additions and 31065 deletions

View File

@@ -358,8 +358,8 @@ jobs:
$env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
$env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
$env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $cacheRoot "openclaw-pnpm-node-modules"
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $env:PNPM_CONFIG_MODULES_DIR ".pnpm"
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $workspace "node_modules"
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $workspace "node_modules\.pnpm"
$env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"

View File

@@ -1953,7 +1953,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-minimax
label: Native live gateway profiles MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: stable full
@@ -2252,7 +2252,7 @@ jobs:
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

@@ -82,10 +82,7 @@
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/only-throw-error": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/prefer-promise-reject-errors": "error",
"typescript/restrict-plus-operands": "error",
"typescript/no-unnecessary-qualifier": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-arguments": "error",
@@ -112,8 +109,6 @@
"typescript/require-array-sort-compare": "error",
"typescript/restrict-template-expressions": "error",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "error",
"typescript/use-unknown-in-catch-callback-variable": "error",
"unicorn/consistent-date-clone": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-function-scoping": "off",
@@ -133,7 +128,6 @@
"unicorn/no-unnecessary-slice-end": "error",
"unicorn/no-useless-error-capture-stack-trace": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-useless-switch-case": "error",
"unicorn/no-zero-fractions": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-dom-node-text-content": "error",

View File

@@ -12,10 +12,6 @@ Docs: https://docs.openclaw.ai
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, and expose calmer composer controls. (#88772, #88825)
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
### Changes
@@ -25,28 +21,17 @@ Docs: https://docs.openclaw.ai
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
- Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the `skill_workshop` agent tool. Thanks @shakkernerd.
- Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
- iOS: support native iPad display layouts.
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
- Workboard: wire task-backed board runs and show task comments in the edit modal.
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
- Code mode: add MCP API files and docs for code-mode integrations.
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
- Control UI: add calmer chat composer controls for active chat entry. (#88772)
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
- Providers: add MiniMax M3 model support. (#88860)
- Doctor: add disk space health checks and stabilize post-upgrade JSON probes.
- Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
### Fixes
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
@@ -55,14 +40,10 @@ Docs: https://docs.openclaw.ai
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
- Plugins: preserve npm plugin roots after blocked installs, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
@@ -76,11 +57,6 @@ Docs: https://docs.openclaw.ai
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, honor Chromium executable overrides, and detect system Chromium for E2E.
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.

View File

@@ -1,6 +1,6 @@
# OpenClaw iOS (Super Alpha)
This iOS app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node` on iPhone and iPad.
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
## Distribution Status
@@ -34,7 +34,7 @@ open OpenClaw.xcodeproj
3. In Xcode:
- Scheme: `OpenClaw`
- Destination: connected iPhone or iPad (recommended for real behavior)
- Destination: connected iPhone (recommended for real behavior)
- Build configuration: `Debug`
- Run (`Product` -> `Run`)
4. If signing fails on a personal team:
@@ -245,13 +245,13 @@ gateway can only send pushes for iOS devices that paired with that gateway.
- Pairing via QR or setup code flow (`/pair qr` or `/pair`, then `/pair approve` in Telegram).
- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt.
- Chat + Talk surfaces through the operator gateway session.
- iOS node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
- Authenticated background `node.presence.alive` beacons that update gateway last-seen metadata when the app moves between foreground and background, without treating suspended sockets as connected.
- Share extension deep-link forwarding into the connected gateway session.
## Computer Use Relationship
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone or iPad canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
## Location Automation Use Case (Testing)

View File

@@ -50,11 +50,6 @@ struct ChatProTab: View {
.onChange(of: self.appModel.chatSessionKey) { _, _ in
self.syncChatViewModel()
}
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
guard connected else { return }
self.syncChatViewModel()
self.viewModel?.refresh()
}
}
private var header: some View {
@@ -156,8 +151,7 @@ struct ChatProTab: View {
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected &&
self.appModel.isOperatorGatewayConnected
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var chatUserAccent: Color {

View File

@@ -45,7 +45,6 @@ struct SettingsProTab: View {
@State var gatewayPassword = ""
@State var manualGatewayPortText = ""
@State var setupStatusText: String?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State var defaultShareInstruction = ""
@State var showGatewayProblemDetails = false
@@ -83,7 +82,6 @@ struct SettingsProTab: View {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -109,17 +107,9 @@ struct SettingsProTab: View {
.onChange(of: self.gatewayPassword) { _, newValue in
self.persistGatewayPassword(newValue)
}
.onChange(of: self.setupCode) { _, newValue in
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.stagedGatewaySetupLink = nil
}
}
.onChange(of: self.defaultShareInstruction) { _, newValue in
ShareToAgentSettings.saveDefaultInstruction(newValue)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {

View File

@@ -202,29 +202,17 @@ extension SettingsProTab {
await self.connectManual()
}
func applyPendingGatewaySetupLinkIfNeeded() {
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
self.setupCode = ""
self.setupStatusText = nil
self.stagedGatewaySetupLink = link
let security = link.tls ? "TLS" : "plain"
self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply."
}
@discardableResult
func applySetupCode() -> Bool {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
let stagedLink = self.stagedGatewaySetupLink
guard !raw.isEmpty || stagedLink != nil else {
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return false
}
guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return false
}
self.stagedGatewaySetupLink = nil
self.applyGatewayLink(link)
return true
}
@@ -311,7 +299,7 @@ extension SettingsProTab {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
self.setupStatusText = "Tailscale is off on this iPhone. Turn it on, then try again."
return false
}
self.setupStatusText = "Checking gateway reachability..."
@@ -522,15 +510,10 @@ extension SettingsProTab {
return gatewayStatus
}
var canApplyGatewaySetup: Bool {
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| self.stagedGatewaySetupLink != nil
}
var tailnetWarningText: String? {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
return "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect."
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
}
func friendlyGatewayMessage(from raw: String) -> String? {

View File

@@ -542,7 +542,7 @@ extension SettingsProTab {
{
Task { await self.applySetupCodeAndConnect() }
}
.disabled(!self.canApplyGatewaySetup)
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if let status = self.setupStatusLine {
Text(status)

View File

@@ -111,7 +111,7 @@ struct GatewayProblemBanner: View {
case .gateway:
"Fix on gateway"
case .iphone:
"Fix on this device"
"Fix on iPhone"
case .both:
"Check both"
case .network:
@@ -227,9 +227,9 @@ struct GatewayProblemDetailsSheet: View {
case .gateway:
"Primary fix: gateway"
case .iphone:
"Primary fix: this device"
"Primary fix: this iPhone"
case .both:
"Primary fix: check both this device and the gateway"
"Primary fix: check both this iPhone and the gateway"
case .network:
"Primary fix: network or remote access"
case .unknown:

View File

@@ -138,9 +138,7 @@ final class NodeAppModel {
var homeCanvasRevision: Int = 0
var lastShareEventText: String = "No share events yet."
var openChatRequestID: Int = 0
var gatewaySetupRequestID: Int = 0
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var pendingGatewaySetupLink: GatewayConnectDeepLink?
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
private(set) var pendingExecApprovalPromptResolving: Bool = false
private(set) var pendingExecApprovalPromptErrorText: String?
@@ -4136,23 +4134,11 @@ extension NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case let .gateway(link):
self.stageGatewaySetupLink(link)
case .dashboard:
case .gateway, .dashboard:
break
}
}
func stageGatewaySetupLink(_ link: GatewayConnectDeepLink) {
self.pendingGatewaySetupLink = link
self.gatewaySetupRequestID &+= 1
}
func consumePendingGatewaySetupLink() -> GatewayConnectDeepLink? {
defer { self.pendingGatewaySetupLink = nil }
return self.pendingGatewaySetupLink
}
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }

View File

@@ -7,7 +7,7 @@ struct OnboardingIntroStep: View {
VStack(spacing: 0) {
Spacer()
Image(systemName: UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone.gen3")
Image(systemName: "iphone.gen3")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.padding(.bottom, 18)
@@ -17,7 +17,7 @@ struct OnboardingIntroStep: View {
.multilineTextAlignment(.center)
.padding(.bottom, 10)
Text("Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools.")
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -114,7 +114,7 @@ struct OnboardingWelcomeStep: View {
.foregroundStyle(.secondary)
Text("/pair qr")
.font(.system(.footnote, design: .monospaced).weight(.semibold))
Text("Then scan the QR code here to connect this device.")
Text("Then scan the QR code here to connect this iPhone.")
.font(.footnote)
.foregroundStyle(.secondary)
}

View File

@@ -669,8 +669,8 @@ extension OpenClawApp {
switch route {
case .agent, .dashboard:
await self.appModel.handleDeepLink(url: url)
case let .gateway(link):
self.appModel.stageGatewaySetupLink(link)
case .gateway:
break
}
}

View File

@@ -32,7 +32,6 @@ struct RootTabs: View {
@State private var didAutoOpenSettings: Bool = false
@State private var didApplyInitialAppearance: Bool = false
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
private enum AppTab: Hashable {
case control
@@ -238,7 +237,6 @@ struct RootTabs: View {
.onAppear { self.updateCanvasState() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onAppear { self.maybeOpenSettingsForGatewaySetup() }
.onAppear { self.maybeShowQuickSetup() }
.onAppear { self.applyInitialAppearanceIfNeeded() }
.onAppear { self.applyInitialChatSessionIfNeeded() }
@@ -298,9 +296,6 @@ struct RootTabs: View {
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectedTab = .chat
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
}
}
private func rootPresentation(_ content: some View) -> some View {
@@ -565,16 +560,6 @@ struct RootTabs: View {
self.selectedTab = .settings
}
private func maybeOpenSettingsForGatewaySetup() {
let requestID = self.appModel.gatewaySetupRequestID
guard requestID != 0, requestID != self.handledGatewaySetupRequestID else { return }
self.handledGatewaySetupRequestID = requestID
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectedTab = .settings
}
private func applyInitialChatSessionIfNeeded() {
guard !self.didApplyInitialChatSession else { return }
self.didApplyInitialChatSession = true

View File

@@ -147,8 +147,8 @@ struct TalkPermissionPromptView: View {
case .upgradeRequested:
"Approve this request on your gateway. Talk will start automatically when approval lands."
default:
"This device needs gateway approval before Talk can use realtime voice. Audio will go directly from " +
"this device to the voice provider."
"This iPhone needs gateway approval before Talk can use realtime voice. Audio will go directly from " +
"this phone to the voice provider."
}
}

View File

@@ -1,12 +1,12 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this iOS app with your OpenClaw Gateway to use your iPhone or iPad as a secure node for chat, voice, approvals, sharing, and device-aware automation.
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from iPhone or iPad
- Chat with your assistant from iPhone
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your iPhone or iPad
- Review Gateway action approvals from your phone
- Share text, links, and media directly from iOS into OpenClaw
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
- Receive push wakes and node status updates for connected workflows
@@ -16,4 +16,4 @@ OpenClaw is local-first: you control your gateway, keys, configuration, and perm
Getting started:
1) Set up your OpenClaw Gateway
2) Open the iOS app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your iPhone or iPad
3) Start using chat, Talk mode, approvals, and automations from your phone

View File

@@ -1 +1 @@
Pair your iPhone or iPad with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.

View File

@@ -97,7 +97,7 @@ targets:
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
TARGETED_DEVICE_FAMILY: "1,2"
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES

View File

@@ -213,7 +213,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway token required",
message: authError.userMessageOverride
?? "This gateway requires an auth token, but this device did not send one.",
?? "This gateway requires an auth token, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
@@ -229,7 +229,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride
?? "The token on this device does not match the gateway token.",
?? "The token on this iPhone does not match the gateway token.",
actionLabel: authError
.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand,
@@ -262,7 +262,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway password required",
message: authError.userMessageOverride
?? "This gateway requires a password, but this device did not send one.",
?? "This gateway requires a password, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
@@ -278,7 +278,7 @@ public enum GatewayConnectionProblemMapper {
owner: .both,
title: authError.titleOverride ?? "Gateway password is out of date",
message: authError.userMessageOverride
?? "The saved password on this device does not match the gateway password.",
?? "The saved password on this iPhone does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
@@ -322,7 +322,7 @@ public enum GatewayConnectionProblemMapper {
return self.problem(
kind: .deviceTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "This device's saved device token is no longer valid",
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
message: authError.userMessageOverride
?? "The gateway rejected the stored device token for this role.",
actionLabel: authError.actionLabel ?? "Repair pairing",
@@ -355,7 +355,7 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride
??
"This connection must include a signed device identity before the gateway can bind permissions to this device.",
"This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
@@ -369,7 +369,7 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake expired",
message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check device time",
actionLabel: authError.actionLabel ?? "Check iPhone time",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(
authError.docsURLString,
@@ -415,8 +415,8 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the identity this device presented.",
actionLabel: authError.actionLabel ?? "Re-pair this device",
?? "The gateway could not verify the identity this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
@@ -429,8 +429,8 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the public key this device presented.",
actionLabel: authError.actionLabel ?? "Re-pair this device",
?? "The gateway could not verify the public key this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
@@ -444,7 +444,7 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway rejected the device identity because the device ID did not match.",
actionLabel: authError.actionLabel ?? "Re-pair this device",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
@@ -745,7 +745,7 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
??
"This device is already paired, but it is requesting a new role that was not previously approved.",
"This iPhone is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -759,7 +759,7 @@ public enum GatewayConnectionProblemMapper {
owner: .gateway,
title: authError.titleOverride ?? "Additional permissions required",
message: authError.userMessageOverride
?? "This device is already paired, but it is requesting new permissions that require approval.",
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -786,7 +786,7 @@ public enum GatewayConnectionProblemMapper {
return self.problem(
kind: .pairingRequired,
owner: .gateway,
title: authError.titleOverride ?? "This device is not approved yet",
title: authError.titleOverride ?? "This iPhone is not approved yet",
message: authError.userMessageOverride
?? "The gateway received the connection request, but this device must be approved first.",
actionLabel: authError.actionLabel ?? "Approve on gateway",

View File

@@ -165,8 +165,6 @@ const config = {
"vite.config.ts!",
"vitest*.ts!",
],
// Workboard lazy-loads Three.js at runtime; Knip's dependency pass misses it.
ignoreDependencies: ["three"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/sdk": {

View File

@@ -1,4 +1,4 @@
cc0fb4e3f1a7e8f233626adb80d686608ddac8c177fe6a55b33970c2baf4ace4 config-baseline.json
042ca98e6200a365accda00e5a6f3e72bdae5853f39ff0cdc3b2cb9c0d6f8f3e config-baseline.core.json
cbf81829dcc8cfd0a16435912da709f8c1d508707385b6493f94cafe211ec67c config-baseline.channel.json
4012b1f8de6f9527c47320a6c7120f30dc30ac1b5524ed63dadef890aad44b20 config-baseline.plugin.json
f4a00ada9d154a4d3a54e109aa6e9f73f22b09d7df9ab6745e87f88724eec06b config-baseline.json
5ee177382cf32c2816dca0a4e67cd6c01df1045d600b21a6e9c11639ddb10ce8 config-baseline.core.json
0e654bad3f1ef9100f76e512c4453c1f26b6bc1f5ee121ce505d0624a1dad4cd config-baseline.channel.json
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
47d4365c4133f57769758907b7cf1a43d17e040db0570a1433e2f03e4fb0bd02 plugin-sdk-api-baseline.json
161027bba89497f5e30127dd0f57b4da623270cfc771b989e29262da5760e723 plugin-sdk-api-baseline.jsonl
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl

View File

@@ -65,7 +65,7 @@ Use this checklist when you already know your old BlueBubbles config and want th
imsg rpc --help
```
Replace `42` with a real chat id from `imsg chats`. Sending requires Automation permission for Messages.app. If OpenClaw will run through SSH, run these commands through the same SSH wrapper or user context that OpenClaw will use. If reads/probes work but sends fail with AppleEvents `-1743`, check whether Automation landed on `/usr/libexec/sshd-keygen-wrapper`; see [SSH wrapper sends fail with AppleEvents -1743](/channels/imessage#ssh-wrapper-sends-fail-with-appleevents-1743).
Replace `42` with a real chat id from `imsg chats`. Sending requires Automation permission for Messages.app. If OpenClaw will run through SSH, run these commands through the same SSH wrapper or user context that OpenClaw will use.
3. Enable the private API bridge when you need advanced actions:

View File

@@ -151,29 +151,6 @@ imsg send <handle> "test"
</Tip>
<Accordion title="SSH wrapper sends fail with AppleEvents -1743">
A remote-SSH setup can read chats, pass `channels status --probe`, and process inbound messages while outbound sends still fail with an AppleEvents authorization error:
```text
Not authorized to send Apple events to Messages. (-1743)
```
Check the signed-in Mac user's TCC database or System Settings > Privacy & Security > Automation. If the Automation entry is recorded for `/usr/libexec/sshd-keygen-wrapper` instead of the `imsg` or local shell process, macOS may not expose a usable Messages toggle for that SSH server-side client:
```text
kTCCServiceAppleEvents | /usr/libexec/sshd-keygen-wrapper | auth_value=0 | com.apple.MobileSMS
```
In that state, repeating `tccutil reset AppleEvents` or rerunning `imsg send` through the same SSH wrapper may keep failing because the process context that needs Messages Automation is the SSH wrapper, not an app the UI can grant.
Use one of the supported `imsg` process contexts instead:
- Run the Gateway, or at least the `imsg` bridge, in the logged-in Messages user's local session.
- Start the Gateway with a LaunchAgent for that user after granting Full Disk Access and Automation from the same session.
- If you keep the two-user SSH topology, verify that a real outbound `imsg send` succeeds through the exact wrapper before enabling the channel. If it cannot be granted Automation, reconfigure to a single-user `imsg` setup instead of relying on the SSH wrapper for sends.
</Accordion>
## Enabling the imsg private API
`imsg` ships in two operational modes:

View File

@@ -336,7 +336,7 @@ Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
### Plugin index
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to the shared SQLite state database under the active OpenClaw state directory. The `installed_plugin_index` row stores durable `installRecords` metadata, including records for broken or missing plugin manifests, plus a manifest-derived cold registry cache used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to `plugins/installs.json` under the active OpenClaw state directory. Its top-level `installRecords` map is the durable source of install metadata, including records for broken or missing plugin manifests. The `plugins` array is the manifest-derived cold registry cache. The file includes a do-not-edit warning and is used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
When OpenClaw sees shipped legacy `plugins.installs` records in config, runtime reads treat them as compatibility input without rewriting `openclaw.json`. Explicit plugin writes and `openclaw doctor --fix` move those records into the plugin index and remove the config key when config writes are allowed; if either write fails, the config records are kept so the install metadata is not lost.

View File

@@ -22,7 +22,6 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre
| `--workspace <dir>` | Agent workspace directory (default `~/.openclaw/workspace`; stored as `agents.defaults.workspace`). |
| `--wizard` | Run interactive onboarding. |
| `--non-interactive` | Run onboarding without prompts. |
| `--accept-risk` | Acknowledge full-system agent access risk; required with `--non-interactive`. |
| `--mode <mode>` | Onboarding mode: `local` or `remote`. |
| `--import-from <provider>` | Migration provider to run during onboarding. |
| `--import-source <path>` | Source agent home for `--import-from`. |
@@ -34,7 +33,7 @@ Initialize the baseline config and agent workspace. With any onboarding flag pre
`openclaw setup` runs the wizard when any of these flags are explicitly present, even without `--wizard`:
`--wizard`, `--non-interactive`, `--accept-risk`, `--mode`, `--import-from`, `--import-source`, `--import-secrets`, `--remote-url`, `--remote-token`.
`--wizard`, `--non-interactive`, `--mode`, `--import-from`, `--import-source`, `--import-secrets`, `--remote-url`, `--remote-token`.
## Examples
@@ -43,7 +42,7 @@ openclaw setup
openclaw setup --workspace ~/.openclaw/workspace
openclaw setup --wizard
openclaw setup --wizard --import-from hermes --import-source ~/.hermes
openclaw setup --non-interactive --accept-risk --mode remote --remote-url wss://gateway-host:18789 --remote-token <token>
openclaw setup --non-interactive --mode remote --remote-url wss://gateway-host:18789 --remote-token <token>
```
## Notes

View File

@@ -99,10 +99,7 @@ openclaw workboard dispatch --url http://127.0.0.1:18789 --token "$OPENCLAW_GATE
`dispatch` first calls the running Gateway RPC method
`workboard.cards.dispatch`. That path uses the same subagent runtime as the
dashboard dispatch action, so ready cards become task-tracked worker runs with
linked session keys. Cards with an assigned agent use agent-scoped subagent
session keys; unassigned cards keep an unscoped subagent key so the Gateway's
configured default agent is preserved.
dashboard dispatch action, so ready cards can become real worker sessions.
The dispatch loop:
@@ -113,8 +110,8 @@ The dispatch loop:
5. Claims each selected card for the dispatcher or assigned agent.
6. Starts a subagent worker run with bounded card context and the card claim
token.
7. Stores the worker run id, session key, task linkage when the Gateway task
ledger reports it, execution status, and worker log on the card.
7. Stores the worker run id, session key, execution status, and worker log on
the card.
Selection is intentionally conservative. One dispatch starts at most three
workers by default, skips archived or already-claimed cards, and starts only one
@@ -149,10 +146,6 @@ JSON output includes the dispatch result. Gateway-backed dispatch can include
`started` and `startFailures`; data-only fallback includes
`gatewayUnavailable: true`. Claim tokens are redacted from card JSON output.
In the dashboard, the same dispatch result is shown as a short summary so an
operator can see how many cards started, promoted, blocked, reclaimed, or
failed without opening card details.
## Slash Command Parity
Command-capable channels can use the matching slash command:

View File

@@ -99,7 +99,7 @@ These are the standard files OpenClaw expects inside the workspace:
</AccordionGroup>
<Note>
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into the session and continues. Large bootstrap files are truncated when injected; adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `openclaw setup` can recreate missing defaults without overwriting existing files.
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into the session and continues. Large bootstrap files are truncated when injected; adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `openclaw setup` can recreate missing defaults without overwriting existing files.
</Note>
## What is NOT in the workspace

View File

@@ -41,8 +41,6 @@ If a file is missing, OpenClaw injects a single "missing file" marker line (and
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). While it is pending, OpenClaw keeps it in Project Context and adds system-prompt bootstrap guidance for the initial ritual instead of copying it into the user message. If you delete it after completing the ritual, it should not be recreated on later restarts.
After a workspace has been observed, OpenClaw also keeps a state-dir attestation marker for the workspace path. If a recently attested workspace disappears or is wiped, startup refuses to silently re-seed `BOOTSTRAP.md`; restore the workspace or use a full onboard reset so the workspace and marker are cleared together.
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:
```json5

View File

@@ -122,7 +122,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (first-run only)
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `always`).

View File

@@ -107,13 +107,6 @@ Deep ranking uses six weighted base signals plus phase reinforcement:
Light and REM phase hits add a small recency-decayed boost from `memory/.dreams/phase-signals.json`.
Shadow-trial results can be layered on top of that base score as a review
signal before any durable write. A helpful trial gives the candidate a small
bounded boost, a neutral trial keeps it deferred, and a harmful trial marks it
as rejected for that scoring pass. This signal is still report-only: it can
change candidate ordering or review metadata, but it does not write to
`MEMORY.md` or promote the candidate by itself.
## QA shadow trial report coverage
QA Lab includes a report-only scenario for exploring how a future dreaming

View File

@@ -303,7 +303,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
| Hugging Face Inference | `huggingface` | `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN` | `huggingface/deepseek-ai/DeepSeek-R1` |
| Kilo Gateway | `kilocode` | `KILOCODE_API_KEY` | `kilocode/kilo/auto` |
| Kimi Coding | `kimi` | `KIMI_API_KEY` or `KIMICODE_API_KEY` | `kimi/kimi-for-coding` |
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M3` |
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M2.7` |
| Mistral | `mistral` | `MISTRAL_API_KEY` | `mistral/mistral-large-latest` |
| Moonshot | `moonshot` | `MOONSHOT_API_KEY` | `moonshot/kimi-k2.6` |
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-super-120b-a12b` |
@@ -331,7 +331,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
Gemini-backed refs follow the same proxy-Gemini sanitation path; `kilocode/kilo/auto` and other proxy-reasoning-unsupported refs skip proxy reasoning injection.
</Accordion>
<Accordion title="MiniMax">
API-key onboarding writes explicit M3 and M2.7 chat model definitions; image understanding stays on the plugin-owned `MiniMax-VL-01` media provider.
API-key onboarding writes explicit text-only M2.7 chat model definitions; image understanding stays on the plugin-owned `MiniMax-VL-01` media provider.
</Accordion>
<Accordion title="NVIDIA">
Model ids use a `nvidia/<vendor>/<model>` namespace (for example `nvidia/nvidia/nemotron-...` alongside `nvidia/moonshotai/kimi-k2.5`); pickers preserve the literal `<provider>/<model-id>` composition while the canonical key sent to the API stays single-prefixed.
@@ -537,7 +537,7 @@ On MiniMax's Anthropic-compatible streaming path, OpenClaw disables thinking by
Plugin-owned capability split:
- Text/chat defaults stay on `minimax/MiniMax-M3`
- Text/chat defaults stay on `minimax/MiniMax-M2.7`
- Image generation is `minimax/image-01` or `minimax-portal/image-01`
- Image understanding is plugin-owned `MiniMax-VL-01` on both MiniMax auth paths
- Web search stays on provider id `minimax`

View File

@@ -208,7 +208,7 @@ because of the bootstrap file limits below.
</Note>
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
(default: 60000). Missing files inject a short missing-file marker. When truncation
occurs, OpenClaw can inject a concise system-prompt warning notice; control this with

View File

@@ -103,11 +103,11 @@ Per-agent override: `agents.list[].contextInjection`. Omitted values inherit
### `agents.defaults.bootstrapMaxChars`
Max characters per workspace bootstrap file before truncation. Default: `20000`.
Max characters per workspace bootstrap file before truncation. Default: `12000`.
```json5
{
agents: { defaults: { bootstrapMaxChars: 20000 } },
agents: { defaults: { bootstrapMaxChars: 12000 } },
}
```
@@ -138,7 +138,7 @@ injection behavior from the shared defaults. Omitted fields inherit from
agents: {
defaults: {
contextInjection: "continuation-skip",
bootstrapMaxChars: 20000,
bootstrapMaxChars: 12000,
bootstrapTotalMaxChars: 60000,
},
list: [

View File

@@ -597,8 +597,6 @@ BlueBubbles support was removed. `channels.bluebubbles` is not a supported runti
If the Gateway is not running on the signed-in Messages Mac, keep `channels.imessage.enabled=true` and set `channels.imessage.cliPath` to an SSH wrapper that runs `imsg "$@"` on that Mac. The default local `imsg` path is macOS-only.
Before relying on an SSH wrapper for production sends, verify an outbound `imsg send` through that exact wrapper. Some macOS TCC states assign Messages Automation to `/usr/libexec/sshd-keygen-wrapper`, which can make reads and probes work while sends fail with AppleEvents `-1743`; see [SSH wrapper sends fail with AppleEvents -1743](/channels/imessage#ssh-wrapper-sends-fail-with-appleevents-1743).
```json5
{
channels: {

View File

@@ -645,14 +645,14 @@ Interactive custom-provider onboarding infers image input for common vision mode
<Accordion title="Local models (LM Studio)">
See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
</Accordion>
<Accordion title="MiniMax M3 (direct)">
<Accordion title="MiniMax M2.7 (direct)">
```json5
{
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M3" },
model: { primary: "minimax/MiniMax-M2.7" },
models: {
"minimax/MiniMax-M3": { alias: "Minimax" },
"minimax/MiniMax-M2.7": { alias: "Minimax" },
},
},
},
@@ -665,12 +665,12 @@ Interactive custom-provider onboarding infers image input for common vision mode
api: "anthropic-messages",
models: [
{
id: "MiniMax-M3",
name: "MiniMax M3",
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 },
contextWindow: 1000000,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
contextWindow: 204800,
maxTokens: 131072,
},
],
@@ -680,7 +680,7 @@ Interactive custom-provider onboarding infers image input for common vision mode
}
```
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M3 and also includes the M2.7 variants. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M2.7 only. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
</Accordion>
<Accordion title="Moonshot AI (Kimi)">

View File

@@ -215,7 +215,7 @@ troubleshooting, see the main [FAQ](/help/faq).
</Accordion>
<Accordion title='Why do I see "Unknown model: minimax/MiniMax-M3"?'>
<Accordion title='Why do I see "Unknown model: minimax/MiniMax-M2.7"?'>
This means the **provider isn't configured** (no MiniMax provider config or auth
profile was found), so the model can't be resolved.
@@ -227,9 +227,8 @@ troubleshooting, see the main [FAQ](/help/faq).
(`MINIMAX_API_KEY` for `minimax`, `MINIMAX_OAUTH_TOKEN` or stored MiniMax
OAuth for `minimax-portal`).
3. Use the exact model id (case-sensitive) for your auth path:
`minimax/MiniMax-M3`, `minimax/MiniMax-M2.7`, or
`minimax/MiniMax-M2.7-highspeed` for API-key setup, or
`minimax-portal/MiniMax-M3`, `minimax-portal/MiniMax-M2.7`, or
`minimax/MiniMax-M2.7` or `minimax/MiniMax-M2.7-highspeed` for API-key
setup, or `minimax-portal/MiniMax-M2.7` /
`minimax-portal/MiniMax-M2.7-highspeed` for OAuth setup.
4. Run:
@@ -254,9 +253,9 @@ troubleshooting, see the main [FAQ](/help/faq).
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M3" },
model: { primary: "minimax/MiniMax-M2.7" },
models: {
"minimax/MiniMax-M3": { alias: "minimax" },
"minimax/MiniMax-M2.7": { alias: "minimax" },
"openai/gpt-5.5": { alias: "gpt" },
},
},

View File

@@ -631,48 +631,6 @@ lives on the [First-run FAQ](/help/faq-first-run).
</Accordion>
<Accordion title="Can I make SOUL.md bigger?">
Yes. `SOUL.md` is one of the workspace bootstrap files injected into the
agent context. The default per-file injection limit is `20000` characters,
and the total bootstrap budget across files is `60000` characters.
Change the shared defaults in your OpenClaw config:
```json5
{
agents: {
defaults: {
bootstrapMaxChars: 50000,
bootstrapTotalMaxChars: 300000,
},
},
}
```
Or override one agent:
```json5
{
agents: {
list: [
{
id: "main",
bootstrapMaxChars: 50000,
bootstrapTotalMaxChars: 300000,
},
],
},
}
```
Use `/context` to check raw vs injected sizes and whether truncation happened.
Keep `SOUL.md` focused on voice, stance, and personality; put operating rules
in `AGENTS.md` and durable facts in memory.
See [Context](/concepts/context) and [Agent config](/gateway/config-agents).
</Accordion>
<Accordion title="Recommended backup strategy">
Put your **agent workspace** in a **private** git repo and back it up somewhere
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER

View File

@@ -73,11 +73,10 @@ Live tests are split into two layers so we can isolate failures:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- Set `OPENCLAW_LIVE_MODELS=modern`, `small`, or `all` (alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
- How to select models:
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M3, Grok 4.3)
- `OPENCLAW_LIVE_MODELS=small` to run the constrained small-model allowlist (Qwen 8B/9B local-compatible routes, Ollama Gemma, OpenRouter Qwen/GLM, and Z.AI GLM)
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M2.7, Grok 4.3)
- `OPENCLAW_LIVE_MODELS=small` to run the constrained small-model allowlist (Qwen 8B/9B local-compatible routes, OpenRouter Qwen/GLM, and Z.AI GLM)
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,..."` (comma allowlist)
- Local Ollama small-model runs default to `http://127.0.0.1:11434`; set `OPENCLAW_LIVE_OLLAMA_BASE_URL` only for LAN, custom, or Ollama Cloud endpoints.
- Modern/all and small sweeps default to their curated caps; set `OPENCLAW_LIVE_MAX_MODELS=0` for an exhaustive selected-profile sweep or a positive number for a smaller cap.
- Exhaustive sweeps use `OPENCLAW_LIVE_TEST_TIMEOUT_MS` for the whole direct-model test timeout. Default: 60 minutes.
- Direct-model probes run with 20-way parallelism by default; set `OPENCLAW_LIVE_MODEL_CONCURRENCY` to override.
@@ -109,7 +108,7 @@ Live tests are split into two layers so we can isolate failures:
- How to enable:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- How to select models:
- Default: modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M3, Grok 4.3)
- Default: modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M2.7, Grok 4.3)
- `OPENCLAW_LIVE_GATEWAY_MODELS=all` is an alias for the modern allowlist
- Or set `OPENCLAW_LIVE_GATEWAY_MODELS="provider/model"` (or comma list) to narrow
- Modern/all gateway sweeps default to a curated high-signal cap; set `OPENCLAW_LIVE_GATEWAY_MAX_MODELS=0` for an exhaustive modern sweep or a positive number for a smaller cap.
@@ -351,7 +350,7 @@ Narrow, explicit allowlists are fastest and least flaky:
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Tool calling across several providers:
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M3" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M2.7" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Google focus (Gemini API key + Antigravity):
- Gemini (API key): `OPENCLAW_LIVE_GATEWAY_MODELS="google/gemini-3-flash-preview" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
@@ -385,10 +384,10 @@ This is the "common models" run we expect to keep working:
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
- DeepSeek: `deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro`
- Z.AI (GLM): `zai/glm-5.1`
- MiniMax: `minimax/MiniMax-M3`
- MiniMax: `minimax/MiniMax-M2.7`
Run gateway smoke with tools + image:
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3.1-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M3" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3.1-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M2.7" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
### Baseline: tool calling (Read + optional Exec)
@@ -399,7 +398,7 @@ Pick at least one per provider family:
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3.1-pro-preview`)
- DeepSeek: `deepseek/deepseek-v4-flash`
- Z.AI (GLM): `zai/glm-5.1`
- MiniMax: `minimax/MiniMax-M3`
- MiniMax: `minimax/MiniMax-M2.7`
Optional additional coverage (nice to have):

View File

@@ -245,7 +245,7 @@ Notes:
The iOS app is a mobile node surface, not a Codex Computer Use backend. Codex
Computer Use and `cua-driver mcp` control a local macOS desktop through MCP
tools; the iOS app exposes iPhone and iPad capabilities through OpenClaw node commands
tools; the iOS app exposes iPhone capabilities through OpenClaw node commands
such as `canvas.*`, `camera.*`, `screen.*`, `location.*`, and `talk.*`.
Agents can still operate the iOS app through OpenClaw by invoking node

View File

@@ -1021,10 +1021,10 @@ plugin index entry with `source: "path"` and a workspace-relative
`plugins.load.paths`; the install record avoids duplicating local workstation
paths into long-lived config. This keeps local development installs visible to
source-plane diagnostics without adding a second raw filesystem-path disclosure
surface. The persisted `installed_plugin_index` SQLite row is the install
surface. The persisted `plugins/installs.json` plugin index is the install
source of truth and can be refreshed without loading plugin runtime modules.
Its `installRecords` map is durable even when a plugin manifest is missing or
invalid; its `plugins` payload is a rebuildable manifest view.
invalid; its `plugins` array is a rebuildable manifest view.
## Context engine plugins

View File

@@ -399,10 +399,8 @@ media caption.
Message hook contexts expose stable correlation fields when available:
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Inbound
and `before_dispatch` contexts also expose reply metadata when the channel has
visibility-filtered quoted message data: `replyToId`, `replyToBody`, and
`replyToSender`. Prefer these first-class fields before reading legacy metadata.
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Prefer
these first-class fields before reading legacy metadata.
Prefer typed `threadId` and `replyToId` fields before using channel-specific
metadata.

View File

@@ -17,15 +17,3 @@ Adds syntax highlighting for languages outside the default diffs viewer set.
## Surface
plugin
<!-- openclaw-plugin-reference:manual-start -->
## Added languages
The base `diffs` plugin already highlights the common languages documented in [Diffs](/tools/diffs). Install this language pack when you want syntax highlighting for a broader set of Shiki-supported languages. If the pack is not installed, those files still render as readable plain text.
Examples include Astro, Vue, Svelte, MDX, GraphQL, Terraform/HCL, Nix, Clojure, Elixir, Haskell, OCaml, Scala, Zig, Solidity, Verilog/VHDL, Fortran, MATLAB, LaTeX, Mermaid, Sass/Less/SCSS, Nginx, Apache, CSV, dotenv, INI, and diff files.
See [Shiki languages](https://shiki.style/languages) for Shiki's upstream language and alias catalog.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -652,7 +652,6 @@ releases.
| `plugin-sdk/zod` | Deprecated Zod compatibility re-export | Import `zod` from `zod` directly |
| `plugin-sdk/memory-core` | Bundled memory-core helpers | Memory manager/config/file/CLI helper surface |
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-embedding-registry` | Memory embedding registry | Lightweight memory embedding provider registry helpers |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |

View File

@@ -355,7 +355,6 @@ usage endpoint failed or returned no usable usage data.
| --- | --- |
| `plugin-sdk/memory-core` | Bundled memory-core helper surface for manager/config/file/CLI helpers |
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-embedding-registry` | Lightweight memory embedding provider registry helpers |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers. `registerMemoryEmbeddingProvider` on this surface is deprecated; use the generic embedding provider API for new providers. |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |

View File

@@ -9,8 +9,7 @@ title: "Workboard plugin"
The Workboard plugin adds an optional Kanban-style board to the
[Control UI](/web/control-ui). Use it to collect agent-sized work cards, assign
them to agents, and track the linked background task, run, and dashboard
session from one card.
them to agents, and jump from a card into the linked dashboard session.
Workboard is intentionally small. It tracks local operating work for an
OpenClaw Gateway; it is not a replacement for GitHub Issues, Linear, Jira, or
@@ -48,8 +47,8 @@ Each card stores:
- priority: `low`, `normal`, `high`, or `urgent`
- labels
- optional agent id
- optional linked task, run, session, or source URL
- optional execution metadata for a Codex or Claude run started from the card
- optional linked session, run, task, or source URL
- optional execution metadata for a Codex or Claude session started from the card
- compact metadata for attempts, comments, links, proof, artifacts, automation,
attachments, worker logs, worker protocol state, claims, diagnostics,
notifications, templates, archive state, and stale-session detection
@@ -66,35 +65,26 @@ proof snippets, related links, comments, archive markers, and stale-session
markers are intentionally local metadata; they do not replace session
transcripts or GitHub issue history.
## Card executions and tasks
## Card executions
Unlinked cards can start work from the card. Autonomous starts use the
Gateway's task-tracked agent run path, then Workboard links the resulting task,
run id, and session key back onto the card. Start uses the Gateway's configured
Unlinked cards can start work from the card. Start uses the Gateway's configured
default agent and model. Codex and Claude actions are optional explicit model
choices:
- Run Codex or Run Claude starts a task-backed agent run, sends the card
prompt, and marks the card `running`.
- Run Codex or Run Claude creates a dashboard session, sends the card prompt,
and marks the card `running`.
- Open Codex or Open Claude creates a linked dashboard session without sending
the card prompt or moving the card, so you can work manually while it stays
attached to the board.
Execution metadata stores the selected engine, mode, model ref, session key,
run id, task id when available, and lifecycle status on the card. Codex
executions use `openai/gpt-5.5`; Claude executions use
`anthropic/claude-sonnet-4-6`.
run id, and lifecycle status on the card. Codex executions use
`openai/gpt-5.5`; Claude executions use `anthropic/claude-sonnet-4-6`.
Each linked execution also records an attempt summary on the same card record.
The attempt summary keeps the engine, mode, model, run id, timestamps, status,
and rolling failure count so repeated failures remain visible on the board.
The dashboard refreshes task status from the Gateway task ledger and matches
tasks back to cards by task id, run id, or linked session key. If a task is
queued or running, the card lifecycle shows active task state. If the task
finishes, fails, times out, or is cancelled, the card lifecycle moves toward
review or blocked status using the same lifecycle sync as linked sessions.
## Agent coordination
Workboard also exposes optional agent tools for board-aware workflows:
@@ -170,15 +160,13 @@ blocked cards that need attention, repeated failures, done cards without proof,
and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw subagent sessions still own execution. The
dispatch action promotes dependency-ready cards, records dispatch metadata on
system processes; normal OpenClaw subagent sessions still own execution. A
dispatch nudge promotes dependency-ready cards, records dispatch metadata on
ready cards, blocks expired claims or timed-out runs, marks board-configured
triage cards as orchestration candidates, then claims a small batch of ready
cards and starts worker runs through the Gateway subagent runtime. Assigned
cards use `agent:<id>:subagent:workboard-*` worker session keys; unassigned
cards use unscoped `subagent:workboard-*` keys so the Gateway still resolves the
configured default agent. Workers get bounded card context plus the claim token
they need to heartbeat, complete, or block the card through the Workboard tools.
cards and starts worker runs through the Gateway subagent runtime. Workers get
bounded card context plus the claim token they need to heartbeat, complete, or
block the card through the Workboard tools.
### Dispatch worker selection

View File

@@ -6,7 +6,7 @@ read_when:
title: "MiniMax"
---
OpenClaw's MiniMax provider defaults to **MiniMax M3**.
OpenClaw's MiniMax provider defaults to **MiniMax M2.7**.
MiniMax also provides:
@@ -26,8 +26,7 @@ Provider split:
| Model | Type | Description |
| ------------------------ | ---------------- | ---------------------------------------- |
| `MiniMax-M3` | Chat (reasoning) | Default hosted reasoning model |
| `MiniMax-M2.7` | Chat (reasoning) | Previous hosted reasoning model |
| `MiniMax-M2.7` | Chat (reasoning) | Default hosted reasoning model |
| `MiniMax-M2.7-highspeed` | Chat (reasoning) | Faster M2.7 reasoning tier |
| `MiniMax-VL-01` | Vision | Image understanding model |
| `image-01` | Image generation | Text-to-image and image-to-image editing |
@@ -80,7 +79,7 @@ Choose your preferred auth method and follow the setup steps.
</Tabs>
<Note>
OAuth setups use the `minimax-portal` provider id. Model refs follow the form `minimax-portal/MiniMax-M3`.
OAuth setups use the `minimax-portal` provider id. Model refs follow the form `minimax-portal/MiniMax-M2.7`.
</Note>
<Tip>
@@ -132,7 +131,7 @@ Choose your preferred auth method and follow the setup steps.
```json5
{
env: { MINIMAX_API_KEY: "sk-..." },
agents: { defaults: { model: { primary: "minimax/MiniMax-M3" } } },
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } },
models: {
mode: "merge",
providers: {
@@ -141,15 +140,6 @@ Choose your preferred auth method and follow the setup steps.
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M3",
name: "MiniMax M3",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 131072,
},
{
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
@@ -180,7 +170,7 @@ Choose your preferred auth method and follow the setup steps.
</Warning>
<Note>
API-key setups use the `minimax` provider id. Model refs follow the form `minimax/MiniMax-M3`.
API-key setups use the `minimax` provider id. Model refs follow the form `minimax/MiniMax-M2.7`.
</Note>
</Tab>
@@ -253,10 +243,9 @@ through the CN endpoint; the default global endpoint is
`https://api.minimax.io`.
When onboarding or API-key setup writes explicit `models.providers.minimax`
entries, OpenClaw materializes `MiniMax-M3`, `MiniMax-M2.7`, and
`MiniMax-M2.7-highspeed` as chat models. M3 advertises text and image input;
image understanding remains exposed separately through the plugin-owned
`MiniMax-VL-01` media provider.
entries, OpenClaw materializes `MiniMax-M2.7` and
`MiniMax-M2.7-highspeed` as text-only chat models. Image understanding is
exposed separately through the plugin-owned `MiniMax-VL-01` media provider.
<Note>
See [Image Generation](/tools/image-generation) for shared tool parameters, provider selection, and failover behavior.
@@ -364,7 +353,7 @@ catalog:
| `minimax-portal` | `MiniMax-VL-01` |
That is why automatic media routing can use MiniMax image understanding even
when the bundled text-provider catalog also includes M3 image-capable chat refs.
when the bundled text-provider catalog still shows text-only M2.7 chat refs.
### Web search
@@ -448,12 +437,12 @@ See [MiniMax Search](/tools/minimax-search) for full web search configuration an
- Model refs follow the auth path:
- API-key setup: `minimax/<model>`
- OAuth setup: `minimax-portal/<model>`
- Default chat model: `MiniMax-M3`
- Alternate chat models: `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`
- Onboarding and direct API-key setup write model definitions for M3 and both M2.7 variants
- Default chat model: `MiniMax-M2.7`
- Alternate chat model: `MiniMax-M2.7-highspeed`
- Onboarding and direct API-key setup write text-only model definitions for both M2.7 variants
- Image understanding uses the plugin-owned `MiniMax-VL-01` media provider
- Update pricing values in `models.json` if you need exact cost tracking
- Use `openclaw models list` to confirm the current provider id, then switch with `openclaw models set minimax/MiniMax-M3` or `openclaw models set minimax-portal/MiniMax-M3`
- Use `openclaw models list` to confirm the current provider id, then switch with `openclaw models set minimax/MiniMax-M2.7` or `openclaw models set minimax-portal/MiniMax-M2.7`
<Tip>
Referral link for MiniMax Coding Plan (10% off): [MiniMax Coding Plan](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
@@ -466,7 +455,7 @@ See [Model providers](/concepts/model-providers) for provider rules.
## Troubleshooting
<AccordionGroup>
<Accordion title='"Unknown model: minimax/MiniMax-M3"'>
<Accordion title='"Unknown model: minimax/MiniMax-M2.7"'>
This usually means the **MiniMax provider is not configured** (no matching provider entry and no MiniMax auth profile/env key found). A fix for this detection is in **2026.1.12**. Fix by:
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
@@ -476,8 +465,8 @@ See [Model providers](/concepts/model-providers) for provider rules.
Make sure the model id is **case-sensitive**:
- API-key path: `minimax/MiniMax-M3`, `minimax/MiniMax-M2.7`, or `minimax/MiniMax-M2.7-highspeed`
- OAuth path: `minimax-portal/MiniMax-M3`, `minimax-portal/MiniMax-M2.7`, or `minimax-portal/MiniMax-M2.7-highspeed`
- API-key path: `minimax/MiniMax-M2.7` or `minimax/MiniMax-M2.7-highspeed`
- OAuth path: `minimax-portal/MiniMax-M2.7` or `minimax-portal/MiniMax-M2.7-highspeed`
Then recheck with:

View File

@@ -20,7 +20,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
prompt surface. It is bounded by `skills.limits.maxSkillsPromptChars`, with
optional per-agent override at `agents.list[].skillsLimits.maxSkillsPromptChars`.
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Native Codex turns do not paste raw `MEMORY.md` from the configured agent workspace when memory tools are available for that workspace; they include a small memory pointer in turn-scoped collaboration developer instructions and use memory tools on demand. If tools are disabled, memory search is unavailable, or the active workspace differs from the agent memory workspace, `MEMORY.md` uses the normal bounded turn-context path. Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large injected files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`. Post-compaction AGENTS.md excerpts are separate and require explicit `agents.defaults.compaction.postCompactionSections` opt-in.
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Native Codex turns do not paste raw `MEMORY.md` from the configured agent workspace when memory tools are available for that workspace; they include a small memory pointer in turn-scoped collaboration developer instructions and use memory tools on demand. If tools are disabled, memory search is unavailable, or the active workspace differs from the agent memory workspace, `MEMORY.md` uses the normal bounded turn-context path. Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large injected files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`. Post-compaction AGENTS.md excerpts are separate and require explicit `agents.defaults.compaction.postCompactionSections` opt-in.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@@ -47,7 +47,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- 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`.
- **MiniMax**: config is auto-written; hosted default is `MiniMax-M2.7`.
API-key setup uses `minimax/...`, and OAuth setup uses
`minimax-portal/...`.
- More detail: [MiniMax](/providers/minimax)

View File

@@ -182,7 +182,7 @@ What you set:
More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway).
</Accordion>
<Accordion title="MiniMax">
Config is auto-written. Hosted default is `MiniMax-M3`; API-key setup uses
Config is auto-written. Hosted default is `MiniMax-M2.7`; API-key setup uses
`minimax/...`, and OAuth setup uses `minimax-portal/...`.
More detail: [MiniMax](/providers/minimax).
</Accordion>

View File

@@ -216,9 +216,7 @@ Install the Diff Viewer Language Pack plugin to highlight other languages:
openclaw plugins install clawhub:@openclaw/diffs-language-pack
```
With the language pack available, OpenClaw can highlight many more languages. If the pack is not installed, files outside the default list still render as readable plain text. Examples include Astro, Vue, Svelte, MDX, GraphQL, Terraform/HCL, Nix, Clojure, Elixir, Haskell, OCaml, Scala, Zig, Solidity, Verilog/VHDL, Fortran, MATLAB, LaTeX, Mermaid, Sass/Less/SCSS, Nginx, Apache, CSV, dotenv, INI, and diff files.
See [Diffs Language Pack plugin](/plugins/reference/diffs-language-pack) for details and [Shiki languages](https://shiki.style/languages) for Shiki's upstream language and alias catalog.
With the language pack available, OpenClaw automatically uses it for languages outside the default list. Without it, those files stay readable as plain text.
## Output details contract

View File

@@ -312,120 +312,6 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
});
it("writes API-key auth into the isolated Codex ACP home when env auth is present", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(
installedBinPath,
"console.log(JSON.stringify({ codexHome: process.env.CODEX_HOME }));\n",
"utf8",
);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
env: { ...process.env, CODEX_API_KEY: "", OPENAI_API_KEY: "sk-test-api-key" },
});
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as {
auth_mode?: unknown;
OPENAI_API_KEY?: unknown;
};
expect(auth).toMatchObject({
OPENAI_API_KEY: "sk-test-api-key",
});
expect(auth).not.toHaveProperty("auth_mode");
if (process.platform !== "win32") {
const mode = (await fs.stat(authPath)).mode & 0o777;
expect(mode).toBe(0o600);
}
});
it("preserves existing isolated Codex auth when env auth is present", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(installedBinPath, "console.log('ok');\n", "utf8");
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
const existingAuth = {
auth_mode: "chatgpt",
tokens: { access_token: "existing-token" },
last_refresh: null,
};
await fs.writeFile(authPath, `${JSON.stringify(existingAuth)}\n`, { mode: 0o600 });
await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
env: { ...process.env, OPENAI_API_KEY: "sk-test-api-key" },
});
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toEqual(existingAuth);
});
it("updates existing isolated Codex API-key auth when env auth changes", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(installedBinPath, "console.log('ok');\n", "utf8");
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
await fs.writeFile(
authPath,
`${JSON.stringify({
OPENAI_API_KEY: "sk-old-api-key",
tokens: null,
last_refresh: null,
})}\n`,
{ mode: 0o600 },
);
await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
env: { ...process.env, CODEX_API_KEY: "sk-new-api-key", OPENAI_API_KEY: "sk-other-key" },
});
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toMatchObject({
OPENAI_API_KEY: "sk-new-api-key",
tokens: null,
last_refresh: null,
});
});
it("launches the locally installed Claude ACP bin without going through npm", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");

View File

@@ -4,11 +4,11 @@ import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
import { quoteCommandPart, splitCommandParts } from "./command-line.js";
import {
extractTrustedCodexProjectPaths,
renderIsolatedCodexConfig,
} from "./codex-trust-config.js";
import { quoteCommandPart, splitCommandParts } from "./command-line.js";
import { resolveAcpxPluginRoot } from "./config.js";
import type { ResolvedAcpxPluginConfig } from "./config.js";
import {
@@ -528,35 +528,6 @@ function buildCodexAcpWrapperScript(installedBinPath?: string): string {
installedBinPath,
stderrLogFileNamePrefix: "codex-acp-wrapper.stderr",
envSetup: `const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url));
const codexAuthPath = fileURLToPath(new URL("./codex-home/auth.json", import.meta.url));
const codexApiKey = (process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY || "").trim();
let shouldWriteCodexApiKeyAuth = false;
if (codexApiKey) {
if (!existsSync(codexAuthPath)) {
shouldWriteCodexApiKeyAuth = true;
} else {
try {
const existingCodexAuth = JSON.parse(readFileSync(codexAuthPath, "utf8"));
shouldWriteCodexApiKeyAuth =
!existingCodexAuth ||
typeof existingCodexAuth !== "object" ||
typeof existingCodexAuth.OPENAI_API_KEY === "string";
} catch {
shouldWriteCodexApiKeyAuth = true;
}
}
}
if (shouldWriteCodexApiKeyAuth) {
writeFileSync(
codexAuthPath,
JSON.stringify({
OPENAI_API_KEY: codexApiKey,
tokens: null,
last_refresh: null,
}) + "\\n",
{ mode: 0o600 },
);
}
const env = {
...process.env,
CODEX_HOME: codexHome,

View File

@@ -68,7 +68,7 @@ class LegacyRunTurnEventQueue {
return item;
}
if (this.error) {
throw toLintErrorObject(this.error, "Non-Error thrown");
throw this.error;
}
if (this.closed) {
return null;
@@ -178,17 +178,3 @@ export function lazyStartRuntimeTurn(
},
};
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -286,7 +286,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
})
.then(
() => ({ status: "resolved" as const }),
(error: unknown) => ({ status: "rejected" as const, error }),
(error) => ({ status: "rejected" as const, error }),
);
expect(outcome.status).toBe("rejected");
@@ -298,12 +298,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
code: "ACP_SESSION_INIT_FAILED",
message: expect.stringContaining("deployment missing"),
});
const error = outcome.error;
expect(error).toBeInstanceOf(AcpRuntimeError);
if (!(error instanceof AcpRuntimeError)) {
throw new Error("expected AcpRuntimeError");
}
expect(error.message).not.toContain("sk-testsecret1234567890");
expect(outcome.error.message).not.toContain("sk-testsecret1234567890");
});
it("adds Codex wrapper stderr tail to generic first-turn failures", async () => {

View File

@@ -218,21 +218,13 @@ describe("active-memory plugin", () => {
};
const waitForAbort = async (abortSignal?: AbortSignal): Promise<never> => {
if (abortSignal?.aborted) {
throw toLintErrorObject(
(abortSignal.reason as unknown) ?? new Error("Operation aborted"),
"Non-Error thrown",
);
throw (abortSignal.reason as unknown) ?? new Error("Operation aborted");
}
return await new Promise<never>((_resolve, reject) => {
abortSignal?.addEventListener(
"abort",
() => {
reject(
toLintErrorObject(
(abortSignal.reason as unknown) ?? new Error("Operation aborted"),
"Non-Error rejection",
),
);
reject((abortSignal.reason as unknown) ?? new Error("Operation aborted"));
},
{ once: true },
);
@@ -4358,17 +4350,3 @@ describe("active-memory plugin", () => {
expect(config.circuitBreakerCooldownMs).toBe(5000);
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -1011,6 +1011,7 @@ function buildPromptStyleLines(style: ActiveMemoryPromptStyle): string[] {
"If relevant memory is mostly a stable user preference or recurring habit, lean toward returning it.",
"If the strongest match is only a one-off historical fact and not a recurring preference or habit, prefer NONE unless the latest user message clearly asks for that fact.",
];
case "balanced":
default:
return [
"Treat the latest user message as the primary query.",
@@ -1981,7 +1982,7 @@ async function waitForSubagentPartialTimeoutData(
(await Promise.race([
subagentPromise.then(
() => undefined,
(error: unknown) => readPartialTimeoutData(error),
(error) => readPartialTimeoutData(error),
),
timeoutPromise,
])) ?? {}

View File

@@ -0,0 +1,7 @@
import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
describePluginRegistrationContract({
pluginId: "alibaba",
videoGenerationProviderIds: ["alibaba"],
requireGenerateVideo: true,
});

View File

@@ -571,7 +571,7 @@ export async function startGatewayBonjourAdvertiser(
.then(() => {
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err: unknown) => {
.catch((err) => {
handleAdvertiseFailure(label, svc, err, "failed");
});
} catch (err) {
@@ -747,7 +747,7 @@ export async function startGatewayBonjourAdvertiser(
)})`,
);
try {
void svc.advertise().catch((err: unknown) => {
void svc.advertise().catch((err) => {
logger.warn(
`bonjour: watchdog re-advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);

View File

@@ -416,8 +416,7 @@ describe("cdp.helpers internal", () => {
await expect(
withCdpSocket(server.url, async (send) => {
await send("Test.ok");
const rejectRawString = () =>
Promise.reject(toLintErrorObject("raw-string-from-callback", "Non-Error rejection"));
const rejectRawString = () => Promise.reject("raw-string-from-callback");
return rejectRawString();
}),
).rejects.toThrow(/raw-string-from-callback/);
@@ -573,17 +572,3 @@ describe("openCdpWebSocket option handling", () => {
ws.close();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -666,535 +666,6 @@ describe("chrome MCP page parsing", () => {
expect(result).toBe(123);
});
it("keeps a shared pending session alive when one waiter aborts", async () => {
let factoryCalls = 0;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
if (!releaseFactory) {
throw new Error("Expected Chrome MCP factory release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
await factoryGate;
const session = createFakeSession();
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const keptCtrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: keptCtrl.signal,
});
const abortedTabsExpectation =
expect(abortedTabsPromise).rejects.toThrow(/first caller cancelled/);
ctrl.abort(new Error("first caller cancelled"));
releaseFactory();
await abortedTabsExpectation;
await expect(tabsPromise).resolves.toHaveLength(2);
expect(factoryCalls).toBe(1);
expect(closeMock).not.toHaveBeenCalled();
});
it("closes a shared pending session when every waiter aborts", async () => {
let factoryCalls = 0;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
if (!releaseFactory) {
throw new Error("Expected Chrome MCP factory release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
await factoryGate;
const session = createFakeSession();
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const tabsExpectation = expect(tabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
releaseFactory();
await tabsExpectation;
await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
expect(factoryCalls).toBe(1);
});
it("starts a fresh shared session after every waiter aborts a pending attach", async () => {
let factoryCalls = 0;
const releaseFactories: Array<() => void> = [];
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
if (!releaseFactory) {
throw new Error("Expected Chrome MCP factory release callback to be initialized");
}
releaseFactories.push(releaseFactory);
await factoryGate;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const abortedTabsExpectation = expect(abortedTabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
await abortedTabsExpectation;
const tabsPromise = listChromeMcpTabs("chrome-live");
await vi.waitFor(() => expect(factoryCalls).toBe(2));
releaseFactories[0]?.();
releaseFactories[1]?.();
await expect(tabsPromise).resolves.toHaveLength(2);
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("closes a shared pending session when every waiter aborts before ready", async () => {
let factoryCalls = 0;
let releaseReady: (() => void) | undefined;
const readyGate = new Promise<void>((resolve) => {
releaseReady = resolve;
});
if (!releaseReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
session.ready = readyGate;
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const tabsExpectation = expect(tabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
releaseReady();
await tabsExpectation;
await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
});
it("starts a fresh session while last-waiter abort cleanup is closing", async () => {
let factoryCalls = 0;
let releaseFirstClose: (() => void) | undefined;
const firstCloseGate = new Promise<void>((resolve) => {
releaseFirstClose = resolve;
});
if (!releaseFirstClose) {
throw new Error("Expected Chrome MCP close release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock =
factoryCalls === 1
? vi.fn(async () => {
await firstCloseGate;
})
: vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = new Promise<void>(() => {});
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const abortedTabsExpectation = expect(abortedTabsPromise).rejects.toThrow(/caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
ctrl.abort(new Error("caller cancelled"));
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
const tabsPromise = listChromeMcpTabs("chrome-live");
await vi.waitFor(() => expect(factoryCalls).toBe(2));
await expect(tabsPromise).resolves.toHaveLength(2);
expect(closeMocks[1]).not.toHaveBeenCalled();
releaseFirstClose();
await abortedTabsExpectation;
});
it("keeps a ready-pending shared session cached when another waiter remains", async () => {
let factoryCalls = 0;
let releaseReady: (() => void) | undefined;
const readyGate = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const readyThen = vi.spyOn(readyGate, "then");
if (!releaseReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
session.ready = readyGate;
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const abortedTabsExpectation =
expect(abortedTabsPromise).rejects.toThrow(/first caller cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(readyThen).toHaveBeenCalledTimes(1));
const keptCtrl = new AbortController();
const tabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: keptCtrl.signal,
});
await vi.waitFor(() => expect(readyThen).toHaveBeenCalledTimes(2));
ctrl.abort(new Error("first caller cancelled"));
releaseReady();
await abortedTabsExpectation;
await expect(tabsPromise).resolves.toHaveLength(2);
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
expect(factoryCalls).toBe(1);
expect(closeMock).not.toHaveBeenCalled();
});
it("starts a fresh shared session when a ready-pending session loses its transport", async () => {
let factoryCalls = 0;
let firstSession: ChromeMcpSession | undefined;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
firstSession = session;
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const firstTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: ctrl.signal,
});
const firstTabsExpectation = expect(firstTabsPromise).rejects.toThrow(/first waiter cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1));
if (!firstSession) {
throw new Error("Expected first Chrome MCP session to be created");
}
(firstSession.transport as { pid: number | null }).pid = null;
const tabsPromise = listChromeMcpTabs("chrome-live");
const siblingTabsPromise = listChromeMcpTabs("chrome-live");
ctrl.abort(new Error("first waiter cancelled"));
releaseFirstReady();
await vi.waitFor(() => expect(factoryCalls).toBe(2));
const [tabs, siblingTabs] = await Promise.all([tabsPromise, siblingTabsPromise]);
expect(tabs).toHaveLength(2);
expect(siblingTabs).toHaveLength(2);
await firstTabsExpectation;
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("surfaces startup failures before treating null-pid pending sessions as stale", async () => {
let factoryCalls = 0;
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
if (factoryCalls > 1) {
throw new Error("unexpected retry");
}
const session = createFakeSession();
(session.transport as { pid: number | null }).pid = null;
const readyFailure = Promise.reject(new Error("startup failed"));
readyFailure.catch(() => {});
session.ready = readyFailure;
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(/startup failed/);
expect(factoryCalls).toBe(1);
await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
});
it("bounds retries when ready sessions keep losing their transport", async () => {
let factoryCalls = 0;
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
(session.transport as { pid: number | null }).pid = null;
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(
/subprocess exited before it became usable/,
);
expect(factoryCalls).toBe(2);
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalled());
await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalled());
});
it("does not reuse a stale ready-pending session for ephemeral probes", async () => {
let factoryCalls = 0;
let firstSession: ChromeMcpSession | undefined;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
firstSession = session;
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const firstAvailablePromise = ensureChromeMcpAvailable("chrome-live", undefined, {
signal: ctrl.signal,
});
const firstAvailableExpectation =
expect(firstAvailablePromise).rejects.toThrow(/first waiter cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1));
if (!firstSession) {
throw new Error("Expected first Chrome MCP session to be created");
}
(firstSession.transport as { pid: number | null }).pid = null;
const availablePromise = ensureChromeMcpAvailable("chrome-live", undefined, {
ephemeral: true,
});
ctrl.abort(new Error("first waiter cancelled"));
releaseFirstReady();
await expect(availablePromise).resolves.toBeUndefined();
expect(factoryCalls).toBe(2);
await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalledTimes(1));
await firstAvailableExpectation;
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
});
it("does not let ephemeral probes persist canceled pending attaches", async () => {
let factoryCalls = 0;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const firstAvailablePromise = ensureChromeMcpAvailable("chrome-live", undefined, {
signal: ctrl.signal,
});
const firstAvailableExpectation =
expect(firstAvailablePromise).rejects.toThrow(/first waiter cancelled/);
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1));
await expect(
ensureChromeMcpAvailable("chrome-live", undefined, {
ephemeral: true,
}),
).resolves.toBeUndefined();
expect(factoryCalls).toBe(2);
expect(firstReadyThen).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalledTimes(1));
ctrl.abort(new Error("first waiter cancelled"));
releaseFirstReady();
await firstAvailableExpectation;
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
expect(factoryCalls).toBe(3);
});
it("keeps a shared session after a readiness timeout while another waiter remains", async () => {
let factoryCalls = 0;
let releaseFirstReady: (() => void) | undefined;
const firstReadyGate = new Promise<void>((resolve) => {
releaseFirstReady = resolve;
});
const firstReadyThen = vi.spyOn(firstReadyGate, "then");
if (!releaseFirstReady) {
throw new Error("Expected Chrome MCP ready release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = firstReadyGate;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
const keptCtrl = new AbortController();
const timedOutTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
timeoutMs: 1,
});
const timedOutTabsExpectation = expect(timedOutTabsPromise).rejects.toThrow(/timed out/);
const keptTabsPromise = listChromeMcpTabs("chrome-live", undefined, {
signal: keptCtrl.signal,
});
await vi.waitFor(() => expect(factoryCalls).toBe(1));
await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(2));
await timedOutTabsExpectation;
const laterTabsPromise = listChromeMcpTabs("chrome-live");
releaseFirstReady();
await expect(keptTabsPromise).resolves.toHaveLength(2);
await expect(laterTabsPromise).resolves.toHaveLength(2);
expect(factoryCalls).toBe(1);
expect(closeMocks[0]).not.toHaveBeenCalled();
keptCtrl.abort(new Error("kept waiter cancelled"));
});
it("closes a shared pending session after a readiness timeout with no other waiters", async () => {
let factoryCalls = 0;
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
closeMocks.push(closeMock);
session.client.close = closeMock as typeof session.client.close;
if (factoryCalls === 1) {
session.ready = new Promise<void>(() => {});
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(
listChromeMcpTabs("chrome-live", undefined, {
timeoutMs: 1,
}),
).rejects.toThrow(/timed out/);
await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1));
await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2);
expect(factoryCalls).toBe(2);
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("preserves session after tool-level errors (isError)", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {

View File

@@ -80,22 +80,6 @@ type ChromeMcpSessionFactory = (
options?: NormalizedChromeMcpProfileOptions,
) => Promise<ChromeMcpSession>;
type PendingChromeMcpSession = {
cacheKey: string;
id: symbol;
promise: Promise<ChromeMcpSession>;
abortController: AbortController;
state: {
waiters: number;
settled: boolean;
};
};
type PendingChromeMcpSessionLease = {
session: ChromeMcpSession;
release: (closeIfLastWaiter: boolean) => Promise<boolean>;
};
export type ChromeMcpProcessInfo = {
pid: number;
ppid: number;
@@ -139,7 +123,7 @@ const STALE_SELECTED_PAGE_ERROR =
const execFileAsync = promisify(execFile);
const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, PendingChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
let sessionFactory: ChromeMcpSessionFactory | null = null;
let chromeMcpProcessCleanupDepsForTest: ChromeMcpProcessCleanupDeps | null = null;
@@ -274,7 +258,7 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
}
}
if (lastError) {
throw toLintErrorObject(lastError, "Non-Error thrown");
throw lastError;
}
return null;
}
@@ -378,10 +362,9 @@ async function closeChromeMcpSessionsForProfile(
): Promise<boolean> {
let closed = false;
for (const [key, pending] of Array.from(pendingSessions.entries())) {
for (const key of Array.from(pendingSessions.keys())) {
if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) {
pendingSessions.delete(key);
abortPendingChromeMcpSession(pending, new Error("Chrome MCP profile session was replaced"));
closed = true;
}
}
@@ -646,7 +629,7 @@ async function closeChromeMcpClientAndProcess(params: {
return;
}
await params.client.close().catch(() => {});
await terminateChromeMcpProcessTree(rootPid, descendantPids).catch((err: unknown) => {
await terminateChromeMcpProcessTree(rootPid, descendantPids).catch((err) => {
log.trace(
`Unable to fully terminate Chrome MCP subprocess tree for pid ${rootPid}: ${err instanceof Error ? err.message : String(err)}`,
);
@@ -667,9 +650,10 @@ async function withChromeMcpHandshakeTimeout<T>(task: Promise<T>): Promise<T> {
return await Promise.race([
task,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error("Chrome MCP handshake timed out"));
}, CHROME_MCP_HANDSHAKE_TIMEOUT_MS);
timer = setTimeout(
() => reject(new Error("Chrome MCP handshake timed out")),
CHROME_MCP_HANDSHAKE_TIMEOUT_MS,
);
timer.unref?.();
}),
]);
@@ -777,8 +761,7 @@ async function waitForChromeMcpReady(
if (signal) {
racers.push(
new Promise<never>((_, reject) => {
abortListener = () =>
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
abortListener = () => reject(signal.reason ?? new Error("aborted"));
signal.addEventListener("abort", abortListener, { once: true });
}),
);
@@ -810,8 +793,7 @@ async function waitForChromeMcpPendingSession(
return await Promise.race([
pending,
new Promise<never>((_, reject) => {
abortListener = () =>
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
abortListener = () => reject(signal.reason ?? new Error("aborted"));
signal.addEventListener("abort", abortListener, { once: true });
}),
]);
@@ -828,132 +810,21 @@ async function createChromeMcpSession(
signal?: AbortSignal,
): Promise<ChromeMcpSession> {
const created = (sessionFactory ?? createRealSession)(profileName, options);
let closedAfterAbort = false;
try {
const session = await waitForChromeMcpPendingSession(created, signal);
if (signal?.aborted) {
closedAfterAbort = true;
await closeChromeMcpSessionHandle(session);
throw signal.reason ?? new Error("aborted");
}
return session;
} catch (err) {
if (signal?.aborted && !closedAfterAbort) {
if (signal?.aborted) {
void created.then((session) => closeChromeMcpSessionHandle(session)).catch(() => {});
}
throw err;
}
}
function abortPendingChromeMcpSession(
pending: PendingChromeMcpSession,
reason: unknown = new Error("Chrome MCP session attach no longer has active waiters"),
): void {
if (!pending.state.settled && !pending.abortController.signal.aborted) {
pending.abortController.abort(reason);
}
}
function forgetCachedChromeMcpSessionIfCurrent(
cacheKey: string,
session: ChromeMcpSession,
): boolean {
const current = sessions.get(cacheKey);
if (current?.transport !== session.transport) {
return false;
}
sessions.delete(cacheKey);
return true;
}
function forgetPendingChromeMcpSessionIfCurrent(
cacheKey: string,
pending: PendingChromeMcpSession,
): boolean {
if (pendingSessions.get(cacheKey) !== pending) {
return false;
}
pendingSessions.delete(cacheKey);
return true;
}
function createSharedPendingChromeMcpSession(
cacheKey: string,
profileName: string,
options: NormalizedChromeMcpProfileOptions,
): PendingChromeMcpSession {
const id = Symbol(cacheKey);
const abortController = new AbortController();
const state = {
waiters: 0,
settled: false,
};
const promise = (async () => {
try {
const created = await createChromeMcpSession(profileName, options, abortController.signal);
if (pendingSessions.get(cacheKey)?.id === id) {
sessions.set(cacheKey, created);
} else {
await closeChromeMcpSessionHandle(created);
}
return created;
} finally {
state.settled = true;
if (state.waiters === 0 && pendingSessions.get(cacheKey)?.id === id) {
pendingSessions.delete(cacheKey);
}
}
})();
const pending: PendingChromeMcpSession = {
cacheKey,
id,
promise,
abortController,
state,
};
void promise.catch(() => {});
return pending;
}
async function waitForSharedPendingChromeMcpSession(
pending: PendingChromeMcpSession,
signal?: AbortSignal,
): Promise<PendingChromeMcpSessionLease> {
pending.state.waiters += 1;
let released = false;
let leasedSession: ChromeMcpSession | undefined;
const release = async (closeIfLastWaiter: boolean) => {
if (released) {
return false;
}
released = true;
pending.state.waiters = Math.max(0, pending.state.waiters - 1);
if (pending.state.waiters !== 0) {
return false;
}
if (pendingSessions.get(pending.cacheKey) === pending) {
pendingSessions.delete(pending.cacheKey);
}
if (!pending.state.settled) {
abortPendingChromeMcpSession(pending, signal?.reason);
} else if (closeIfLastWaiter && leasedSession) {
forgetCachedChromeMcpSessionIfCurrent(pending.cacheKey, leasedSession);
await closeChromeMcpSessionHandle(leasedSession);
}
return true;
};
try {
leasedSession = await waitForChromeMcpPendingSession(pending.promise, signal);
return {
session: leasedSession,
release,
};
} catch (err) {
await release(signal?.aborted === true);
throw err;
}
}
async function getSession(
profileName: string,
profileOptions?: ChromeMcpOptionsInput,
@@ -963,79 +834,43 @@ async function getSession(
const options = normalizeChromeMcpOptions(profileOptions);
const cacheKey = buildChromeMcpSessionCacheKey(profileName, options);
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
if (signal?.aborted) {
throw signal.reason ?? new Error("aborted");
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
let staleReadySessionRetries = 0;
for (;;) {
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
let pendingLease: PendingChromeMcpSessionLease | undefined;
let leasedPending: PendingChromeMcpSession | undefined;
const pending = pendingSessions.get(cacheKey);
if (pending) {
leasedPending = pending;
pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal);
session = pendingLease.session;
}
if (!session) {
const createdPending = createSharedPendingChromeMcpSession(cacheKey, profileName, options);
pendingSessions.set(cacheKey, createdPending);
leasedPending = createdPending;
pendingLease = await waitForSharedPendingChromeMcpSession(createdPending, signal);
session = pendingLease.session;
}
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
if (session.transport.pid === null) {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
if (leasedPending) {
forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending);
}
if (pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
}
staleReadySessionRetries += 1;
if (staleReadySessionRetries > 1) {
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${redactChromeMcpProfileLabelForDiagnostic(profileName)}". ` +
"The Chrome MCP subprocess exited before it became usable.",
);
}
continue;
}
return session;
} catch (err) {
if (signal?.aborted && pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
} else if (pendingLease && leasedPending && leasedPending.state.waiters > 1) {
await pendingLease.release(false);
pendingLease = undefined;
} else {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
if (leasedPending) {
forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending);
}
if (pendingLease) {
await pendingLease.release(true);
pendingLease = undefined;
if (!session) {
let pending = pendingSessions.get(cacheKey);
if (!pending) {
pending = (async () => {
const created = await createChromeMcpSession(profileName, options, signal);
if (pendingSessions.get(cacheKey) === pending) {
sessions.set(cacheKey, created);
} else {
await closeChromeMcpSessionHandle(session);
await closeChromeMcpSessionHandle(created);
}
}
throw err;
} finally {
await pendingLease?.release(false);
return created;
})();
pendingSessions.set(cacheKey, pending);
}
try {
session = await pending;
} finally {
if (pendingSessions.get(cacheKey) === pending) {
pendingSessions.delete(cacheKey);
}
}
}
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
sessions.delete(cacheKey);
}
throw err;
}
}
@@ -1044,65 +879,41 @@ async function getExistingSession(
profileName: string,
timeoutMs?: number,
signal?: AbortSignal,
includePending = true,
): Promise<ChromeMcpSession | null> {
if (!includePending && pendingSessions.has(cacheKey)) {
return null;
}
let session = sessions.get(cacheKey);
if (session && session.transport.pid === null) {
sessions.delete(cacheKey);
session = undefined;
}
const pending = pendingSessions.get(cacheKey);
if (includePending && pending) {
const pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal);
let pendingLeaseReleased = false;
session = pendingLease.session;
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
if (session.transport.pid === null) {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
forgetPendingChromeMcpSessionIfCurrent(cacheKey, pending);
await pendingLease.release(true);
pendingLeaseReleased = true;
return null;
}
return session;
} catch (err) {
if (signal?.aborted) {
await pendingLease.release(true);
pendingLeaseReleased = true;
} else if (pending.state.waiters > 1) {
await pendingLease.release(false);
pendingLeaseReleased = true;
} else {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
forgetPendingChromeMcpSessionIfCurrent(cacheKey, pending);
await pendingLease.release(true);
pendingLeaseReleased = true;
}
throw err;
} finally {
if (!pendingLeaseReleased) {
await pendingLease.release(false);
}
}
}
if (session) {
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
forgetCachedChromeMcpSessionIfCurrent(cacheKey, session);
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
sessions.delete(cacheKey);
}
throw err;
}
}
return null;
const pending = pendingSessions.get(cacheKey);
if (!pending) {
return null;
}
session = await waitForChromeMcpPendingSession(pending, signal);
try {
await waitForChromeMcpReady(session, profileName, timeoutMs, signal);
return session;
} catch (err) {
const current = sessions.get(cacheKey);
if (current?.transport === session.transport) {
sessions.delete(cacheKey);
}
throw err;
}
}
async function createEphemeralSession(
@@ -1149,7 +960,6 @@ async function leaseSession(
profileName,
options.timeoutMs,
options.signal,
false,
);
if (existingSession) {
return {
@@ -1212,8 +1022,7 @@ async function callTool(
if (signal) {
racers.push(
new Promise<never>((_, reject) => {
abortListener = () =>
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
abortListener = () => reject(signal.reason ?? new Error("aborted"));
signal.addEventListener("abort", abortListener, { once: true });
}),
);
@@ -1727,24 +1536,7 @@ export function setChromeMcpProcessCleanupDepsForTest(
export async function resetChromeMcpSessionsForTest(): Promise<void> {
sessionFactory = null;
for (const pending of pendingSessions.values()) {
abortPendingChromeMcpSession(pending, new Error("Chrome MCP sessions reset for test"));
}
pendingSessions.clear();
await stopAllChromeMcpSessions();
chromeMcpProcessCleanupDepsForTest = null;
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -315,17 +315,9 @@ export async function fetchBrowserJson<T>(
let abortListener: (() => void) | undefined;
const abortPromise: Promise<never> = abortCtrl.signal.aborted
? Promise.reject(
toLintErrorObject(abortCtrl.signal.reason ?? new Error("aborted"), "Non-Error rejection"),
)
? Promise.reject(abortCtrl.signal.reason ?? new Error("aborted"))
: new Promise((_, reject) => {
abortListener = () =>
reject(
toLintErrorObject(
abortCtrl.signal.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
abortListener = () => reject(abortCtrl.signal.reason ?? new Error("aborted"));
abortCtrl.signal.addEventListener("abort", abortListener, { once: true });
});
@@ -390,17 +382,3 @@ export const testApi = {
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
};
export { testApi as __test };
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -1359,12 +1359,12 @@ export async function gotoPageWithNavigationGuard(
try {
const response = await opts.page.goto(opts.url, { timeout: opts.timeoutMs });
if (blockedError) {
throw toLintErrorObject(blockedError, "Non-Error thrown");
throw blockedError;
}
return response;
} catch (err) {
if (blockedError) {
throw toLintErrorObject(blockedError, "Non-Error thrown");
throw blockedError;
}
throw err;
} finally {
@@ -1813,17 +1813,3 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
}
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -192,7 +192,7 @@ async function assertObservedDelayedNavigations(opts: {
});
}
if (subframeError) {
throw toLintErrorObject(subframeError, "Non-Error thrown");
throw subframeError;
}
}
@@ -276,7 +276,7 @@ function scheduleDelayedInteractionNavigationGuard(opts: {
const settle = (err?: unknown) => {
cleanup();
if (err) {
reject(toLintErrorObject(err, "Non-Error rejection"));
reject(err);
return;
}
resolve();
@@ -428,11 +428,11 @@ async function assertInteractionNavigationCompletedSafely<T>(opts: {
}
if (subframeError) {
throw toLintErrorObject(subframeError, "Non-Error thrown");
throw subframeError;
}
if (actionError) {
throw toLintErrorObject(actionError, "Non-Error thrown");
throw actionError;
}
return result as T;
}
@@ -478,14 +478,12 @@ function createAbortPromiseWithListener(
const abortPromise: Promise<never> = signal.aborted
? (() => {
onAbort?.(signal.reason);
return Promise.reject(
toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"),
);
return Promise.reject(signal.reason ?? new Error("aborted"));
})()
: new Promise((_, reject) => {
abortListener = () => {
onAbort?.(signal.reason);
reject(toLintErrorObject(signal.reason ?? new Error("aborted"), "Non-Error rejection"));
reject(signal.reason ?? new Error("aborted"));
};
signal.addEventListener("abort", abortListener, { once: true });
});
@@ -1714,17 +1712,3 @@ export async function batchViaPlaywright(opts: {
}
return { results };
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -178,7 +178,7 @@ async function runExistingSessionActionWithNavigationGuard<T>(params: {
}
if (actionError) {
throw toLintErrorObject(actionError, "Non-Error thrown");
throw actionError;
}
return result as T;
@@ -809,17 +809,3 @@ export function registerBrowserAgentActRoutes(
}),
);
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -701,7 +701,7 @@ export function registerBrowserAgentSnapshotRoutes(
const pw = await getPwAiModule();
const snap = plan.wantsRoleSnapshot
? pw
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs).catch(async (err: unknown) => {
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs).catch(async (err) => {
const fallback = await cdpRoleSnapshot();
if (fallback) {
return fallback;

View File

@@ -20,21 +20,10 @@ describe("browser route dispatcher (abort)", () => {
const signal = req.signal;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(
toLintErrorObject(
signal.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
reject(signal.reason ?? new Error("aborted"));
return;
}
const onAbort = () =>
reject(
toLintErrorObject(
signal?.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
const onAbort = () => reject(signal?.reason ?? new Error("aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => {
signal?.removeEventListener("abort", onAbort);
@@ -92,17 +81,3 @@ describe("browser route dispatcher (abort)", () => {
expect(body.error).toBe("invalid path parameter encoding: id");
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -66,7 +66,7 @@ export async function normalizeBrowserScreenshot(
}
if (processorUnavailableError) {
throw toLintErrorObject(processorUnavailableError, "Non-Error thrown");
throw processorUnavailableError;
}
const best = smallest?.buffer ?? buffer;
@@ -74,17 +74,3 @@ export async function normalizeBrowserScreenshot(
`Browser screenshot could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(best.byteLength / (1024 * 1024)).toFixed(2)}MB)`,
);
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -377,7 +377,7 @@ export function createProfileTabOps({
method: "PUT",
},
getCdpControlPolicy(),
).catch(async (err: unknown) => {
).catch(async (err) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(
endpoint,

View File

@@ -57,10 +57,7 @@ vi.mock("../sdk-node-runtime.js", () => ({
new Promise<never>((_, reject) => {
abortCtrl.signal.addEventListener(
"abort",
() =>
reject(
toLintErrorObject(abortCtrl.signal.reason ?? timeoutError, "Non-Error rejection"),
),
() => reject(abortCtrl.signal.reason ?? timeoutError),
{ once: true },
);
}),
@@ -493,17 +490,3 @@ describe("runBrowserProxyCommand", () => {
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -45,14 +45,11 @@ function waitForAbort(
cleanup: () => void;
} {
if (signal.aborted) {
return {
promise: Promise.reject(toLintErrorObject(signal.reason ?? fallback, "Non-Error rejection")),
cleanup: () => undefined,
};
return { promise: Promise.reject(signal.reason ?? fallback), cleanup: () => undefined };
}
let listener: (() => void) | undefined;
const promise = new Promise<never>((_, reject) => {
listener = () => reject(toLintErrorObject(signal.reason ?? fallback, "Non-Error rejection"));
listener = () => reject(signal.reason ?? fallback);
signal.addEventListener("abort", listener, { once: true });
});
return {
@@ -85,17 +82,3 @@ export async function withTimeout<T>(
abort.cleanup();
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -84,7 +84,7 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, "127.0.0.1", () => resolve(s));
s.once("error", reject);
}).catch((err: unknown) => {
}).catch((err) => {
logServer.error(`openclaw browser server failed to bind 127.0.0.1:${port}: ${String(err)}`);
return null;
});

View File

@@ -0,0 +1,8 @@
import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
describePluginRegistrationContract({
pluginId: "byteplus",
providerIds: ["byteplus", "byteplus-plan"],
videoGenerationProviderIds: ["byteplus"],
requireGenerateVideo: true,
});

View File

@@ -193,6 +193,8 @@ async function pollBytePlusTask(params: {
throw new Error(
readBytePlusErrorMessage(payload.error) || "BytePlus video generation failed",
);
case "queued":
case "running":
default:
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
break;

View File

@@ -222,9 +222,7 @@ async function main() {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch(
/** @param {unknown} error */ (error) => {
fail(error instanceof Error ? error.message : String(error));
},
);
await main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
}

View File

@@ -42,10 +42,8 @@ async function main() {
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch(
/** @param {unknown} err */ (err) => {
console.error(String(err));
process.exit(1);
},
);
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
}

View File

@@ -487,7 +487,7 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
})().catch((err: unknown) => {
})().catch((err) => {
opts.runtime.error(`Canvas host request failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");

View File

@@ -11,7 +11,7 @@ function formatErrorMessage(error: unknown): string {
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
serveCodexSupervisorMcp().catch((err: unknown) => {
serveCodexSupervisorMcp().catch((err) => {
process.stderr.write(`codex-supervisor-serve: ${formatErrorMessage(err)}\n`);
process.exit(1);
});

View File

@@ -303,7 +303,7 @@ describe("createCodexDynamicToolBridge", () => {
tools: [
createTool({ name: "message" }),
createTool({
name: "fuzzplugin_move_angles",
name: "dofbot_move_angles",
parameters: { type: "array", items: { type: "number" } },
execute: badExecute,
}),
@@ -324,17 +324,17 @@ describe("createCodexDynamicToolBridge", () => {
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.telemetry.quarantinedTools).toEqual([
{
tool: "fuzzplugin_move_angles",
violations: ['fuzzplugin_move_angles.inputSchema.type must be "object"'],
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
},
]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("fuzzplugin_move_angles"),
expect.stringContaining("dofbot_move_angles"),
expect.objectContaining({
tools: [
{
tool: "fuzzplugin_move_angles",
violations: ['fuzzplugin_move_angles.inputSchema.type must be "object"'],
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
},
],
}),
@@ -349,9 +349,9 @@ describe("createCodexDynamicToolBridge", () => {
runId: "run-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
toolName: "fuzzplugin_move_angles",
toolName: "dofbot_move_angles",
deniedReason: "unsupported_tool_schema",
reason: 'fuzzplugin_move_angles.inputSchema.type must be "object"',
reason: 'dofbot_move_angles.inputSchema.type must be "object"',
}),
);
@@ -360,13 +360,13 @@ describe("createCodexDynamicToolBridge", () => {
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "fuzzplugin_move_angles",
tool: "dofbot_move_angles",
arguments: {},
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: fuzzplugin_move_angles" }],
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: dofbot_move_angles" }],
});
expect(badExecute).not.toHaveBeenCalled();
});

View File

@@ -185,41 +185,6 @@ describe("Codex native hook relay config", () => {
});
});
it("keeps selected no-policy PreToolUse installed with an unavailable no-op marker", () => {
expect(
buildCodexNativeHookRelayConfig({
relay: createRelay({ inactiveEvents: ["pre_tool_use"] }),
events: ["pre_tool_use"],
}),
).toEqual({
"features.hooks": true,
"hooks.PreToolUse": [
{
hooks: [
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --pre-tool-use-unavailable noop",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
},
],
},
],
"hooks.state": {
"/<session-flags>/config.toml:pre_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
"<session-flags>/config.toml:pre_tool_use:0:0": {
enabled: true,
trusted_hash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
},
});
});
it("clears omitted hook events when requested", () => {
expect(
buildCodexNativeHookRelayConfig({
@@ -311,11 +276,7 @@ function createRelay(options?: {
expiresAtMs: Date.now() + 1000,
shouldRelayEvent: (event) => !inactiveEvents.has(event),
commandForEvent: (event) =>
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}${
event === "pre_tool_use" && inactiveEvents.has(event)
? " --pre-tool-use-unavailable noop"
: ""
}`,
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}`,
renew: () => undefined,
unregister: () => undefined,
};

View File

@@ -231,11 +231,7 @@ export function buildCodexNativeHookRelayConfig(params: {
for (const event of CODEX_NATIVE_HOOK_RELAY_EVENTS) {
const codexEvent = CODEX_HOOK_EVENT_BY_NATIVE_EVENT[event];
const selected = selectedEvents.has(event);
const shouldRelay = params.relay.shouldRelayEvent(event);
// Keep no-policy PreToolUse commands installed with an explicit no-op marker;
// otherwise a stale relay fallback cannot distinguish no policy from unknown policy.
const selectedNoopPreToolUse = selected && event === "pre_tool_use" && !shouldRelay;
if (!selected || (!shouldRelay && !selectedNoopPreToolUse)) {
if (!selected || !params.relay.shouldRelayEvent(event)) {
if (selected || params.clearOmittedEvents) {
config[`hooks.${codexEvent}`] = [] satisfies JsonValue;
}

View File

@@ -556,7 +556,7 @@ export class CodexNativeSubagentMonitor {
childState.transcriptPollTimer = setTimeout(() => {
childState.transcriptPollTimer = undefined;
void this.reconcileChildTranscript(childState.childThreadId)
.catch((error: unknown) => {
.catch((error) => {
embeddedAgentLog.warn("Failed to reconcile Codex native subagent transcript", {
childThreadId: childState.childThreadId,
error: formatErrorMessage(error),
@@ -595,7 +595,7 @@ export class CodexNativeSubagentMonitor {
}
this.taskRowReconcileTimer = setInterval(
() => {
void this.reconcileKnownTaskRows().catch((error: unknown) => {
void this.reconcileKnownTaskRows().catch((error) => {
embeddedAgentLog.warn("Failed to reconcile Codex native subagent task rows", {
error: formatErrorMessage(error),
});

View File

@@ -88,10 +88,10 @@ export async function waitForPluginApprovalDecision(params: {
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
if (params.signal!.aborted) {
reject(toLintErrorObject(params.signal!.reason, "Non-Error rejection"));
reject(params.signal!.reason);
return;
}
onAbort = () => reject(toLintErrorObject(params.signal!.reason, "Non-Error rejection"));
onAbort = () => reject(params.signal!.reason);
params.signal!.addEventListener("abort", onAbort, { once: true });
});
try {
@@ -121,17 +121,3 @@ export function mapExecDecisionToOutcome(
function truncateForGateway(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 3))}...`;
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -1037,7 +1037,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await vi.waitFor(
() => {
if (runError) {
throw toLintErrorObject(runError, "Non-Error thrown");
throw runError;
}
expect(harness.requests.map((request) => request.method)).toContain("turn/start");
},
@@ -1709,17 +1709,3 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(maintain).not.toHaveBeenCalled();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -696,7 +696,7 @@ describe("runCodexAppServerAttempt", () => {
});
await expect(
client.request("turn/start", turnParams).catch(async (error: unknown) => {
client.request("turn/start", turnParams).catch(async (error) => {
await releaseCodexSandboxExecServerEnvironment(sandbox);
throw error;
}),
@@ -763,7 +763,7 @@ describe("runCodexAppServerAttempt", () => {
nativeCodeModeOnlyEnabled: false,
userMcpServersEnabled: false,
environmentSelection,
}).catch(async (error: unknown) => {
}).catch(async (error) => {
await releaseCodexSandboxExecServerEnvironment(sandbox);
throw error;
}),
@@ -1237,7 +1237,7 @@ describe("runCodexAppServerAttempt", () => {
params.prompt = "already persisted prompt";
params.suppressNextUserMessagePersistence = true;
const readTranscript = async () =>
fs.readFile(sessionFile, "utf8").catch((error: unknown) => {
fs.readFile(sessionFile, "utf8").catch((error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}

View File

@@ -94,11 +94,9 @@ export function createCodexUserInputBridge(params: {
resolvePending(emptyUserInputResponse());
return;
}
void deliverUserInputPrompt(params.paramsForRun, requestParams.questions).catch(
(error: unknown) => {
embeddedAgentLog.warn("failed to deliver codex user input prompt", { error });
},
);
void deliverUserInputPrompt(params.paramsForRun, requestParams.questions).catch((error) => {
embeddedAgentLog.warn("failed to deliver codex user input prompt", { error });
});
});
},
handleQueuedMessage(text) {

View File

@@ -388,24 +388,22 @@ async function withPluginMigrationEligibility(params: {
return evaluated;
}
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch(
(error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: {
code: "app_inventory_unavailable",
apps,
error: message,
},
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
});
}
return undefined;
},
);
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: {
code: "app_inventory_unavailable",
apps,
error: message,
},
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
});
}
return undefined;
});
if (!snapshot) {
return evaluated;
}

View File

@@ -0,0 +1,11 @@
import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
describePluginRegistrationContract({
pluginId: "comfy",
providerIds: ["comfy"],
imageGenerationProviderIds: ["comfy"],
musicGenerationProviderIds: ["comfy"],
videoGenerationProviderIds: ["comfy"],
requireGenerateImage: true,
requireGenerateVideo: true,
});

View File

@@ -1396,7 +1396,7 @@ describe("runCopilotAttempt", () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
pool.release = vi.fn(async () => {
throw toLintErrorObject("release failed", "Non-Error thrown");
throw "release failed";
});
await expect(runCopilotAttempt(makeParams(), { pool })).rejects.toThrow("release failed");
@@ -1414,7 +1414,7 @@ describe("runCopilotAttempt", () => {
});
const pool = makeFakePool(sdk);
pool.release = vi.fn(async () => {
throw toLintErrorObject("release failed", "Non-Error thrown");
throw "release failed";
});
const result = await runCopilotAttempt(makeParams(), { pool });
@@ -2534,17 +2534,3 @@ describe("runCopilotAttempt", () => {
});
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -115,7 +115,7 @@ export function attachEventBridge(
});
deltaChain = deltaQueue.then(() => {
if (firstDeltaError !== undefined) {
throw toLintErrorObject(firstDeltaError, "Non-Error thrown");
throw firstDeltaError;
}
});
void deltaChain.catch(() => undefined);
@@ -354,17 +354,3 @@ function registerListener<K extends SessionEventType>(
session.off?.(eventType, handler as (...args: unknown[]) => void);
});
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -419,7 +419,7 @@ describe("createCopilotClientPool", () => {
it("normalizes non-Error stop failures during dispose", async () => {
const sdk = makeFake({
stop: () => {
throw toLintErrorObject("stop-string", "Non-Error thrown");
throw "stop-string";
},
});
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
@@ -485,17 +485,3 @@ describe("createCopilotClientPool", () => {
expect(String(sdk.ctorCalls[0]?.baseDirectory)).toBe(normalizedHome);
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -30,7 +30,7 @@ export async function loadCopilotSdk(options: LoadCopilotSdkOptions = {}): Promi
const promise = doLoad(options);
if (useCache) {
cached = promise.catch((err: unknown) => {
cached = promise.catch((err) => {
cached = undefined;
throw err;
});

View File

@@ -204,7 +204,7 @@ describe("createTraceContextProvider", () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: () => {
throw toLintErrorObject("string-boom", "Non-Error thrown");
throw "string-boom";
},
onError,
});
@@ -236,17 +236,3 @@ describe("createTraceContextProvider", () => {
expect(getTraceparent).not.toHaveBeenCalled();
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -504,11 +504,11 @@ export function createPairingNotifierService(api: OpenClawPluginApi): OpenClawPl
await notifyPendingPairingRequests({ api, statePath });
};
await tick().catch((err: unknown) => {
await tick().catch((err) => {
api.logger.warn(`device-pair: initial notify poll failed: ${formatErrorMessage(err)}`);
});
notifyInterval = setInterval(() => {
tick().catch((err: unknown) => {
tick().catch((err) => {
api.logger.warn(`device-pair: notify poll failed: ${formatErrorMessage(err)}`);
});
}, NOTIFY_POLL_INTERVAL_MS);

View File

@@ -4,8 +4,6 @@ Official extended syntax highlighting pack for the OpenClaw Diffs plugin.
The base `@openclaw/diffs` plugin ships a curated language set. Install this package when you want the full Shiki language catalog available in rendered diff viewers and diff image/PDF output.
The pack adds highlighting for languages outside the default diffs viewer set, including Astro, Vue, Svelte, MDX, GraphQL, Terraform/HCL, Nix, Clojure, Elixir, Haskell, OCaml, Scala, Zig, Solidity, Verilog/VHDL, Fortran, MATLAB, LaTeX, Mermaid, Sass/Less/SCSS, Nginx, Apache, CSV, dotenv, INI, and diff files. See the plugin reference and Shiki language catalog for details.
## Install
```bash

View File

@@ -331,7 +331,7 @@ async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<str
return await executablePathCache.valuePromise;
}
const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error: unknown) => {
const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error) => {
if (executablePathCache?.valuePromise === valuePromise) {
executablePathCache = null;
}
@@ -405,7 +405,7 @@ async function acquireSharedBrowser(params: {
}
return browser;
})
.catch((error: unknown) => {
.catch((error) => {
if (sharedBrowserState?.browserPromise === browserPromise) {
sharedBrowserState = null;
}

View File

@@ -226,7 +226,7 @@ export class DiffArtifactStore {
this.nextCleanupAt = now + this.cleanupIntervalMs;
const cleanupPromise = this.cleanupExpired()
.catch((error: unknown) => {
.catch((error) => {
this.nextCleanupAt = 0;
this.logger?.warn(`Failed to clean expired diff artifacts: ${String(error)}`);
})

View File

@@ -163,7 +163,7 @@ describe("createDiscordRestClient proxy support", () => {
},
})
.catch((err: unknown) => {
reject(toLintErrorObject(err, "Non-Error rejection"));
reject(err);
server.close();
});
});
@@ -175,17 +175,3 @@ describe("createDiscordRestClient proxy support", () => {
expect(received.body).toContain('"attachments":[{"id":0,"filename":"image.png"}]');
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -153,6 +153,7 @@ export function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle
return ButtonStyle.Danger;
case "link":
return ButtonStyle.Link;
case "primary":
default:
return ButtonStyle.Primary;
}

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