Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f975ebf91b |
No files matched your search
@@ -55,5 +55,3 @@ apps/ios/*.mobileprovision
|
|||||||
# Local untracked files
|
# Local untracked files
|
||||||
.local/
|
.local/
|
||||||
.vscode/
|
.vscode/
|
||||||
IDENTITY.md
|
|
||||||
USER.md
|
|
||||||
+12
-40
@@ -2,71 +2,44 @@
|
|||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## 2026.1.15 (unreleased)
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
|
||||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
|
||||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
|
||||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
- TUI: show provider/model labels for the active session and default model.
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||||
|
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||||
|
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
|
||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||||
|
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||||
|
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
|
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||||
|
- Fix: macOS gateway stays attached across restarts (no port flapping) and status probes pick the right auth. (#982) — thanks @wes-davis.
|
||||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||||
|
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
|
||||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
|
||||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
|
||||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
|
||||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
|
||||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
|
||||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
|
||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
|
||||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
|
||||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
|
||||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
|
||||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
|
||||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
|
||||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
|
||||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
|
||||||
|
|
||||||
## 2026.1.14-1
|
## 2026.1.14-1
|
||||||
|
|
||||||
@@ -168,7 +141,6 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
|
||||||
|
|
||||||
## 2026.1.12-1
|
## 2026.1.12-1
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ Sources/Voice/VoiceTab.swift
|
|||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Clawdbot
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.clawdbot
|
bundleIdPrefix: com.clawdbot
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "18.0"
|
iOS: "17.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
@@ -73,15 +73,6 @@
|
|||||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-concurrency-extras",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
|
||||||
"version" : "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-configuration",
|
"identity" : "swift-configuration",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -153,24 +144,6 @@
|
|||||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||||
"version" : "1.6.3"
|
"version" : "1.6.3"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swiftui-math",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
|
||||||
"version" : "0.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "textual",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/textual",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
|
||||||
"version" : "0.2.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -341,15 +341,6 @@ final class AppState {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sanitizeSSHTarget(_ value: String) -> String {
|
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.hasPrefix("ssh ") {
|
|
||||||
return trimmed.replacingOccurrences(of: "ssh ", with: "")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startConfigWatcher() {
|
private func startConfigWatcher() {
|
||||||
let configUrl = ClawdbotConfigFile.url()
|
let configUrl = ClawdbotConfigFile.url()
|
||||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||||
@@ -415,7 +406,6 @@ final class AppState {
|
|||||||
|
|
||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
let remoteIdentity = self.remoteIdentity
|
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@@ -445,46 +435,15 @@ final class AppState {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if connectionMode == .remote {
|
if connectionMode == .remote, let host = remoteHost {
|
||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||||
if let host = remoteHost {
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||||
let existingUrl = (remote["url"] as? String)?
|
let port = parsedExisting?.port ?? 18789
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
if existingUrl != desiredUrl {
|
||||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
remote["url"] = desiredUrl
|
||||||
let port = parsedExisting?.port ?? 18789
|
|
||||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
|
||||||
if existingUrl != desiredUrl {
|
|
||||||
remote["url"] = desiredUrl
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
|
||||||
if !sanitizedTarget.isEmpty {
|
|
||||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
|
||||||
remote["sshTarget"] = sanitizedTarget
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
} else if remote["sshTarget"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshTarget")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmedIdentity.isEmpty {
|
|
||||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
|
||||||
remote["sshIdentity"] = trimmedIdentity
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
} else if remote["sshIdentity"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshIdentity")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteChanged {
|
|
||||||
gateway["remote"] = remote
|
gateway["remote"] = remote
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,15 +188,9 @@ extension CronJobEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDeleteAfterRun(
|
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||||
to root: inout [String: Any],
|
if self.scheduleKind == .at {
|
||||||
scheduleKind: ScheduleKind? = nil,
|
root["deleteAfterRun"] = self.deleteAfterRun
|
||||||
deleteAfterRun: Bool? = nil)
|
|
||||||
{
|
|
||||||
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
|
||||||
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
|
||||||
if resolvedSchedule == .at {
|
|
||||||
root["deleteAfterRun"] = resolvedDelete
|
|
||||||
} else if self.job?.deleteAfterRun != nil {
|
} else if self.job?.deleteAfterRun != nil {
|
||||||
root["deleteAfterRun"] = false
|
root["deleteAfterRun"] = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ enum GatewayEnvironment {
|
|||||||
let port = self.gatewayPort()
|
let port = self.gatewayPort()
|
||||||
if let gatewayBin {
|
if let gatewayBin {
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ enum GatewayEnvironment {
|
|||||||
case let .success(resolvedRuntime) = runtime
|
case let .success(resolvedRuntime) = runtime
|
||||||
{
|
{
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ extension InstancesSettings {
|
|||||||
host: "phone",
|
host: "phone",
|
||||||
ip: "10.0.0.3",
|
ip: "10.0.0.3",
|
||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
platform: "iOS 18.0",
|
platform: "iOS 17.2",
|
||||||
deviceFamily: "iPhone",
|
deviceFamily: "iPhone",
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: 35,
|
lastInputSeconds: 35,
|
||||||
@@ -446,7 +446,7 @@ extension InstancesSettings {
|
|||||||
_ = view.platformIcon("watchOS 10")
|
_ = view.platformIcon("watchOS 10")
|
||||||
_ = view.platformIcon("unknown 1.0")
|
_ = view.platformIcon("unknown 1.0")
|
||||||
_ = view.prettyPlatform("macOS 14.2")
|
_ = view.prettyPlatform("macOS 14.2")
|
||||||
_ = view.prettyPlatform("iOS 18")
|
_ = view.prettyPlatform("iOS 17")
|
||||||
_ = view.prettyPlatform("ipados 17.1")
|
_ = view.prettyPlatform("ipados 17.1")
|
||||||
_ = view.prettyPlatform("linux")
|
_ = view.prettyPlatform("linux")
|
||||||
_ = view.prettyPlatform(" ")
|
_ = view.prettyPlatform(" ")
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ final class MacNodeModeCoordinator {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if SystemRunPolicy.load() != .never {
|
if SystemRunPolicy.load() != .never {
|
||||||
commands.append(ClawdbotSystemCommand.which.rawValue)
|
|
||||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleScreenRecordInvoke(req)
|
return try await self.handleScreenRecordInvoke(req)
|
||||||
case ClawdbotSystemCommand.run.rawValue:
|
case ClawdbotSystemCommand.run.rawValue:
|
||||||
return try await self.handleSystemRun(req)
|
return try await self.handleSystemRun(req)
|
||||||
case ClawdbotSystemCommand.which.rawValue:
|
|
||||||
return try await self.handleSystemWhich(req)
|
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
default:
|
default:
|
||||||
@@ -496,33 +494,6 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
||||||
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
|
|
||||||
let bins = params.bins
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
guard !bins.isEmpty else {
|
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchPaths = CommandResolver.preferredPaths()
|
|
||||||
var matches: [String] = []
|
|
||||||
var paths: [String: String] = [:]
|
|
||||||
for bin in bins {
|
|
||||||
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
|
|
||||||
matches.append(bin)
|
|
||||||
paths[bin] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WhichPayload: Encodable {
|
|
||||||
let bins: [String]
|
|
||||||
let paths: [String: String]
|
|
||||||
}
|
|
||||||
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -351,8 +351,11 @@ actor PortGuardian {
|
|||||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||||
return false
|
return false
|
||||||
case .local:
|
case .local:
|
||||||
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
|
// The gateway may listen as `clawdbot` or via its runtime (e.g. node).
|
||||||
if full.contains("gateway-daemon") { return true }
|
let hasGatewayArgs =
|
||||||
|
full.contains(" gateway ") &&
|
||||||
|
full.contains(" --port \(port)")
|
||||||
|
if hasGatewayArgs { return true }
|
||||||
// If args are unavailable, treat a clawdbot listener as expected.
|
// If args are unavailable, treat a clawdbot listener as expected.
|
||||||
if cmd.contains("clawdbot"), full == cmd { return true }
|
if cmd.contains("clawdbot"), full == cmd { return true }
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ClawdbotProtocol
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||||
@@ -70,16 +70,22 @@ struct CronJobEditorSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||||
let view = CronJobEditor(
|
var view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
|
view.name = "One-shot"
|
||||||
|
view.sessionTarget = .main
|
||||||
|
view.payloadKind = .systemEvent
|
||||||
|
view.systemEventText = "hello"
|
||||||
|
view.scheduleKind = .at
|
||||||
|
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
view.deleteAfterRun = true
|
||||||
|
|
||||||
var root: [String: Any] = [:]
|
let payload = try view.buildPayload()
|
||||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
let raw = payload["deleteAfterRun"]?.value as? Bool
|
||||||
let raw = root["deleteAfterRun"] as? Bool
|
|
||||||
#expect(raw == true)
|
#expect(raw == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ struct CronModelsTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 15,
|
timeoutSeconds: 15,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: false)
|
bestEffortDeliver: false)
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Testing
|
|||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789", "--bind", "loopback"],
|
||||||
"EnvironmentVariables": [
|
"EnvironmentVariables": [
|
||||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
||||||
@@ -28,7 +28,7 @@ import Testing
|
|||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789"],
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
|
|||||||
host: "gateway",
|
host: "gateway",
|
||||||
ip: "10.0.0.4",
|
ip: "10.0.0.4",
|
||||||
version: "3.0.0",
|
version: "3.0.0",
|
||||||
platform: "iOS 18",
|
platform: "iOS 17",
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: nil,
|
lastInputSeconds: nil,
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import Testing
|
|||||||
stateversion: StateVersion(presence: 1, health: 1),
|
stateversion: StateVersion(presence: 1, health: 1),
|
||||||
uptimems: 123,
|
uptimems: 123,
|
||||||
configpath: nil,
|
configpath: nil,
|
||||||
statedir: nil,
|
statedir: nil)
|
||||||
sessiondefaults: nil)
|
|
||||||
|
|
||||||
let hello = HelloOk(
|
let hello = HelloOk(
|
||||||
type: "hello",
|
type: "hello",
|
||||||
|
|||||||
@@ -21,15 +21,6 @@ struct MacNodeRuntimeTests {
|
|||||||
#expect(response.ok == false)
|
#expect(response.ok == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
|
|
||||||
let runtime = MacNodeRuntime()
|
|
||||||
let params = ClawdbotSystemWhichParams(bins: [])
|
|
||||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
|
||||||
let response = await runtime.handleInvoke(
|
|
||||||
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
|
|
||||||
#expect(response.ok == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||||
let runtime = MacNodeRuntime()
|
let runtime = MacNodeRuntime()
|
||||||
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
||||||
@@ -74,10 +65,6 @@ struct MacNodeRuntimeTests {
|
|||||||
{
|
{
|
||||||
CLLocation(latitude: 0, longitude: 0)
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
|
||||||
.allowOnce
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let services = await MainActor.run { FakeMainActorServices() }
|
let services = await MainActor.run { FakeMainActorServices() }
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
||||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||||
#expect(!order.contains(7))
|
#expect(!order.contains(7))
|
||||||
#expect(order.contains(3))
|
#expect(order.contains(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
||||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||||
#expect(!order.contains(8))
|
#expect(!order.contains(8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "sms",
|
provider: "sms",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Testing
|
|||||||
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
||||||
defer { _ = TalkAudioPlayer.shared.stop() }
|
defer { _ = TalkAudioPlayer.shared.stop() }
|
||||||
|
|
||||||
_ = try await withTimeout(seconds: 4.0) {
|
_ = try await withTimeout(seconds: 2.0) {
|
||||||
await TalkAudioPlayer.shared.play(data: wav)
|
await TalkAudioPlayer.shared.play(data: wav)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ import Testing
|
|||||||
await Task.yield()
|
await Task.yield()
|
||||||
_ = await TalkAudioPlayer.shared.play(data: wav)
|
_ = await TalkAudioPlayer.shared.play(data: wav)
|
||||||
|
|
||||||
_ = try await withTimeout(seconds: 4.0) {
|
_ = try await withTimeout(seconds: 2.0) {
|
||||||
await first.value
|
await first.value
|
||||||
}
|
}
|
||||||
#expect(true)
|
#expect(true)
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ import Testing
|
|||||||
#expect(opts.thinking == "low")
|
#expect(opts.thinking == "low")
|
||||||
#expect(opts.deliver == true)
|
#expect(opts.deliver == true)
|
||||||
#expect(opts.to == nil)
|
#expect(opts.to == nil)
|
||||||
#expect(opts.channel == .last)
|
#expect(opts.provider == .last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ClawdbotKit",
|
name: "ClawdbotKit",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v18),
|
.iOS(.v17),
|
||||||
.macOS(.v15),
|
.macOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
@@ -14,7 +14,6 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -30,13 +29,7 @@ let package = Package(
|
|||||||
]),
|
]),
|
||||||
.target(
|
.target(
|
||||||
name: "ClawdbotChatUI",
|
name: "ClawdbotChatUI",
|
||||||
dependencies: [
|
dependencies: ["ClawdbotKit"],
|
||||||
"ClawdbotKit",
|
|
||||||
.product(
|
|
||||||
name: "Textual",
|
|
||||||
package: "textual",
|
|
||||||
condition: .when(platforms: [.macOS, .iOS])),
|
|
||||||
],
|
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum ChatMarkdownPreprocessor {
|
|
||||||
struct InlineImage: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let label: String
|
|
||||||
let image: ClawdbotPlatformImage?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Result {
|
|
||||||
let cleaned: String
|
|
||||||
let images: [InlineImage]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func preprocess(markdown raw: String) -> Result {
|
|
||||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
|
||||||
return Result(cleaned: raw, images: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
let ns = raw as NSString
|
|
||||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
|
||||||
if matches.isEmpty { return Result(cleaned: raw, images: []) }
|
|
||||||
|
|
||||||
var images: [InlineImage] = []
|
|
||||||
var cleaned = raw
|
|
||||||
|
|
||||||
for match in matches.reversed() {
|
|
||||||
guard match.numberOfRanges >= 3 else { continue }
|
|
||||||
let label = ns.substring(with: match.range(at: 1))
|
|
||||||
let dataURL = ns.substring(with: match.range(at: 2))
|
|
||||||
|
|
||||||
let image: ClawdbotPlatformImage? = {
|
|
||||||
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
|
||||||
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
|
||||||
guard let data = Data(base64Encoded: b64) else { return nil }
|
|
||||||
return ClawdbotPlatformImage(data: data)
|
|
||||||
}()
|
|
||||||
images.append(InlineImage(label: label, image: image))
|
|
||||||
|
|
||||||
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
|
||||||
let end = cleaned.index(start, offsetBy: match.range.length)
|
|
||||||
cleaned.replaceSubrange(start..<end, with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized = cleaned
|
|
||||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return Result(cleaned: normalized, images: images.reversed())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Textual
|
|
||||||
|
|
||||||
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
|
||||||
case standard
|
|
||||||
case compact
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct ChatMarkdownRenderer: View {
|
|
||||||
enum Context {
|
|
||||||
case user
|
|
||||||
case assistant
|
|
||||||
}
|
|
||||||
|
|
||||||
let text: String
|
|
||||||
let context: Context
|
|
||||||
let variant: ChatMarkdownVariant
|
|
||||||
let font: Font
|
|
||||||
let textColor: Color
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
StructuredText(markdown: processed.cleaned)
|
|
||||||
.modifier(ChatMarkdownStyle(
|
|
||||||
variant: self.variant,
|
|
||||||
context: self.context,
|
|
||||||
font: self.font,
|
|
||||||
textColor: self.textColor))
|
|
||||||
|
|
||||||
if !processed.images.isEmpty {
|
|
||||||
InlineImageList(images: processed.images)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ChatMarkdownStyle: ViewModifier {
|
|
||||||
let variant: ChatMarkdownVariant
|
|
||||||
let context: ChatMarkdownRenderer.Context
|
|
||||||
let font: Font
|
|
||||||
let textColor: Color
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
Group {
|
|
||||||
if self.variant == .compact {
|
|
||||||
content.textual.structuredTextStyle(.default)
|
|
||||||
} else {
|
|
||||||
content.textual.structuredTextStyle(.gitHub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(self.font)
|
|
||||||
.foregroundStyle(self.textColor)
|
|
||||||
.textual.inlineStyle(self.inlineStyle)
|
|
||||||
.textual.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var inlineStyle: InlineStyle {
|
|
||||||
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
|
||||||
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
|
||||||
return InlineStyle()
|
|
||||||
.code(.monospaced, .fontScale(codeScale))
|
|
||||||
.link(.foregroundColor(linkColor))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct InlineImageList: View {
|
|
||||||
let images: [ChatMarkdownPreprocessor.InlineImage]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ForEach(images, id: \.id) { item in
|
|
||||||
if let img = item.image {
|
|
||||||
ClawdbotPlatformImageFactory.image(img)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(maxHeight: 260)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
||||||
} else {
|
|
||||||
Text(item.label.isEmpty ? "Image" : item.label)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ChatMarkdownSplitter {
|
||||||
|
struct InlineImage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let label: String
|
||||||
|
let image: ClawdbotPlatformImage?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Block: Identifiable {
|
||||||
|
enum Kind: Equatable {
|
||||||
|
case text
|
||||||
|
case code(language: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let kind: Kind
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SplitResult {
|
||||||
|
let blocks: [Block]
|
||||||
|
let images: [InlineImage]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func split(markdown raw: String) -> SplitResult {
|
||||||
|
let extracted = self.extractInlineImages(from: raw)
|
||||||
|
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
|
||||||
|
return SplitResult(blocks: blocks, images: extracted.images)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitCodeBlocks(from raw: String) -> [Block] {
|
||||||
|
var blocks: [Block] = []
|
||||||
|
var buffer: [String] = []
|
||||||
|
var inCode = false
|
||||||
|
var codeLang: String?
|
||||||
|
var codeLines: [String] = []
|
||||||
|
|
||||||
|
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||||
|
if line.hasPrefix("```") {
|
||||||
|
if inCode {
|
||||||
|
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
||||||
|
codeLines.removeAll(keepingCapacity: true)
|
||||||
|
inCode = false
|
||||||
|
codeLang = nil
|
||||||
|
} else {
|
||||||
|
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !text.isEmpty {
|
||||||
|
blocks.append(Block(kind: .text, text: text))
|
||||||
|
}
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
inCode = true
|
||||||
|
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if codeLang?.isEmpty == true { codeLang = nil }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCode {
|
||||||
|
codeLines.append(line)
|
||||||
|
} else {
|
||||||
|
buffer.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCode {
|
||||||
|
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
||||||
|
} else {
|
||||||
|
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !text.isEmpty {
|
||||||
|
blocks.append(Block(kind: .text, text: text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
|
||||||
|
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||||
|
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||||
|
return (raw, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let ns = raw as NSString
|
||||||
|
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||||
|
if matches.isEmpty { return (raw, []) }
|
||||||
|
|
||||||
|
var images: [InlineImage] = []
|
||||||
|
var cleaned = raw
|
||||||
|
|
||||||
|
for match in matches.reversed() {
|
||||||
|
guard match.numberOfRanges >= 3 else { continue }
|
||||||
|
let label = ns.substring(with: match.range(at: 1))
|
||||||
|
let dataURL = ns.substring(with: match.range(at: 2))
|
||||||
|
|
||||||
|
let image: ClawdbotPlatformImage? = {
|
||||||
|
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
||||||
|
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
||||||
|
guard let data = Data(base64Encoded: b64) else { return nil }
|
||||||
|
return ClawdbotPlatformImage(data: data)
|
||||||
|
}()
|
||||||
|
images.append(InlineImage(label: label, image: image))
|
||||||
|
|
||||||
|
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
||||||
|
let end = cleaned.index(start, offsetBy: match.range.length)
|
||||||
|
cleaned.replaceSubrange(start..<end, with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = cleaned
|
||||||
|
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (normalized, images.reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,16 +137,10 @@ private struct ChatBubbleShape: InsettableShape {
|
|||||||
struct ChatMessageBubble: View {
|
struct ChatMessageBubble: View {
|
||||||
let message: ClawdbotChatMessage
|
let message: ClawdbotChatMessage
|
||||||
let style: ClawdbotChatView.Style
|
let style: ClawdbotChatView.Style
|
||||||
let markdownVariant: ChatMarkdownVariant
|
|
||||||
let userAccent: Color?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChatMessageBody(
|
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
|
||||||
message: self.message,
|
|
||||||
isUser: self.isUser,
|
|
||||||
style: self.style,
|
|
||||||
markdownVariant: self.markdownVariant,
|
|
||||||
userAccent: self.userAccent)
|
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
@@ -160,7 +154,6 @@ private struct ChatMessageBody: View {
|
|||||||
let message: ClawdbotChatMessage
|
let message: ClawdbotChatMessage
|
||||||
let isUser: Bool
|
let isUser: Bool
|
||||||
let style: ClawdbotChatView.Style
|
let style: ClawdbotChatView.Style
|
||||||
let markdownVariant: ChatMarkdownVariant
|
|
||||||
let userAccent: Color?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -176,14 +169,39 @@ private struct ChatMessageBody: View {
|
|||||||
isUser: self.isUser)
|
isUser: self.isUser)
|
||||||
}
|
}
|
||||||
} else if self.isUser {
|
} else if self.isUser {
|
||||||
ChatMarkdownRenderer(
|
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||||
text: text,
|
ForEach(split.blocks) { block in
|
||||||
context: .user,
|
switch block.kind {
|
||||||
variant: self.markdownVariant,
|
case .text:
|
||||||
font: .system(size: 14),
|
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
||||||
textColor: textColor)
|
case let .code(language):
|
||||||
|
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !split.images.isEmpty {
|
||||||
|
ForEach(
|
||||||
|
split.images,
|
||||||
|
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||||
|
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||||
|
if let img = item.image {
|
||||||
|
ClawdbotPlatformImageFactory.image(img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 260)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||||
|
} else {
|
||||||
|
Text(item.label.isEmpty ? "Image" : item.label)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
|
ChatAssistantTextBody(text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.inlineAttachments.isEmpty {
|
if !self.inlineAttachments.isEmpty {
|
||||||
@@ -487,11 +505,10 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct ChatStreamingAssistantBubble: View {
|
struct ChatStreamingAssistantBubble: View {
|
||||||
let text: String
|
let text: String
|
||||||
let markdownVariant: ChatMarkdownVariant
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
|
ChatAssistantTextBody(text: self.text)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
@@ -595,22 +612,114 @@ private struct TypingDots: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private struct MarkdownTextView: View {
|
||||||
|
let text: String
|
||||||
|
let textColor: Color
|
||||||
|
let font: Font
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let normalized = self.text.replacingOccurrences(
|
||||||
|
of: "(?<!\\n)\\n(?!\\n)",
|
||||||
|
with: " ",
|
||||||
|
options: .regularExpression)
|
||||||
|
let options = AttributedString.MarkdownParsingOptions(
|
||||||
|
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
||||||
|
Text(attributed)
|
||||||
|
.font(self.font)
|
||||||
|
.foregroundStyle(self.textColor)
|
||||||
|
} else {
|
||||||
|
Text(normalized)
|
||||||
|
.font(self.font)
|
||||||
|
.foregroundStyle(self.textColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private struct ChatAssistantTextBody: View {
|
private struct ChatAssistantTextBody: View {
|
||||||
let text: String
|
let text: String
|
||||||
let markdownVariant: ChatMarkdownVariant
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let segments = AssistantTextParser.segments(from: self.text)
|
let segments = AssistantTextParser.segments(from: self.text)
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(segments) { segment in
|
ForEach(segments) { segment in
|
||||||
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
||||||
ChatMarkdownRenderer(
|
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
|
||||||
text: segment.text,
|
|
||||||
context: .assistant,
|
|
||||||
variant: self.markdownVariant,
|
|
||||||
font: font,
|
|
||||||
textColor: ClawdbotChatTheme.assistantText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private struct ChatMarkdownBody: View {
|
||||||
|
let text: String
|
||||||
|
let textColor: Color
|
||||||
|
let font: Font
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(split.blocks) { block in
|
||||||
|
switch block.kind {
|
||||||
|
case .text:
|
||||||
|
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
||||||
|
case let .code(language):
|
||||||
|
CodeBlockView(code: block.text, language: language, isUser: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !split.images.isEmpty {
|
||||||
|
ForEach(
|
||||||
|
split.images,
|
||||||
|
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||||
|
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||||
|
if let img = item.image {
|
||||||
|
ClawdbotPlatformImageFactory.image(img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 260)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||||
|
} else {
|
||||||
|
Text(item.label.isEmpty ? "Image" : item.label)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private struct CodeBlockView: View {
|
||||||
|
let code: String
|
||||||
|
let language: String?
|
||||||
|
let isUser: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let language, !language.isEmpty {
|
||||||
|
Text(language)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(self.code)
|
||||||
|
.font(.system(size: 13, weight: .regular, design: .monospaced))
|
||||||
|
.foregroundStyle(self.isUser ? .white : .primary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
private let markdownVariant: ChatMarkdownVariant
|
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
@@ -43,13 +42,11 @@ public struct ClawdbotChatView: View {
|
|||||||
viewModel: ClawdbotChatViewModel,
|
viewModel: ClawdbotChatViewModel,
|
||||||
showsSessionSwitcher: Bool = false,
|
showsSessionSwitcher: Bool = false,
|
||||||
style: Style = .standard,
|
style: Style = .standard,
|
||||||
markdownVariant: ChatMarkdownVariant = .standard,
|
|
||||||
userAccent: Color? = nil)
|
userAccent: Color? = nil)
|
||||||
{
|
{
|
||||||
self._viewModel = State(initialValue: viewModel)
|
self._viewModel = State(initialValue: viewModel)
|
||||||
self.showsSessionSwitcher = showsSessionSwitcher
|
self.showsSessionSwitcher = showsSessionSwitcher
|
||||||
self.style = style
|
self.style = style
|
||||||
self.markdownVariant = markdownVariant
|
|
||||||
self.userAccent = userAccent
|
self.userAccent = userAccent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +151,7 @@ public struct ClawdbotChatView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var messageListRows: some View {
|
private var messageListRows: some View {
|
||||||
ForEach(self.visibleMessages) { msg in
|
ForEach(self.visibleMessages) { msg in
|
||||||
ChatMessageBubble(
|
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
|
||||||
message: msg,
|
|
||||||
style: self.style,
|
|
||||||
markdownVariant: self.markdownVariant,
|
|
||||||
userAccent: self.userAccent)
|
|
||||||
.frame(
|
.frame(
|
||||||
maxWidth: .infinity,
|
maxWidth: .infinity,
|
||||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||||
@@ -179,7 +172,7 @@ public struct ClawdbotChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
||||||
ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant)
|
ChatStreamingAssistantBubble(text: text)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
case which = "system.which"
|
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,14 +39,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
|
|
||||||
public var bins: [String]
|
|
||||||
|
|
||||||
public init(bins: [String]) {
|
|
||||||
self.bins = bins
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
|
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
|
||||||
public var title: String
|
public var title: String
|
||||||
public var body: String
|
public var body: String
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import Testing
|
|
||||||
@testable import ClawdbotChatUI
|
|
||||||
|
|
||||||
@Suite("ChatMarkdownPreprocessor")
|
|
||||||
struct ChatMarkdownPreprocessorTests {
|
|
||||||
@Test func extractsDataURLImages() {
|
|
||||||
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
|
|
||||||
let markdown = """
|
|
||||||
Hello
|
|
||||||
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
|
||||||
|
|
||||||
#expect(result.cleaned == "Hello")
|
|
||||||
#expect(result.images.count == 1)
|
|
||||||
#expect(result.images.first?.image != nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -101,29 +101,6 @@ group messages, so use admin if you need full visibility.
|
|||||||
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
||||||
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
||||||
|
|
||||||
## Commands (native + custom)
|
|
||||||
Clawdbot registers native commands (like `/status`, `/reset`, `/model`) with Telegram’s bot menu on startup.
|
|
||||||
You can add custom commands to the menu via config:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
customCommands: [
|
|
||||||
{ command: "backup", description: "Git backup" },
|
|
||||||
{ command: "generate", description: "Create an image" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
|
||||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
|
||||||
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
|
|
||||||
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
|
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||||
|
|||||||
@@ -871,7 +871,6 @@ Notes:
|
|||||||
- `commands.text: false` disables parsing chat messages for commands.
|
- `commands.text: false` disables parsing chat messages for commands.
|
||||||
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only.
|
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only.
|
||||||
- Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
|
- Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
|
||||||
- `channels.telegram.customCommands` adds extra Telegram bot menu entries. Names are normalized; conflicts with native commands are ignored.
|
|
||||||
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
||||||
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
|
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
|
||||||
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
||||||
@@ -930,10 +929,6 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
customCommands: [
|
|
||||||
{ command: "backup", description: "Git backup" },
|
|
||||||
{ command: "generate", description: "Create an image" }
|
|
||||||
],
|
|
||||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||||
|
|||||||
@@ -161,15 +161,6 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
|
|||||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||||
|
|
||||||
### Model strength (security note)
|
|
||||||
|
|
||||||
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
|
||||||
|
|
||||||
Recommendations:
|
|
||||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
|
||||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
|
||||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
|
||||||
|
|
||||||
## Reasoning & verbose output in groups
|
## Reasoning & verbose output in groups
|
||||||
|
|
||||||
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
|
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway-daemon",
|
"gateway",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND}",
|
"${CLAWDBOT_GATEWAY_BIND}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@@ -1355,11 +1355,9 @@ Facts (from code):
|
|||||||
|
|
||||||
Fix:
|
Fix:
|
||||||
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
|
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
|
||||||
- If you don’t have a token yet: `clawdbot doctor --generate-gateway-token`.
|
|
||||||
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
|
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
|
||||||
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
|
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
|
||||||
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
|
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
|
||||||
- Still stuck? Run `clawdbot status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
|
|
||||||
|
|
||||||
### I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens
|
### I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
enabled: true, // default: true
|
enabled: true, // default: true
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
||||||
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
|
||||||
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
headless: false,
|
headless: false,
|
||||||
@@ -81,8 +79,6 @@ Notes:
|
|||||||
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
||||||
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
||||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
|
||||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
|
||||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||||
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
|
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
|
||||||
@@ -158,42 +154,6 @@ Example:
|
|||||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
||||||
directly to a Chromium-based browser instance without a remote control server.
|
directly to a Chromium-based browser instance without a remote control server.
|
||||||
|
|
||||||
Remote CDP URLs can include auth:
|
|
||||||
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
|
||||||
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
|
|
||||||
|
|
||||||
Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
|
||||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
|
||||||
tokens instead of committing them to config files.
|
|
||||||
|
|
||||||
### Browserless (hosted remote CDP)
|
|
||||||
|
|
||||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
|
||||||
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
|
|
||||||
Browserless region endpoint and authenticate with your API key.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
defaultProfile: "browserless",
|
|
||||||
remoteCdpTimeoutMs: 2000,
|
|
||||||
remoteCdpHandshakeTimeoutMs: 4000,
|
|
||||||
profiles: {
|
|
||||||
browserless: {
|
|
||||||
cdpUrl: "https://production-sfo.browserless.io?token=<BROWSERLESS_API_KEY>",
|
|
||||||
color: "#00AA00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
|
||||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
|
||||||
|
|
||||||
### Running the control server on the browser machine
|
### Running the control server on the browser machine
|
||||||
|
|
||||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
Run a standalone browser control server (recommended when your Gateway is remote):
|
||||||
|
|||||||
@@ -19,53 +19,13 @@ open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; e
|
|||||||
Useful flags:
|
Useful flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# GPT image models with various options
|
|
||||||
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
|
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
|
||||||
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
|
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
|
||||||
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
|
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
|
||||||
python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp
|
|
||||||
|
|
||||||
# DALL-E 3 (note: count is automatically limited to 1)
|
|
||||||
python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid
|
|
||||||
python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape"
|
|
||||||
|
|
||||||
# DALL-E 2
|
|
||||||
python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Model-Specific Parameters
|
|
||||||
|
|
||||||
Different models support different parameter values. The script automatically selects appropriate defaults based on the model.
|
|
||||||
|
|
||||||
### Size
|
|
||||||
|
|
||||||
- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto`
|
|
||||||
- Default: `1024x1024`
|
|
||||||
- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792`
|
|
||||||
- Default: `1024x1024`
|
|
||||||
- **dall-e-2**: `256x256`, `512x512`, or `1024x1024`
|
|
||||||
- Default: `1024x1024`
|
|
||||||
|
|
||||||
### Quality
|
|
||||||
|
|
||||||
- **GPT image models**: `auto`, `high`, `medium`, or `low`
|
|
||||||
- Default: `high`
|
|
||||||
- **dall-e-3**: `hd` or `standard`
|
|
||||||
- Default: `standard`
|
|
||||||
- **dall-e-2**: `standard` only
|
|
||||||
- Default: `standard`
|
|
||||||
|
|
||||||
### Other Notable Differences
|
|
||||||
|
|
||||||
- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model.
|
|
||||||
- **GPT image models** support additional parameters:
|
|
||||||
- `--background`: `transparent`, `opaque`, or `auto` (default)
|
|
||||||
- `--output-format`: `png` (default), `jpeg`, or `webp`
|
|
||||||
- Note: `stream` and `moderation` are available via API but not yet implemented in this script
|
|
||||||
- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking)
|
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`)
|
- `*.png` images
|
||||||
- `prompts.json` (prompt → file mapping)
|
- `prompts.json` (prompt → file mapping)
|
||||||
- `index.html` (thumbnail gallery)
|
- `index.html` (thumbnail gallery)
|
||||||
@@ -62,53 +62,24 @@ def pick_prompts(count: int) -> list[str]:
|
|||||||
return prompts
|
return prompts
|
||||||
|
|
||||||
|
|
||||||
def get_model_defaults(model: str) -> tuple[str, str]:
|
|
||||||
"""Return (default_size, default_quality) for the given model."""
|
|
||||||
if model == "dall-e-2":
|
|
||||||
# quality will be ignored
|
|
||||||
return ("1024x1024", "standard")
|
|
||||||
elif model == "dall-e-3":
|
|
||||||
return ("1024x1024", "standard")
|
|
||||||
else:
|
|
||||||
# GPT image or future models
|
|
||||||
return ("1024x1024", "high")
|
|
||||||
|
|
||||||
|
|
||||||
def request_images(
|
def request_images(
|
||||||
api_key: str,
|
api_key: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
model: str,
|
model: str,
|
||||||
size: str,
|
size: str,
|
||||||
quality: str,
|
quality: str,
|
||||||
background: str = "",
|
|
||||||
output_format: str = "",
|
|
||||||
style: str = "",
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = "https://api.openai.com/v1/images/generations"
|
url = "https://api.openai.com/v1/images/generations"
|
||||||
args = {
|
body = json.dumps(
|
||||||
"model": model,
|
{
|
||||||
"prompt": prompt,
|
"model": model,
|
||||||
"size": size,
|
"prompt": prompt,
|
||||||
"n": 1,
|
"size": size,
|
||||||
}
|
"quality": quality,
|
||||||
|
"n": 1,
|
||||||
# Quality parameter - dall-e-2 doesn't accept this parameter
|
"response_format": "b64_json",
|
||||||
if model != "dall-e-2":
|
}
|
||||||
args["quality"] = quality
|
).encode("utf-8")
|
||||||
|
|
||||||
if model.startswith("dall-e"):
|
|
||||||
args["response_format"] = "b64_json"
|
|
||||||
|
|
||||||
if model.startswith("gpt-image"):
|
|
||||||
if background:
|
|
||||||
args["background"] = background
|
|
||||||
if output_format:
|
|
||||||
args["output_format"] = output_format
|
|
||||||
|
|
||||||
if model == "dall-e-3" and style:
|
|
||||||
args["style"] = style
|
|
||||||
|
|
||||||
body = json.dumps(args).encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -165,11 +136,8 @@ def main() -> int:
|
|||||||
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
|
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
|
||||||
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
|
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
|
||||||
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
|
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
|
||||||
ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.")
|
ap.add_argument("--size", default="1024x1024", help="Image size (e.g. 1024x1024, 1536x1024).")
|
||||||
ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.")
|
ap.add_argument("--quality", default="high", help="Image quality (varies by model).")
|
||||||
ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.")
|
|
||||||
ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.")
|
|
||||||
ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.")
|
|
||||||
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
|
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
@@ -178,46 +146,21 @@ def main() -> int:
|
|||||||
print("Missing OPENAI_API_KEY", file=sys.stderr)
|
print("Missing OPENAI_API_KEY", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
# Apply model-specific defaults if not specified
|
|
||||||
default_size, default_quality = get_model_defaults(args.model)
|
|
||||||
size = args.size or default_size
|
|
||||||
quality = args.quality or default_quality
|
|
||||||
|
|
||||||
count = args.count
|
|
||||||
if args.model == "dall-e-3" and count > 1:
|
|
||||||
print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr)
|
|
||||||
count = 1
|
|
||||||
|
|
||||||
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
|
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
prompts = [args.prompt] * count if args.prompt else pick_prompts(count)
|
prompts = [args.prompt] * args.count if args.prompt else pick_prompts(args.count)
|
||||||
|
|
||||||
# Determine file extension based on output format
|
|
||||||
if args.model.startswith("gpt-image") and args.output_format:
|
|
||||||
file_ext = args.output_format
|
|
||||||
else:
|
|
||||||
file_ext = "png"
|
|
||||||
|
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
for idx, prompt in enumerate(prompts, start=1):
|
for idx, prompt in enumerate(prompts, start=1):
|
||||||
print(f"[{idx}/{len(prompts)}] {prompt}")
|
print(f"[{idx}/{len(prompts)}] {prompt}")
|
||||||
res = request_images(
|
res = request_images(api_key, prompt, args.model, args.size, args.quality)
|
||||||
api_key,
|
|
||||||
prompt,
|
|
||||||
args.model,
|
|
||||||
size,
|
|
||||||
quality,
|
|
||||||
args.background,
|
|
||||||
args.output_format,
|
|
||||||
args.style,
|
|
||||||
)
|
|
||||||
b64 = res.get("data", [{}])[0].get("b64_json")
|
b64 = res.get("data", [{}])[0].get("b64_json")
|
||||||
if not b64:
|
if not b64:
|
||||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||||
image_bytes = base64.b64decode(b64)
|
png = base64.b64decode(b64)
|
||||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
filename = f"{idx:03d}-{slugify(prompt)[:40]}.png"
|
||||||
(out_dir / filename).write_bytes(image_bytes)
|
(out_dir / filename).write_bytes(png)
|
||||||
items.append({"prompt": prompt, "file": filename})
|
items.append({"prompt": prompt, "file": filename})
|
||||||
|
|
||||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ describe("session_status tool", () => {
|
|||||||
expect(details.ok).toBe(true);
|
expect(details.ok).toBe(true);
|
||||||
expect(details.statusText).toContain("Clawdbot");
|
expect(details.statusText).toContain("Clawdbot");
|
||||||
expect(details.statusText).toContain("🧠 Model:");
|
expect(details.statusText).toContain("🧠 Model:");
|
||||||
expect(details.statusText).not.toContain("OAuth/token status");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("errors for unknown session keys", async () => {
|
it("errors for unknown session keys", async () => {
|
||||||
|
|||||||
@@ -9,63 +9,26 @@ import type { EmbeddedContextFile } from "./types.js";
|
|||||||
|
|
||||||
type ContentBlockWithSignature = {
|
type ContentBlockWithSignature = {
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
thoughtSignature?: unknown;
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThoughtSignatureSanitizeOptions = {
|
|
||||||
allowBase64Only?: boolean;
|
|
||||||
includeCamelCase?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isBase64Signature(value: string): boolean {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
const compact = trimmed.replace(/\s+/g, "");
|
|
||||||
if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false;
|
|
||||||
const isUrl = compact.includes("-") || compact.includes("_");
|
|
||||||
try {
|
|
||||||
const buf = Buffer.from(compact, isUrl ? "base64url" : "base64");
|
|
||||||
if (buf.length === 0) return false;
|
|
||||||
const encoded = buf.toString(isUrl ? "base64url" : "base64");
|
|
||||||
const normalize = (input: string) => input.replace(/=+$/g, "");
|
|
||||||
return normalize(encoded) === normalize(compact);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips Claude-style thought_signature fields from content blocks.
|
* Strips Claude-style thought_signature fields from content blocks.
|
||||||
*
|
*
|
||||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
||||||
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
||||||
*/
|
*/
|
||||||
export function stripThoughtSignatures<T>(
|
export function stripThoughtSignatures<T>(content: T): T {
|
||||||
content: T,
|
|
||||||
options?: ThoughtSignatureSanitizeOptions,
|
|
||||||
): T {
|
|
||||||
if (!Array.isArray(content)) return content;
|
if (!Array.isArray(content)) return content;
|
||||||
const allowBase64Only = options?.allowBase64Only ?? false;
|
|
||||||
const includeCamelCase = options?.includeCamelCase ?? false;
|
|
||||||
const shouldStripSignature = (value: unknown): boolean => {
|
|
||||||
if (!allowBase64Only) {
|
|
||||||
return typeof value === "string" && value.startsWith("msg_");
|
|
||||||
}
|
|
||||||
return typeof value !== "string" || !isBase64Signature(value);
|
|
||||||
};
|
|
||||||
return content.map((block) => {
|
return content.map((block) => {
|
||||||
if (!block || typeof block !== "object") return block;
|
if (!block || typeof block !== "object") return block;
|
||||||
const rec = block as ContentBlockWithSignature;
|
const rec = block as ContentBlockWithSignature;
|
||||||
const stripSnake = shouldStripSignature(rec.thought_signature);
|
const signature = rec.thought_signature;
|
||||||
const stripCamel = includeCamelCase ? shouldStripSignature(rec.thoughtSignature) : false;
|
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
||||||
if (!stripSnake && !stripCamel) {
|
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
const next = { ...rec };
|
const { thought_signature: _signature, ...rest } = rec;
|
||||||
if (stripSnake) delete next.thought_signature;
|
return rest;
|
||||||
if (stripCamel) delete next.thoughtSignature;
|
|
||||||
return next;
|
|
||||||
}) as T;
|
}) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export { sanitizeGoogleTurnOrdering };
|
|||||||
type GeminiToolCallBlock = {
|
type GeminiToolCallBlock = {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
thoughtSignature?: unknown;
|
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
toolCallId?: unknown;
|
toolCallId?: unknown;
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
@@ -119,8 +118,7 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
|||||||
const blockRecord = block as GeminiToolCallBlock;
|
const blockRecord = block as GeminiToolCallBlock;
|
||||||
const type = blockRecord.type;
|
const type = blockRecord.type;
|
||||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
||||||
const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature;
|
const hasSignature = Boolean(blockRecord.thought_signature);
|
||||||
const hasSignature = Boolean(signature);
|
|
||||||
if (!hasSignature) {
|
if (!hasSignature) {
|
||||||
const id =
|
const id =
|
||||||
typeof blockRecord.id === "string"
|
typeof blockRecord.id === "string"
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
enforceToolCallLast?: boolean;
|
enforceToolCallLast?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
sanitizeThoughtSignatures?: {
|
|
||||||
allowBase64Only?: boolean;
|
|
||||||
includeCamelCase?: boolean;
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
// We sanitize historical session messages because Anthropic can reject a request
|
// We sanitize historical session messages because Anthropic can reject a request
|
||||||
@@ -86,7 +82,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const strippedContent = options?.preserveSignatures
|
const strippedContent = options?.preserveSignatures
|
||||||
? content // Keep signatures for Antigravity Claude
|
? content // Keep signatures for Antigravity Claude
|
||||||
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
: stripThoughtSignatures(content); // Strip for Gemini
|
||||||
|
|
||||||
const filteredContent = strippedContent.filter((block) => {
|
const filteredContent = strippedContent.filter((block) => {
|
||||||
if (!block || typeof block !== "object") return true;
|
if (!block || typeof block !== "object") return true;
|
||||||
|
|||||||
@@ -145,65 +145,6 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
expect(assistant.content?.[1]?.text).toBe("internal note");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
|
|
||||||
const sessionManager = SessionManager.inMemory();
|
|
||||||
const input = [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "hi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
|
||||||
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
|
||||||
{
|
|
||||||
type: "toolCall",
|
|
||||||
id: "call_1",
|
|
||||||
name: "read",
|
|
||||||
arguments: { path: "/tmp/foo" },
|
|
||||||
thoughtSignature: '{"id":1}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "toolCall",
|
|
||||||
id: "call_2",
|
|
||||||
name: "read",
|
|
||||||
arguments: { path: "/tmp/bar" },
|
|
||||||
thoughtSignature: "c2ln",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] satisfies AgentMessage[];
|
|
||||||
|
|
||||||
const out = await sanitizeSessionHistory({
|
|
||||||
messages: input,
|
|
||||||
modelApi: "openrouter",
|
|
||||||
provider: "openrouter",
|
|
||||||
modelId: "google/gemini-1.5-pro",
|
|
||||||
sessionManager,
|
|
||||||
sessionId: "session:openrouter-gemini",
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
|
||||||
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
|
|
||||||
};
|
|
||||||
expect(assistant.content).toEqual([
|
|
||||||
{ type: "text", text: "hello" },
|
|
||||||
{ type: "text", text: "ok" },
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: '[Tool Call: read (ID: call_1)]\nArguments: {\n "path": "/tmp/foo"\n}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "toolCall",
|
|
||||||
id: "call_2",
|
|
||||||
name: "read",
|
|
||||||
arguments: { path: "/tmp/bar" },
|
|
||||||
thoughtSignature: "c2ln",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
|
|||||||
-60
@@ -261,64 +261,4 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
||||||
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
it("returns role ordering error when session ends with a user turn", async () => {
|
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
|
||||||
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
|
||||||
sessionManager.appendMessage({
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "text", text: "seed user 1" }],
|
|
||||||
});
|
|
||||||
sessionManager.appendMessage({
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "seed assistant" }],
|
|
||||||
stopReason: "stop",
|
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
usage: {
|
|
||||||
input: 1,
|
|
||||||
output: 1,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 2,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
sessionManager.appendMessage({
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "text", text: "seed user 2" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
|
||||||
await ensureModels(cfg, agentDir);
|
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "hello",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
agentDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.meta.error?.kind).toBe("role_ordering");
|
|
||||||
expect(result.meta.error?.message).toMatch(/incorrect role information|roles must alternate/i);
|
|
||||||
expect(result.payloads?.[0]?.text).toContain("Message ordering conflict");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
||||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import * as helpers from "./pi-embedded-helpers.js";
|
|
||||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("./pi-embedded-helpers.js", async () => {
|
|
||||||
const actual = await vi.importActual("./pi-embedded-helpers.js");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
isGoogleModelApi: vi.fn(),
|
|
||||||
downgradeGeminiHistory: vi.fn(),
|
|
||||||
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// We don't mock session-transcript-repair.js as it is a pure function and complicates mocking.
|
|
||||||
// We rely on the real implementation which should pass through our simple messages.
|
|
||||||
|
|
||||||
describe("sanitizeSessionHistory", () => {
|
|
||||||
const mockSessionManager = {
|
|
||||||
getEntries: vi.fn().mockReturnValue([]),
|
|
||||||
appendCustomEntry: vi.fn(),
|
|
||||||
} as unknown as SessionManager;
|
|
||||||
|
|
||||||
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
|
||||||
// Default mock implementation
|
|
||||||
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
|
|
||||||
if (!msgs) return [];
|
|
||||||
return [...msgs, { role: "system", content: "downgraded" }];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should downgrade history for Google models if provider is not google-antigravity", async () => {
|
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
|
||||||
|
|
||||||
const result = await sanitizeSessionHistory({
|
|
||||||
messages: mockMessages,
|
|
||||||
modelApi: "google-gemini",
|
|
||||||
provider: "google-vertex",
|
|
||||||
sessionManager: mockSessionManager,
|
|
||||||
sessionId: "test-session",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
|
||||||
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
|
||||||
// Check if the result contains the downgraded message
|
|
||||||
expect(result).toContainEqual({ role: "system", content: "downgraded" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT downgrade history for google-antigravity provider", async () => {
|
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
|
||||||
|
|
||||||
const result = await sanitizeSessionHistory({
|
|
||||||
messages: mockMessages,
|
|
||||||
modelApi: "google-gemini",
|
|
||||||
provider: "google-antigravity",
|
|
||||||
sessionManager: mockSessionManager,
|
|
||||||
sessionId: "test-session",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
|
||||||
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
|
||||||
// Result should not contain the downgraded message
|
|
||||||
expect(result).not.toContainEqual({
|
|
||||||
role: "system",
|
|
||||||
content: "downgraded",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT downgrade history for non-Google models", async () => {
|
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
|
||||||
|
|
||||||
const _result = await sanitizeSessionHistory({
|
|
||||||
messages: mockMessages,
|
|
||||||
modelApi: "anthropic-messages",
|
|
||||||
provider: "anthropic",
|
|
||||||
sessionManager: mockSessionManager,
|
|
||||||
sessionId: "test-session",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
|
|
||||||
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should downgrade history if provider is undefined but model is Google", async () => {
|
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
|
||||||
|
|
||||||
const _result = await sanitizeSessionHistory({
|
|
||||||
messages: mockMessages,
|
|
||||||
modelApi: "google-gemini",
|
|
||||||
provider: undefined,
|
|
||||||
sessionManager: mockSessionManager,
|
|
||||||
sessionId: "test-session",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
|
||||||
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -308,7 +308,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
modelApi: model.api,
|
modelApi: model.api,
|
||||||
modelId,
|
modelId,
|
||||||
provider,
|
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,13 +82,12 @@ function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[]
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeToolsForGoogle<
|
export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TResult = unknown>(
|
||||||
TSchemaType extends TSchema = TSchema,
|
params: {
|
||||||
TResult = unknown,
|
tools: AgentTool<TSchemaType, TResult>[];
|
||||||
>(params: {
|
provider: string;
|
||||||
tools: AgentTool<TSchemaType, TResult>[];
|
},
|
||||||
provider: string;
|
): AgentTool<TSchemaType, TResult>[] {
|
||||||
}): AgentTool<TSchemaType, TResult>[] {
|
|
||||||
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
||||||
return params.tools;
|
return params.tools;
|
||||||
}
|
}
|
||||||
@@ -96,9 +95,7 @@ export function sanitizeToolsForGoogle<
|
|||||||
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
|
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
|
||||||
return {
|
return {
|
||||||
...tool,
|
...tool,
|
||||||
parameters: cleanToolSchemaForGemini(
|
parameters: cleanToolSchemaForGemini(tool.parameters as Record<string, unknown>) as TSchemaType,
|
||||||
tool.parameters as Record<string, unknown>,
|
|
||||||
) as TSchemaType,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,34 +185,22 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
modelApi?: string | null;
|
modelApi?: string | null;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
provider?: string;
|
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||||
const provider = (params.provider ?? "").toLowerCase();
|
|
||||||
const modelId = (params.modelId ?? "").toLowerCase();
|
|
||||||
const isOpenRouterGemini =
|
|
||||||
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
|
||||||
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
|
|
||||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
|
||||||
? { allowBase64Only: true, includeCamelCase: true }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||||
const isAntigravityProvider =
|
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
|
||||||
provider === "google-antigravity" || params.modelApi === "google-antigravity";
|
|
||||||
const shouldDowngradeThinking = isGeminiLike && !isAntigravityClaudeModel;
|
|
||||||
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||||
const downgradedThinking = shouldDowngradeThinking
|
const downgradedThinking = shouldDowngradeGemini
|
||||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||||
: repairedTools;
|
: repairedTools;
|
||||||
const shouldDowngradeHistory = shouldDowngradeThinking && !isAntigravityProvider;
|
const downgraded = shouldDowngradeGemini
|
||||||
const downgraded = shouldDowngradeHistory
|
|
||||||
? downgradeGeminiHistory(downgradedThinking)
|
? downgradeGeminiHistory(downgradedThinking)
|
||||||
: downgradedThinking;
|
: downgradedThinking;
|
||||||
|
|
||||||
|
|||||||
@@ -274,8 +274,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
provider,
|
provider,
|
||||||
model: model.id,
|
model: model.id,
|
||||||
},
|
},
|
||||||
systemPromptReport: attempt.systemPromptReport,
|
|
||||||
error: { kind: "role_ordering", message: errorText },
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,6 @@ export async function runEmbeddedAttempt(
|
|||||||
messages: activeSession.messages,
|
messages: activeSession.messages,
|
||||||
modelApi: params.model.api,
|
modelApi: params.model.api,
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
provider: params.provider,
|
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
|
|||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
systemPromptReport?: SessionSystemPromptReport;
|
systemPromptReport?: SessionSystemPromptReport;
|
||||||
error?: {
|
error?: {
|
||||||
kind: "context_overflow" | "compaction_failure" | "role_ordering";
|
kind: "context_overflow" | "compaction_failure";
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export type SubscribeEmbeddedPiSessionParams = {
|
|||||||
blockReplyChunking?: BlockReplyChunking;
|
blockReplyChunking?: BlockReplyChunking;
|
||||||
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||||
onAssistantMessageStart?: () => void | Promise<void>;
|
onAssistantMessageStart?: () => void | Promise<void>;
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
onAgentEvent?: (evt: {
|
||||||
|
stream: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => void | Promise<void>;
|
||||||
enforceFinalTag?: boolean;
|
enforceFinalTag?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ function buildSandboxBrowserResolvedConfig(params: {
|
|||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost,
|
cdpHost,
|
||||||
cdpIsLoopback: true,
|
cdpIsLoopback: true,
|
||||||
remoteCdpTimeoutMs: 1500,
|
|
||||||
remoteCdpHandshakeTimeoutMs: 3000,
|
|
||||||
color: DEFAULT_CLAWD_BROWSER_COLOR,
|
color: DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
executablePath: undefined,
|
executablePath: undefined,
|
||||||
headless: params.headless,
|
headless: params.headless,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||||
|
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
|
||||||
import {
|
import {
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
formatUsageWindowSummary,
|
formatUsageWindowSummary,
|
||||||
loadProviderUsageSummary,
|
loadProviderUsageSummary,
|
||||||
resolveUsageProviderId,
|
resolveUsageProviderId,
|
||||||
|
type UsageProviderId,
|
||||||
} from "../../infra/provider-usage.js";
|
} from "../../infra/provider-usage.js";
|
||||||
import {
|
import {
|
||||||
buildAgentMainSessionKey,
|
buildAgentMainSessionKey,
|
||||||
@@ -281,33 +283,48 @@ export function createSessionStatusTool(opts?: {
|
|||||||
defaultModel: DEFAULT_MODEL,
|
defaultModel: DEFAULT_MODEL,
|
||||||
});
|
});
|
||||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||||
let usageLine: string | undefined;
|
const authHealth = buildAuthHealthSummary({
|
||||||
if (usageProvider) {
|
store: authStore,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
const oauthProfiles = authHealth.profiles.filter(
|
||||||
|
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||||
|
);
|
||||||
|
|
||||||
|
const usageProviders = Array.from(
|
||||||
|
new Set(
|
||||||
|
oauthProfiles
|
||||||
|
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||||
|
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const usageByProvider = new Map<string, string>();
|
||||||
|
if (usageProviders.length > 0) {
|
||||||
try {
|
try {
|
||||||
const usageSummary = await loadProviderUsageSummary({
|
const usageSummary = await loadProviderUsageSummary({
|
||||||
timeoutMs: 3500,
|
timeoutMs: 3500,
|
||||||
providers: [usageProvider],
|
providers: usageProviders,
|
||||||
agentDir,
|
agentDir,
|
||||||
});
|
});
|
||||||
const snapshot = usageSummary.providers.find(
|
for (const snapshot of usageSummary.providers) {
|
||||||
(entry) => entry.provider === usageProvider,
|
|
||||||
);
|
|
||||||
if (snapshot) {
|
|
||||||
const formatted = formatUsageWindowSummary(snapshot, {
|
const formatted = formatUsageWindowSummary(snapshot, {
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
maxWindows: 2,
|
maxWindows: 2,
|
||||||
includeResets: true,
|
|
||||||
});
|
});
|
||||||
if (formatted && !formatted.startsWith("error:")) {
|
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
||||||
usageLine = `📊 Usage: ${formatted}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||||
|
const usageLine =
|
||||||
|
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
|
||||||
|
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const isGroup =
|
const isGroup =
|
||||||
resolved.entry.chatType === "group" ||
|
resolved.entry.chatType === "group" ||
|
||||||
resolved.entry.chatType === "room" ||
|
resolved.entry.chatType === "room" ||
|
||||||
@@ -353,13 +370,53 @@ export function createSessionStatusTool(opts?: {
|
|||||||
includeTranscriptUsage: false,
|
includeTranscriptUsage: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authStatusLines = (() => {
|
||||||
|
if (oauthProfiles.length === 0) return [];
|
||||||
|
const formatStatus = (status: string) => {
|
||||||
|
if (status === "ok") return "ok";
|
||||||
|
if (status === "static") return "static";
|
||||||
|
if (status === "expiring") return "expiring";
|
||||||
|
if (status === "missing") return "unknown";
|
||||||
|
return "expired";
|
||||||
|
};
|
||||||
|
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
||||||
|
for (const profile of oauthProfiles) {
|
||||||
|
const current = profilesByProvider.get(profile.provider);
|
||||||
|
if (current) current.push(profile);
|
||||||
|
else profilesByProvider.set(profile.provider, [profile]);
|
||||||
|
}
|
||||||
|
const lines: string[] = ["OAuth/token status"];
|
||||||
|
for (const [provider, profiles] of profilesByProvider) {
|
||||||
|
const usageKey = resolveUsageProviderId(provider);
|
||||||
|
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||||
|
const usageSuffix = usage ? ` — usage: ${usage}` : "";
|
||||||
|
lines.push(`- ${provider}${usageSuffix}`);
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const labelText = profile.label || profile.profileId;
|
||||||
|
const status = formatStatus(profile.status);
|
||||||
|
const expiry =
|
||||||
|
profile.status === "static"
|
||||||
|
? ""
|
||||||
|
: profile.expiresAt
|
||||||
|
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||||
|
: " expires unknown";
|
||||||
|
const source = profile.source !== "store" ? ` (${profile.source})` : "";
|
||||||
|
lines.push(` - ${labelText} ${status}${expiry}${source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const fullStatusText =
|
||||||
|
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: statusText }],
|
content: [{ type: "text", text: fullStatusText }],
|
||||||
details: {
|
details: {
|
||||||
ok: true,
|
ok: true,
|
||||||
sessionKey: resolved.key,
|
sessionKey: resolved.key,
|
||||||
changedModel,
|
changedModel,
|
||||||
statusText,
|
statusText: fullStatusText,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
shouldEmitToolResult: () => boolean;
|
shouldEmitToolResult: () => boolean;
|
||||||
pendingToolTasks: Set<Promise<void>>;
|
pendingToolTasks: Set<Promise<void>>;
|
||||||
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
|
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
|
||||||
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
|
|
||||||
isHeartbeat: boolean;
|
isHeartbeat: boolean;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
getActiveSessionEntry: () => SessionEntry | undefined;
|
getActiveSessionEntry: () => SessionEntry | undefined;
|
||||||
@@ -377,17 +376,6 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
didResetAfterCompactionFailure = true;
|
didResetAfterCompactionFailure = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (embeddedError?.kind === "role_ordering") {
|
|
||||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
|
|
||||||
if (didReset) {
|
|
||||||
return {
|
|
||||||
kind: "final",
|
|
||||||
payload: {
|
|
||||||
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -407,17 +395,6 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
didResetAfterCompactionFailure = true;
|
didResetAfterCompactionFailure = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isRoleOrderingError) {
|
|
||||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
|
|
||||||
if (didReset) {
|
|
||||||
return {
|
|
||||||
kind: "final",
|
|
||||||
payload: {
|
|
||||||
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-recover from Gemini session corruption by resetting the session
|
// Auto-recover from Gemini session corruption by resetting the session
|
||||||
if (
|
if (
|
||||||
|
|||||||
+2
-62
@@ -3,7 +3,6 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import * as sessions from "../../config/sessions.js";
|
|
||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import type { TemplateContext } from "../templating.js";
|
import type { TemplateContext } from "../templating.js";
|
||||||
import type { GetReplyOptions } from "../types.js";
|
import type { GetReplyOptions } from "../types.js";
|
||||||
@@ -128,14 +127,11 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
try {
|
try {
|
||||||
const sessionId = "session";
|
const sessionId = "session";
|
||||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
const sessionEntry = { sessionId, updatedAt: Date.now() };
|
||||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
|
||||||
const sessionStore = { main: sessionEntry };
|
const sessionStore = { main: sessionEntry };
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
|
||||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
|
||||||
|
|
||||||
runEmbeddedPiAgentMock
|
runEmbeddedPiAgentMock
|
||||||
.mockImplementationOnce(async () => {
|
.mockImplementationOnce(async () => {
|
||||||
@@ -179,14 +175,11 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
try {
|
try {
|
||||||
const sessionId = "session";
|
const sessionId = "session";
|
||||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
const sessionEntry = { sessionId, updatedAt: Date.now() };
|
||||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
|
||||||
const sessionStore = { main: sessionEntry };
|
const sessionStore = { main: sessionEntry };
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
|
||||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
|
||||||
|
|
||||||
runEmbeddedPiAgentMock
|
runEmbeddedPiAgentMock
|
||||||
.mockImplementationOnce(async () => ({
|
.mockImplementationOnce(async () => ({
|
||||||
@@ -219,59 +212,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
expect(payload).toMatchObject({ text: "ok" });
|
expect(payload).toMatchObject({ text: "ok" });
|
||||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||||
|
|
||||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
|
||||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
|
||||||
} finally {
|
|
||||||
if (prevStateDir) {
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
|
||||||
} else {
|
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("resets the session after role ordering payloads", async () => {
|
|
||||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-role-ordering-"));
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
|
||||||
try {
|
|
||||||
const sessionId = "session";
|
|
||||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
|
||||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
|
||||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
|
||||||
const sessionStore = { main: sessionEntry };
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
||||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
|
||||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
|
||||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
|
||||||
|
|
||||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
|
||||||
payloads: [{ text: "Message ordering conflict - please try again.", isError: true }],
|
|
||||||
meta: {
|
|
||||||
durationMs: 1,
|
|
||||||
error: {
|
|
||||||
kind: "role_ordering",
|
|
||||||
message: 'messages: roles must alternate between "user" and "assistant"',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { run } = createMinimalRun({
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "main",
|
|
||||||
storePath,
|
|
||||||
});
|
|
||||||
const res = await run();
|
|
||||||
|
|
||||||
const payload = Array.isArray(res) ? res[0] : res;
|
|
||||||
expect(payload).toMatchObject({
|
|
||||||
text: expect.stringContaining("Message ordering conflict"),
|
|
||||||
});
|
|
||||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
|
||||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
|
||||||
await expect(fs.access(transcriptPath)).rejects.toBeDefined();
|
|
||||||
|
|
||||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
|
||||||
import { setCliSessionId } from "../../agents/cli-session.js";
|
import { setCliSessionId } from "../../agents/cli-session.js";
|
||||||
import { lookupContextTokens } from "../../agents/context.js";
|
import { lookupContextTokens } from "../../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||||
@@ -9,7 +8,6 @@ import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
|
|||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveSessionFilePath,
|
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
@@ -211,23 +209,11 @@ export async function runReplyAgent(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let responseUsageLine: string | undefined;
|
let responseUsageLine: string | undefined;
|
||||||
type SessionResetOptions = {
|
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> => {
|
||||||
failureLabel: string;
|
|
||||||
buildLogMessage: (nextSessionId: string) => string;
|
|
||||||
cleanupTranscripts?: boolean;
|
|
||||||
};
|
|
||||||
const resetSession = async ({
|
|
||||||
failureLabel,
|
|
||||||
buildLogMessage,
|
|
||||||
cleanupTranscripts,
|
|
||||||
}: SessionResetOptions): Promise<boolean> => {
|
|
||||||
if (!sessionKey || !activeSessionStore || !storePath) return false;
|
if (!sessionKey || !activeSessionStore || !storePath) return false;
|
||||||
const prevEntry = activeSessionStore[sessionKey] ?? activeSessionEntry;
|
|
||||||
if (!prevEntry) return false;
|
|
||||||
const prevSessionId = cleanupTranscripts ? prevEntry.sessionId : undefined;
|
|
||||||
const nextSessionId = crypto.randomUUID();
|
const nextSessionId = crypto.randomUUID();
|
||||||
const nextEntry: SessionEntry = {
|
const nextEntry: SessionEntry = {
|
||||||
...prevEntry,
|
...(activeSessionStore[sessionKey] ?? activeSessionEntry),
|
||||||
sessionId: nextSessionId,
|
sessionId: nextSessionId,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
systemSent: false,
|
systemSent: false,
|
||||||
@@ -247,42 +233,18 @@ export async function runReplyAgent(params: {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
`Failed to persist session reset after ${failureLabel} (${sessionKey}): ${String(err)}`,
|
`Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
followupRun.run.sessionId = nextSessionId;
|
followupRun.run.sessionId = nextSessionId;
|
||||||
followupRun.run.sessionFile = nextSessionFile;
|
followupRun.run.sessionFile = nextSessionFile;
|
||||||
activeSessionEntry = nextEntry;
|
activeSessionEntry = nextEntry;
|
||||||
activeIsNewSession = true;
|
activeIsNewSession = true;
|
||||||
defaultRuntime.error(buildLogMessage(nextSessionId));
|
defaultRuntime.error(
|
||||||
if (cleanupTranscripts && prevSessionId) {
|
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
|
||||||
const transcriptCandidates = new Set<string>();
|
);
|
||||||
const resolved = resolveSessionFilePath(prevSessionId, prevEntry, { agentId });
|
|
||||||
if (resolved) transcriptCandidates.add(resolved);
|
|
||||||
transcriptCandidates.add(resolveSessionTranscriptPath(prevSessionId, agentId));
|
|
||||||
for (const candidate of transcriptCandidates) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(candidate);
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> =>
|
|
||||||
resetSession({
|
|
||||||
failureLabel: "compaction failure",
|
|
||||||
buildLogMessage: (nextSessionId) =>
|
|
||||||
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
|
|
||||||
});
|
|
||||||
const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise<boolean> =>
|
|
||||||
resetSession({
|
|
||||||
failureLabel: "role ordering conflict",
|
|
||||||
buildLogMessage: (nextSessionId) =>
|
|
||||||
`Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
|
|
||||||
cleanupTranscripts: true,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const runOutcome = await runAgentTurnWithFallback({
|
const runOutcome = await runAgentTurnWithFallback({
|
||||||
commandBody,
|
commandBody,
|
||||||
@@ -298,7 +260,6 @@ export async function runReplyAgent(params: {
|
|||||||
shouldEmitToolResult,
|
shouldEmitToolResult,
|
||||||
pendingToolTasks,
|
pendingToolTasks,
|
||||||
resetSessionAfterCompactionFailure,
|
resetSessionAfterCompactionFailure,
|
||||||
resetSessionAfterRoleOrderingConflict,
|
|
||||||
isHeartbeat,
|
isHeartbeat,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
getActiveSessionEntry: () => activeSessionEntry,
|
getActiveSessionEntry: () => activeSessionEntry,
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ export async function buildStatusReply(params: {
|
|||||||
const formatted = formatUsageWindowSummary(snapshot, {
|
const formatted = formatUsageWindowSummary(snapshot, {
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
maxWindows: 2,
|
maxWindows: 2,
|
||||||
includeResets: true,
|
|
||||||
});
|
});
|
||||||
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
|
|
||||||
|
|
||||||
describe("cdp.helpers", () => {
|
|
||||||
it("preserves query params when appending CDP paths", () => {
|
|
||||||
const url = appendCdpPath("https://example.com?token=abc", "/json/version");
|
|
||||||
expect(url).toBe("https://example.com/json/version?token=abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends paths under a base prefix", () => {
|
|
||||||
const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list");
|
|
||||||
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds basic auth headers when credentials are present", () => {
|
|
||||||
const headers = getHeadersWithAuth("https://user:pass@example.com");
|
|
||||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps preexisting authorization headers", () => {
|
|
||||||
const headers = getHeadersWithAuth("https://user:pass@example.com", {
|
|
||||||
Authorization: "Bearer token",
|
|
||||||
});
|
|
||||||
expect(headers.Authorization).toBe("Bearer token");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -28,29 +28,6 @@ export function isLoopbackHost(host: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
|
|
||||||
if (hasAuthHeader) return headers;
|
|
||||||
if (parsed.username || parsed.password) {
|
|
||||||
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
|
||||||
return { ...headers, Authorization: `Basic ${auth}` };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendCdpPath(cdpUrl: string, path: string): string {
|
|
||||||
const url = new URL(cdpUrl);
|
|
||||||
const basePath = url.pathname.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
url.pathname = `${basePath}${suffix}`;
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCdpSender(ws: WebSocket) {
|
function createCdpSender(ws: WebSocket) {
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const pending = new Map<number, Pending>();
|
const pending = new Map<number, Pending>();
|
||||||
@@ -98,12 +75,11 @@ function createCdpSender(ws: WebSocket) {
|
|||||||
return { send, closeWithError };
|
return { send, closeWithError };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit): Promise<T> {
|
export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
const res = await fetch(url, { signal: ctrl.signal });
|
||||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -111,28 +87,11 @@ export async function fetchJson<T>(url: string, timeoutMs = 1500, init?: Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise<void> {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
|
||||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withCdpSocket<T>(
|
export async function withCdpSocket<T>(
|
||||||
wsUrl: string,
|
wsUrl: string,
|
||||||
fn: (send: CdpSendFn) => Promise<T>,
|
fn: (send: CdpSendFn) => Promise<T>,
|
||||||
opts?: { headers?: Record<string, string> },
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
|
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
|
||||||
const ws = new WebSocket(wsUrl, {
|
|
||||||
handshakeTimeout: 5000,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
});
|
|
||||||
const { send, closeWithError } = createCdpSender(ws);
|
const { send, closeWithError } = createCdpSender(ws);
|
||||||
|
|
||||||
const openPromise = new Promise<void>((resolve, reject) => {
|
const openPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@@ -165,20 +165,4 @@ describe("cdp", () => {
|
|||||||
);
|
);
|
||||||
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
|
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates auth and query params onto normalized websocket URLs", () => {
|
|
||||||
const normalized = normalizeCdpWsUrl(
|
|
||||||
"ws://127.0.0.1:9222/devtools/browser/ABC",
|
|
||||||
"https://user:pass@example.com?token=abc",
|
|
||||||
);
|
|
||||||
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upgrades ws to wss when CDP uses https", () => {
|
|
||||||
const normalized = normalizeCdpWsUrl(
|
|
||||||
"ws://production-sfo.browserless.io",
|
|
||||||
"https://production-sfo.browserless.io?token=abc",
|
|
||||||
);
|
|
||||||
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+3
-17
@@ -1,6 +1,4 @@
|
|||||||
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
import { fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
||||||
|
|
||||||
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
|
|
||||||
|
|
||||||
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||||
const ws = new URL(wsUrl);
|
const ws = new URL(wsUrl);
|
||||||
@@ -11,16 +9,6 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
|||||||
if (cdpPort) ws.port = cdpPort;
|
if (cdpPort) ws.port = cdpPort;
|
||||||
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
|
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
|
||||||
}
|
}
|
||||||
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
|
|
||||||
ws.protocol = "wss:";
|
|
||||||
}
|
|
||||||
if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
|
|
||||||
ws.username = cdp.username;
|
|
||||||
ws.password = cdp.password;
|
|
||||||
}
|
|
||||||
for (const [key, value] of cdp.searchParams.entries()) {
|
|
||||||
if (!ws.searchParams.has(key)) ws.searchParams.append(key, value);
|
|
||||||
}
|
|
||||||
return ws.toString();
|
return ws.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +68,8 @@ export async function createTargetViaCdp(opts: {
|
|||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
url: string;
|
url: string;
|
||||||
}): Promise<{ targetId: string }> {
|
}): Promise<{ targetId: string }> {
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
const base = opts.cdpUrl.replace(/\/$/, "");
|
||||||
appendCdpPath(opts.cdpUrl, "/json/version"),
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`${base}/json/version`, 1500);
|
||||||
1500,
|
|
||||||
);
|
|
||||||
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||||
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
||||||
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ describe("browser default executable detection", () => {
|
|||||||
it("prefers default Chromium browser on macOS", async () => {
|
it("prefers default Chromium browser on macOS", async () => {
|
||||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||||
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
|
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
|
||||||
return JSON.stringify([
|
return "com.google.Chrome";
|
||||||
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.google.Chrome" },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
|
if (cmd === "/usr/bin/osascript" && argsStr.includes("POSIX path")) {
|
||||||
return "/Applications/Google Chrome.app";
|
return "/Applications/Google Chrome.app";
|
||||||
}
|
}
|
||||||
if (cmd === "/usr/bin/defaults") {
|
if (cmd === "/usr/bin/defaults") {
|
||||||
@@ -37,11 +35,9 @@ describe("browser default executable detection", () => {
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||||
const value = String(p);
|
String(p).includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||||
if (value.includes("com.apple.launchservices.secure.plist")) return true;
|
);
|
||||||
return value.includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
|
|
||||||
});
|
|
||||||
|
|
||||||
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
||||||
const exe = resolveBrowserExecutableForPlatform(
|
const exe = resolveBrowserExecutableForPlatform(
|
||||||
@@ -56,18 +52,14 @@ describe("browser default executable detection", () => {
|
|||||||
it("falls back when default browser is non-Chromium on macOS", async () => {
|
it("falls back when default browser is non-Chromium on macOS", async () => {
|
||||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||||
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
|
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
|
||||||
return JSON.stringify([
|
return "com.apple.Safari";
|
||||||
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.apple.Safari" },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||||
const value = String(p);
|
String(p).includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||||
if (value.includes("com.apple.launchservices.secure.plist")) return true;
|
);
|
||||||
return value.includes("Google Chrome.app/Contents/MacOS/Google Chrome");
|
|
||||||
});
|
|
||||||
|
|
||||||
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
||||||
const exe = resolveBrowserExecutableForPlatform(
|
const exe = resolveBrowserExecutableForPlatform(
|
||||||
|
|||||||
@@ -95,17 +95,12 @@ function exists(filePath: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function execText(
|
function execText(command: string, args: string[], timeoutMs = 1200): string | null {
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
timeoutMs = 1200,
|
|
||||||
maxBuffer = 1024 * 1024,
|
|
||||||
): string | null {
|
|
||||||
try {
|
try {
|
||||||
const output = execFileSync(command, args, {
|
const output = execFileSync(command, args, {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
maxBuffer,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
return String(output ?? "").trim() || null;
|
return String(output ?? "").trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -119,12 +114,7 @@ function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"]
|
|||||||
if (id.includes("edge")) return "edge";
|
if (id.includes("edge")) return "edge";
|
||||||
if (id.includes("chromium")) return "chromium";
|
if (id.includes("chromium")) return "chromium";
|
||||||
if (id.includes("canary")) return "canary";
|
if (id.includes("canary")) return "canary";
|
||||||
if (
|
if (id.includes("opera") || id.includes("vivaldi") || id.includes("yandex") || id.includes("thebrowser")) {
|
||||||
id.includes("opera") ||
|
|
||||||
id.includes("vivaldi") ||
|
|
||||||
id.includes("yandex") ||
|
|
||||||
id.includes("thebrowser")
|
|
||||||
) {
|
|
||||||
return "chromium";
|
return "chromium";
|
||||||
}
|
}
|
||||||
return "chrome";
|
return "chrome";
|
||||||
@@ -136,12 +126,13 @@ function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
|
|||||||
if (lower.includes("edge") || lower.includes("msedge")) return "edge";
|
if (lower.includes("edge") || lower.includes("msedge")) return "edge";
|
||||||
if (lower.includes("chromium")) return "chromium";
|
if (lower.includes("chromium")) return "chromium";
|
||||||
if (lower.includes("canary") || lower.includes("sxs")) return "canary";
|
if (lower.includes("canary") || lower.includes("sxs")) return "canary";
|
||||||
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex"))
|
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) return "chromium";
|
||||||
return "chromium";
|
|
||||||
return "chrome";
|
return "chrome";
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null {
|
function detectDefaultChromiumExecutable(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
): BrowserExecutable | null {
|
||||||
if (platform === "darwin") return detectDefaultChromiumExecutableMac();
|
if (platform === "darwin") return detectDefaultChromiumExecutableMac();
|
||||||
if (platform === "linux") return detectDefaultChromiumExecutableLinux();
|
if (platform === "linux") return detectDefaultChromiumExecutableLinux();
|
||||||
if (platform === "win32") return detectDefaultChromiumExecutableWindows();
|
if (platform === "win32") return detectDefaultChromiumExecutableWindows();
|
||||||
@@ -149,12 +140,14 @@ function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExec
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
|
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
|
||||||
const bundleId = detectDefaultBrowserBundleIdMac();
|
const bundleId = execText("/usr/bin/osascript", [
|
||||||
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) return null;
|
"-e",
|
||||||
|
'id of application (path to default application for URL "http://example.com")',
|
||||||
|
]);
|
||||||
|
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId.trim())) return null;
|
||||||
const appPathRaw = execText("/usr/bin/osascript", [
|
const appPathRaw = execText("/usr/bin/osascript", [
|
||||||
"-e",
|
"-e",
|
||||||
`POSIX path of (path to application id "${bundleId}")`,
|
'POSIX path of (path to default application for URL "http://example.com")',
|
||||||
]);
|
]);
|
||||||
if (!appPathRaw) return null;
|
if (!appPathRaw) return null;
|
||||||
const appPath = appPathRaw.trim().replace(/\/$/, "");
|
const appPath = appPathRaw.trim().replace(/\/$/, "");
|
||||||
@@ -169,45 +162,6 @@ function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
|
|||||||
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
|
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectDefaultBrowserBundleIdMac(): string | null {
|
|
||||||
const plistPath = path.join(
|
|
||||||
os.homedir(),
|
|
||||||
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
|
|
||||||
);
|
|
||||||
if (!exists(plistPath)) return null;
|
|
||||||
const handlersRaw = execText(
|
|
||||||
"/usr/bin/plutil",
|
|
||||||
["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath],
|
|
||||||
2000,
|
|
||||||
5 * 1024 * 1024,
|
|
||||||
);
|
|
||||||
if (!handlersRaw) return null;
|
|
||||||
let handlers: unknown;
|
|
||||||
try {
|
|
||||||
handlers = JSON.parse(handlersRaw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(handlers)) return null;
|
|
||||||
|
|
||||||
const resolveScheme = (scheme: string) => {
|
|
||||||
let candidate: string | null = null;
|
|
||||||
for (const entry of handlers) {
|
|
||||||
if (!entry || typeof entry !== "object") continue;
|
|
||||||
const record = entry as Record<string, unknown>;
|
|
||||||
if (record.LSHandlerURLScheme !== scheme) continue;
|
|
||||||
const role =
|
|
||||||
(typeof record.LSHandlerRoleAll === "string" && record.LSHandlerRoleAll) ||
|
|
||||||
(typeof record.LSHandlerRoleViewer === "string" && record.LSHandlerRoleViewer) ||
|
|
||||||
null;
|
|
||||||
if (role) candidate = role;
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
};
|
|
||||||
|
|
||||||
return resolveScheme("http") ?? resolveScheme("https");
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
||||||
const desktopId =
|
const desktopId =
|
||||||
execText("xdg-settings", ["get", "default-web-browser"]) ||
|
execText("xdg-settings", ["get", "default-web-browser"]) ||
|
||||||
@@ -231,7 +185,8 @@ function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
|||||||
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
|
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
|
||||||
const progId = readWindowsProgId();
|
const progId = readWindowsProgId();
|
||||||
const command =
|
const command =
|
||||||
(progId ? readWindowsCommandForProgId(progId) : null) || readWindowsCommandForProgId("http");
|
(progId ? readWindowsCommandForProgId(progId) : null) ||
|
||||||
|
readWindowsCommandForProgId("http");
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
const expanded = expandWindowsEnvVars(command);
|
const expanded = expandWindowsEnvVars(command);
|
||||||
const exePath = extractWindowsExecutablePath(expanded);
|
const exePath = extractWindowsExecutablePath(expanded);
|
||||||
@@ -288,7 +243,7 @@ function splitExecLine(line: string): string[] {
|
|||||||
let quoteChar = "";
|
let quoteChar = "";
|
||||||
for (let i = 0; i < line.length; i += 1) {
|
for (let i = 0; i < line.length; i += 1) {
|
||||||
const ch = line[i];
|
const ch = line[i];
|
||||||
if ((ch === '"' || ch === "'") && (!inQuotes || ch === quoteChar)) {
|
if ((ch === "\"" || ch === "'") && (!inQuotes || ch === quoteChar)) {
|
||||||
if (inQuotes) {
|
if (inQuotes) {
|
||||||
inQuotes = false;
|
inQuotes = false;
|
||||||
quoteChar = "";
|
quoteChar = "";
|
||||||
@@ -345,7 +300,7 @@ function readWindowsCommandForProgId(progId: string): string | null {
|
|||||||
function expandWindowsEnvVars(value: string): string {
|
function expandWindowsEnvVars(value: string): string {
|
||||||
return value.replace(/%([^%]+)%/g, (_match, name) => {
|
return value.replace(/%([^%]+)%/g, (_match, name) => {
|
||||||
const key = String(name ?? "").trim();
|
const key = String(name ?? "").trim();
|
||||||
return key ? (process.env[key] ?? `%${key}%`) : _match;
|
return key ? process.env[key] ?? `%${key}%` : _match;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -7,8 +7,7 @@ import WebSocket from "ws";
|
|||||||
import { ensurePortAvailable } from "../infra/ports.js";
|
import { ensurePortAvailable } from "../infra/ports.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { CONFIG_DIR } from "../utils.js";
|
import { CONFIG_DIR } from "../utils.js";
|
||||||
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import { appendCdpPath } from "./cdp.helpers.js";
|
|
||||||
import {
|
import {
|
||||||
type BrowserExecutable,
|
type BrowserExecutable,
|
||||||
resolveBrowserExecutableForPlatform,
|
resolveBrowserExecutableForPlatform,
|
||||||
@@ -72,10 +71,9 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
const base = cdpUrl.replace(/\/$/, "");
|
||||||
const res = await fetch(versionUrl, {
|
const res = await fetch(`${base}/json/version`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
headers: getHeadersWithAuth(versionUrl),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = (await res.json()) as ChromeVersion;
|
const data = (await res.json()) as ChromeVersion;
|
||||||
@@ -100,11 +98,7 @@ export async function getChromeWebSocketUrl(
|
|||||||
|
|
||||||
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
|
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
|
||||||
return await new Promise<boolean>((resolve) => {
|
return await new Promise<boolean>((resolve) => {
|
||||||
const headers = getHeadersWithAuth(wsUrl);
|
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
|
||||||
const ws = new WebSocket(wsUrl, {
|
|
||||||
handshakeTimeout: timeoutMs,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
});
|
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ describe("browser config", () => {
|
|||||||
expect(clawd?.driver).toBe("clawd");
|
expect(clawd?.driver).toBe("clawd");
|
||||||
expect(clawd?.cdpPort).toBe(18800);
|
expect(clawd?.cdpPort).toBe(18800);
|
||||||
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:18800");
|
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:18800");
|
||||||
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
|
|
||||||
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => {
|
it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => {
|
||||||
@@ -54,16 +52,6 @@ describe("browser config", () => {
|
|||||||
expect(resolved.color).toBe("#FF4500");
|
expect(resolved.color).toBe("#FF4500");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports custom remote CDP timeouts", () => {
|
|
||||||
const resolved = resolveBrowserConfig({
|
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
|
||||||
remoteCdpTimeoutMs: 2200,
|
|
||||||
remoteCdpHandshakeTimeoutMs: 5000,
|
|
||||||
});
|
|
||||||
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
|
|
||||||
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to default color for invalid hex", () => {
|
it("falls back to default color for invalid hex", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
controlUrl: "http://localhost:18791",
|
controlUrl: "http://localhost:18791",
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export type ResolvedBrowserConfig = {
|
|||||||
cdpProtocol: "http" | "https";
|
cdpProtocol: "http" | "https";
|
||||||
cdpHost: string;
|
cdpHost: string;
|
||||||
cdpIsLoopback: boolean;
|
cdpIsLoopback: boolean;
|
||||||
remoteCdpTimeoutMs: number;
|
|
||||||
remoteCdpHandshakeTimeoutMs: number;
|
|
||||||
color: string;
|
color: string;
|
||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
@@ -63,11 +61,6 @@ function normalizeHexColor(raw: string | undefined) {
|
|||||||
return normalized.toUpperCase();
|
return normalized.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTimeoutMs(raw: number | undefined, fallback: number) {
|
|
||||||
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
|
|
||||||
return value < 0 ? fallback : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseHttpUrl(raw: string, label: string) {
|
export function parseHttpUrl(raw: string, label: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
const parsed = new URL(trimmed);
|
const parsed = new URL(trimmed);
|
||||||
@@ -156,11 +149,6 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
|||||||
);
|
);
|
||||||
const controlPort = controlInfo.port;
|
const controlPort = controlInfo.port;
|
||||||
const defaultColor = normalizeHexColor(cfg?.color);
|
const defaultColor = normalizeHexColor(cfg?.color);
|
||||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
|
||||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
|
||||||
cfg?.remoteCdpHandshakeTimeoutMs,
|
|
||||||
Math.max(2000, remoteCdpTimeoutMs * 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||||
|
|
||||||
@@ -218,8 +206,6 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
|||||||
cdpProtocol,
|
cdpProtocol,
|
||||||
cdpHost: cdpInfo.parsed.hostname,
|
cdpHost: cdpInfo.parsed.hostname,
|
||||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||||
remoteCdpTimeoutMs,
|
|
||||||
remoteCdpHandshakeTimeoutMs,
|
|
||||||
color: defaultColor,
|
color: defaultColor,
|
||||||
executablePath,
|
executablePath,
|
||||||
headless,
|
headless,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
} from "playwright-core";
|
} from "playwright-core";
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { getHeadersWithAuth } from "./cdp.helpers.js";
|
|
||||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||||
|
|
||||||
export type BrowserConsoleMessage = {
|
export type BrowserConsoleMessage = {
|
||||||
@@ -269,8 +268,7 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
|
|||||||
const timeout = 5000 + attempt * 2000;
|
const timeout = 5000 + attempt * 2000;
|
||||||
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
|
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
|
||||||
const endpoint = wsUrl ?? normalized;
|
const endpoint = wsUrl ?? normalized;
|
||||||
const headers = getHeadersWithAuth(endpoint);
|
const browser = await chromium.connectOverCDP(endpoint, { timeout });
|
||||||
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
|
|
||||||
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
|
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
|
||||||
cached = connected;
|
cached = connected;
|
||||||
observeBrowser(browser);
|
observeBrowser(browser);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import {
|
import {
|
||||||
isChromeCdpReady,
|
isChromeCdpReady,
|
||||||
isChromeReachable,
|
isChromeReachable,
|
||||||
@@ -50,8 +50,7 @@ async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit):
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -63,8 +62,7 @@ async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promi
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
@@ -108,7 +106,7 @@ function createProfileContext(
|
|||||||
webSocketDebuggerUrl?: string;
|
webSocketDebuggerUrl?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
}>
|
}>
|
||||||
>(appendCdpPath(profile.cdpUrl, "/json/list"));
|
>(`${profile.cdpUrl.replace(/\/$/, "")}/json/list`);
|
||||||
return raw
|
return raw
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
targetId: t.id ?? "",
|
targetId: t.id ?? "",
|
||||||
@@ -148,13 +146,8 @@ function createProfileContext(
|
|||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new"));
|
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||||
const endpoint = endpointUrl.search
|
const endpoint = `${base}/json/new?${encoded}`;
|
||||||
? (() => {
|
|
||||||
endpointUrl.searchParams.set("url", url);
|
|
||||||
return endpointUrl.toString();
|
|
||||||
})()
|
|
||||||
: `${endpointUrl.toString()}?${encoded}`;
|
|
||||||
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
|
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
}).catch(async (err) => {
|
}).catch(async (err) => {
|
||||||
@@ -171,41 +164,18 @@ function createProfileContext(
|
|||||||
targetId: created.id,
|
targetId: created.id,
|
||||||
title: created.title ?? "",
|
title: created.title ?? "",
|
||||||
url: created.url ?? url,
|
url: created.url ?? url,
|
||||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
|
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, base),
|
||||||
type: created.type,
|
type: created.type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRemoteHttpTimeout = (timeoutMs: number | undefined) => {
|
const isReachable = async (timeoutMs = 300) => {
|
||||||
if (profile.cdpIsLoopback) return timeoutMs ?? 300;
|
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
||||||
const resolved = state().resolved;
|
return await isChromeCdpReady(profile.cdpUrl, timeoutMs, wsTimeout);
|
||||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
|
||||||
return Math.max(Math.floor(timeoutMs), resolved.remoteCdpTimeoutMs);
|
|
||||||
}
|
|
||||||
return resolved.remoteCdpTimeoutMs;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRemoteWsTimeout = (timeoutMs: number | undefined) => {
|
const isHttpReachable = async (timeoutMs = 300) => {
|
||||||
if (profile.cdpIsLoopback) {
|
return await isChromeReachable(profile.cdpUrl, timeoutMs);
|
||||||
const base = timeoutMs ?? 300;
|
|
||||||
return Math.max(200, Math.min(2000, base * 2));
|
|
||||||
}
|
|
||||||
const resolved = state().resolved;
|
|
||||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
|
||||||
return Math.max(Math.floor(timeoutMs) * 2, resolved.remoteCdpHandshakeTimeoutMs);
|
|
||||||
}
|
|
||||||
return resolved.remoteCdpHandshakeTimeoutMs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReachable = async (timeoutMs?: number) => {
|
|
||||||
const httpTimeout = resolveRemoteHttpTimeout(timeoutMs);
|
|
||||||
const wsTimeout = resolveRemoteWsTimeout(timeoutMs);
|
|
||||||
return await isChromeCdpReady(profile.cdpUrl, httpTimeout, wsTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isHttpReachable = async (timeoutMs?: number) => {
|
|
||||||
const httpTimeout = resolveRemoteHttpTimeout(timeoutMs);
|
|
||||||
return await isChromeReachable(profile.cdpUrl, httpTimeout);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
||||||
@@ -357,6 +327,7 @@ function createProfileContext(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const focusTab = async (targetId: string): Promise<void> => {
|
const focusTab = async (targetId: string): Promise<void> => {
|
||||||
|
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||||
const tabs = await listTabs();
|
const tabs = await listTabs();
|
||||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
@@ -365,12 +336,13 @@ function createProfileContext(
|
|||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new Error("tab not found");
|
||||||
}
|
}
|
||||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`));
|
await fetchOk(`${base}/json/activate/${resolved.targetId}`);
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
profileState.lastTargetId = resolved.targetId;
|
profileState.lastTargetId = resolved.targetId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeTab = async (targetId: string): Promise<void> => {
|
const closeTab = async (targetId: string): Promise<void> => {
|
||||||
|
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||||
const tabs = await listTabs();
|
const tabs = await listTabs();
|
||||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
@@ -379,7 +351,7 @@ function createProfileContext(
|
|||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new Error("tab not found");
|
||||||
}
|
}
|
||||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`));
|
await fetchOk(`${base}/json/close/${resolved.targetId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ vi.mock("./cdp.js", () => ({
|
|||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
getHeadersWithAuth: vi.fn(() => ({})),
|
|
||||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
|
||||||
const base = cdpUrl.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
return `${base}${suffix}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./pw-ai.js", () => pwMocks);
|
vi.mock("./pw-ai.js", () => pwMocks);
|
||||||
|
|||||||
@@ -129,12 +129,6 @@ vi.mock("./cdp.js", () => ({
|
|||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
getHeadersWithAuth: vi.fn(() => ({})),
|
|
||||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
|
||||||
const base = cdpUrl.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
return `${base}${suffix}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./pw-ai.js", () => pwMocks);
|
vi.mock("./pw-ai.js", () => pwMocks);
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ vi.mock("./cdp.js", () => ({
|
|||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
getHeadersWithAuth: vi.fn(() => ({})),
|
|
||||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
|
||||||
const base = cdpUrl.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
return `${base}${suffix}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./pw-ai.js", () => pwMocks);
|
vi.mock("./pw-ai.js", () => pwMocks);
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ vi.mock("./cdp.js", () => ({
|
|||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
getHeadersWithAuth: vi.fn(() => ({})),
|
|
||||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
|
||||||
const base = cdpUrl.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
return `${base}${suffix}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./pw-ai.js", () => pwMocks);
|
vi.mock("./pw-ai.js", () => pwMocks);
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ vi.mock("./cdp.js", () => ({
|
|||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
getHeadersWithAuth: vi.fn(() => ({})),
|
|
||||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
|
||||||
const base = cdpUrl.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
return `${base}${suffix}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./pw-ai.js", () => pwMocks);
|
vi.mock("./pw-ai.js", () => pwMocks);
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ vi.mock("./cdp.js", () => ({
|
|||||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||||
snapshotAria: cdpMocks.snapshotAria,
|
snapshotAria: cdpMocks.snapshotAria,
|
||||||
getHeadersWithAuth: vi.fn(() => ({})),
|
|
||||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
|
||||||
const base = cdpUrl.replace(/\/$/, "");
|
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
||||||
return `${base}${suffix}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./pw-ai.js", () => pwMocks);
|
vi.mock("./pw-ai.js", () => pwMocks);
|
||||||
|
|||||||
+15246
-17439
File diff suppressed because it is too large.
Load diff
@@ -238,11 +238,7 @@ export function registerConfigCli(program: Command) {
|
|||||||
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
|
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean") {
|
||||||
typeof res.value === "string" ||
|
|
||||||
typeof res.value === "number" ||
|
|
||||||
typeof res.value === "boolean"
|
|
||||||
) {
|
|
||||||
defaultRuntime.log(String(res.value));
|
defaultRuntime.log(String(res.value));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,11 +225,7 @@ describe("daemon-cli coverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||||
const parsed = JSON.parse(jsonLine ?? "{}") as {
|
const parsed = JSON.parse(jsonLine ?? "{}") as { ok?: boolean; action?: string; result?: string };
|
||||||
ok?: boolean;
|
|
||||||
action?: string;
|
|
||||||
result?: string;
|
|
||||||
};
|
|
||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
expect(parsed.action).toBe("install");
|
expect(parsed.action).toBe("install");
|
||||||
expect(parsed.result).toBe("installed");
|
expect(parsed.result).toBe("installed");
|
||||||
|
|||||||
@@ -53,13 +53,6 @@ export function registerGatewayCli(program: Command) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
|
|
||||||
addGatewayRunCommand(
|
|
||||||
program
|
|
||||||
.command("gateway-daemon", { hidden: true })
|
|
||||||
.description("Run the WebSocket Gateway as a long-lived daemon"),
|
|
||||||
);
|
|
||||||
|
|
||||||
gatewayCallOpts(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("call")
|
.command("call")
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js";
|
import {
|
||||||
|
listPairingChannels,
|
||||||
|
notifyPairingApproved,
|
||||||
|
} from "../channels/plugins/pairing.js";
|
||||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
||||||
|
|||||||
@@ -378,7 +378,9 @@ export function registerPluginsCli(program: Command) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (record.source !== "npm") {
|
if (record.source !== "npm") {
|
||||||
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`));
|
defaultRuntime.log(
|
||||||
|
chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!record.spec) {
|
if (!record.spec) {
|
||||||
@@ -410,7 +412,9 @@ export function registerPluginsCli(program: Command) {
|
|||||||
if (currentVersion && probe.version && currentVersion === probe.version) {
|
if (currentVersion && probe.version && currentVersion === probe.version) {
|
||||||
defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`);
|
defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`);
|
||||||
} else {
|
} else {
|
||||||
defaultRuntime.log(`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`);
|
defaultRuntime.log(
|
||||||
|
`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const resolveGatewayPort = vi.fn(() => 18789);
|
|||||||
const discoverGatewayBeacons = vi.fn(async () => []);
|
const discoverGatewayBeacons = vi.fn(async () => []);
|
||||||
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
||||||
const sshStop = vi.fn(async () => {});
|
const sshStop = vi.fn(async () => {});
|
||||||
const resolveSshConfig = vi.fn(async () => null);
|
|
||||||
const startSshPortForward = vi.fn(async () => ({
|
const startSshPortForward = vi.fn(async () => ({
|
||||||
parsedTarget: { user: "me", host: "studio", port: 22 },
|
parsedTarget: { user: "me", host: "studio", port: 22 },
|
||||||
localPort: 18789,
|
localPort: 18789,
|
||||||
@@ -93,16 +92,8 @@ vi.mock("../infra/tailnet.js", () => ({
|
|||||||
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
|
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../infra/ssh-tunnel.js", async (importOriginal) => {
|
vi.mock("../infra/ssh-tunnel.js", () => ({
|
||||||
const actual = await importOriginal<typeof import("../infra/ssh-tunnel.js")>();
|
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../infra/ssh-config.js", () => ({
|
|
||||||
resolveSshConfig: (opts: unknown) => resolveSshConfig(opts),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../gateway/probe.js", () => ({
|
vi.mock("../gateway/probe.js", () => ({
|
||||||
@@ -188,122 +179,4 @@ describe("gateway-status command", () => {
|
|||||||
const targets = parsed.targets as Array<Record<string, unknown>>;
|
const targets = parsed.targets as Array<Record<string, unknown>>;
|
||||||
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
|
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("infers SSH target from gateway.remote.url and ssh config", async () => {
|
|
||||||
const runtimeLogs: string[] = [];
|
|
||||||
const runtime = {
|
|
||||||
log: (msg: string) => runtimeLogs.push(msg),
|
|
||||||
error: (_msg: string) => {},
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`__exit__:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalUser = process.env.USER;
|
|
||||||
try {
|
|
||||||
process.env.USER = "steipete";
|
|
||||||
loadConfig.mockReturnValueOnce({
|
|
||||||
gateway: {
|
|
||||||
mode: "remote",
|
|
||||||
remote: { url: "ws://peters-mac-studio-1.sheep-coho.ts.net:18789", token: "rtok" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
resolveSshConfig.mockResolvedValueOnce({
|
|
||||||
user: "steipete",
|
|
||||||
host: "peters-mac-studio-1.sheep-coho.ts.net",
|
|
||||||
port: 2222,
|
|
||||||
identityFiles: ["/tmp/id_ed25519"],
|
|
||||||
});
|
|
||||||
|
|
||||||
startSshPortForward.mockClear();
|
|
||||||
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
|
||||||
await gatewayStatusCommand(
|
|
||||||
{ timeout: "1000", json: true },
|
|
||||||
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
|
||||||
const call = startSshPortForward.mock.calls[0]?.[0] as {
|
|
||||||
target: string;
|
|
||||||
identity?: string;
|
|
||||||
};
|
|
||||||
expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222");
|
|
||||||
expect(call.identity).toBe("/tmp/id_ed25519");
|
|
||||||
} finally {
|
|
||||||
process.env.USER = originalUser;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to host-only when USER is missing and ssh config is unavailable", async () => {
|
|
||||||
const runtimeLogs: string[] = [];
|
|
||||||
const runtime = {
|
|
||||||
log: (msg: string) => runtimeLogs.push(msg),
|
|
||||||
error: (_msg: string) => {},
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`__exit__:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalUser = process.env.USER;
|
|
||||||
try {
|
|
||||||
process.env.USER = "";
|
|
||||||
loadConfig.mockReturnValueOnce({
|
|
||||||
gateway: {
|
|
||||||
mode: "remote",
|
|
||||||
remote: { url: "ws://studio.example:18789", token: "rtok" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
resolveSshConfig.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
startSshPortForward.mockClear();
|
|
||||||
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
|
||||||
await gatewayStatusCommand(
|
|
||||||
{ timeout: "1000", json: true },
|
|
||||||
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
|
||||||
);
|
|
||||||
|
|
||||||
const call = startSshPortForward.mock.calls[0]?.[0] as {
|
|
||||||
target: string;
|
|
||||||
};
|
|
||||||
expect(call.target).toBe("studio.example");
|
|
||||||
} finally {
|
|
||||||
process.env.USER = originalUser;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps explicit SSH identity even when ssh config provides one", async () => {
|
|
||||||
const runtimeLogs: string[] = [];
|
|
||||||
const runtime = {
|
|
||||||
log: (msg: string) => runtimeLogs.push(msg),
|
|
||||||
error: (_msg: string) => {},
|
|
||||||
exit: (code: number) => {
|
|
||||||
throw new Error(`__exit__:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
loadConfig.mockReturnValueOnce({
|
|
||||||
gateway: {
|
|
||||||
mode: "remote",
|
|
||||||
remote: { url: "ws://studio.example:18789", token: "rtok" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
resolveSshConfig.mockResolvedValueOnce({
|
|
||||||
user: "me",
|
|
||||||
host: "studio.example",
|
|
||||||
port: 22,
|
|
||||||
identityFiles: ["/tmp/id_from_config"],
|
|
||||||
});
|
|
||||||
|
|
||||||
startSshPortForward.mockClear();
|
|
||||||
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
|
||||||
await gatewayStatusCommand(
|
|
||||||
{ timeout: "1000", json: true, sshIdentity: "/tmp/explicit_id" },
|
|
||||||
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
|
||||||
);
|
|
||||||
|
|
||||||
const call = startSshPortForward.mock.calls[0]?.[0] as {
|
|
||||||
identity?: string;
|
|
||||||
};
|
|
||||||
expect(call.identity).toBe("/tmp/explicit_id");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -2,8 +2,7 @@ import { withProgress } from "../cli/progress.js";
|
|||||||
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
||||||
import { probeGateway } from "../gateway/probe.js";
|
import { probeGateway } from "../gateway/probe.js";
|
||||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||||
import { resolveSshConfig } from "../infra/ssh-config.js";
|
import { startSshPortForward } from "../infra/ssh-tunnel.js";
|
||||||
import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
@@ -48,25 +47,13 @@ export async function gatewayStatusCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
|
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
|
||||||
let sshIdentity =
|
const sshIdentity =
|
||||||
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
|
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
|
||||||
const remotePort = resolveGatewayPort(cfg);
|
const remotePort = resolveGatewayPort(cfg);
|
||||||
|
|
||||||
let sshTunnelError: string | null = null;
|
let sshTunnelError: string | null = null;
|
||||||
let sshTunnelStarted = false;
|
let sshTunnelStarted = false;
|
||||||
|
|
||||||
if (!sshTarget) {
|
|
||||||
sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sshTarget) {
|
|
||||||
const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs);
|
|
||||||
if (resolved) {
|
|
||||||
sshTarget = resolved.target;
|
|
||||||
if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { discovery, probed } = await withProgress(
|
const { discovery, probed } = await withProgress(
|
||||||
{
|
{
|
||||||
label: "Inspecting gateways…",
|
label: "Inspecting gateways…",
|
||||||
@@ -327,51 +314,3 @@ export async function gatewayStatusCommand(
|
|||||||
|
|
||||||
if (!ok) runtime.exit(1);
|
if (!ok) runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null {
|
|
||||||
if (typeof rawUrl !== "string") return null;
|
|
||||||
const trimmed = rawUrl.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
let host: string | null = null;
|
|
||||||
try {
|
|
||||||
host = new URL(trimmed).hostname || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!host) return null;
|
|
||||||
const user = process.env.USER?.trim() || "";
|
|
||||||
return user ? `${user}@${host}` : host;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSshTarget(input: { user?: string; host?: string; port?: number }): string | null {
|
|
||||||
const host = input.host?.trim() ?? "";
|
|
||||||
if (!host) return null;
|
|
||||||
const user = input.user?.trim() ?? "";
|
|
||||||
const base = user ? `${user}@${host}` : host;
|
|
||||||
const port = input.port ?? 22;
|
|
||||||
if (port && port !== 22) return `${base}:${port}`;
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveSshTarget(
|
|
||||||
rawTarget: string,
|
|
||||||
identity: string | null,
|
|
||||||
overallTimeoutMs: number,
|
|
||||||
): Promise<{ target: string; identity?: string } | null> {
|
|
||||||
const parsed = parseSshTarget(rawTarget);
|
|
||||||
if (!parsed) return null;
|
|
||||||
const config = await resolveSshConfig(parsed, {
|
|
||||||
identity: identity ?? undefined,
|
|
||||||
timeoutMs: Math.min(800, overallTimeoutMs),
|
|
||||||
});
|
|
||||||
if (!config) return { target: rawTarget, identity: identity ?? undefined };
|
|
||||||
const target = buildSshTarget({
|
|
||||||
user: config.user ?? parsed.user,
|
|
||||||
host: config.host ?? parsed.host,
|
|
||||||
port: config.port ?? parsed.port,
|
|
||||||
});
|
|
||||||
if (!target) return { target: rawTarget, identity: identity ?? undefined };
|
|
||||||
const identityFile =
|
|
||||||
identity ?? config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ?? undefined;
|
|
||||||
return { target, identity: identityFile };
|
|
||||||
}
|
|
||||||
@@ -424,11 +424,7 @@ export async function modelsStatusCommand(
|
|||||||
timeoutMs: 3500,
|
timeoutMs: 3500,
|
||||||
});
|
});
|
||||||
for (const snapshot of usageSummary.providers) {
|
for (const snapshot of usageSummary.providers) {
|
||||||
const formatted = formatUsageWindowSummary(snapshot, {
|
const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(), maxWindows: 2 });
|
||||||
now: Date.now(),
|
|
||||||
maxWindows: 2,
|
|
||||||
includeResets: true,
|
|
||||||
});
|
|
||||||
if (formatted) {
|
if (formatted) {
|
||||||
usageByProvider.set(snapshot.provider, formatted);
|
usageByProvider.set(snapshot.provider, formatted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,9 @@ export async function statusAllCommand(
|
|||||||
: gatewayProbe?.error
|
: gatewayProbe?.error
|
||||||
? `unreachable (${gatewayProbe.error})`
|
? `unreachable (${gatewayProbe.error})`
|
||||||
: "unreachable";
|
: "unreachable";
|
||||||
const gatewayAuth = gatewayReachable ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : "";
|
const gatewayAuth = gatewayReachable
|
||||||
|
? ` · auth ${formatGatewayAuthUsed(probeAuth)}`
|
||||||
|
: "";
|
||||||
const gatewaySelfLine =
|
const gatewaySelfLine =
|
||||||
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
|
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -28,18 +28,18 @@ describe("config discord", () => {
|
|||||||
dm: {
|
dm: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowFrom: ["steipete"],
|
allowFrom: ["steipete"],
|
||||||
groupEnabled: true,
|
groupEnabled: true,
|
||||||
groupChannels: ["clawd-dm"],
|
groupChannels: ["clawd-dm"],
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
emojiUploads: true,
|
emojiUploads: true,
|
||||||
stickerUploads: false,
|
stickerUploads: false,
|
||||||
channels: true,
|
channels: true,
|
||||||
},
|
},
|
||||||
guilds: {
|
guilds: {
|
||||||
"123": {
|
"123": {
|
||||||
slug: "friends-of-clawd",
|
slug: "friends-of-clawd",
|
||||||
requireMention: false,
|
requireMention: false,
|
||||||
users: ["steipete"],
|
users: ["steipete"],
|
||||||
channels: {
|
channels: {
|
||||||
general: { allow: true },
|
general: { allow: true },
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { ClawdbotSchema } from "./zod-schema.js";
|
|
||||||
|
|
||||||
describe("telegram custom commands schema", () => {
|
|
||||||
it("normalizes custom commands", () => {
|
|
||||||
const res = ClawdbotSchema.safeParse({
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
customCommands: [{ command: "/Backup", description: " Git backup " }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.success).toBe(true);
|
|
||||||
if (!res.success) return;
|
|
||||||
|
|
||||||
expect(res.data.channels?.telegram?.customCommands).toEqual([
|
|
||||||
{ command: "backup", description: "Git backup" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects custom commands with invalid names", () => {
|
|
||||||
const res = ClawdbotSchema.safeParse({
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
customCommands: [{ command: "Bad-Name", description: "Override status" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.success).toBe(false);
|
|
||||||
if (res.success) return;
|
|
||||||
|
|
||||||
expect(
|
|
||||||
res.error.issues.some(
|
|
||||||
(issue) =>
|
|
||||||
issue.path.join(".") === "channels.telegram.customCommands.0.command" &&
|
|
||||||
issue.message.includes("invalid"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -167,8 +167,6 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"commands.useAccessGroups": "Use Access Groups",
|
"commands.useAccessGroups": "Use Access Groups",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
"browser.controlUrl": "Browser Control URL",
|
"browser.controlUrl": "Browser Control URL",
|
||||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
|
||||||
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
|
||||||
"session.dmScope": "DM Session Scope",
|
"session.dmScope": "DM Session Scope",
|
||||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||||
"messages.ackReaction": "Ack Reaction Emoji",
|
"messages.ackReaction": "Ack Reaction Emoji",
|
||||||
@@ -177,7 +175,6 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"talk.apiKey": "Talk API Key",
|
"talk.apiKey": "Talk API Key",
|
||||||
"channels.whatsapp": "WhatsApp",
|
"channels.whatsapp": "WhatsApp",
|
||||||
"channels.telegram": "Telegram",
|
"channels.telegram": "Telegram",
|
||||||
"channels.telegram.customCommands": "Telegram Custom Commands",
|
|
||||||
"channels.discord": "Discord",
|
"channels.discord": "Discord",
|
||||||
"channels.slack": "Slack",
|
"channels.slack": "Slack",
|
||||||
"channels.signal": "Signal",
|
"channels.signal": "Signal",
|
||||||
@@ -306,8 +303,7 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"plugins.installs.*.source": 'Install source ("npm", "archive", or "path").',
|
"plugins.installs.*.source": 'Install source ("npm", "archive", or "path").',
|
||||||
"plugins.installs.*.spec": "Original npm spec used for install (if source is npm).",
|
"plugins.installs.*.spec": "Original npm spec used for install (if source is npm).",
|
||||||
"plugins.installs.*.sourcePath": "Original archive/path used for install (if any).",
|
"plugins.installs.*.sourcePath": "Original archive/path used for install (if any).",
|
||||||
"plugins.installs.*.installPath":
|
"plugins.installs.*.installPath": "Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||||
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
|
||||||
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||||
@@ -352,8 +348,6 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
|
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
|
||||||
"session.agentToAgent.maxPingPongTurns":
|
"session.agentToAgent.maxPingPongTurns":
|
||||||
"Max reply-back turns between requester and target (0–5).",
|
"Max reply-back turns between requester and target (0–5).",
|
||||||
"channels.telegram.customCommands":
|
|
||||||
"Additional Telegram bot menu commands (merged with native; conflicts ignored).",
|
|
||||||
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
|
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
|
||||||
"messages.ackReactionScope":
|
"messages.ackReactionScope":
|
||||||
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
|
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
|
|
||||||
|
|
||||||
export type TelegramCustomCommandInput = {
|
|
||||||
command?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TelegramCustomCommandIssue = {
|
|
||||||
index: number;
|
|
||||||
field: "command" | "description";
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function normalizeTelegramCommandName(value: string): string {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
|
|
||||||
return withoutSlash.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTelegramCommandDescription(value: string): string {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTelegramCustomCommands(params: {
|
|
||||||
commands?: TelegramCustomCommandInput[] | null;
|
|
||||||
reservedCommands?: Set<string>;
|
|
||||||
checkReserved?: boolean;
|
|
||||||
checkDuplicates?: boolean;
|
|
||||||
}): {
|
|
||||||
commands: Array<{ command: string; description: string }>;
|
|
||||||
issues: TelegramCustomCommandIssue[];
|
|
||||||
} {
|
|
||||||
const entries = Array.isArray(params.commands) ? params.commands : [];
|
|
||||||
const reserved = params.reservedCommands ?? new Set<string>();
|
|
||||||
const checkReserved = params.checkReserved !== false;
|
|
||||||
const checkDuplicates = params.checkDuplicates !== false;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const resolved: Array<{ command: string; description: string }> = [];
|
|
||||||
const issues: TelegramCustomCommandIssue[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < entries.length; index += 1) {
|
|
||||||
const entry = entries[index];
|
|
||||||
const normalized = normalizeTelegramCommandName(String(entry?.command ?? ""));
|
|
||||||
if (!normalized) {
|
|
||||||
issues.push({
|
|
||||||
index,
|
|
||||||
field: "command",
|
|
||||||
message: "Telegram custom command is missing a command name.",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
|
||||||
issues.push({
|
|
||||||
index,
|
|
||||||
field: "command",
|
|
||||||
message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (checkReserved && reserved.has(normalized)) {
|
|
||||||
issues.push({
|
|
||||||
index,
|
|
||||||
field: "command",
|
|
||||||
message: `Telegram custom command "/${normalized}" conflicts with a native command.`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (checkDuplicates && seen.has(normalized)) {
|
|
||||||
issues.push({
|
|
||||||
index,
|
|
||||||
field: "command",
|
|
||||||
message: `Telegram custom command "/${normalized}" is duplicated.`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const description = normalizeTelegramCommandDescription(String(entry?.description ?? ""));
|
|
||||||
if (!description) {
|
|
||||||
issues.push({
|
|
||||||
index,
|
|
||||||
field: "description",
|
|
||||||
message: `Telegram custom command "/${normalized}" is missing a description.`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (checkDuplicates) {
|
|
||||||
seen.add(normalized);
|
|
||||||
}
|
|
||||||
resolved.push({ command: normalized, description });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { commands: resolved, issues };
|
|
||||||
}
|
|
||||||
@@ -21,10 +21,6 @@ export type BrowserConfig = {
|
|||||||
controlToken?: string;
|
controlToken?: string;
|
||||||
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
/** Remote CDP HTTP timeout (ms). Default: 1500. */
|
|
||||||
remoteCdpTimeoutMs?: number;
|
|
||||||
/** Remote CDP WebSocket handshake timeout (ms). Default: max(remoteCdpTimeoutMs * 2, 2000). */
|
|
||||||
remoteCdpHandshakeTimeoutMs?: number;
|
|
||||||
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Override the browser executable path (all platforms). */
|
/** Override the browser executable path (all platforms). */
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ export type TelegramActionConfig = {
|
|||||||
deleteMessage?: boolean;
|
deleteMessage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Custom command definition for Telegram bot menu. */
|
|
||||||
export type TelegramCustomCommand = {
|
|
||||||
/** Command name (without leading /). */
|
|
||||||
command: string;
|
|
||||||
/** Description shown in Telegram command menu. */
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TelegramAccountConfig = {
|
export type TelegramAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -29,8 +21,6 @@ export type TelegramAccountConfig = {
|
|||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Override native command registration for Telegram (bool or "auto"). */
|
/** Override native command registration for Telegram (bool or "auto"). */
|
||||||
commands?: ProviderCommandsConfig;
|
commands?: ProviderCommandsConfig;
|
||||||
/** Custom commands to register in Telegram's command menu (merged with native). */
|
|
||||||
customCommands?: TelegramCustomCommand[];
|
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ import {
|
|||||||
RetryConfigSchema,
|
RetryConfigSchema,
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
import {
|
|
||||||
normalizeTelegramCommandDescription,
|
|
||||||
normalizeTelegramCommandName,
|
|
||||||
resolveTelegramCustomCommands,
|
|
||||||
} from "./telegram-custom-commands.js";
|
|
||||||
|
|
||||||
export const TelegramTopicSchema = z.object({
|
export const TelegramTopicSchema = z.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
@@ -36,36 +31,11 @@ export const TelegramGroupSchema = z.object({
|
|||||||
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TelegramCustomCommandSchema = z.object({
|
|
||||||
command: z.string().transform(normalizeTelegramCommandName),
|
|
||||||
description: z.string().transform(normalizeTelegramCommandDescription),
|
|
||||||
});
|
|
||||||
|
|
||||||
const validateTelegramCustomCommands = (
|
|
||||||
value: { customCommands?: Array<{ command?: string; description?: string }> },
|
|
||||||
ctx: z.RefinementCtx,
|
|
||||||
) => {
|
|
||||||
if (!value.customCommands || value.customCommands.length === 0) return;
|
|
||||||
const { issues } = resolveTelegramCustomCommands({
|
|
||||||
commands: value.customCommands,
|
|
||||||
checkReserved: false,
|
|
||||||
checkDuplicates: false,
|
|
||||||
});
|
|
||||||
for (const issue of issues) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["customCommands", issue.index, issue.field],
|
|
||||||
message: issue.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TelegramAccountSchemaBase = z.object({
|
export const TelegramAccountSchemaBase = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
@@ -110,7 +80,6 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
|
|||||||
message:
|
message:
|
||||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||||
});
|
});
|
||||||
validateTelegramCustomCommands(value, ctx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||||
@@ -124,7 +93,6 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
|||||||
message:
|
message:
|
||||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||||
});
|
});
|
||||||
validateTelegramCustomCommands(value, ctx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DiscordDmSchema = z
|
export const DiscordDmSchema = z
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
export const SessionSchema = z
|
export const SessionSchema = z
|
||||||
.object({
|
.object({
|
||||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||||
dmScope: z
|
dmScope: z.union([
|
||||||
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
|
z.literal("main"),
|
||||||
.optional(),
|
z.literal("per-peer"),
|
||||||
|
z.literal("per-channel-peer"),
|
||||||
|
]).optional(),
|
||||||
resetTriggers: z.array(z.string()).optional(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ export const ClawdbotSchema = z
|
|||||||
controlUrl: z.string().optional(),
|
controlUrl: z.string().optional(),
|
||||||
controlToken: z.string().optional(),
|
controlToken: z.string().optional(),
|
||||||
cdpUrl: z.string().optional(),
|
cdpUrl: z.string().optional(),
|
||||||
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
|
|
||||||
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
|
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
executablePath: z.string().optional(),
|
executablePath: z.string().optional(),
|
||||||
headless: z.boolean().optional(),
|
headless: z.boolean().optional(),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("parseSystemdExecStart", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("preserves quoted arguments", () => {
|
it("preserves quoted arguments", () => {
|
||||||
const execStart = '/usr/bin/clawdbot gateway start --name "My Bot"';
|
const execStart = "/usr/bin/clawdbot gateway start --name \"My Bot\"";
|
||||||
expect(parseSystemdExecStart(execStart)).toEqual([
|
expect(parseSystemdExecStart(execStart)).toEqual([
|
||||||
"/usr/bin/clawdbot",
|
"/usr/bin/clawdbot",
|
||||||
"gateway",
|
"gateway",
|
||||||
|
|||||||
@@ -93,34 +93,6 @@ describe("gateway auth", () => {
|
|||||||
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
|
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats local tailscale serve hostnames as direct", async () => {
|
|
||||||
const res = await authorizeGatewayConnect({
|
|
||||||
auth: { mode: "none", allowTailscale: true },
|
|
||||||
connectAuth: null,
|
|
||||||
req: {
|
|
||||||
socket: { remoteAddress: "127.0.0.1" },
|
|
||||||
headers: { host: "gateway.tailnet-1234.ts.net:443" },
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
expect(res.method).toBe("none");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat tailscale clients as direct", async () => {
|
|
||||||
const res = await authorizeGatewayConnect({
|
|
||||||
auth: { mode: "none", allowTailscale: true },
|
|
||||||
connectAuth: null,
|
|
||||||
req: {
|
|
||||||
socket: { remoteAddress: "100.64.0.42" },
|
|
||||||
headers: { host: "gateway.tailnet-1234.ts.net" },
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok).toBe(false);
|
|
||||||
expect(res.reason).toBe("tailscale_user_missing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows tailscale identity to satisfy token mode auth", async () => {
|
it("allows tailscale identity to satisfy token mode auth", async () => {
|
||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||||
|
|||||||
+5
-18
@@ -42,33 +42,20 @@ function isLoopbackAddress(ip: string | undefined): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHostName(hostHeader?: string): string {
|
|
||||||
const host = (hostHeader ?? "").trim().toLowerCase();
|
|
||||||
if (!host) return "";
|
|
||||||
if (host.startsWith("[")) {
|
|
||||||
const end = host.indexOf("]");
|
|
||||||
if (end !== -1) return host.slice(1, end);
|
|
||||||
}
|
|
||||||
const [name] = host.split(":");
|
|
||||||
return name ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||||
if (!req) return false;
|
if (!req) return false;
|
||||||
const clientIp = req.socket?.remoteAddress ?? "";
|
const clientIp = req.socket?.remoteAddress ?? "";
|
||||||
if (!isLoopbackAddress(clientIp)) return false;
|
if (!isLoopbackAddress(clientIp)) return false;
|
||||||
|
|
||||||
const host = getHostName(req.headers?.host);
|
const host = (req.headers.host ?? "").toLowerCase();
|
||||||
const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
const hostIsLocal =
|
||||||
const hostIsTailscaleServe = host.endsWith(".ts.net");
|
host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]");
|
||||||
|
|
||||||
const hasForwarded = Boolean(
|
const hasForwarded = Boolean(
|
||||||
req.headers?.["x-forwarded-for"] ||
|
req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"],
|
||||||
req.headers?.["x-real-ip"] ||
|
|
||||||
req.headers?.["x-forwarded-host"],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
|
return hostIsLocal && !hasForwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||||
|
|||||||
@@ -162,9 +162,7 @@ export async function startGatewayNodeBridge(params: {
|
|||||||
});
|
});
|
||||||
if (started.port > 0) {
|
if (started.port > 0) {
|
||||||
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
|
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
|
||||||
params.logBridge.info(
|
params.logBridge.info(`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`);
|
||||||
`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`,
|
|
||||||
);
|
|
||||||
return { bridge: started, nodePresenceTimers };
|
return { bridge: started, nodePresenceTimers };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
|
|||||||
const loopbackHost = "127.0.0.1";
|
const loopbackHost = "127.0.0.1";
|
||||||
|
|
||||||
const listeners: Array<{ host: string; server: net.Server }> = [];
|
const listeners: Array<{ host: string; server: net.Server }> = [];
|
||||||
const createServer = () =>
|
const createServer = () => (opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection));
|
||||||
opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection);
|
|
||||||
const primary = createServer();
|
const primary = createServer();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const onError = (err: Error) => reject(err);
|
const onError = (err: Error) => reject(err);
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ const RESET_KEYS = [
|
|||||||
"resetAt",
|
"resetAt",
|
||||||
"reset_time",
|
"reset_time",
|
||||||
"resetTime",
|
"resetTime",
|
||||||
"next_reset_at",
|
|
||||||
"nextResetAt",
|
|
||||||
"next_reset_time",
|
|
||||||
"nextResetTime",
|
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"expiresAt",
|
"expiresAt",
|
||||||
"expire_at",
|
"expire_at",
|
||||||
@@ -56,14 +52,6 @@ const USED_KEYS = [
|
|||||||
"usedQuota",
|
"usedQuota",
|
||||||
"used_times",
|
"used_times",
|
||||||
"usedTimes",
|
"usedTimes",
|
||||||
"prompt_used",
|
|
||||||
"promptUsed",
|
|
||||||
"used_prompt",
|
|
||||||
"usedPrompt",
|
|
||||||
"prompts_used",
|
|
||||||
"promptsUsed",
|
|
||||||
"current_interval_usage_count",
|
|
||||||
"currentIntervalUsageCount",
|
|
||||||
"consumed",
|
"consumed",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -77,20 +65,6 @@ const TOTAL_KEYS = [
|
|||||||
"totalQuota",
|
"totalQuota",
|
||||||
"total_times",
|
"total_times",
|
||||||
"totalTimes",
|
"totalTimes",
|
||||||
"prompt_total",
|
|
||||||
"promptTotal",
|
|
||||||
"total_prompt",
|
|
||||||
"totalPrompt",
|
|
||||||
"prompt_limit",
|
|
||||||
"promptLimit",
|
|
||||||
"limit_prompt",
|
|
||||||
"limitPrompt",
|
|
||||||
"prompts_total",
|
|
||||||
"promptsTotal",
|
|
||||||
"total_prompts",
|
|
||||||
"totalPrompts",
|
|
||||||
"current_interval_total_count",
|
|
||||||
"currentIntervalTotalCount",
|
|
||||||
"limit",
|
"limit",
|
||||||
"quota",
|
"quota",
|
||||||
"quota_limit",
|
"quota_limit",
|
||||||
@@ -113,20 +87,6 @@ const REMAINING_KEYS = [
|
|||||||
"remain_times",
|
"remain_times",
|
||||||
"remainingTimes",
|
"remainingTimes",
|
||||||
"remaining_times",
|
"remaining_times",
|
||||||
"prompt_remain",
|
|
||||||
"promptRemain",
|
|
||||||
"remain_prompt",
|
|
||||||
"remainPrompt",
|
|
||||||
"prompt_remaining",
|
|
||||||
"promptRemaining",
|
|
||||||
"remaining_prompt",
|
|
||||||
"remainingPrompt",
|
|
||||||
"prompts_remaining",
|
|
||||||
"promptsRemaining",
|
|
||||||
"prompt_left",
|
|
||||||
"promptLeft",
|
|
||||||
"prompts_left",
|
|
||||||
"promptsLeft",
|
|
||||||
"left",
|
"left",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -184,62 +144,6 @@ function parseEpoch(value: unknown): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAny(record: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
||||||
return keys.some((key) => key in record);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreUsageRecord(record: Record<string, unknown>): number {
|
|
||||||
let score = 0;
|
|
||||||
if (hasAny(record, PERCENT_KEYS)) score += 4;
|
|
||||||
if (hasAny(record, TOTAL_KEYS)) score += 3;
|
|
||||||
if (hasAny(record, USED_KEYS) || hasAny(record, REMAINING_KEYS)) score += 2;
|
|
||||||
if (hasAny(record, RESET_KEYS)) score += 1;
|
|
||||||
if (hasAny(record, PLAN_KEYS)) score += 1;
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectUsageCandidates(root: Record<string, unknown>): Record<string, unknown>[] {
|
|
||||||
const MAX_SCAN_DEPTH = 4;
|
|
||||||
const MAX_SCAN_NODES = 60;
|
|
||||||
const queue: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
|
|
||||||
const seen = new Set<object>();
|
|
||||||
const candidates: Array<{ record: Record<string, unknown>; score: number; depth: number }> = [];
|
|
||||||
let scanned = 0;
|
|
||||||
|
|
||||||
while (queue.length && scanned < MAX_SCAN_NODES) {
|
|
||||||
const next = queue.shift();
|
|
||||||
if (!next) break;
|
|
||||||
scanned += 1;
|
|
||||||
const { value, depth } = next;
|
|
||||||
|
|
||||||
if (isRecord(value)) {
|
|
||||||
if (seen.has(value)) continue;
|
|
||||||
seen.add(value);
|
|
||||||
const score = scoreUsageRecord(value);
|
|
||||||
if (score > 0) candidates.push({ record: value, score, depth });
|
|
||||||
if (depth < MAX_SCAN_DEPTH) {
|
|
||||||
for (const nested of Object.values(value)) {
|
|
||||||
if (isRecord(nested) || Array.isArray(nested)) {
|
|
||||||
queue.push({ value: nested, depth: depth + 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value) && depth < MAX_SCAN_DEPTH) {
|
|
||||||
for (const nested of value) {
|
|
||||||
if (isRecord(nested) || Array.isArray(nested)) {
|
|
||||||
queue.push({ value: nested, depth: depth + 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates.sort((a, b) => b.score - a.score || a.depth - b.depth);
|
|
||||||
return candidates.map((candidate) => candidate.record);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveWindowLabel(payload: Record<string, unknown>): string {
|
function deriveWindowLabel(payload: Record<string, unknown>): string {
|
||||||
const hours = pickNumber(payload, WINDOW_HOUR_KEYS);
|
const hours = pickNumber(payload, WINDOW_HOUR_KEYS);
|
||||||
if (hours && Number.isFinite(hours)) return `${hours}h`;
|
if (hours && Number.isFinite(hours)) return `${hours}h`;
|
||||||
@@ -249,32 +153,21 @@ function deriveWindowLabel(payload: Record<string, unknown>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deriveUsedPercent(payload: Record<string, unknown>): number | null {
|
function deriveUsedPercent(payload: Record<string, unknown>): number | null {
|
||||||
const total = pickNumber(payload, TOTAL_KEYS);
|
|
||||||
let used = pickNumber(payload, USED_KEYS);
|
|
||||||
const remaining = pickNumber(payload, REMAINING_KEYS);
|
|
||||||
if (used === undefined && remaining !== undefined && total !== undefined) {
|
|
||||||
used = total - remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromCounts =
|
|
||||||
total && total > 0 && used !== undefined && Number.isFinite(used)
|
|
||||||
? clampPercent((used / total) * 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const percentRaw = pickNumber(payload, PERCENT_KEYS);
|
const percentRaw = pickNumber(payload, PERCENT_KEYS);
|
||||||
if (percentRaw !== undefined) {
|
if (percentRaw !== undefined) {
|
||||||
const normalized = clampPercent(percentRaw <= 1 ? percentRaw * 100 : percentRaw);
|
const normalized = percentRaw <= 1 ? percentRaw * 100 : percentRaw;
|
||||||
if (fromCounts !== null) {
|
return clampPercent(normalized);
|
||||||
const inverted = clampPercent(100 - normalized);
|
|
||||||
if (Math.abs(normalized - fromCounts) <= 1 || Math.abs(inverted - fromCounts) <= 1) {
|
|
||||||
return fromCounts;
|
|
||||||
}
|
|
||||||
return fromCounts;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fromCounts;
|
const total = pickNumber(payload, TOTAL_KEYS);
|
||||||
|
if (!total || total <= 0) return null;
|
||||||
|
let used = pickNumber(payload, USED_KEYS);
|
||||||
|
if (used === undefined) {
|
||||||
|
const remaining = pickNumber(payload, REMAINING_KEYS);
|
||||||
|
if (remaining !== undefined) used = total - remaining;
|
||||||
|
}
|
||||||
|
if (used === undefined || !Number.isFinite(used)) return null;
|
||||||
|
return clampPercent((used / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMinimaxUsage(
|
export async function fetchMinimaxUsage(
|
||||||
@@ -326,20 +219,7 @@ export async function fetchMinimaxUsage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = isRecord(data.data) ? data.data : data;
|
const payload = isRecord(data.data) ? data.data : data;
|
||||||
const candidates = collectUsageCandidates(payload);
|
const usedPercent = deriveUsedPercent(payload);
|
||||||
let usageRecord: Record<string, unknown> = payload;
|
|
||||||
let usedPercent: number | null = null;
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const candidatePercent = deriveUsedPercent(candidate);
|
|
||||||
if (candidatePercent !== null) {
|
|
||||||
usageRecord = candidate;
|
|
||||||
usedPercent = candidatePercent;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (usedPercent === null) {
|
|
||||||
usedPercent = deriveUsedPercent(payload);
|
|
||||||
}
|
|
||||||
if (usedPercent === null) {
|
if (usedPercent === null) {
|
||||||
return {
|
return {
|
||||||
provider: "minimax",
|
provider: "minimax",
|
||||||
@@ -350,13 +230,10 @@ export async function fetchMinimaxUsage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resetAt =
|
const resetAt =
|
||||||
parseEpoch(pickString(usageRecord, RESET_KEYS)) ??
|
parseEpoch(pickString(payload, RESET_KEYS)) ?? parseEpoch(pickNumber(payload, RESET_KEYS));
|
||||||
parseEpoch(pickNumber(usageRecord, RESET_KEYS)) ??
|
|
||||||
parseEpoch(pickString(payload, RESET_KEYS)) ??
|
|
||||||
parseEpoch(pickNumber(payload, RESET_KEYS));
|
|
||||||
const windows: UsageWindow[] = [
|
const windows: UsageWindow[] = [
|
||||||
{
|
{
|
||||||
label: deriveWindowLabel(usageRecord),
|
label: deriveWindowLabel(payload),
|
||||||
usedPercent,
|
usedPercent,
|
||||||
resetAt,
|
resetAt,
|
||||||
},
|
},
|
||||||
@@ -366,6 +243,6 @@ export async function fetchMinimaxUsage(
|
|||||||
provider: "minimax",
|
provider: "minimax",
|
||||||
displayName: PROVIDER_LABELS.minimax,
|
displayName: PROVIDER_LABELS.minimax,
|
||||||
windows,
|
windows,
|
||||||
plan: pickString(usageRecord, PLAN_KEYS) ?? pickString(payload, PLAN_KEYS),
|
plan: pickString(payload, PLAN_KEYS),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user