Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38841c71e0 | ||
|
|
ec1b430a2c | ||
|
|
3431d3d115 | ||
|
|
fe9e027d58 | ||
|
|
33d17957e5 | ||
|
|
25ae5f897e | ||
|
|
624ff09314 | ||
|
|
c8003ae472 | ||
|
|
6bf627bce8 | ||
|
|
08525435e0 | ||
|
|
026cf1130e | ||
|
|
bb14b19922 | ||
|
|
544ca062a3 | ||
|
|
be9aa5494a | ||
|
|
0d6af15d1c | ||
|
|
eda9410bce | ||
|
|
a51ed8a5dd | ||
|
|
19bcbf85df | ||
|
|
f49d0e5476 | ||
|
|
9c4c9c5edd | ||
|
|
0f34255359 | ||
|
|
de5fb65cb8 | ||
|
|
e773f84e39 | ||
|
|
b969c216fc | ||
|
|
f51a4d2aca | ||
|
|
30b3a9de30 | ||
|
|
0391f6553b | ||
|
|
d0c986c4f0 | ||
|
|
384028e12e | ||
|
|
4c14d6c8db | ||
|
|
6fa437613b | ||
|
|
949fa1051f | ||
|
|
4965727f39 | ||
|
|
7c34883267 | ||
|
|
072c3dc55c | ||
|
|
83b3875131 | ||
|
|
a35083808c | ||
|
|
3d3ec9d972 | ||
|
|
9838a2850f | ||
|
|
6c6bc6ff1c | ||
|
|
1791c1a765 | ||
|
|
6e53c061ff | ||
|
|
1773f8aea2 | ||
|
|
52bdf57743 | ||
|
|
b3ab24eb8e | ||
|
|
6ac1c1d6ea | ||
|
|
7655a501d0 | ||
|
|
3b1b14b0b1 | ||
|
|
bf15c87d2b | ||
|
|
2dae4d382f | ||
|
|
e9a47a02d1 | ||
|
|
929666a8c8 | ||
|
|
cd409e5667 | ||
|
|
8e80823b03 | ||
|
|
319afd192d | ||
|
|
6e0daf0936 | ||
|
|
d0cb4e092f | ||
|
|
f5a881c99d | ||
|
|
66377fc030 | ||
|
|
d8d295b0b3 | ||
|
|
ef36e24522 | ||
|
|
d43d4fcced | ||
|
|
d42b69df74 | ||
|
|
1ec1f6dcbf | ||
|
|
e96b939732 | ||
|
|
e479c870fd | ||
|
|
f2db894685 | ||
|
|
4f03283126 | ||
|
|
ed7dec0975 | ||
|
|
fbb3da506f | ||
|
|
dfa6c5c2b3 | ||
|
|
028eed5fe8 | ||
|
|
2b16a87f04 | ||
|
|
731049936d | ||
|
|
5a5b058ba0 | ||
|
|
72f28be648 | ||
|
|
ff645524d8 | ||
|
|
c534390bc0 | ||
|
|
c5003e5441 | ||
|
|
0b3ebb0c63 | ||
|
|
23981496f9 | ||
|
|
f2e425dc2b | ||
|
|
e48d68bbc7 | ||
|
|
842fc8d08b | ||
|
|
54ec14262b | ||
|
|
d0c70178e0 | ||
|
|
3bc9c330eb | ||
|
|
b7fcc8584f | ||
|
|
2b8ce3f06b | ||
|
|
fa9aafce83 | ||
|
|
0d5dec4c66 | ||
|
|
b2d5889f6e | ||
|
|
2ee71e4154 | ||
|
|
509215e935 | ||
|
|
f726656d1e | ||
|
|
30d3e1da21 |
No files matched your search
@@ -55,3 +55,5 @@ apps/ios/*.mobileprovision
|
|||||||
# Local untracked files
|
# Local untracked files
|
||||||
.local/
|
.local/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
IDENTITY.md
|
||||||
|
USER.md
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||||
|
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
@@ -116,6 +117,14 @@
|
|||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
|
|
||||||
|
## NPM + 1Password (publish/verify)
|
||||||
|
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||||
|
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||||
|
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||||
|
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||||
|
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||||
|
- Kill the tmux session after publish.
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
## Exclamation Mark Escaping Workaround
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
|
|||||||
+53
-13
@@ -1,43 +1,82 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## 2026.1.16 (unreleased)
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||||
|
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
||||||
|
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||||
|
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||||
|
|
||||||
|
### Changes
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||||
|
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||||
|
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||||
|
- Skills: add user-invocable skill commands with sanitized names and an opt-out for model invocation.
|
||||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
- TUI: show provider/model labels for the active session and default model.
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
- Agents: default to no narration for routine tool calls. (#1008) — thanks @cpojer.
|
||||||
- Agents: include recent context in reminder cron payloads. (#993) — thanks @cpojer.
|
|
||||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
|
||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||||
|
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
|
||||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||||
|
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
||||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||||
|
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||||
|
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||||
|
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||||
|
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
|
||||||
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
|
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||||
|
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||||
|
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||||
|
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||||
|
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||||
|
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||||
|
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||||
|
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
|
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||||
|
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||||
|
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||||
|
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||||
|
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||||
|
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||||
|
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||||
|
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||||
|
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||||
|
- Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts.
|
||||||
|
- Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira.
|
||||||
|
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
|
||||||
|
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
|
||||||
|
|
||||||
## 2026.1.14-1
|
## 2026.1.14-1
|
||||||
|
|
||||||
@@ -139,6 +178,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||||
|
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
||||||
|
|
||||||
## 2026.1.12-1
|
## 2026.1.12-1
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||||
|
ENV CLAWDBOT_PREFER_PNPM=1
|
||||||
RUN pnpm ui:install
|
RUN pnpm ui:install
|
||||||
RUN pnpm ui:build
|
RUN pnpm ui:build
|
||||||
|
|
||||||
|
|||||||
+81
-17
@@ -2,6 +2,87 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.15</title>
|
||||||
|
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>5998</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||||
|
<h3>Highlights</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||||
|
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||||
|
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||||
|
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Breaking</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||||
|
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||||
|
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||||
|
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||||
|
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||||
|
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||||
|
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||||
|
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||||
|
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||||
|
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||||
|
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||||
|
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||||
|
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||||
|
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||||
|
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||||
|
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||||
|
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||||
|
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||||
|
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||||
|
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||||
|
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||||
|
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||||
|
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||||
|
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||||
|
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||||
|
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||||
|
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||||
|
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||||
|
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||||
|
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||||
|
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||||
|
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||||
|
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||||
|
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||||
|
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||||
|
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||||
|
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||||
|
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||||
|
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||||
|
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||||
|
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||||
|
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||||
|
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||||
|
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||||
|
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||||
|
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||||
|
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||||
|
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||||
|
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||||
|
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||||
|
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.14-1</title>
|
<title>2026.1.14-1</title>
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||||
@@ -190,22 +271,5 @@
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.1.11-3</title>
|
|
||||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5212</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
|
||||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -119,7 +119,7 @@ dependencies {
|
|||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||||
testImplementation("org.robolectric:robolectric:4.16")
|
testImplementation("org.robolectric:robolectric:4.16")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class BridgeSession(
|
|||||||
connectWithSocket(endpoint, hello, null)
|
connectWithSocket(endpoint, hello, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
||||||
val socket =
|
val socket =
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
createBridgeSocket(tls) { fingerprint ->
|
||||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||||
@@ -260,15 +260,15 @@ class BridgeSession(
|
|||||||
else -> throw IllegalStateException("unexpected bridge response")
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
}
|
}
|
||||||
|
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine() ?: break
|
||||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
when (frame["type"].asStringOrNull()) {
|
when (frame["type"].asStringOrNull()) {
|
||||||
"event" -> {
|
"event" -> {
|
||||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
val event = frame["event"].asStringOrNull() ?: continue
|
||||||
val payload = frame["payloadJSON"].asStringOrNull()
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
onEvent(event, payload)
|
onEvent(event, payload)
|
||||||
}
|
}
|
||||||
"ping" -> {
|
"ping" -> {
|
||||||
val id = frame["id"].asStringOrNull() ?: ""
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
@@ -314,20 +314,20 @@ class BridgeSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"invoke-res" -> {
|
"invoke-res" -> {
|
||||||
// gateway->node only (ignore)
|
// gateway->node only (ignore)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
for ((_, waiter) in pending) {
|
|
||||||
waiter.cancel()
|
|
||||||
}
|
|
||||||
pending.clear()
|
|
||||||
conn.closeQuietly()
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.clawdbot.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -21,6 +22,7 @@ fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? =
|
|||||||
if (params == null) return Socket()
|
if (params == null) return Socket()
|
||||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||||
val defaultTrust = defaultTrustManager()
|
val defaultTrust = defaultTrustManager()
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
val trustManager =
|
val trustManager =
|
||||||
object : X509TrustManager {
|
object : X509TrustManager {
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
|||||||
@@ -32,11 +32,10 @@ func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options?
|
|||||||
sec_protocol_options_set_verify_block(
|
sec_protocol_options_set_verify_block(
|
||||||
options.securityProtocolOptions,
|
options.securityProtocolOptions,
|
||||||
{ _, trust, complete in
|
{ _, trust, complete in
|
||||||
guard let trust else {
|
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||||
complete(false)
|
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||||
return
|
let cert = chain.first
|
||||||
}
|
{
|
||||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
let data = SecCertificateCopyData(cert) as Data
|
||||||
let fingerprint = sha256Hex(data)
|
let fingerprint = sha256Hex(data)
|
||||||
if let expected {
|
if let expected {
|
||||||
@@ -49,7 +48,7 @@ func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options?
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||||
complete(ok)
|
complete(ok)
|
||||||
},
|
},
|
||||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
||||||
|
|||||||
@@ -190,14 +190,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listDevices() -> [CameraDeviceInfo] {
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
let types: [AVCaptureDevice.DeviceType] = [
|
return Self.discoverVideoDevices().map { device in
|
||||||
.builtInWideAngleCamera,
|
|
||||||
]
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: types,
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified)
|
|
||||||
return session.devices.map { device in
|
|
||||||
CameraDeviceInfo(
|
CameraDeviceInfo(
|
||||||
id: device.uniqueID,
|
id: device.uniqueID,
|
||||||
name: device.localizedName,
|
name: device.localizedName,
|
||||||
@@ -232,7 +225,7 @@ actor CameraController {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +245,24 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||||
|
let types: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.builtInUltraWideCamera,
|
||||||
|
.builtInTelephotoCamera,
|
||||||
|
.builtInDualCamera,
|
||||||
|
.builtInDualWideCamera,
|
||||||
|
.builtInTripleCamera,
|
||||||
|
.builtInTrueDepthCamera,
|
||||||
|
.builtInLiDARDepthCamera,
|
||||||
|
]
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: types,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified)
|
||||||
|
return session.devices
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
let q = quality ?? 0.9
|
let q = quality ?? 0.9
|
||||||
return min(1.0, max(0.05, q))
|
return min(1.0, max(0.05, q))
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||||
{
|
{
|
||||||
{ sample, type, error in
|
{ sample, type, error in
|
||||||
|
let sampleBox = UncheckedSendableBox(value: sample)
|
||||||
// ReplayKit can call the capture handler on a background queue.
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
// Serialize writes to avoid queue asserts.
|
// Serialize writes to avoid queue asserts.
|
||||||
recordQueue.async {
|
recordQueue.async {
|
||||||
|
let sample = sampleBox.value
|
||||||
if let error {
|
if let error {
|
||||||
state.withLock { state in
|
state.withLock { state in
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ Sources/Voice/VoiceTab.swift
|
|||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
||||||
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Clawdbot
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.clawdbot
|
bundleIdPrefix: com.clawdbot
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "17.0"
|
iOS: "18.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
@@ -73,6 +73,15 @@
|
|||||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-concurrency-extras",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||||
|
"version" : "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-configuration",
|
"identity" : "swift-configuration",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -144,6 +153,24 @@
|
|||||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||||
"version" : "1.6.3"
|
"version" : "1.6.3"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftui-math",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||||
|
"version" : "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "textual",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/textual",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||||
|
"version" : "0.2.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -257,30 +257,8 @@ final class AppState {
|
|||||||
|
|
||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let configMode: ConnectionMode? = switch configModeRaw {
|
|
||||||
case "local":
|
|
||||||
.local
|
|
||||||
case "remote":
|
|
||||||
.remote
|
|
||||||
default:
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let configHasRemoteUrl = !(configRemoteUrl?
|
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.isEmpty ?? true)
|
|
||||||
|
|
||||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
|
||||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
|
||||||
configMode
|
|
||||||
} else if configHasRemoteUrl {
|
|
||||||
.remote
|
|
||||||
} else if let storedMode {
|
|
||||||
ConnectionMode(rawValue: storedMode) ?? .local
|
|
||||||
} else {
|
|
||||||
onboardingSeen ? .local : .unconfigured
|
|
||||||
}
|
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.connectionMode = resolvedConnectionMode
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
@@ -341,6 +319,15 @@ final class AppState {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func sanitizeSSHTarget(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("ssh ") {
|
||||||
|
return trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
private func startConfigWatcher() {
|
private func startConfigWatcher() {
|
||||||
let configUrl = ClawdbotConfigFile.url()
|
let configUrl = ClawdbotConfigFile.url()
|
||||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||||
@@ -406,6 +393,7 @@ final class AppState {
|
|||||||
|
|
||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
|
let remoteIdentity = self.remoteIdentity
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@@ -435,15 +423,46 @@ final class AppState {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if connectionMode == .remote, let host = remoteHost {
|
if connectionMode == .remote {
|
||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
var remoteChanged = false
|
||||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
|
||||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
if let host = remoteHost {
|
||||||
let port = parsedExisting?.port ?? 18789
|
let existingUrl = (remote["url"] as? String)?
|
||||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if existingUrl != desiredUrl {
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||||
remote["url"] = desiredUrl
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||||
|
let port = parsedExisting?.port ?? 18789
|
||||||
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||||
|
if existingUrl != desiredUrl {
|
||||||
|
remote["url"] = desiredUrl
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||||
|
if !sanitizedTarget.isEmpty {
|
||||||
|
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||||
|
remote["sshTarget"] = sanitizedTarget
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else if remote["sshTarget"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshTarget")
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedIdentity.isEmpty {
|
||||||
|
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||||
|
remote["sshIdentity"] = trimmedIdentity
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else if remote["sshIdentity"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshIdentity")
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if remoteChanged {
|
||||||
gateway["remote"] = remote
|
gateway["remote"] = remote
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,14 +385,8 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
let mode: AppState.ConnectionMode
|
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||||
if let modeRaw {
|
|
||||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
|
||||||
} else {
|
|
||||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
|
||||||
mode = seen ? .local : .unconfigured
|
|
||||||
}
|
|
||||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum EffectiveConnectionModeSource: Sendable, Equatable {
|
||||||
|
case configMode
|
||||||
|
case configRemoteURL
|
||||||
|
case userDefaults
|
||||||
|
case onboarding
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EffectiveConnectionMode: Sendable, Equatable {
|
||||||
|
let mode: AppState.ConnectionMode
|
||||||
|
let source: EffectiveConnectionModeSource
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionModeResolver {
|
||||||
|
static func resolve(
|
||||||
|
root: [String: Any],
|
||||||
|
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
||||||
|
{
|
||||||
|
let gateway = root["gateway"] as? [String: Any]
|
||||||
|
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
||||||
|
let configMode = configModeRaw
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
switch configMode {
|
||||||
|
case "local":
|
||||||
|
return EffectiveConnectionMode(mode: .local, source: .configMode)
|
||||||
|
case "remote":
|
||||||
|
return EffectiveConnectionMode(mode: .remote, source: .configMode)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
|
||||||
|
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !remoteURL.isEmpty {
|
||||||
|
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
|
||||||
|
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
|
||||||
|
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||||
|
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,9 +188,15 @@ extension CronJobEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
func applyDeleteAfterRun(
|
||||||
if self.scheduleKind == .at {
|
to root: inout [String: Any],
|
||||||
root["deleteAfterRun"] = self.deleteAfterRun
|
scheduleKind: ScheduleKind? = nil,
|
||||||
|
deleteAfterRun: Bool? = nil)
|
||||||
|
{
|
||||||
|
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
||||||
|
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
||||||
|
if resolvedSchedule == .at {
|
||||||
|
root["deleteAfterRun"] = resolvedDelete
|
||||||
} else if self.job?.deleteAfterRun != nil {
|
} else if self.job?.deleteAfterRun != nil {
|
||||||
root["deleteAfterRun"] = false
|
root["deleteAfterRun"] = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ConcurrencyExtras
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
|
|||||||
static let shared = GatewayEndpointStore()
|
static let shared = GatewayEndpointStore()
|
||||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||||
|
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||||
|
private enum EnvOverrideWarningKind: Sendable {
|
||||||
|
case token
|
||||||
|
case password
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
||||||
|
|
||||||
struct Deps: Sendable {
|
struct Deps: Sendable {
|
||||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||||
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
|
|||||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||||
token: {
|
token: {
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||||
return GatewayEndpointStore.resolveGatewayToken(
|
return GatewayEndpointStore.resolveGatewayToken(
|
||||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
isRemote: isRemote,
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment,
|
env: ProcessInfo.processInfo.environment,
|
||||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||||
return GatewayEndpointStore.resolveGatewayPassword(
|
return GatewayEndpointStore.resolveGatewayPassword(
|
||||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
isRemote: isRemote,
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment,
|
env: ProcessInfo.processInfo.environment,
|
||||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||||
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
|
|||||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
|
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
||||||
|
!configPassword.isEmpty
|
||||||
|
{
|
||||||
|
self.warnEnvOverrideOnce(
|
||||||
|
kind: .password,
|
||||||
|
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
||||||
|
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
||||||
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
if isRemote {
|
if isRemote {
|
||||||
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
||||||
|
if isRemote {
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let password = remote["password"] as? String
|
||||||
|
{
|
||||||
|
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let password = auth["password"] as? String
|
||||||
|
{
|
||||||
|
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private static func resolveGatewayToken(
|
private static func resolveGatewayToken(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
|
|||||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
|
!configToken.isEmpty
|
||||||
|
{
|
||||||
|
self.warnEnvOverrideOnce(
|
||||||
|
kind: .token,
|
||||||
|
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
||||||
|
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
||||||
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
if isRemote {
|
if isRemote {
|
||||||
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||||
|
if isRemote {
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let token = remote["token"] as? String
|
||||||
|
{
|
||||||
|
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let token = auth["token"] as? String
|
||||||
|
{
|
||||||
|
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func warnEnvOverrideOnce(
|
||||||
|
kind: EnvOverrideWarningKind,
|
||||||
|
envVar: String,
|
||||||
|
configKey: String)
|
||||||
|
{
|
||||||
|
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
||||||
|
switch kind {
|
||||||
|
case .token:
|
||||||
|
guard !state.token else { return false }
|
||||||
|
state.token = true
|
||||||
|
return true
|
||||||
|
case .password:
|
||||||
|
guard !state.password else { return false }
|
||||||
|
state.password = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard shouldWarn else { return }
|
||||||
|
Self.staticLogger.warning(
|
||||||
|
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
||||||
|
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
private let deps: Deps
|
private let deps: Deps
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||||
|
|
||||||
|
|||||||
@@ -2,77 +2,16 @@ import Foundation
|
|||||||
|
|
||||||
enum GatewayLaunchAgentManager {
|
enum GatewayLaunchAgentManager {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
|
||||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
|
||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
private enum GatewayProgramArgumentsError: LocalizedError {
|
|
||||||
case message(String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case let .message(message):
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var legacyPlistURL: URL {
|
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func gatewayProgramArguments(
|
|
||||||
port: Int,
|
|
||||||
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
|
||||||
{
|
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
|
||||||
#if DEBUG
|
|
||||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
|
||||||
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
|
||||||
}
|
|
||||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
|
||||||
switch CommandResolver.runtimeResolution() {
|
|
||||||
case let .success(runtime):
|
|
||||||
let cmd = CommandResolver.makeRuntimeCommand(
|
|
||||||
runtime: runtime,
|
|
||||||
entrypoint: entry,
|
|
||||||
subcommand: "gateway-daemon",
|
|
||||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
|
||||||
return .success(cmd)
|
|
||||||
case .failure:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
let searchPaths = CommandResolver.preferredPaths()
|
|
||||||
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
|
||||||
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
|
||||||
}
|
|
||||||
|
|
||||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
|
||||||
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
|
||||||
{
|
|
||||||
let cmd = CommandResolver.makeRuntimeCommand(
|
|
||||||
runtime: runtime,
|
|
||||||
entrypoint: entry,
|
|
||||||
subcommand: "gateway-daemon",
|
|
||||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
|
||||||
return .success(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
return loaded
|
||||||
return result.status == 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
@@ -81,255 +20,44 @@ enum GatewayLaunchAgentManager {
|
|||||||
self.logger.info("launchd enable skipped (disable marker set)")
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
self.logger.info("launchd enable requested via CLI port=\(port)")
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
return await self.runDaemonCommand([
|
||||||
|
"install",
|
||||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
"--force",
|
||||||
let desiredToken = self.preferredGatewayToken()
|
"--port",
|
||||||
let desiredPassword = self.preferredGatewayPassword()
|
"\(port)",
|
||||||
let desiredConfig = DesiredConfig(
|
"--runtime",
|
||||||
port: port,
|
"node",
|
||||||
bind: desiredBind,
|
])
|
||||||
token: desiredToken,
|
|
||||||
password: desiredPassword)
|
|
||||||
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
|
||||||
guard case let .success(programArguments) = programArgumentsResult else {
|
|
||||||
if case let .failure(error) = programArgumentsResult {
|
|
||||||
let message = error.localizedDescription
|
|
||||||
self.logger.error("launchd enable failed: \(message)")
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return "Failed to resolve gateway command."
|
|
||||||
}
|
|
||||||
|
|
||||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
|
||||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
|
||||||
let loaded = await self.isLoaded()
|
|
||||||
if loaded {
|
|
||||||
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
|
||||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
|
||||||
await self.ensureEnabled()
|
|
||||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
|
||||||
self.writePlist(programArguments: programArguments)
|
|
||||||
|
|
||||||
await self.ensureEnabled()
|
|
||||||
if loaded {
|
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
|
||||||
}
|
|
||||||
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
|
||||||
if bootstrap.status != 0 {
|
|
||||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
|
||||||
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? "Failed to bootstrap gateway launchd job"
|
|
||||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
await self.ensureEnabled()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd disable requested")
|
self.logger.info("launchd disable requested via CLI")
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
return await self.runDaemonCommand(["uninstall"])
|
||||||
await self.ensureDisabled()
|
|
||||||
try? FileManager.default.removeItem(at: self.plistURL)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func kickstart() async {
|
static func kickstart() async {
|
||||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
||||||
}
|
|
||||||
|
|
||||||
private static func writePlist(programArguments: [String]) {
|
|
||||||
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
|
||||||
let token = self.preferredGatewayToken()
|
|
||||||
let password = self.preferredGatewayPassword()
|
|
||||||
var envEntries = """
|
|
||||||
<key>PATH</key>
|
|
||||||
<string>\(preferredPath)</string>
|
|
||||||
"""
|
|
||||||
if let token {
|
|
||||||
let escapedToken = self.escapePlistValue(token)
|
|
||||||
envEntries += """
|
|
||||||
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
|
||||||
<string>\(escapedToken)</string>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
if let password {
|
|
||||||
let escapedPassword = self.escapePlistValue(password)
|
|
||||||
envEntries += """
|
|
||||||
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
|
||||||
<string>\(escapedPassword)</string>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
let argsXml = programArguments
|
|
||||||
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
|
||||||
.joined(separator: "\n ")
|
|
||||||
let plist = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>\(gatewayLaunchdLabel)</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
\(argsXml)
|
|
||||||
</array>
|
|
||||||
<key>WorkingDirectory</key>
|
|
||||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
\(envEntries)
|
|
||||||
</dict>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
"""
|
|
||||||
do {
|
|
||||||
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func preferredGatewayBind() -> String? {
|
|
||||||
if CommandResolver.connectionModeIsRemote() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
|
||||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
if self.supportedBindModes.contains(trimmed) {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let bind = gateway["bind"] as? String
|
|
||||||
{
|
|
||||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
if self.supportedBindModes.contains(trimmed) {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func preferredGatewayToken() -> String? {
|
|
||||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmed.isEmpty {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let auth = gateway["auth"] as? [String: Any],
|
|
||||||
let token = auth["token"] as? String
|
|
||||||
{
|
|
||||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !value.isEmpty {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func preferredGatewayPassword() -> String? {
|
|
||||||
// First check environment variable
|
|
||||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmed.isEmpty {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
// Then check config file (gateway.auth.password)
|
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let auth = gateway["auth"] as? [String: Any],
|
|
||||||
let password = auth["password"] as? String
|
|
||||||
{
|
|
||||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func escapePlistValue(_ raw: String) -> String {
|
|
||||||
raw
|
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
|
||||||
.replacingOccurrences(of: "\"", with: """)
|
|
||||||
.replacingOccurrences(of: "'", with: "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DesiredConfig: Equatable {
|
|
||||||
let port: Int
|
|
||||||
let bind: String
|
|
||||||
let token: String?
|
|
||||||
let password: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct InstalledConfig: Equatable {
|
|
||||||
let port: Int?
|
|
||||||
let bind: String?
|
|
||||||
let token: String?
|
|
||||||
let password: String?
|
|
||||||
|
|
||||||
func matches(_ desired: DesiredConfig) -> Bool {
|
|
||||||
guard self.port == desired.port else { return false }
|
|
||||||
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
|
||||||
guard self.token == desired.token else { return false }
|
|
||||||
guard self.password == desired.password else { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func readPlistConfig() -> InstalledConfig? {
|
|
||||||
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
|
||||||
return InstalledConfig(
|
|
||||||
port: snapshot.port,
|
|
||||||
bind: snapshot.bind,
|
|
||||||
token: snapshot.token,
|
|
||||||
password: snapshot.password)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func ensureEnabled() async {
|
static func launchdGatewayLogPath() -> String {
|
||||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
let snapshot = self.launchdConfigSnapshot()
|
||||||
guard result.status != 0 else { return }
|
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
!stdout.isEmpty
|
||||||
if msg.isEmpty {
|
{
|
||||||
self.logger.warning("launchd enable failed")
|
return stdout
|
||||||
} else {
|
|
||||||
self.logger.warning("launchd enable failed: \(msg)")
|
|
||||||
}
|
}
|
||||||
}
|
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!stderr.isEmpty
|
||||||
private static func ensureDisabled() async {
|
{
|
||||||
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
return stderr
|
||||||
guard result.status != 0 else { return }
|
|
||||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if msg.isEmpty {
|
|
||||||
self.logger.warning("launchd disable failed")
|
|
||||||
} else {
|
|
||||||
self.logger.warning("launchd disable failed: \(msg)")
|
|
||||||
}
|
}
|
||||||
|
return LogLocator.launchdGatewayLogPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,20 +67,102 @@ extension GatewayLaunchAgentManager {
|
|||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
return FileManager.default.fileExists(atPath: marker.path)
|
return FileManager.default.fileExists(atPath: marker.path)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
extension GatewayLaunchAgentManager {
|
let result = await self.runDaemonCommandResult(
|
||||||
static func _testPreferredGatewayBind() -> String? {
|
["status", "--json", "--no-probe"],
|
||||||
self.preferredGatewayBind()
|
timeout: 15,
|
||||||
|
quiet: true)
|
||||||
|
guard result.success, let payload = result.payload else { return nil }
|
||||||
|
guard
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||||
|
let service = json["service"] as? [String: Any],
|
||||||
|
let loaded = service["loaded"] as? Bool
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _testPreferredGatewayToken() -> String? {
|
private struct CommandResult {
|
||||||
self.preferredGatewayToken()
|
let success: Bool
|
||||||
|
let payload: Data?
|
||||||
|
let message: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
private struct ParsedDaemonJson {
|
||||||
self.escapePlistValue(raw)
|
let text: String
|
||||||
|
let object: [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runDaemonCommand(
|
||||||
|
_ args: [String],
|
||||||
|
timeout: Double = 15,
|
||||||
|
quiet: Bool = false) async -> String?
|
||||||
|
{
|
||||||
|
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
||||||
|
if result.success { return nil }
|
||||||
|
return result.message ?? "Gateway daemon command failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runDaemonCommandResult(
|
||||||
|
_ args: [String],
|
||||||
|
timeout: Double,
|
||||||
|
quiet: Bool) async -> CommandResult
|
||||||
|
{
|
||||||
|
let command = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "daemon",
|
||||||
|
extraArgs: self.withJsonFlag(args))
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||||
|
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
||||||
|
let ok = parsed?.object["ok"] as? Bool
|
||||||
|
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
||||||
|
let payload = parsed?.text.data(using: .utf8)
|
||||||
|
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||||
|
let success = ok ?? response.success
|
||||||
|
if success {
|
||||||
|
return CommandResult(success: true, payload: payload, message: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if quiet {
|
||||||
|
return CommandResult(success: false, payload: payload, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||||
|
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||||
|
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
||||||
|
?? "Gateway daemon command failed (\(exit))"
|
||||||
|
self.logger.error("\(fullMessage, privacy: .public)")
|
||||||
|
return CommandResult(success: false, payload: payload, message: detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||||
|
if args.contains("--json") { return args }
|
||||||
|
return args + ["--json"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let start = trimmed.firstIndex(of: "{"),
|
||||||
|
let end = trimmed.lastIndex(of: "}")
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let jsonText = String(trimmed[start...end])
|
||||||
|
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
|
return ParsedDaemonJson(text: jsonText, object: object)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func summarize(_ text: String) -> String? {
|
||||||
|
let lines = text
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard let last = lines.last else { return nil }
|
||||||
|
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
|
|||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||||
|
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||||
|
switch self.status {
|
||||||
|
case .starting, .running, .attachedExisting:
|
||||||
|
return
|
||||||
|
case .stopped, .failed:
|
||||||
|
break
|
||||||
|
}
|
||||||
self.status = .starting
|
self.status = .starting
|
||||||
self.logger.debug("gateway start requested")
|
self.logger.debug("gateway start requested")
|
||||||
|
|
||||||
@@ -140,7 +148,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func refreshLog() {
|
func refreshLog() {
|
||||||
guard self.logRefreshTask == nil else { return }
|
guard self.logRefreshTask == nil else { return }
|
||||||
let path = LogLocator.launchdGatewayLogPath
|
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
||||||
let limit = self.logLimit
|
let limit = self.logLimit
|
||||||
self.logRefreshTask = Task { [weak self] in
|
self.logRefreshTask = Task { [weak self] in
|
||||||
let log = await Task.detached(priority: .utility) {
|
let log = await Task.detached(priority: .utility) {
|
||||||
@@ -354,7 +362,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||||
self.logger.debug("gateway log cleared")
|
self.logger.debug("gateway log cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,13 @@ struct GeneralSettings: View {
|
|||||||
Text(policy.title).tag(policy)
|
Text(policy.title).tag(policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Text("""
|
Text("""
|
||||||
Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`.
|
Controls remote command execution on this Mac when it is paired as a node. \
|
||||||
|
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||||
|
"Never" disables `system.run`.
|
||||||
""")
|
""")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
@@ -111,7 +114,8 @@ struct GeneralSettings: View {
|
|||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
.disabled(self.locationMode == .off)
|
.disabled(self.locationMode == .off)
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ extension InstancesSettings {
|
|||||||
host: "phone",
|
host: "phone",
|
||||||
ip: "10.0.0.3",
|
ip: "10.0.0.3",
|
||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
platform: "iOS 17.2",
|
platform: "iOS 18.0",
|
||||||
deviceFamily: "iPhone",
|
deviceFamily: "iPhone",
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: 35,
|
lastInputSeconds: 35,
|
||||||
@@ -446,7 +446,7 @@ extension InstancesSettings {
|
|||||||
_ = view.platformIcon("watchOS 10")
|
_ = view.platformIcon("watchOS 10")
|
||||||
_ = view.platformIcon("unknown 1.0")
|
_ = view.platformIcon("unknown 1.0")
|
||||||
_ = view.prettyPlatform("macOS 14.2")
|
_ = view.prettyPlatform("macOS 14.2")
|
||||||
_ = view.prettyPlatform("iOS 17")
|
_ = view.prettyPlatform("iOS 18")
|
||||||
_ = view.prettyPlatform("ipados 17.1")
|
_ = view.prettyPlatform("ipados 17.1")
|
||||||
_ = view.prettyPlatform("linux")
|
_ = view.prettyPlatform("linux")
|
||||||
_ = view.prettyPlatform(" ")
|
_ = view.prettyPlatform(" ")
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ enum Launchctl {
|
|||||||
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||||
let programArguments: [String]
|
let programArguments: [String]
|
||||||
let environment: [String: String]
|
let environment: [String: String]
|
||||||
|
let stdoutPath: String?
|
||||||
|
let stderrPath: String?
|
||||||
|
|
||||||
let port: Int?
|
let port: Int?
|
||||||
let bind: String?
|
let bind: String?
|
||||||
@@ -53,6 +55,10 @@ enum LaunchAgentPlist {
|
|||||||
guard let root = rootAny as? [String: Any] else { return nil }
|
guard let root = rootAny as? [String: Any] else { return nil }
|
||||||
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||||
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||||
|
let stdoutPath = (root["StandardOutPath"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let stderrPath = (root["StandardErrorPath"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||||
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||||
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
@@ -60,6 +66,8 @@ enum LaunchAgentPlist {
|
|||||||
return LaunchAgentPlistSnapshot(
|
return LaunchAgentPlistSnapshot(
|
||||||
programArguments: programArguments,
|
programArguments: programArguments,
|
||||||
environment: env,
|
environment: env,
|
||||||
|
stdoutPath: stdoutPath,
|
||||||
|
stderrPath: stderrPath,
|
||||||
port: port,
|
port: port,
|
||||||
bind: bind,
|
bind: bind,
|
||||||
token: token,
|
token: token,
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ actor MacNodeBridgeSession {
|
|||||||
do {
|
do {
|
||||||
try await self.send(response)
|
try await self.send(response)
|
||||||
} catch {
|
} catch {
|
||||||
await self.logInvokeSendFailure(error)
|
self.logInvokeSendFailure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ enum MacNodeBridgeTLSStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
static func loadFingerprint(stableID: String) -> String? {
|
||||||
let key = keyPrefix + stableID
|
let key = self.keyPrefix + stableID
|
||||||
let raw = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return raw?.isEmpty == false ? raw : nil
|
return raw?.isEmpty == false ? raw : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
static func saveFingerprint(_ value: String, stableID: String) {
|
||||||
let key = keyPrefix + stableID
|
let key = self.keyPrefix + stableID
|
||||||
defaults.set(value, forKey: key)
|
self.defaults.set(value, forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +40,10 @@ func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.O
|
|||||||
sec_protocol_options_set_verify_block(
|
sec_protocol_options_set_verify_block(
|
||||||
options.securityProtocolOptions,
|
options.securityProtocolOptions,
|
||||||
{ _, trust, complete in
|
{ _, trust, complete in
|
||||||
guard let trust else {
|
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||||
complete(false)
|
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||||
return
|
let cert = chain.first
|
||||||
}
|
{
|
||||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
let data = SecCertificateCopyData(cert) as Data
|
||||||
let fingerprint = sha256Hex(data)
|
let fingerprint = sha256Hex(data)
|
||||||
if let expected {
|
if let expected {
|
||||||
@@ -57,7 +56,7 @@ func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.O
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||||
complete(ok)
|
complete(ok)
|
||||||
},
|
},
|
||||||
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
||||||
@@ -71,5 +70,5 @@ private func sha256Hex(_ data: Data) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
||||||
raw.lowercased().filter { $0.isHexDigit }
|
raw.lowercased().filter(\.isHexDigit)
|
||||||
}
|
}
|
||||||
@@ -164,6 +164,7 @@ final class MacNodeModeCoordinator {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if SystemRunPolicy.load() != .never {
|
if SystemRunPolicy.load() != .never {
|
||||||
|
commands.append(ClawdbotSystemCommand.which.rawValue)
|
||||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +464,7 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
||||||
let endpoint = result.endpoint
|
let endpoint = result.endpoint
|
||||||
guard case .service = endpoint else { return nil }
|
guard case .service = endpoint else { return nil }
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||||
@@ -477,7 +478,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveDiscoveredTLSParams(
|
private nonisolated static func resolveDiscoveredTLSParams(
|
||||||
stableID: String,
|
stableID: String,
|
||||||
tlsEnabled: Bool,
|
tlsEnabled: Bool,
|
||||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
||||||
@@ -503,7 +504,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
||||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||||
return MacNodeBridgeTLSParams(
|
return MacNodeBridgeTLSParams(
|
||||||
required: true,
|
required: true,
|
||||||
@@ -519,12 +520,12 @@ final class MacNodeModeCoordinator {
|
|||||||
storeKey: stableID)
|
storeKey: stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return raw.isEmpty ? nil : raw
|
return raw.isEmpty ? nil : raw
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
return raw == "1" || raw == "true" || raw == "yes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleScreenRecordInvoke(req)
|
return try await self.handleScreenRecordInvoke(req)
|
||||||
case ClawdbotSystemCommand.run.rawValue:
|
case ClawdbotSystemCommand.run.rawValue:
|
||||||
return try await self.handleSystemRun(req)
|
return try await self.handleSystemRun(req)
|
||||||
|
case ClawdbotSystemCommand.which.rawValue:
|
||||||
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
default:
|
default:
|
||||||
@@ -494,6 +496,33 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
|
||||||
|
let bins = params.bins
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard !bins.isEmpty else {
|
||||||
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchPaths = CommandResolver.preferredPaths()
|
||||||
|
var matches: [String] = []
|
||||||
|
var paths: [String: String] = [:]
|
||||||
|
for bin in bins {
|
||||||
|
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
|
||||||
|
matches.append(bin)
|
||||||
|
paths[bin] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WhichPayload: Encodable {
|
||||||
|
let bins: [String]
|
||||||
|
let paths: [String: String]
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
|
||||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -586,8 +615,8 @@ actor MacNodeRuntime {
|
|||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !key.isEmpty else { continue }
|
guard !key.isEmpty else { continue }
|
||||||
let upper = key.uppercased()
|
let upper = key.uppercased()
|
||||||
if blockedEnvKeys.contains(upper) { continue }
|
if self.blockedEnvKeys.contains(upper) { continue }
|
||||||
if blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
}
|
}
|
||||||
return merged
|
return merged
|
||||||
|
|||||||
@@ -351,10 +351,11 @@ actor PortGuardian {
|
|||||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||||
return false
|
return false
|
||||||
case .local:
|
case .local:
|
||||||
if !cmd.contains("clawdbot") { return false }
|
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
|
||||||
if full.contains("gateway-daemon") { return true }
|
if full.contains("gateway-daemon") { return true }
|
||||||
// If args are unavailable, treat a clawdbot listener as expected.
|
// If args are unavailable, treat a clawdbot listener as expected.
|
||||||
return full == cmd
|
if cmd.contains("clawdbot"), full == cmd { return true }
|
||||||
|
return false
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .never:
|
case .never:
|
||||||
return "Never"
|
"Never"
|
||||||
case .ask:
|
case .ask:
|
||||||
return "Always Ask"
|
"Always Ask"
|
||||||
case .always:
|
case .always:
|
||||||
return "Always Allow"
|
"Always Allow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +75,13 @@ enum SystemRunAllowlist {
|
|||||||
|
|
||||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||||
let key = key(for: argv)
|
let key = key(for: argv)
|
||||||
return load(from: defaults).contains(key)
|
return self.load(from: defaults).contains(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||||
let key = key(for: argv)
|
let key = key(for: argv)
|
||||||
guard !key.isEmpty else { return }
|
guard !key.isEmpty else { return }
|
||||||
var allowlist = load(from: defaults)
|
var allowlist = self.load(from: defaults)
|
||||||
if allowlist.insert(key).inserted {
|
if allowlist.insert(key).inserted {
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
provider: "whatsapp",
|
channel: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||||
@@ -70,22 +70,16 @@ struct CronJobEditorSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||||
var view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
view.name = "One-shot"
|
|
||||||
view.sessionTarget = .main
|
|
||||||
view.payloadKind = .systemEvent
|
|
||||||
view.systemEventText = "hello"
|
|
||||||
view.scheduleKind = .at
|
|
||||||
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
view.deleteAfterRun = true
|
|
||||||
|
|
||||||
let payload = try view.buildPayload()
|
var root: [String: Any] = [:]
|
||||||
let raw = payload["deleteAfterRun"]?.value as? Bool
|
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
||||||
|
let raw = root["deleteAfterRun"] as? Bool
|
||||||
#expect(raw == true)
|
#expect(raw == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ struct CronModelsTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 15,
|
timeoutSeconds: 15,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
provider: "whatsapp",
|
channel: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: false)
|
bestEffortDeliver: false)
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ import Testing
|
|||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite struct GatewayEndpointStoreTests {
|
@Suite struct GatewayEndpointStoreTests {
|
||||||
|
private func makeDefaults() -> UserDefaults {
|
||||||
|
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||||
|
stdoutPath: nil,
|
||||||
|
stderrPath: nil,
|
||||||
port: nil,
|
port: nil,
|
||||||
bind: nil,
|
bind: nil,
|
||||||
token: "launchd-token",
|
token: "launchd-token",
|
||||||
@@ -31,6 +40,8 @@ import Testing
|
|||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||||
|
stdoutPath: nil,
|
||||||
|
stderrPath: nil,
|
||||||
port: nil,
|
port: nil,
|
||||||
bind: nil,
|
bind: nil,
|
||||||
token: "launchd-token",
|
token: "launchd-token",
|
||||||
@@ -48,6 +59,8 @@ import Testing
|
|||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
||||||
|
stdoutPath: nil,
|
||||||
|
stderrPath: nil,
|
||||||
port: nil,
|
port: nil,
|
||||||
bind: nil,
|
bind: nil,
|
||||||
token: nil,
|
token: nil,
|
||||||
@@ -60,4 +73,70 @@ import Testing
|
|||||||
launchdSnapshot: snapshot)
|
launchdSnapshot: snapshot)
|
||||||
#expect(password == "launchd-pass")
|
#expect(password == "launchd-pass")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set("remote", forKey: connectionModeKey)
|
||||||
|
|
||||||
|
let root: [String: Any] = [
|
||||||
|
"gateway": [
|
||||||
|
"mode": " local ",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
|
#expect(resolved.mode == .local)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func connectionModeResolverTrimsConfigMode() {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set("local", forKey: connectionModeKey)
|
||||||
|
|
||||||
|
let root: [String: Any] = [
|
||||||
|
"gateway": [
|
||||||
|
"mode": " remote ",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
|
#expect(resolved.mode == .remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set("remote", forKey: connectionModeKey)
|
||||||
|
|
||||||
|
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
||||||
|
#expect(resolved.mode == .remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set("local", forKey: connectionModeKey)
|
||||||
|
|
||||||
|
let root: [String: Any] = [
|
||||||
|
"gateway": [
|
||||||
|
"mode": "staging",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
|
#expect(resolved.mode == .local)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set("local", forKey: connectionModeKey)
|
||||||
|
|
||||||
|
let root: [String: Any] = [
|
||||||
|
"gateway": [
|
||||||
|
"remote": [
|
||||||
|
"url": " ws://umbrel:18789 ",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
|
#expect(resolved.mode == .remote)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
|
|||||||
host: "gateway",
|
host: "gateway",
|
||||||
ip: "10.0.0.4",
|
ip: "10.0.0.4",
|
||||||
version: "3.0.0",
|
version: "3.0.0",
|
||||||
platform: "iOS 17",
|
platform: "iOS 18",
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: nil,
|
lastInputSeconds: nil,
|
||||||
|
|||||||
@@ -112,20 +112,6 @@ struct LowCoverageHelperTests {
|
|||||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func gatewayLaunchAgentHelpers() async throws {
|
|
||||||
await TestIsolation.withEnvValues(
|
|
||||||
[
|
|
||||||
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
|
||||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
|
||||||
]) {
|
|
||||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
|
||||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
|
||||||
#expect(
|
|
||||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
|
||||||
"a&b<c>"'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func portGuardianParsesListenersAndBuildsReports() {
|
@Test func portGuardianParsesListenersAndBuildsReports() {
|
||||||
let output = """
|
let output = """
|
||||||
p123
|
p123
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import Testing
|
|||||||
stateversion: StateVersion(presence: 1, health: 1),
|
stateversion: StateVersion(presence: 1, health: 1),
|
||||||
uptimems: 123,
|
uptimems: 123,
|
||||||
configpath: nil,
|
configpath: nil,
|
||||||
statedir: nil)
|
statedir: nil,
|
||||||
|
sessiondefaults: nil)
|
||||||
|
|
||||||
let hello = HelloOk(
|
let hello = HelloOk(
|
||||||
type: "hello",
|
type: "hello",
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
|
|||||||
#expect(response.ok == false)
|
#expect(response.ok == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
|
||||||
|
let runtime = MacNodeRuntime()
|
||||||
|
let params = ClawdbotSystemWhichParams(bins: [])
|
||||||
|
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||||
|
let response = await runtime.handleInvoke(
|
||||||
|
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
|
||||||
|
#expect(response.ok == false)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||||
let runtime = MacNodeRuntime()
|
let runtime = MacNodeRuntime()
|
||||||
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
||||||
@@ -65,6 +74,10 @@ struct MacNodeRuntimeTests {
|
|||||||
{
|
{
|
||||||
CLLocation(latitude: 0, longitude: 0)
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||||
|
.allowOnce
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let services = await MainActor.run { FakeMainActorServices() }
|
let services = await MainActor.run { FakeMainActorServices() }
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
||||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||||
#expect(!order.contains(7))
|
#expect(!order.contains(7))
|
||||||
#expect(order.contains(3))
|
#expect(order.contains(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
||||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||||
#expect(!order.contains(8))
|
#expect(!order.contains(8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
provider: "sms",
|
channel: "sms",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Testing
|
|||||||
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
||||||
defer { _ = TalkAudioPlayer.shared.stop() }
|
defer { _ = TalkAudioPlayer.shared.stop() }
|
||||||
|
|
||||||
_ = try await withTimeout(seconds: 2.0) {
|
_ = try await withTimeout(seconds: 4.0) {
|
||||||
await TalkAudioPlayer.shared.play(data: wav)
|
await TalkAudioPlayer.shared.play(data: wav)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ import Testing
|
|||||||
await Task.yield()
|
await Task.yield()
|
||||||
_ = await TalkAudioPlayer.shared.play(data: wav)
|
_ = await TalkAudioPlayer.shared.play(data: wav)
|
||||||
|
|
||||||
_ = try await withTimeout(seconds: 2.0) {
|
_ = try await withTimeout(seconds: 4.0) {
|
||||||
await first.value
|
await first.value
|
||||||
}
|
}
|
||||||
#expect(true)
|
#expect(true)
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ import Testing
|
|||||||
#expect(opts.thinking == "low")
|
#expect(opts.thinking == "low")
|
||||||
#expect(opts.deliver == true)
|
#expect(opts.deliver == true)
|
||||||
#expect(opts.to == nil)
|
#expect(opts.to == nil)
|
||||||
#expect(opts.provider == .last)
|
#expect(opts.channel == .last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ClawdbotKit",
|
name: "ClawdbotKit",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v18),
|
||||||
.macOS(.v15),
|
.macOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
@@ -14,6 +14,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||||
|
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -29,7 +30,13 @@ let package = Package(
|
|||||||
]),
|
]),
|
||||||
.target(
|
.target(
|
||||||
name: "ClawdbotChatUI",
|
name: "ClawdbotChatUI",
|
||||||
dependencies: ["ClawdbotKit"],
|
dependencies: [
|
||||||
|
"ClawdbotKit",
|
||||||
|
.product(
|
||||||
|
name: "Textual",
|
||||||
|
package: "textual",
|
||||||
|
condition: .when(platforms: [.macOS, .iOS])),
|
||||||
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ChatMarkdownPreprocessor {
|
||||||
|
struct InlineImage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let label: String
|
||||||
|
let image: ClawdbotPlatformImage?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Result {
|
||||||
|
let cleaned: String
|
||||||
|
let images: [InlineImage]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func preprocess(markdown raw: String) -> Result {
|
||||||
|
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||||
|
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||||
|
return Result(cleaned: raw, images: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let ns = raw as NSString
|
||||||
|
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||||
|
if matches.isEmpty { return Result(cleaned: raw, images: []) }
|
||||||
|
|
||||||
|
var images: [InlineImage] = []
|
||||||
|
var cleaned = raw
|
||||||
|
|
||||||
|
for match in matches.reversed() {
|
||||||
|
guard match.numberOfRanges >= 3 else { continue }
|
||||||
|
let label = ns.substring(with: match.range(at: 1))
|
||||||
|
let dataURL = ns.substring(with: match.range(at: 2))
|
||||||
|
|
||||||
|
let image: ClawdbotPlatformImage? = {
|
||||||
|
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
||||||
|
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
||||||
|
guard let data = Data(base64Encoded: b64) else { return nil }
|
||||||
|
return ClawdbotPlatformImage(data: data)
|
||||||
|
}()
|
||||||
|
images.append(InlineImage(label: label, image: image))
|
||||||
|
|
||||||
|
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
||||||
|
let end = cleaned.index(start, offsetBy: match.range.length)
|
||||||
|
cleaned.replaceSubrange(start..<end, with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = cleaned
|
||||||
|
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return Result(cleaned: normalized, images: images.reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Textual
|
||||||
|
|
||||||
|
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
||||||
|
case standard
|
||||||
|
case compact
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ChatMarkdownRenderer: View {
|
||||||
|
enum Context {
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: String
|
||||||
|
let context: Context
|
||||||
|
let variant: ChatMarkdownVariant
|
||||||
|
let font: Font
|
||||||
|
let textColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
StructuredText(markdown: processed.cleaned)
|
||||||
|
.modifier(ChatMarkdownStyle(
|
||||||
|
variant: self.variant,
|
||||||
|
context: self.context,
|
||||||
|
font: self.font,
|
||||||
|
textColor: self.textColor))
|
||||||
|
|
||||||
|
if !processed.images.isEmpty {
|
||||||
|
InlineImageList(images: processed.images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChatMarkdownStyle: ViewModifier {
|
||||||
|
let variant: ChatMarkdownVariant
|
||||||
|
let context: ChatMarkdownRenderer.Context
|
||||||
|
let font: Font
|
||||||
|
let textColor: Color
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
Group {
|
||||||
|
if self.variant == .compact {
|
||||||
|
content.textual.structuredTextStyle(.default)
|
||||||
|
} else {
|
||||||
|
content.textual.structuredTextStyle(.gitHub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(self.font)
|
||||||
|
.foregroundStyle(self.textColor)
|
||||||
|
.textual.inlineStyle(self.inlineStyle)
|
||||||
|
.textual.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inlineStyle: InlineStyle {
|
||||||
|
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
||||||
|
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||||
|
return InlineStyle()
|
||||||
|
.code(.monospaced, .fontScale(codeScale))
|
||||||
|
.link(.foregroundColor(linkColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private struct InlineImageList: View {
|
||||||
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ForEach(images, id: \.id) { item in
|
||||||
|
if let img = item.image {
|
||||||
|
ClawdbotPlatformImageFactory.image(img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 260)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||||
|
} else {
|
||||||
|
Text(item.label.isEmpty ? "Image" : item.label)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum ChatMarkdownSplitter {
|
|
||||||
struct InlineImage: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let label: String
|
|
||||||
let image: ClawdbotPlatformImage?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Block: Identifiable {
|
|
||||||
enum Kind: Equatable {
|
|
||||||
case text
|
|
||||||
case code(language: String?)
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = UUID()
|
|
||||||
let kind: Kind
|
|
||||||
let text: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SplitResult {
|
|
||||||
let blocks: [Block]
|
|
||||||
let images: [InlineImage]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func split(markdown raw: String) -> SplitResult {
|
|
||||||
let extracted = self.extractInlineImages(from: raw)
|
|
||||||
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
|
|
||||||
return SplitResult(blocks: blocks, images: extracted.images)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func splitCodeBlocks(from raw: String) -> [Block] {
|
|
||||||
var blocks: [Block] = []
|
|
||||||
var buffer: [String] = []
|
|
||||||
var inCode = false
|
|
||||||
var codeLang: String?
|
|
||||||
var codeLines: [String] = []
|
|
||||||
|
|
||||||
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
|
||||||
if line.hasPrefix("```") {
|
|
||||||
if inCode {
|
|
||||||
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
|
||||||
codeLines.removeAll(keepingCapacity: true)
|
|
||||||
inCode = false
|
|
||||||
codeLang = nil
|
|
||||||
} else {
|
|
||||||
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !text.isEmpty {
|
|
||||||
blocks.append(Block(kind: .text, text: text))
|
|
||||||
}
|
|
||||||
buffer.removeAll(keepingCapacity: true)
|
|
||||||
inCode = true
|
|
||||||
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if codeLang?.isEmpty == true { codeLang = nil }
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if inCode {
|
|
||||||
codeLines.append(line)
|
|
||||||
} else {
|
|
||||||
buffer.append(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if inCode {
|
|
||||||
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
|
||||||
} else {
|
|
||||||
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !text.isEmpty {
|
|
||||||
blocks.append(Block(kind: .text, text: text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
|
|
||||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
|
||||||
return (raw, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
let ns = raw as NSString
|
|
||||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
|
||||||
if matches.isEmpty { return (raw, []) }
|
|
||||||
|
|
||||||
var images: [InlineImage] = []
|
|
||||||
var cleaned = raw
|
|
||||||
|
|
||||||
for match in matches.reversed() {
|
|
||||||
guard match.numberOfRanges >= 3 else { continue }
|
|
||||||
let label = ns.substring(with: match.range(at: 1))
|
|
||||||
let dataURL = ns.substring(with: match.range(at: 2))
|
|
||||||
|
|
||||||
let image: ClawdbotPlatformImage? = {
|
|
||||||
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
|
||||||
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
|
||||||
guard let data = Data(base64Encoded: b64) else { return nil }
|
|
||||||
return ClawdbotPlatformImage(data: data)
|
|
||||||
}()
|
|
||||||
images.append(InlineImage(label: label, image: image))
|
|
||||||
|
|
||||||
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
|
||||||
let end = cleaned.index(start, offsetBy: match.range.length)
|
|
||||||
cleaned.replaceSubrange(start..<end, with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized = cleaned
|
|
||||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return (normalized, images.reversed())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -137,10 +137,16 @@ private struct ChatBubbleShape: InsettableShape {
|
|||||||
struct ChatMessageBubble: View {
|
struct ChatMessageBubble: View {
|
||||||
let message: ClawdbotChatMessage
|
let message: ClawdbotChatMessage
|
||||||
let style: ClawdbotChatView.Style
|
let style: ClawdbotChatView.Style
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
let userAccent: Color?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
|
ChatMessageBody(
|
||||||
|
message: self.message,
|
||||||
|
isUser: self.isUser,
|
||||||
|
style: self.style,
|
||||||
|
markdownVariant: self.markdownVariant,
|
||||||
|
userAccent: self.userAccent)
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
@@ -154,6 +160,7 @@ private struct ChatMessageBody: View {
|
|||||||
let message: ClawdbotChatMessage
|
let message: ClawdbotChatMessage
|
||||||
let isUser: Bool
|
let isUser: Bool
|
||||||
let style: ClawdbotChatView.Style
|
let style: ClawdbotChatView.Style
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
let userAccent: Color?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -169,39 +176,14 @@ private struct ChatMessageBody: View {
|
|||||||
isUser: self.isUser)
|
isUser: self.isUser)
|
||||||
}
|
}
|
||||||
} else if self.isUser {
|
} else if self.isUser {
|
||||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
ChatMarkdownRenderer(
|
||||||
ForEach(split.blocks) { block in
|
text: text,
|
||||||
switch block.kind {
|
context: .user,
|
||||||
case .text:
|
variant: self.markdownVariant,
|
||||||
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
font: .system(size: 14),
|
||||||
case let .code(language):
|
textColor: textColor)
|
||||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !split.images.isEmpty {
|
|
||||||
ForEach(
|
|
||||||
split.images,
|
|
||||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
|
||||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
|
||||||
if let img = item.image {
|
|
||||||
ClawdbotPlatformImageFactory.image(img)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(maxHeight: 260)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
||||||
} else {
|
|
||||||
Text(item.label.isEmpty ? "Image" : item.label)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ChatAssistantTextBody(text: text)
|
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.inlineAttachments.isEmpty {
|
if !self.inlineAttachments.isEmpty {
|
||||||
@@ -505,10 +487,11 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct ChatStreamingAssistantBubble: View {
|
struct ChatStreamingAssistantBubble: View {
|
||||||
let text: String
|
let text: String
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ChatAssistantTextBody(text: self.text)
|
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
@@ -612,114 +595,22 @@ private struct TypingDots: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct MarkdownTextView: View {
|
|
||||||
let text: String
|
|
||||||
let textColor: Color
|
|
||||||
let font: Font
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let normalized = self.text.replacingOccurrences(
|
|
||||||
of: "(?<!\\n)\\n(?!\\n)",
|
|
||||||
with: " ",
|
|
||||||
options: .regularExpression)
|
|
||||||
let options = AttributedString.MarkdownParsingOptions(
|
|
||||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
||||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
|
||||||
Text(attributed)
|
|
||||||
.font(self.font)
|
|
||||||
.foregroundStyle(self.textColor)
|
|
||||||
} else {
|
|
||||||
Text(normalized)
|
|
||||||
.font(self.font)
|
|
||||||
.foregroundStyle(self.textColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct ChatAssistantTextBody: View {
|
private struct ChatAssistantTextBody: View {
|
||||||
let text: String
|
let text: String
|
||||||
|
let markdownVariant: ChatMarkdownVariant
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let segments = AssistantTextParser.segments(from: self.text)
|
let segments = AssistantTextParser.segments(from: self.text)
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(segments) { segment in
|
ForEach(segments) { segment in
|
||||||
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
||||||
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
|
ChatMarkdownRenderer(
|
||||||
|
text: segment.text,
|
||||||
|
context: .assistant,
|
||||||
|
variant: self.markdownVariant,
|
||||||
|
font: font,
|
||||||
|
textColor: ClawdbotChatTheme.assistantText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct ChatMarkdownBody: View {
|
|
||||||
let text: String
|
|
||||||
let textColor: Color
|
|
||||||
let font: Font
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
ForEach(split.blocks) { block in
|
|
||||||
switch block.kind {
|
|
||||||
case .text:
|
|
||||||
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
|
||||||
case let .code(language):
|
|
||||||
CodeBlockView(code: block.text, language: language, isUser: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !split.images.isEmpty {
|
|
||||||
ForEach(
|
|
||||||
split.images,
|
|
||||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
|
||||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
|
||||||
if let img = item.image {
|
|
||||||
ClawdbotPlatformImageFactory.image(img)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(maxHeight: 260)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
||||||
} else {
|
|
||||||
Text(item.label.isEmpty ? "Image" : item.label)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct CodeBlockView: View {
|
|
||||||
let code: String
|
|
||||||
let language: String?
|
|
||||||
let isUser: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if let language, !language.isEmpty {
|
|
||||||
Text(language)
|
|
||||||
.font(.caption2.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text(self.code)
|
|
||||||
.font(.system(size: 13, weight: .regular, design: .monospaced))
|
|
||||||
.foregroundStyle(self.isUser ? .white : .primary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
@@ -42,11 +43,13 @@ public struct ClawdbotChatView: View {
|
|||||||
viewModel: ClawdbotChatViewModel,
|
viewModel: ClawdbotChatViewModel,
|
||||||
showsSessionSwitcher: Bool = false,
|
showsSessionSwitcher: Bool = false,
|
||||||
style: Style = .standard,
|
style: Style = .standard,
|
||||||
|
markdownVariant: ChatMarkdownVariant = .standard,
|
||||||
userAccent: Color? = nil)
|
userAccent: Color? = nil)
|
||||||
{
|
{
|
||||||
self._viewModel = State(initialValue: viewModel)
|
self._viewModel = State(initialValue: viewModel)
|
||||||
self.showsSessionSwitcher = showsSessionSwitcher
|
self.showsSessionSwitcher = showsSessionSwitcher
|
||||||
self.style = style
|
self.style = style
|
||||||
|
self.markdownVariant = markdownVariant
|
||||||
self.userAccent = userAccent
|
self.userAccent = userAccent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +154,11 @@ public struct ClawdbotChatView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var messageListRows: some View {
|
private var messageListRows: some View {
|
||||||
ForEach(self.visibleMessages) { msg in
|
ForEach(self.visibleMessages) { msg in
|
||||||
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
|
ChatMessageBubble(
|
||||||
|
message: msg,
|
||||||
|
style: self.style,
|
||||||
|
markdownVariant: self.markdownVariant,
|
||||||
|
userAccent: self.userAccent)
|
||||||
.frame(
|
.frame(
|
||||||
maxWidth: .infinity,
|
maxWidth: .infinity,
|
||||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||||
@@ -172,7 +179,7 @@ public struct ClawdbotChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
||||||
ChatStreamingAssistantBubble(text: text)
|
ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,14 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
|
||||||
|
public var bins: [String]
|
||||||
|
|
||||||
|
public init(bins: [String]) {
|
||||||
|
self.bins = bins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
|
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
|
||||||
public var title: String
|
public var title: String
|
||||||
public var body: String
|
public var body: String
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import ClawdbotChatUI
|
||||||
|
|
||||||
|
@Suite("ChatMarkdownPreprocessor")
|
||||||
|
struct ChatMarkdownPreprocessorTests {
|
||||||
|
@Test func extractsDataURLImages() {
|
||||||
|
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
|
||||||
|
let markdown = """
|
||||||
|
Hello
|
||||||
|
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||||
|
|
||||||
|
#expect(result.cleaned == "Hello")
|
||||||
|
#expect(result.images.count == 1)
|
||||||
|
#expect(result.images.first?.image != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -227,6 +227,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
|||||||
actions: {
|
actions: {
|
||||||
reactions: true,
|
reactions: true,
|
||||||
stickers: true,
|
stickers: true,
|
||||||
|
emojiUploads: true,
|
||||||
|
stickerUploads: true,
|
||||||
polls: true,
|
polls: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
messages: true,
|
messages: true,
|
||||||
@@ -237,6 +239,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
|||||||
roleInfo: true,
|
roleInfo: true,
|
||||||
roles: false,
|
roles: false,
|
||||||
channelInfo: true,
|
channelInfo: true,
|
||||||
|
channels: true,
|
||||||
voiceStatus: true,
|
voiceStatus: true,
|
||||||
events: true,
|
events: true,
|
||||||
moderation: false
|
moderation: false
|
||||||
@@ -304,8 +307,9 @@ ack reaction after the bot replies.
|
|||||||
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||||
- `reactions` (covers react + read reactions)
|
- `reactions` (covers react + read reactions)
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||||
|
- `channels` (create/edit/delete channels + categories + permissions)
|
||||||
- `roles` (role add/remove, default `false`)
|
- `roles` (role add/remove, default `false`)
|
||||||
- `moderation` (timeout/kick/ban, default `false`)
|
- `moderation` (timeout/kick/ban, default `false`)
|
||||||
|
|
||||||
@@ -321,6 +325,8 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| reactions | enabled | React + list reactions + emojiList |
|
| reactions | enabled | React + list reactions + emojiList |
|
||||||
| stickers | enabled | Send stickers |
|
| stickers | enabled | Send stickers |
|
||||||
|
| emojiUploads | enabled | Upload emojis |
|
||||||
|
| stickerUploads | enabled | Upload stickers |
|
||||||
| polls | enabled | Create polls |
|
| polls | enabled | Create polls |
|
||||||
| permissions | enabled | Channel permission snapshot |
|
| permissions | enabled | Channel permission snapshot |
|
||||||
| messages | enabled | Read/send/edit/delete |
|
| messages | enabled | Read/send/edit/delete |
|
||||||
@@ -330,6 +336,7 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
| memberInfo | enabled | Member info |
|
| memberInfo | enabled | Member info |
|
||||||
| roleInfo | enabled | Role list |
|
| roleInfo | enabled | Role list |
|
||||||
| channelInfo | enabled | Channel info + list |
|
| channelInfo | enabled | Channel info + list |
|
||||||
|
| channels | enabled | Channel/category management |
|
||||||
| voiceStatus | enabled | Voice state lookup |
|
| voiceStatus | enabled | Voice state lookup |
|
||||||
| events | enabled | List/create scheduled events |
|
| events | enabled | List/create scheduled events |
|
||||||
| roles | disabled | Role add/remove |
|
| roles | disabled | Role add/remove |
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ group messages, so use admin if you need full visibility.
|
|||||||
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
||||||
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
||||||
|
|
||||||
|
## Commands (native + custom)
|
||||||
|
Clawdbot registers native commands (like `/status`, `/reset`, `/model`) with Telegram’s bot menu on startup.
|
||||||
|
You can add custom commands to the menu via config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
customCommands: [
|
||||||
|
{ command: "backup", description: "Git backup" },
|
||||||
|
{ command: "generate", description: "Create an image" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||||
|
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||||
|
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
|
||||||
|
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||||
|
|||||||
@@ -82,15 +82,13 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
|
|||||||
"selfChatMode": true,
|
"selfChatMode": true,
|
||||||
"dmPolicy": "allowlist",
|
"dmPolicy": "allowlist",
|
||||||
"allowFrom": ["+15551234567"]
|
"allowFrom": ["+15551234567"]
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"responsePrefix": "[clawdbot]"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Tip: set `messages.responsePrefix` explicitly if you want a consistent bot prefix
|
Self-chat replies default to `[{identity.name}]` when set (otherwise `[clawdbot]`)
|
||||||
on outbound replies.
|
if `messages.responsePrefix` is unset. Set it explicitly to customize or disable
|
||||||
|
the prefix (use `""` to remove it).
|
||||||
|
|
||||||
### Number sourcing tips
|
### Number sourcing tips
|
||||||
- **Local eSIM** from your country's mobile carrier (most reliable)
|
- **Local eSIM** from your country's mobile carrier (most reliable)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot config` (get/set/unset config values)"
|
||||||
|
read_when:
|
||||||
|
- You want to read or edit config non-interactively
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot config`
|
||||||
|
|
||||||
|
Config helpers: get/set/unset values by path. Run without a subcommand to open
|
||||||
|
the configure wizard (same as `clawdbot configure`).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get browser.executablePath
|
||||||
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
|
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||||
|
clawdbot config unset tools.web.search.apiKey
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
Paths use dot or bracket notation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get agents.defaults.workspace
|
||||||
|
clawdbot config get agents.list[0].id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||||
|
Use `--json` to require JSON5 parsing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set agents.defaults.heartbeat.every "0m"
|
||||||
|
clawdbot config set gateway.port 19001 --json
|
||||||
|
clawdbot config set channels.whatsapp.groups '["*"]' --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway after edits.
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot configure` / `clawdbot config` (interactive configuration prompts)"
|
summary: "CLI reference for `clawdbot configure` (interactive configuration prompts)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to tweak credentials, devices, or agent defaults interactively
|
- You want to tweak credentials, devices, or agent defaults interactively
|
||||||
---
|
---
|
||||||
|
|
||||||
# `clawdbot configure` (alias: `config`)
|
# `clawdbot configure`
|
||||||
|
|
||||||
Interactive prompt to set up credentials, devices, and agent defaults.
|
Interactive prompt to set up credentials, devices, and agent defaults.
|
||||||
|
|
||||||
|
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
|
||||||
|
`clawdbot config get|set|unset` for non-interactive edits.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||||
|
- Config CLI: [Config](/cli/config)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ Related:
|
|||||||
|
|
||||||
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `daemon status` supports `--json` for scripting.
|
||||||
|
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||||
@@ -21,3 +21,14 @@ clawdbot doctor --repair
|
|||||||
clawdbot doctor --deep
|
clawdbot doctor --deep
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## macOS: `launchctl` env overrides
|
||||||
|
|
||||||
|
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
|
||||||
|
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
|
||||||
|
|
||||||
|
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
|
||||||
|
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
|
||||||
|
```
|
||||||
+19
-4
@@ -13,7 +13,8 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
|
|
||||||
- [`setup`](/cli/setup)
|
- [`setup`](/cli/setup)
|
||||||
- [`onboard`](/cli/onboard)
|
- [`onboard`](/cli/onboard)
|
||||||
- [`configure`](/cli/configure) (alias: `config`)
|
- [`configure`](/cli/configure)
|
||||||
|
- [`config`](/cli/config)
|
||||||
- [`doctor`](/cli/doctor)
|
- [`doctor`](/cli/doctor)
|
||||||
- [`dashboard`](/cli/dashboard)
|
- [`dashboard`](/cli/dashboard)
|
||||||
- [`reset`](/cli/reset)
|
- [`reset`](/cli/reset)
|
||||||
@@ -83,7 +84,11 @@ Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”).
|
|||||||
clawdbot [--dev] [--profile <name>] <command>
|
clawdbot [--dev] [--profile <name>] <command>
|
||||||
setup
|
setup
|
||||||
onboard
|
onboard
|
||||||
configure (alias: config)
|
configure
|
||||||
|
config
|
||||||
|
get
|
||||||
|
set
|
||||||
|
unset
|
||||||
doctor
|
doctor
|
||||||
security
|
security
|
||||||
audit
|
audit
|
||||||
@@ -310,9 +315,18 @@ Options:
|
|||||||
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
|
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
### `configure` / `config`
|
### `configure`
|
||||||
Interactive configuration wizard (models, channels, skills, gateway).
|
Interactive configuration wizard (models, channels, skills, gateway).
|
||||||
|
|
||||||
|
### `config`
|
||||||
|
Non-interactive config helpers (get/set/unset). Running `clawdbot config` with no
|
||||||
|
subcommand launches the wizard.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- `config get <path>`: print a config value (dot/bracket path).
|
||||||
|
- `config set <path> <value>`: set a value (JSON5 or raw string).
|
||||||
|
- `config unset <path>`: remove a value.
|
||||||
|
|
||||||
### `doctor`
|
### `doctor`
|
||||||
Health checks + quick fixes (config + gateway + legacy services).
|
Health checks + quick fixes (config + gateway + legacy services).
|
||||||
|
|
||||||
@@ -594,8 +608,9 @@ Notes:
|
|||||||
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||||
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
||||||
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
||||||
|
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||||
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
||||||
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
|
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||||
|
|
||||||
### `logs`
|
### `logs`
|
||||||
Tail Gateway file logs via RPC.
|
Tail Gateway file logs via RPC.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ clawdbot plugins info <id>
|
|||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
|
|||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
|
clawdbot plugins update <id> --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
|
||||||
@@ -871,6 +871,7 @@ Notes:
|
|||||||
- `commands.text: false` disables parsing chat messages for commands.
|
- `commands.text: false` disables parsing chat messages for commands.
|
||||||
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only.
|
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only.
|
||||||
- Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
|
- Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
|
||||||
|
- `channels.telegram.customCommands` adds extra Telegram bot menu entries. Names are normalized; conflicts with native commands are ignored.
|
||||||
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
||||||
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
|
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
|
||||||
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
||||||
@@ -929,6 +930,10 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
customCommands: [
|
||||||
|
{ command: "backup", description: "Git backup" },
|
||||||
|
{ command: "generate", description: "Create an image" }
|
||||||
|
],
|
||||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||||
@@ -1266,7 +1271,9 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
|
|||||||
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
|
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
|
||||||
streaming, final replies) across channels unless already present.
|
streaming, final replies) across channels unless already present.
|
||||||
|
|
||||||
If `messages.responsePrefix` is unset, no prefix is applied by default.
|
If `messages.responsePrefix` is unset, no prefix is applied by default. WhatsApp self-chat
|
||||||
|
replies are the exception: they default to `[{identity.name}]` when set, otherwise
|
||||||
|
`[clawdbot]`, so same-phone conversations stay legible.
|
||||||
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
|
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
|
||||||
|
|
||||||
#### Template variables
|
#### Template variables
|
||||||
|
|||||||
@@ -161,6 +161,15 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
|
|||||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||||
|
|
||||||
|
### Model strength (security note)
|
||||||
|
|
||||||
|
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
||||||
|
|
||||||
|
Recommendations:
|
||||||
|
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||||
|
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||||
|
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||||
|
|
||||||
## Reasoning & verbose output in groups
|
## Reasoning & verbose output in groups
|
||||||
|
|
||||||
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
|
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
|
||||||
|
|||||||
@@ -169,6 +169,20 @@ The agent was interrupted mid-response.
|
|||||||
|
|
||||||
**Fix:** Just send another message. The session continues.
|
**Fix:** Just send another message. The session continues.
|
||||||
|
|
||||||
|
### "Agent failed before reply: Unknown model: anthropic/claude-haiku-3-5"
|
||||||
|
|
||||||
|
Clawdbot intentionally rejects **older/insecure models** (especially those more
|
||||||
|
vulnerable to prompt injection). If you see this error, the model name is no
|
||||||
|
longer supported.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Pick a **latest** model for the provider and update your config or model alias.
|
||||||
|
- If you’re unsure which models are available, run `clawdbot models list` or
|
||||||
|
`clawdbot models scan` and choose a supported one.
|
||||||
|
- Check gateway logs for the detailed failure reason.
|
||||||
|
|
||||||
|
See also: [Models CLI](/cli/models) and [Model providers](/concepts/model-providers).
|
||||||
|
|
||||||
### Messages Not Triggering
|
### Messages Not Triggering
|
||||||
|
|
||||||
**Check 1:** Is the sender allowlisted?
|
**Check 1:** Is the sender allowlisted?
|
||||||
@@ -306,6 +320,139 @@ Clawdbot keeps conversation history in memory.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Common troubleshooting
|
||||||
|
|
||||||
|
### “All models failed” — what should I check first?
|
||||||
|
|
||||||
|
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
||||||
|
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
|
||||||
|
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
|
||||||
|
- **Model status**: use `/model status` (chat) or `clawdbot models status` (CLI).
|
||||||
|
|
||||||
|
### I’m running on my personal WhatsApp number — why is self-chat weird?
|
||||||
|
|
||||||
|
Enable self-chat mode and allowlist your own number:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
selfChatMode: true,
|
||||||
|
dmPolicy: "allowlist",
|
||||||
|
allowFrom: ["+15555550123"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [WhatsApp setup](/channels/whatsapp).
|
||||||
|
|
||||||
|
### WhatsApp logged me out. How do I re‑auth?
|
||||||
|
|
||||||
|
Run the login command again and scan the QR code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build errors on `main` — what’s the standard fix path?
|
||||||
|
|
||||||
|
1) `git pull origin main && pnpm install`
|
||||||
|
2) `pnpm clawdbot doctor`
|
||||||
|
3) Check GitHub issues or Discord
|
||||||
|
4) Temporary workaround: check out an older commit
|
||||||
|
|
||||||
|
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
||||||
|
|
||||||
|
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
||||||
|
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
||||||
|
|
||||||
|
Typical recovery:
|
||||||
|
```bash
|
||||||
|
git status # ensure you’re in the repo root
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm clawdbot doctor
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Why: pnpm is the configured package manager for this repo, and the dependency
|
||||||
|
patching workflow relies on it.
|
||||||
|
|
||||||
|
### How do I switch between git installs and npm installs?
|
||||||
|
|
||||||
|
Use the **website installer** and select the install method with a flag. It
|
||||||
|
upgrades in place and rewrites the gateway service to point at the new install.
|
||||||
|
|
||||||
|
Switch **to git install**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch **to npm global**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://clawd.bot/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The git flow only rebases if the repo is clean. Commit or stash changes first.
|
||||||
|
- After switching, run:
|
||||||
|
```bash
|
||||||
|
clawdbot doctor
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
||||||
|
|
||||||
|
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
|
||||||
|
- `agents.defaults.blockStreamingDefault` is still `"off"`.
|
||||||
|
- `channels.telegram.blockStreaming` is set to `false`.
|
||||||
|
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
|
||||||
|
(private chat + topics). Draft streaming disables block streaming in that case.
|
||||||
|
- Your `minChars` / coalesce settings are too high, so chunks get merged.
|
||||||
|
- The model emits one large text block (no mid‑reply flush points).
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) Put block streaming settings under `agents.defaults`, not the root.
|
||||||
|
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
|
||||||
|
3) Use smaller chunk/coalesce thresholds while debugging.
|
||||||
|
|
||||||
|
See [Streaming](/concepts/streaming).
|
||||||
|
|
||||||
|
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
||||||
|
|
||||||
|
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
||||||
|
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
||||||
|
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
||||||
|
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
||||||
|
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
||||||
|
Top‑level `channels.discord.requireMention` is not a supported key.
|
||||||
|
4) Ensure the bot has **Message Content Intent** and channel permissions.
|
||||||
|
5) Run `clawdbot channels status --probe` for audit hints.
|
||||||
|
|
||||||
|
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
|
||||||
|
|
||||||
|
### Cloud Code Assist API error: invalid tool schema (400). What now?
|
||||||
|
|
||||||
|
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
|
||||||
|
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
|
||||||
|
schemas in current `main`, but the fix is not in the last release yet (as of
|
||||||
|
January 13, 2026).
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) **Update Clawdbot**:
|
||||||
|
- If you can run from source, pull `main` and restart the gateway.
|
||||||
|
- Otherwise, wait for the next release that includes the schema scrubber.
|
||||||
|
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
|
||||||
|
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
|
||||||
|
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
|
||||||
|
`properties` and simple enums.
|
||||||
|
|
||||||
|
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
|
||||||
|
|
||||||
## macOS Specific Issues
|
## macOS Specific Issues
|
||||||
|
|
||||||
### App Crashes when Granting Permissions (Speech/Mic)
|
### App Crashes when Granting Permissions (Speech/Mic)
|
||||||
|
|||||||
@@ -160,11 +160,15 @@ clawdbot plugins install <path> # add a local file/dir to plugins.l
|
|||||||
clawdbot plugins install ./extensions/voice-call # relative path ok
|
clawdbot plugins install ./extensions/voice-call # relative path ok
|
||||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||||
clawdbot plugins install @clawdbot/voice-call # install from npm
|
clawdbot plugins install @clawdbot/voice-call # install from npm
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`plugins update` only works for npm installs tracked under `plugins.installs`.
|
||||||
|
|
||||||
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
||||||
|
|
||||||
## Plugin API (overview)
|
## Plugin API (overview)
|
||||||
|
|||||||
+80
-146
@@ -70,11 +70,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
||||||
- [Sessions & multiple chats](#sessions-multiple-chats)
|
- [Sessions & multiple chats](#sessions-multiple-chats)
|
||||||
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
||||||
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
|
||||||
|
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
||||||
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
||||||
|
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
|
||||||
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
||||||
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
||||||
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
||||||
|
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
|
||||||
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
||||||
- [What is the “default model”?](#what-is-the-default-model)
|
- [What is the “default model”?](#what-is-the-default-model)
|
||||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||||
@@ -107,25 +110,17 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Logging and debugging](#logging-and-debugging)
|
- [Logging and debugging](#logging-and-debugging)
|
||||||
- [Where are logs?](#where-are-logs)
|
- [Where are logs?](#where-are-logs)
|
||||||
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
||||||
|
- [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway)
|
||||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||||
- [Media & attachments](#media-attachments)
|
- [Media & attachments](#media-attachments)
|
||||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||||
- [Security and access control](#security-and-access-control)
|
- [Security and access control](#security-and-access-control)
|
||||||
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
||||||
|
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
||||||
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
||||||
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
||||||
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
||||||
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
||||||
- [Common troubleshooting](#common-troubleshooting)
|
|
||||||
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
|
|
||||||
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
|
|
||||||
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
|
|
||||||
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
|
|
||||||
- [npm install fails (allow-build-scripts / missing tar or yargs). What now?](#npm-install-fails-allow-build-scripts--missing-tar-or-yargs-what-now)
|
|
||||||
- [How do I switch between git installs and npm installs?](#how-do-i-switch-between-git-installs-and-npm-installs)
|
|
||||||
- [Telegram block streaming isn’t splitting text between tool calls. Why?](#telegram-block-streaming-isnt-splitting-text-between-tool-calls-why)
|
|
||||||
- [Discord doesn’t reply in my server even with `requireMention: false`. Why?](#discord-doesnt-reply-in-my-server-even-with-requiremention-false-why)
|
|
||||||
- [Cloud Code Assist API error: invalid tool schema (400). What now?](#cloud-code-assist-api-error-invalid-tool-schema-400-what-now)
|
|
||||||
|
|
||||||
## First 60 seconds if something's broken
|
## First 60 seconds if something's broken
|
||||||
|
|
||||||
@@ -858,7 +853,21 @@ See [/concepts/model-providers](/concepts/model-providers) and [/environment](/e
|
|||||||
|
|
||||||
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
|
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
|
||||||
|
|
||||||
### How do I completely reset Clawdbot (but keep it installed)?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
|
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||||
|
message starts a fresh session id for that chat key. This does not delete
|
||||||
|
transcripts — it just starts a new session.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
session: {
|
||||||
|
idleMinutes: 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How do I completely reset Clawdbot but keep it installed?
|
||||||
|
|
||||||
Use the reset command:
|
Use the reset command:
|
||||||
|
|
||||||
@@ -905,6 +914,24 @@ If it keeps happening:
|
|||||||
|
|
||||||
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
||||||
|
|
||||||
|
### Why am I getting heartbeat messages every 30 minutes?
|
||||||
|
|
||||||
|
Heartbeats run every **30m** by default. Tune or disable them:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "2h" // or "0m" to disable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||||
|
|
||||||
### Do I need to add a “bot account” to a WhatsApp group?
|
### Do I need to add a “bot account” to a WhatsApp group?
|
||||||
|
|
||||||
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
|
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
|
||||||
@@ -935,6 +962,19 @@ See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
|||||||
|
|
||||||
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
||||||
|
|
||||||
|
### How many workspaces and agents can I create?
|
||||||
|
|
||||||
|
No hard limits. Dozens (even hundreds) are fine, but watch for:
|
||||||
|
|
||||||
|
- **Disk growth:** sessions + transcripts live under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||||
|
- **Token cost:** more agents means more concurrent model usage.
|
||||||
|
- **Ops overhead:** per-agent auth profiles, workspaces, and channel routing.
|
||||||
|
|
||||||
|
Tips:
|
||||||
|
- Keep one **active** workspace per agent (`agents.defaults.workspace`).
|
||||||
|
- Prune old sessions (delete JSONL or store entries) if disk grows.
|
||||||
|
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
|
||||||
|
|
||||||
## Models: defaults, selection, aliases, switching
|
## Models: defaults, selection, aliases, switching
|
||||||
|
|
||||||
### What is the “default model”?
|
### What is the “default model”?
|
||||||
@@ -1315,9 +1355,11 @@ Facts (from code):
|
|||||||
|
|
||||||
Fix:
|
Fix:
|
||||||
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
|
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
|
||||||
|
- If you don’t have a token yet: `clawdbot doctor --generate-gateway-token`.
|
||||||
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
|
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
|
||||||
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
|
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
|
||||||
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
|
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
|
||||||
|
- Still stuck? Run `clawdbot status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
|
||||||
|
|
||||||
### I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens
|
### I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens
|
||||||
|
|
||||||
@@ -1407,6 +1449,14 @@ clawdbot daemon restart
|
|||||||
|
|
||||||
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
||||||
|
|
||||||
|
### ELI5: `clawdbot daemon restart` vs `clawdbot gateway`
|
||||||
|
|
||||||
|
- `clawdbot daemon restart`: restarts the **background service** (launchd/systemd).
|
||||||
|
- `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session.
|
||||||
|
|
||||||
|
If you installed the daemon, use the daemon commands. Use `clawdbot gateway` when
|
||||||
|
you want a one-off, foreground run.
|
||||||
|
|
||||||
### What’s the fastest way to get more details when something fails?
|
### What’s the fastest way to get more details when something fails?
|
||||||
|
|
||||||
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
|
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
|
||||||
@@ -1423,7 +1473,11 @@ CLI sending:
|
|||||||
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
|
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
|
Also check:
|
||||||
|
- The target channel supports outbound media and isn’t blocked by allowlists.
|
||||||
|
- The file is within the provider’s size limits (images are resized to max 2048px).
|
||||||
|
|
||||||
|
See [Images](/nodes/images).
|
||||||
|
|
||||||
## Security and access control
|
## Security and access control
|
||||||
|
|
||||||
@@ -1439,6 +1493,19 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
|
|||||||
|
|
||||||
Run `clawdbot doctor` to surface risky DM policies.
|
Run `clawdbot doctor` to surface risky DM policies.
|
||||||
|
|
||||||
|
### I ran `/start` in Telegram but didn’t get a pairing code
|
||||||
|
|
||||||
|
Pairing codes are sent **only** when an unknown sender messages the bot and
|
||||||
|
`dmPolicy: "pairing"` is enabled. `/start` by itself doesn’t generate a code.
|
||||||
|
|
||||||
|
Check pending requests:
|
||||||
|
```bash
|
||||||
|
clawdbot pairing list telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want immediate access, allowlist your sender id or set `dmPolicy: "open"`
|
||||||
|
for that account.
|
||||||
|
|
||||||
### WhatsApp: will it message my contacts? How does pairing work?
|
### WhatsApp: will it message my contacts? How does pairing work?
|
||||||
|
|
||||||
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
|
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
|
||||||
@@ -1496,139 +1563,6 @@ Queue mode controls how new messages interact with an in‑flight run. Use `/que
|
|||||||
|
|
||||||
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
||||||
|
|
||||||
## Common troubleshooting
|
|
||||||
|
|
||||||
### “All models failed” — what should I check first?
|
|
||||||
|
|
||||||
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
|
||||||
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
|
|
||||||
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
|
|
||||||
- **`/model status`** to see current configured models + shorthands.
|
|
||||||
|
|
||||||
### I’m running on my personal WhatsApp number — why is self-chat weird?
|
|
||||||
|
|
||||||
Enable self-chat mode and allowlist your own number:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
whatsapp: {
|
|
||||||
selfChatMode: true,
|
|
||||||
dmPolicy: "allowlist",
|
|
||||||
allowFrom: ["+15555550123"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [WhatsApp setup](/channels/whatsapp).
|
|
||||||
|
|
||||||
### WhatsApp logged me out. How do I re‑auth?
|
|
||||||
|
|
||||||
Run the login command again and scan the QR code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot channels login
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build errors on `main` — what’s the standard fix path?
|
|
||||||
|
|
||||||
1) `git pull origin main && pnpm install`
|
|
||||||
2) `pnpm clawdbot doctor`
|
|
||||||
3) Check GitHub issues or Discord
|
|
||||||
4) Temporary workaround: check out an older commit
|
|
||||||
|
|
||||||
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
|
||||||
|
|
||||||
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
|
||||||
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
|
||||||
|
|
||||||
Typical recovery:
|
|
||||||
```bash
|
|
||||||
git status # ensure you’re in the repo root
|
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
pnpm clawdbot doctor
|
|
||||||
clawdbot daemon restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Why: pnpm is the configured package manager for this repo, and the dependency
|
|
||||||
patching workflow relies on it.
|
|
||||||
|
|
||||||
### How do I switch between git installs and npm installs?
|
|
||||||
|
|
||||||
Use the **website installer** and select the install method with a flag. It
|
|
||||||
upgrades in place and rewrites the gateway service to point at the new install.
|
|
||||||
|
|
||||||
Switch **to git install**:
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Switch **to npm global**:
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://clawd.bot/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- The git flow only rebases if the repo is clean. Commit or stash changes first.
|
|
||||||
- After switching, run:
|
|
||||||
```bash
|
|
||||||
clawdbot doctor
|
|
||||||
clawdbot daemon restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
|
||||||
|
|
||||||
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
|
|
||||||
- `agents.defaults.blockStreamingDefault` is still `"off"`.
|
|
||||||
- `channels.telegram.blockStreaming` is set to `false`.
|
|
||||||
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
|
|
||||||
(private chat + topics). Draft streaming disables block streaming in that case.
|
|
||||||
- Your `minChars` / coalesce settings are too high, so chunks get merged.
|
|
||||||
- The model emits one large text block (no mid‑reply flush points).
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) Put block streaming settings under `agents.defaults`, not the root.
|
|
||||||
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
|
|
||||||
3) Use smaller chunk/coalesce thresholds while debugging.
|
|
||||||
|
|
||||||
See [Streaming](/concepts/streaming).
|
|
||||||
|
|
||||||
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
|
||||||
|
|
||||||
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
|
||||||
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
|
||||||
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
|
||||||
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
|
||||||
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
|
||||||
Top‑level `channels.discord.requireMention` is not a supported key.
|
|
||||||
4) Ensure the bot has **Message Content Intent** and channel permissions.
|
|
||||||
5) Run `clawdbot channels status --probe` for audit hints.
|
|
||||||
|
|
||||||
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
|
|
||||||
|
|
||||||
### Cloud Code Assist API error: invalid tool schema (400). What now?
|
|
||||||
|
|
||||||
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
|
|
||||||
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
|
|
||||||
schemas in current `main`, but the fix is not in the last release yet (as of
|
|
||||||
January 13, 2026).
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) **Update Clawdbot**:
|
|
||||||
- If you can run from source, pull `main` and restart the gateway.
|
|
||||||
- Otherwise, wait for the next release that includes the schema scrubber.
|
|
||||||
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
|
|
||||||
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
|
|
||||||
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
|
|
||||||
`properties` and simple enums.
|
|
||||||
|
|
||||||
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
|
|
||||||
|
|
||||||
## Answer the exact question from the screenshot/chat log
|
## Answer the exact question from the screenshot/chat log
|
||||||
|
|
||||||
**Q: “What’s the default model for Anthropic with an API key?”**
|
**Q: “What’s the default model for Anthropic with an API key?”**
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ Full setup walkthrough (28m) by VelvetShark.
|
|||||||
|
|
||||||
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
|
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Linear CLI" icon="terminal" href="https://github.com/Finesssee/linear-cli">
|
||||||
|
**@NessZerra** • `devtools` `linear` `cli` `issues`
|
||||||
|
|
||||||
|
CLI for Linear that integrates with agentic workflows (Claude Code, Clawdbot). Manage issues, projects, and workflows from the terminal. First external PR merged!
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Beeper CLI" icon="message" href="https://github.com/blqke/beepcli">
|
||||||
|
**@jules** • `messaging` `beeper` `cli` `automation`
|
||||||
|
|
||||||
|
Read, send, and archive messages via Beeper Desktop. Uses Beeper local MCP API so agents can manage all your chats (iMessage, WhatsApp, etc.) in one place.
|
||||||
|
</Card>
|
||||||
|
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
## 🤖 Automation & Workflows
|
## 🤖 Automation & Workflows
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
1) **Existing config detection**
|
1) **Existing config detection**
|
||||||
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
||||||
|
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
|
||||||
|
(or pass `--reset`).
|
||||||
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
||||||
you to run `clawdbot doctor` before continuing.
|
you to run `clawdbot doctor` before continuing.
|
||||||
- Reset uses `trash` (never `rm`) and offers scopes:
|
- Reset uses `trash` (never `rm`) and offers scopes:
|
||||||
|
|||||||
@@ -112,3 +112,18 @@ curl -s http://127.0.0.1:18791/tabs
|
|||||||
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
||||||
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
||||||
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
||||||
|
|
||||||
|
### Problem: "Chrome extension relay is running, but no tab is connected"
|
||||||
|
|
||||||
|
You’re using the `chrome` profile (extension relay). It expects the Clawdbot
|
||||||
|
browser extension to be attached to a live tab.
|
||||||
|
|
||||||
|
Fix options:
|
||||||
|
1. **Use the managed browser:** `clawdbot browser start --browser-profile clawd`
|
||||||
|
(or set `browser.defaultProfile: "clawd"`).
|
||||||
|
2. **Use the extension relay:** install the extension, open a tab, and click the
|
||||||
|
Clawdbot extension icon to attach it.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `chrome` profile uses your **system default Chromium browser** when possible.
|
||||||
|
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP.
|
||||||
+67
-3
@@ -14,8 +14,10 @@ control server.
|
|||||||
|
|
||||||
Beginner view:
|
Beginner view:
|
||||||
- Think of it as a **separate, agent-only browser**.
|
- Think of it as a **separate, agent-only browser**.
|
||||||
- It does **not** touch your personal browser profile.
|
- The `clawd` profile does **not** touch your personal browser profile.
|
||||||
- The agent can **open tabs, read pages, click, and type** in a safe lane.
|
- The agent can **open tabs, read pages, click, and type** in a safe lane.
|
||||||
|
- The default `chrome` profile uses the **system default Chromium browser** via the
|
||||||
|
extension relay; switch to `clawd` for the isolated managed browser.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
@@ -39,6 +41,14 @@ clawdbot browser --browser-profile clawd snapshot
|
|||||||
If you get “Browser disabled”, enable it in config (see below) and restart the
|
If you get “Browser disabled”, enable it in config (see below) and restart the
|
||||||
Gateway.
|
Gateway.
|
||||||
|
|
||||||
|
## Profiles: `clawd` vs `chrome`
|
||||||
|
|
||||||
|
- `clawd`: managed, isolated browser (no extension required).
|
||||||
|
- `chrome`: extension relay to your **system browser** (requires the Clawdbot
|
||||||
|
extension to be attached to a tab).
|
||||||
|
|
||||||
|
Set `browser.defaultProfile: "clawd"` if you want managed mode by default.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Browser settings live in `~/.clawdbot/clawdbot.json`.
|
Browser settings live in `~/.clawdbot/clawdbot.json`.
|
||||||
@@ -49,6 +59,8 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
enabled: true, // default: true
|
enabled: true, // default: true
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
||||||
|
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
||||||
|
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
headless: false,
|
headless: false,
|
||||||
@@ -69,13 +81,25 @@ Notes:
|
|||||||
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
||||||
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
||||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||||
|
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||||
|
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
||||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
|
||||||
|
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||||
|
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
|
||||||
|
|
||||||
## Use Brave (or another Chromium-based browser)
|
## Use Brave (or another Chromium-based browser)
|
||||||
|
|
||||||
Set `browser.executablePath` to override auto-detection:
|
If your **system default** browser is Chromium-based (Chrome/Brave/Edge/etc),
|
||||||
|
Clawdbot uses it automatically. Set `browser.executablePath` to override
|
||||||
|
auto-detection:
|
||||||
|
|
||||||
|
CLI example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
|
```
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
// macOS
|
// macOS
|
||||||
@@ -134,6 +158,42 @@ Example:
|
|||||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
||||||
directly to a Chromium-based browser instance without a remote control server.
|
directly to a Chromium-based browser instance without a remote control server.
|
||||||
|
|
||||||
|
Remote CDP URLs can include auth:
|
||||||
|
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
||||||
|
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
|
||||||
|
|
||||||
|
Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
||||||
|
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||||
|
tokens instead of committing them to config files.
|
||||||
|
|
||||||
|
### Browserless (hosted remote CDP)
|
||||||
|
|
||||||
|
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||||
|
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
|
||||||
|
Browserless region endpoint and authenticate with your API key.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
defaultProfile: "browserless",
|
||||||
|
remoteCdpTimeoutMs: 2000,
|
||||||
|
remoteCdpHandshakeTimeoutMs: 4000,
|
||||||
|
profiles: {
|
||||||
|
browserless: {
|
||||||
|
cdpUrl: "https://production-sfo.browserless.io?token=<BROWSERLESS_API_KEY>",
|
||||||
|
color: "#00AA00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||||
|
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||||
|
|
||||||
### Running the control server on the browser machine
|
### Running the control server on the browser machine
|
||||||
|
|
||||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
Run a standalone browser control server (recommended when your Gateway is remote):
|
||||||
@@ -339,6 +399,10 @@ Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
|||||||
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
||||||
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
||||||
|
|
||||||
|
If you see `Playwright is not available in this gateway build`, install the full
|
||||||
|
Playwright package (not `playwright-core`) and restart the gateway, or reinstall
|
||||||
|
Clawdbot with browser support.
|
||||||
|
|
||||||
## How it works (internal)
|
## How it works (internal)
|
||||||
|
|
||||||
High-level flow:
|
High-level flow:
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ Notes:
|
|||||||
- Use `{baseDir}` in instructions to reference the skill folder path.
|
- Use `{baseDir}` in instructions to reference the skill folder path.
|
||||||
- Optional frontmatter keys:
|
- Optional frontmatter keys:
|
||||||
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`).
|
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`).
|
||||||
|
- `user-invocable` — `true|false` (default: `true`). When `true`, the skill is exposed as a user slash command.
|
||||||
|
- `disable-model-invocation` — `true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation).
|
||||||
|
|
||||||
## Gating (load-time filters)
|
## Gating (load-time filters)
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ Notes:
|
|||||||
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
|
||||||
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
|
||||||
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
|
||||||
|
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
|
||||||
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
|
||||||
|
|
||||||
## Usage vs cost (what shows where)
|
## Usage vs cost (what shows where)
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ Auto-archive:
|
|||||||
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||||
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
|
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Sub-agent auth is resolved by **agent id**, not by session type:
|
||||||
|
- The sub-agent session key is `agent:<agentId>:subagent:<uuid>`.
|
||||||
|
- The auth store is loaded from that agent’s `agentDir`.
|
||||||
|
- The main agent’s auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts.
|
||||||
|
|
||||||
|
Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet.
|
||||||
|
|
||||||
## Announce
|
## Announce
|
||||||
|
|
||||||
Sub-agents report back via an announce step:
|
Sub-agents report back via an announce step:
|
||||||
|
|||||||
@@ -323,38 +323,41 @@ run_profile() {
|
|||||||
local workspace="$3"
|
local workspace="$3"
|
||||||
local agent_model_provider="$4" # "openai"|"anthropic"
|
local agent_model_provider="$4" # "openai"|"anthropic"
|
||||||
|
|
||||||
echo "==> Onboard ($profile)"
|
echo "==> Onboard ($profile)"
|
||||||
if [[ "$agent_model_provider" == "openai" ]]; then
|
if [[ "$agent_model_provider" == "openai" ]]; then
|
||||||
clawdbot --profile "$profile" onboard \
|
clawdbot --profile "$profile" onboard \
|
||||||
--non-interactive \
|
--non-interactive \
|
||||||
--flow quickstart \
|
--accept-risk \
|
||||||
--auth-choice openai-api-key \
|
--flow quickstart \
|
||||||
--openai-api-key "$OPENAI_API_KEY" \
|
--auth-choice openai-api-key \
|
||||||
--gateway-port "$port" \
|
--openai-api-key "$OPENAI_API_KEY" \
|
||||||
|
--gateway-port "$port" \
|
||||||
|
--gateway-bind loopback \
|
||||||
|
--gateway-auth token \
|
||||||
|
--workspace "$workspace" \
|
||||||
|
--skip-health
|
||||||
|
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
|
||||||
|
clawdbot --profile "$profile" onboard \
|
||||||
|
--non-interactive \
|
||||||
|
--accept-risk \
|
||||||
|
--flow quickstart \
|
||||||
|
--auth-choice token \
|
||||||
|
--token-provider anthropic \
|
||||||
|
--token "$ANTHROPIC_API_TOKEN" \
|
||||||
|
--gateway-port "$port" \
|
||||||
--gateway-bind loopback \
|
--gateway-bind loopback \
|
||||||
--gateway-auth token \
|
--gateway-auth token \
|
||||||
--workspace "$workspace" \
|
--workspace "$workspace" \
|
||||||
--skip-health
|
--skip-health
|
||||||
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
|
else
|
||||||
clawdbot --profile "$profile" onboard \
|
clawdbot --profile "$profile" onboard \
|
||||||
--non-interactive \
|
--non-interactive \
|
||||||
--flow quickstart \
|
--accept-risk \
|
||||||
--auth-choice token \
|
--flow quickstart \
|
||||||
--token-provider anthropic \
|
--auth-choice apiKey \
|
||||||
--token "$ANTHROPIC_API_TOKEN" \
|
--anthropic-api-key "$ANTHROPIC_API_KEY" \
|
||||||
--gateway-port "$port" \
|
--gateway-port "$port" \
|
||||||
--gateway-bind loopback \
|
--gateway-bind loopback \
|
||||||
--gateway-auth token \
|
|
||||||
--workspace "$workspace" \
|
|
||||||
--skip-health
|
|
||||||
else
|
|
||||||
clawdbot --profile "$profile" onboard \
|
|
||||||
--non-interactive \
|
|
||||||
--flow quickstart \
|
|
||||||
--auth-choice apiKey \
|
|
||||||
--anthropic-api-key "$ANTHROPIC_API_KEY" \
|
|
||||||
--gateway-port "$port" \
|
|
||||||
--gateway-bind loopback \
|
|
||||||
--gateway-auth token \
|
--gateway-auth token \
|
||||||
--workspace "$workspace" \
|
--workspace "$workspace" \
|
||||||
--skip-health
|
--skip-health
|
||||||
|
|||||||
@@ -69,15 +69,20 @@ if [[ "$*" == *"enable-linger"* ]]; then
|
|||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
LOGINCTL
|
LOGINCTL
|
||||||
chmod +x /tmp/clawdbot-bin/loginctl
|
chmod +x /tmp/clawdbot-bin/loginctl
|
||||||
|
|
||||||
# Install the npm-global variant from the local /app source.
|
# Install the npm-global variant from the local /app source.
|
||||||
pkg_tgz="$(npm pack --silent /app)"
|
# `npm pack` can emit script output; keep only the tarball name.
|
||||||
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
|
pkg_tgz="$(npm pack --silent /app | tail -n 1 | tr -d '\r')"
|
||||||
|
if [ ! -f "/app/$pkg_tgz" ]; then
|
||||||
npm_bin="/tmp/npm-prefix/bin/clawdbot"
|
echo "npm pack failed (expected /app/$pkg_tgz)"
|
||||||
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
|
exit 1
|
||||||
git_entry="/app/dist/entry.js"
|
fi
|
||||||
|
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
|
||||||
|
|
||||||
|
npm_bin="/tmp/npm-prefix/bin/clawdbot"
|
||||||
|
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
|
||||||
|
git_entry="/app/dist/entry.js"
|
||||||
|
|
||||||
assert_entrypoint() {
|
assert_entrypoint() {
|
||||||
local unit_path="$1"
|
local unit_path="$1"
|
||||||
@@ -126,17 +131,17 @@ LOGINCTL
|
|||||||
assert_entrypoint "$unit_path" "$doctor_expected"
|
assert_entrypoint "$unit_path" "$doctor_expected"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_flow \
|
run_flow \
|
||||||
"npm-to-git" \
|
"npm-to-git" \
|
||||||
"$npm_bin daemon install --force" \
|
"$npm_bin daemon install --force" \
|
||||||
"$npm_entry" \
|
"$npm_entry" \
|
||||||
"node $git_entry doctor --repair --force" \
|
"node $git_entry doctor --repair --force" \
|
||||||
"$git_entry"
|
"$git_entry"
|
||||||
|
|
||||||
run_flow \
|
run_flow \
|
||||||
"git-to-npm" \
|
"git-to-npm" \
|
||||||
"node $git_entry daemon install --force" \
|
"node $git_entry daemon install --force" \
|
||||||
"$git_entry" \
|
"$git_entry" \
|
||||||
"$npm_bin doctor --repair --force" \
|
"$npm_bin doctor --repair --force" \
|
||||||
"$npm_entry"
|
"$npm_entry"
|
||||||
'
|
'
|
||||||
@@ -42,6 +42,38 @@ TRASH
|
|||||||
printf "%b" "$payload" >&3 2>/dev/null || true
|
printf "%b" "$payload" >&3 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wait_for_log() {
|
||||||
|
local needle="$1"
|
||||||
|
local timeout_s="${2:-45}"
|
||||||
|
local needle_compact
|
||||||
|
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
|
||||||
|
local start_s
|
||||||
|
start_s="$(date +%s)"
|
||||||
|
while true; do
|
||||||
|
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||||
|
if NEEDLE="$needle_compact" node --input-type=module -e "
|
||||||
|
import fs from \"node:fs\";
|
||||||
|
const file = process.env.WIZARD_LOG_PATH;
|
||||||
|
const needle = process.env.NEEDLE ?? \"\";
|
||||||
|
let text = \"\";
|
||||||
|
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||||
|
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
|
||||||
|
process.exit(text.includes(needle) ? 0 : 1);
|
||||||
|
"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ $(( $(date +%s) - start_s )) -ge "$timeout_s" ]; then
|
||||||
|
echo "Timeout waiting for log: $needle"
|
||||||
|
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||||
|
tail -n 140 "$WIZARD_LOG_PATH" || true
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
start_gateway() {
|
start_gateway() {
|
||||||
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
|
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
|
||||||
GATEWAY_PID="$!"
|
GATEWAY_PID="$!"
|
||||||
@@ -81,6 +113,8 @@ TRASH
|
|||||||
input_fifo="$(mktemp -u "/tmp/clawdbot-onboard-${case_name}.XXXXXX")"
|
input_fifo="$(mktemp -u "/tmp/clawdbot-onboard-${case_name}.XXXXXX")"
|
||||||
mkfifo "$input_fifo"
|
mkfifo "$input_fifo"
|
||||||
local log_path="/tmp/clawdbot-onboard-${case_name}.log"
|
local log_path="/tmp/clawdbot-onboard-${case_name}.log"
|
||||||
|
WIZARD_LOG_PATH="$log_path"
|
||||||
|
export WIZARD_LOG_PATH
|
||||||
# Run under script to keep an interactive TTY for clack prompts.
|
# Run under script to keep an interactive TTY for clack prompts.
|
||||||
script -q -c "$command" "$log_path" < "$input_fifo" &
|
script -q -c "$command" "$log_path" < "$input_fifo" &
|
||||||
wizard_pid=$!
|
wizard_pid=$!
|
||||||
@@ -135,36 +169,44 @@ TRASH
|
|||||||
}
|
}
|
||||||
|
|
||||||
send_local_basic() {
|
send_local_basic() {
|
||||||
|
# Risk acknowledgement (default is "No").
|
||||||
|
send $'"'"'y\r'"'"' 0.6
|
||||||
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
|
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
|
||||||
send $'"'"'\r'"'"' 0.5
|
send $'"'"'\r'"'"' 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
send_reset_config_only() {
|
send_reset_config_only() {
|
||||||
# Reset config + reuse the local defaults flow.
|
# Risk acknowledgement (default is "No").
|
||||||
send $'"'"'\e[B'"'"' 0.3
|
send $'"'"'y\r'"'"' 0.8
|
||||||
send $'"'"'\e[B'"'"' 0.3
|
# Reset config + reuse the local defaults flow.
|
||||||
send $'"'"'\r'"'"' 0.4
|
send $'"'"'\e[B'"'"' 0.3
|
||||||
send $'"'"'\r'"'"' 0.4
|
send $'"'"'\e[B'"'"' 0.3
|
||||||
send "" 1.2
|
send $'"'"'\r'"'"' 0.4
|
||||||
send_local_basic
|
send $'"'"'\r'"'"' 0.4
|
||||||
}
|
send "" 1.2
|
||||||
|
send_local_basic
|
||||||
|
}
|
||||||
|
|
||||||
send_channels_flow() {
|
send_channels_flow() {
|
||||||
# Configure channels via configure wizard.
|
# Configure channels via configure wizard.
|
||||||
send $'"'"'\r'"'"' 1.0
|
# Prompts are interactive; notes are not. Use conservative delays to stay in sync.
|
||||||
send "" 1.5
|
# Where will the Gateway run? -> Local (default)
|
||||||
# Mode (default Configure channels)
|
send $'"'"'\r'"'"' 1.2
|
||||||
send $'"'"'\r'"'"' 0.8
|
# Channels mode -> Configure/link (default)
|
||||||
send "" 1.0
|
send $'"'"'\r'"'"' 1.5
|
||||||
# Configure chat channels now? -> No
|
# Select a channel -> Finished (last option; clack wraps on Up)
|
||||||
send $'"'"'n\r'"'"' 0.6
|
send $'"'"'\e[A\r'"'"' 2.0
|
||||||
|
# Keep stdin open until wizard exits.
|
||||||
|
send "" 2.5
|
||||||
}
|
}
|
||||||
|
|
||||||
send_skills_flow() {
|
send_skills_flow() {
|
||||||
# Select skills section and skip optional installs.
|
# Select skills section and skip optional installs.
|
||||||
send $'"'"'\r'"'"' 1.0
|
send $'"'"'\r'"'"' 1.2
|
||||||
send "" 1.2
|
send "" 1.0
|
||||||
send $'"'"'n\r'"'"' 0.6
|
# Configure skills now? -> No
|
||||||
|
send $'"'"'n\r'"'"' 1.2
|
||||||
|
send "" 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
run_case_local_basic() {
|
run_case_local_basic() {
|
||||||
@@ -257,7 +299,7 @@ NODE
|
|||||||
export HOME="$home_dir"
|
export HOME="$home_dir"
|
||||||
mkdir -p "$HOME"
|
mkdir -p "$HOME"
|
||||||
# Smoke test non-interactive remote config write.
|
# Smoke test non-interactive remote config write.
|
||||||
node dist/index.js onboard --non-interactive \
|
node dist/index.js onboard --non-interactive --accept-risk \
|
||||||
--mode remote \
|
--mode remote \
|
||||||
--remote-url ws://gateway.local:18789 \
|
--remote-url ws://gateway.local:18789 \
|
||||||
--remote-token remote-token \
|
--remote-token remote-token \
|
||||||
|
|||||||
+25
-7
@@ -206,6 +206,31 @@ if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
|
|||||||
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
|
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches).
|
||||||
|
# This reduces noisy "could not connect" errors during app startup.
|
||||||
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
|
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
||||||
|
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
||||||
|
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
||||||
|
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
|
||||||
|
fi
|
||||||
|
GATEWAY_PORT="$(
|
||||||
|
node -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(path.join(process.env.HOME, ".clawdbot", "clawdbot.json"), "utf8");
|
||||||
|
const cfg = JSON.parse(raw);
|
||||||
|
const port = cfg && cfg.gateway && typeof cfg.gateway.port === "number" ? cfg.gateway.port : 18789;
|
||||||
|
process.stdout.write(String(port));
|
||||||
|
} catch {
|
||||||
|
process.stdout.write("18789");
|
||||||
|
}
|
||||||
|
'
|
||||||
|
)"
|
||||||
|
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true"
|
||||||
|
fi
|
||||||
|
|
||||||
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
||||||
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
||||||
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
||||||
@@ -226,13 +251,6 @@ else
|
|||||||
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches).
|
|
||||||
if [ "$NO_SIGN" -eq 1 ]; then
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
|
||||||
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
|
||||||
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
|
||||||
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
|
|
||||||
fi
|
|
||||||
run_step "verify gateway port 18789 (unsigned)" bash -lc "lsof -iTCP:18789 -sTCP:LISTEN | head -n 5 || true"
|
|
||||||
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
|
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
|
||||||
fi
|
fi
|
||||||
+11
-2
@@ -45,10 +45,19 @@ function which(cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRunner() {
|
function resolveRunner() {
|
||||||
const bun = which("bun");
|
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
|
||||||
if (bun) return { cmd: bun, kind: "bun" };
|
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
|
||||||
|
if (!preferPnpm) {
|
||||||
|
const bun = which("bun");
|
||||||
|
if (bun) return { cmd: bun, kind: "bun" };
|
||||||
|
}
|
||||||
const pnpm = which("pnpm");
|
const pnpm = which("pnpm");
|
||||||
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
||||||
|
if (preferPnpm) {
|
||||||
|
// Fallback to bun if pnpm not found even when preferring pnpm
|
||||||
|
const bun = which("bun");
|
||||||
|
if (bun) return { cmd: bun, kind: "bun" };
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,53 @@ open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; e
|
|||||||
Useful flags:
|
Useful flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# GPT image models with various options
|
||||||
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
|
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
|
||||||
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
|
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
|
||||||
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
|
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
|
||||||
|
python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp
|
||||||
|
|
||||||
|
# DALL-E 3 (note: count is automatically limited to 1)
|
||||||
|
python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid
|
||||||
|
python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape"
|
||||||
|
|
||||||
|
# DALL-E 2
|
||||||
|
python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Model-Specific Parameters
|
||||||
|
|
||||||
|
Different models support different parameter values. The script automatically selects appropriate defaults based on the model.
|
||||||
|
|
||||||
|
### Size
|
||||||
|
|
||||||
|
- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto`
|
||||||
|
- Default: `1024x1024`
|
||||||
|
- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792`
|
||||||
|
- Default: `1024x1024`
|
||||||
|
- **dall-e-2**: `256x256`, `512x512`, or `1024x1024`
|
||||||
|
- Default: `1024x1024`
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
|
||||||
|
- **GPT image models**: `auto`, `high`, `medium`, or `low`
|
||||||
|
- Default: `high`
|
||||||
|
- **dall-e-3**: `hd` or `standard`
|
||||||
|
- Default: `standard`
|
||||||
|
- **dall-e-2**: `standard` only
|
||||||
|
- Default: `standard`
|
||||||
|
|
||||||
|
### Other Notable Differences
|
||||||
|
|
||||||
|
- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model.
|
||||||
|
- **GPT image models** support additional parameters:
|
||||||
|
- `--background`: `transparent`, `opaque`, or `auto` (default)
|
||||||
|
- `--output-format`: `png` (default), `jpeg`, or `webp`
|
||||||
|
- Note: `stream` and `moderation` are available via API but not yet implemented in this script
|
||||||
|
- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking)
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
- `*.png` images
|
- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`)
|
||||||
- `prompts.json` (prompt → file mapping)
|
- `prompts.json` (prompt → file mapping)
|
||||||
- `index.html` (thumbnail gallery)
|
- `index.html` (thumbnail gallery)
|
||||||
@@ -62,24 +62,53 @@ def pick_prompts(count: int) -> list[str]:
|
|||||||
return prompts
|
return prompts
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_defaults(model: str) -> tuple[str, str]:
|
||||||
|
"""Return (default_size, default_quality) for the given model."""
|
||||||
|
if model == "dall-e-2":
|
||||||
|
# quality will be ignored
|
||||||
|
return ("1024x1024", "standard")
|
||||||
|
elif model == "dall-e-3":
|
||||||
|
return ("1024x1024", "standard")
|
||||||
|
else:
|
||||||
|
# GPT image or future models
|
||||||
|
return ("1024x1024", "high")
|
||||||
|
|
||||||
|
|
||||||
def request_images(
|
def request_images(
|
||||||
api_key: str,
|
api_key: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
model: str,
|
model: str,
|
||||||
size: str,
|
size: str,
|
||||||
quality: str,
|
quality: str,
|
||||||
|
background: str = "",
|
||||||
|
output_format: str = "",
|
||||||
|
style: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = "https://api.openai.com/v1/images/generations"
|
url = "https://api.openai.com/v1/images/generations"
|
||||||
body = json.dumps(
|
args = {
|
||||||
{
|
"model": model,
|
||||||
"model": model,
|
"prompt": prompt,
|
||||||
"prompt": prompt,
|
"size": size,
|
||||||
"size": size,
|
"n": 1,
|
||||||
"quality": quality,
|
}
|
||||||
"n": 1,
|
|
||||||
"response_format": "b64_json",
|
# Quality parameter - dall-e-2 doesn't accept this parameter
|
||||||
}
|
if model != "dall-e-2":
|
||||||
).encode("utf-8")
|
args["quality"] = quality
|
||||||
|
|
||||||
|
if model.startswith("dall-e"):
|
||||||
|
args["response_format"] = "b64_json"
|
||||||
|
|
||||||
|
if model.startswith("gpt-image"):
|
||||||
|
if background:
|
||||||
|
args["background"] = background
|
||||||
|
if output_format:
|
||||||
|
args["output_format"] = output_format
|
||||||
|
|
||||||
|
if model == "dall-e-3" and style:
|
||||||
|
args["style"] = style
|
||||||
|
|
||||||
|
body = json.dumps(args).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -136,8 +165,11 @@ def main() -> int:
|
|||||||
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
|
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
|
||||||
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
|
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
|
||||||
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
|
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
|
||||||
ap.add_argument("--size", default="1024x1024", help="Image size (e.g. 1024x1024, 1536x1024).")
|
ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.")
|
||||||
ap.add_argument("--quality", default="high", help="Image quality (varies by model).")
|
ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.")
|
||||||
|
ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.")
|
||||||
|
ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.")
|
||||||
|
ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.")
|
||||||
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
|
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
@@ -146,21 +178,46 @@ def main() -> int:
|
|||||||
print("Missing OPENAI_API_KEY", file=sys.stderr)
|
print("Missing OPENAI_API_KEY", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
# Apply model-specific defaults if not specified
|
||||||
|
default_size, default_quality = get_model_defaults(args.model)
|
||||||
|
size = args.size or default_size
|
||||||
|
quality = args.quality or default_quality
|
||||||
|
|
||||||
|
count = args.count
|
||||||
|
if args.model == "dall-e-3" and count > 1:
|
||||||
|
print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr)
|
||||||
|
count = 1
|
||||||
|
|
||||||
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
|
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
prompts = [args.prompt] * args.count if args.prompt else pick_prompts(args.count)
|
prompts = [args.prompt] * count if args.prompt else pick_prompts(count)
|
||||||
|
|
||||||
|
# Determine file extension based on output format
|
||||||
|
if args.model.startswith("gpt-image") and args.output_format:
|
||||||
|
file_ext = args.output_format
|
||||||
|
else:
|
||||||
|
file_ext = "png"
|
||||||
|
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
for idx, prompt in enumerate(prompts, start=1):
|
for idx, prompt in enumerate(prompts, start=1):
|
||||||
print(f"[{idx}/{len(prompts)}] {prompt}")
|
print(f"[{idx}/{len(prompts)}] {prompt}")
|
||||||
res = request_images(api_key, prompt, args.model, args.size, args.quality)
|
res = request_images(
|
||||||
|
api_key,
|
||||||
|
prompt,
|
||||||
|
args.model,
|
||||||
|
size,
|
||||||
|
quality,
|
||||||
|
args.background,
|
||||||
|
args.output_format,
|
||||||
|
args.style,
|
||||||
|
)
|
||||||
b64 = res.get("data", [{}])[0].get("b64_json")
|
b64 = res.get("data", [{}])[0].get("b64_json")
|
||||||
if not b64:
|
if not b64:
|
||||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||||
png = base64.b64decode(b64)
|
image_bytes = base64.b64decode(b64)
|
||||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.png"
|
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
||||||
(out_dir / filename).write_bytes(png)
|
(out_dir / filename).write_bytes(image_bytes)
|
||||||
items.append({"prompt": prompt, "file": filename})
|
items.append({"prompt": prompt, "file": filename})
|
||||||
|
|
||||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||||
|
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||||
|
|
||||||
describe("ensureAuthProfileStore", () => {
|
describe("ensureAuthProfileStore", () => {
|
||||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||||
@@ -45,4 +46,80 @@ describe("ensureAuthProfileStore", () => {
|
|||||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("merges main auth profiles into agent store and keeps agent overrides", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-merge-"));
|
||||||
|
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||||
|
try {
|
||||||
|
const mainDir = path.join(root, "main-agent");
|
||||||
|
const agentDir = path.join(root, "agent-x");
|
||||||
|
fs.mkdirSync(mainDir, { recursive: true });
|
||||||
|
fs.mkdirSync(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
process.env.CLAWDBOT_AGENT_DIR = mainDir;
|
||||||
|
process.env.PI_CODING_AGENT_DIR = mainDir;
|
||||||
|
|
||||||
|
const mainStore = {
|
||||||
|
version: AUTH_STORE_VERSION,
|
||||||
|
profiles: {
|
||||||
|
"openai:default": {
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openai",
|
||||||
|
key: "main-key",
|
||||||
|
},
|
||||||
|
"anthropic:default": {
|
||||||
|
type: "api_key",
|
||||||
|
provider: "anthropic",
|
||||||
|
key: "main-anthropic-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(mainDir, "auth-profiles.json"),
|
||||||
|
`${JSON.stringify(mainStore, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentStore = {
|
||||||
|
version: AUTH_STORE_VERSION,
|
||||||
|
profiles: {
|
||||||
|
"openai:default": {
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openai",
|
||||||
|
key: "agent-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(agentDir, "auth-profiles.json"),
|
||||||
|
`${JSON.stringify(agentStore, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
|
expect(store.profiles["anthropic:default"]).toMatchObject({
|
||||||
|
type: "api_key",
|
||||||
|
provider: "anthropic",
|
||||||
|
key: "main-anthropic-key",
|
||||||
|
});
|
||||||
|
expect(store.profiles["openai:default"]).toMatchObject({
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openai",
|
||||||
|
key: "agent-key",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousAgentDir === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||||
|
}
|
||||||
|
if (previousPiAgentDir === undefined) {
|
||||||
|
delete process.env.PI_CODING_AGENT_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||||
|
}
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -111,6 +111,34 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeRecord<T>(
|
||||||
|
base?: Record<string, T>,
|
||||||
|
override?: Record<string, T>,
|
||||||
|
): Record<string, T> | undefined {
|
||||||
|
if (!base && !override) return undefined;
|
||||||
|
if (!base) return { ...override };
|
||||||
|
if (!override) return { ...base };
|
||||||
|
return { ...base, ...override };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAuthProfileStores(base: AuthProfileStore, override: AuthProfileStore): AuthProfileStore {
|
||||||
|
if (
|
||||||
|
Object.keys(override.profiles).length === 0 &&
|
||||||
|
!override.order &&
|
||||||
|
!override.lastGood &&
|
||||||
|
!override.usageStats
|
||||||
|
) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: Math.max(base.version, override.version ?? base.version),
|
||||||
|
profiles: { ...base.profiles, ...override.profiles },
|
||||||
|
order: mergeRecord(base.order, override.order),
|
||||||
|
lastGood: mergeRecord(base.lastGood, override.lastGood),
|
||||||
|
usageStats: mergeRecord(base.usageStats, override.usageStats),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||||
const oauthPath = resolveOAuthPath();
|
const oauthPath = resolveOAuthPath();
|
||||||
const oauthRaw = loadJsonFile(oauthPath);
|
const oauthRaw = loadJsonFile(oauthPath);
|
||||||
@@ -191,7 +219,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureAuthProfileStore(
|
function loadAuthProfileStoreForAgent(
|
||||||
agentDir?: string,
|
agentDir?: string,
|
||||||
options?: { allowKeychainPrompt?: boolean },
|
options?: { allowKeychainPrompt?: boolean },
|
||||||
): AuthProfileStore {
|
): AuthProfileStore {
|
||||||
@@ -207,6 +235,19 @@ export function ensureAuthProfileStore(
|
|||||||
return asStore;
|
return asStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: inherit auth-profiles from main agent if subagent has none
|
||||||
|
if (agentDir) {
|
||||||
|
const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
|
||||||
|
const mainRaw = loadJsonFile(mainAuthPath);
|
||||||
|
const mainStore = coerceAuthStore(mainRaw);
|
||||||
|
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
|
||||||
|
// Clone main store to subagent directory for auth inheritance
|
||||||
|
saveJsonFile(authPath, mainStore);
|
||||||
|
log.info("inherited auth-profiles from main agent", { agentDir });
|
||||||
|
return mainStore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||||
const legacy = coerceLegacyStore(legacyRaw);
|
const legacy = coerceLegacyStore(legacyRaw);
|
||||||
const store: AuthProfileStore = {
|
const store: AuthProfileStore = {
|
||||||
@@ -274,6 +315,21 @@ export function ensureAuthProfileStore(
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ensureAuthProfileStore(
|
||||||
|
agentDir?: string,
|
||||||
|
options?: { allowKeychainPrompt?: boolean },
|
||||||
|
): AuthProfileStore {
|
||||||
|
const store = loadAuthProfileStoreForAgent(agentDir, options);
|
||||||
|
const authPath = resolveAuthStorePath(agentDir);
|
||||||
|
const mainAuthPath = resolveAuthStorePath();
|
||||||
|
if (!agentDir || authPath === mainAuthPath) {
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
|
||||||
|
return mergeAuthProfileStores(mainStore, store);
|
||||||
|
}
|
||||||
|
|
||||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||||
const authPath = resolveAuthStorePath(agentDir);
|
const authPath = resolveAuthStorePath(agentDir);
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createExecTool } from "./bash-tools.exec";
|
||||||
|
import {
|
||||||
|
getFinishedSession,
|
||||||
|
getSession,
|
||||||
|
resetProcessRegistryForTests,
|
||||||
|
} from "./bash-process-registry";
|
||||||
|
import { killProcessTree } from "./shell-utils";
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetProcessRegistryForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("background exec is not killed when tool signal aborts", async () => {
|
||||||
|
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
"toolcall",
|
||||||
|
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.details.status).toBe("running");
|
||||||
|
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
const running = getSession(sessionId);
|
||||||
|
const finished = getFinishedSession(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(finished).toBeUndefined();
|
||||||
|
expect(running?.exited).toBe(false);
|
||||||
|
} finally {
|
||||||
|
const pid = running?.pid;
|
||||||
|
if (pid) killProcessTree(pid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("background exec still times out after tool signal abort", async () => {
|
||||||
|
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
"toolcall",
|
||||||
|
{
|
||||||
|
command: 'node -e "setTimeout(() => {}, 5000)"',
|
||||||
|
background: true,
|
||||||
|
timeout: 0.2,
|
||||||
|
},
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.details.status).toBe("running");
|
||||||
|
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
let finished = getFinishedSession(sessionId);
|
||||||
|
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||||
|
while (!finished && Date.now() < deadline) {
|
||||||
|
await sleep(20);
|
||||||
|
finished = getFinishedSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = getSession(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(finished).toBeTruthy();
|
||||||
|
expect(finished?.status).toBe("failed");
|
||||||
|
} finally {
|
||||||
|
const pid = running?.pid;
|
||||||
|
if (pid) killProcessTree(pid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("yielded background exec is not killed when tool signal aborts", async () => {
|
||||||
|
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
"toolcall",
|
||||||
|
{ command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.details.status).toBe("running");
|
||||||
|
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
const running = getSession(sessionId);
|
||||||
|
const finished = getFinishedSession(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(finished).toBeUndefined();
|
||||||
|
expect(running?.exited).toBe(false);
|
||||||
|
} finally {
|
||||||
|
const pid = running?.pid;
|
||||||
|
if (pid) killProcessTree(pid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("yielded background exec still times out", async () => {
|
||||||
|
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
|
||||||
|
|
||||||
|
const result = await tool.execute("toolcall", {
|
||||||
|
command: 'node -e "setTimeout(() => {}, 5000)"',
|
||||||
|
yieldMs: 5,
|
||||||
|
timeout: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.details.status).toBe("running");
|
||||||
|
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||||
|
|
||||||
|
let finished = getFinishedSession(sessionId);
|
||||||
|
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||||
|
while (!finished && Date.now() < deadline) {
|
||||||
|
await sleep(20);
|
||||||
|
finished = getFinishedSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = getSession(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(finished).toBeTruthy();
|
||||||
|
expect(finished?.status).toBe("failed");
|
||||||
|
} finally {
|
||||||
|
const pid = running?.pid;
|
||||||
|
if (pid) killProcessTree(pid);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -262,21 +262,28 @@ export function createExecTool(
|
|||||||
fn();
|
fn();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAbort = () => {
|
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
|
||||||
|
const onAbortSignal = () => {
|
||||||
|
if (yielded || session.backgrounded) return;
|
||||||
killSession(session);
|
killSession(session);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signal?.aborted) onAbort();
|
// Timeouts always terminate, even for backgrounded sessions.
|
||||||
|
const onTimeout = () => {
|
||||||
|
timedOut = true;
|
||||||
|
killSession(session);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal?.aborted) onAbortSignal();
|
||||||
else if (signal) {
|
else if (signal) {
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbortSignal, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
|
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
|
||||||
if (effectiveTimeout > 0) {
|
if (effectiveTimeout > 0) {
|
||||||
timeoutTimer = setTimeout(() => {
|
timeoutTimer = setTimeout(() => {
|
||||||
timedOut = true;
|
onTimeout();
|
||||||
onAbort();
|
|
||||||
}, effectiveTimeout * 1000);
|
}, effectiveTimeout * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ describe("session_status tool", () => {
|
|||||||
expect(details.ok).toBe(true);
|
expect(details.ok).toBe(true);
|
||||||
expect(details.statusText).toContain("Clawdbot");
|
expect(details.statusText).toContain("Clawdbot");
|
||||||
expect(details.statusText).toContain("🧠 Model:");
|
expect(details.statusText).toContain("🧠 Model:");
|
||||||
|
expect(details.statusText).not.toContain("OAuth/token status");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("errors for unknown session keys", async () => {
|
it("errors for unknown session keys", async () => {
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function createClawdbotTools(options?: {
|
|||||||
createNodesTool(),
|
createNodesTool(),
|
||||||
createCronTool({
|
createCronTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
config: options?.config,
|
|
||||||
}),
|
}),
|
||||||
createMessageTool({
|
createMessageTool({
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ function isModelNotFoundErrorMessage(raw: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
||||||
|
const msg = raw.toLowerCase();
|
||||||
|
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||||
|
}
|
||||||
|
|
||||||
function toInt(value: string | undefined, fallback: number): number {
|
function toInt(value: string | undefined, fallback: number): number {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
if (!trimmed) return fallback;
|
if (!trimmed) return fallback;
|
||||||
@@ -371,6 +376,18 @@ describeLive("live models (profile keys)", () => {
|
|||||||
logProgress(`${progressLabel}: skip (empty response)`);
|
logProgress(`${progressLabel}: skip (empty response)`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
ok.text.length === 0 &&
|
||||||
|
allowNotFoundSkip &&
|
||||||
|
(model.provider === "google-antigravity" || model.provider === "openai-codex")
|
||||||
|
) {
|
||||||
|
skipped.push({
|
||||||
|
model: id,
|
||||||
|
reason: "no text returned (provider returned empty content)",
|
||||||
|
});
|
||||||
|
logProgress(`${progressLabel}: skip (empty response)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
expect(ok.text.length).toBeGreaterThan(0);
|
expect(ok.text.length).toBeGreaterThan(0);
|
||||||
logProgress(`${progressLabel}: done`);
|
logProgress(`${progressLabel}: done`);
|
||||||
break;
|
break;
|
||||||
@@ -416,6 +433,15 @@ describeLive("live models (profile keys)", () => {
|
|||||||
logProgress(`${progressLabel}: skip (rate limit)`);
|
logProgress(`${progressLabel}: skip (rate limit)`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
allowNotFoundSkip &&
|
||||||
|
model.provider === "openai-codex" &&
|
||||||
|
isChatGPTUsageLimitErrorMessage(message)
|
||||||
|
) {
|
||||||
|
skipped.push({ model: id, reason: message });
|
||||||
|
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
logProgress(`${progressLabel}: failed`);
|
logProgress(`${progressLabel}: failed`);
|
||||||
failures.push({ model: id, error: message });
|
failures.push({ model: id, error: message });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -9,26 +9,63 @@ import type { EmbeddedContextFile } from "./types.js";
|
|||||||
|
|
||||||
type ContentBlockWithSignature = {
|
type ContentBlockWithSignature = {
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ThoughtSignatureSanitizeOptions = {
|
||||||
|
allowBase64Only?: boolean;
|
||||||
|
includeCamelCase?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBase64Signature(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const compact = trimmed.replace(/\s+/g, "");
|
||||||
|
if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false;
|
||||||
|
const isUrl = compact.includes("-") || compact.includes("_");
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(compact, isUrl ? "base64url" : "base64");
|
||||||
|
if (buf.length === 0) return false;
|
||||||
|
const encoded = buf.toString(isUrl ? "base64url" : "base64");
|
||||||
|
const normalize = (input: string) => input.replace(/=+$/g, "");
|
||||||
|
return normalize(encoded) === normalize(compact);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips Claude-style thought_signature fields from content blocks.
|
* Strips Claude-style thought_signature fields from content blocks.
|
||||||
*
|
*
|
||||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
||||||
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
||||||
*/
|
*/
|
||||||
export function stripThoughtSignatures<T>(content: T): T {
|
export function stripThoughtSignatures<T>(
|
||||||
|
content: T,
|
||||||
|
options?: ThoughtSignatureSanitizeOptions,
|
||||||
|
): T {
|
||||||
if (!Array.isArray(content)) return content;
|
if (!Array.isArray(content)) return content;
|
||||||
|
const allowBase64Only = options?.allowBase64Only ?? false;
|
||||||
|
const includeCamelCase = options?.includeCamelCase ?? false;
|
||||||
|
const shouldStripSignature = (value: unknown): boolean => {
|
||||||
|
if (!allowBase64Only) {
|
||||||
|
return typeof value === "string" && value.startsWith("msg_");
|
||||||
|
}
|
||||||
|
return typeof value !== "string" || !isBase64Signature(value);
|
||||||
|
};
|
||||||
return content.map((block) => {
|
return content.map((block) => {
|
||||||
if (!block || typeof block !== "object") return block;
|
if (!block || typeof block !== "object") return block;
|
||||||
const rec = block as ContentBlockWithSignature;
|
const rec = block as ContentBlockWithSignature;
|
||||||
const signature = rec.thought_signature;
|
const stripSnake = shouldStripSignature(rec.thought_signature);
|
||||||
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
const stripCamel = includeCamelCase ? shouldStripSignature(rec.thoughtSignature) : false;
|
||||||
|
if (!stripSnake && !stripCamel) {
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
const { thought_signature: _signature, ...rest } = rec;
|
const next = { ...rec };
|
||||||
return rest;
|
if (stripSnake) delete next.thought_signature;
|
||||||
|
if (stripCamel) delete next.thoughtSignature;
|
||||||
|
return next;
|
||||||
}) as T;
|
}) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export { sanitizeGoogleTurnOrdering };
|
|||||||
type GeminiToolCallBlock = {
|
type GeminiToolCallBlock = {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
thought_signature?: unknown;
|
thought_signature?: unknown;
|
||||||
|
thoughtSignature?: unknown;
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
toolCallId?: unknown;
|
toolCallId?: unknown;
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
@@ -118,7 +119,8 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
|||||||
const blockRecord = block as GeminiToolCallBlock;
|
const blockRecord = block as GeminiToolCallBlock;
|
||||||
const type = blockRecord.type;
|
const type = blockRecord.type;
|
||||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
||||||
const hasSignature = Boolean(blockRecord.thought_signature);
|
const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature;
|
||||||
|
const hasSignature = Boolean(signature);
|
||||||
if (!hasSignature) {
|
if (!hasSignature) {
|
||||||
const id =
|
const id =
|
||||||
typeof blockRecord.id === "string"
|
typeof blockRecord.id === "string"
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
enforceToolCallLast?: boolean;
|
enforceToolCallLast?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
|
sanitizeThoughtSignatures?: {
|
||||||
|
allowBase64Only?: boolean;
|
||||||
|
includeCamelCase?: boolean;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
// We sanitize historical session messages because Anthropic can reject a request
|
// We sanitize historical session messages because Anthropic can reject a request
|
||||||
@@ -82,7 +86,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const strippedContent = options?.preserveSignatures
|
const strippedContent = options?.preserveSignatures
|
||||||
? content // Keep signatures for Antigravity Claude
|
? content // Keep signatures for Antigravity Claude
|
||||||
: stripThoughtSignatures(content); // Strip for Gemini
|
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
||||||
|
|
||||||
const filteredContent = strippedContent.filter((block) => {
|
const filteredContent = strippedContent.filter((block) => {
|
||||||
if (!block || typeof block !== "object") return true;
|
if (!block || typeof block !== "object") return true;
|
||||||
|
|||||||
@@ -145,6 +145,65 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
|||||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
expect(assistant.content?.[1]?.text).toBe("internal note");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||||
|
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "/tmp/foo" },
|
||||||
|
thoughtSignature: '{"id":1}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_2",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "/tmp/bar" },
|
||||||
|
thoughtSignature: "c2ln",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionHistory({
|
||||||
|
messages: input,
|
||||||
|
modelApi: "openrouter",
|
||||||
|
provider: "openrouter",
|
||||||
|
modelId: "google/gemini-1.5-pro",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "session:openrouter-gemini",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||||
|
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
|
||||||
|
};
|
||||||
|
expect(assistant.content).toEqual([
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "text", text: "ok" },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: '[Tool Call: read (ID: call_1)]\nArguments: {\n "path": "/tmp/foo"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_2",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "/tmp/bar" },
|
||||||
|
thoughtSignature: "c2ln",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const input = [
|
const input = [
|
||||||
|
|||||||
+60
@@ -261,4 +261,64 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
||||||
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
it("returns role ordering error when session ends with a user turn", async () => {
|
||||||
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
|
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
|
sessionManager.appendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "seed user 1" }],
|
||||||
|
});
|
||||||
|
sessionManager.appendMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "seed assistant" }],
|
||||||
|
stopReason: "stop",
|
||||||
|
api: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-1",
|
||||||
|
usage: {
|
||||||
|
input: 1,
|
||||||
|
output: 1,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 2,
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
sessionManager.appendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "seed user 2" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
|
const result = await runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "hello",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-1",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
agentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.meta.error?.kind).toBe("role_ordering");
|
||||||
|
expect(result.meta.error?.message).toMatch(/incorrect role information|roles must alternate/i);
|
||||||
|
expect(result.payloads?.[0]?.text).toContain("Message ordering conflict");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as helpers from "./pi-embedded-helpers.js";
|
||||||
|
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("./pi-embedded-helpers.js", async () => {
|
||||||
|
const actual = await vi.importActual("./pi-embedded-helpers.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
isGoogleModelApi: vi.fn(),
|
||||||
|
downgradeGeminiHistory: vi.fn(),
|
||||||
|
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// We don't mock session-transcript-repair.js as it is a pure function and complicates mocking.
|
||||||
|
// We rely on the real implementation which should pass through our simple messages.
|
||||||
|
|
||||||
|
describe("sanitizeSessionHistory", () => {
|
||||||
|
const mockSessionManager = {
|
||||||
|
getEntries: vi.fn().mockReturnValue([]),
|
||||||
|
appendCustomEntry: vi.fn(),
|
||||||
|
} as unknown as SessionManager;
|
||||||
|
|
||||||
|
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
||||||
|
// Default mock implementation
|
||||||
|
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
|
||||||
|
if (!msgs) return [];
|
||||||
|
return [...msgs, { role: "system", content: "downgraded" }];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should downgrade history for Google models if provider is not google-antigravity", async () => {
|
||||||
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await sanitizeSessionHistory({
|
||||||
|
messages: mockMessages,
|
||||||
|
modelApi: "google-gemini",
|
||||||
|
provider: "google-vertex",
|
||||||
|
sessionManager: mockSessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||||
|
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
||||||
|
// Check if the result contains the downgraded message
|
||||||
|
expect(result).toContainEqual({ role: "system", content: "downgraded" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT downgrade history for google-antigravity provider", async () => {
|
||||||
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await sanitizeSessionHistory({
|
||||||
|
messages: mockMessages,
|
||||||
|
modelApi: "google-gemini",
|
||||||
|
provider: "google-antigravity",
|
||||||
|
sessionManager: mockSessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||||
|
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
||||||
|
// Result should not contain the downgraded message
|
||||||
|
expect(result).not.toContainEqual({
|
||||||
|
role: "system",
|
||||||
|
content: "downgraded",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT downgrade history for non-Google models", async () => {
|
||||||
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
|
const _result = await sanitizeSessionHistory({
|
||||||
|
messages: mockMessages,
|
||||||
|
modelApi: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
sessionManager: mockSessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
|
||||||
|
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should downgrade history if provider is undefined but model is Google", async () => {
|
||||||
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||||
|
|
||||||
|
const _result = await sanitizeSessionHistory({
|
||||||
|
messages: mockMessages,
|
||||||
|
modelApi: "google-gemini",
|
||||||
|
provider: undefined,
|
||||||
|
sessionManager: mockSessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||||
|
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,7 +44,11 @@ import {
|
|||||||
} from "../skills.js";
|
} from "../skills.js";
|
||||||
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../workspace.js";
|
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../workspace.js";
|
||||||
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
||||||
import { logToolSchemasForGoogle, sanitizeSessionHistory } from "./google.js";
|
import {
|
||||||
|
logToolSchemasForGoogle,
|
||||||
|
sanitizeSessionHistory,
|
||||||
|
sanitizeToolsForGoogle,
|
||||||
|
} from "./google.js";
|
||||||
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
||||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
@@ -183,7 +187,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||||
});
|
});
|
||||||
const runAbortController = new AbortController();
|
const runAbortController = new AbortController();
|
||||||
const tools = createClawdbotCodingTools({
|
const toolsRaw = createClawdbotCodingTools({
|
||||||
exec: {
|
exec: {
|
||||||
...resolveExecToolDefaults(params.config),
|
...resolveExecToolDefaults(params.config),
|
||||||
elevated: params.bashElevated,
|
elevated: params.bashElevated,
|
||||||
@@ -200,6 +204,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
modelId,
|
modelId,
|
||||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||||
});
|
});
|
||||||
|
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
|
||||||
logToolSchemasForGoogle({ tools, provider });
|
logToolSchemasForGoogle({ tools, provider });
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
const runtimeChannel = normalizeMessageChannel(
|
const runtimeChannel = normalizeMessageChannel(
|
||||||
@@ -303,6 +308,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
modelApi: model.api,
|
modelApi: model.api,
|
||||||
modelId,
|
modelId,
|
||||||
|
provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { sanitizeToolsForGoogle } from "./google.js";
|
||||||
|
|
||||||
|
describe("sanitizeToolsForGoogle", () => {
|
||||||
|
it("strips unsupported schema keywords for Google providers", () => {
|
||||||
|
const tool = {
|
||||||
|
name: "test",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
foo: {
|
||||||
|
type: "string",
|
||||||
|
format: "uuid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async () => ({ ok: true, content: [] }),
|
||||||
|
} as unknown as AgentTool;
|
||||||
|
|
||||||
|
const [sanitized] = sanitizeToolsForGoogle({
|
||||||
|
tools: [tool],
|
||||||
|
provider: "google-gemini-cli",
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = sanitized.parameters as {
|
||||||
|
additionalProperties?: unknown;
|
||||||
|
properties?: Record<string, { format?: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.additionalProperties).toBeUndefined();
|
||||||
|
expect(params.properties?.foo?.format).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { TSchema } from "@sinclair/typebox";
|
||||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||||
@@ -14,6 +15,7 @@ import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
|||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { describeUnknownError } from "./utils.js";
|
import { describeUnknownError } from "./utils.js";
|
||||||
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
||||||
|
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
||||||
|
|
||||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||||
@@ -80,17 +82,39 @@ function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[]
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizeToolsForGoogle<
|
||||||
|
TSchemaType extends TSchema = TSchema,
|
||||||
|
TResult = unknown,
|
||||||
|
>(params: {
|
||||||
|
tools: AgentTool<TSchemaType, TResult>[];
|
||||||
|
provider: string;
|
||||||
|
}): AgentTool<TSchemaType, TResult>[] {
|
||||||
|
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
||||||
|
return params.tools;
|
||||||
|
}
|
||||||
|
return params.tools.map((tool) => {
|
||||||
|
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
parameters: cleanToolSchemaForGemini(
|
||||||
|
tool.parameters as Record<string, unknown>,
|
||||||
|
) as TSchemaType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) {
|
export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) {
|
||||||
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
|
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
|
||||||
|
const tools = sanitizeToolsForGoogle(params);
|
||||||
log.info("google tool schema snapshot", {
|
log.info("google tool schema snapshot", {
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
toolCount: params.tools.length,
|
toolCount: tools.length,
|
||||||
tools: toolNames,
|
tools: toolNames,
|
||||||
});
|
});
|
||||||
for (const [index, tool] of params.tools.entries()) {
|
for (const [index, tool] of tools.entries()) {
|
||||||
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
|
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
log.warn("google tool schema has unsupported keywords", {
|
log.warn("google tool schema has unsupported keywords", {
|
||||||
@@ -164,22 +188,34 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
modelApi?: string | null;
|
modelApi?: string | null;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
provider?: string;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||||
|
const provider = (params.provider ?? "").toLowerCase();
|
||||||
|
const modelId = (params.modelId ?? "").toLowerCase();
|
||||||
|
const isOpenRouterGemini =
|
||||||
|
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
||||||
|
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
|
||||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||||
|
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||||
|
? { allowBase64Only: true, includeCamelCase: true }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||||
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
|
const isAntigravityProvider =
|
||||||
|
provider === "google-antigravity" || params.modelApi === "google-antigravity";
|
||||||
|
const shouldDowngradeThinking = isGeminiLike && !isAntigravityClaudeModel;
|
||||||
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||||
const downgradedThinking = shouldDowngradeGemini
|
const downgradedThinking = shouldDowngradeThinking
|
||||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||||
: repairedTools;
|
: repairedTools;
|
||||||
const downgraded = shouldDowngradeGemini
|
const shouldDowngradeHistory = shouldDowngradeThinking && !isAntigravityProvider;
|
||||||
|
const downgraded = shouldDowngradeHistory
|
||||||
? downgradeGeminiHistory(downgradedThinking)
|
? downgradeGeminiHistory(downgradedThinking)
|
||||||
: downgradedThinking;
|
: downgradedThinking;
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,8 @@ export async function runEmbeddedPiAgent(
|
|||||||
provider,
|
provider,
|
||||||
model: model.id,
|
model: model.id,
|
||||||
},
|
},
|
||||||
|
systemPromptReport: attempt.systemPromptReport,
|
||||||
|
error: { kind: "role_ordering", message: errorText },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../
|
|||||||
import { isAbortError } from "../abort.js";
|
import { isAbortError } from "../abort.js";
|
||||||
import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
||||||
import { applyExtraParamsToAgent } from "../extra-params.js";
|
import { applyExtraParamsToAgent } from "../extra-params.js";
|
||||||
import { logToolSchemasForGoogle, sanitizeSessionHistory } from "../google.js";
|
import {
|
||||||
|
logToolSchemasForGoogle,
|
||||||
|
sanitizeSessionHistory,
|
||||||
|
sanitizeToolsForGoogle,
|
||||||
|
} from "../google.js";
|
||||||
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
|
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
|
||||||
import { log } from "../logger.js";
|
import { log } from "../logger.js";
|
||||||
import { buildModelAliasLines } from "../model.js";
|
import { buildModelAliasLines } from "../model.js";
|
||||||
@@ -127,7 +131,7 @@ export async function runEmbeddedAttempt(
|
|||||||
|
|
||||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||||
|
|
||||||
const tools = createClawdbotCodingTools({
|
const toolsRaw = createClawdbotCodingTools({
|
||||||
exec: {
|
exec: {
|
||||||
...resolveExecToolDefaults(params.config),
|
...resolveExecToolDefaults(params.config),
|
||||||
elevated: params.bashElevated,
|
elevated: params.bashElevated,
|
||||||
@@ -148,6 +152,7 @@ export async function runEmbeddedAttempt(
|
|||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
hasRepliedRef: params.hasRepliedRef,
|
hasRepliedRef: params.hasRepliedRef,
|
||||||
});
|
});
|
||||||
|
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
|
||||||
logToolSchemasForGoogle({ tools, provider: params.provider });
|
logToolSchemasForGoogle({ tools, provider: params.provider });
|
||||||
|
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
@@ -318,6 +323,7 @@ export async function runEmbeddedAttempt(
|
|||||||
messages: activeSession.messages,
|
messages: activeSession.messages,
|
||||||
modelApi: params.model.api,
|
modelApi: params.model.api,
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
|
provider: params.provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
|
|||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
systemPromptReport?: SessionSystemPromptReport;
|
systemPromptReport?: SessionSystemPromptReport;
|
||||||
error?: {
|
error?: {
|
||||||
kind: "context_overflow" | "compaction_failure";
|
kind: "context_overflow" | "compaction_failure" | "role_ordering";
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "start" },
|
data: { phase: "start" },
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
ctx.state.compactionInFlight = true;
|
ctx.state.compactionInFlight = true;
|
||||||
ctx.ensureCompactionPromise();
|
ctx.ensureCompactionPromise();
|
||||||
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
|
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "compaction",
|
stream: "compaction",
|
||||||
data: { phase: "start" },
|
data: { phase: "start" },
|
||||||
});
|
});
|
||||||
@@ -43,7 +43,7 @@ export function handleAutoCompactionEnd(
|
|||||||
} else {
|
} else {
|
||||||
ctx.maybeResolveCompactionWait();
|
ctx.maybeResolveCompactionWait();
|
||||||
}
|
}
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "compaction",
|
stream: "compaction",
|
||||||
data: { phase: "end", willRetry },
|
data: { phase: "end", willRetry },
|
||||||
});
|
});
|
||||||
@@ -59,7 +59,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function handleMessageUpdate(
|
|||||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
data: {
|
data: {
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "./pi-embedded-subscribe.tools.js";
|
} from "./pi-embedded-subscribe.tools.js";
|
||||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||||
|
|
||||||
export function handleToolExecutionStart(
|
export async function handleToolExecutionStart(
|
||||||
ctx: EmbeddedPiSubscribeContext,
|
ctx: EmbeddedPiSubscribeContext,
|
||||||
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
|
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
|
||||||
) {
|
) {
|
||||||
@@ -53,7 +53,8 @@ export function handleToolExecutionStart(
|
|||||||
args: args as Record<string, unknown>,
|
args: args as Record<string, unknown>,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
// Await onAgentEvent to ensure typing indicator starts before tool summaries are emitted.
|
||||||
|
await ctx.params.onAgentEvent?.({
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
data: { phase: "start", name: toolName, toolCallId },
|
data: { phase: "start", name: toolName, toolCallId },
|
||||||
});
|
});
|
||||||
@@ -108,7 +109,7 @@ export function handleToolExecutionUpdate(
|
|||||||
partialResult: sanitized,
|
partialResult: sanitized,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
data: {
|
data: {
|
||||||
phase: "update",
|
phase: "update",
|
||||||
@@ -170,7 +171,7 @@ export function handleToolExecutionEnd(
|
|||||||
result: sanitizedResult,
|
result: sanitizedResult,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
data: {
|
data: {
|
||||||
phase: "result",
|
phase: "result",
|
||||||
|
|||||||
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user