Compare commits

..
Author SHA1 Message Date
Peter Steinberger c4e4e54055 fix: default direct gateway port + docs (#1603) (thanks @ngutman) 2026-01-24 21:01:42 +00:00
Nimrod Gutman a8a3c85b4c feat(macos): add direct gateway transport 2026-01-24 20:54:04 +00:00
Peter Steinberger a1ed671636 docs: add backup strategy faq 2026-01-24 20:34:05 +00:00
Peter Steinberger 8c47d226ad docs: add subscription requirement faq 2026-01-24 20:32:33 +00:00
Peter Steinberger e1942603e9 docs: add xfinity unblock link 2026-01-24 20:30:03 +00:00
Peter Steinberger 926c2647b8 docs: mention local-only model option 2026-01-24 20:15:58 +00:00
Peter Steinberger c427f4a2fc docs: add imessage mac requirement faq 2026-01-24 20:13:13 +00:00
Peter Steinberger f99f9a6b64 docs: add self-update faq entry 2026-01-24 20:10:47 +00:00
Petter BlombergandPeter Steinberger 39d8c441eb fix: reduce log noise for node disconnect/late invoke errors (#1607)
* fix: reduce log noise for node disconnect/late invoke errors

- Handle both 'node not connected' and 'node disconnected' errors at info level
- Return success with late:true for unknown invoke IDs instead of error
- Add 30-second throttle to skills change listener to prevent rapid-fire probes
- Add tests for isNodeUnavailableError and late invoke handling

* fix: clean up skills refresh timer and listener on shutdown

Store the return value from registerSkillsChangeListener() and call it
on gateway shutdown. Also clear any pending refresh timer. This follows
the same pattern used for agentUnsub and heartbeatUnsub.

* refactor: simplify KISS/YAGNI - inline checks, remove unit tests for internal utilities

* fix: reduce gateway log noise (#1607) (thanks @petter-b)

* test: align agent id casing expectations (#1607)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:05:41 +00:00
Peter Steinberger 40ef3b5d30 docs: add linux install faq entry 2026-01-24 19:58:42 +00:00
Peter Steinberger 390b730b37 fix: unify reasoning tags + agent ids (#1613) (thanks @kyleok) (#1629) 2026-01-24 19:56:02 +00:00
Ganghyun KimandClaude Code 71457fa100 fix(tui): strip <final> tags in TUI display (#1613)
Add <final> tag handling to stripThinkingTags() to prevent reasoning-tag
provider responses from leaking incomplete tags during streaming.

When using providers like google-antigravity/*, ollama, or minimax, the
model wraps responses in <think>...</think> and <final>...</final> tags.
The TUI was only stripping <think> tags, causing <final> to leak through
and display as the response ~50% of the time.

This is a defense-in-depth fix for the TUI layer.

Fixes: #1561

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-01-24 19:52:34 +00:00
Peter Steinberger da7a45b3a5 docs: clarify migration state vs workspace 2026-01-24 19:50:02 +00:00
Denys VitaliandClaude 15a9c21203 Add Build & Release Docker Image workflows (#1602)
* ci: build & release docker image

* ci: sync docker-release workflow updates

Squashes:
- ci: use correct runs-on
- ci: build images

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove submodule checkout from docker-release.yml

Removed submodule checkout step from Docker release workflow.

* Simplify Docker release workflow by removing submodule checkout

Removed submodule checkout step from Docker release workflow.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-24 19:23:55 +00:00
27 changed files with 956 additions and 235 deletions

No files matched your search

+10 -6
View File
@@ -41,8 +41,12 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64 type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64 type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push amd64 image - name: Build and push amd64 image
id: build id: build
@@ -86,7 +90,11 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64 type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64 type=ref,event=branch,suffix=-arm64
- name: Build and push arm64 image - name: Build and push arm64 image
@@ -110,9 +118,6 @@ jobs:
contents: read contents: read
needs: [build-amd64, build-arm64] needs: [build-amd64, build-arm64]
steps: steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -128,12 +133,11 @@ jobs:
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_tag}}
- name: Create and push manifest - name: Create and push manifest
run: | run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-amd64.outputs.image-digest }} \ ${{ needs.build-amd64.outputs.image-digest }} \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-arm64.outputs.image-digest }} ${{ needs.build-arm64.outputs.image-digest }}
env: env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
+2 -1
View File
@@ -12,12 +12,13 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing. - Heartbeat: normalize target identifiers for consistent routing.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
## 2026.1.23-1 ## 2026.1.23-1
### Fixes ### Fixes
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js). - Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
- CI: publish multi-arch Docker images on main and release tags. (#1602) Thanks @denysvitali.
## 2026.1.23 ## 2026.1.23
+82 -28
View File
@@ -24,6 +24,11 @@ final class AppState {
case remote case remote
} }
enum RemoteTransport: String {
case ssh
case direct
}
var isPaused: Bool { var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
} }
@@ -166,6 +171,10 @@ final class AppState {
} }
} }
var remoteTransport: RemoteTransport {
didSet { self.syncGatewayConfigIfNeeded() }
}
var canvasEnabled: Bool { var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
} }
@@ -200,6 +209,10 @@ final class AppState {
} }
} }
var remoteUrl: String {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteIdentity: String { var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
} }
@@ -263,13 +276,15 @@ final class AppState {
} }
let configRoot = ClawdbotConfigFile.loadDict() let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote, if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl) let host = AppState.remoteHost(from: configRemoteUrl)
{ {
@@ -277,6 +292,7 @@ final class AppState {
} else { } else {
self.remoteTarget = storedRemoteTarget self.remoteTarget = storedRemoteTarget
} }
self.remoteUrl = configRemoteUrl ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -354,10 +370,11 @@ final class AppState {
private func applyConfigOverrides(_ root: [String: Any]) { private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any] let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let hasRemoteUrl = !(remoteUrl? let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
let desiredMode: ConnectionMode? = switch modeRaw { let desiredMode: ConnectionMode? = switch modeRaw {
case "local": case "local":
@@ -378,8 +395,17 @@ final class AppState {
self.connectionMode = .remote self.connectionMode = .remote
} }
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
let targetMode = desiredMode ?? self.connectionMode let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote, if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl) let host = AppState.remoteHost(from: remoteUrl)
{ {
self.updateRemoteTarget(host: host) self.updateRemoteTarget(host: host)
@@ -402,6 +428,8 @@ final class AppState {
let connectionMode = self.connectionMode let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode { let desiredMode: String? = switch connectionMode {
case .local: case .local:
"local" "local"
@@ -435,39 +463,63 @@ final class AppState {
var remote = gateway["remote"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false var remoteChanged = false
if let host = remoteHost { if remoteTransport == .direct {
let existingUrl = (remote["url"] as? String)? let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedUrl.isEmpty {
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) if remote["url"] != nil {
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" remote.removeValue(forKey: "url")
let port = parsedExisting?.port ?? 18789 remoteChanged = true
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" }
if existingUrl != desiredUrl { } else {
remote["url"] = desiredUrl let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true
}
}
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true remoteChanged = true
} }
} } else {
if remote["transport"] != nil {
remote.removeValue(forKey: "transport")
remoteChanged = true
}
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty { if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget { if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true remoteChanged = true
} }
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty { if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity { if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true remoteChanged = true
} }
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
} }
if remoteChanged { if remoteChanged {
@@ -621,8 +673,10 @@ extension AppState {
state.iconOverride = .system state.iconOverride = .system
state.heartbeatsEnabled = true state.heartbeatsEnabled = true
state.connectionMode = .local state.connectionMode = .local
state.remoteTransport = .ssh
state.canvasEnabled = true state.canvasEnabled = true
state.remoteTarget = "user@example.com" state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot" state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = "" state.remoteCliPath = ""
@@ -0,0 +1,47 @@
import ClawdbotDiscovery
import Foundation
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
tailnetDns: gateway.tailnetDns,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
tailnetDns: String?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
return "wss://\(tailnetDns)"
}
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
}
static func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
return nil
}
return host
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -4,6 +4,8 @@ import SwiftUI
struct GatewayDiscoveryInlineList: View { struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel var discovery: GatewayDiscoveryModel
var currentTarget: String? var currentTarget: String?
var currentUrl: String?
var transport: AppState.RemoteTransport
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID? @State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
} else { } else {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.gateways.prefix(6)) { gateway in ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway) let display = self.displayInfo(for: gateway)
let selected = (target != nil && self.currentTarget? let selected = display.selected
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
Text(target ?? "Gateway pairing only") Text(display.label)
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor))) .fill(Color(NSColor.controlBackgroundColor)))
} }
} }
.help("Click a discovered gateway to fill the SSH target.") .help(self.transport == .direct
? "Click a discovered gateway to fill the gateway URL."
: "Click a discovered gateway to fill the SSH target.")
} }
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { private func displayInfo(
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
guard let host else { return nil } {
let user = NSUserName() switch self.transport {
return GatewayDiscoveryModel.buildSSHTarget( case .direct:
user: user, let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
host: host, let label = url ?? "Gateway pairing only"
port: gateway.sshPort) let selected = url != nil && self.trimmed(self.currentUrl) == url
} return (label, selected)
case .ssh:
private func sanitizedTailnetHost(_ host: String?) -> String? { let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
guard let host else { return nil } let label = target ?? "Gateway pairing only"
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) let selected = target != nil && self.trimmed(self.currentTarget) == target
if trimmed.isEmpty { return nil } return (label, selected)
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
return nil
} }
return trimmed
} }
private func rowBackground(selected: Bool, hovered: Bool) -> Color { private func rowBackground(selected: Bool, hovered: Bool) -> Color {
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
if hovered { return Color.secondary.opacity(0.08) } if hovered { return Color.secondary.opacity(0.08) }
return Color.clear return Color.clear
} }
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
} }
struct GatewayDiscoveryMenu: View { struct GatewayDiscoveryMenu: View {
@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
token: token, token: token,
password: password)) password: password))
case .remote: case .remote:
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
self.cancelRemoteEnsure()
self.setState(.unavailable(
mode: .remote,
reason: "gateway.remote.url missing or invalid for direct transport"))
return
}
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return
}
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
@@ -341,6 +354,25 @@ actor GatewayEndpointStore {
code: 1, code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
}
self.logger.info("remote transport direct; skipping SSH tunnel")
return portInt
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError( throw NSError(
@@ -401,6 +433,21 @@ actor GatewayEndpointStore {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
}
self.kickRemoteEnsureIfNeeded(detail: detail) self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else { guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
@@ -0,0 +1,64 @@
import Foundation
enum GatewayRemoteConfig {
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["transport"] as? String
else {
return .ssh
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
}
static func resolveUrlString(root: [String: Any]) -> String? {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let urlRaw = remote["url"] as? String
else {
return nil
}
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)
}
static func normalizeGatewayUrlString(_ raw: String) -> String? {
self.normalizeGatewayUrl(raw)?.absoluteString
}
static func normalizeGatewayUrl(_ raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return nil }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
if scheme == "ws", url.port == nil {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.port = 18789
return components.url
}
return url
}
static func defaultPort(for url: URL) -> Int? {
if let port = url.port { return port }
let scheme = url.scheme?.lowercased() ?? ""
switch scheme {
case "wss":
return 443
case "ws":
return 18789
default:
return nil
}
}
}
+162 -69
View File
@@ -17,6 +17,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
private var remoteLabelWidth: CGFloat { 88 }
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
Picker("Mode", selection: self.$state.connectionMode) { Picker("Mode", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured) Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote) Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
} }
.pickerStyle(.menu) .pickerStyle(.menu)
.labelsHidden() .labelsHidden()
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
private var remoteCard: some View { private var remoteCard: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) { self.remoteTransportRow
Text("SSH")
.font(.callout.weight(.semibold)) if self.state.remoteTransport == .ssh {
.frame(width: 48, alignment: .leading) self.remoteSshRow
TextField("user@host[:22]", text: self.$state.remoteTarget) } else {
.textFieldStyle(.roundedBorder) self.remoteDirectRow
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
GatewayDiscoveryInlineList( GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery, discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget) currentTarget: self.state.remoteTarget,
currentUrl: self.state.remoteUrl,
transport: self.state.remoteTransport)
{ gateway in { gateway in
self.applyDiscoveredGateway(gateway) self.applyDiscoveredGateway(gateway)
} }
.padding(.leading, 58) .padding(.leading, self.remoteLabelWidth + 10)
self.remoteStatusView self.remoteStatusView
.padding(.leading, 58) .padding(.leading, self.remoteLabelWidth + 10)
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { if self.state.remoteTransport == .ssh {
VStack(alignment: .leading, spacing: 8) { DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
LabeledContent("Identity file") { VStack(alignment: .leading, spacing: 8) {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) LabeledContent("Identity file") {
.textFieldStyle(.roundedBorder) TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.frame(width: 280) .textFieldStyle(.roundedBorder)
} .frame(width: 280)
LabeledContent("Project root") { }
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) LabeledContent("Project root") {
.textFieldStyle(.roundedBorder) TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.frame(width: 280) .textFieldStyle(.roundedBorder)
} .frame(width: 280)
LabeledContent("CLI path") { }
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath) LabeledContent("CLI path") {
.textFieldStyle(.roundedBorder) TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.frame(width: 280) .textFieldStyle(.roundedBorder)
.frame(width: 280)
}
} }
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
} }
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
} }
// Diagnostics // Diagnostics
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
} }
} }
Text("Tip: enable Tailscale for stable remote access.") if self.state.remoteTransport == .ssh {
.font(.footnote) Text("Tip: enable Tailscale for stable remote access.")
.foregroundStyle(.secondary) .font(.footnote)
.lineLimit(1) .foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
} }
.transition(.opacity) .transition(.opacity)
.onAppear { self.gatewayDiscovery.start() } .onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() } .onDisappear { self.gatewayDiscovery.stop() }
} }
private var remoteTransportRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(maxWidth: 320)
}
}
private var remoteSshRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private var remoteDirectRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
private var controlStatusLine: String { private var controlStatusLine: String {
switch ControlChannel.shared.state { switch ControlChannel.shared.state {
case .connected: "Connected" case .connected: "Connected"
@@ -458,24 +523,36 @@ extension GeneralSettings {
func testRemote() async { func testRemote() async {
self.remoteStatus = .checking self.remoteStatus = .checking
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
guard !settings.target.isEmpty else { if self.state.remoteTransport == .direct {
self.remoteStatus = .failed("Set an SSH target first") let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
return guard !trimmedUrl.isEmpty else {
self.remoteStatus = .failed("Set a gateway URL first")
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
return
}
} else {
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
} }
// Step 1: basic SSH reachability check // Step 2: control channel health check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
// Step 2: control channel health over tunnel
let originalMode = AppStateStore.shared.connectionMode let originalMode = AppStateStore.shared.connectionMode
do { do {
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
@@ -502,6 +579,14 @@ extension GeneralSettings {
} }
} }
private static func isValidWsUrl(_ raw: String) -> Bool {
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return false }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] { private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [ var args: [String] = [
"/usr/bin/ssh", "/usr/bin/ssh",
@@ -570,12 +655,18 @@ extension GeneralSettings {
let host = gateway.tailnetDns ?? gateway.lanHost let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return } guard let host else { return }
let user = NSUserName() let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( if self.state.remoteTransport == .direct {
user: user, if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
host: host, self.state.remoteUrl = url
port: gateway.sshPort) }
self.state.remoteCliPath = gateway.cliPath ?? "" } else {
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
} }
} }
@@ -598,7 +689,9 @@ extension GeneralSettings {
static func exerciseForTesting() { static func exerciseForTesting() {
let state = AppState(preview: true) let state = AppState(preview: true)
state.connectionMode = .remote state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222" state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "/tmp/id_ed25519" state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/clawdbot" state.remoteProjectRoot = "/tmp/clawdbot"
state.remoteCliPath = "/tmp/clawdbot" state.remoteCliPath = "/tmp/clawdbot"
@@ -1,4 +1,5 @@
import AppKit import AppKit
import Foundation
import Observation import Observation
import SwiftUI import SwiftUI
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
switch mode { switch mode {
case .remote: case .remote:
platform = "remote" platform = "remote"
let target = AppStateStore.shared.remoteTarget if AppStateStore.shared.remoteTransport == .direct {
if let parsed = CommandResolver.parseSSHTarget(target) { let trimmedUrl = AppStateStore.shared.remoteUrl
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" .trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
if let port = url.port {
host = "\(urlHost):\(port)"
} else {
host = urlHost
}
} else {
host = trimmedUrl.nonEmpty
}
} else { } else {
host = target.nonEmpty let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
} else {
host = target.nonEmpty
}
} }
case .local: case .local:
platform = "local" platform = "local"
@@ -25,7 +25,11 @@ extension OnboardingView {
self.preferredGatewayID = gateway.stableID self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost { if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let user = NSUserName() let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user, user: user,
@@ -177,42 +177,67 @@ extension OnboardingView {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow { GridRow {
Text("SSH target") Text("Transport")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading) .frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget) Picker("Transport", selection: self.$state.remoteTransport) {
.textFieldStyle(.roundedBorder) Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
.frame(width: fieldWidth) Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(width: fieldWidth)
} }
GridRow { if self.state.remoteTransport == .direct {
Text("Identity file") GridRow {
.font(.callout.weight(.semibold)) Text("Gateway URL")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) .frame(width: labelWidth, alignment: .leading)
.textFieldStyle(.roundedBorder) TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.frame(width: fieldWidth) .textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
} }
GridRow { if self.state.remoteTransport == .ssh {
Text("Project root") GridRow {
.font(.callout.weight(.semibold)) Text("SSH target")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) .frame(width: labelWidth, alignment: .leading)
.textFieldStyle(.roundedBorder) TextField("user@host[:port]", text: self.$state.remoteTarget)
.frame(width: fieldWidth) .textFieldStyle(.roundedBorder)
} .frame(width: fieldWidth)
GridRow { }
Text("CLI path") GridRow {
.font(.callout.weight(.semibold)) Text("Identity file")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField( .frame(width: labelWidth, alignment: .leading)
"/Applications/Clawdbot.app/.../clawdbot", TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
text: self.$state.remoteCliPath) .textFieldStyle(.roundedBorder)
.textFieldStyle(.roundedBorder) .frame(width: fieldWidth)
.frame(width: fieldWidth) }
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
} }
} }
Text("Tip: keep Tailscale enabled so your gateway stays reachable.") Text(self.state.remoteTransport == .direct
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -225,7 +250,10 @@ extension OnboardingView {
} }
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let host = gateway.tailnetDns ?? gateway.lanHost { if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
}
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : "" let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)" return "\(host)\(portSuffix)"
} }
@@ -175,4 +175,10 @@ import Testing
customBindHost: "192.168.1.10") customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10") #expect(host == "192.168.1.10")
} }
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://gateway:18789")
}
} }
+17 -1
View File
@@ -2825,13 +2825,14 @@ Auth and Tailscale:
Remote client defaults (CLI): Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
macOS app behavior: macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. - Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode. - If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file. - When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
```json5 ```json5
{ {
@@ -2846,6 +2847,21 @@ macOS app behavior:
} }
``` ```
Direct transport example (macOS app):
```json5
{
gateway: {
mode: "remote",
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
token: "your-token"
}
}
}
```
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
+110 -1
View File
@@ -9,6 +9,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [What is Clawdbot?](#what-is-clawdbot) - [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph) - [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition)
- [Quick start and first-run setup](#quick-start-and-first-run-setup) - [Quick start and first-run setup](#quick-start-and-first-run-setup)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot) - [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding) - [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
@@ -20,8 +21,11 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now) - [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev) - [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer) - [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps) - [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
- [Can I ask Clawd to update itself?](#can-i-ask-clawd-to-update-itself)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do) - [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [Do I need a Claude or OpenAI subscription to run this?](#do-i-need-a-claude-or-openai-subscription-to-run-this)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work) - [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Where do I find an Anthropic setup-token?](#where-do-i-find-an-anthropic-setup-token) - [Where do I find an Anthropic setup-token?](#where-do-i-find-an-anthropic-setup-token)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth) - [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
@@ -32,6 +36,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats) - [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region) - [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this) - [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this)
- [Do I need a Mac mini for iMessage support?](#do-i-need-a-mac-mini-for-imessage-support)
- [Can I use Bun?](#can-i-use-bun) - [Can I use Bun?](#can-i-use-bun)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom) - [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots) - [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
@@ -56,6 +61,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Where things live on disk](#where-things-live-on-disk) - [Where things live on disk](#where-things-live-on-disk)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data) - [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live) - [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot) - [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace) - [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store) - [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
@@ -96,6 +102,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching) - [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model) - [What is the “default model”?](#what-is-the-default-model)
- [What model do you recommend?](#what-model-do-you-recommend) - [What model do you recommend?](#what-model-do-you-recommend)
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting) - [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply) - [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21) - [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21)
@@ -196,6 +203,28 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product. Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
### What’s the value proposition?
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
SaaS.
Highlights:
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
workspace + session history local.
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
plus mobile voice and Canvas on supported platforms.
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
and failover.
- **Local-only option:** run local models so **all data can stay on your device** if you want.
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
workspace and defaults.
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
[Memory](/concepts/memory).
## Quick start and first-run setup ## Quick start and first-run setup
### What’s the recommended way to install and set up Clawdbot? ### What’s the recommended way to install and set up Clawdbot?
@@ -254,7 +283,9 @@ not a hard minimum.
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding? ### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
Yes. Copy the **state directory** and **workspace**, then run Doctor once: Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
keeps your bot “exactly the same” (memory, session history, auth, and channel
state) as long as you copy **both** locations:
1) Install Clawdbot on the new machine. 1) Install Clawdbot on the new machine.
2) Copy `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`) from the old machine. 2) Copy `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`) from the old machine.
@@ -264,6 +295,10 @@ Yes. Copy the **state directory** and **workspace**, then run Doctor once:
That preserves config, auth profiles, WhatsApp creds, sessions, and memory. If you’re in That preserves config, auth profiles, WhatsApp creds, sessions, and memory. If you’re in
remote mode, remember the gateway host owns the session store and workspace. remote mode, remember the gateway host owns the session store and workspace.
**Important:** if you only commit/push your workspace to GitHub, you’re backing
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents/<agentId>/sessions/`).
Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data), Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor), [Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote). [Remote mode](/gateway/remote).
@@ -282,6 +317,7 @@ section is the latest shipped version. Entries are grouped by **Highlights**, **
Some Comcast/Xfinity connections incorrectly block `docs.clawd.bot` via Xfinity Some Comcast/Xfinity connections incorrectly block `docs.clawd.bot` via Xfinity
Advanced Security. Disable it or allowlist `docs.clawd.bot`, then retry. More Advanced Security. Disable it or allowlist `docs.clawd.bot`, then retry. More
detail: [Troubleshooting](/help/troubleshooting#docsclawdbot-shows-an-ssl-error-comcastxfinity). detail: [Troubleshooting](/help/troubleshooting#docsclawdbot-shows-an-ssl-error-comcastxfinity).
Please help us unblock it by reporting here: https://spa.xfinity.com/check_url_status.
If you still can't reach the site, the docs are mirrored on GitHub: If you still can't reach the site, the docs are mirrored on GitHub:
https://github.com/clawdbot/clawdbot/tree/main/docs https://github.com/clawdbot/clawdbot/tree/main/docs
@@ -317,6 +353,14 @@ curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
More detail: [Install](/install) and [Installer flags](/install/installer). More detail: [Install](/install) and [Installer flags](/install/installer).
### How do I install Clawdbot on Linux?
Short answer: follow the Linux guide, then run the onboarding wizard.
- Linux quick path + service install: [Linux](/platforms/linux).
- Full walkthrough: [Getting Started](/start/getting-started).
- Installer + updates: [Install & updates](/install/updating).
### How do I install Clawdbot on a VPS? ### How do I install Clawdbot on a VPS?
Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the Gateway. Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the Gateway.
@@ -324,6 +368,31 @@ Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the
Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly). Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly).
Remote access: [Gateway remote](/gateway/remote). Remote access: [Gateway remote](/gateway/remote).
### Can I ask Clawd to update itself?
Short answer: **possible, not recommended**. The update flow can restart the
Gateway (which drops the active session), may need a clean git checkout, and
can prompt for confirmation. Safer: run updates from a shell as the operator.
Use the CLI:
```bash
clawdbot update
clawdbot update status
clawdbot update --channel stable|beta|dev
clawdbot update --tag <dist-tag|version>
clawdbot update --no-restart
```
If you must automate from an agent:
```bash
clawdbot update --yes --no-restart
clawdbot gateway restart
```
Docs: [Update](/cli/update), [Updating](/install/updating).
### What does the onboarding wizard actually do? ### What does the onboarding wizard actually do?
`clawdbot onboard` is the recommended setup path. In **local mode** it walks you through: `clawdbot onboard` is the recommended setup path. In **local mode** it walks you through:
@@ -337,6 +406,15 @@ Remote access: [Gateway remote](/gateway/remote).
It also warns if your configured model is unknown or missing auth. It also warns if your configured model is unknown or missing auth.
### Do I need a Claude or OpenAI subscription to run this?
No. You can run Clawdbot with **API keys** (Anthropic/OpenAI/others) or with
**local‑only models** so your data stays on your device. Subscriptions (Claude
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
[Local models](/gateway/local-models), [Models](/concepts/models).
### How does Anthropic "setup-token" auth work? ### How does Anthropic "setup-token" auth work?
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth). `claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
@@ -406,6 +484,20 @@ If you want other macOS‑only tools, run the Gateway on a Mac or pair a macOS n
Docs: [iMessage](/channels/imessage), [Nodes](/nodes), [Mac remote mode](/platforms/mac/remote). Docs: [iMessage](/channels/imessage), [Nodes](/nodes), [Mac remote mode](/platforms/mac/remote).
### Do I need a Mac mini for iMessage support?
You need **some macOS device** signed into Messages. It does **not** have to be a Mac mini —
any Mac works. Clawdbot’s iMessage integrations run on macOS (BlueBubbles or `imsg`), while
the Gateway can run elsewhere.
Common setups:
- Run the Gateway on Linux/VPS, and point `channels.imessage.cliPath` at an SSH wrapper that
runs `imsg` on the Mac.
- Run everything on the Mac if you want the simplest single‑machine setup.
Docs: [iMessage](/channels/imessage), [BlueBubbles](/channels/bluebubbles),
[Mac remote mode](/platforms/mac/remote).
### Can I use Bun? ### Can I use Bun?
Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram. Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram.
@@ -706,6 +798,18 @@ workspace, not your local laptop).
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory). See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### What’s the recommended backup strategy?
Put your **agent workspace** in a **private** git repo and back it up somewhere
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER
files, and lets you restore the assistant’s “mind” later.
Do **not** commit anything under `~/.clawdbot` (credentials, sessions, tokens).
If you need a full restore, back up both the workspace and the state directory
separately (see the migration question above).
Docs: [Agent workspace](/concepts/agent-workspace).
### How do I completely uninstall Clawdbot? ### How do I completely uninstall Clawdbot?
See the dedicated guide: [Uninstall](/install/uninstall). See the dedicated guide: [Uninstall](/install/uninstall).
@@ -1220,6 +1324,11 @@ injection and unsafe behavior. See [Security](/gateway/security).
More context: [Models](/concepts/models). More context: [Models](/concepts/models).
### What do Clawd, Flawd, and Krill use for models?
- **Clawd + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-5`) — see [Anthropic](/providers/anthropic).
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) — see [MiniMax](/providers/minimax).
### How do I switch models on the fly (without restarting)? ### How do I switch models on the fly (without restarting)?
Use the `/model` command as a standalone message: Use the `/model` command as a standalone message:
+16
View File
@@ -460,6 +460,22 @@ File contents here`,
expect(result).toBe("The actual answer."); expect(result).toBe("The actual answer.");
}); });
it("strips final tags while keeping content", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<final>\nAnswer\n</final>",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Answer");
});
it("strips thought tags", () => { it("strips thought tags", () => {
const msg: AssistantMessage = { const msg: AssistantMessage = {
role: "assistant", role: "assistant",
+2 -30
View File
@@ -1,4 +1,5 @@
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js"; import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js"; import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
@@ -166,36 +167,7 @@ export function stripDowngradedToolCallText(text: string): string {
* that slip through other filtering mechanisms. * that slip through other filtering mechanisms.
*/ */
export function stripThinkingTagsFromText(text: string): string { export function stripThinkingTagsFromText(text: string): string {
if (!text) return text; return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
// Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of text.matchAll(tagRe)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (!inThinking && !isClose) {
// Opening tag - save text before it.
result += text.slice(lastIndex, idx);
inThinking = true;
} else if (inThinking && isClose) {
// Closing tag - skip content inside.
inThinking = false;
}
lastIndex = idx + match[0].length;
}
// Append remaining text if we're not inside thinking.
if (!inThinking) {
result += text.slice(lastIndex);
}
return result.trim();
} }
export function extractAssistantText(msg: AssistantMessage): string { export function extractAssistantText(msg: AssistantMessage): string {
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
describe("gateway.remote.transport", () => {
it("accepts direct transport", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects unknown transport", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
remote: {
transport: "udp",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.remote.transport");
}
});
});
+2
View File
@@ -80,6 +80,8 @@ export type GatewayTailscaleConfig = {
export type GatewayRemoteConfig = { export type GatewayRemoteConfig = {
/** Remote Gateway WebSocket URL (ws:// or wss://). */ /** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string; url?: string;
/** Transport for macOS remote connections (ssh tunnel or direct WS). */
transport?: "ssh" | "direct";
/** Token for remote auth (when the gateway requires token auth). */ /** Token for remote auth (when the gateway requires token auth). */
token?: string; token?: string;
/** Password for remote auth (when the gateway requires password auth). */ /** Password for remote auth (when the gateway requires password auth). */
+1
View File
@@ -332,6 +332,7 @@ export const ClawdbotSchema = z
remote: z remote: z
.object({ .object({
url: z.string().optional(), url: z.string().optional(),
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
token: z.string().optional(), token: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
tlsFingerprint: z.string().optional(), tlsFingerprint: z.string().optional(),
+4 -1
View File
@@ -468,7 +468,10 @@ export const nodeHandlers: GatewayRequestHandlers = {
error: p.error ?? null, error: p.error ?? null,
}); });
if (!ok) { if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown invoke id")); // Late-arriving results (after invoke timeout) are expected and harmless.
// Return success instead of error to reduce log noise; client can discard.
context.logGateway.debug(`late invoke result ignored: id=${p.id} node=${p.nodeId}`);
respond(true, { ok: true, ignored: true }, undefined);
return; return;
} }
respond(true, { ok: true }, undefined); respond(true, { ok: true }, undefined);
+18 -3
View File
@@ -344,9 +344,19 @@ export async function startGatewayServer(
setSkillsRemoteRegistry(nodeRegistry); setSkillsRemoteRegistry(nodeRegistry);
void primeRemoteSkillsCache(); void primeRemoteSkillsCache();
registerSkillsChangeListener(() => { // Debounce skills-triggered node probes to avoid feedback loops and rapid-fire invokes.
const latest = loadConfig(); // Skills changes can happen in bursts (e.g., file watcher events), and each probe
void refreshRemoteBinsForConnectedNodes(latest); // takes time to complete. A 30-second delay ensures we batch changes together.
let skillsRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const skillsRefreshDelayMs = 30_000;
const skillsChangeUnsub = registerSkillsChangeListener((event) => {
if (event.reason === "remote-node") return;
if (skillsRefreshTimer) clearTimeout(skillsRefreshTimer);
skillsRefreshTimer = setTimeout(() => {
skillsRefreshTimer = null;
const latest = loadConfig();
void refreshRemoteBinsForConnectedNodes(latest);
}, skillsRefreshDelayMs);
}); });
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({ const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
@@ -544,6 +554,11 @@ export async function startGatewayServer(
if (diagnosticsEnabled) { if (diagnosticsEnabled) {
stopDiagnosticHeartbeat(); stopDiagnosticHeartbeat();
} }
if (skillsRefreshTimer) {
clearTimeout(skillsRefreshTimer);
skillsRefreshTimer = null;
}
skillsChangeUnsub();
await close(opts); await close(opts);
}, },
}; };
@@ -0,0 +1,125 @@
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
vi.mock("../infra/update-runner.js", () => ({
runGatewayUpdate: vi.fn(async () => ({
status: "ok",
mode: "git",
root: "/repo",
steps: [],
durationMs: 12,
})),
}));
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
});
afterAll(async () => {
ws.close();
await server.close();
});
describe("late-arriving invoke results", () => {
test("returns success for unknown invoke id (late arrival after timeout)", async () => {
// Create a node client WebSocket
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
try {
// Connect as a node with device identity
const identity = loadOrCreateDeviceIdentity();
const nodeId = identity.deviceId;
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: ["canvas.snapshot"],
});
// Send an invoke result with an unknown ID (simulating late arrival after timeout)
const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>(
nodeWs,
"node.invoke.result",
{
id: "unknown-invoke-id-12345",
nodeId,
ok: true,
payloadJSON: JSON.stringify({ result: "late" }),
},
);
// Late-arriving results return success instead of error to reduce log noise
expect(result.ok).toBe(true);
expect(result.payload?.ok).toBe(true);
expect(result.payload?.ignored).toBe(true);
} finally {
nodeWs.close();
}
});
test("returns success for unknown invoke id with error payload", async () => {
// Verifies late results are accepted regardless of their ok/error status
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
try {
await connectOk(nodeWs, {
role: "node",
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
platform: "darwin",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: [],
});
const identity = loadOrCreateDeviceIdentity();
const nodeId = identity.deviceId;
// Late invoke result with error payload - should still return success
const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>(
nodeWs,
"node.invoke.result",
{
id: "another-unknown-invoke-id",
nodeId,
ok: false,
error: { code: "FAILED", message: "test error" },
},
);
expect(result.ok).toBe(true);
expect(result.payload?.ok).toBe(true);
expect(result.payload?.ignored).toBe(true);
} finally {
nodeWs.close();
}
});
});
+17 -4
View File
@@ -55,10 +55,10 @@ function extractErrorMessage(err: unknown): string | undefined {
function logRemoteBinProbeFailure(nodeId: string, err: unknown) { function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
const message = extractErrorMessage(err); const message = extractErrorMessage(err);
const label = describeNode(nodeId); const label = describeNode(nodeId);
if (message?.includes("node not connected")) { // Node unavailable errors (not connected or disconnected mid-operation) are expected
log.info( // when nodes have transient connections - log at info level instead of warn
`remote bin probe skipped: node not connected (${label}); check nodes list/status for ${label}`, if (message?.includes("node not connected") || message?.includes("node disconnected")) {
); log.info(`remote bin probe skipped: node unavailable (${label})`);
return; return;
} }
if (message?.includes("invoke timed out") || message?.includes("timeout")) { if (message?.includes("invoke timed out") || message?.includes("timeout")) {
@@ -213,6 +213,15 @@ function parseBinProbePayload(payloadJSON: string | null | undefined, payload?:
return []; return [];
} }
function areBinSetsEqual(a: Set<string> | undefined, b: Set<string>): boolean {
if (!a) return false;
if (a.size !== b.size) return false;
for (const bin of b) {
if (!a.has(bin)) return false;
}
return true;
}
export async function refreshRemoteNodeBins(params: { export async function refreshRemoteNodeBins(params: {
nodeId: string; nodeId: string;
platform?: string; platform?: string;
@@ -261,7 +270,11 @@ export async function refreshRemoteNodeBins(params: {
return; return;
} }
const bins = parseBinProbePayload(res.payloadJSON, res.payload); const bins = parseBinProbePayload(res.payloadJSON, res.payload);
const existingBins = remoteNodes.get(params.nodeId)?.bins;
const nextBins = new Set(bins);
const hasChanged = !areBinSetsEqual(existingBins, nextBins);
recordRemoteNodeBins(params.nodeId, bins); recordRemoteNodeBins(params.nodeId, bins);
if (!hasChanged) return;
await updatePairedNodeMetadata(params.nodeId, { bins }); await updatePairedNodeMetadata(params.nodeId, { bins });
bumpSkillsSnapshotVersion({ reason: "remote-node" }); bumpSkillsSnapshotVersion({ reason: "remote-node" });
} catch (err) { } catch (err) {
+2 -1
View File
@@ -67,9 +67,10 @@ export function normalizeAgentId(value: string | undefined | null): string {
export function sanitizeAgentId(value: string | undefined | null): string { export function sanitizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID; if (!trimmed) return DEFAULT_AGENT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
return ( return (
trimmed trimmed
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-") .replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+/, "") .replace(/^-+/, "")
.replace(/-+$/, "") .replace(/-+$/, "")
+61
View File
@@ -0,0 +1,61 @@
export type ReasoningTagMode = "strict" | "preserve";
export type ReasoningTagTrim = "none" | "start" | "both";
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking|final)\b/i;
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^>]*>/gi;
const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
function applyTrim(value: string, mode: ReasoningTagTrim): string {
if (mode === "none") return value;
if (mode === "start") return value.trimStart();
return value.trim();
}
export function stripReasoningTagsFromText(
text: string,
options?: {
mode?: ReasoningTagMode;
trim?: ReasoningTagTrim;
},
): string {
if (!text) return text;
if (!QUICK_TAG_RE.test(text)) return text;
const mode = options?.mode ?? "strict";
const trimMode = options?.trim ?? "both";
let cleaned = text;
if (FINAL_TAG_RE.test(cleaned)) {
FINAL_TAG_RE.lastIndex = 0;
cleaned = cleaned.replace(FINAL_TAG_RE, "");
} else {
FINAL_TAG_RE.lastIndex = 0;
}
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of cleaned.matchAll(THINKING_TAG_RE)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (!inThinking) {
result += cleaned.slice(lastIndex, idx);
if (!isClose) {
inThinking = true;
}
} else if (isClose) {
inThinking = false;
}
lastIndex = idx + match[0].length;
}
if (!inThinking || mode === "preserve") {
result += cleaned.slice(lastIndex);
}
return applyTrim(result, trimMode);
}
+17
View File
@@ -21,5 +21,22 @@ describe("stripThinkingTags", () => {
it("returns original text when no tags exist", () => { it("returns original text when no tags exist", () => {
expect(stripThinkingTags("Hello")).toBe("Hello"); expect(stripThinkingTags("Hello")).toBe("Hello");
}); });
it("strips <final>…</final> segments", () => {
const input = "<final>\n\nHello there\n\n</final>";
expect(stripThinkingTags(input)).toBe("Hello there\n\n");
});
it("strips mixed <think> and <final> tags", () => {
const input = "<think>reasoning</think>\n\n<final>Hello</final>";
expect(stripThinkingTags(input)).toBe("Hello");
});
it("handles incomplete <final tag gracefully", () => {
// When streaming splits mid-tag, we may see "<final" without closing ">"
// This should not crash and should handle gracefully
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
});
}); });
+3 -33
View File
@@ -1,3 +1,5 @@
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export function formatMs(ms?: number | null): string { export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a"; if (!ms && ms !== 0) return "n/a";
return new Date(ms).toLocaleString(); return new Date(ms).toLocaleString();
@@ -67,38 +69,6 @@ export function parseList(input: string): string[] {
.filter((v) => v.length > 0); .filter((v) => v.length > 0);
} }
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
export function stripThinkingTags(value: string): string { export function stripThinkingTags(value: string): string {
if (!value) return value; return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
const hasOpen = THINKING_OPEN_RE.test(value);
const hasClose = THINKING_CLOSE_RE.test(value);
if (!hasOpen && !hasClose) return value;
// If we don't have a balanced pair, avoid dropping trailing content.
if (hasOpen !== hasClose) {
if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart();
return value.replace(THINKING_OPEN_RE, "").trimStart();
}
if (!THINKING_TAG_RE.test(value)) return value;
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of value.matchAll(THINKING_TAG_RE)) {
const idx = match.index ?? 0;
if (!inThinking) {
result += value.slice(lastIndex, idx);
}
const tag = match[0].toLowerCase();
inThinking = !tag.includes("/");
lastIndex = idx + match[0].length;
}
if (!inThinking) {
result += value.slice(lastIndex);
}
return result.trimStart();
} }