Compare commits
1 Commits
packager-r
...
client-sid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c52c4145 |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -204,14 +204,6 @@ jobs:
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
|
||||
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,8 +27,6 @@ apps/android/.cxx/
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/MoltbotKit/.build/
|
||||
apps/shared/OpenClawKit/.build/
|
||||
apps/shared/OpenClawKit/Package.resolved
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -6,38 +6,24 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
|
||||
- Skills/Security: defense-in-depth security hardening for community skills (ClawHub installs). Adds capability declarations (`shell`, `filesystem`, `network`, `browser`, `sessions`), trust tier classification (builtin/verified/community/local), SKILL.md content scanning (blocks prompt injection, capability inflation, boundary spoofing), skill-aware tool policy enforcement (denies undeclared dangerous tools for community skills), command-dispatch gating, and before-tool-call audit monitoring with session context. Community skills that fail critical scanning are blocked from loading. `openclaw skills list/info/check` now show capabilities, trust tiers, scan results, and runtime policy.
|
||||
- Skills/Logging: all security-related log entries tagged with `category: "security"` for filtering. Skills CLI commands output structured JSON to the file logger (no more ASCII tables in logs). Web UI Logs tab adds a "Security" filter chip for security-only event views.
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
|
||||
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
|
||||
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
|
||||
- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
|
||||
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
|
||||
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
|
||||
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
|
||||
- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
|
||||
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
|
||||
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
|
||||
- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
|
||||
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
|
||||
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
|
||||
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
|
||||
- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
|
||||
## 2026.2.14
|
||||
|
||||
@@ -94,7 +80,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
|
||||
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
|
||||
- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
|
||||
- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729)
|
||||
- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
|
||||
- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
|
||||
- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
|
||||
@@ -135,8 +120,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko.
|
||||
- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
|
||||
- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739)
|
||||
- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-<agentId>`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420.
|
||||
- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras.
|
||||
- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
|
||||
- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
|
||||
- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
|
||||
|
||||
@@ -6,7 +6,7 @@ final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
@@ -39,7 +39,7 @@ final class CalendarService: CalendarServicing {
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
@@ -95,6 +95,38 @@ final class CalendarService: CalendarServicing {
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
|
||||
@@ -93,10 +93,14 @@ actor CameraController {
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality)
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
|
||||
return (
|
||||
format: format.rawValue,
|
||||
@@ -331,8 +335,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
|
||||
@@ -360,8 +364,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import EventKit
|
||||
|
||||
enum EventKitAuthorization {
|
||||
static func allowsRead(status: EKAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func allowsWrite(status: EKAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +136,43 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
self.statusText = GatewayDiscoveryStatusText.make(
|
||||
states: Array(self.statesByDomain.values),
|
||||
hasBrowsers: !self.browsers.isEmpty)
|
||||
let states = Array(self.statesByDomain.values)
|
||||
if states.isEmpty {
|
||||
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
self.statusText = "Failed: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let waiting = states.first(where: { state in
|
||||
if case .waiting = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
self.statusText = "Waiting: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||
self.statusText = "Searching…"
|
||||
return
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||
self.statusText = "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
self.statusText = "Searching…"
|
||||
}
|
||||
|
||||
private static func prettyState(_ state: NWBrowser.State) -> String {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewaySetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
enum GatewaySetupCode {
|
||||
static func decode(raw: String) -> GatewaySetupPayload? {
|
||||
if let payload = decodeFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private static func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
enum TCPProbe {
|
||||
static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool {
|
||||
guard port >= 1, port <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: queueLabel)
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,37 @@ extension NodeAppModel {
|
||||
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
||||
guard let host = url.host, !host.isEmpty else { return false }
|
||||
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
||||
return await TCPProbe.probe(
|
||||
host: host,
|
||||
port: portInt,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
queueLabel: "a2ui.preflight")
|
||||
guard portInt >= 1, portInt <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "a2ui.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,15 @@ private struct ManualEntryStep: View {
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
@@ -264,7 +273,7 @@ private struct ManualEntryStep: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return
|
||||
}
|
||||
@@ -314,7 +323,34 @@ private struct ManualEntryStep: View {
|
||||
}
|
||||
}
|
||||
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
|
||||
@@ -6,7 +6,7 @@ final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
@@ -50,7 +50,7 @@ final class RemindersService: RemindersServicing {
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
@@ -100,6 +100,38 @@ final class RemindersService: RemindersServicing {
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
|
||||
@@ -256,11 +256,64 @@ private struct CanvasContent: View {
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.cameraHUDText,
|
||||
cameraHUDKind: self.cameraHUDKind)
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,10 +104,66 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
cameraHUDKind: self.appModel.cameraHUDKind)
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText = self.appModel.cameraHUDText,
|
||||
let cameraHUDKind = self.appModel.cameraHUDKind,
|
||||
!cameraHUDText.isEmpty
|
||||
{
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -590,6 +590,15 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
@@ -617,7 +626,7 @@ struct SettingsTab: View {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return false
|
||||
}
|
||||
@@ -718,14 +727,67 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
|
||||
await TCPProbe.probe(
|
||||
host: host,
|
||||
port: port,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
queueLabel: "gateway.preflight")
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "gateway.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -790,6 +852,44 @@ struct SettingsTab: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
private static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum StatusActivityBuilder {
|
||||
static func build(
|
||||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
cameraHUDText: String?,
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
) -> StatusPill.Activity? {
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if voiceWakeEnabled {
|
||||
let voiceStatus = appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = OpenClawKit.AnyCodable
|
||||
@@ -41,3 +42,40 @@ extension AnyCodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawProtocol.AnyCodable {
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
self.value as? Bool
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
self.value as? Int
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
self.value as? Double
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: OpenClawProtocol.AnyCodable]? {
|
||||
self.value as? [String: OpenClawProtocol.AnyCodable]
|
||||
}
|
||||
|
||||
var arrayValue: [OpenClawProtocol.AnyCodable]? {
|
||||
self.value as? [OpenClawProtocol.AnyCodable]
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: OpenClawProtocol.AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [OpenClawProtocol.AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,16 +106,14 @@ actor CameraCaptureService {
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let res: (data: Data, widthPx: Int, heightPx: Int)
|
||||
do {
|
||||
res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality)
|
||||
} catch {
|
||||
throw CameraError.captureFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||
}
|
||||
|
||||
@@ -357,8 +355,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
self.cont = nil
|
||||
@@ -380,8 +378,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?
|
||||
) {
|
||||
error: Error?)
|
||||
{
|
||||
guard let error else { return }
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [url.path],
|
||||
queueLabel: "ai.openclaw.canvaswatcher",
|
||||
onChange: onChange)
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "ai.openclaw.canvaswatcher")
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -15,10 +19,76 @@ final class CanvasFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.watcher.start()
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CanvasFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.url.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.watcher.stop()
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CoalescingFSEventsWatcher: @unchecked Sendable {
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
|
||||
private let paths: [String]
|
||||
private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool
|
||||
private let onChange: () -> Void
|
||||
private let coalesceDelay: TimeInterval
|
||||
|
||||
init(
|
||||
paths: [String],
|
||||
queueLabel: String,
|
||||
coalesceDelay: TimeInterval = 0.12,
|
||||
shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
|
||||
onChange: @escaping () -> Void
|
||||
) {
|
||||
self.paths = paths
|
||||
self.queue = DispatchQueue(label: queueLabel)
|
||||
self.coalesceDelay = coalesceDelay
|
||||
self.shouldNotify = shouldNotify
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = self.paths as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CoalescingFSEventsWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
|
||||
) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.shouldNotify(numEvents, eventPaths) else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "ai.openclaw.configwatcher")
|
||||
self.onChange = onChange
|
||||
self.watchedDir = url.deletingLastPathComponent()
|
||||
self.targetPath = url.path
|
||||
self.targetName = url.lastPathComponent
|
||||
let watchedDirPath = self.watchedDir.path
|
||||
let targetPath = self.targetPath
|
||||
let targetName = self.targetName
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [watchedDirPath],
|
||||
queueLabel: "ai.openclaw.configwatcher",
|
||||
shouldNotify: { _, eventPaths in
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == targetPath { return true }
|
||||
if path.hasSuffix("/\(targetName)") { return true }
|
||||
if path == watchedDirPath { return true }
|
||||
}
|
||||
return false
|
||||
},
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -36,10 +25,94 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.watcher.start()
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.watchedDir.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.watcher.stop()
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(
|
||||
numEvents: numEvents,
|
||||
eventPaths: eventPaths,
|
||||
eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||
{
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.matchesTarget(eventPaths: eventPaths) else { return }
|
||||
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == self.targetPath { return true }
|
||||
if path.hasSuffix("/\(self.targetName)") { return true }
|
||||
if path == self.watchedDir.path { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ final class DevicePairingApprovalPrompter {
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var resolvedByRequestId: Set<String> = []
|
||||
|
||||
private final class AlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
let paired: [PairedDevice]?
|
||||
@@ -228,11 +238,35 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||
guard let alert = self.activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
self.activeAlert = nil
|
||||
self.activeRequestId = nil
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = AlertHostWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = ""
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.isOpaque = false
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
self.alertHostWindow = window
|
||||
return window
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
|
||||
@@ -158,7 +158,7 @@ final class InstancesStore {
|
||||
|
||||
private func localFallbackInstance(reason: String) -> InstanceInfo {
|
||||
let host = Host.current().localizedName ?? "this-mac"
|
||||
let ip = SystemPresenceInfo.primaryIPv4Address()
|
||||
let ip = Self.primaryIPv4Address()
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
@@ -172,13 +172,58 @@ final class InstancesStore {
|
||||
platform: platform,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
lastInputSeconds: SystemPresenceInfo.lastInputSeconds(),
|
||||
lastInputSeconds: Self.lastInputSeconds(),
|
||||
mode: "local",
|
||||
reason: reason,
|
||||
text: text,
|
||||
ts: ts)
|
||||
}
|
||||
|
||||
private static func lastInputSeconds() -> Int? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||
return Int(seconds.rounded())
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Keep the last raw payload for logging.
|
||||
|
||||
@@ -38,6 +38,16 @@ final class NodePairingApprovalPrompter {
|
||||
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||
private var autoApproveAttempts: Set<String> = []
|
||||
|
||||
private final class AlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
let paired: [PairedNode]?
|
||||
@@ -232,11 +242,35 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||
guard let alert = self.activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
self.activeAlert = nil
|
||||
self.activeRequestId = nil
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = AlertHostWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = ""
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.isOpaque = false
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
self.alertHostWindow = window
|
||||
return window
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
final class PairingAlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum PairingAlertSupport {
|
||||
static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
|
||||
guard let alert = activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
activeAlert = nil
|
||||
activeRequestId = nil
|
||||
}
|
||||
|
||||
static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow {
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = PairingAlertHostWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = ""
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.isOpaque = false
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
alertHostWindow = window
|
||||
return window
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Cocoa
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -32,10 +33,10 @@ final class PresenceReporter {
|
||||
private func push(reason: String) async {
|
||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue }
|
||||
let host = InstanceIdentity.displayName
|
||||
let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown"
|
||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||
let version = Self.appVersionString()
|
||||
let platform = Self.platformString()
|
||||
let lastInput = SystemPresenceInfo.lastInputSeconds()
|
||||
let lastInput = Self.lastInputSeconds()
|
||||
let text = Self.composePresenceSummary(mode: mode, reason: reason)
|
||||
var params: [String: AnyHashable] = [
|
||||
"instanceId": AnyHashable(self.instanceId),
|
||||
@@ -63,9 +64,9 @@ final class PresenceReporter {
|
||||
|
||||
private static func composePresenceSummary(mode: String, reason: String) -> String {
|
||||
let host = InstanceIdentity.displayName
|
||||
let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown"
|
||||
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||
let version = Self.appVersionString()
|
||||
let lastInput = SystemPresenceInfo.lastInputSeconds()
|
||||
let lastInput = Self.lastInputSeconds()
|
||||
let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown"
|
||||
return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)"
|
||||
}
|
||||
@@ -86,7 +87,50 @@ final class PresenceReporter {
|
||||
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
}
|
||||
|
||||
// (SystemPresenceInfo) last input + primary IPv4.
|
||||
private static func lastInputSeconds() -> Int? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||
return Int(seconds.rounded())
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@@ -104,11 +148,11 @@ extension PresenceReporter {
|
||||
}
|
||||
|
||||
static func _testLastInputSeconds() -> Int? {
|
||||
SystemPresenceInfo.lastInputSeconds()
|
||||
self.lastInputSeconds()
|
||||
}
|
||||
|
||||
static func _testPrimaryIPv4Address() -> String? {
|
||||
SystemPresenceInfo.primaryIPv4Address()
|
||||
self.primaryIPv4Address()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum SystemPresenceInfo {
|
||||
static func lastInputSeconds() -> Int? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||
return Int(seconds.rounded())
|
||||
}
|
||||
|
||||
static func primaryIPv4Address() -> String? {
|
||||
NetworkInterfaces.primaryIPv4Address()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawDiscovery
|
||||
import os
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Manages Tailscale integration and status checking.
|
||||
@Observable
|
||||
@@ -138,7 +140,7 @@ final class TailscaleService {
|
||||
self.logger.info("Tailscale API not responding; app likely not running")
|
||||
}
|
||||
|
||||
if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() {
|
||||
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||
self.tailscaleIP = fallback
|
||||
if !self.isRunning {
|
||||
self.isRunning = true
|
||||
@@ -176,7 +178,49 @@ final class TailscaleService {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if Self.isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
TailscaleNetwork.detectTailnetIPv4()
|
||||
self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,9 +329,43 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
self.statusText = GatewayDiscoveryStatusText.make(
|
||||
states: Array(self.statesByDomain.values),
|
||||
hasBrowsers: !self.browsers.isEmpty)
|
||||
let states = Array(self.statesByDomain.values)
|
||||
if states.isEmpty {
|
||||
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
self.statusText = "Failed: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let waiting = states.first(where: { state in
|
||||
if case .waiting = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
self.statusText = "Waiting: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||
self.statusText = "Searching…"
|
||||
return
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||
self.statusText = "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
self.statusText = "Searching…"
|
||||
}
|
||||
|
||||
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
public enum TailscaleNetwork {
|
||||
public static func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
public static func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if self.isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
struct ConnectOptions {
|
||||
var url: String?
|
||||
@@ -299,7 +301,7 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
|
||||
|
||||
private func resolveLocalHost(bind: String?) -> String {
|
||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let tailnetIP = TailscaleNetwork.detectTailnetIPv4()
|
||||
let tailnetIP = detectTailnetIPv4()
|
||||
switch normalized {
|
||||
case "tailnet":
|
||||
return tailnetIP ?? "127.0.0.1"
|
||||
@@ -307,3 +309,45 @@ private func resolveLocalHost(bind: String?) -> String {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
private func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
@@ -103,22 +103,18 @@ public final class OpenClawChatViewModel {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let cutoff = now - (24 * 60 * 60 * 1000)
|
||||
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
var seen = Set<String>()
|
||||
var recent: [OpenClawChatSessionEntry] = []
|
||||
for entry in sorted {
|
||||
guard !seen.contains(entry.key) else { continue }
|
||||
seen.insert(entry.key)
|
||||
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
||||
recent.append(entry)
|
||||
}
|
||||
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
|
||||
// Always show the main session first, even if it hasn't been updated recently.
|
||||
if let main = sorted.first(where: { $0.key == "main" }) {
|
||||
result.append(main)
|
||||
included.insert(main.key)
|
||||
} else {
|
||||
result.append(self.placeholderSession(key: "main"))
|
||||
included.insert("main")
|
||||
}
|
||||
|
||||
for entry in sorted {
|
||||
guard !included.contains(entry.key) else { continue }
|
||||
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
||||
for entry in recent where !included.contains(entry.key) {
|
||||
result.append(entry)
|
||||
included.insert(entry.key)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,93 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
|
||||
public typealias AnyCodable = OpenClawProtocol.AnyCodable
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
///
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||
public let value: Any
|
||||
|
||||
public init(_ value: Any) { self.value = value }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as NSDictionary:
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for (k, v) in dict {
|
||||
guard let key = k as? String else { continue }
|
||||
converted[key] = AnyCodable(v)
|
||||
}
|
||||
try container.encode(converted)
|
||||
case let array as NSArray:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
default:
|
||||
let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
case let (l as Int, r as Int): l == r
|
||||
case let (l as Double, r as Double): l == r
|
||||
case let (l as Bool, r as Bool): l == r
|
||||
case let (l as String, r as String): l == r
|
||||
case (_ as NSNull, _ as NSNull): true
|
||||
case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
|
||||
case let (l as [AnyCodable], r as [AnyCodable]): l == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self.value {
|
||||
case let v as Int:
|
||||
hasher.combine(0); hasher.combine(v)
|
||||
case let v as Double:
|
||||
hasher.combine(1); hasher.combine(v)
|
||||
case let v as Bool:
|
||||
hasher.combine(2); hasher.combine(v)
|
||||
case let v as String:
|
||||
hasher.combine(3); hasher.combine(v)
|
||||
case _ as NSNull:
|
||||
hasher.combine(4)
|
||||
case let v as [String: AnyCodable]:
|
||||
hasher.combine(5)
|
||||
for (k, val) in v.sorted(by: { $0.key < $1.key }) {
|
||||
hasher.combine(k)
|
||||
hasher.combine(val)
|
||||
}
|
||||
case let v as [AnyCodable]:
|
||||
hasher.combine(6)
|
||||
for item in v {
|
||||
hasher.combine(item)
|
||||
}
|
||||
default:
|
||||
hasher.combine(999)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
public enum GatewayDiscoveryStatusText {
|
||||
public static func make(states: [NWBrowser.State], hasBrowsers: Bool) -> String {
|
||||
if states.isEmpty {
|
||||
return hasBrowsers ? "Setup" : "Idle"
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
return "Failed: \(err)"
|
||||
}
|
||||
}
|
||||
|
||||
if let waiting = states.first(where: { state in
|
||||
if case .waiting = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
return "Waiting: \(err)"
|
||||
}
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||
return "Searching…"
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||
return "Setup"
|
||||
}
|
||||
|
||||
return "Searching…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ import OpenClawProtocol
|
||||
import Foundation
|
||||
|
||||
public enum GatewayPayloadDecoding {
|
||||
public static func decode<T: Decodable>(
|
||||
_ payload: OpenClawProtocol.AnyCodable,
|
||||
as _: T.Type = T.self) throws -> T
|
||||
{
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
public static func decode<T: Decodable>(
|
||||
_ payload: AnyCodable,
|
||||
as _: T.Type = T.self) throws -> T
|
||||
@@ -10,6 +18,14 @@ public enum GatewayPayloadDecoding {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
public static func decodeIfPresent<T: Decodable>(
|
||||
_ payload: OpenClawProtocol.AnyCodable?,
|
||||
as _: T.Type = T.self) throws -> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try self.decode(payload, as: T.self)
|
||||
}
|
||||
|
||||
public static func decodeIfPresent<T: Decodable>(
|
||||
_ payload: AnyCodable?,
|
||||
as _: T.Type = T.self) throws -> T?
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
public enum NetworkInterfaces {
|
||||
public static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,26 +52,18 @@ public enum OpenClawKitResources {
|
||||
for candidate in candidates {
|
||||
guard let baseURL = candidate else { continue }
|
||||
|
||||
// SwiftPM often places the resource bundle next to (or near) the test runner bundle,
|
||||
// not inside it. Walk up a few levels and check common container paths.
|
||||
var roots: [URL] = []
|
||||
roots.append(baseURL)
|
||||
roots.append(baseURL.appendingPathComponent("Resources"))
|
||||
roots.append(baseURL.appendingPathComponent("Contents/Resources"))
|
||||
|
||||
var current = baseURL
|
||||
for _ in 0 ..< 5 {
|
||||
current = current.deletingLastPathComponent()
|
||||
roots.append(current)
|
||||
roots.append(current.appendingPathComponent("Resources"))
|
||||
roots.append(current.appendingPathComponent("Contents/Resources"))
|
||||
// Direct path
|
||||
let directURL = baseURL.appendingPathComponent("\(bundleName).bundle")
|
||||
if let bundle = Bundle(url: directURL) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
for root in roots {
|
||||
let bundleURL = root.appendingPathComponent("\(bundleName).bundle")
|
||||
if let bundle = Bundle(url: bundleURL) {
|
||||
return bundle
|
||||
}
|
||||
// Inside Resources/
|
||||
let resourcesURL = baseURL
|
||||
.appendingPathComponent("Resources")
|
||||
.appendingPathComponent("\(bundleName).bundle")
|
||||
if let bundle = Bundle(url: resourcesURL) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public enum PhotoCapture {
|
||||
public static func transcodeJPEGForGateway(
|
||||
rawData: Data,
|
||||
maxWidthPx: Int,
|
||||
quality: Double,
|
||||
maxPayloadBytes: Int = 5 * 1024 * 1024
|
||||
) throws -> (data: Data, widthPx: Int, heightPx: Int) {
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
return try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
maxWidthPx: maxWidthPx,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
///
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
public let value: Any
|
||||
|
||||
public init(_ value: Any) { self.value = value }
|
||||
@@ -17,7 +16,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
@@ -50,46 +51,4 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
case let (l as Int, r as Int): l == r
|
||||
case let (l as Double, r as Double): l == r
|
||||
case let (l as Bool, r as Bool): l == r
|
||||
case let (l as String, r as String): l == r
|
||||
case (_ as NSNull, _ as NSNull): true
|
||||
case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
|
||||
case let (l as [AnyCodable], r as [AnyCodable]): l == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self.value {
|
||||
case let v as Int:
|
||||
hasher.combine(0); hasher.combine(v)
|
||||
case let v as Double:
|
||||
hasher.combine(1); hasher.combine(v)
|
||||
case let v as Bool:
|
||||
hasher.combine(2); hasher.combine(v)
|
||||
case let v as String:
|
||||
hasher.combine(3); hasher.combine(v)
|
||||
case _ as NSNull:
|
||||
hasher.combine(4)
|
||||
case let v as [String: AnyCodable]:
|
||||
hasher.combine(5)
|
||||
for (k, val) in v.sorted(by: { $0.key < $1.key }) {
|
||||
hasher.combine(k)
|
||||
hasher.combine(val)
|
||||
}
|
||||
case let v as [AnyCodable]:
|
||||
hasher.combine(6)
|
||||
for item in v {
|
||||
hasher.combine(item)
|
||||
}
|
||||
default:
|
||||
hasher.combine(999)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,23 +313,6 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Ack reactions">
|
||||
`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
|
||||
|
||||
Resolution order:
|
||||
|
||||
- `channels.discord.accounts.<accountId>.ackReaction`
|
||||
- `channels.discord.ackReaction`
|
||||
- `messages.ackReaction`
|
||||
- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
|
||||
|
||||
Notes:
|
||||
|
||||
- Discord accepts unicode emoji or custom emoji names.
|
||||
- Use `""` to disable the reaction for a channel or account.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Config writes">
|
||||
Channel-initiated config writes are enabled by default.
|
||||
|
||||
@@ -499,30 +482,6 @@ Default gate behavior:
|
||||
| moderation | disabled |
|
||||
| presence | disabled |
|
||||
|
||||
## Components v2 UI
|
||||
|
||||
OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept `components` for custom UI (advanced; requires Carbon component instances), while legacy `embeds` remain available but are not recommended.
|
||||
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex).
|
||||
- Set per account with `channels.discord.accounts.<id>.ui.components.accentColor`.
|
||||
- `embeds` are ignored when components v2 are present.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
ui: {
|
||||
components: {
|
||||
accentColor: "#5865F2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Voice messages
|
||||
|
||||
Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files.
|
||||
@@ -615,7 +574,6 @@ High-signal Discord fields:
|
||||
- media/retry: `mediaMaxMb`, `retry`
|
||||
- actions: `actions.*`
|
||||
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
||||
- UI: `ui.components.accentColor`
|
||||
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
||||
|
||||
## Safety and operations
|
||||
|
||||
@@ -21,7 +21,7 @@ title: grammY
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||
- **Live stream preview:** optional `channels.telegram.streamMode` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
Open questions
|
||||
|
||||
@@ -287,22 +287,6 @@ Available action groups in current Slack tooling:
|
||||
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
|
||||
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
|
||||
|
||||
## Ack reactions
|
||||
|
||||
`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
|
||||
|
||||
Resolution order:
|
||||
|
||||
- `channels.slack.accounts.<accountId>.ackReaction`
|
||||
- `channels.slack.ackReaction`
|
||||
- `messages.ackReaction`
|
||||
- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
|
||||
|
||||
Notes:
|
||||
|
||||
- Slack expects shortcodes (for example `"eyes"`).
|
||||
- Use `""` to disable the reaction for a channel or account.
|
||||
|
||||
## Manifest and scope checklist
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
@@ -221,20 +221,23 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
## Feature reference
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Live stream preview (message edits)">
|
||||
OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives.
|
||||
<Accordion title="Draft streaming in Telegram DMs">
|
||||
OpenClaw can stream partial replies with Telegram draft bubbles (`sendMessageDraft`).
|
||||
|
||||
Requirement:
|
||||
Requirements:
|
||||
|
||||
- `channels.telegram.streamMode` is not `"off"` (default: `"partial"`)
|
||||
- private chat
|
||||
- inbound update includes `message_thread_id`
|
||||
- bot topics are enabled (`getMe().has_topics_enabled`)
|
||||
|
||||
Modes:
|
||||
|
||||
- `off`: no live preview
|
||||
- `partial`: frequent preview updates from partial text
|
||||
- `block`: chunked preview updates using `channels.telegram.draftChunk`
|
||||
- `off`: no draft streaming
|
||||
- `partial`: frequent draft updates from partial text
|
||||
- `block`: chunked draft updates using `channels.telegram.draftChunk`
|
||||
|
||||
`draftChunk` defaults for `streamMode: "block"`:
|
||||
`draftChunk` defaults for block mode:
|
||||
|
||||
- `minChars: 200`
|
||||
- `maxChars: 800`
|
||||
@@ -242,17 +245,13 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
`maxChars` is clamped by `channels.telegram.textChunkLimit`.
|
||||
|
||||
This works in direct chats and groups/topics.
|
||||
Draft streaming is DM-only; groups/channels do not use draft bubbles.
|
||||
|
||||
For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message).
|
||||
|
||||
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
||||
|
||||
`streamMode` is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
|
||||
If you want early real Telegram messages instead of draft updates, use block streaming (`channels.telegram.blockStreaming: true`).
|
||||
|
||||
Telegram-only reasoning stream:
|
||||
|
||||
- `/reasoning stream` sends reasoning to the live preview while generating
|
||||
- `/reasoning stream` sends reasoning to the draft bubble while generating
|
||||
- final answer is sent without reasoning text
|
||||
|
||||
</Accordion>
|
||||
@@ -571,23 +570,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Ack reactions">
|
||||
`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
|
||||
|
||||
Resolution order:
|
||||
|
||||
- `channels.telegram.accounts.<accountId>.ackReaction`
|
||||
- `channels.telegram.ackReaction`
|
||||
- `messages.ackReaction`
|
||||
- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
|
||||
|
||||
Notes:
|
||||
|
||||
- Telegram expects unicode emoji (for example "👀").
|
||||
- Use `""` to disable the reaction for a channel or account.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Config writes from Telegram events and commands">
|
||||
Channel config writes are enabled by default (`configWrites !== false`).
|
||||
|
||||
@@ -721,7 +703,7 @@ Primary reference:
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (live stream preview).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
|
||||
@@ -745,7 +727,7 @@ Telegram-specific high-signal fields:
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
||||
- command/menu: `commands.native`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streamMode` (preview), `draftChunk`, `blockStreaming`
|
||||
- streaming: `streamMode`, `draftChunk`, `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||
|
||||
@@ -26,3 +26,14 @@ The audit warns when multiple DM senders share the main session and recommends *
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy.
|
||||
|
||||
## Skill security
|
||||
|
||||
Community skills (installed from ClawHub) are subject to additional security enforcement:
|
||||
|
||||
- **SKILL.md scanning**: content is scanned for prompt injection patterns, capability inflation, and boundary spoofing before entering the system prompt. Skills with critical findings are blocked from loading.
|
||||
- **Capability enforcement**: community skills must declare `capabilities` (e.g., `shell`, `network`) in frontmatter. Undeclared dangerous tool usage is blocked at runtime.
|
||||
- **Command dispatch gating**: community skills using `command-dispatch: tool` can't dispatch to dangerous tools without the matching capability.
|
||||
- **Audit logging**: all security events are tagged with `category: "security"` and include session context for forensics. View in the web UI Logs tab using the Security filter.
|
||||
|
||||
See `openclaw skills check` for a runtime security overview and `openclaw skills info <name>` for per-skill security details.
|
||||
|
||||
@@ -18,9 +18,163 @@ Related:
|
||||
|
||||
## Commands
|
||||
|
||||
### `openclaw skills list`
|
||||
|
||||
List all skills with status, capabilities, and source.
|
||||
|
||||
```bash
|
||||
openclaw skills list
|
||||
openclaw skills list --eligible
|
||||
openclaw skills info <name>
|
||||
openclaw skills check
|
||||
openclaw skills list # all skills
|
||||
openclaw skills list --eligible # only ready-to-use skills
|
||||
openclaw skills list --json # JSON output
|
||||
openclaw skills list -v # verbose (show missing requirements)
|
||||
```
|
||||
|
||||
Output columns: **Status** (`+ ready`, `x missing`, `x blocked`), **Skill** (name + capability icons), **Description**, **Source**.
|
||||
|
||||
Capability icons displayed next to skill names:
|
||||
|
||||
| Icon | Capability |
|
||||
|------|-----------|
|
||||
| `>_` | `shell` — run shell commands |
|
||||
| `📂` | `filesystem` — read/write files |
|
||||
| `🌐` | `network` — outbound HTTP |
|
||||
| `🔍` | `browser` — browser automation |
|
||||
| `⚡` | `sessions` — cross-session orchestration |
|
||||
|
||||
Skills blocked by security scanning show `x blocked` instead of `x missing`.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Skills (10/12 ready)
|
||||
|
||||
Status Skill Description Source
|
||||
+ ready git-autopush >_ 🌐 Automate git workflows openclaw-managed
|
||||
+ ready think Extended thinking bundled
|
||||
+ ready peekaboo 🔍 ⚡ Browser peek and screenshot bundled
|
||||
x missing summarize >_ Summarize with CLI tool bundled
|
||||
x blocked evil-injector >_ Totally harmless skill openclaw-managed
|
||||
- disabled old-skill Deprecated skill workspace
|
||||
```
|
||||
|
||||
With `-v` (verbose), two extra columns appear — **Scan** and **Missing**:
|
||||
|
||||
```
|
||||
Status Skill Description Source Scan Missing
|
||||
+ ready git-autopush >_ 🌐 Automate git wor... openclaw-managed
|
||||
x missing summarize >_ Summarize with... bundled bins: summarize
|
||||
x blocked evil-injector >_ Totally harmless... openclaw-managed [blocked]
|
||||
+ ready sketch-tool 🌐 >_ Generate sketches openclaw-managed [warn]
|
||||
```
|
||||
|
||||
### `openclaw skills info <name>`
|
||||
|
||||
Show detailed information about a single skill including security status.
|
||||
|
||||
```bash
|
||||
openclaw skills info git-helper
|
||||
openclaw skills info git-helper --json
|
||||
```
|
||||
|
||||
Displays: description, source, file path, capabilities (with descriptions), security scan results, requirements (met/unmet), and install options.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
git-autopush + Ready
|
||||
|
||||
Automate git commit, push, and PR workflows.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/git-autopush/SKILL.md
|
||||
Homepage https://github.com/example/git-autopush
|
||||
Primary env GH_TOKEN
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
🌐 network Make outbound HTTP requests
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
|
||||
Requirements
|
||||
bin git + ok
|
||||
bin gh + ok
|
||||
env GH_TOKEN + ok
|
||||
```
|
||||
|
||||
For a skill with missing requirements:
|
||||
|
||||
```
|
||||
summarize x Missing requirements
|
||||
|
||||
Summarize URLs and files using the summarize CLI.
|
||||
|
||||
Source bundled
|
||||
Path /opt/openclaw/skills/summarize/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
|
||||
Requirements
|
||||
bin summarize x missing
|
||||
|
||||
Install options
|
||||
brew Install summarize (brew install summarize)
|
||||
```
|
||||
|
||||
For a skill blocked by scanning:
|
||||
|
||||
```
|
||||
evil-injector x Blocked (security)
|
||||
|
||||
Totally harmless skill.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/evil-injector/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan [blocked] prompt injection detected
|
||||
```
|
||||
|
||||
### `openclaw skills check`
|
||||
|
||||
Security-focused overview of all skills.
|
||||
|
||||
```bash
|
||||
openclaw skills check
|
||||
openclaw skills check --json
|
||||
```
|
||||
|
||||
Shows: total/eligible/disabled/blocked/missing counts, capabilities requested by community skills, runtime policy restrictions, and scan result summary.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Skills Status Check
|
||||
|
||||
Status Count
|
||||
Total 12
|
||||
Eligible 10
|
||||
Disabled 1
|
||||
Blocked (allowlist) 0
|
||||
Missing requirements 1
|
||||
|
||||
Community skill capabilities
|
||||
Icon Capability # Skills
|
||||
>_ shell 3 git-autopush, deploy-helper, node-runner
|
||||
📂 filesystem 2 git-autopush, file-editor
|
||||
🌐 network 2 git-autopush, sketch-tool
|
||||
|
||||
Scan results
|
||||
Result #
|
||||
Clean 11
|
||||
Warning 1
|
||||
Blocked 0
|
||||
```
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
summary: "Streaming + chunking behavior (block replies, Telegram preview streaming, limits)"
|
||||
summary: "Streaming + chunking behavior (block replies, draft streaming, limits)"
|
||||
read_when:
|
||||
- Explaining how streaming or chunking works on channels
|
||||
- Changing block streaming or channel chunking behavior
|
||||
- Debugging duplicate/early block replies or Telegram preview streaming
|
||||
- Debugging duplicate/early block replies or draft streaming
|
||||
title: "Streaming and Chunking"
|
||||
---
|
||||
|
||||
@@ -12,9 +12,9 @@ title: "Streaming and Chunking"
|
||||
OpenClaw has two separate “streaming” layers:
|
||||
|
||||
- **Block streaming (channels):** emit completed **blocks** as the assistant writes. These are normal channel messages (not token deltas).
|
||||
- **Token-ish streaming (Telegram only):** update a temporary **preview message** with partial text while generating.
|
||||
- **Token-ish streaming (Telegram only):** update a **draft bubble** with partial text while generating; final message is sent at the end.
|
||||
|
||||
There is **no true token-delta streaming** to channel messages today. Telegram preview streaming is the only partial-stream surface.
|
||||
There is **no real token streaming** to external channel messages today. Telegram draft streaming is the only partial-stream surface.
|
||||
|
||||
## Block streaming (channel messages)
|
||||
|
||||
@@ -99,38 +99,37 @@ This maps to:
|
||||
- **No block streaming:** `blockStreamingDefault: "off"` (only final reply).
|
||||
|
||||
**Channel note:** For non-Telegram channels, block streaming is **off unless**
|
||||
`*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview
|
||||
`*.blockStreaming` is explicitly set to `true`. Telegram can stream drafts
|
||||
(`channels.telegram.streamMode`) without block replies.
|
||||
|
||||
Config location reminder: the `blockStreaming*` defaults live under
|
||||
`agents.defaults`, not the root config.
|
||||
|
||||
## Telegram preview streaming (token-ish)
|
||||
## Telegram draft streaming (token-ish)
|
||||
|
||||
Telegram is the only channel with live preview streaming:
|
||||
Telegram is the only channel with draft streaming:
|
||||
|
||||
- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
|
||||
- Uses Bot API `sendMessageDraft` in **private chats with topics**.
|
||||
- `channels.telegram.streamMode: "partial" | "block" | "off"`.
|
||||
- `partial`: preview updates with latest stream text.
|
||||
- `block`: preview updates in chunked blocks (same chunker rules).
|
||||
- `off`: no preview streaming.
|
||||
- Preview chunk config (only for `streamMode: "block"`): `channels.telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
|
||||
- Preview streaming is separate from block streaming.
|
||||
- When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming.
|
||||
- Text-only finals are applied by editing the preview message in place.
|
||||
- Non-text/complex finals fall back to normal final message delivery.
|
||||
- `/reasoning stream` writes reasoning into the live preview (Telegram only).
|
||||
- `partial`: draft updates with the latest stream text.
|
||||
- `block`: draft updates in chunked blocks (same chunker rules).
|
||||
- `off`: no draft streaming.
|
||||
- Draft chunk config (only for `streamMode: "block"`): `channels.telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
|
||||
- Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram channels.
|
||||
- Final reply is still a normal message.
|
||||
- `/reasoning stream` writes reasoning into the draft bubble (Telegram only).
|
||||
|
||||
When draft streaming is active, OpenClaw disables block streaming for that reply to avoid double-streaming.
|
||||
|
||||
```
|
||||
Telegram
|
||||
└─ sendMessage (temporary preview message)
|
||||
├─ streamMode=partial → edit latest text
|
||||
└─ streamMode=block → chunker + edit updates
|
||||
└─ final text-only reply → final edit on same message
|
||||
└─ fallback: cleanup preview + normal final delivery (media/complex)
|
||||
Telegram (private + topics)
|
||||
└─ sendMessageDraft (draft bubble)
|
||||
├─ streamMode=partial → update latest text
|
||||
└─ streamMode=block → chunker updates draft
|
||||
└─ final reply → normal message
|
||||
```
|
||||
|
||||
Legend:
|
||||
|
||||
- `preview message`: temporary Telegram message updated during generation.
|
||||
- `final edit`: in-place edit on the same preview message (text-only).
|
||||
- `sendMessageDraft`: Telegram draft bubble (not a real message).
|
||||
- `final reply`: normal Telegram message send.
|
||||
|
||||
@@ -155,7 +155,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
|
||||
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
|
||||
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
|
||||
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
|
||||
- Draft streaming uses Telegram `sendMessageDraft` (requires private chat topics).
|
||||
- Retry policy: see [Retry policy](/concepts/retry).
|
||||
|
||||
### Discord
|
||||
@@ -211,11 +211,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
textChunkLimit: 2000,
|
||||
chunkMode: "length", // length | newline
|
||||
maxLinesPerMessage: 17,
|
||||
ui: {
|
||||
components: {
|
||||
accentColor: "#5865F2",
|
||||
},
|
||||
},
|
||||
retry: {
|
||||
attempts: 3,
|
||||
minDelayMs: 500,
|
||||
@@ -232,7 +227,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
|
||||
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
||||
|
||||
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
|
||||
|
||||
@@ -1238,8 +1232,6 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
|
||||
### Ack reaction
|
||||
|
||||
- Defaults to active agent's `identity.emoji`, otherwise `"👀"`. Set `""` to disable.
|
||||
- Per-channel overrides: `channels.<channel>.ackReaction`, `channels.<channel>.accounts.<id>.ackReaction`.
|
||||
- Resolution order: account → channel → `messages.ackReaction` → identity fallback.
|
||||
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
|
||||
- `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only).
|
||||
|
||||
|
||||
@@ -119,6 +119,18 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
|
||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
|
||||
## Skill security
|
||||
|
||||
Community skills (installed from ClawHub) are subject to runtime security enforcement:
|
||||
|
||||
- **Capabilities**: Skills declare what system access they need (`shell`, `filesystem`, `network`, `browser`, `sessions`) in `metadata.openclaw.capabilities`. No capabilities = read-only. Community skills that use tools without declaring the matching capability are blocked at runtime.
|
||||
- **SKILL.md scanning**: Content is scanned for prompt injection patterns, capability inflation, and boundary spoofing before entering the system prompt. Skills with critical findings are blocked from loading.
|
||||
- **Trust tiers**: Skills are classified as `builtin`, `community`, or `local`. Only `community` skills (installed from ClawHub) are subject to enforcement — builtin and local skills are exempt. Author verification may be introduced in a future release to provide an additional trust signal.
|
||||
- **Command dispatch gating**: Community skills using `command-dispatch: tool` can't dispatch to dangerous tools without declaring the matching capability.
|
||||
- **Audit logging**: All security events are tagged with `category: "security"` and include session context.
|
||||
|
||||
Use `openclaw skills check` for a security overview and `openclaw skills info <name>` for per-skill details. See [Skills CLI](/cli/skills) for full command reference.
|
||||
|
||||
## Dynamic skills (watcher / remote nodes)
|
||||
|
||||
OpenClaw can refresh the skills list mid-session:
|
||||
@@ -126,7 +138,7 @@ OpenClaw can refresh the skills list mid-session:
|
||||
- **Skills watcher**: changes to `SKILL.md` can update the skills snapshot on the next agent turn.
|
||||
- **Remote nodes**: connecting a macOS node can make macOS-only skills eligible (based on bin probing).
|
||||
|
||||
Treat skill folders as **trusted code** and restrict who can modify them.
|
||||
Restrict who can modify skill folders. Community skills are subject to scanning and capability enforcement (see above), but local and workspace skills are treated as trusted — if someone can write to your skill folders, they can inject instructions into the system prompt.
|
||||
|
||||
## The Threat Model
|
||||
|
||||
|
||||
@@ -81,9 +81,15 @@ A typical skill includes:
|
||||
|
||||
- A `SKILL.md` file with the primary description and usage.
|
||||
- Optional configs, scripts, or supporting files used by the skill.
|
||||
- Metadata such as tags, summary, and install requirements.
|
||||
- Metadata such as tags, summary, install requirements, and capabilities.
|
||||
|
||||
ClawHub uses metadata to power discovery and display skill capabilities.
|
||||
Skills declare what system access they need via `capabilities` in frontmatter
|
||||
(e.g., `shell`, `filesystem`, `network`). OpenClaw enforces these at runtime —
|
||||
community skills that use tools without declaring the matching capability are
|
||||
blocked. See [Skills](/tools/skills#gating-load-time-filters) for the
|
||||
full capability reference.
|
||||
|
||||
ClawHub uses metadata to power discovery and safely expose skill capabilities.
|
||||
The registry also tracks usage signals (such as stars and downloads) to improve
|
||||
ranking and visibility.
|
||||
|
||||
@@ -103,7 +109,17 @@ ClawHub is open by default. Anyone can upload skills, but a GitHub account must
|
||||
be at least one week old to publish. This helps slow down abuse without blocking
|
||||
legitimate contributors.
|
||||
|
||||
Reporting and moderation:
|
||||
### Capabilities and enforcement
|
||||
|
||||
Skills declare `capabilities` in their SKILL.md frontmatter to describe what
|
||||
system access they need. ClawHub displays these to users before install.
|
||||
OpenClaw enforces them at runtime — community skills that attempt to use tools
|
||||
without the matching declared capability are blocked. Skills with no capabilities
|
||||
are treated as read-only (model-only instructions, no tool access).
|
||||
|
||||
Available capabilities: `shell`, `filesystem`, `network`, `browser`, `sessions`.
|
||||
|
||||
### Reporting and moderation
|
||||
|
||||
- Any signed in user can report a skill.
|
||||
- Report reasons are required and recorded.
|
||||
|
||||
@@ -35,11 +35,27 @@ description: A simple skill that says hello.
|
||||
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
|
||||
```
|
||||
|
||||
### 3. Add Tools (Optional)
|
||||
### 3. Declare Capabilities
|
||||
|
||||
If your skill uses system tools, declare them in the `metadata.openclaw.capabilities` field:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy_helper
|
||||
description: Automate deployment workflows.
|
||||
metadata: { "openclaw": { "capabilities": ["shell", "filesystem"] } }
|
||||
---
|
||||
```
|
||||
|
||||
Available capabilities: `shell`, `filesystem`, `network`, `browser`, `sessions`.
|
||||
|
||||
Skills without capabilities are treated as read-only (model-only instructions). Community skills published to ClawHub **must** declare capabilities matching their tool usage — undeclared capabilities are blocked at runtime.
|
||||
|
||||
### 4. Add Tools (Optional)
|
||||
|
||||
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
|
||||
|
||||
### 4. Refresh OpenClaw
|
||||
### 5. Refresh OpenClaw
|
||||
|
||||
Ask your agent to "refresh skills" or restart the gateway. OpenClaw will discover the new directory and index the `SKILL.md`.
|
||||
|
||||
|
||||
@@ -68,12 +68,162 @@ that up as `<workspace>/skills` on the next session.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat third-party skills as **untrusted code**. Read them before enabling.
|
||||
- Treat third-party skills as **untrusted** until you have reviewed them. Runtime enforcement reduces blast radius but does not eliminate risk — read a skill's SKILL.md and declared capabilities before enabling it.
|
||||
- **Capabilities**: Community skills (from ClawHub) must declare `capabilities` in `metadata.openclaw` to describe what system access they need. Skills that don't declare capabilities are treated as read-only. Undeclared dangerous tool usage (e.g., `exec` without `shell` capability) is blocked at runtime for community skills. SKILL.md content is scanned for prompt injection before entering the system prompt. See [Security](/gateway/security#skill-security) for full details.
|
||||
- Local and workspace skills are exempt from capability enforcement. If someone can write to your skill folders, they can inject instructions into the system prompt — restrict who can modify them.
|
||||
- Prefer sandboxed runs for untrusted inputs and risky tools. See [Sandboxing](/gateway/sandboxing).
|
||||
- `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the **host** process
|
||||
for that agent turn (not the sandbox). Keep secrets out of prompts and logs.
|
||||
- For a broader threat model and checklists, see [Security](/gateway/security).
|
||||
|
||||
### Example: correct capability declaration
|
||||
|
||||
This skill runs shell commands and makes HTTP requests. It declares both capabilities, so OpenClaw allows the tool calls:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: git-autopush
|
||||
description: Automate git commit, push, and PR workflows.
|
||||
metadata: { "openclaw": { "capabilities": ["shell", "network"], "requires": { "bins": ["git", "gh"] } } }
|
||||
---
|
||||
|
||||
# git-autopush
|
||||
|
||||
When the user asks to push their changes:
|
||||
1. Run `git add -A && git commit` via the exec tool.
|
||||
2. Run `git push` via the exec tool.
|
||||
3. If requested, create a PR using `gh pr create`.
|
||||
```
|
||||
|
||||
`openclaw skills info git-autopush` shows:
|
||||
|
||||
```
|
||||
git-autopush + Ready
|
||||
|
||||
Automate git commit, push, and PR workflows.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/git-autopush/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
🌐 network Make outbound HTTP requests
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
```
|
||||
|
||||
### Example: missing capability declaration
|
||||
|
||||
This skill runs shell commands but doesn't declare `shell`. OpenClaw blocks the `exec` calls at runtime:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy-helper
|
||||
description: Deploy to production.
|
||||
metadata: { "openclaw": { "requires": { "bins": ["rsync"] } } }
|
||||
---
|
||||
|
||||
# deploy-helper
|
||||
|
||||
When the user asks to deploy, run `rsync -avz ./dist/ user@host:/var/www/` via the exec tool.
|
||||
```
|
||||
|
||||
This skill has no `capabilities` declared, so it's treated as read-only. When the model tries to call `exec` on behalf of this skill's instructions, OpenClaw denies it. `openclaw skills info deploy-helper` shows:
|
||||
|
||||
```
|
||||
deploy-helper + Ready
|
||||
|
||||
Deploy to production.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/deploy-helper/SKILL.md
|
||||
|
||||
Capabilities
|
||||
(none — read-only skill)
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
```
|
||||
|
||||
The fix is to add `"capabilities": ["shell"]` to the metadata.
|
||||
|
||||
### Example: blocked skill (failed security scan)
|
||||
|
||||
If a SKILL.md contains prompt injection patterns, the scan blocks it from loading entirely:
|
||||
|
||||
```
|
||||
evil-injector x Blocked (security)
|
||||
|
||||
Totally harmless skill.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/evil-injector/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan [blocked] prompt injection detected
|
||||
```
|
||||
|
||||
This skill never enters the system prompt. It shows as `x blocked` in `openclaw skills list`.
|
||||
|
||||
### How the model sees skills
|
||||
|
||||
The model does not see the full SKILL.md in the system prompt. It only sees a compact XML listing with three fields per skill: `name`, `description`, and `location` (the file path). The model then uses the `read` tool to load the full SKILL.md on demand when the task matches.
|
||||
|
||||
This is what the model receives in the system prompt:
|
||||
|
||||
```
|
||||
## Skills (mandatory)
|
||||
Before replying: scan <available_skills> <description> entries.
|
||||
- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.
|
||||
- If multiple could apply: choose the most specific one, then read/follow it.
|
||||
- If none clearly apply: do not read any SKILL.md.
|
||||
Constraints: never read more than one skill up front; only read after selecting.
|
||||
|
||||
The following skills provide specialized instructions for specific tasks.
|
||||
Use the read tool to load a skill's file when the task matches its description.
|
||||
When a skill file references a relative path, resolve it against the skill
|
||||
directory (parent of SKILL.md / dirname of the path) and use that absolute
|
||||
path in tool commands.
|
||||
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>git-autopush</name>
|
||||
<description>Automate git commit, push, and PR workflows.</description>
|
||||
<location>/home/user/.openclaw/skills/git-autopush/SKILL.md</location>
|
||||
</skill>
|
||||
<skill>
|
||||
<name>todoist-cli</name>
|
||||
<description>Manage Todoist tasks, projects, and labels.</description>
|
||||
<location>/home/user/.openclaw/skills/todoist-cli/SKILL.md</location>
|
||||
</skill>
|
||||
</available_skills>
|
||||
```
|
||||
|
||||
**What this means for skill authors:**
|
||||
|
||||
- **`description` is your pitch** — it's the only thing the model reads to decide whether to load your skill. Make it specific and task-oriented. "Manage Todoist tasks, projects, and labels from the command line" is better than "Todoist integration."
|
||||
- **`name` must be lowercase `[a-z0-9-]`**, max 64 characters, must match the parent directory name.
|
||||
- **`description` max 1024 characters.**
|
||||
- **Your SKILL.md body is loaded on demand** — it needs to be self-contained instructions the model can follow after reading.
|
||||
- **Relative paths in SKILL.md** are resolved against the skill directory. Use relative paths to reference supporting files.
|
||||
|
||||
The `Skill` type from `@mariozechner/pi-coding-agent`:
|
||||
|
||||
```typescript
|
||||
interface Skill {
|
||||
name: string; // from frontmatter (or parent dir name)
|
||||
description: string; // from frontmatter (required, max 1024 chars)
|
||||
filePath: string; // absolute path to SKILL.md
|
||||
baseDir: string; // parent directory of SKILL.md
|
||||
source: string; // origin identifier
|
||||
disableModelInvocation: boolean; // if true, excluded from prompt
|
||||
}
|
||||
```
|
||||
|
||||
## Format (AgentSkills + Pi-compatible)
|
||||
|
||||
`SKILL.md` must include at least:
|
||||
@@ -116,6 +266,7 @@ metadata:
|
||||
{
|
||||
"requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"], "config": ["browser.enabled"] },
|
||||
"primaryEnv": "GEMINI_API_KEY",
|
||||
"capabilities": ["browser", "network"],
|
||||
},
|
||||
}
|
||||
---
|
||||
@@ -125,8 +276,16 @@ Fields under `metadata.openclaw`:
|
||||
|
||||
- `always: true` — always include the skill (skip other gates).
|
||||
- `emoji` — optional emoji used by the macOS Skills UI.
|
||||
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
|
||||
- `homepage` — optional URL shown as "Website" in the macOS Skills UI.
|
||||
- `os` — optional list of platforms (`darwin`, `linux`, `win32`). If set, the skill is only eligible on those OSes.
|
||||
- `capabilities` — list of system access the skill needs. Used for security enforcement and user-facing display. Allowed values:
|
||||
- `shell` — run shell commands (maps to `exec`, `process`)
|
||||
- `filesystem` — read/write/edit files (maps to `read`, `write`, `edit`, `apply_patch`)
|
||||
- `network` — outbound HTTP (maps to `web_search`, `web_fetch`)
|
||||
- `browser` — browser automation (maps to `browser`, `canvas`)
|
||||
- `sessions` — cross-session orchestration (maps to `sessions_spawn`, `sessions_send`, `subagents`)
|
||||
|
||||
No capabilities declared = read-only, model-only skill. Community skills with undeclared capabilities that attempt to use dangerous tools will be blocked at runtime. See [Security](/gateway/security) for enforcement details.
|
||||
- `requires.bins` — list; each must exist on `PATH`.
|
||||
- `requires.anyBins` — list; at least one must exist on `PATH`.
|
||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
@@ -220,12 +219,26 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
// Close the multipart body
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
const res = await postMultipartFormData({
|
||||
// Combine all parts into a single buffer
|
||||
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
body.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
boundary,
|
||||
parts,
|
||||
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
});
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
@@ -377,12 +376,26 @@ export async function setGroupIconBlueBubbles(
|
||||
// Close multipart body
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
const res = await postMultipartFormData({
|
||||
// Combine into single buffer
|
||||
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
body.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
boundary,
|
||||
parts,
|
||||
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
});
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
export function concatUint8Arrays(parts: Uint8Array[]): Uint8Array {
|
||||
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
body.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function postMultipartFormData(params: {
|
||||
url: string;
|
||||
boundary: string;
|
||||
parts: Uint8Array[];
|
||||
timeoutMs: number;
|
||||
}): Promise<Response> {
|
||||
const body = Buffer.from(concatUint8Arrays(params.parts));
|
||||
return await blueBubblesFetchWithTimeout(
|
||||
params.url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${params.boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
params.timeoutMs,
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
export type BlueBubblesProbe = BaseProbeResult & {
|
||||
export type BlueBubblesProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type BlueBubblesServerInfo = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
@@ -434,6 +433,27 @@ async function resolveFeishuMediaList(params: {
|
||||
* Build media payload for inbound context.
|
||||
* Similar to Discord's buildDiscordMediaPayload().
|
||||
*/
|
||||
function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFeishuMessageEvent(
|
||||
event: FeishuMessageEvent,
|
||||
botOpenId?: string,
|
||||
@@ -746,7 +766,7 @@ export async function handleFeishuMessage(params: {
|
||||
log,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const mediaPayload = buildAgentMediaPayload(mediaList);
|
||||
const mediaPayload = buildFeishuMediaPayload(mediaList);
|
||||
|
||||
// Fetch quoted/replied message content if parentId exists
|
||||
let quotedContent: string | undefined;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
import {
|
||||
resolveFeishuAccount,
|
||||
@@ -308,9 +303,20 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
},
|
||||
outbound: feishuOutbound,
|
||||
status: {
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
port: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
...buildBaseChannelStatusSummary(snapshot),
|
||||
configured: snapshot.configured ?? false,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
port: snapshot.port ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
FeishuConfigSchema,
|
||||
FeishuGroupSchema,
|
||||
@@ -53,7 +52,9 @@ export type FeishuSendResult = {
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
export type FeishuProbeResult = BaseProbeResult<string> & {
|
||||
export type FeishuProbeResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
appId?: string;
|
||||
botName?: string;
|
||||
botOpenId?: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
emptyPluginConfigSchema,
|
||||
isWSL2Sync,
|
||||
type OpenClawPluginApi,
|
||||
@@ -397,19 +396,37 @@ const antigravityPlugin = {
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: "google-antigravity",
|
||||
const profileId = `google-antigravity:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "google-antigravity",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
credentialExtra: { projectId: result.projectId },
|
||||
notes: [
|
||||
"Antigravity uses Google Cloud project quotas.",
|
||||
"Enable Gemini for Google Cloud on your project if requests fail.",
|
||||
],
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
throw err;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
emptyPluginConfigSchema,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
@@ -47,16 +46,34 @@ const geminiCliPlugin = {
|
||||
});
|
||||
|
||||
spin.stop("Gemini CLI OAuth complete");
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: PROVIDER_ID,
|
||||
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
credentialExtra: { projectId: result.projectId },
|
||||
notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."],
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Gemini CLI OAuth failed");
|
||||
await ctx.prompter.note(
|
||||
|
||||
@@ -2,9 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
normalizeWebhookPath,
|
||||
readJsonBodyWithLimit,
|
||||
resolveWebhookPath,
|
||||
requestBodyErrorToText,
|
||||
resolveMentionGatingWithBypass,
|
||||
} from "openclaw/plugin-sdk";
|
||||
@@ -88,6 +86,34 @@ function warnDeprecatedUsersEmailEntries(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "/";
|
||||
}
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||
const trimmedPath = webhookPath?.trim();
|
||||
if (trimmedPath) {
|
||||
return normalizeWebhookPath(trimmedPath);
|
||||
}
|
||||
if (webhookUrl?.trim()) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return normalizeWebhookPath(parsed.pathname || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "/googlechat";
|
||||
}
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
@@ -907,11 +933,7 @@ async function uploadAttachmentForReply(params: {
|
||||
|
||||
export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void {
|
||||
const core = getGoogleChatRuntime();
|
||||
const webhookPath = resolveWebhookPath({
|
||||
webhookPath: options.webhookPath,
|
||||
webhookUrl: options.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
});
|
||||
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
|
||||
if (!webhookPath) {
|
||||
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
||||
return () => {};
|
||||
@@ -946,11 +968,8 @@ export function resolveGoogleChatWebhookPath(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}): string {
|
||||
return (
|
||||
resolveWebhookPath({
|
||||
webhookPath: params.account.config.webhookPath,
|
||||
webhookUrl: params.account.config.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
}) ?? "/googlechat"
|
||||
resolveWebhookPath(params.account.config.webhookPath, params.account.config.webhookUrl) ??
|
||||
"/googlechat"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
@@ -84,10 +83,12 @@ export type IrcInboundMessage = {
|
||||
isGroup: boolean;
|
||||
};
|
||||
|
||||
export type IrcProbe = BaseProbeResult<string> & {
|
||||
export type IrcProbe = {
|
||||
ok: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -3,33 +3,29 @@ import type { CoreConfig } from "./types.js";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
|
||||
function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string {
|
||||
return value.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
? value.slice(prefix.length).trim()
|
||||
: value;
|
||||
}
|
||||
|
||||
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
|
||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:");
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:");
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:");
|
||||
|
||||
const lower = roomId.toLowerCase();
|
||||
if (lower.startsWith("matrix:")) {
|
||||
roomId = roomId.slice("matrix:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("channel:")) {
|
||||
roomId = roomId.slice("channel:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
return resolveMatrixRoomConfig({
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
const resolved = resolveMatrixRoomConfigForGroup(params);
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) {
|
||||
return false;
|
||||
@@ -47,6 +43,27 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
export function resolveMatrixGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const resolved = resolveMatrixRoomConfigForGroup(params);
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
const lower = roomId.toLowerCase();
|
||||
if (lower.startsWith("matrix:")) {
|
||||
roomId = roomId.slice("matrix:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("channel:")) {
|
||||
roomId = roomId.slice("channel:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
return resolved?.tools;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { createMatrixClient, isBunRuntime } from "./client.js";
|
||||
|
||||
export type MatrixProbe = BaseProbeResult & {
|
||||
export type MatrixProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs: number;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
logInboundDrop,
|
||||
@@ -180,6 +179,27 @@ function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]):
|
||||
return `${tag} (${mediaList.length} ${suffix})`;
|
||||
}
|
||||
|
||||
function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMattermostWsUrl(baseUrl: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
@@ -630,7 +650,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
}
|
||||
|
||||
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
const mediaPayload = buildAgentMediaPayload(mediaList);
|
||||
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
||||
const inboundHistory =
|
||||
historyKey && historyLimit > 0
|
||||
? (channelHistories.get(historyKey) ?? []).map((entry) => ({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
|
||||
|
||||
export type MattermostProbe = BaseProbeResult & {
|
||||
export type MattermostProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
MSTeamsConfigSchema,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
@@ -417,9 +415,20 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
outbound: msteamsOutbound,
|
||||
status: {
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
port: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
...buildBaseChannelStatusSummary(snapshot),
|
||||
configured: snapshot.configured ?? false,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
port: snapshot.port ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
|
||||
@@ -63,32 +63,6 @@ function looksLikeGuid(value: string): boolean {
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(value);
|
||||
}
|
||||
|
||||
async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{
|
||||
appId: string;
|
||||
appPassword: string;
|
||||
tenantId: string;
|
||||
}> {
|
||||
const appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
return { appId, appPassword, tenantId };
|
||||
}
|
||||
|
||||
async function promptMSTeamsAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
@@ -277,7 +251,24 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
@@ -285,10 +276,44 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (appId && appPassword && tenantId) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type ProbeMSTeamsResult = BaseProbeResult<string> & {
|
||||
export type ProbeMSTeamsResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
appId?: string;
|
||||
graph?: {
|
||||
ok: boolean;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatPairingApproveHint,
|
||||
type ChannelPlugin,
|
||||
@@ -159,8 +157,28 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
||||
},
|
||||
|
||||
status: {
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: "nostr",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime" as const,
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
publicKey: snapshot.publicKey ?? null,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
@@ -252,11 +249,35 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("signal", accounts),
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: "signal",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime",
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
...buildBaseChannelStatusSummary(snapshot),
|
||||
configured: snapshot.configured ?? false,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
|
||||
import { ensureUrbitChannelOpen } from "./channel-ops.js";
|
||||
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
|
||||
import { urbitFetch } from "./fetch.js";
|
||||
|
||||
@@ -70,35 +70,69 @@ export class UrbitChannelClient {
|
||||
|
||||
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
|
||||
await this.open();
|
||||
const channelId = this.channelId;
|
||||
if (!channelId) {
|
||||
throw new Error("Channel not opened");
|
||||
}
|
||||
return await pokeUrbitChannel(
|
||||
{
|
||||
baseUrl: this.baseUrl,
|
||||
cookie: this.cookie,
|
||||
ship: this.ship,
|
||||
channelId,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const pokeId = Date.now();
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
mark: params.mark,
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.baseUrl,
|
||||
path: this.channelPath,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
},
|
||||
{ ...params, auditContext: "tlon-urbit-poke" },
|
||||
);
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-poke",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
return pokeId;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async scry(path: string): Promise<unknown> {
|
||||
return await scryUrbitPath(
|
||||
{
|
||||
baseUrl: this.baseUrl,
|
||||
cookie: this.cookie,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const scryPath = `/~/scry${path}`;
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.baseUrl,
|
||||
path: scryPath,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Cookie: this.cookie },
|
||||
},
|
||||
{ path, auditContext: "tlon-urbit-scry" },
|
||||
);
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-scry",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async getOurName(): Promise<string> {
|
||||
|
||||
@@ -12,78 +12,6 @@ export type UrbitChannelDeps = {
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
export async function pokeUrbitChannel(
|
||||
deps: UrbitChannelDeps,
|
||||
params: { app: string; mark: string; json: unknown; auditContext: string },
|
||||
): Promise<number> {
|
||||
const pokeId = Date.now();
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: deps.ship,
|
||||
app: params.app,
|
||||
mark: params.mark,
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: deps.baseUrl,
|
||||
path: `/~/channel/${deps.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: deps.cookie,
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
},
|
||||
ssrfPolicy: deps.ssrfPolicy,
|
||||
lookupFn: deps.lookupFn,
|
||||
fetchImpl: deps.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: params.auditContext,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
return pokeId;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function scryUrbitPath(
|
||||
deps: Pick<UrbitChannelDeps, "baseUrl" | "cookie" | "ssrfPolicy" | "lookupFn" | "fetchImpl">,
|
||||
params: { path: string; auditContext: string },
|
||||
): Promise<unknown> {
|
||||
const scryPath = `/~/scry${params.path}`;
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: deps.baseUrl,
|
||||
path: scryPath,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Cookie: deps.cookie },
|
||||
},
|
||||
ssrfPolicy: deps.ssrfPolicy,
|
||||
lookupFn: deps.lookupFn,
|
||||
fetchImpl: deps.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: params.auditContext,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${params.path}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUrbitChannel(
|
||||
deps: UrbitChannelDeps,
|
||||
params: { body: unknown; auditContext: string },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { Readable } from "node:stream";
|
||||
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
|
||||
import { ensureUrbitChannelOpen } from "./channel-ops.js";
|
||||
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
|
||||
import { urbitFetch } from "./fetch.js";
|
||||
|
||||
@@ -290,31 +290,71 @@ export class UrbitSSEClient {
|
||||
}
|
||||
|
||||
async poke(params: { app: string; mark: string; json: unknown }) {
|
||||
return await pokeUrbitChannel(
|
||||
{
|
||||
baseUrl: this.url,
|
||||
cookie: this.cookie,
|
||||
ship: this.ship,
|
||||
channelId: this.channelId,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const pokeId = Date.now();
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: this.ship,
|
||||
app: params.app,
|
||||
mark: params.mark,
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
},
|
||||
{ ...params, auditContext: "tlon-urbit-poke" },
|
||||
);
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-poke",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
return pokeId;
|
||||
}
|
||||
|
||||
async scry(path: string) {
|
||||
return await scryUrbitPath(
|
||||
{
|
||||
baseUrl: this.url,
|
||||
cookie: this.cookie,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/scry${path}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
},
|
||||
{ path, auditContext: "tlon-urbit-scry" },
|
||||
);
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-scry",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async attemptReconnect() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient } from "@twurple/chat";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
@@ -7,7 +6,9 @@ import { normalizeToken } from "./utils/twitch.js";
|
||||
/**
|
||||
* Result of probing a Twitch account
|
||||
*/
|
||||
export type ProbeTwitchResult = BaseProbeResult<string> & {
|
||||
export type ProbeTwitchResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
username?: string;
|
||||
elapsedMs: number;
|
||||
connected?: boolean;
|
||||
|
||||
@@ -2,9 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
normalizeWebhookPath,
|
||||
readJsonBodyWithLimit,
|
||||
resolveWebhookPath,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
@@ -82,6 +80,34 @@ type WebhookTarget = {
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "/";
|
||||
}
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||
const trimmedPath = webhookPath?.trim();
|
||||
if (trimmedPath) {
|
||||
return normalizeWebhookPath(trimmedPath);
|
||||
}
|
||||
if (webhookUrl?.trim()) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return normalizeWebhookPath(parsed.pathname || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
@@ -674,7 +700,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
||||
throw new Error("Zalo webhook secret must be 8-256 characters");
|
||||
}
|
||||
|
||||
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
|
||||
const path = resolveWebhookPath(webhookPath, webhookUrl);
|
||||
if (!path) {
|
||||
throw new Error("Zalo webhookPath could not be derived");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
|
||||
|
||||
export type ZaloProbeResult = BaseProbeResult<string> & {
|
||||
export type ZaloProbeResult = {
|
||||
ok: boolean;
|
||||
bot?: ZaloBotInfo;
|
||||
error?: string;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { type BaseTokenResolution, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
|
||||
export type ZaloTokenResolution = BaseTokenResolution & {
|
||||
export type ZaloTokenResolution = {
|
||||
token: string;
|
||||
source: "env" | "config" | "configFile" | "none";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import type { ZcaUserInfo } from "./types.js";
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
|
||||
export type ZalouserProbeResult = BaseProbeResult<string> & {
|
||||
export interface ZalouserProbeResult {
|
||||
ok: boolean;
|
||||
user?: ZcaUserInfo;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function probeZalouser(
|
||||
profile: string,
|
||||
|
||||
@@ -39,9 +39,8 @@
|
||||
"android:install": "cd apps/android && ./gradlew :app:installDebug",
|
||||
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity",
|
||||
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
|
||||
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts && node scripts/copy-pnpm-lock-to-dist.mjs",
|
||||
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"build:runtime": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts && node scripts/copy-pnpm-lock-to-dist.mjs",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint",
|
||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// Why is this in `dist/`?
|
||||
//
|
||||
// `npm pack`/`npm publish` will not include a top-level `pnpm-lock.yaml` in the
|
||||
// tarball, even if it is listed in `package.json#files`.
|
||||
//
|
||||
// Packagers (eg Nix) still want the lockfile to deterministically reproduce the
|
||||
// runtime `node_modules` for a published OpenClaw version.
|
||||
//
|
||||
// Workaround: copy it under `dist/` (which is already published) so it ships as
|
||||
// `dist/pnpm-lock.yaml`.
|
||||
const repoRoot = process.cwd();
|
||||
const src = path.join(repoRoot, "pnpm-lock.yaml");
|
||||
const outDir = path.join(repoRoot, "dist");
|
||||
const out = path.join(outDir, "pnpm-lock.yaml");
|
||||
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
await fs.copyFile(src, out);
|
||||
@@ -13,7 +13,6 @@ const requiredPathGroups = [
|
||||
"dist/plugin-sdk/index.js",
|
||||
"dist/plugin-sdk/index.d.ts",
|
||||
"dist/build-info.json",
|
||||
"dist/pnpm-lock.yaml",
|
||||
];
|
||||
const forbiddenPrefixes = ["dist/OpenClaw.app/"];
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const unitIsolatedFilesRaw = [
|
||||
"src/browser/server-context.remote-tab-ops.test.ts",
|
||||
"src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts",
|
||||
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
|
||||
"src/imessage/monitor.shutdown.unhandled-rejection.test.ts",
|
||||
"src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts",
|
||||
];
|
||||
const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file));
|
||||
|
||||
|
||||
@@ -16,12 +16,6 @@ Use the `message` tool. No provider-specific `discord` tool exposed to the agent
|
||||
- Prefer explicit ids: `guildId`, `channelId`, `messageId`, `userId`.
|
||||
- Multi-account: optional `accountId`.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Avoid Markdown tables in outbound Discord messages.
|
||||
- Mention users as `<@USER_ID>`.
|
||||
- Prefer Discord components v2 (`components`) for rich UI; use legacy `embeds` only when you must.
|
||||
|
||||
## Targets
|
||||
|
||||
- Send-like actions: `to: "channel:<id>"` or `to: "user:<id>"`.
|
||||
@@ -53,37 +47,6 @@ Send with media:
|
||||
}
|
||||
```
|
||||
|
||||
- Optional `silent: true` to suppress Discord notifications.
|
||||
|
||||
Send with components v2 (recommended for rich UI):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "send",
|
||||
"channel": "discord",
|
||||
"to": "channel:123",
|
||||
"message": "Status update",
|
||||
"components": "[Carbon v2 components]"
|
||||
}
|
||||
```
|
||||
|
||||
- `components` expects Carbon component instances (Container, TextDisplay, etc.) from JS/TS integrations.
|
||||
- Do not combine `components` with `embeds` (Discord rejects v2 + embeds).
|
||||
|
||||
Legacy embeds (not recommended):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "send",
|
||||
"channel": "discord",
|
||||
"to": "channel:123",
|
||||
"message": "Status update",
|
||||
"embeds": [{ "title": "Legacy", "description": "Embeds are legacy." }]
|
||||
}
|
||||
```
|
||||
|
||||
- `embeds` are ignored when components v2 are present.
|
||||
|
||||
React:
|
||||
|
||||
```json
|
||||
@@ -194,4 +157,4 @@ Presence (often gated):
|
||||
|
||||
- Short, conversational, low ceremony.
|
||||
- No markdown tables.
|
||||
- Mention users as `<@USER_ID>`.
|
||||
- Prefer multiple small replies over one wall of text.
|
||||
|
||||
@@ -2,11 +2,12 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
|
||||
describe("resolveOpenClawAgentDir", () => {
|
||||
const env = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]);
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tempStateDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -14,7 +15,21 @@ describe("resolveOpenClawAgentDir", () => {
|
||||
await fs.rm(tempStateDir, { recursive: true, force: true });
|
||||
tempStateDir = null;
|
||||
}
|
||||
env.restore();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to the multi-agent path when no overrides are set", async () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentDir,
|
||||
resolveEffectiveModelFallbacks,
|
||||
resolveAgentModelFallbacksOverride,
|
||||
resolveAgentModelPrimary,
|
||||
resolveAgentWorkspaceDir,
|
||||
@@ -113,60 +112,6 @@ describe("resolveAgentConfig", () => {
|
||||
},
|
||||
};
|
||||
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
|
||||
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: false,
|
||||
}),
|
||||
).toEqual(["openai/gpt-5.2"]);
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: true,
|
||||
}),
|
||||
).toEqual(["openai/gpt-5.2"]);
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg: cfgNoOverride,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: true,
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
const cfgInheritDefaults: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
fallbacks: ["openai/gpt-4.1"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "linus",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg: cfgInheritDefaults,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: true,
|
||||
}),
|
||||
).toEqual(["openai/gpt-4.1"]);
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg: cfgDisable,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: true,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return agent-specific sandbox config", () => {
|
||||
|
||||
@@ -163,22 +163,6 @@ export function resolveAgentModelFallbacksOverride(
|
||||
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
|
||||
}
|
||||
|
||||
export function resolveEffectiveModelFallbacks(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
hasSessionModelOverride: boolean;
|
||||
}): string[] | undefined {
|
||||
const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId);
|
||||
if (!params.hasSessionModelOverride) {
|
||||
return agentFallbacksOverride;
|
||||
}
|
||||
const defaultFallbacks =
|
||||
typeof params.cfg.agents?.defaults?.model === "object"
|
||||
? (params.cfg.agents.defaults.model.fallbacks ?? [])
|
||||
: [];
|
||||
return agentFallbacksOverride ?? defaultFallbacks;
|
||||
}
|
||||
|
||||
export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export type AnnounceIdFromChildRunParams = {
|
||||
childSessionKey: string;
|
||||
childRunId: string;
|
||||
};
|
||||
|
||||
export function buildAnnounceIdFromChildRun(params: AnnounceIdFromChildRunParams): string {
|
||||
return `v1:${params.childSessionKey}:${params.childRunId}`;
|
||||
}
|
||||
|
||||
export function buildAnnounceIdempotencyKey(announceId: string): string {
|
||||
return `announce:${announceId}`;
|
||||
}
|
||||
|
||||
export function resolveQueueAnnounceId(params: {
|
||||
announceId?: string;
|
||||
sessionKey: string;
|
||||
enqueuedAt: number;
|
||||
}): string {
|
||||
const announceId = params.announceId?.trim();
|
||||
if (announceId) {
|
||||
return announceId;
|
||||
}
|
||||
// Backward-compatible fallback for queue items that predate announceId.
|
||||
return `legacy:${params.sessionKey}:${params.enqueuedAt}`;
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
import {
|
||||
ANTHROPIC_CFG,
|
||||
ANTHROPIC_STORE,
|
||||
} from "./auth-profiles.resolve-auth-profile-order.fixtures.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const store = ANTHROPIC_STORE;
|
||||
const cfg = ANTHROPIC_CFG;
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "api_key" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("does not prioritize lastGood over round-robin ordering", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
|
||||
export const ANTHROPIC_STORE: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ANTHROPIC_CFG = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "api_key" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,30 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const _store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const _cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "api_key" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("normalizes z.ai aliases in auth.order", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
|
||||
@@ -2,6 +2,30 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const _store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const _cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "api_key" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("orders by lastUsed when no explicit order exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./auth-profiles.js";
|
||||
import {
|
||||
ANTHROPIC_CFG,
|
||||
ANTHROPIC_STORE,
|
||||
} from "./auth-profiles.resolve-auth-profile-order.fixtures.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const store = ANTHROPIC_STORE;
|
||||
const cfg = ANTHROPIC_CFG;
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "api_key" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("uses stored profiles when no config exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
|
||||
@@ -1,100 +1,56 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import {
|
||||
addSession,
|
||||
appendOutput,
|
||||
markExited,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry.js";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
function createBackgroundSession(id: string): ProcessSession {
|
||||
return {
|
||||
id,
|
||||
command: "test",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
}
|
||||
const sleepAndEcho =
|
||||
process.platform === "win32"
|
||||
? "Start-Sleep -Milliseconds 300; Write-Output done"
|
||||
: "sleep 0.3; echo done";
|
||||
|
||||
test("process poll waits for completion when timeout is provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const processTool = createProcessTool();
|
||||
const sessionId = "sess";
|
||||
const session = createBackgroundSession(sessionId);
|
||||
addSession(session);
|
||||
const execTool = createExecTool();
|
||||
const processTool = createProcessTool();
|
||||
const started = Date.now();
|
||||
const run = await execTool.execute("toolcall", {
|
||||
command: sleepAndEcho,
|
||||
background: true,
|
||||
});
|
||||
expect(run.details.status).toBe("running");
|
||||
const sessionId = run.details.sessionId;
|
||||
|
||||
setTimeout(() => {
|
||||
appendOutput(session, "stdout", "done\n");
|
||||
markExited(session, 0, null, "completed");
|
||||
}, 10);
|
||||
|
||||
const pollPromise = processTool.execute("toolcall", {
|
||||
action: "poll",
|
||||
sessionId,
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
void pollPromise.finally(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const poll = await pollPromise;
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain("done");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
const poll = await processTool.execute("toolcall", {
|
||||
action: "poll",
|
||||
sessionId,
|
||||
timeout: 2000,
|
||||
});
|
||||
const elapsedMs = Date.now() - started;
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain("done");
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
test("process poll accepts string timeout values", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const processTool = createProcessTool();
|
||||
const sessionId = "sess-2";
|
||||
const session = createBackgroundSession(sessionId);
|
||||
addSession(session);
|
||||
setTimeout(() => {
|
||||
appendOutput(session, "stdout", "done\n");
|
||||
markExited(session, 0, null, "completed");
|
||||
}, 10);
|
||||
const execTool = createExecTool();
|
||||
const processTool = createProcessTool();
|
||||
const run = await execTool.execute("toolcall", {
|
||||
command: sleepAndEcho,
|
||||
background: true,
|
||||
});
|
||||
expect(run.details.status).toBe("running");
|
||||
const sessionId = run.details.sessionId;
|
||||
|
||||
const pollPromise = processTool.execute("toolcall", {
|
||||
action: "poll",
|
||||
sessionId,
|
||||
timeout: "2000",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
const poll = await pollPromise;
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain("done");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
const poll = await processTool.execute("toolcall", {
|
||||
action: "poll",
|
||||
sessionId,
|
||||
timeout: "2000",
|
||||
});
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
expect(details.status).toBe("completed");
|
||||
expect(details.aggregated ?? "").toContain("done");
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyConfiguredContextWindows } from "./context.js";
|
||||
|
||||
describe("applyConfiguredContextWindows", () => {
|
||||
it("overrides discovered cache values with explicit models.providers contextWindow", () => {
|
||||
const cache = new Map<string, number>([["anthropic/claude-opus-4-6", 1_000_000]]);
|
||||
applyConfiguredContextWindows({
|
||||
cache,
|
||||
modelsConfig: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
models: [{ id: "anthropic/claude-opus-4-6", contextWindow: 200_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(cache.get("anthropic/claude-opus-4-6")).toBe(200_000);
|
||||
});
|
||||
|
||||
it("adds config-only model context windows and ignores invalid entries", () => {
|
||||
const cache = new Map<string, number>();
|
||||
applyConfiguredContextWindows({
|
||||
cache,
|
||||
modelsConfig: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
models: [
|
||||
{ id: "custom/model", contextWindow: 150_000 },
|
||||
{ id: "bad/model", contextWindow: 0 },
|
||||
{ id: "", contextWindow: 300_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(cache.get("custom/model")).toBe(150_000);
|
||||
expect(cache.has("bad/model")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,52 +6,13 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
|
||||
type ModelEntry = { id: string; contextWindow?: number };
|
||||
type ConfigModelEntry = { id?: string; contextWindow?: number };
|
||||
type ProviderConfigEntry = { models?: ConfigModelEntry[] };
|
||||
type ModelsConfig = { providers?: Record<string, ProviderConfigEntry | undefined> };
|
||||
|
||||
export function applyConfiguredContextWindows(params: {
|
||||
cache: Map<string, number>;
|
||||
modelsConfig: ModelsConfig | undefined;
|
||||
}) {
|
||||
const providers = params.modelsConfig?.providers;
|
||||
if (!providers || typeof providers !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const provider of Object.values(providers)) {
|
||||
if (!Array.isArray(provider?.models)) {
|
||||
continue;
|
||||
}
|
||||
for (const model of provider.models) {
|
||||
const modelId = typeof model?.id === "string" ? model.id : undefined;
|
||||
const contextWindow =
|
||||
typeof model?.contextWindow === "number" ? model.contextWindow : undefined;
|
||||
if (!modelId || !contextWindow || contextWindow <= 0) {
|
||||
continue;
|
||||
}
|
||||
params.cache.set(modelId, contextWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MODEL_CACHE = new Map<string, number>();
|
||||
const loadPromise = (async () => {
|
||||
let cfg: ReturnType<typeof loadConfig> | undefined;
|
||||
try {
|
||||
cfg = loadConfig();
|
||||
} catch {
|
||||
// If config can't be loaded, leave cache empty.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
} catch {
|
||||
// Continue with best-effort discovery/overrides.
|
||||
}
|
||||
|
||||
try {
|
||||
const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js");
|
||||
const cfg = loadConfig();
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
||||
@@ -65,16 +26,9 @@ const loadPromise = (async () => {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If model discovery fails, continue with config overrides only.
|
||||
// If pi-ai isn't available, leave cache empty; lookup will fall back.
|
||||
}
|
||||
|
||||
applyConfiguredContextWindows({
|
||||
cache: MODEL_CACHE,
|
||||
modelsConfig: cfg.models as ModelsConfig | undefined,
|
||||
});
|
||||
})().catch(() => {
|
||||
// Keep lookup best-effort.
|
||||
});
|
||||
})();
|
||||
|
||||
export function lookupContextTokens(modelId?: string): number | undefined {
|
||||
if (!modelId) {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveAckReaction } from "./identity.js";
|
||||
|
||||
describe("resolveAckReaction", () => {
|
||||
it("prefers account-level overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
messages: { ackReaction: "👀" },
|
||||
agents: { list: [{ id: "main", identity: { emoji: "✅" } }] },
|
||||
channels: {
|
||||
slack: {
|
||||
ackReaction: "eyes",
|
||||
accounts: {
|
||||
acct1: { ackReaction: " party_parrot " },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "acct1" })).toBe(
|
||||
"party_parrot",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to channel-level overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
messages: { ackReaction: "👀" },
|
||||
agents: { list: [{ id: "main", identity: { emoji: "✅" } }] },
|
||||
channels: {
|
||||
slack: {
|
||||
ackReaction: "eyes",
|
||||
accounts: {
|
||||
acct1: { ackReaction: "party_parrot" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "missing" })).toBe(
|
||||
"eyes",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the global ackReaction when channel overrides are missing", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
messages: { ackReaction: "✅" },
|
||||
agents: { list: [{ id: "main", identity: { emoji: "😺" } }] },
|
||||
};
|
||||
|
||||
expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("✅");
|
||||
});
|
||||
|
||||
it("falls back to the agent identity emoji when global config is unset", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { list: [{ id: "main", identity: { emoji: "🔥" } }] },
|
||||
};
|
||||
|
||||
expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("🔥");
|
||||
});
|
||||
|
||||
it("returns the default emoji when no config is present", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
expect(resolveAckReaction(cfg, "main")).toBe("👀");
|
||||
});
|
||||
|
||||
it("allows empty strings to disable reactions", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
messages: { ackReaction: "👀" },
|
||||
channels: {
|
||||
telegram: {
|
||||
ackReaction: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAckReaction(cfg, "main", { channel: "telegram" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -10,37 +10,11 @@ export function resolveAgentIdentity(
|
||||
return resolveAgentConfig(cfg, agentId)?.identity;
|
||||
}
|
||||
|
||||
export function resolveAckReaction(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { channel?: string; accountId?: string },
|
||||
): string {
|
||||
// L1: Channel account level
|
||||
if (opts?.channel && opts?.accountId) {
|
||||
const channelCfg = getChannelConfig(cfg, opts.channel);
|
||||
const accounts = channelCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
const accountReaction = accounts?.[opts.accountId]?.ackReaction as string | undefined;
|
||||
if (accountReaction !== undefined) {
|
||||
return accountReaction.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// L2: Channel level
|
||||
if (opts?.channel) {
|
||||
const channelCfg = getChannelConfig(cfg, opts.channel);
|
||||
const channelReaction = channelCfg?.ackReaction as string | undefined;
|
||||
if (channelReaction !== undefined) {
|
||||
return channelReaction.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// L3: Global messages level
|
||||
export function resolveAckReaction(cfg: OpenClawConfig, agentId: string): string {
|
||||
const configured = cfg.messages?.ackReaction;
|
||||
if (configured !== undefined) {
|
||||
return configured.trim();
|
||||
}
|
||||
|
||||
// L4: Agent identity emoji fallback
|
||||
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
|
||||
return emoji || DEFAULT_ACK_REACTION;
|
||||
}
|
||||
|
||||
@@ -47,33 +47,6 @@ const MINIMAX_API_COST = {
|
||||
cacheWrite: 10,
|
||||
};
|
||||
|
||||
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
|
||||
|
||||
function buildMinimaxModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ProviderModelConfig["input"];
|
||||
}): ProviderModelConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxTextModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
}): ProviderModelConfig {
|
||||
return buildMinimaxModel({ ...params, input: ["text"] });
|
||||
}
|
||||
|
||||
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
|
||||
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
|
||||
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
@@ -416,32 +389,51 @@ function buildMinimaxProvider(): ProviderConfig {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
buildMinimaxTextModel({
|
||||
{
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.1-lightning",
|
||||
name: "MiniMax M2.1 Lightning",
|
||||
reasoning: false,
|
||||
}),
|
||||
buildMinimaxModel({
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5-Lightning",
|
||||
name: "MiniMax M2.5 Lightning",
|
||||
reasoning: true,
|
||||
}),
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -451,16 +443,24 @@ function buildMinimaxPortalProvider(): ProviderConfig {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
buildMinimaxTextModel({
|
||||
{
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,37 +33,21 @@ vi.mock("../gateway/call.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
type GatewayCall = { method: string; params?: Record<string, unknown> };
|
||||
|
||||
async function getGatewayCalls(): Promise<GatewayCall[]> {
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
return (callGateway as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call) => call[0] as GatewayCall,
|
||||
);
|
||||
}
|
||||
|
||||
function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
|
||||
for (let i = calls.length - 1; i >= 0; i -= 1) {
|
||||
const call = calls[i];
|
||||
if (call && predicate(call)) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("sessions_spawn thinking defaults", () => {
|
||||
it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => {
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
|
||||
const result = await tool.execute("call-1", { task: "hello" });
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
|
||||
const calls = await getGatewayCalls();
|
||||
const agentCall = findLastCall(calls, (call) => call.method === "agent");
|
||||
const thinkingPatch = findLastCall(
|
||||
calls,
|
||||
(call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined,
|
||||
);
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
const calls = (callGateway as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
|
||||
const agentCall = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "agent");
|
||||
const thinkingPatch = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
|
||||
|
||||
expect(agentCall?.params?.thinking).toBe("high");
|
||||
expect(thinkingPatch?.params?.thinkingLevel).toBe("high");
|
||||
@@ -74,12 +58,15 @@ describe("sessions_spawn thinking defaults", () => {
|
||||
const result = await tool.execute("call-2", { task: "hello", thinking: "low" });
|
||||
expect(result.details).toMatchObject({ status: "accepted" });
|
||||
|
||||
const calls = await getGatewayCalls();
|
||||
const agentCall = findLastCall(calls, (call) => call.method === "agent");
|
||||
const thinkingPatch = findLastCall(
|
||||
calls,
|
||||
(call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined,
|
||||
);
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
const calls = (callGateway as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||
|
||||
const agentCall = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "agent");
|
||||
const thinkingPatch = calls
|
||||
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
|
||||
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
|
||||
|
||||
expect(agentCall?.params?.thinking).toBe("low");
|
||||
expect(thinkingPatch?.params?.thinkingLevel).toBe("low");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user