Compare commits

..
Author SHA1 Message Date
Peter Steinberger 990a778d00 docs: update changelog for #166 2026-01-04 06:09:33 +01:00
Peter Steinberger 56274eb76e style: fix lint formatting 2026-01-04 06:09:04 +01:00
Peter Steinberger d9987a65b2 fix(macos): bridge wizard option values 2026-01-04 06:04:32 +01:00
Peter Steinberger b59c9c38c2 chore: update Peekaboo submodule 2026-01-04 06:04:30 +01:00
Tu Nombre RealandClaude Opus 4.5 d32b77b330 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:57:08 +01:00
Peter Steinberger add1301a51 feat(sessions): add agent-to-agent post step 2026-01-04 03:04:55 +01:00
Peter Steinberger 052cec70ae fix: render thinking text in italics 2026-01-04 02:44:11 +01:00
Peter Steinberger 534de59f7c docs: clarify menu bar sessionKey usage 2026-01-04 02:10:22 +01:00
Peter Steinberger 1d06164e18 refactor: use per-send run ids for gateway agent 2026-01-04 02:08:52 +01:00
Peter Steinberger fe67073b74 fix: avoid sessions_send timeouts 2026-01-04 01:52:01 +01:00
Peter Steinberger cbf41859aa test: relax cron default scheduler timeout 2026-01-04 01:45:50 +01:00
Cash Williams 12186e14a9 fix(android): handle unreachable gateway gracefully
Previously, if the gateway was unreachable (wrong IP, offline, etc.),
the Android app would crash with an unhandled socket exception.

Changes:
- Wrap socket.connect() in try/catch to handle connection failures
- Return PairResult with error message instead of crashing
- Display actual error message in status text instead of generic 'pairing required'

This gives users useful feedback like 'Connection refused' or
'Network is unreachable' instead of a crash.
2026-01-04 01:44:43 +01:00
Peter Steinberger fbaa109a3a fix: stabilize lint and test timeouts 2026-01-04 01:42:08 +01:00
Peter Steinberger 70d68d29d0 fix: warm agent.wait cache 2026-01-04 01:35:02 +01:00
Peter Steinberger e7615c464a docs: update apple-reminders skill for remindctl 2026-01-04 01:33:47 +01:00
Peter Steinberger a1780efb9f fix: adjust typing TTL 2026-01-04 00:26:31 +00:00
Peter Steinberger 53d954695e style: format agent.wait imports 2026-01-04 01:22:22 +01:00
Peter Steinberger 44bdd4ca0c chore: regen Swift protocol models 2026-01-04 01:20:20 +01:00
Peter Steinberger 8724c2aea8 fix: satisfy gate checks 2026-01-04 01:16:53 +01:00
Peter Steinberger e3c543ec06 fix: wait on agent.wait for sessions_send 2026-01-04 01:15:23 +01:00
Peter Steinberger 412e8b3aee test: cover gif playback send params 2026-01-03 23:57:43 +00:00
Peter Steinberger 5862f95bd2 fix: lock main session deletion 2026-01-03 23:57:17 +00:00
Peter Steinberger e17c038d18 fix: add gif playback for WhatsApp sends 2026-01-03 23:56:40 +00:00
Peter Steinberger e1dd764504 feat: add node location support 2026-01-04 00:54:44 +01:00
Peter Steinberger 52f59e6dc1 fix: drop stale ClawdisCLI build flag 2026-01-04 00:42:22 +01:00
Peter Steinberger 3bc24bf179 fix: wait for final agent response in sessions_send 2026-01-04 00:40:40 +01:00
Peter Steinberger e07fdd117d docs: migrate Mintlify config 2026-01-04 00:36:55 +01:00
Peter Steinberger 7c062e0ef2 fix: clarify provider requirements in onboarding 2026-01-03 23:29:38 +00:00
Peter Steinberger 0f1781fc2c docs: add Mintlify config 2026-01-04 00:25:42 +01:00
Peter Steinberger 0f6e566a20 fix: make sessions_send wait via agent events 2026-01-04 00:12:14 +01:00
Peter Steinberger 03ee77b0e1 docs: add mac config sync note 2026-01-04 00:09:18 +01:00
Peter Steinberger 86038ec165 chore: apply lint fixes 2026-01-04 00:06:02 +01:00
Peter Steinberger e7c9b9a749 feat: add sessions tools and send policy 2026-01-03 23:44:42 +01:00
Peter Steinberger 919d5d1dbb fix: restore sandbox PATH default 2026-01-03 22:36:16 +00:00
Peter Steinberger 3f7c69fa7f docs: note mac app config sync 2026-01-03 23:34:25 +01:00
Shadow cc07ea82a4 CI: split macOS/android checks 2026-01-03 23:25:51 +01:00
Peter Steinberger 30e22769bb docs: update changelog for #144 2026-01-03 22:25:30 +00:00
Peter Steinberger 6c406b488d ci: consolidate check jobs 2026-01-03 22:25:29 +00:00
Peter Steinberger f13f89e8b9 docs: update changelog for PR 156 2026-01-03 22:59:11 +01:00
Peter Steinberger 8b069e62fc fix: appease lint after merge 2026-01-03 22:59:11 +01:00
Shadow e2709a3ebd CI: split macOS/android checks 2026-01-03 21:55:39 +00:00
Azade 18a89a31af fix(browser): avoid esbuild __name helper in evaluateViaPlaywright
When tsx/esbuild compiles arrow functions, it adds a __name helper
for debugging. This helper doesn't exist in the browser context,
causing 'ReferenceError: __name is not defined' when using
page.evaluate() with inline functions.

The fix uses new Function() constructed at runtime, which esbuild
doesn't transform, avoiding the __name injection.
2026-01-03 22:37:21 +01:00
Peter Steinberger 934f891932 fix: include embedded agent error cause in reply 2026-01-03 21:30:43 +00:00
Peter Steinberger 5493772910 fix: tolerate missing sandbox config in embedded runner 2026-01-03 21:30:40 +00:00
Peter Steinberger c533593d8e fix: add ~/.local/bin to PATH bootstrap for uv-installed tools (fixes #78) (#150) 2026-01-03 22:25:52 +01:00
Mariano Belinky fe1b894676 docs: clarify personal vs private in README (#125) 2026-01-03 22:21:55 +01:00
Mariano Belinky d88581eb7c fix: add ~/.local/bin to PATH for uv tool binaries (#78) 2026-01-03 22:21:16 +01:00
Peter Steinberger 3d39e2ad75 feat(macos): sync gateway config 2026-01-03 22:17:04 +01:00
Peter Steinberger 2dc10ce337 docs: expand peekaboo skill docs 2026-01-03 22:14:21 +01:00
Peter Steinberger d8a417f7ff feat: add sandbox browser support 2026-01-03 22:14:18 +01:00
Peter Steinberger 107dc1aa42 style(logging): organize embedded log imports 2026-01-03 21:09:44 +00:00
Peter Steinberger 9d2d0c64c2 test(gateway): cover config reload 2026-01-03 21:01:26 +00:00
Peter Steinberger 3872f32419 fix(logging): quiet embedded run console logs 2026-01-03 20:57:39 +00:00
Peter Steinberger 3b075dff8a feat: add per-session agent sandbox 2026-01-03 21:41:58 +01:00
128 changed files with 6977 additions and 306 deletions

No files matched your search

+67 -60
View File
@@ -5,12 +5,33 @@ on:
pull_request:
jobs:
build:
checks:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
runtime: [node, bun]
include:
- runtime: node
task: lint
command: pnpm lint
- runtime: node
task: test
command: pnpm test
- runtime: node
task: build
command: pnpm build
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: lint
command: bunx biome check src
- runtime: bun
task: test
command: bunx vitest run
- runtime: bun
task: build
command: bunx tsc -p tsconfig.json
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -77,37 +98,42 @@ jobs:
pnpm -v
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Lint (node)
if: matrix.runtime == 'node'
run: pnpm lint
- name: Test (node)
if: matrix.runtime == 'node'
run: pnpm test
- name: Build (node)
if: matrix.runtime == 'node'
run: pnpm build
- name: Protocol check (node)
if: matrix.runtime == 'node'
run: pnpm protocol:check
- name: Lint (bun)
if: matrix.runtime == 'bun'
run: bunx biome check src
- name: Test (bun)
if: matrix.runtime == 'bun'
run: bunx vitest run
- name: Build (bun)
if: matrix.runtime == 'bun'
run: bunx tsc -p tsconfig.json
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- task: build
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- task: test
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -142,35 +168,8 @@ jobs:
xcodebuild -version
swift --version
- name: SwiftLint
run: swiftlint --config .swiftlint.yml
- name: SwiftFormat (lint mode)
run: swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Swift build (release)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Swift tests (coverage)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest
@@ -346,6 +345,14 @@ jobs:
android:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: ./gradlew --no-daemon :app:testDebugUnitTest
- task: build
command: ./gradlew --no-daemon :app:assembleDebug
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -385,6 +392,6 @@ jobs:
"platforms;android-36" \
"build-tools;36.0.0"
- name: Android unit tests + debug build
- name: Run Android ${{ matrix.task }}
working-directory: apps/android
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
run: ${{ matrix.command }}
+21 -1
View File
@@ -6,6 +6,7 @@
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
- Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs.
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
@@ -16,10 +17,20 @@
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
### Fixes
- Browser tools: tolerate trailing semicolons in evaluate expressions (#153) — thanks @azade-c.
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
- Auto-reply: fix typing TTL to 2 minutes and log TTL with s/m units.
- Bash tool: default auto-background delay to 10s.
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
@@ -28,19 +39,24 @@
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
- Chat UI: render assistant `<think>`/`<final>` markup as italic thinking text in history + streaming instead of showing raw tags.
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
- Agent: route embedded run lifecycle logs through subsystem console formatting and reduce log noise.
- Agent tools: scope the Discord tool to Discord surface runs.
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
- Agent tools: emit verbose tool summaries at tool start (no debounce).
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
- Gateway: prevent deleting the main session and abort active runs before deleting other sessions.
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
- Onboarding: clarify provider requirements (WhatsApp/Signal phone numbers, iMessage Apple ID guidance) in the provider picker.
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
- macOS onboarding: increase window height so the permissions page fits without scrolling.
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
- Build: fix regex literal in tool-meta path detection (watch build error).
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Dependencies: bump pi-mono packages to 0.32.3.
@@ -49,11 +65,15 @@
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Skills: expand peekaboo skill examples + common parameters.
- Skills: add tmux skill + interactive coding guidance in coding-agent.
- Gateway: document port configuration + multi-instance isolation.
- Gateway: document config hot reload + reload matrix.
- Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
## 2.0.0-beta5 — 2026-01-03
+16
View File
@@ -0,0 +1,16 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]
+27
View File
@@ -0,0 +1,27 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
chromium \
curl \
fonts-liberation \
fonts-noto-color-emoji \
git \
jq \
novnc \
python3 \
websockify \
x11vnc \
xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdis-sandbox-browser
RUN chmod +x /usr/local/bin/clawdis-sandbox-browser
EXPOSE 9222 5900 6080
CMD ["clawdis-sandbox-browser"]
Submodule Peekaboo updated: 9db365b73c...b69e4e8dc0.
+1 -1
View File
@@ -18,7 +18,7 @@
**Clawdis** is a *personal AI assistant* you run on your own devices.
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
@@ -9,12 +9,9 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature
@@ -0,0 +1,15 @@
package com.clawdis.android
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}
}
@@ -33,6 +33,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
@@ -70,6 +72,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
runtime.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
runtime.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
runtime.setPreventSleep(value)
}
@@ -3,6 +3,7 @@ package com.clawdis.android
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
import android.os.SystemClock
import androidx.core.content.ContextCompat
@@ -16,6 +17,7 @@ import com.clawdis.android.bridge.BridgeEndpoint
import com.clawdis.android.bridge.BridgePairingClient
import com.clawdis.android.bridge.BridgeSession
import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.node.LocationCaptureManager
import com.clawdis.android.BuildConfig
import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager
@@ -24,6 +26,7 @@ import com.clawdis.android.protocol.ClawdisCameraCommand
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
import com.clawdis.android.protocol.ClawdisCanvasCommand
import com.clawdis.android.protocol.ClawdisLocationCommand
import com.clawdis.android.protocol.ClawdisScreenCommand
import com.clawdis.android.voice.TalkModeManager
import com.clawdis.android.voice.VoiceWakeManager
@@ -31,6 +34,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,6 +59,7 @@ class NodeRuntime(context: Context) {
val prefs = SecurePrefs(appContext)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
@@ -186,6 +191,8 @@ class NodeRuntime(context: Context) {
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
@@ -312,6 +319,14 @@ class NodeRuntime(context: Context) {
prefs.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
prefs.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
prefs.setPreventSleep(value)
}
@@ -373,6 +388,9 @@ class NodeRuntime(context: Context) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue)
}
}
val resolved =
if (storedToken.isNullOrBlank()) {
@@ -384,6 +402,9 @@ class NodeRuntime(context: Context) {
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue)
}
}
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
@@ -412,7 +433,8 @@ class NodeRuntime(context: Context) {
}
if (!resolved.ok || resolved.token.isNullOrBlank()) {
_statusText.value = "Failed: pairing required"
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
_statusText.value = "Failed: $errorMessage"
return@launch
}
@@ -444,6 +466,9 @@ class NodeRuntime(context: Context) {
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue)
}
},
commands = invokeCommands,
),
@@ -458,6 +483,28 @@ class NodeRuntime(context: Context) {
)
}
private fun hasFineLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun hasCoarseLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
private fun hasBackgroundLocationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
fun connectManual() {
val host = manualHost.value.trim()
val port = manualPort.value
@@ -667,6 +714,14 @@ class NodeRuntime(context: Context) {
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
if (command.startsWith(ClawdisLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off
) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
return when (command) {
ClawdisCanvasCommand.Present.rawValue -> {
@@ -787,6 +842,59 @@ class NodeRuntime(context: Context) {
if (includeAudio) externalAudioCaptureActive.value = false
}
}
ClawdisLocationCommand.Get.rawValue -> {
val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled.value
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
"coarse" -> "coarse"
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
}
val providers =
when (accuracy) {
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
}
try {
val payload =
location.getLocation(
desiredProviders = providers,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,
isPrecise = accuracy == "precise",
)
BridgeSession.InvokeResult.ok(payload.payloadJson)
} catch (err: TimeoutCancellationException) {
BridgeSession.InvokeResult.error(
code = "LOCATION_TIMEOUT",
message = "LOCATION_TIMEOUT: no fix in time",
)
} catch (err: Throwable) {
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
}
}
ClawdisScreenCommand.Record.rawValue -> {
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
_screenRecordActive.value = true
@@ -840,6 +948,25 @@ class NodeRuntime(context: Context) {
return code to "$code: $message"
}
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
if (paramsJson.isNullOrBlank()) {
return Triple(null, 10_000L, null)
}
val root =
try {
json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
}
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
val timeoutMs =
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
?: 10_000L
val desiredAccuracy =
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
}
private fun resolveA2uiHostUrl(): String? {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
if (raw.isBlank()) return null
@@ -47,6 +47,14 @@ class SecurePrefs(context: Context) {
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
@@ -93,6 +101,16 @@ class SecurePrefs(context: Context) {
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
prefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
prefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
prefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
@@ -37,21 +37,21 @@ class BridgePairingClient {
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
@@ -111,6 +111,9 @@ class BridgePairingClient {
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
@@ -0,0 +1,98 @@
package com.clawdis.android.node
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
class LocationCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
suspend fun getLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): Payload =
withContext(Dispatchers.Main) {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
!manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
) {
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
}
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
val location =
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time))
val source = location.provider
val altitudeMeters = if (location.hasAltitude()) location.altitude else null
val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null
val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null
Payload(
buildString {
append("{\"lat\":")
append(location.latitude)
append(",\"lon\":")
append(location.longitude)
append(",\"accuracyMeters\":")
append(location.accuracy.toDouble())
if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters)
if (speedMps != null) append(",\"speedMps\":").append(speedMps)
if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg)
append(",\"timestamp\":\"").append(timestamp).append('"')
append(",\"isPrecise\":").append(isPrecise)
append(",\"source\":\"").append(source).append('"')
append('}')
},
)
}
private fun bestLastKnown(
manager: LocationManager,
providers: List<String>,
maxAgeMs: Long?,
): Location? {
val now = System.currentTimeMillis()
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
val freshest = candidates.maxByOrNull { it.time } ?: return null
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
return freshest
}
@SuppressLint("MissingPermission")
private suspend fun requestCurrent(
manager: LocationManager,
providers: List<String>,
timeoutMs: Long,
): Location {
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
return withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
if (location != null) {
cont.resume(location)
} else {
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
}
}
}
}
}
}
@@ -5,6 +5,7 @@ enum class ClawdisCapability(val rawValue: String) {
Camera("camera"),
Screen("screen"),
VoiceWake("voiceWake"),
Location("location"),
}
enum class ClawdisCanvasCommand(val rawValue: String) {
@@ -49,3 +50,12 @@ enum class ClawdisScreenCommand(val rawValue: String) {
const val NamespacePrefix: String = "screen."
}
}
enum class ClawdisLocationCommand(val rawValue: String) {
Get("location.get"),
;
companion object {
const val NamespacePrefix: String = "location."
}
}
@@ -1,8 +1,12 @@
package com.clawdis.android.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -42,12 +46,14 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.clawdis.android.BuildConfig
import com.clawdis.android.LocationMode
import com.clawdis.android.MainViewModel
import com.clawdis.android.NodeForegroundService
import com.clawdis.android.VoiceWakeMode
@@ -58,6 +64,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val wakeWords by viewModel.wakeWords.collectAsState()
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
@@ -101,6 +109,41 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk)
}
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
var pendingPreciseToggle by remember { mutableStateOf(false) }
val locationPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val granted = fineOk || coarseOk
val requestedMode = pendingLocationMode
pendingLocationMode = null
if (pendingPreciseToggle) {
pendingPreciseToggle = false
viewModel.setLocationPreciseEnabled(fineOk)
return@rememberLauncherForActivityResult
}
if (!granted) {
viewModel.setLocationMode(LocationMode.Off)
return@rememberLauncherForActivityResult
}
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
}
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
// Status text is handled by NodeRuntime.
@@ -122,6 +165,47 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
fun requestLocationPermissions(targetMode: LocationMode) {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
} else {
pendingLocationMode = targetMode
locationPermissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
}
}
fun setPreciseLocationChecked(checked: Boolean) {
if (!checked) {
viewModel.setLocationPreciseEnabled(false)
return
}
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk) {
viewModel.setLocationPreciseEnabled(true)
} else {
pendingPreciseToggle = true
locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
}
}
val visibleBridges =
if (isConnected && remoteAddress != null) {
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
@@ -149,7 +233,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Order parity: Node → Bridge → Voice → Camera → Screen.
// Order parity: Node → Bridge → Voice → Camera → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item {
OutlinedTextField(
@@ -423,6 +507,64 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() }
// Location
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
item {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Off") },
supportingContent = { Text("Disable location sharing.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Off,
onClick = { viewModel.setLocationMode(LocationMode.Off) },
)
},
)
ListItem(
headlineContent = { Text("While Using") },
supportingContent = { Text("Only while Clawdis is open.") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
ListItem(
headlineContent = { Text("Always") },
supportingContent = { Text("Allow background location (requires system permission).") },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
)
},
)
}
}
item {
ListItem(
headlineContent = { Text("Precise Location") },
supportingContent = { Text("Use precise GPS when available.") },
trailingContent = {
Switch(
checked = locationPreciseEnabled,
onCheckedChange = ::setPreciseLocationChecked,
enabled = locationMode != LocationMode.Off,
)
},
)
}
item {
Text(
"Always may require Android Settings to allow background location.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { HorizontalDivider() }
// Screen
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
item {
@@ -453,3 +595,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}
private fun openAppSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}
@@ -229,6 +229,10 @@ final class BridgeConnectionController {
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) }
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
let locationMode = ClawdisLocationMode(rawValue: locationModeRaw) ?? .off
if locationMode != .off { caps.append(ClawdisCapability.location.rawValue) }
return caps
}
@@ -251,6 +255,9 @@ final class BridgeConnectionController {
commands.append(ClawdisCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue)
}
if caps.contains(ClawdisCapability.location.rawValue) {
commands.append(ClawdisLocationCommand.get.rawValue)
}
return commands
}
+4
View File
@@ -35,6 +35,10 @@
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Clawdis uses your location when you allow location sharing.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdis can share your location in the background when you enable Always.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>
@@ -0,0 +1,142 @@
import ClawdisKit
import CoreLocation
import Foundation
@MainActor
final class LocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(iOS 14.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
}
func ensureAuthorization(mode: ClawdisLocationMode) async -> CLAuthorizationStatus {
guard CLLocationManager.locationServicesEnabled() else { return .denied }
let status = self.manager.authorizationStatus
if status == .notDetermined {
self.manager.requestWhenInUseAuthorization()
let updated = await self.awaitAuthorizationChange()
if mode != .always { return updated }
}
if mode == .always {
let current = self.manager.authorizationStatus
if current == .authorizedWhenInUse {
self.manager.requestAlwaysAuthorization()
return await self.awaitAuthorizationChange()
}
return current
}
return self.manager.authorizationStatus
}
func currentLocation(
params: ClawdisLocationGetParams,
desiredAccuracy: ClawdisLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
}
}
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
await withCheckedContinuation { cont in
self.authContinuation = cont
}
}
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
return kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
case .precise:
return kCLLocationAccuracyBest
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if let cont = self.authContinuation {
self.authContinuation = nil
cont.resume(returning: manager.authorizationStatus)
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: error)
}
}
+82
View File
@@ -31,6 +31,7 @@ final class NodeAppModel {
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
let talkMode = TalkModeManager()
private let locationService = LocationService()
private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge }
@@ -188,6 +189,19 @@ final class NodeAppModel {
self.talkMode.setEnabled(enabled)
}
func requestLocationPermissions(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = await self.locationService.ensureAuthorization(mode: mode)
switch status {
case .authorizedAlways:
return true
case .authorizedWhenInUse:
return mode != .always
default:
return false
}
}
func connectToBridge(
endpoint: NWEndpoint,
hello: BridgeHello)
@@ -466,6 +480,64 @@ final class NodeAppModel {
do {
switch command {
case ClawdisLocationCommand.get.rawValue:
let mode = self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
if self.isBackgrounded, mode != .always {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .backgroundUnavailable,
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
}
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
ClawdisLocationGetParams()
let desired = params.desiredAccuracy ??
(self.isLocationPreciseEnabled() ? .precise : .balanced)
let status = self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
if self.isBackgrounded && status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
}
let location = try await self.locationService.currentLocation(
params: params,
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdisLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case ClawdisCanvasCommand.present.rawValue:
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
ClawdisCanvasPresentParams()
@@ -696,6 +768,16 @@ final class NodeAppModel {
}
}
private func locationMode() -> ClawdisLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return ClawdisLocationMode(rawValue: raw) ?? .off
}
private func isLocationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 20, userInfo: [
@@ -23,6 +23,8 @@ struct SettingsTab: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdisLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
@@ -34,6 +36,7 @@ struct SettingsTab: View {
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
var body: some View {
NavigationStack {
@@ -181,6 +184,22 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
Section("Location") {
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(ClawdisLocationMode.off.rawValue)
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdisLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always requires system permission and may prompt to open Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Screen") {
Toggle("Prevent Sleep", isOn: self.$preventSleep)
Text("Keeps the screen awake while Clawdis is open.")
@@ -201,6 +220,7 @@ struct SettingsTab: View {
}
.onAppear {
self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
}
.onChange(of: self.preferredBridgeStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -210,6 +230,20 @@ struct SettingsTab: View {
.onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus.text = nil
}
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.appModel.requestLocationPermissions(mode: mode)
if !granted {
await MainActor.run {
self.locationEnabledModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
}
@@ -278,6 +312,10 @@ struct SettingsTab: View {
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private var locationMode: ClawdisLocationMode {
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
+172 -3
View File
@@ -8,6 +8,8 @@ import SwiftUI
@Observable
final class AppState {
private let isPreview: Bool
private var isInitializing = true
private var configWatcher: ConfigFileWatcher?
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -153,6 +155,7 @@ final class AppState {
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
self.syncGatewayConfigIfNeeded()
}
}
@@ -181,7 +184,10 @@ final class AppState {
}
var remoteTarget: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
self.syncGatewayConfigIfNeeded()
}
}
var remoteIdentity: String {
@@ -245,13 +251,44 @@ final class AppState {
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
}
let configRoot = ClawdisConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = {
switch configModeRaw {
case "local":
return .local
case "remote":
return .remote
default:
return nil
}
}()
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
if let storedMode {
if let configMode {
self.connectionMode = configMode
} else if configHasRemoteUrl {
self.connectionMode = .remote
} else if let storedMode {
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
} else {
self.connectionMode = onboardingSeen ? .local : .unconfigured
}
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if self.connectionMode == .remote,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
{
self.remoteTarget = "\(NSUserName())@\(host)"
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -278,6 +315,138 @@ final class AppState {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
}
self.isInitializing = false
if !self.isPreview {
self.startConfigWatcher()
}
}
deinit {
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
let url = URL(string: raw),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return nil
}
return host
}
private func startConfigWatcher() {
let configUrl = ClawdisConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
Task { @MainActor in
self?.applyConfigFromDisk()
}
}
self.configWatcher?.start()
}
private func applyConfigFromDisk() {
let root = ClawdisConfigFile.loadDict()
self.applyConfigOverrides(root)
}
private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let desiredMode: ConnectionMode? = {
switch modeRaw {
case "local":
return .local
case "remote":
return .remote
case "unconfigured":
return .unconfigured
default:
return nil
}
}()
if let desiredMode {
if desiredMode != self.connectionMode {
self.connectionMode = desiredMode
}
} else if hasRemoteUrl, self.connectionMode != .remote {
self.connectionMode = .remote
}
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
let host = AppState.remoteHost(from: remoteUrl)
{
self.updateRemoteTarget(host: host)
}
}
private func updateRemoteTarget(host: String) {
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
let user = parsed?.user ?? NSUserName()
let port = parsed?.port ?? 22
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
if assembled != self.remoteTarget {
self.remoteTarget = assembled
}
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
var root = ClawdisConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String?
switch self.connectionMode {
case .local:
desiredMode = "local"
case .remote:
desiredMode = "remote"
case .unconfigured:
desiredMode = nil
}
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if self.connectionMode == .remote,
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
{
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
}
}
guard changed else { return }
root["gateway"] = gateway
ClawdisConfigFile.saveDict(root)
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
@@ -0,0 +1,118 @@
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
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.queue = DispatchQueue(label: "com.clawdis.configwatcher")
self.onChange = onChange
self.watchedDir = url.deletingLastPathComponent()
self.targetPath = url.path
self.targetName = url.lastPathComponent
}
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<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() {
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
}
}
@@ -25,6 +25,8 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let remoteCliPathKey = "clawdis.remoteCliPath"
let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled"
let locationModeKey = "clawdis.locationMode"
let locationPreciseKey = "clawdis.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
@@ -306,6 +306,7 @@ extension GatewayConnection {
struct SnapshotConfig: Decodable, Sendable {
struct Session: Decodable, Sendable {
let mainKey: String?
let scope: String?
}
let session: Session?
@@ -316,9 +317,11 @@ extension GatewayConnection {
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
let raw = snapshot.config?.session?.mainKey
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
if scope == "global" {
return "global"
}
return "main"
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
@@ -1,11 +1,15 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import CoreLocation
import Observation
import SwiftUI
struct GeneralSettings: View {
@Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdisLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel()
@@ -18,6 +22,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
var body: some View {
ScrollView(.vertical) {
@@ -64,6 +69,26 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdisLocationMode.off.rawValue)
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdisLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow(
title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@@ -90,12 +115,27 @@ struct GeneralSettings: View {
guard !self.isPreview else { return }
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
}
.onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled {
CanvasManager.shared.hideAll()
}
}
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
private var activeBinding: Binding<Bool> {
@@ -104,6 +144,29 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 })
}
private var locationMode: ClawdisLocationMode {
ClawdisLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = CLLocationManager.authorizationStatus()
if status == .authorizedAlways || status == .authorizedWhenInUse {
if mode == .always && status != .authorizedAlways {
let updated = await LocationPermissionRequester.shared.request(always: true)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
}
return true
}
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
switch updated {
case .authorizedAlways, .authorizedWhenInUse:
return true
default:
return false
}
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Clawdis runs")
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
compact.representedObject = row.key
menu.addItem(compact)
if row.key != "main" {
if row.key != "main" && row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self
del.representedObject = row.key
@@ -0,0 +1,116 @@
import ClawdisKit
import CoreLocation
import Foundation
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(macOS 11.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
}
func currentLocation(
desiredAccuracy: ClawdisLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
}
}
private func withTimeout<T>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
throw Error.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
return kCLLocationAccuracyKilometer
case .balanced:
return kCLLocationAccuracyHundredMeters
case .precise:
return kCLLocationAccuracyBest
}
}
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let errorCopy = error // Capture error for Sendable compliance
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: errorCopy)
}
}
}
@@ -110,6 +110,10 @@ final class MacNodeModeCoordinator {
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
caps.append(ClawdisCapability.camera.rawValue)
}
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
if ClawdisLocationMode(rawValue: rawLocationMode) != .off {
caps.append(ClawdisCapability.location.rawValue)
}
return caps
}
@@ -139,6 +143,9 @@ final class MacNodeModeCoordinator {
commands.append(ClawdisCameraCommand.snap.rawValue)
commands.append(ClawdisCameraCommand.clip.rawValue)
}
if capsSet.contains(ClawdisCapability.location.rawValue) {
commands.append(ClawdisLocationCommand.get.rawValue)
}
return commands
}
@@ -6,6 +6,7 @@ import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService()
@MainActor private let locationService = MacNodeLocationService()
// swiftlint:disable:next function_body_length cyclomatic_complexity
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -168,6 +169,63 @@ actor MacNodeRuntime {
let payload = try Self.encodePayload(["devices": devices])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisLocationCommand.get.rawValue:
let mode = Self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
ClawdisLocationGetParams()
let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced)
let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
do {
let location = try await self.locationService.currentLocation(
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdisLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
} catch MacNodeLocationService.Error.timeout {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_TIMEOUT: no fix in time"))
} catch {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "LOCATION_UNAVAILABLE: \(error.localizedDescription)"))
}
case MacNodeScreenCommand.record.rawValue:
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
MacNodeScreenRecordParams()
@@ -413,6 +471,16 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private nonisolated static func locationMode() -> ClawdisLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdisLocationMode(rawValue: raw) ?? .off
}
private nonisolated static func locationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { return true }
return UserDefaults.standard.bool(forKey: locationPreciseKey)
}
private static func errorResponse(
_ req: BridgeInvokeRequest,
code: ClawdisNodeErrorCode,
@@ -6,6 +6,25 @@ import SwiftUI
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
// MARK: - Swift 6 AnyCodable Bridging Helpers
// Bridge between ClawdisProtocol.AnyCodable and the local module to avoid
// Swift 6 strict concurrency type conflicts.
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable {
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data)
{
return decoded
}
return AnyCodable(value.value)
}
private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? {
value.map(bridgeToLocal)
}
@MainActor
@Observable
final class OnboardingWizardModel {
@@ -285,11 +304,11 @@ struct OnboardingWizardStepView: View {
return
}
let option = optionItems[selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label))
onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect":
let values = optionItems
.filter { selectedIndices.contains($0.index) }
.map { $0.option.value ?? AnyCodable($0.option.label) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
onSubmit(AnyCodable(values))
case "action":
onSubmit(AnyCodable(true))
@@ -307,12 +326,12 @@ private struct WizardOptionItem: Identifiable {
}
private struct WizardOption {
let value: AnyCodable?
let value: ProtocolAnyCodable?
let label: String
let hint: String?
}
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil }
do {
let data = try JSONEncoder().encode(raw)
@@ -323,7 +342,7 @@ private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
}
}
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] {
private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] }
return raw.map { entry in
let value = entry["value"]
@@ -337,7 +356,7 @@ private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? ""
}
private func anyCodableString(_ value: AnyCodable?) -> String {
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value {
case let string as String:
return string
@@ -352,11 +371,11 @@ private func anyCodableString(_ value: AnyCodable?) -> String {
}
}
private func anyCodableStringValue(_ value: AnyCodable?) -> String? {
private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
value?.value as? String
}
private func anyCodableBool(_ value: AnyCodable?) -> Bool {
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value {
case let bool as Bool:
return bool
@@ -367,18 +386,18 @@ private func anyCodableBool(_ value: AnyCodable?) -> Bool {
}
}
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value {
case let arr as [AnyCodable]:
case let arr as [ProtocolAnyCodable]:
return arr
case let arr as [Any]:
return arr.map { AnyCodable($0) }
return arr.map { ProtocolAnyCodable($0) }
default:
return []
}
}
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) {
case let (l as String, r as String):
return l == r
@@ -3,6 +3,7 @@ import ApplicationServices
import AVFoundation
import ClawdisIPC
import CoreGraphics
import CoreLocation
import Foundation
import Observation
import Speech
@@ -33,6 +34,8 @@ enum PermissionManager {
await self.ensureSpeechRecognition(interactive: interactive)
case .camera:
await self.ensureCamera(interactive: interactive)
case .location:
await self.ensureLocation(interactive: interactive)
}
}
@@ -134,6 +137,25 @@ enum PermissionManager {
}
}
private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
case .denied, .restricted:
if interactive {
LocationPermissionHelper.openSettings()
}
return false
@unknown default:
return false
}
}
static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
@@ -176,6 +198,9 @@ enum PermissionManager {
case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location:
let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
}
}
return results
@@ -227,6 +252,54 @@ enum CameraPermissionHelper {
}
}
enum LocationPermissionHelper {
static func openSettings() {
let candidates = [
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
"x-apple.systempreferences:com.apple.preference.security",
]
for candidate in candidates {
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
return
}
}
}
}
@MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
override init() {
super.init()
self.manager.delegate = self
}
func request(always: Bool) async -> CLAuthorizationStatus {
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
return await withCheckedContinuation { cont in
self.continuation = cont
}
}
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
}
}
enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
@@ -121,6 +121,7 @@ struct PermissionRow: View {
case .microphone: "Microphone"
case .speechRecognition: "Speech Recognition"
case .camera: "Camera"
case .location: "Location"
}
}
@@ -134,6 +135,7 @@ struct PermissionRow: View {
case .microphone: "Allow Voice Wake and audio capture"
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
case .camera: "Capture photos and video from the camera"
case .location: "Share location when requested by the agent"
}
}
@@ -146,6 +148,7 @@ struct PermissionRow: View {
case .microphone: "mic"
case .speechRecognition: "waveform"
case .camera: "camera"
case .location: "location"
}
}
}
@@ -47,6 +47,8 @@
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
<key>NSCameraUsageDescription</key>
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
<key>NSLocationUsageDescription</key>
<string>Clawdis can share your location when requested by the agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSSpeechRecognitionUsageDescription</key>
+1
View File
@@ -12,6 +12,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
case microphone
case speechRecognition
case camera
case location
}
public enum CameraFacing: String, Codable, Sendable {
@@ -340,6 +340,7 @@ public struct SendParams: Codable {
public let to: String
public let message: String
public let mediaurl: String?
public let gifplayback: Bool?
public let provider: String?
public let idempotencykey: String
@@ -347,12 +348,14 @@ public struct SendParams: Codable {
to: String,
message: String,
mediaurl: String?,
gifplayback: Bool?,
provider: String?,
idempotencykey: String
) {
self.to = to
self.message = message
self.mediaurl = mediaurl
self.gifplayback = gifplayback
self.provider = provider
self.idempotencykey = idempotencykey
}
@@ -360,6 +363,7 @@ public struct SendParams: Codable {
case to
case message
case mediaurl = "mediaUrl"
case gifplayback = "gifPlayback"
case provider
case idempotencykey = "idempotencyKey"
}
@@ -374,6 +378,8 @@ public struct AgentParams: Codable {
public let deliver: Bool?
public let channel: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
public let idempotencykey: String
public init(
@@ -385,6 +391,8 @@ public struct AgentParams: Codable {
deliver: Bool?,
channel: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
idempotencykey: String
) {
self.message = message
@@ -395,6 +403,8 @@ public struct AgentParams: Codable {
self.deliver = deliver
self.channel = channel
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
@@ -406,10 +416,33 @@ public struct AgentParams: Codable {
case deliver
case channel
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
case idempotencykey = "idempotencyKey"
}
}
public struct AgentWaitParams: Codable {
public let runid: String
public let afterms: Int?
public let timeoutms: Int?
public init(
runid: String,
afterms: Int?,
timeoutms: Int?
) {
self.runid = runid
self.afterms = afterms
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case afterms = "afterMs"
case timeoutms = "timeoutMs"
}
}
public struct WakeParams: Codable {
public let mode: AnyCodable
public let text: String
@@ -614,6 +647,7 @@ public struct SessionsPatchParams: Codable {
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let model: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
public init(
@@ -621,12 +655,14 @@ public struct SessionsPatchParams: Codable {
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
model: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?
) {
self.key = key
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.model = model
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
private enum CodingKeys: String, CodingKey {
@@ -634,6 +670,7 @@ public struct SessionsPatchParams: Codable {
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case model
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
}
@@ -834,7 +871,7 @@ public struct WizardStep: Codable {
}
}
public struct WizardNextResult: Codable {
public struct WizardNextResult: Codable, Sendable {
public let done: Bool
public let step: [String: AnyCodable]?
public let status: AnyCodable?
@@ -859,7 +896,7 @@ public struct WizardNextResult: Codable {
}
}
public struct WizardStartResult: Codable {
public struct WizardStartResult: Codable, Sendable {
public let sessionid: String
public let done: Bool
public let step: [String: AnyCodable]?
@@ -888,7 +925,7 @@ public struct WizardStartResult: Codable {
}
}
public struct WizardStatusResult: Codable {
public struct WizardStatusResult: Codable, Sendable {
public let status: AnyCodable
public let error: String?
@@ -32,7 +32,7 @@ import Testing
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "primary")
#expect(key == "main")
}
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
@@ -54,4 +54,14 @@ import Testing
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotUsesGlobalScope() throws {
let json = """
{
"config": { "session": { "scope": "global" } }
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "global")
}
}
@@ -0,0 +1,139 @@
import Foundation
struct AssistantTextSegment: Identifiable {
enum Kind {
case thinking
case response
}
let id = UUID()
let kind: Kind
let text: String
}
enum AssistantTextParser {
static func segments(from raw: String) -> [AssistantTextSegment] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
guard raw.contains("<") else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
var segments: [AssistantTextSegment] = []
var cursor = raw.startIndex
var currentKind: AssistantTextSegment.Kind = .response
var matchedTag = false
while let match = self.nextTag(in: raw, from: cursor) {
matchedTag = true
if match.range.lowerBound > cursor {
self.appendSegment(kind: currentKind, text: raw[cursor..<match.range.lowerBound], to: &segments)
}
guard let tagEnd = raw.range(of: ">", range: match.range.upperBound..<raw.endIndex) else {
cursor = raw.endIndex
break
}
let isSelfClosing = self.isSelfClosingTag(in: raw, tagEnd: tagEnd)
cursor = tagEnd.upperBound
if isSelfClosing { continue }
if match.closing {
currentKind = .response
} else {
currentKind = match.kind == .think ? .thinking : .response
}
}
if cursor < raw.endIndex {
self.appendSegment(kind: currentKind, text: raw[cursor..<raw.endIndex], to: &segments)
}
guard matchedTag else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
return segments
}
static func hasVisibleContent(in raw: String) -> Bool {
!self.segments(from: raw).isEmpty
}
private enum TagKind {
case think
case final
}
private struct TagMatch {
let kind: TagKind
let closing: Bool
let range: Range<String.Index>
}
private static func nextTag(in text: String, from start: String.Index) -> TagMatch? {
let candidates: [TagMatch] = [
self.findTagStart(tag: "think", closing: false, in: text, from: start).map {
TagMatch(kind: .think, closing: false, range: $0)
},
self.findTagStart(tag: "think", closing: true, in: text, from: start).map {
TagMatch(kind: .think, closing: true, range: $0)
},
self.findTagStart(tag: "final", closing: false, in: text, from: start).map {
TagMatch(kind: .final, closing: false, range: $0)
},
self.findTagStart(tag: "final", closing: true, in: text, from: start).map {
TagMatch(kind: .final, closing: true, range: $0)
},
].compactMap { $0 }
return candidates.min { $0.range.lowerBound < $1.range.lowerBound }
}
private static func findTagStart(
tag: String,
closing: Bool,
in text: String,
from start: String.Index) -> Range<String.Index>?
{
let token = closing ? "</\(tag)" : "<\(tag)"
var searchRange = start..<text.endIndex
while let range = text.range(
of: token,
options: [.caseInsensitive, .diacriticInsensitive],
range: searchRange)
{
let boundaryIndex = range.upperBound
guard boundaryIndex < text.endIndex else { return range }
let boundary = text[boundaryIndex]
let isBoundary = boundary == ">" || boundary.isWhitespace || (!closing && boundary == "/")
if isBoundary {
return range
}
searchRange = boundaryIndex..<text.endIndex
}
return nil
}
private static func isSelfClosingTag(in text: String, tagEnd: Range<String.Index>) -> Bool {
var cursor = tagEnd.lowerBound
while cursor > text.startIndex {
cursor = text.index(before: cursor)
let char = text[cursor]
if char.isWhitespace { continue }
return char == "/"
}
return false
}
private static func appendSegment(
kind: AssistantTextSegment.Kind,
text: Substring,
to segments: inout [AssistantTextSegment])
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
segments.append(AssistantTextSegment(kind: kind, text: trimmed))
}
}
@@ -158,7 +158,6 @@ private struct ChatMessageBody: View {
var body: some View {
let text = self.primaryText
let split = ChatMarkdownSplitter.split(markdown: text)
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
VStack(alignment: .leading, spacing: 10) {
@@ -169,37 +168,40 @@ private struct ChatMessageBody: View {
text: text,
isUser: self.isUser)
}
} else {
} else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text)
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: textColor)
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdisPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdisPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
} else {
ChatAssistantTextBody(text: text)
}
if !self.inlineAttachments.isEmpty {
@@ -506,7 +508,7 @@ struct ChatStreamingAssistantBubble: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText)
ChatAssistantTextBody(text: self.text)
}
.padding(12)
.background(
@@ -614,6 +616,7 @@ private struct TypingDots: View {
private struct MarkdownTextView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
@@ -624,20 +627,36 @@ private struct MarkdownTextView: View {
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(.system(size: 14))
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(.system(size: 14))
.font(self.font)
.foregroundStyle(self.textColor)
}
}
}
@MainActor
private struct ChatAssistantTextBody: View {
let text: String
var body: some View {
let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdisChatTheme.assistantText, font: font)
}
}
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
@@ -645,7 +664,7 @@ private struct ChatMarkdownBody: View {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor)
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
@@ -169,7 +169,7 @@ public struct ClawdisChatView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -246,7 +246,7 @@ public struct ClawdisChatView: View {
return true
}
if let text = self.viewModel.streamingAssistantText,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
AssistantTextParser.hasVisibleContent(in: text)
{
return true
}
@@ -261,7 +261,7 @@ public struct ClawdisChatView: View {
private var showsEmptyState: Bool {
self.viewModel.messages.isEmpty &&
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
!(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
self.viewModel.pendingRunCount == 0 &&
self.viewModel.pendingToolCalls.isEmpty
}
@@ -5,4 +5,5 @@ public enum ClawdisCapability: String, Codable, Sendable {
case camera
case screen
case voiceWake
case location
}
@@ -0,0 +1,57 @@
import Foundation
public enum ClawdisLocationCommand: String, Codable, Sendable {
case get = "location.get"
}
public enum ClawdisLocationAccuracy: String, Codable, Sendable {
case coarse
case balanced
case precise
}
public struct ClawdisLocationGetParams: Codable, Sendable, Equatable {
public var timeoutMs: Int?
public var maxAgeMs: Int?
public var desiredAccuracy: ClawdisLocationAccuracy?
public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: ClawdisLocationAccuracy? = nil) {
self.timeoutMs = timeoutMs
self.maxAgeMs = maxAgeMs
self.desiredAccuracy = desiredAccuracy
}
}
public struct ClawdisLocationPayload: Codable, Sendable, Equatable {
public var lat: Double
public var lon: Double
public var accuracyMeters: Double
public var altitudeMeters: Double?
public var speedMps: Double?
public var headingDeg: Double?
public var timestamp: String
public var isPrecise: Bool
public var source: String?
public init(
lat: Double,
lon: Double,
accuracyMeters: Double,
altitudeMeters: Double? = nil,
speedMps: Double? = nil,
headingDeg: Double? = nil,
timestamp: String,
isPrecise: Bool,
source: String? = nil)
{
self.lat = lat
self.lon = lon
self.accuracyMeters = accuracyMeters
self.altitudeMeters = altitudeMeters
self.speedMps = speedMps
self.headingDeg = headingDeg
self.timestamp = timestamp
self.isPrecise = isPrecise
self.source = source
}
}
@@ -0,0 +1,7 @@
import Foundation
public enum ClawdisLocationMode: String, Codable, Sendable, CaseIterable {
case off
case whileUsing
case always
}
@@ -0,0 +1,37 @@
@testable import ClawdisChatUI
import Testing
@Suite struct AssistantTextParserTests {
@Test func splitsThinkAndFinalSegments() {
let segments = AssistantTextParser.segments(
from: "<think>internal</think>\n\n<final>Hello there</final>")
#expect(segments.count == 2)
#expect(segments[0].kind == .thinking)
#expect(segments[0].text == "internal")
#expect(segments[1].kind == .response)
#expect(segments[1].text == "Hello there")
}
@Test func keepsTextWithoutTags() {
let segments = AssistantTextParser.segments(from: "Just text.")
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == "Just text.")
}
@Test func ignoresThinkingLikeTags() {
let raw = "<thinking>example</thinking>\nKeep this."
let segments = AssistantTextParser.segments(from: raw)
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines))
}
@Test func dropsEmptyTaggedContent() {
let segments = AssistantTextParser.segments(from: "<think></think>")
#expect(segments.isEmpty)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- Required: `--message <text>`
- Session selection:
- If `--session-id` is given, reuse it.
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `session.mainKey`).
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `main`, or `global` when scope is global).
- Runs the embedded Pi agent (configured via `agent`).
- Thinking/verbose:
- Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
+1 -2
View File
@@ -138,8 +138,7 @@ Example:
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
idleMinutes: 10080,
mainKey: "main"
idleMinutes: 10080
}
}
```
+3
View File
@@ -31,6 +31,9 @@ Author: steipete · Status: draft spec · Date: 2025-12-20
- The gateway port is configurable via `gateway.port` or `CLAWDIS_GATEWAY_PORT` (default 18789). The mac app reads that value for launchd, probes, and remote SSH tunnels.
- The mac app connects to the bridge as a **node** and advertises capabilities/commands.
- Agent‑facing actions are exposed via `node.invoke` (no local control socket).
- The mac app watches `~/.clawdis/clawdis.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the mac app treats it as remote mode.
- Changing connection mode in the mac app writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
### Node commands (mac)
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
+95 -1
View File
@@ -33,6 +33,11 @@ better forms without hard-coding config knowledge.
}
```
Build the default image once with:
```bash
scripts/sandbox-setup.sh
```
## Self-chat mode (recommended for group control)
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
@@ -323,6 +328,9 @@ Default: `~/clawd`.
}
```
If `agent.sandbox` is enabled, non-main sessions can override this with their
own per-session workspaces under `agent.sandbox.workspaceRoot`.
### `messages`
Controls inbound/outbound prefixes and timestamps.
@@ -435,6 +443,77 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1.
### `agent.sandbox`
Optional per-session **Docker sandboxing** for the embedded agent. Intended for
non-main sessions so they cannot access your host system.
Defaults (if enabled):
- one container per session
- Debian bookworm-slim based image
- workspace per session under `~/.clawdis/sandboxes`
- auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer)
```json5
{
agent: {
sandbox: {
mode: "non-main", // off | non-main | all
perSession: true,
workspaceRoot: "~/.clawdis/sandboxes",
docker: {
image: "clawdis-sandbox:bookworm-slim",
containerPrefix: "clawdis-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "bridge",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
setupCommand: "apt-get update && apt-get install -y git curl jq"
},
browser: {
enabled: false,
image: "clawdis-sandbox-browser:bookworm-slim",
containerPrefix: "clawdis-sbx-browser-",
cdpPort: 9222,
vncPort: 5900,
noVncPort: 6080,
headless: false,
enableNoVnc: true
},
tools: {
allow: ["bash", "process", "read", "write", "edit"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
idleHours: 24, // 0 disables idle pruning
maxAgeDays: 7 // 0 disables max-age pruning
}
}
}
}
```
Build the default sandbox image once with:
```bash
scripts/sandbox-setup.sh
```
Build the optional browser image with:
```bash
scripts/sandbox-browser-setup.sh
```
When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed
Chromium instance (CDP). If noVNC is enabled (default when headless=false),
the noVNC URL is injected into the system prompt so the agent can reference it.
This does not require `browser.enabled` in the main config; the sandbox control
URL is injected per session.
### `models` (custom providers + base URLs)
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers
@@ -541,11 +620,21 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
idleMinutes: 60,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json",
mainKey: "main"
// mainKey is ignored; primary key is fixed to "main"
sendPolicy: {
rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } }
],
default: "allow"
}
}
}
```
Fields:
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
### `skills` (skills config)
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
@@ -683,6 +772,11 @@ Remote client defaults (CLI):
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
macOS app behavior:
- Clawdis.app watches `~/.clawdis/clawdis.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
```json5
{
gateway: {
+178 -8
View File
@@ -9,16 +9,27 @@ read_when:
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
## Quick start (recommended)
This guide covers:
- Containerized Gateway (full Clawdis in Docker)
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)
From the repo root:
## Requirements
- Docker Desktop (or Docker Engine) + Docker Compose v2
- Enough disk for images + logs
## Containerized Gateway (Docker Compose)
### Quick start (recommended)
From repo root:
```bash
./docker-setup.sh
```
This script:
- builds the image
- builds the gateway image
- runs the onboarding wizard
- runs WhatsApp login
- starts the gateway via Docker Compose
@@ -27,7 +38,7 @@ It writes config/workspace on the host:
- `~/.clawdis/`
- `~/clawd`
## Manual flow (compose)
### Manual flow (compose)
```bash
docker build -t clawdis:local -f Dockerfile .
@@ -36,14 +47,173 @@ docker compose run --rm clawdis-cli login
docker compose up -d clawdis-gateway
```
## E2E smoke test (Docker)
### Health check
```bash
docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"
```
### E2E smoke test (Docker)
```bash
scripts/e2e/onboard-docker.sh
```
## Notes
### Notes
- Gateway bind defaults to `lan` for container use.
- Health check:
`docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"`
- The gateway container is the source of truth for sessions (`~/.clawdis/sessions`).
## Per-session Agent Sandbox (host gateway + Docker tools)
### What it does
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker
container. The gateway stays on your host, but the tool execution is isolated:
- one container per session (hard wall)
- per-session workspace folder mounted at `/workspace`
- allow/deny tool policy (deny wins)
### Default behavior
- Image: `clawdis-sandbox:bookworm-slim`
- One container per session
- Workspace per session under `~/.clawdis/sandboxes`
- Auto-prune: idle > 24h OR age > 7d
- Default allow: `bash`, `process`, `read`, `write`, `edit`
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`
### Enable sandboxing
```json5
{
agent: {
sandbox: {
mode: "non-main", // off | non-main | all
perSession: true,
workspaceRoot: "~/.clawdis/sandboxes",
docker: {
image: "clawdis-sandbox:bookworm-slim",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "bridge",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
setupCommand: "apt-get update && apt-get install -y git curl jq"
},
tools: {
allow: ["bash", "process", "read", "write", "edit"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
idleHours: 24, // 0 disables idle pruning
maxAgeDays: 7 // 0 disables max-age pruning
}
}
}
}
```
### Build the default sandbox image
```bash
scripts/sandbox-setup.sh
```
This builds `clawdis-sandbox:bookworm-slim` using `Dockerfile.sandbox`.
### Sandbox browser image
To run the browser tool inside the sandbox, build the browser image:
```bash
scripts/sandbox-browser-setup.sh
```
This builds `clawdis-sandbox-browser:bookworm-slim` using
`Dockerfile.sandbox-browser`. The container runs Chromium with CDP enabled and
an optional noVNC observer (headful via Xvfb).
Notes:
- Headful (Xvfb) reduces bot blocking vs headless.
- Headless can still be used by setting `agent.sandbox.browser.headless=true`.
- No full desktop environment (GNOME) is needed; Xvfb provides the display.
Use config:
```json5
{
agent: {
sandbox: {
browser: { enabled: true }
}
}
}
```
Custom browser image:
```json5
{
agent: {
sandbox: { browser: { image: "my-clawdis-browser" } }
}
}
```
When enabled, the agent receives:
- a sandbox browser control URL (for the `browser` tool)
- a noVNC URL (if enabled and headless=false)
Remember: if you use an allowlist for tools, add `browser` (and remove it from
deny) or the tool remains blocked.
Prune rules (`agent.sandbox.prune`) apply to browser containers too.
### Custom sandbox image
Build your own image and point config to it:
```bash
docker build -t my-clawdis-sbx -f Dockerfile.sandbox .
```
```json5
{
agent: {
sandbox: { docker: { image: "my-clawdis-sbx" } }
}
}
```
### Tool policy (allow/deny)
- `deny` wins over `allow`.
- If `allow` is empty: all tools (except deny) are available.
- If `allow` is non-empty: only tools in `allow` are available (minus deny).
### Pruning strategy
Two knobs:
- `prune.idleHours`: remove containers not used in X hours (0 = disable)
- `prune.maxAgeDays`: remove containers older than X days (0 = disable)
Example:
- Keep busy sessions but cap lifetime:
`idleHours: 24`, `maxAgeDays: 7`
- Never prune:
`idleHours: 0`, `maxAgeDays: 0`
### Security notes
- Hard wall only applies to **tools** (bash/read/write/edit).
- Host-only tools like browser/camera/canvas are blocked by default.
- Allowing `browser` in sandbox **breaks isolation** (browser runs on host).
## Troubleshooting
- Image missing: build with `scripts/sandbox-setup.sh` or set `agent.sandbox.docker.image`.
- Container not running: it will auto-create per session on demand.
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
mounted workspace ownership (or chown the workspace folder).
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "Clawdis",
"theme": "mint",
"logo": {
"light": "/assets/pixel-lobster.svg",
"dark": "/assets/pixel-lobster.svg"
},
"favicon": "/assets/pixel-lobster.svg",
"colors": {
"primary": "#FF5A36"
},
"topbarLinks": [
{
"name": "GitHub",
"url": "https://github.com/steipete/clawdis"
},
{
"name": "Releases",
"url": "https://github.com/steipete/clawdis/releases"
}
],
"navigation": {
"groups": [
{
"group": "Getting Started",
"pages": [
"index",
"onboarding",
"wizard",
"clawd",
"faq"
]
},
{
"group": "Core Concepts",
"pages": [
"architecture",
"agent",
"session",
"rpc",
"queue",
"tools"
]
},
{
"group": "Gateway & Providers",
"pages": [
"gateway",
"gateway/pairing",
"whatsapp",
"telegram",
"discord",
"imessage",
"webhook",
"gmail-pubsub"
]
},
{
"group": "Platforms",
"pages": [
"mac/dev-setup",
"mac/menu-bar",
"mac/voicewake",
"ios/connect",
"android/connect",
"webchat",
"web"
]
},
{
"group": "Operations",
"pages": [
"doctor",
"troubleshooting",
"logging",
"security",
"health",
"background-process",
"gateway-lock",
"remote"
]
}
]
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ message. If the reply is only `HEARTBEAT_OK`, it is dropped.
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
## Behavior
- Runs in the main session (`session.mainKey`, or `global` when scope is global).
- Runs in the main session (`main`, or `global` when scope is global).
- Uses the main lane queue; if requests are in flight, the wake is retried.
- Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the
session alive (`updatedAt` is restored).
+1
View File
@@ -24,6 +24,7 @@ CLAWDIS is now **web-only** (Baileys). This document captures the current media
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB.
- **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`).
- **Documents:** anything else, up to 100 MB, with filename preserved when available.
- WhatsApp GIF-style playback: send an MP4 with `gifPlayback: true` (CLI: `--gif-playback`) so mobile clients loop inline.
- MIME detection prefers magic bytes, then headers, then file extension.
- Caption comes from `--message` or `reply.text`; empty caption is allowed.
- Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL.
+95
View File
@@ -0,0 +1,95 @@
---
summary: "Location command for nodes (location.get), permission modes, and background behavior"
read_when:
- Adding location node support or permissions UI
- Designing background location + push flows
---
# Location command (nodes)
## TL;DR
- `location.get` is a node command (via `node.invoke`).
- Off by default.
- Settings use a selector: Off / While Using / Always.
- Separate toggle: Precise Location.
## Why a selector (not just a switch)
OS permissions are multi-level. We can expose a selector in-app, but the OS still decides the actual grant.
- iOS/macOS: user can choose **While Using** or **Always** in system prompts/Settings. App can request upgrade, but OS may require Settings.
- Android: background location is a separate permission; on Android 10+ it often requires a Settings flow.
- Precise location is a separate grant (iOS 14+ “Precise”, Android “fine” vs “coarse”).
Selector in UI drives our requested mode; actual grant lives in OS settings.
## Settings model
Per node device:
- `location.enabledMode`: `off | whileUsing | always`
- `location.preciseEnabled`: bool
UI behavior:
- Selecting `whileUsing` requests foreground permission.
- Selecting `always` first ensures `whileUsing`, then requests background (or sends user to Settings if required).
- If OS denies requested level, revert to the highest granted level and show status.
## Permissions mapping (node.permissions)
Optional. macOS node reports `location` via the permissions map; iOS/Android may omit it.
## Command: `location.get`
Called via `node.invoke`.
Params (suggested):
```json
{
"timeoutMs": 10000,
"maxAgeMs": 15000,
"desiredAccuracy": "coarse|balanced|precise"
}
```
Response payload:
```json
{
"lat": 48.20849,
"lon": 16.37208,
"accuracyMeters": 12.5,
"altitudeMeters": 182.0,
"speedMps": 0.0,
"headingDeg": 270.0,
"timestamp": "2026-01-03T12:34:56.000Z",
"isPrecise": true,
"source": "gps|wifi|cell|unknown"
}
```
Errors (stable codes):
- `LOCATION_DISABLED`: selector is off.
- `LOCATION_PERMISSION_REQUIRED`: permission missing for requested mode.
- `LOCATION_BACKGROUND_UNAVAILABLE`: app is backgrounded but only While Using allowed.
- `LOCATION_TIMEOUT`: no fix in time.
- `LOCATION_UNAVAILABLE`: system failure / no providers.
## Background behavior (future)
Goal: model can request location even when node is backgrounded, but only when:
- User selected **Always**.
- OS grants background location.
- App is allowed to run in background for location (iOS background mode / Android foreground service or special allowance).
Push-triggered flow (future):
1) Gateway sends a push to the node (silent push or FCM data).
2) Node wakes briefly and calls `location.get` internally.
3) Node forwards payload to Gateway.
Notes:
- iOS: Always permission + background location mode required. Silent push may be throttled; expect intermittent failures.
- Android: background location may require a foreground service; otherwise, expect denial.
## Model/tooling integration
- Tool surface: `nodes` tool adds `location_get` action (node required).
- CLI: `clawdis nodes location get --node <id>`.
- Agent guidelines: only call when user enabled location and understands the scope.
## UX copy (suggested)
- Off: “Location sharing is disabled.”
- While Using: “Only when Clawdis is open.”
- Always: “Allow background location. Requires system permission.”
- Precise: “Use precise GPS location. Toggle off to share approximate location.”
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
## State model
- Sessions: events arrive with `runId` (session key). The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
- Sessions: events arrive with `runId` (per-run) plus `sessionKey` in the payload. The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
- Priority: main always wins. If main is active, its state is shown immediately. If main is idle, the most recently active non‑main session is shown. We do not flip‑flop mid‑activity; we only switch when the current session goes idle or main becomes active.
- Activity kinds:
- `job`: high‑level command execution (`state: started|streaming|done|error`).
+1 -1
View File
@@ -5,7 +5,7 @@ read_when:
---
# Web Chat (macOS app)
The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main` by default, configurable via `session.mainKey`).
The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main`, or `global` when scope is global).
- **Local mode**: connects directly to the local Gateway WebSocket.
- **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane.
+16
View File
@@ -94,6 +94,22 @@ Notes:
- Screen recordings are clamped to `<= 60s`.
- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio).
## Location (nodes)
Nodes expose `location.get` when Location is enabled in settings.
CLI helper:
```bash
clawdis nodes location get --node <idOrNameOrIp>
clawdis nodes location get --node <idOrNameOrIp> --accuracy precise --max-age 15000 --location-timeout 10000
```
Notes:
- Location is **off by default**.
- “Always” requires system permission; background fetch is best-effort.
- The response includes lat/lon, accuracy (meters), and timestamp.
## System commands (mac node)
The macOS node exposes `system.run` and `system.notify`.
+6
View File
@@ -99,6 +99,12 @@ services:
network_mode: bridge # Limited network
```
### Per-session sandbox (Clawdis-native)
Clawdis can also run **non-main sessions** inside per-session Docker containers
(`agent.sandbox`). This keeps the gateway on your host while isolating agent
tools in a hard wall container. See `docs/configuration.md` for the full config.
Expose only the services your AI needs:
- ✅ GoWA API (for WhatsApp)
- ✅ Specific HTTP APIs
+116
View File
@@ -0,0 +1,116 @@
---
summary: "Agent session tools for listing sessions, fetching history, and sending cross-session messages"
read_when:
- Adding or modifying session tools
---
# Session Tools
Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session.
## Tool Names
- `sessions_list`
- `sessions_history`
- `sessions_send`
## Key Model
- Main direct chat bucket is always the literal key `"main"`.
- Group chats use `surface:group:<id>` or `surface:channel:<id>`.
- Cron jobs use `cron:<job.id>`.
- Hooks use `hook:<uuid>` unless explicitly set.
- Node bridge uses `node-<nodeId>` unless explicitly set.
`global` and `unknown` are internal-only and never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
## sessions_list
List sessions as an array of rows.
Parameters:
- `kinds?: string[]` filter: any of `"main" | "group" | "cron" | "hook" | "node" | "other"`
- `limit?: number` max rows (default: server default, clamp e.g. 200)
- `activeMinutes?: number` only sessions updated within N minutes
- `messageLimit?: number` 0 = no messages (default 0); >0 = include last N messages
Behavior:
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
Row shape (JSON):
- `key`: session key (string)
- `kind`: `main | group | cron | hook | node | other`
- `provider`: `whatsapp | telegram | discord | signal | imessage | webchat | internal | unknown`
- `displayName` (group display label if available)
- `updatedAt` (ms)
- `sessionId`
- `model`, `contextTokens`, `totalTokens`
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
- `sendPolicy` (session override if set)
- `lastChannel`, `lastTo`
- `transcriptPath` (best-effort path derived from store dir + sessionId)
- `messages?` (only when `messageLimit > 0`)
## sessions_history
Fetch transcript for one session.
Parameters:
- `sessionKey` (required)
- `limit?: number` max messages (server clamps)
- `includeTools?: boolean` (default false)
Behavior:
- `includeTools=false` filters `role: "toolResult"` messages.
- Returns messages array in the raw transcript format.
## sessions_send
Send a message into another session.
Parameters:
- `sessionKey` (required)
- `message` (required)
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
Behavior:
- `timeoutSeconds = 0`: enqueue and return `{ runId, status: "accepted" }`.
- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`.
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
- If the run fails: `{ runId, status: "error", error }`.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdis starts an **agent-to-agent post step**:
- The agent can reply with the announcement to post to the target session.
- To stay silent, reply exactly `ANNOUNCE_SKIP`.
- Any other reply is sent to the target channel.
- The post step includes the original request and round‑1 reply in context.
## Provider Field
- For groups, `provider` is the `surface` recorded on the session entry.
- For direct chats, `provider` maps from `lastChannel`.
- For cron/hook/node, `provider` is `internal`.
- If missing, `provider` is `unknown`.
## Security / Send Policy
Policy-based blocking by surface/chat type (not per session id).
```json
{
"session": {
"sendPolicy": {
"rules": [
{
"match": { "surface": "discord", "chatType": "group" },
"action": "deny"
}
],
"default": "allow"
}
}
}
```
Runtime override (per session entry):
- `sendPolicy: "allow" | "deny"` (unset = inherit config)
- Settable via `sessions.patch` or owner-only `/send on|off|inherit`.
Enforcement points:
- `chat.send` / `agent` (gateway)
- auto-reply delivery logic
+28 -2
View File
@@ -5,7 +5,7 @@ read_when:
---
# Session Management
Clawdis treats **one session as primary**. By default the canonical key is `main` for every direct chat; no configuration is required. You can rename it via `session.mainKey` if you really want, but there is still only a single primary session. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
Clawdis treats **one session as primary**. The canonical key is fixed to `main` for direct chats (or `global` when scope is global); no configuration is required. `session.mainKey` is ignored. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Clawdis). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -26,12 +26,38 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
- Other sources:
- Cron jobs: `cron:<job.id>`
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
## Send policy (optional)
Block delivery for specific session types without listing individual ids.
```json5
{
session: {
sendPolicy: {
rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } },
{ action: "deny", match: { keyPrefix: "cron:" } }
],
default: "allow"
}
}
}
```
Runtime override (owner only):
- `/send on` → allow for this session
- `/send off` → deny for this session
- `/send inherit` → clear override and use config rules
## Configuration (optional rename example)
```json5
// ~/.clawdis/clawdis.json
@@ -41,7 +67,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
idleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json",
mainKey: "main" // optional rename; still a single primary
// mainKey is ignored; primary key is fixed to "main"
}
}
```
+16
View File
@@ -78,11 +78,13 @@ Core actions:
- `pending`, `approve`, `reject` (pairing)
- `notify` (macOS `system.notify`)
- `camera_snap`, `camera_clip`, `screen_record`
- `location_get`
Notes:
- Camera/screen commands require the node app to be foregrounded.
- Images return image blocks + `MEDIA:<path>`.
- Videos return `FILE:<path>` (mp4).
- Location returns a JSON payload (lat/lon/accuracy/timestamp).
### `cron`
Manage Gateway cron jobs and wakeups.
@@ -105,6 +107,20 @@ Core actions:
Notes:
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
### `sessions_list` / `sessions_history` / `sessions_send`
List sessions, inspect transcript history, or send to another session.
Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- `sessions_send` always runs a follow‑up **agent‑to‑agent post step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
### `discord`
Send Discord reactions, stickers, or polls.
+1 -1
View File
@@ -28,7 +28,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (if required).
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
- `--session <key>`: Session key (default: `main`, or `global` when scope is global).
- `--deliver`: Deliver assistant replies to the provider (default off).
- `--thinking <level>`: Override thinking level for sends.
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
+4 -1
View File
@@ -92,6 +92,9 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
- Caption only on first media item.
- Media fetch supports HTTP(S) and local paths.
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
- CLI: `clawdis send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
## Media limits + optimization
- Default cap: 5 MB (per media item).
@@ -123,7 +126,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- `agent.heartbeat.model` (optional override)
- `agent.heartbeat.target`
- `agent.heartbeat.to`
- `session.*` (scope, idle, store, mainKey)
- `session.*` (scope, idle, store; `mainKey` is ignored)
- `web.enabled` (disable provider startup when false)
- `web.heartbeatSeconds`
- `web.reconnect.*`
+2
View File
@@ -38,6 +38,8 @@
"scripts": {
"dev": "tsx src/index.ts",
"docs:list": "tsx scripts/docs-list.ts",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && mint broken-links",
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
"release:check": "tsx scripts/release-check.ts",
"ui:install": "pnpm -C ui install",
+1 -1
View File
@@ -7,7 +7,7 @@ PRODUCT="Clawdis"
BIN="$BUILD_PATH/debug/$PRODUCT"
printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n"
swift build -c debug --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH"
swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH"
printf "\n⏹ Stopping existing $PRODUCT...\n"
killall -q "$PRODUCT" 2>/dev/null || true
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
export DISPLAY=:1
CDP_PORT="${CLAWDIS_BROWSER_CDP_PORT:-9222}"
VNC_PORT="${CLAWDIS_BROWSER_VNC_PORT:-5900}"
NOVNC_PORT="${CLAWDIS_BROWSER_NOVNC_PORT:-6080}"
ENABLE_NOVNC="${CLAWDIS_BROWSER_ENABLE_NOVNC:-1}"
HEADLESS="${CLAWDIS_BROWSER_HEADLESS:-0}"
mkdir -p /workspace/.chrome
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
if [[ "${HEADLESS}" == "1" ]]; then
CHROME_ARGS=(
"--headless=new"
"--disable-gpu"
)
else
CHROME_ARGS=()
fi
CHROME_ARGS+=(
"--remote-debugging-address=0.0.0.0"
"--remote-debugging-port=${CDP_PORT}"
"--user-data-dir=/workspace/.chrome"
"--no-first-run"
"--no-default-browser-check"
"--disable-dev-shm-usage"
"--disable-background-networking"
"--disable-features=TranslateUI"
"--metrics-recording-only"
"--no-sandbox"
)
chromium "${CHROME_ARGS[@]}" about:blank &
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost &
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
fi
wait -n
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="clawdis-sandbox-browser:bookworm-slim"
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox-browser .
echo "Built ${IMAGE_NAME}"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="clawdis-sandbox:bookworm-slim"
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox .
echo "Built ${IMAGE_NAME}"
+48 -24
View File
@@ -1,43 +1,67 @@
---
name: apple-reminders
description: Manage Apple Reminders via the `memo` CLI on macOS (create, complete, and delete reminders). Use when a user asks Clawdis to add a reminder, mark reminders as done, or manage their reminder list.
homepage: https://github.com/antoniorodr/memo
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
homepage: https://github.com/steipete/remindctl
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
---
# Apple Reminders CLI
# Apple Reminders CLI (remindctl)
Use `memo rem` to manage Apple Reminders directly from the terminal. Create, complete, and delete reminders with simple commands.
Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output.
Setup
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
- Manual (pip): `pip install .` (after cloning the repo)
- macOS-only; if prompted, grant Automation access to Reminders.app.
- Install (Homebrew): `brew install steipete/tap/remindctl`
- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
- macOS-only; grant Reminders permission when prompted.
Permissions
- Check status: `remindctl status`
- Request access: `remindctl authorize`
View Reminders
- List all reminders: `memo rem`
- Default (today): `remindctl`
- Today: `remindctl today`
- Tomorrow: `remindctl tomorrow`
- Week: `remindctl week`
- Overdue: `remindctl overdue`
- Upcoming: `remindctl upcoming`
- Completed: `remindctl completed`
- All: `remindctl all`
- Specific date: `remindctl 2026-01-04`
Manage Lists
- List all lists: `remindctl list`
- Show list: `remindctl list Work`
- Create list: `remindctl list Projects --create`
- Rename list: `remindctl list Work --rename Office`
- Delete list: `remindctl list Work --delete`
Create Reminders
- Add a new reminder: `memo rem -a`
- Opens interactive prompt to create a reminder.
- Quick add: `memo rem -a "Buy groceries"`
- Quick add: `remindctl add "Buy milk"`
- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`
Edit Reminders
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`
Complete Reminders
- Mark reminder as done: `memo rem -c`
- Interactive selection of reminder to complete.
- Complete by id: `remindctl complete 1 2 3`
Delete Reminders
- Delete a reminder: `memo rem -d`
- Interactive selection of reminder to delete.
- Delete by id: `remindctl delete 4A83 --force`
Examples
- Add a quick reminder: `memo rem -a "Call dentist tomorrow"`
- List all reminders: `memo rem`
- Complete a reminder: `memo rem -c` (then select from list)
- Delete a reminder: `memo rem -d` (then select from list)
Output Formats
- JSON (scripting): `remindctl today --json`
- Plain TSV: `remindctl today --plain`
- Counts only: `remindctl today --quiet`
Date Formats
Accepted by `--due` and date filters:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- ISO 8601 (`2026-01-04T12:34:56Z`)
Notes
- macOS-only.
- Requires Apple Reminders.app to be accessible.
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
- The `memo` CLI shares the same installation for both Notes and Reminders functionality.
- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
- If running over SSH, grant access on the Mac that runs the command.
+92 -2
View File
@@ -9,7 +9,7 @@ metadata: {"clawdis":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekab
Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI
elements, drive input, and manage apps/windows/menus. Commands share a snapshot
cache and most support `--json-output` for scripting. Run `peekaboo` or
cache and support `--json`/`-j` for scripting. Run `peekaboo` or
`peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata.
Tip: run via `polter peekaboo` to ensure fresh builds.
@@ -33,7 +33,7 @@ Interaction
- `drag`: drag & drop across elements/coords/Dock
- `hotkey`: modifier combos like `cmd,shift,t`
- `move`: cursor positioning with optional smoothing
- `paste`: set clipboard → paste → restore
- `paste`: set clipboard -> paste -> restore
- `press`: special-key sequences with repeats
- `scroll`: directional scrolling (targeted + smooth)
- `swipe`: gesture-style drags between targets
@@ -58,6 +58,96 @@ Global runtime flags
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
- `--no-remote`, `--bridge-socket <path>`
## Quickstart (happy path)
```bash
peekaboo permissions
peekaboo list apps --json
peekaboo see --annotate --path /tmp/peekaboo-see.png
peekaboo click --on B1
peekaboo type "Hello" --return
```
## Common targeting parameters (most interaction commands)
- App/window: `--app`, `--pid`, `--window-title`, `--window-id`, `--window-index`
- Snapshot targeting: `--snapshot` (ID from `see`; defaults to latest)
- Element/coords: `--on`/`--id` (element ID), `--coords x,y`
- Focus control: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`,
`--focus-timeout-seconds`, `--focus-retry-count`
## Common capture parameters
- Output: `--path`, `--format png|jpg`, `--retina`
- Targeting: `--mode screen|window|frontmost`, `--screen-index`,
`--window-title`, `--window-id`
- Analysis: `--analyze "prompt"`, `--annotate`
- Capture engine: `--capture-engine auto|classic|cg|modern|sckit`
## Common motion/typing parameters
- Timing: `--duration` (drag/swipe), `--steps`, `--delay` (type/scroll/press)
- Human-ish movement: `--profile human|linear`, `--wpm` (typing)
- Scroll: `--direction up|down|left|right`, `--amount <ticks>`, `--smooth`
## Examples
### See -> click -> type (most reliable flow)
```bash
peekaboo see --app Safari --window-title "Login" --annotate --path /tmp/see.png
peekaboo click --on B3 --app Safari
peekaboo type "user@example.com" --app Safari
peekaboo press tab --count 1 --app Safari
peekaboo type "supersecret" --app Safari --return
```
### Target by window id
```bash
peekaboo list windows --app "Visual Studio Code" --json
peekaboo click --window-id 12345 --coords 120,160
peekaboo type "Hello from Peekaboo" --window-id 12345
```
### Capture screenshots + analyze
```bash
peekaboo image --mode screen --screen-index 0 --retina --path /tmp/screen.png
peekaboo image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"
peekaboo see --mode screen --screen-index 0 --analyze "Summarize the dashboard"
```
### Live capture (motion-aware)
```bash
peekaboo capture live --mode region --region 100,100,800,600 --duration 30 \
--active-fps 8 --idle-fps 2 --highlight-changes --path /tmp/capture
```
### App + window management
```bash
peekaboo app launch "Safari" --open https://example.com
peekaboo window focus --app Safari --window-title "Example"
peekaboo window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800
peekaboo app quit --app Safari
```
### Menus, menubar, dock
```bash
peekaboo menu click --app Safari --item "New Window"
peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts"
peekaboo menu click-extra --title "WiFi"
peekaboo dock launch Safari
peekaboo menubar list --json
```
### Mouse + gesture input
```bash
peekaboo move 500,300 --smooth
peekaboo drag --from B1 --to T2
peekaboo swipe --from-coords 100,500 --to-coords 100,200 --duration 800
peekaboo scroll --direction down --amount 6 --smooth
```
### Keyboard input
```bash
peekaboo hotkey --keys "cmd,shift,t"
peekaboo press escape
peekaboo type "Line 1\nLine 2" --delay 10
```
Notes
- Requires Screen Recording + Accessibility permissions.
- Use `peekaboo see --annotate` to identify targets before clicking.
+140 -11
View File
@@ -1,7 +1,9 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
@@ -18,6 +20,7 @@ import {
markExited,
setJobTtlMs,
} from "./bash-process-registry.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import {
getShellConfig,
killProcessTree,
@@ -31,6 +34,8 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000,
150_000,
);
const DEFAULT_PATH =
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const stringEnum = (
values: readonly string[],
@@ -47,12 +52,20 @@ const stringEnum = (
export type BashToolDefaults = {
backgroundMs?: number;
timeoutSec?: number;
sandbox?: BashSandboxConfig;
};
export type ProcessToolDefaults = {
cleanupMs?: number;
};
export type BashSandboxConfig = {
containerName: string;
workspaceDir: string;
containerWorkdir: string;
env?: Record<string, string>;
};
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
workdir: Type.Optional(
@@ -136,19 +149,55 @@ export function createBashTool(
const startedAt = Date.now();
const sessionId = randomUUID();
const warnings: string[] = [];
const workdir = resolveWorkdir(
params.workdir?.trim() || process.cwd(),
warnings,
);
const sandbox = defaults?.sandbox;
const rawWorkdir = params.workdir?.trim() || process.cwd();
let workdir = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir;
if (sandbox) {
const resolved = await resolveSandboxWorkdir({
workdir: rawWorkdir,
sandbox,
warnings,
});
workdir = resolved.hostWorkdir;
containerWorkdir = resolved.containerWorkdir;
} else {
workdir = resolveWorkdir(rawWorkdir, warnings);
}
const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env;
const child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
});
const baseEnv = coerceEnv(process.env);
const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
const env = sandbox
? buildSandboxEnv({
paramsEnv: params.env,
sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
const child = sandbox
? spawn(
"docker",
buildDockerExecArgs({
containerName: sandbox.containerName,
command: params.command,
workdir: containerWorkdir ?? sandbox.containerWorkdir,
env,
tty: false,
}),
{
cwd: workdir,
env: process.env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
},
)
: spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
});
const session = {
id: sessionId,
@@ -776,6 +825,86 @@ export function createProcessTool(
export const processTool = createProcessTool();
function buildSandboxEnv(params: {
paramsEnv?: Record<string, string>;
sandboxEnv?: Record<string, string>;
containerWorkdir: string;
}) {
const env: Record<string, string> = {
PATH: DEFAULT_PATH,
HOME: params.containerWorkdir,
};
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
env[key] = value;
}
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
env[key] = value;
}
return env;
}
function coerceEnv(env?: NodeJS.ProcessEnv | Record<string, string>) {
const record: Record<string, string> = {};
if (!env) return record;
for (const [key, value] of Object.entries(env)) {
if (typeof value === "string") record[key] = value;
}
return record;
}
function buildDockerExecArgs(params: {
containerName: string;
command: string;
workdir?: string;
env: Record<string, string>;
tty: boolean;
}) {
const args = ["exec", "-i"];
if (params.tty) args.push("-t");
if (params.workdir) {
args.push("-w", params.workdir);
}
for (const [key, value] of Object.entries(params.env)) {
args.push("-e", `${key}=${value}`);
}
args.push(params.containerName, "sh", "-lc", params.command);
return args;
}
async function resolveSandboxWorkdir(params: {
workdir: string;
sandbox: BashSandboxConfig;
warnings: string[];
}) {
const fallback = params.sandbox.workspaceDir;
try {
const resolved = await assertSandboxPath({
filePath: params.workdir,
cwd: process.cwd(),
root: params.sandbox.workspaceDir,
});
const stats = await fs.stat(resolved.resolved);
if (!stats.isDirectory()) {
throw new Error("workdir is not a directory");
}
const relative = resolved.relative
? resolved.relative.split(path.sep).join(path.posix.sep)
: "";
const containerWorkdir = relative
? path.posix.join(params.sandbox.containerWorkdir, relative)
: params.sandbox.containerWorkdir;
return { hostWorkdir: resolved.resolved, containerWorkdir };
} catch {
params.warnings.push(
`Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`,
);
return {
hostWorkdir: fallback,
containerWorkdir: params.sandbox.containerWorkdir,
};
}
}
function killSession(session: {
pid?: number;
child?: ChildProcessWithoutNullStreams;
+247
View File
@@ -0,0 +1,247 @@
import { describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: { mainKey: "main", scope: "per-sender" },
}),
resolveGatewayPort: () => 18789,
}));
import { createClawdisTools } from "./clawdis-tools.js";
describe("sessions tools", () => {
it("sessions_list filters kinds and includes messages", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.list") {
return {
path: "/tmp/sessions.json",
sessions: [
{
key: "main",
kind: "direct",
sessionId: "s-main",
updatedAt: 10,
lastChannel: "whatsapp",
},
{
key: "discord:group:dev",
kind: "group",
sessionId: "s-group",
updatedAt: 11,
surface: "discord",
displayName: "discord:g-dev",
},
{
key: "cron:job-1",
kind: "direct",
sessionId: "s-cron",
updatedAt: 9,
},
{ key: "global", kind: "global" },
{ key: "unknown", kind: "unknown" },
],
};
}
if (request.method === "chat.history") {
return {
messages: [
{ role: "toolResult", content: [] },
{
role: "assistant",
content: [{ type: "text", text: "hi" }],
},
],
};
}
return {};
});
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_list",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_list tool");
const result = await tool.execute("call1", { messageLimit: 1 });
const details = result.details as {
sessions?: Array<Record<string, unknown>>;
};
expect(details.sessions).toHaveLength(3);
const main = details.sessions?.find((s) => s.key === "main");
expect(main?.provider).toBe("whatsapp");
expect(main?.messages?.length).toBe(1);
expect(main?.messages?.[0]?.role).toBe("assistant");
const cronOnly = await tool.execute("call2", { kinds: ["cron"] });
const cronDetails = cronOnly.details as {
sessions?: Array<Record<string, unknown>>;
};
expect(cronDetails.sessions).toHaveLength(1);
expect(cronDetails.sessions?.[0]?.kind).toBe("cron");
});
it("sessions_history filters tool messages by default", async () => {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "chat.history") {
return {
messages: [
{ role: "toolResult", content: [] },
{ role: "assistant", content: [{ type: "text", text: "ok" }] },
],
};
}
return {};
});
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_history",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_history tool");
const result = await tool.execute("call3", { sessionKey: "main" });
const details = result.details as { messages?: unknown[] };
expect(details.messages).toHaveLength(1);
expect(details.messages?.[0]?.role).toBe("assistant");
const withTools = await tool.execute("call4", {
sessionKey: "main",
includeTools: true,
});
const withToolsDetails = withTools.details as { messages?: unknown[] };
expect(withToolsDetails.messages).toHaveLength(2);
});
it("sessions_send supports fire-and-forget and wait", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let _historyCallCount = 0;
let sendCallCount = 0;
let waitRunId: string | undefined;
let nextHistoryIsWaitReply = false;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { message?: string } | undefined;
if (params?.message === "wait") {
waitRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 1234 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
if (params?.runId && params.runId === waitRunId) {
nextHistoryIsWaitReply = true;
}
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
_historyCallCount += 1;
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
nextHistoryIsWaitReply = false;
return {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text,
},
],
timestamp: 20,
},
],
};
}
if (request.method === "send") {
sendCallCount += 1;
return { messageId: "m1" };
}
return {};
});
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_send",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const fire = await tool.execute("call5", {
sessionKey: "main",
message: "ping",
timeoutSeconds: 0,
});
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
const waitPromise = tool.execute("call6", {
sessionKey: "main",
message: "wait",
timeoutSeconds: 1,
});
const waited = await waitPromise;
expect(waited.details).toMatchObject({
status: "ok",
reply: "done",
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history",
);
expect(agentCalls).toHaveLength(4);
for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" });
}
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
),
).toBe(true);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent post step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(3);
expect(historyOnlyCalls).toHaveLength(3);
expect(
waitCalls.some(
(call) =>
typeof (call.params as { afterMs?: number })?.afterMs === "number",
),
).toBe(true);
expect(sendCallCount).toBe(0);
});
});
+793 -7
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
@@ -40,7 +41,11 @@ import {
writeScreenRecordToFile,
} from "../cli/nodes-screen.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { type DiscordActionConfig, loadConfig } from "../config/config.js";
import {
type ClawdisConfig,
type DiscordActionConfig,
loadConfig,
} from "../config/config.js";
import {
addRoleDiscord,
banMemberDiscord,
@@ -208,6 +213,130 @@ function jsonResult(payload: unknown): AgentToolResult<unknown> {
};
}
type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
model?: string;
contextTokens?: number | null;
totalTokens?: number | null;
thinkingLevel?: string;
verboseLevel?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
sendPolicy?: string;
lastChannel?: string;
lastTo?: string;
transcriptPath?: string;
messages?: unknown[];
};
function normalizeKey(value?: string) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function resolveMainSessionAlias(cfg: ClawdisConfig) {
const mainKey = normalizeKey(cfg.session?.mainKey) ?? "main";
const scope = cfg.session?.scope ?? "per-sender";
const alias = scope === "global" ? "global" : mainKey;
return { mainKey, alias, scope };
}
function resolveDisplaySessionKey(params: {
key: string;
alias: string;
mainKey: string;
}) {
if (params.key === params.alias) return "main";
if (params.key === params.mainKey) return "main";
return params.key;
}
function resolveInternalSessionKey(params: {
key: string;
alias: string;
mainKey: string;
}) {
if (params.key === "main") return params.alias;
return params.key;
}
function classifySessionKind(params: {
key: string;
gatewayKind?: string | null;
alias: string;
mainKey: string;
}): SessionKind {
const key = params.key;
if (key === params.alias || key === params.mainKey) return "main";
if (key.startsWith("cron:")) return "cron";
if (key.startsWith("hook:")) return "hook";
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
if (params.gatewayKind === "group") return "group";
if (
key.startsWith("group:") ||
key.includes(":group:") ||
key.includes(":channel:")
) {
return "group";
}
return "other";
}
function deriveProvider(params: {
key: string;
kind: SessionKind;
surface?: string | null;
lastChannel?: string | null;
}): string {
if (
params.kind === "cron" ||
params.kind === "hook" ||
params.kind === "node"
)
return "internal";
const surface = normalizeKey(params.surface ?? undefined);
if (surface) return surface;
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
if (lastChannel) return lastChannel;
const parts = params.key.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts[0];
}
return "unknown";
}
function stripToolMessages(messages: unknown[]): unknown[] {
return messages.filter((msg) => {
if (!msg || typeof msg !== "object") return true;
const role = (msg as { role?: unknown }).role;
return role !== "toolResult";
});
}
function extractAssistantText(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined;
if ((message as { role?: unknown }).role !== "assistant") return undefined;
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) return undefined;
const chunks: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
chunks.push(text);
}
}
const joined = chunks.join("").trim();
return joined ? joined : undefined;
}
async function imageResult(params: {
label: string;
path: string;
@@ -256,7 +385,7 @@ async function imageResultFromFile(params: {
function resolveBrowserBaseUrl(controlUrl?: string) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled) {
if (!resolved.enabled && !controlUrl?.trim()) {
throw new Error(
"Browser control is disabled. Set browser.enabled=true in ~/.clawdis/clawdis.json.",
);
@@ -575,7 +704,9 @@ const BrowserToolSchema = Type.Union([
}),
]);
function createBrowserTool(): AnyAgentTool {
function createBrowserTool(opts?: {
defaultControlUrl?: string;
}): AnyAgentTool {
return {
label: "Browser",
name: "browser",
@@ -586,7 +717,9 @@ function createBrowserTool(): AnyAgentTool {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const baseUrl = resolveBrowserBaseUrl(controlUrl);
const baseUrl = resolveBrowserBaseUrl(
controlUrl ?? opts?.defaultControlUrl,
);
switch (action) {
case "status":
@@ -1084,6 +1217,22 @@ const NodesToolSchema = Type.Union([
includeAudio: Type.Optional(Type.Boolean()),
outPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("location_get"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: Type.Optional(
Type.Union([
Type.Literal("coarse"),
Type.Literal("balanced"),
Type.Literal("precise"),
]),
),
}),
]);
function createNodesTool(): AnyAgentTool {
@@ -1091,7 +1240,7 @@ function createNodesTool(): AnyAgentTool {
label: "Nodes",
name: "nodes",
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen).",
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location).",
parameters: NodesToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -1383,6 +1532,37 @@ function createNodesTool(): AnyAgentTool {
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" &&
Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
}
@@ -2304,13 +2484,619 @@ function createGatewayTool(): AnyAgentTool {
};
}
export function createClawdisTools(): AnyAgentTool[] {
const SessionsListToolSchema = Type.Object({
kinds: Type.Optional(Type.Array(Type.String())),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
messageLimit: Type.Optional(Type.Integer({ minimum: 0 })),
});
const SessionsHistoryToolSchema = Type.Object({
sessionKey: Type.String(),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
includeTools: Type.Optional(Type.Boolean()),
});
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
function createSessionsListTool(): AnyAgentTool {
return {
label: "Sessions",
name: "sessions_list",
description: "List sessions with optional filters and last messages.",
parameters: SessionsListToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
value.trim().toLowerCase(),
);
const allowedKindsList = (kindsRaw ?? []).filter((value) =>
["main", "group", "cron", "hook", "node", "other"].includes(value),
);
const allowedKinds = allowedKindsList.length
? new Set(allowedKindsList)
: undefined;
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
const activeMinutes =
typeof params.activeMinutes === "number" &&
Number.isFinite(params.activeMinutes)
? Math.max(1, Math.floor(params.activeMinutes))
: undefined;
const messageLimitRaw =
typeof params.messageLimit === "number" &&
Number.isFinite(params.messageLimit)
? Math.max(0, Math.floor(params.messageLimit))
: 0;
const messageLimit = Math.min(messageLimitRaw, 20);
const list = (await callGateway({
method: "sessions.list",
params: {
limit,
activeMinutes,
includeGlobal: true,
includeUnknown: true,
},
})) as {
path?: string;
sessions?: Array<Record<string, unknown>>;
};
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const rows: SessionListRow[] = [];
for (const entry of sessions) {
if (!entry || typeof entry !== "object") continue;
const key = typeof entry.key === "string" ? entry.key : "";
if (!key) continue;
if (key === "unknown") continue;
if (key === "global" && alias !== "global") continue;
const gatewayKind =
typeof entry.kind === "string" ? entry.kind : undefined;
const kind = classifySessionKind({ key, gatewayKind, alias, mainKey });
if (allowedKinds && !allowedKinds.has(kind)) continue;
const displayKey = resolveDisplaySessionKey({
key,
alias,
mainKey,
});
const surface =
typeof entry.surface === "string" ? entry.surface : undefined;
const lastChannel =
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const provider = deriveProvider({
key,
kind,
surface,
lastChannel,
});
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : undefined;
const transcriptPath =
sessionId && storePath
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
: undefined;
const row: SessionListRow = {
key: displayKey,
kind,
provider,
displayName:
typeof entry.displayName === "string"
? entry.displayName
: undefined,
updatedAt:
typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
sessionId,
model: typeof entry.model === "string" ? entry.model : undefined,
contextTokens:
typeof entry.contextTokens === "number"
? entry.contextTokens
: undefined,
totalTokens:
typeof entry.totalTokens === "number"
? entry.totalTokens
: undefined,
thinkingLevel:
typeof entry.thinkingLevel === "string"
? entry.thinkingLevel
: undefined,
verboseLevel:
typeof entry.verboseLevel === "string"
? entry.verboseLevel
: undefined,
systemSent:
typeof entry.systemSent === "boolean"
? entry.systemSent
: undefined,
abortedLastRun:
typeof entry.abortedLastRun === "boolean"
? entry.abortedLastRun
: undefined,
sendPolicy:
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastChannel,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
transcriptPath,
};
if (messageLimit > 0) {
const resolvedKey = resolveInternalSessionKey({
key: displayKey,
alias,
mainKey,
});
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit: messageLimit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(history?.messages)
? history.messages
: [];
const filtered = stripToolMessages(rawMessages);
row.messages =
filtered.length > messageLimit
? filtered.slice(-messageLimit)
: filtered;
}
rows.push(row);
}
return jsonResult({
count: rows.length,
sessions: rows,
});
},
};
}
function createSessionsHistoryTool(): AnyAgentTool {
return {
label: "Session History",
name: "sessions_history",
description: "Fetch message history for a session.",
parameters: SessionsHistoryToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
const includeTools = Boolean(params.includeTools);
const result = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(result?.messages)
? result.messages
: [];
const messages = includeTools
? rawMessages
: stripToolMessages(rawMessages);
return jsonResult({
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
messages,
});
},
};
}
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
type AnnounceTarget = {
channel: string;
to: string;
};
function resolveAnnounceTargetFromKey(
sessionKey: string,
): AnnounceTarget | null {
const parts = sessionKey.split(":").filter(Boolean);
if (parts.length < 3) return null;
const [surface, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null;
const id = rest.join(":").trim();
if (!id) return null;
if (!surface) return null;
const channel = surface.toLowerCase();
if (channel === "discord") {
return { channel, to: `channel:${id}` };
}
if (channel === "signal") {
return { channel, to: `group:${id}` };
}
return { channel, to: id };
}
function buildAgentToAgentMessageContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
}) {
const lines = [
"Agent-to-agent message context:",
params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Requester surface: ${params.requesterSurface}.`
: undefined,
`Target session: ${params.targetSessionKey}.`,
].filter(Boolean);
return lines.join("\n");
}
function buildAgentToAgentPostContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
targetChannel?: string;
originalMessage: string;
roundOneReply?: string;
}) {
const lines = [
"Agent-to-agent post step:",
params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Requester surface: ${params.requesterSurface}.`
: undefined,
`Target session: ${params.targetSessionKey}.`,
params.targetChannel
? `Target surface: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}`
: "Round 1 reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.",
"After this reply, the agent-to-agent conversation is over.",
].filter(Boolean);
return lines.join("\n");
}
function isAnnounceSkip(text?: string) {
return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN;
}
function createSessionsSendTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
}): AnyAgentTool {
return {
label: "Session Send",
name: "sessions_send",
description: "Send a message into another session.",
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const timeoutSeconds =
typeof params.timeoutSeconds === "number" &&
Number.isFinite(params.timeoutSeconds)
? Math.max(0, Math.floor(params.timeoutSeconds))
: 30;
const timeoutMs = timeoutSeconds * 1000;
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
const displayKey = resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
});
const agentMessageContext = buildAgentToAgentMessageContext({
requesterSessionKey: opts?.agentSessionKey,
requesterSurface: opts?.agentSurface,
targetSessionKey: displayKey,
});
const sendParams = {
message,
sessionKey: resolvedKey,
idempotencyKey,
deliver: false,
lane: "nested",
extraSystemPrompt: agentMessageContext,
};
const resolveAnnounceTarget =
async (): Promise<AnnounceTarget | null> => {
const parsed = resolveAnnounceTargetFromKey(resolvedKey);
if (parsed) return parsed;
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: true,
includeUnknown: true,
limit: 200,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const match =
sessions.find((entry) => entry?.key === resolvedKey) ??
sessions.find((entry) => entry?.key === displayKey);
const channel =
typeof match?.lastChannel === "string"
? match.lastChannel
: undefined;
const to =
typeof match?.lastTo === "string" ? match.lastTo : undefined;
if (channel && to) return { channel, to };
} catch {
// ignore; fall through to null
}
return null;
};
const runAgentToAgentPost = async (roundOneReply?: string) => {
const announceTarget = await resolveAnnounceTarget();
try {
const postPrompt = buildAgentToAgentPostContext({
requesterSessionKey: opts?.agentSessionKey,
requesterSurface: opts?.agentSurface,
targetSessionKey: displayKey,
targetChannel: announceTarget?.channel ?? "unknown",
originalMessage: message,
roundOneReply,
});
const postIdem = crypto.randomUUID();
const postResponse = (await callGateway({
method: "agent",
params: {
message: "Agent-to-agent post step.",
sessionKey: resolvedKey,
idempotencyKey: postIdem,
deliver: false,
lane: "nested",
extraSystemPrompt: postPrompt,
},
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const postRunId =
typeof postResponse?.runId === "string" && postResponse.runId
? postResponse.runId
: postIdem;
const postAcceptedAt =
typeof postResponse?.acceptedAt === "number"
? postResponse.acceptedAt
: undefined;
const postWaitMs = Math.min(announceTimeoutMs, 60_000);
const postWait = (await callGateway({
method: "agent.wait",
params: {
runId: postRunId,
afterMs: postAcceptedAt,
timeoutMs: postWaitMs,
},
timeoutMs: postWaitMs + 2000,
})) as { status?: string };
if (postWait?.status === "ok") {
const postHistory = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] };
const postFiltered = stripToolMessages(
Array.isArray(postHistory?.messages) ? postHistory.messages : [],
);
const postLast =
postFiltered.length > 0
? postFiltered[postFiltered.length - 1]
: undefined;
const postReply = postLast
? extractAssistantText(postLast)
: undefined;
if (
announceTarget &&
postReply &&
postReply.trim() &&
!isAnnounceSkip(postReply)
) {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: postReply.trim(),
provider: announceTarget.channel,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
}
}
} catch {
// Best-effort announce; ignore failures to avoid breaking the caller response.
}
};
if (timeoutSeconds === 0) {
try {
const response = (await callGateway({
method: "agent",
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string };
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
void runAgentToAgentPost();
return jsonResult({
runId,
status: "accepted",
sessionKey: displayKey,
});
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
return jsonResult({
runId,
status: "error",
error: message,
sessionKey: displayKey,
});
}
}
let acceptedAt: number | undefined;
try {
const response = (await callGateway({
method: "agent",
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
if (typeof response?.acceptedAt === "number") {
acceptedAt = response.acceptedAt;
}
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
return jsonResult({
runId,
status: "error",
error: message,
sessionKey: displayKey,
});
}
let waitStatus: string | undefined;
let waitError: string | undefined;
try {
const wait = (await callGateway({
method: "agent.wait",
params: {
runId,
afterMs: acceptedAt,
timeoutMs,
},
timeoutMs: timeoutMs + 2000,
})) as { status?: string; error?: string };
waitStatus = typeof wait?.status === "string" ? wait.status : undefined;
waitError = typeof wait?.error === "string" ? wait.error : undefined;
} catch (err) {
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
return jsonResult({
runId,
status: message.includes("gateway timeout") ? "timeout" : "error",
error: message,
sessionKey: displayKey,
});
}
if (waitStatus === "timeout") {
return jsonResult({
runId,
status: "timeout",
error: waitError,
sessionKey: displayKey,
});
}
if (waitStatus === "error") {
return jsonResult({
runId,
status: "error",
error: waitError ?? "agent error",
sessionKey: displayKey,
});
}
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const reply = last ? extractAssistantText(last) : undefined;
void runAgentToAgentPost(reply ?? undefined);
return jsonResult({
runId,
status: "ok",
reply,
sessionKey: displayKey,
});
},
};
}
export function createClawdisTools(options?: {
browserControlUrl?: string;
agentSessionKey?: string;
agentSurface?: string;
}): AnyAgentTool[] {
return [
createBrowserTool(),
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createGatewayTool(),
createSessionsListTool(),
createSessionsHistoryTool(),
createSessionsSendTool({
agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface,
}),
];
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js";
import type { SandboxContext } from "./sandbox.js";
describe("buildEmbeddedSandboxInfo", () => {
it("returns undefined when sandbox is missing", () => {
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
});
it("maps sandbox context into prompt info", () => {
const sandbox = {
enabled: true,
sessionKey: "session:test",
workspaceDir: "/tmp/clawdis-sandbox",
containerName: "clawdis-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "clawdis-sandbox:bookworm-slim",
containerPrefix: "clawdis-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["bash"],
deny: ["browser"],
},
browser: {
controlUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
containerName: "clawdis-sbx-browser-test",
},
} satisfies SandboxContext;
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
enabled: true,
workspaceDir: "/tmp/clawdis-sandbox",
browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
});
});
});
+84 -7
View File
@@ -26,12 +26,12 @@ import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import type { ClawdisConfig } from "../config/config.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import {
type enqueueCommand,
enqueueCommandInLane,
} from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveClawdisAgentDir } from "./agent-paths.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
@@ -48,6 +48,7 @@ import {
} from "./pi-embedded-subscribe.js";
import { extractAssistantText } from "./pi-embedded-utils.js";
import { createClawdisCodingTools } from "./pi-tools.js";
import { resolveSandboxContext } from "./sandbox.js";
import {
applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot,
@@ -94,13 +95,26 @@ type EmbeddedPiQueueHandle = {
abort: () => void;
};
const log = createSubsystemLogger("agent/embedded");
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
type EmbeddedRunWaiter = {
resolve: (ended: boolean) => void;
timer: NodeJS.Timeout;
};
const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
const OAUTH_FILENAME = "oauth.json";
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
let oauthStorageConfigured = false;
type OAuthStorage = Record<string, OAuthCredentials>;
type EmbeddedSandboxInfo = {
enabled: boolean;
workspaceDir?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
};
function resolveSessionLane(key: string) {
const cleaned = key.trim() || "main";
@@ -112,6 +126,18 @@ function resolveGlobalLane(lane?: string) {
return cleaned ? cleaned : "main";
}
export function buildEmbeddedSandboxInfo(
sandbox?: Awaited<ReturnType<typeof resolveSandboxContext>>,
): EmbeddedSandboxInfo | undefined {
if (!sandbox?.enabled) return undefined;
return {
enabled: true,
workspaceDir: sandbox.workspaceDir,
browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
};
}
function resolveClawdisOAuthPath(): string {
const overrideDir =
process.env.CLAWDIS_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
@@ -226,6 +252,46 @@ export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
return handle.isStreaming();
}
export function waitForEmbeddedPiRunEnd(
sessionId: string,
timeoutMs = 15_000,
): Promise<boolean> {
if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId))
return Promise.resolve(true);
return new Promise((resolve) => {
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId) ?? new Set();
const waiter: EmbeddedRunWaiter = {
resolve,
timer: setTimeout(
() => {
waiters.delete(waiter);
if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId);
resolve(false);
},
Math.max(100, timeoutMs),
),
};
waiters.add(waiter);
EMBEDDED_RUN_WAITERS.set(sessionId, waiters);
if (!ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
waiters.delete(waiter);
if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId);
clearTimeout(waiter.timer);
resolve(true);
}
});
}
function notifyEmbeddedRunEnded(sessionId: string) {
const waiters = EMBEDDED_RUN_WAITERS.get(sessionId);
if (!waiters || waiters.size === 0) return;
EMBEDDED_RUN_WAITERS.delete(sessionId);
for (const waiter of waiters) {
clearTimeout(waiter.timer);
waiter.resolve(true);
}
}
export function resolveEmbeddedSessionLane(key: string) {
return resolveSessionLane(key);
}
@@ -383,7 +449,7 @@ export async function runEmbeddedPiAgent(params: {
const thinkingLevel = mapThinkingLevel(params.thinkLevel);
defaultRuntime.log?.(
log.debug(
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`,
);
@@ -408,6 +474,12 @@ export async function runEmbeddedPiAgent(params: {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
@@ -424,7 +496,9 @@ export async function runEmbeddedPiAgent(params: {
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash,
sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
@@ -434,6 +508,7 @@ export async function runEmbeddedPiAgent(params: {
node: process.version,
model: `${provider}/${modelId}`,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
@@ -443,6 +518,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
runtimeInfo,
sandboxInfo,
}),
contextFiles,
skills: promptSkills,
@@ -515,14 +591,14 @@ export async function runEmbeddedPiAgent(params: {
let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout(
() => {
defaultRuntime.log(
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
abortRun();
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return;
defaultRuntime.log(
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}, 10_000);
@@ -548,7 +624,7 @@ export async function runEmbeddedPiAgent(params: {
let promptError: unknown = null;
try {
const promptStartedAt = Date.now();
defaultRuntime.log?.(
log.debug(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
);
try {
@@ -556,7 +632,7 @@ export async function runEmbeddedPiAgent(params: {
} catch (err) {
promptError = err;
} finally {
defaultRuntime.log?.(
log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
);
}
@@ -572,6 +648,7 @@ export async function runEmbeddedPiAgent(params: {
unsubscribe();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId);
}
session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort);
@@ -645,7 +722,7 @@ export async function runEmbeddedPiAgent(params: {
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
);
defaultRuntime.log?.(
log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
);
return {
+8 -11
View File
@@ -4,8 +4,8 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { defaultRuntime } from "../runtime.js";
import {
extractAssistantText,
inferToolMetaFromArgs,
@@ -15,6 +15,7 @@ const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
const TOOL_RESULT_MAX_CHARS = 8000;
const log = createSubsystemLogger("agent/embedded");
export type BlockReplyChunking = {
minChars: number;
@@ -324,7 +325,7 @@ export function subscribeEmbeddedPiSession(params: {
const args = (evt as AgentEvent & { args: unknown }).args;
const meta = inferToolMetaFromArgs(toolName, args);
toolMetaById.set(toolCallId, meta);
defaultRuntime.log?.(
log.debug(
`embedded run tool start: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
@@ -590,21 +591,19 @@ export function subscribeEmbeddedPiSession(params: {
const toolCallId = String(
(evt as AgentEvent & { toolCallId: string }).toolCallId,
);
defaultRuntime.log?.(
log.debug(
`embedded run tool end: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
}
if (evt.type === "agent_start") {
defaultRuntime.log?.(`embedded run agent start: runId=${params.runId}`);
log.debug(`embedded run agent start: runId=${params.runId}`);
}
if (evt.type === "auto_compaction_start") {
compactionInFlight = true;
ensureCompactionPromise();
defaultRuntime.log?.(
`embedded run compaction start: runId=${params.runId}`,
);
log.debug(`embedded run compaction start: runId=${params.runId}`);
}
if (evt.type === "auto_compaction_end") {
@@ -613,16 +612,14 @@ export function subscribeEmbeddedPiSession(params: {
if (willRetry) {
noteCompactionRetry();
resetForCompactionRetry();
defaultRuntime.log?.(
`embedded run compaction retry: runId=${params.runId}`,
);
log.debug(`embedded run compaction retry: runId=${params.runId}`);
} else {
maybeResolveCompactionWait();
}
}
if (evt.type === "agent_end") {
defaultRuntime.log?.(`embedded run agent end: runId=${params.runId}`);
log.debug(`embedded run agent end: runId=${params.runId}`);
if (pendingCompactionRetry > 0) {
resolveCompactionRetry();
} else {
+1
View File
@@ -10,4 +10,5 @@ export {
queueEmbeddedPiMessage,
resolveEmbeddedSessionLane,
runEmbeddedPiAgent,
waitForEmbeddedPiRunEnd,
} from "./pi-embedded-runner.js";
+29
View File
@@ -158,4 +158,33 @@ describe("createClawdisCodingTools", () => {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("filters tools by sandbox policy", () => {
const sandbox = {
enabled: true,
sessionKey: "sandbox:test",
workspaceDir: path.join(os.tmpdir(), "clawdis-sandbox"),
containerName: "clawdis-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "clawdis-sandbox:bookworm-slim",
containerPrefix: "clawdis-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: [],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["bash"],
deny: ["browser"],
},
};
const tools = createClawdisCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
expect(tools.some((tool) => tool.name === "read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
});
+100 -5
View File
@@ -1,5 +1,11 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { codingTools, readTool } from "@mariozechner/pi-coding-agent";
import {
codingTools,
createEditTool,
createReadTool,
createWriteTool,
readTool,
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { detectMime } from "../media/mime.js";
@@ -11,6 +17,8 @@ import {
type ProcessToolDefaults,
} from "./bash-tools.js";
import { createClawdisTools } from "./clawdis-tools.js";
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { sanitizeToolResultImages } from "./tool-images.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
@@ -284,6 +292,59 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
};
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
function filterToolsByPolicy(
tools: AnyAgentTool[],
policy?: SandboxToolPolicy,
) {
if (!policy) return tools;
const deny = new Set(normalizeToolNames(policy.deny));
const allowRaw = normalizeToolNames(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
return tools.filter((tool) => {
const name = tool.name.toLowerCase();
if (deny.has(name)) return false;
if (allow) return allow.has(name);
return true;
});
}
function wrapSandboxPathGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
return {
...tool,
execute: async (toolCallId, args, signal, onUpdate) => {
const record =
args && typeof args === "object"
? (args as Record<string, unknown>)
: undefined;
const filePath = record?.path;
if (typeof filePath === "string" && filePath.trim()) {
await assertSandboxPath({ filePath, cwd: root, root });
}
return tool.execute(toolCallId, args, signal, onUpdate);
},
};
}
function createSandboxedReadTool(root: string) {
const base = createReadTool(root);
return wrapSandboxPathGuard(createClawdisReadTool(base), root);
}
function createSandboxedWriteTool(root: string) {
const base = createWriteTool(root);
return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root);
}
function createSandboxedEditTool(root: string) {
const base = createEditTool(root);
return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root);
}
function createWhatsAppLoginTool(): AnyAgentTool {
return {
label: "WhatsApp Login",
@@ -383,27 +444,61 @@ function shouldIncludeDiscordTool(surface?: string): boolean {
export function createClawdisCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
surface?: string;
sandbox?: SandboxContext | null;
sessionKey?: string;
}): AnyAgentTool[] {
const bashToolName = "bash";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const sandboxRoot = sandbox?.workspaceDir;
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
if (tool.name === readTool.name) return [createClawdisReadTool(tool)];
if (tool.name === readTool.name) {
return sandboxRoot
? [createSandboxedReadTool(sandboxRoot)]
: [createClawdisReadTool(tool)];
}
if (tool.name === bashToolName) return [];
if (sandboxRoot && (tool.name === "write" || tool.name === "edit")) {
return [];
}
return [tool as AnyAgentTool];
});
const bashTool = createBashTool(options?.bash);
const bashTool = createBashTool({
...options?.bash,
sandbox: sandbox
? {
containerName: sandbox.containerName,
workspaceDir: sandbox.workspaceDir,
containerWorkdir: sandbox.containerWorkdir,
env: sandbox.docker.env,
}
: undefined,
});
const processTool = createProcessTool({
cleanupMs: options?.bash?.cleanupMs,
});
const tools: AnyAgentTool[] = [
...base,
...(sandboxRoot
? [
createSandboxedEditTool(sandboxRoot),
createSandboxedWriteTool(sandboxRoot),
]
: []),
bashTool as unknown as AnyAgentTool,
processTool as unknown as AnyAgentTool,
createWhatsAppLoginTool(),
...createClawdisTools(),
...createClawdisTools({
browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey,
agentSurface: options?.surface,
}),
];
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
const filtered = allowDiscord
? tools
: tools.filter((tool) => tool.name !== "discord");
return filtered.map(normalizeToolParameters);
const sandboxed = sandbox
? filterToolsByPolicy(filtered, sandbox.tools)
: filtered;
return sandboxed.map(normalizeToolParameters);
}
+83
View File
@@ -0,0 +1,83 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(filePath);
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
}
function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (path.isAbsolute(expanded)) return expanded;
return path.resolve(cwd, expanded);
}
export function resolveSandboxPath(params: {
filePath: string;
cwd: string;
root: string;
}): { resolved: string; relative: string } {
const resolved = resolveToCwd(params.filePath, params.cwd);
const rootResolved = path.resolve(params.root);
const relative = path.relative(rootResolved, resolved);
if (!relative || relative === "") {
return { resolved, relative: "" };
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(
`Path escapes sandbox root (${shortPath(rootResolved)}): ${params.filePath}`,
);
}
return { resolved, relative };
}
export async function assertSandboxPath(params: {
filePath: string;
cwd: string;
root: string;
}) {
const resolved = resolveSandboxPath(params);
await assertNoSymlink(resolved.relative, path.resolve(params.root));
return resolved;
}
async function assertNoSymlink(relative: string, root: string) {
if (!relative) return;
const parts = relative.split(path.sep).filter(Boolean);
let current = root;
for (const part of parts) {
current = path.join(current, part);
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
throw new Error(`Symlink not allowed in sandbox path: ${current}`);
}
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code === "ENOENT") {
return;
}
throw err;
}
}
}
function shortPath(value: string) {
if (value.startsWith(os.homedir())) {
return `~${value.slice(os.homedir().length)}`;
}
return value;
}
+776
View File
@@ -0,0 +1,776 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
type BrowserBridge,
startBrowserBridgeServer,
stopBrowserBridgeServer,
} from "../browser/bridge-server.js";
import type { ResolvedBrowserConfig } from "../browser/config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
import type { ClawdisConfig } from "../config/config.js";
import { STATE_DIR_CLAWDIS } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
} from "./workspace.js";
export type SandboxToolPolicy = {
allow?: string[];
deny?: string[];
};
export type SandboxBrowserConfig = {
enabled: boolean;
image: string;
containerPrefix: string;
cdpPort: number;
vncPort: number;
noVncPort: number;
headless: boolean;
enableNoVnc: boolean;
};
export type SandboxDockerConfig = {
image: string;
containerPrefix: string;
workdir: string;
readOnlyRoot: boolean;
tmpfs: string[];
network: string;
user?: string;
capDrop: string[];
env?: Record<string, string>;
setupCommand?: string;
};
export type SandboxPruneConfig = {
idleHours: number;
maxAgeDays: number;
};
export type SandboxConfig = {
mode: "off" | "non-main" | "all";
perSession: boolean;
workspaceRoot: string;
docker: SandboxDockerConfig;
browser: SandboxBrowserConfig;
tools: SandboxToolPolicy;
prune: SandboxPruneConfig;
};
export type SandboxBrowserContext = {
controlUrl: string;
noVncUrl?: string;
containerName: string;
};
export type SandboxContext = {
enabled: boolean;
sessionKey: string;
workspaceDir: string;
containerName: string;
containerWorkdir: string;
docker: SandboxDockerConfig;
tools: SandboxToolPolicy;
browser?: SandboxBrowserContext;
};
const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(
os.homedir(),
".clawdis",
"sandboxes",
);
const DEFAULT_SANDBOX_IMAGE = "clawdis-sandbox:bookworm-slim";
const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdis-sbx-";
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"];
const DEFAULT_TOOL_DENY = [
"browser",
"canvas",
"nodes",
"cron",
"discord",
"gateway",
];
const DEFAULT_SANDBOX_BROWSER_IMAGE = "clawdis-sandbox-browser:bookworm-slim";
const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdis-sbx-browser-";
const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;
const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDIS, "sandbox");
const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
const SANDBOX_BROWSER_REGISTRY_PATH = path.join(
SANDBOX_STATE_DIR,
"browsers.json",
);
type SandboxRegistryEntry = {
containerName: string;
sessionKey: string;
createdAtMs: number;
lastUsedAtMs: number;
image: string;
};
type SandboxRegistry = {
entries: SandboxRegistryEntry[];
};
type SandboxBrowserRegistryEntry = {
containerName: string;
sessionKey: string;
createdAtMs: number;
lastUsedAtMs: number;
image: string;
cdpPort: number;
noVncPort?: number;
};
type SandboxBrowserRegistry = {
entries: SandboxBrowserRegistryEntry[];
};
let lastPruneAtMs = 0;
const BROWSER_BRIDGES = new Map<
string,
{ bridge: BrowserBridge; containerName: string }
>();
function normalizeToolList(values?: string[]) {
if (!values) return [];
return values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => value.toLowerCase());
}
function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(normalizeToolList(policy.deny));
if (deny.has(name.toLowerCase())) return false;
const allow = normalizeToolList(policy.allow);
if (allow.length === 0) return true;
return allow.includes(name.toLowerCase());
}
function defaultSandboxConfig(cfg?: ClawdisConfig): SandboxConfig {
const agent = cfg?.agent?.sandbox;
return {
mode: agent?.mode ?? "off",
perSession: agent?.perSession ?? true,
workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: {
image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE,
containerPrefix:
agent?.docker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX,
workdir: agent?.docker?.workdir ?? DEFAULT_SANDBOX_WORKDIR,
readOnlyRoot: agent?.docker?.readOnlyRoot ?? true,
tmpfs: agent?.docker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"],
network: agent?.docker?.network ?? "bridge",
user: agent?.docker?.user,
capDrop: agent?.docker?.capDrop ?? ["ALL"],
env: agent?.docker?.env ?? { LANG: "C.UTF-8" },
setupCommand: agent?.docker?.setupCommand,
},
browser: {
enabled: agent?.browser?.enabled ?? false,
image: agent?.browser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
containerPrefix:
agent?.browser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX,
cdpPort: agent?.browser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT,
vncPort: agent?.browser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT,
noVncPort:
agent?.browser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT,
headless: agent?.browser?.headless ?? false,
enableNoVnc: agent?.browser?.enableNoVnc ?? true,
},
tools: {
allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
deny: agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
},
prune: {
idleHours: agent?.prune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS,
maxAgeDays: agent?.prune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS,
},
};
}
function shouldSandboxSession(
cfg: SandboxConfig,
sessionKey: string,
mainKey: string,
) {
if (cfg.mode === "off") return false;
if (cfg.mode === "all") return true;
return sessionKey.trim() !== mainKey.trim();
}
function slugifySessionKey(value: string) {
const trimmed = value.trim() || "session";
const hash = crypto
.createHash("sha1")
.update(trimmed)
.digest("hex")
.slice(0, 8);
const safe = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
const base = safe.slice(0, 32) || "session";
return `${base}-${hash}`;
}
function resolveSandboxWorkspaceDir(root: string, sessionKey: string) {
const resolvedRoot = resolveUserPath(root);
const slug = slugifySessionKey(sessionKey);
return path.join(resolvedRoot, slug);
}
async function readRegistry(): Promise<SandboxRegistry> {
try {
const raw = await fs.readFile(SANDBOX_REGISTRY_PATH, "utf-8");
const parsed = JSON.parse(raw) as SandboxRegistry;
if (parsed && Array.isArray(parsed.entries)) return parsed;
} catch {
// ignore
}
return { entries: [] };
}
async function writeRegistry(registry: SandboxRegistry) {
await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true });
await fs.writeFile(
SANDBOX_REGISTRY_PATH,
`${JSON.stringify(registry, null, 2)}\n`,
"utf-8",
);
}
async function updateRegistry(entry: SandboxRegistryEntry) {
const registry = await readRegistry();
const existing = registry.entries.find(
(item) => item.containerName === entry.containerName,
);
const next = registry.entries.filter(
(item) => item.containerName !== entry.containerName,
);
next.push({
...entry,
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
image: existing?.image ?? entry.image,
});
await writeRegistry({ entries: next });
}
async function removeRegistryEntry(containerName: string) {
const registry = await readRegistry();
const next = registry.entries.filter(
(item) => item.containerName !== containerName,
);
if (next.length === registry.entries.length) return;
await writeRegistry({ entries: next });
}
async function readBrowserRegistry(): Promise<SandboxBrowserRegistry> {
try {
const raw = await fs.readFile(SANDBOX_BROWSER_REGISTRY_PATH, "utf-8");
const parsed = JSON.parse(raw) as SandboxBrowserRegistry;
if (parsed && Array.isArray(parsed.entries)) return parsed;
} catch {
// ignore
}
return { entries: [] };
}
async function writeBrowserRegistry(registry: SandboxBrowserRegistry) {
await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true });
await fs.writeFile(
SANDBOX_BROWSER_REGISTRY_PATH,
`${JSON.stringify(registry, null, 2)}\n`,
"utf-8",
);
}
async function updateBrowserRegistry(entry: SandboxBrowserRegistryEntry) {
const registry = await readBrowserRegistry();
const existing = registry.entries.find(
(item) => item.containerName === entry.containerName,
);
const next = registry.entries.filter(
(item) => item.containerName !== entry.containerName,
);
next.push({
...entry,
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
image: existing?.image ?? entry.image,
});
await writeBrowserRegistry({ entries: next });
}
async function removeBrowserRegistryEntry(containerName: string) {
const registry = await readBrowserRegistry();
const next = registry.entries.filter(
(item) => item.containerName !== containerName,
);
if (next.length === registry.entries.length) return;
await writeBrowserRegistry({ entries: next });
}
function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
return new Promise<{ stdout: string; stderr: string; code: number }>(
(resolve, reject) => {
const child = spawn("docker", args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("close", (code) => {
const exitCode = code ?? 0;
if (exitCode !== 0 && !opts?.allowFailure) {
reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`));
return;
}
resolve({ stdout, stderr, code: exitCode });
});
},
);
}
async function readDockerPort(containerName: string, port: number) {
const result = await execDocker(["port", containerName, `${port}/tcp`], {
allowFailure: true,
});
if (result.code !== 0) return null;
const line = result.stdout.trim().split(/\r?\n/)[0] ?? "";
const match = line.match(/:(\d+)\s*$/);
if (!match) return null;
const mapped = Number.parseInt(match[1] ?? "", 10);
return Number.isFinite(mapped) ? mapped : null;
}
async function dockerImageExists(image: string) {
const result = await execDocker(["image", "inspect", image], {
allowFailure: true,
});
return result.code === 0;
}
async function ensureDockerImage(image: string) {
const exists = await dockerImageExists(image);
if (exists) return;
if (image === DEFAULT_SANDBOX_IMAGE) {
await execDocker(["pull", "debian:bookworm-slim"]);
await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]);
return;
}
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
}
async function dockerContainerState(name: string) {
const result = await execDocker(
["inspect", "-f", "{{.State.Running}}", name],
{ allowFailure: true },
);
if (result.code !== 0) return { exists: false, running: false };
return { exists: true, running: result.stdout.trim() === "true" };
}
async function ensureSandboxWorkspace(workspaceDir: string, seedFrom?: string) {
await fs.mkdir(workspaceDir, { recursive: true });
if (seedFrom) {
const seed = resolveUserPath(seedFrom);
const files = [
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
];
for (const name of files) {
const src = path.join(seed, name);
const dest = path.join(workspaceDir, name);
try {
await fs.access(dest);
} catch {
try {
const content = await fs.readFile(src, "utf-8");
await fs.writeFile(dest, content, { encoding: "utf-8", flag: "wx" });
} catch {
// ignore missing seed file
}
}
}
}
await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: true });
}
async function createSandboxContainer(params: {
name: string;
cfg: SandboxDockerConfig;
workspaceDir: string;
sessionKey: string;
}) {
const { name, cfg, workspaceDir, sessionKey } = params;
await ensureDockerImage(cfg.image);
const args = ["create", "--name", name];
args.push("--label", "clawdis.sandbox=1");
args.push("--label", `clawdis.sessionKey=${sessionKey}`);
args.push("--label", `clawdis.createdAtMs=${Date.now()}`);
if (cfg.readOnlyRoot) args.push("--read-only");
for (const entry of cfg.tmpfs) {
args.push("--tmpfs", entry);
}
if (cfg.network) args.push("--network", cfg.network);
if (cfg.user) args.push("--user", cfg.user);
for (const cap of cfg.capDrop) {
args.push("--cap-drop", cap);
}
args.push("--security-opt", "no-new-privileges");
args.push("--workdir", cfg.workdir);
args.push("-v", `${workspaceDir}:${cfg.workdir}`);
args.push(cfg.image, "sleep", "infinity");
await execDocker(args);
await execDocker(["start", name]);
if (cfg.setupCommand?.trim()) {
await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]);
}
}
async function ensureSandboxContainer(params: {
sessionKey: string;
workspaceDir: string;
cfg: SandboxConfig;
}) {
const slug = params.cfg.perSession
? slugifySessionKey(params.sessionKey)
: "shared";
const name = `${params.cfg.docker.containerPrefix}${slug}`;
const containerName = name.slice(0, 63);
const state = await dockerContainerState(containerName);
if (!state.exists) {
await createSandboxContainer({
name: containerName,
cfg: params.cfg.docker,
workspaceDir: params.workspaceDir,
sessionKey: params.sessionKey,
});
} else if (!state.running) {
await execDocker(["start", containerName]);
}
const now = Date.now();
await updateRegistry({
containerName,
sessionKey: params.sessionKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.docker.image,
});
return containerName;
}
async function ensureSandboxBrowserImage(image: string) {
const exists = await dockerImageExists(image);
if (exists) return;
throw new Error(
`Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`,
);
}
function buildSandboxBrowserResolvedConfig(params: {
controlPort: number;
cdpPort: number;
headless: boolean;
}): ResolvedBrowserConfig {
const controlHost = "127.0.0.1";
const controlUrl = `http://${controlHost}:${params.controlPort}`;
const cdpHost = "127.0.0.1";
const cdpUrl = `http://${cdpHost}:${params.cdpPort}`;
return {
enabled: true,
controlUrl,
controlHost,
controlPort: params.controlPort,
cdpUrl,
cdpHost,
cdpPort: params.cdpPort,
cdpIsLoopback: true,
color: DEFAULT_CLAWD_BROWSER_COLOR,
executablePath: undefined,
headless: params.headless,
noSandbox: false,
attachOnly: true,
};
}
async function ensureSandboxBrowser(params: {
sessionKey: string;
workspaceDir: string;
cfg: SandboxConfig;
}): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null;
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
const slug = params.cfg.perSession
? slugifySessionKey(params.sessionKey)
: "shared";
const name = `${params.cfg.browser.containerPrefix}${slug}`;
const containerName = name.slice(0, 63);
const state = await dockerContainerState(containerName);
if (!state.exists) {
await ensureSandboxBrowserImage(params.cfg.browser.image);
const args = ["create", "--name", containerName];
args.push("--label", "clawdis.sandbox=1");
args.push("--label", "clawdis.sandboxBrowser=1");
args.push("--label", `clawdis.sessionKey=${params.sessionKey}`);
args.push("--label", `clawdis.createdAtMs=${Date.now()}`);
if (params.cfg.docker.readOnlyRoot) args.push("--read-only");
for (const entry of params.cfg.docker.tmpfs) {
args.push("--tmpfs", entry);
}
if (params.cfg.docker.network)
args.push("--network", params.cfg.docker.network);
if (params.cfg.docker.user) args.push("--user", params.cfg.docker.user);
for (const cap of params.cfg.docker.capDrop) {
args.push("--cap-drop", cap);
}
args.push("--security-opt", "no-new-privileges");
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`);
args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) {
args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`);
}
args.push(
"-e",
`CLAWDIS_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`,
);
args.push(
"-e",
`CLAWDIS_BROWSER_ENABLE_NOVNC=${
params.cfg.browser.enableNoVnc ? "1" : "0"
}`,
);
args.push("-e", `CLAWDIS_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`);
args.push("-e", `CLAWDIS_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`);
args.push(
"-e",
`CLAWDIS_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`,
);
args.push(params.cfg.browser.image);
await execDocker(args);
await execDocker(["start", containerName]);
} else if (!state.running) {
await execDocker(["start", containerName]);
}
const mappedCdp = await readDockerPort(
containerName,
params.cfg.browser.cdpPort,
);
if (!mappedCdp) {
throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`);
}
const mappedNoVnc =
params.cfg.browser.enableNoVnc && !params.cfg.browser.headless
? await readDockerPort(containerName, params.cfg.browser.noVncPort)
: null;
const existing = BROWSER_BRIDGES.get(params.sessionKey);
const shouldReuse =
existing &&
existing.containerName === containerName &&
existing.bridge.state.resolved.cdpPort === mappedCdp;
if (existing && !shouldReuse) {
await stopBrowserBridgeServer(existing.bridge.server).catch(
() => undefined,
);
BROWSER_BRIDGES.delete(params.sessionKey);
}
let bridge: BrowserBridge;
if (shouldReuse && existing) {
bridge = existing.bridge;
} else {
bridge = await startBrowserBridgeServer({
resolved: buildSandboxBrowserResolvedConfig({
controlPort: 0,
cdpPort: mappedCdp,
headless: params.cfg.browser.headless,
}),
});
}
if (!shouldReuse) {
BROWSER_BRIDGES.set(params.sessionKey, { bridge, containerName });
}
const now = Date.now();
await updateBrowserRegistry({
containerName,
sessionKey: params.sessionKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.browser.image,
cdpPort: mappedCdp,
noVncPort: mappedNoVnc ?? undefined,
});
const noVncUrl =
mappedNoVnc &&
params.cfg.browser.enableNoVnc &&
!params.cfg.browser.headless
? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote`
: undefined;
return {
controlUrl: bridge.baseUrl,
noVncUrl,
containerName,
};
}
async function pruneSandboxContainers(cfg: SandboxConfig) {
const now = Date.now();
const idleHours = cfg.prune.idleHours;
const maxAgeDays = cfg.prune.maxAgeDays;
if (idleHours === 0 && maxAgeDays === 0) return;
const registry = await readRegistry();
for (const entry of registry.entries) {
const idleMs = now - entry.lastUsedAtMs;
const ageMs = now - entry.createdAtMs;
if (
(idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) ||
(maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000)
) {
try {
await execDocker(["rm", "-f", entry.containerName], {
allowFailure: true,
});
} catch {
// ignore prune failures
} finally {
await removeRegistryEntry(entry.containerName);
}
}
}
}
async function pruneSandboxBrowsers(cfg: SandboxConfig) {
const now = Date.now();
const idleHours = cfg.prune.idleHours;
const maxAgeDays = cfg.prune.maxAgeDays;
if (idleHours === 0 && maxAgeDays === 0) return;
const registry = await readBrowserRegistry();
for (const entry of registry.entries) {
const idleMs = now - entry.lastUsedAtMs;
const ageMs = now - entry.createdAtMs;
if (
(idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) ||
(maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000)
) {
try {
await execDocker(["rm", "-f", entry.containerName], {
allowFailure: true,
});
} catch {
// ignore prune failures
} finally {
await removeBrowserRegistryEntry(entry.containerName);
const bridge = BROWSER_BRIDGES.get(entry.sessionKey);
if (bridge?.containerName === entry.containerName) {
await stopBrowserBridgeServer(bridge.bridge.server).catch(
() => undefined,
);
BROWSER_BRIDGES.delete(entry.sessionKey);
}
}
}
}
}
async function maybePruneSandboxes(cfg: SandboxConfig) {
const now = Date.now();
if (now - lastPruneAtMs < 5 * 60 * 1000) return;
lastPruneAtMs = now;
try {
await pruneSandboxContainers(cfg);
await pruneSandboxBrowsers(cfg);
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: JSON.stringify(error);
defaultRuntime.error?.(
`Sandbox prune failed: ${message ?? "unknown error"}`,
);
}
}
export async function resolveSandboxContext(params: {
config?: ClawdisConfig;
sessionKey?: string;
workspaceDir?: string;
}): Promise<SandboxContext | null> {
const rawSessionKey = params.sessionKey?.trim();
if (!rawSessionKey) return null;
const cfg = defaultSandboxConfig(params.config);
const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
await maybePruneSandboxes(cfg);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey)
: workspaceRoot;
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(workspaceDir, seedWorkspace);
const containerName = await ensureSandboxContainer({
sessionKey: rawSessionKey,
workspaceDir,
cfg,
});
const browser = await ensureSandboxBrowser({
sessionKey: rawSessionKey,
workspaceDir,
cfg,
});
return {
enabled: true,
sessionKey: rawSessionKey,
workspaceDir,
containerName,
containerWorkdir: cfg.docker.workdir,
docker: cfg.docker,
tools: cfg.tools,
browser: browser ?? undefined,
};
}
+25
View File
@@ -13,6 +13,12 @@ export function buildAgentSystemPromptAppend(params: {
node?: string;
model?: string;
};
sandboxInfo?: {
enabled: boolean;
workspaceDir?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
};
}) {
const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off"
@@ -72,6 +78,25 @@ export function buildAgentSystemPromptAppend(params: {
`Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"",
params.sandboxInfo?.enabled ? "## Sandbox" : "",
params.sandboxInfo?.enabled
? [
"Tool execution is isolated in a Docker sandbox.",
"Some tools may be unavailable due to sandbox policy.",
params.sandboxInfo.workspaceDir
? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}`
: "",
params.sandboxInfo.browserControlUrl
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
: "",
params.sandboxInfo.browserNoVncUrl
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
: "",
]
.filter(Boolean)
.join("\n")
: "",
params.sandboxInfo?.enabled ? "" : "",
ownerLine ? "## User Identity" : "",
ownerLine ?? "",
ownerLine ? "" : "",
+15
View File
@@ -150,6 +150,21 @@
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"sessions_list": {
"emoji": "🗂️",
"title": "Sessions",
"detailKeys": ["kinds", "limit", "activeMinutes", "messageLimit"]
},
"sessions_history": {
"emoji": "🧾",
"title": "Session History",
"detailKeys": ["sessionKey", "limit"]
},
"sessions_send": {
"emoji": "📨",
"title": "Session Send",
"detailKeys": ["sessionKey", "timeoutSeconds"]
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
+60
View File
@@ -107,6 +107,42 @@ describe("trigger handling", () => {
});
});
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/send off",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+1000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Send policy set to off");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ sendPolicy?: string }
>;
expect(store.main?.sendPolicy).toBe("deny");
});
});
it("returns a context overflow fallback when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
@@ -131,6 +167,30 @@ describe("trigger handling", () => {
});
});
it("includes the error cause when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
new Error("sandbox is not defined"),
);
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe(
"⚠️ Agent failed before reply: sandbox is not defined. Check gateway logs for details.",
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("uses heartbeat model override for heartbeat runs", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
+54 -3
View File
@@ -55,6 +55,7 @@ import {
} from "../infra/system-events.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { normalizeE164 } from "../utils.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
@@ -65,6 +66,7 @@ import {
} from "./group-activation.js";
import { stripHeartbeatToken } from "./heartbeat.js";
import { extractModelDirective } from "./model.js";
import { parseSendPolicyCommand } from "./send-policy.js";
import { buildStatusMessage } from "./status.js";
import type { MsgContext, TemplateContext } from "./templating.js";
import {
@@ -831,7 +833,11 @@ export async function getReplyFromConfig(
const typingIntervalSeconds =
typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6;
const typingIntervalMs = typingIntervalSeconds * 1000;
const typingTtlMs = Math.min(Math.max(15_000, typingIntervalMs * 5), 60_000);
const typingTtlMs = 2 * 60_000;
const formatTypingTtl = (ms: number) => {
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
return `${Math.round(ms / 1000)}s`;
};
const cleanupTyping = () => {
if (typingTtlTimer) {
clearTimeout(typingTtlTimer);
@@ -852,7 +858,7 @@ export async function getReplyFromConfig(
typingTtlTimer = setTimeout(() => {
if (!typingTimer) return;
defaultRuntime.log(
`typing TTL reached (${typingTtlMs}ms); stopping typing indicator`,
`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`,
);
cleanupTyping();
}, typingTtlMs);
@@ -986,6 +992,7 @@ export async function getReplyFromConfig(
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
sendPolicy: baseEntry?.sendPolicy,
queueMode: baseEntry?.queueMode,
queueDebounceMs: baseEntry?.queueDebounceMs,
queueCap: baseEntry?.queueCap,
@@ -1587,6 +1594,7 @@ export async function getReplyFromConfig(
? stripMentions(rawBodyNormalized, ctx, cfg)
: rawBodyNormalized;
const activationCommand = parseActivationCommand(commandBodyNormalized);
const sendPolicyCommand = parseSendPolicyCommand(commandBodyNormalized);
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
const ownerCandidates = isWhatsAppSurface
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
@@ -1633,6 +1641,36 @@ export async function getReplyFromConfig(
};
}
if (sendPolicyCommand.hasCommand) {
if (!isOwnerSender) {
logVerbose(`Ignoring /send from non-owner: ${senderE164 || "<unknown>"}`);
cleanupTyping();
return undefined;
}
if (!sendPolicyCommand.mode) {
cleanupTyping();
return { text: "⚙️ Usage: /send on|off|inherit" };
}
if (sessionEntry && sessionStore && sessionKey) {
if (sendPolicyCommand.mode === "inherit") {
delete sessionEntry.sendPolicy;
} else {
sessionEntry.sendPolicy = sendPolicyCommand.mode;
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
cleanupTyping();
const label =
sendPolicyCommand.mode === "inherit"
? "inherit"
: sendPolicyCommand.mode === "allow"
? "on"
: "off";
return { text: `⚙️ Send policy set to ${label}.` };
}
if (
commandBodyNormalized === "/restart" ||
commandBodyNormalized === "restart" ||
@@ -1710,6 +1748,19 @@ export async function getReplyFromConfig(
return { text: "⚙️ Agent was aborted." };
}
const sendPolicy = resolveSendPolicy({
cfg,
entry: sessionEntry,
sessionKey,
surface: sessionEntry?.surface ?? surface,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
logVerbose(`Send blocked by policy for session ${sessionKey ?? "unknown"}`);
cleanupTyping();
return undefined;
}
const isFirstTurnInSession = isNewSession || !systemSent;
const isGroupChat = sessionCtx.ChatType === "group";
const wasMentioned = ctx.WasMentioned === true;
@@ -2291,7 +2342,7 @@ export async function getReplyFromConfig(
return finalizeWithFollowup({
text: isContextOverflow
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
: "⚠️ Agent failed. Check gateway logs.",
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
});
}
+29
View File
@@ -0,0 +1,29 @@
export type SendPolicyOverride = "allow" | "deny";
export function normalizeSendPolicyOverride(
raw?: string | null,
): SendPolicyOverride | undefined {
const value = raw?.trim().toLowerCase();
if (!value) return undefined;
if (value === "allow" || value === "on") return "allow";
if (value === "deny" || value === "off") return "deny";
return undefined;
}
export function parseSendPolicyCommand(raw?: string): {
hasCommand: boolean;
mode?: SendPolicyOverride | "inherit";
} {
if (!raw) return { hasCommand: false };
const trimmed = raw.trim();
if (!trimmed) return { hasCommand: false };
const match = trimmed.match(/^\/?send\b(?:\s+([a-zA-Z]+))?/i);
if (!match) return { hasCommand: false };
const token = match[1]?.trim().toLowerCase();
if (!token) return { hasCommand: true };
if (token === "inherit" || token === "default" || token === "reset") {
return { hasCommand: true, mode: "inherit" };
}
const mode = normalizeSendPolicyOverride(token);
return { hasCommand: true, mode };
}
+67
View File
@@ -0,0 +1,67 @@
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import type { ResolvedBrowserConfig } from "./config.js";
import { registerBrowserRoutes } from "./routes/index.js";
import {
type BrowserServerState,
createBrowserRouteContext,
} from "./server-context.js";
export type BrowserBridge = {
server: Server;
port: number;
baseUrl: string;
state: BrowserServerState;
};
export async function startBrowserBridgeServer(params: {
resolved: ResolvedBrowserConfig;
host?: string;
port?: number;
}): Promise<BrowserBridge> {
const host = params.host ?? "127.0.0.1";
const port = params.port ?? 0;
const app = express();
app.use(express.json({ limit: "1mb" }));
const state: BrowserServerState = {
server: null as unknown as Server,
port,
cdpPort: params.resolved.cdpPort,
running: null,
resolved: params.resolved,
};
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
state.running = running;
},
});
registerBrowserRoutes(app, ctx);
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
s.once("error", reject);
});
const address = server.address() as AddressInfo | null;
const resolvedPort = address?.port ?? port;
state.server = server;
state.port = resolvedPort;
state.resolved.controlHost = host;
state.resolved.controlPort = resolvedPort;
state.resolved.controlUrl = `http://${host}:${resolvedPort}`;
const baseUrl = state.resolved.controlUrl;
return { server, port: resolvedPort, baseUrl, state };
}
export async function stopBrowserBridgeServer(server: Server): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
-37
View File
@@ -232,41 +232,4 @@ describe("pw-tools-core", () => {
expect(dismiss).toHaveBeenCalled();
expect(accept).not.toHaveBeenCalled();
});
it("evaluates expressions with trailing semicolons", async () => {
const evaluate = vi.fn(
async (fn: (body: string) => unknown, body: string) => fn(body),
);
currentPage = { evaluate };
const mod = await importModule();
const result = await mod.evaluateViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
fn: "() => 42;",
});
expect(result).toBe(42);
expect(evaluate).toHaveBeenCalled();
});
it("evaluates element expressions with trailing semicolons", async () => {
const evaluate = vi.fn(
async (
fn: (el: { tagName: string }, body: string) => unknown,
body: string,
) => fn({ tagName: "DIV" }, body),
);
currentRefLocator = { evaluate };
currentPage = {};
const mod = await importModule();
const result = await mod.evaluateViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
fn: "(el) => el.tagName;",
ref: "1",
});
expect(result).toBe("DIV");
expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "1");
});
});
+4 -4
View File
@@ -207,14 +207,13 @@ export async function evaluateViaPlaywright(opts: {
}): Promise<unknown> {
const fnText = String(opts.fn ?? "").trim();
if (!fnText) throw new Error("function is required");
const fnBody = fnText.replace(/[;\s]+$/g, "");
if (!fnBody) throw new Error("function is required");
const page = await getPageForTargetId(opts);
ensurePageState(page);
if (opts.ref) {
const locator = refLocator(page, opts.ref);
// Use Function constructor at runtime to avoid esbuild adding __name helper
// which doesn't exist in the browser context
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
const elementEvaluator = new Function(
"el",
"fnBody",
@@ -228,10 +227,11 @@ export async function evaluateViaPlaywright(opts: {
}
`,
) as (el: Element, fnBody: string) => unknown;
return await locator.evaluate(elementEvaluator, fnBody);
return await locator.evaluate(elementEvaluator, fnText);
}
// Use Function constructor at runtime to avoid esbuild adding __name helper
// which doesn't exist in the browser context
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- required for browser-context eval
const browserEvaluator = new Function(
"fnBody",
`
@@ -244,7 +244,7 @@ export async function evaluateViaPlaywright(opts: {
}
`,
) as (fnBody: string) => unknown;
return await page.evaluate(browserEvaluator, fnBody);
return await page.evaluate(browserEvaluator, fnText);
}
export async function armFileUploadViaPlaywright(opts: {
+24
View File
@@ -129,6 +129,30 @@ describe("gateway-cli coverage", () => {
);
});
it("passes gifPlayback for gateway send when flag set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
randomIdempotencyKey.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(
["gateway", "send", "--to", "+1555", "--message", "hi", "--gif-playback"],
{ from: "user" },
);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "send",
params: expect.objectContaining({ gifPlayback: true }),
}),
);
});
it("validates gateway ports and handles force/start errors", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
+10 -1
View File
@@ -33,7 +33,14 @@ type GatewayRunSignalAction = "stop" | "restart";
function parsePort(raw: unknown): number | null {
if (raw === undefined || raw === null) return null;
const parsed = Number.parseInt(String(raw), 10);
const value =
typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "bigint"
? raw.toString()
: null;
if (value === null) return null;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
@@ -571,6 +578,7 @@ export function registerGatewayCli(program: Command) {
.requiredOption("--to <jidOrPhone>", "Destination (E.164 or jid)")
.requiredOption("--message <text>", "Message text")
.option("--media-url <url>", "Optional media URL")
.option("--gif-playback", "Treat video media as GIF playback", false)
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
@@ -579,6 +587,7 @@ export function registerGatewayCli(program: Command) {
to: opts.to,
message: opts.message,
mediaUrl: opts.mediaUrl,
gifPlayback: opts.gifPlayback,
idempotencyKey,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
+43
View File
@@ -158,4 +158,47 @@ describe("nodes-cli coverage", () => {
delivery: "overlay",
});
});
it("invokes location.get with params", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
const { registerNodesCli } = await import("./nodes-cli.js");
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
[
"nodes",
"location",
"get",
"--node",
"mac-1",
"--accuracy",
"precise",
"--max-age",
"1000",
"--location-timeout",
"5000",
"--invoke-timeout",
"6000",
],
{ from: "user" },
);
const invoke = callGateway.mock.calls.find(
(call) => call[0]?.method === "node.invoke",
)?.[0];
expect(invoke).toBeTruthy();
expect(invoke?.params?.command).toBe("location.get");
expect(invoke?.params?.params).toEqual({
maxAgeMs: 1000,
desiredAccuracy: "precise",
timeoutMs: 5000,
});
expect(invoke?.params?.timeoutMs).toBe(6000);
});
});
+100
View File
@@ -45,6 +45,9 @@ type NodesRpcOpts = {
quality?: string;
delayMs?: string;
deviceId?: string;
maxAge?: string;
accuracy?: string;
locationTimeout?: string;
duration?: string;
screen?: string;
fps?: string;
@@ -1204,4 +1207,101 @@ export function registerNodesCli(program: Command) {
}),
{ timeoutMs: 180_000 },
);
const location = nodes
.command("location")
.description("Fetch location from a paired node");
nodesCallOpts(
location
.command("get")
.description("Fetch the current location from a node")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--max-age <ms>", "Use cached location newer than this (ms)")
.option(
"--accuracy <coarse|balanced|precise>",
"Desired accuracy (default: balanced/precise depending on node setting)",
)
.option("--location-timeout <ms>", "Location fix timeout (ms)", "10000")
.option(
"--invoke-timeout <ms>",
"Node invoke timeout in ms (default 20000)",
"20000",
)
.action(async (opts: NodesRpcOpts) => {
try {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const maxAgeMs = opts.maxAge
? Number.parseInt(String(opts.maxAge), 10)
: undefined;
const desiredAccuracyRaw =
typeof opts.accuracy === "string"
? opts.accuracy.trim().toLowerCase()
: undefined;
const desiredAccuracy =
desiredAccuracyRaw === "coarse" ||
desiredAccuracyRaw === "balanced" ||
desiredAccuracyRaw === "precise"
? desiredAccuracyRaw
: undefined;
const timeoutMs = opts.locationTimeout
? Number.parseInt(String(opts.locationTimeout), 10)
: undefined;
const invokeTimeoutMs = opts.invokeTimeout
? Number.parseInt(String(opts.invokeTimeout), 10)
: undefined;
const invokeParams: Record<string, unknown> = {
nodeId,
command: "location.get",
params: {
maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : undefined,
desiredAccuracy,
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
},
idempotencyKey: randomIdempotencyKey(),
};
if (
typeof invokeTimeoutMs === "number" &&
Number.isFinite(invokeTimeoutMs)
) {
invokeParams.timeoutMs = invokeTimeoutMs;
}
const raw = (await callGatewayCli(
"node.invoke",
opts,
invokeParams,
)) as unknown;
const res =
typeof raw === "object" && raw !== null
? (raw as { payload?: unknown })
: {};
const payload =
res.payload && typeof res.payload === "object"
? (res.payload as Record<string, unknown>)
: {};
if (opts.json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
return;
}
const lat = payload.lat;
const lon = payload.lon;
const acc = payload.accuracyMeters;
if (typeof lat === "number" && typeof lon === "number") {
const accText =
typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : "";
defaultRuntime.log(`${lat},${lon}${accText}`);
return;
}
defaultRuntime.log(JSON.stringify(payload));
} catch (err) {
defaultRuntime.error(`nodes location get failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
{ timeoutMs: 30_000 },
);
}
+5
View File
@@ -311,6 +311,11 @@ export function buildProgram() {
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|telegram|discord|signal|imessage (default: whatsapp)",
+1 -1
View File
@@ -14,7 +14,7 @@ export function registerTuiCli(program: Command) {
.option("--password <password>", "Gateway password (if required)")
.option(
"--session <key>",
"Session key (default: session.mainKey from config)",
'Session key (default: "main", or "global" when scope is global)',
)
.option("--deliver", "Deliver assistant replies", false)
.option("--thinking <level>", "Thinking level override")
+26 -6
View File
@@ -42,6 +42,7 @@ import {
registerAgentRunContext,
} from "../infra/agent-events.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js";
@@ -59,6 +60,9 @@ type AgentCommandOpts = {
provider?: string; // delivery provider (whatsapp|telegram|...)
bestEffortDeliver?: boolean;
abortSignal?: AbortSignal;
lane?: string;
runId?: string;
extraSystemPrompt?: string;
};
type SessionResolution = {
@@ -207,9 +211,23 @@ export async function agentCommand(
persistedVerbose,
} = sessionResolution;
let sessionEntry = resolvedSessionEntry;
const runId = opts.runId?.trim() || sessionId;
if (sessionKey) {
registerAgentRunContext(sessionId, { sessionKey });
registerAgentRunContext(runId, { sessionKey });
}
if (opts.deliver === true) {
const sendPolicy = resolveSendPolicy({
cfg,
entry: sessionEntry,
sessionKey,
surface: sessionEntry?.surface,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
throw new Error("send blocked by session policy");
}
}
let resolvedThinkLevel =
@@ -334,7 +352,7 @@ export async function agentCommand(
const startedAt = Date.now();
emitAgentEvent({
runId: sessionId,
runId,
stream: "job",
data: {
state: "started",
@@ -368,18 +386,20 @@ export async function agentCommand(
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId: sessionId,
runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => {
emitAgentEvent({
runId: sessionId,
runId,
stream: evt.stream,
data: evt.data,
});
},
});
emitAgentEvent({
runId: sessionId,
runId,
stream: "job",
data: {
state: "done",
@@ -393,7 +413,7 @@ export async function agentCommand(
});
} catch (err) {
emitAgentEvent({
runId: sessionId,
runId,
stream: "job",
data: {
state: "error",
+3 -2
View File
@@ -28,10 +28,11 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"WhatsApp: links via WhatsApp Web (scan QR), stores creds for future sends.",
"WhatsApp: dedicated second number recommended; primary number OK (self-chat).",
"Telegram: Bot API (token from @BotFather), replies via your bot.",
"Discord: Bot token from Discord Developer Portal; invite bot to your server.",
"Signal: signal-cli as a linked device (recommended: separate bot number).",
"iMessage: local imsg CLI (JSON-RPC over stdio) reading Messages DB.",
"Signal: signal-cli as a linked device; separate number recommended.",
"iMessage: local imsg CLI; separate Apple ID recommended only on a separate Mac.",
].join("\n"),
"How providers work",
);
+31 -1
View File
@@ -3,7 +3,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdisConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { resolveNodeManagerOptions } from "./onboard-helpers.js";
import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
function summarizeInstallFailure(message: string): string | undefined {
const cleaned = message
@@ -59,6 +59,13 @@ export async function setupSkills(
);
const blocked = report.skills.filter((s) => s.blockedByAllowlist);
const needsBrewPrompt =
process.platform !== "win32" &&
report.skills.some((skill) =>
skill.install.some((option) => option.kind === "brew"),
) &&
!(await detectBinary("brew"));
await prompter.note(
[
`Eligible: ${eligible.length}`,
@@ -74,6 +81,29 @@ export async function setupSkills(
});
if (!shouldConfigure) return cfg;
if (needsBrewPrompt) {
await prompter.note(
[
"Many skill dependencies are shipped via Homebrew.",
"Without brew, you'll need to build from source or download releases manually.",
].join("\n"),
"Homebrew recommended",
);
const showBrewInstall = await prompter.confirm({
message: "Show Homebrew install command?",
initialValue: true,
});
if (showBrewInstall) {
await prompter.note(
[
"Run:",
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
].join("\n"),
"Homebrew install",
);
}
}
const nodeManager = (await prompter.select({
message: "Preferred node manager for skill installs",
options: resolveNodeManagerOptions(),
+21
View File
@@ -76,6 +76,27 @@ describe("sendCommand", () => {
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1"));
});
it("passes gifPlayback to gateway send", async () => {
callGatewayMock.mockClear();
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await sendCommand(
{
to: "+1",
message: "hi",
gifPlayback: true,
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "send",
params: expect.objectContaining({ gifPlayback: true }),
}),
);
});
it("routes to telegram provider", async () => {
const deps = makeDeps({
sendMessageTelegram: vi
+2
View File
@@ -13,6 +13,7 @@ export async function sendCommand(
json?: boolean;
dryRun?: boolean;
media?: string;
gifPlayback?: boolean;
},
deps: CliDeps,
runtime: RuntimeEnv,
@@ -144,6 +145,7 @@ export async function sendCommand(
to: opts.to,
message: opts.message,
mediaUrl: opts.media,
gifPlayback: opts.gifPlayback,
provider,
idempotencyKey: randomIdempotencyKey(),
},
+169 -4
View File
@@ -15,10 +15,25 @@ import { parseDurationMs } from "../cli/parse-duration.js";
* - Config is managed externally (read-only from Nix perspective)
*/
export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1";
let warnedMainKeyOverride = false;
export type ReplyMode = "text" | "command";
export type SessionScope = "per-sender" | "global";
export type ReplyToMode = "off" | "first" | "all";
export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = {
surface?: string;
chatType?: "direct" | "group" | "room";
keyPrefix?: string;
};
export type SessionSendPolicyRule = {
action: SessionSendPolicyAction;
match?: SessionSendPolicyMatch;
};
export type SessionSendPolicyConfig = {
default?: SessionSendPolicyAction;
rules?: SessionSendPolicyRule[];
};
export type SessionConfig = {
scope?: SessionScope;
@@ -28,6 +43,7 @@ export type SessionConfig = {
store?: string;
typingIntervalSeconds?: number;
mainKey?: string;
sendPolicy?: SessionSendPolicyConfig;
};
export type LoggingConfig = {
@@ -634,6 +650,61 @@ export type ClawdisConfig = {
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/** Use one container per session (recommended for hard isolation). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: {
/** Docker image to use for sandbox containers. */
image?: string;
/** Prefix for sandbox container names. */
containerPrefix?: string;
/** Container workdir mount path (default: /workspace). */
workdir?: string;
/** Run container rootfs read-only. */
readOnlyRoot?: boolean;
/** Extra tmpfs mounts for read-only containers. */
tmpfs?: string[];
/** Container network mode (bridge|none|custom). */
network?: string;
/** Container user (uid:gid). */
user?: string;
/** Drop Linux capabilities. */
capDrop?: string[];
/** Extra environment variables for sandbox exec. */
env?: Record<string, string>;
/** Optional setup command run once after container creation. */
setupCommand?: string;
};
/** Optional sandboxed browser settings. */
browser?: {
enabled?: boolean;
image?: string;
containerPrefix?: string;
cdpPort?: number;
vncPort?: number;
noVncPort?: number;
headless?: boolean;
enableNoVnc?: boolean;
};
/** Tool allow/deny policy (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
/** Auto-prune sandbox containers. */
prune?: {
/** Prune if idle for more than N hours (0 disables). */
idleHours?: number;
/** Prune if older than N days (0 disables). */
maxAgeDays?: number;
};
};
};
routing?: RoutingConfig;
messages?: MessagesConfig;
@@ -798,6 +869,31 @@ const SessionSchema = z
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
mainKey: z.string().optional(),
sendPolicy: z
.object({
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
rules: z
.array(
z.object({
action: z.union([z.literal("allow"), z.literal("deny")]),
match: z
.object({
surface: z.string().optional(),
chatType: z
.union([
z.literal("direct"),
z.literal("group"),
z.literal("room"),
])
.optional(),
keyPrefix: z.string().optional(),
})
.optional(),
}),
)
.optional(),
})
.optional(),
})
.optional();
@@ -1041,6 +1137,53 @@ export const ClawdisSchema = z.object({
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: z
.object({
image: z.string().optional(),
containerPrefix: z.string().optional(),
workdir: z.string().optional(),
readOnlyRoot: z.boolean().optional(),
tmpfs: z.array(z.string()).optional(),
network: z.string().optional(),
user: z.string().optional(),
capDrop: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
setupCommand: z.string().optional(),
})
.optional(),
browser: z
.object({
enabled: z.boolean().optional(),
image: z.string().optional(),
containerPrefix: z.string().optional(),
cdpPort: z.number().int().positive().optional(),
vncPort: z.number().int().positive().optional(),
noVncPort: z.number().int().positive().optional(),
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
})
.optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
prune: z
.object({
idleHours: z.number().int().nonnegative().optional(),
maxAgeDays: z.number().int().nonnegative().optional(),
})
.optional(),
})
.optional(),
})
.optional(),
routing: RoutingSchema,
@@ -1629,6 +1772,24 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig {
return mutated ? next : cfg;
}
function applySessionDefaults(cfg: ClawdisConfig): ClawdisConfig {
const session = cfg.session;
if (!session || session.mainKey === undefined) return cfg;
const trimmed = session.mainKey.trim();
const next: ClawdisConfig = {
...cfg,
session: { ...session, mainKey: "main" },
};
if (trimmed && trimmed !== "main" && !warnedMainKeyOverride) {
warnedMainKeyOverride = true;
console.warn('session.mainKey is ignored; main session is always "main".');
}
return next;
}
export function loadConfig(): ClawdisConfig {
// Read config file (JSON5) if present.
const configPath = CONFIG_PATH_CLAWDIS;
@@ -1645,7 +1806,9 @@ export function loadConfig(): ClawdisConfig {
}
return {};
}
return applyIdentityDefaults(validated.data as ClawdisConfig);
return applySessionDefaults(
applyIdentityDefaults(validated.data as ClawdisConfig),
);
} catch (err) {
console.error(`Failed to read config at ${configPath}`, err);
return {};
@@ -1679,7 +1842,9 @@ export function validateConfigObject(
}
return {
ok: true,
config: applyIdentityDefaults(validated.data as ClawdisConfig),
config: applySessionDefaults(
applyIdentityDefaults(validated.data as ClawdisConfig),
),
};
}
@@ -1738,7 +1903,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const configPath = CONFIG_PATH_CLAWDIS;
const exists = fs.existsSync(configPath);
if (!exists) {
const config = applyTalkApiKey({});
const config = applyTalkApiKey(applySessionDefaults({}));
const legacyIssues: LegacyConfigIssue[] = [];
return {
path: configPath,
@@ -1792,7 +1957,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
raw,
parsed: parsedRes.parsed,
valid: true,
config: applyTalkApiKey(validated.config),
config: applyTalkApiKey(applySessionDefaults(validated.config)),
issues: [],
legacyIssues,
};
+6 -3
View File
@@ -129,13 +129,16 @@ function buildBaseHints(): ConfigUiHints {
};
}
for (const [path, label] of Object.entries(FIELD_LABELS)) {
hints[path] = { ...(hints[path] ?? {}), label };
const current = hints[path];
hints[path] = current ? { ...current, label } : { label };
}
for (const [path, help] of Object.entries(FIELD_HELP)) {
hints[path] = { ...(hints[path] ?? {}), help };
const current = hints[path];
hints[path] = current ? { ...current, help } : { help };
}
for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) {
hints[path] = { ...(hints[path] ?? {}), placeholder };
const current = hints[path];
hints[path] = current ? { ...current, placeholder } : { placeholder };
}
return hints;
}
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more