Compare commits

..
Author SHA1 Message Date
Peter Steinberger 9a14267dfa chore: update appcast 2026-01-21 08:25:20 +00:00
Peter Steinberger 010d305401 chore: tidy package files list 2026-01-21 08:25:01 +00:00
Peter Steinberger 3210c91f6b chore: release 2026.1.20 2026-01-21 08:23:49 +00:00
Peter Steinberger e3cea55d72 docs: add npm files check to release checklist 2026-01-21 08:10:53 +00:00
Peter Steinberger 687a902f3e fix: align chat composer 2026-01-21 07:48:00 +00:00
Peter Steinberger fe860de148 fix: quiet update banner and skip duplicate plugin CLI 2026-01-21 07:37:22 +00:00
Peter Steinberger bc8a59faa4 chore: release 2026.1.20-1 2026-01-21 07:37:22 +00:00
Peter Steinberger 91bcdad503 fix: guard anthropic refusal trigger 2026-01-21 07:28:49 +00:00
Peter Steinberger ab97c6880b Merge pull request #1360 from SocialNerd42069/fix/duplicate-assistant-texts
fix: prevent duplicate assistant texts from whitespace differences
2026-01-21 06:31:01 +00:00
Peter Steinberger 65dd73b4c3 fix: clean up slack threading landings (#1360) (thanks @SocialNerd42069) 2026-01-21 06:29:36 +00:00
SocialNerd42069 b69aa011fe Add auto-notify on completion to coding-agent skill 2026-01-21 06:29:36 +00:00
SocialNerd42069 e3a44b10bc fix: prevent duplicate assistant texts from whitespace differences
- Add per-message dedup tracking in subscribeEmbeddedPiSession
- Compare both trimmed and normalized text to catch near-duplicates
- Reset dedup state on each new assistant message
- Add test for trailing whitespace edge case

Fixes duplicate Slack message delivery when the same text appears
with minor whitespace differences (e.g., trailing newline).
2026-01-21 06:29:36 +00:00
SocialNerd42069 5b8007784b fix(slack): handle Bolt ESM/CJS import for Node 25.x
The slackBoltModule.default points to App class directly on Node 25.x,
not the module object. Check for App property first before using default.
2026-01-21 06:29:36 +00:00
SocialNerd42069 0d6e78b718 fix(slack): respect verbose setting and preserve thread context for tool notifications
Fixes two bugs in Slack tool notification delivery:

1. Tool notifications ignored verbose=false - normalized verbose values so
   boolean false/'false' are properly treated as 'off'

2. Thread context lost - Slack outbound adapter now falls back to threadId
   when replyToId is missing, and MessageThreadId is set for thread replies

Closes #1333
2026-01-21 06:29:36 +00:00
SocialNerd42069 46ab4cb19e my local tweaks 2026-01-21 06:29:36 +00:00
Peter Steinberger 32edaad823 fix: address update cli type import 2026-01-21 06:10:27 +00:00
Peter Steinberger 5dcd48544a feat: align update channel installs 2026-01-21 06:00:54 +00:00
Peter Steinberger 1e05925e47 fix: normalize model override auth handling 2026-01-21 06:00:21 +00:00
Peter Steinberger fb47f1cbeb chore: rename clawlog references 2026-01-21 05:53:32 +00:00
Peter Steinberger 15d1421cf2 Merge pull request #1357 from vignesh07/fix/node-invoke-timeout
fix(node): enforce timeout for node.invoke handlers
2026-01-21 05:49:36 +00:00
Peter Steinberger 899bbd40d7 Merge pull request #1358 from vignesh07/fix/ios-talkmode-simulator
fix(ios): prevent Talk mode crash on simulator
2026-01-21 05:42:17 +00:00
Peter Steinberger 555b2578a8 feat: add /allowlist command 2026-01-21 05:34:53 +00:00
Peter Steinberger 0229b8bbd8 docs: expand 2026.1.20 highlights 2026-01-21 05:34:29 +00:00
Peter Steinberger 552f9eff7b docs: add 2026.1.20 highlight 2026-01-21 05:31:37 +00:00
Peter Steinberger 36e0cffaaf fix: stabilize directory cli output 2026-01-21 05:25:28 +00:00
Peter Steinberger e17a9c6abf docs: expand 2026.1.20 changelog 2026-01-21 05:24:23 +00:00
Peter Steinberger 6180603ef4 feat: improve doctor update flow 2026-01-21 05:23:37 +00:00
Peter Steinberger 810374d648 fix: align cli output tests and help examples 2026-01-21 05:20:31 +00:00
Peter Steinberger 968b967854 Merge pull request #1354 from vignesh07/fix/gateway-ios-client-id
fix(gateway): allow clawdbot-ios client id
2026-01-21 05:09:15 +00:00
Peter Steinberger 110079d99d fix: guard nodes status duration parsing (#1354) (thanks @vignesh07) 2026-01-21 05:07:27 +00:00
Peter Steinberger 34a126a6d7 fix: allow mobile node client ids (#1354) (thanks @vignesh07) 2026-01-21 05:07:26 +00:00
Vignesh Natarajan 31462f64d8 fix: allow clawdbot-ios gateway client id
The iOS app currently identifies as clientId=clawdbot-ios when
connecting in node mode. Add this ID to the allowed gateway client
IDs so the handshake schema accepts it.

Also fixes a TS strictness issue in auto-reply status formatting
(parts filter) that caused
> clawdbot@2026.1.20 build /Users/vignesh/clawd/clawdbot-upstream
> tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts

[copy-hook-metadata] Copied boot-md/HOOK.md
[copy-hook-metadata] Copied command-logger/HOOK.md
[copy-hook-metadata] Copied session-memory/HOOK.md
[copy-hook-metadata] Copied soul-evil/HOOK.md
[copy-hook-metadata] Done to fail.
2026-01-21 05:07:26 +00:00
Peter Steinberger de0a488985 refactor: unify gateway connectivity state 2026-01-21 05:01:32 +00:00
Peter Steinberger 15f16de651 docs: update nodes list/status flags 2026-01-21 04:52:54 +00:00
Vignesh Natarajan b46855d8c4 fix(ios): prevent Talk mode crash on simulator
- Disable Talk mode start on iOS simulator (no audio input)
- Validate audio input format before installing tap to avoid
  AVFAudio assertion crashes on misconfigured devices.

Tested:
- Launched app on iOS simulator and tapping Talk no longer crashes
  (shows error path instead).
2026-01-20 20:52:42 -08:00
Vignesh Natarajan feaad8250b fix(node): enforce node.invoke timeout in node client
Use the timeout provided on node invoke requests to ensure node
clients always respond with a result.

This prevents gateway-side node.invoke calls from hanging until the
gateway timeout when a node command stalls.

Tests:
- swift test --filter GatewayNodeSessionTests
2026-01-20 20:50:20 -08:00
Peter Steinberger fa7df1976d feat: theme hooks/skills/plugins output 2026-01-21 04:48:34 +00:00
Peter Steinberger 2cd62f94a5 feat: tableize device/directory outputs 2026-01-21 04:48:33 +00:00
Peter Steinberger a74c19feed docs: unify cli help examples 2026-01-21 04:48:33 +00:00
Peter Steinberger 1ad4a7194e fix: allow node exec fallback and defer node approvals 2026-01-21 04:46:50 +00:00
Peter Steinberger beec504ebd feat: filter nodes list/status 2026-01-21 04:39:15 +00:00
Peter Steinberger fe1133e2c5 Merge pull request #1348 from vignesh07/feat/tui-input-history
feat(tui): add input history (↑/↓) for submitted messages
2026-01-21 04:37:49 +00:00
Peter Steinberger 6f37f1d8ff fix: record tui input history (#1348) (thanks @vignesh07) 2026-01-21 04:37:22 +00:00
Peter Steinberger 57700f33a9 fix: record node last-connect by instance id 2026-01-21 04:32:53 +00:00
Vignesh Natarajan 2700794228 feat(tui): add input history for submitted messages (WIP)
Record submitted inputs in the editor history so up/down arrow
can recall previous messages.

Adds a small helper to wire submit handling and unit tests for
routing/recording behavior.

No PR yet (per request).
2026-01-21 04:31:33 +00:00
dependabot[bot] 416894c642 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.3 to 7.5.4
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.3...v7.5.4)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-21 04:22:46 +00:00
Peter Steinberger db88378ae3 fix: normalize node invoke result payloadJSON 2026-01-21 04:17:35 +00:00
Peter Steinberger e97b4973bb docs: clarify node host exec flow 2026-01-21 04:14:34 +00:00
Peter Steinberger 832dfb02fe fix: omit null payloadJSON in node invoke results 2026-01-21 04:13:47 +00:00
Peter Steinberger 15e3a2a395 fix: sanitize node invoke result params 2026-01-21 04:13:47 +00:00
Peter Steinberger 8c472c210f docs: colorize update help 2026-01-21 04:08:50 +00:00
Peter Steinberger 833bbcd166 fix: show subcommand help on --help 2026-01-21 04:08:50 +00:00
Peter Steinberger d7440baef6 docs: clarify update help 2026-01-21 04:08:50 +00:00
Peter Steinberger 58b131919f feat: use tsgo for dev/watch builds 2026-01-21 04:06:09 +00:00
Peter Steinberger 186e86660a Merge pull request #1350 from Jackten/fix/fallback-authprofile-provider-scope
test(auto-reply): regression for authProfileId across provider fallback
2026-01-21 03:53:07 +00:00
Peter Steinberger 18d47b47d2 Merge pull request #1349 from siddhantjain/fix/immediate-callback-answer
fix(telegram): answer callback queries immediately to prevent retries
2026-01-21 03:51:50 +00:00
Peter Steinberger eb1e2c7a3b fix: suppress node warnings unless verbose 2026-01-21 03:47:50 +00:00
Peter Steinberger 6ea4cb0012 fix: suppress npm fund prompts 2026-01-21 03:47:50 +00:00
Peter Steinberger 184f5a5fc3 fix: suppress update deprecation warnings 2026-01-21 03:47:50 +00:00
Peter Steinberger 4ad359ffcd feat: add non-interactive update option 2026-01-21 03:47:50 +00:00
Peter Steinberger 38cc2a3288 fix: guard media status parts filter 2026-01-21 03:43:42 +00:00
Peter Steinberger 28c49db494 fix: default exec security to allowlist 2026-01-21 03:40:27 +00:00
Peter Steinberger 026e6c4df4 fix: restore bundled plugin discovery 2026-01-21 03:40:05 +00:00
Peter Steinberger 841dfc693e style: flatten approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger f38278d919 style: collapse approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger 9545edcb49 style: tighten approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger f3554a3ad8 feat: render approvals get as table 2026-01-21 03:36:54 +00:00
Peter Steinberger b30359e9cd fix(macos): ignore launchd token in remote mode 2026-01-21 03:34:51 +00:00
Peter Steinberger d3898ee8df test(macos): cover gateway host resolution 2026-01-21 03:34:51 +00:00
Peter Steinberger d1c2fc4bc8 fix: hide empty status rows 2026-01-21 03:32:16 +00:00
Peter Steinberger a5a3ab958f fix: skip bundled plugin discovery in tests 2026-01-21 03:29:03 +00:00
Peter Steinberger 165861e78d feat: add approvals allowlist examples 2026-01-21 03:27:50 +00:00
Peter Steinberger e7c355ee85 feat: group service help examples by submenu 2026-01-21 03:27:50 +00:00
Peter Steinberger 052a58f2f7 style: tighten nodes pending row 2026-01-21 03:27:50 +00:00
Peter Steinberger 5ff56ffb4e style: compress nodes pending age 2026-01-21 03:27:50 +00:00
Peter Steinberger 9a3dd626a1 style: format nodes pending row 2026-01-21 03:27:50 +00:00
Peter Steinberger aae4b2952f feat: polish nodes cli output 2026-01-21 03:27:50 +00:00
Peter Steinberger aec622fe63 chore: remove fresh dist log 2026-01-21 03:13:50 +00:00
Peter Steinberger e6287270d9 feat: render nodes status as table 2026-01-21 03:11:27 +00:00
Vultr-Clawd Admin c05a7b5390 test(auto-reply): drop auth profile on provider fallback 2026-01-20 23:07:33 -04:00
Siddhant Jain 020fecef5c fix(telegram): answer callback queries immediately to prevent retries
Telegram retries callback queries if they aren't acknowledged quickly.
Previously, answerCallbackQuery was called in a finally block AFTER
processing, which could take several seconds for agent responses.

This change moves answerCallbackQuery to immediately after basic
validation, before any processing begins. This prevents Telegram
from sending duplicate callbacks while the agent is thinking.

Fixes duplicate callback handling when agent processing is slow.
2026-01-21 03:04:28 +00:00
Peter Steinberger caf9dec89c feat: add nodes list table with last connect 2026-01-21 03:03:48 +00:00
Peter Steinberger 438a41f91f refactor: harden log stream writes 2026-01-21 03:03:29 +00:00
Peter SteinbergerandSeb Slight a0cd295c0f fix: add browser snapshot default mode (#1336)
Co-authored-by: Seb Slight <sbarrios93@gmail.com>
2026-01-21 03:03:10 +00:00
Peter Steinberger 14d3d72bcc refactor(ui): reuse emoji icon helpers 2026-01-21 02:58:56 +00:00
Peter SteinbergerandBradley Priest 03916ed10e feat(ui): add copy-as-markdown in chat
Co-authored-by: Bradley Priest <bradleypriest@users.noreply.github.com>
2026-01-21 02:58:56 +00:00
Peter SteinbergerandEli 5bd55037e4 fix: harden web fetch SSRF and redirects
Co-authored-by: Eli <fogboots@users.noreply.github.com>
2026-01-21 02:54:14 +00:00
Peter Steinberger ec51bb700c Merge pull request #1314 from dbhurley/fix/control-ui-token-auth
fix: allow token auth to bypass device identity requirement
2026-01-21 02:44:47 +00:00
Peter Steinberger 051d518078 Merge pull request #1318 from sebslight/fix/logs-follow-output
CLI: avoid logs --follow echo
2026-01-21 02:38:14 +00:00
Peter Steinberger 294886b54f fix(macos): return bind host 2026-01-21 02:29:41 +00:00
Peter Steinberger 6629e31789 Merge pull request #1322 from KrauseFx/fix/cron-edit-preserve-delivery-on-message
Fix(cli): Preserve delivery settings when updating message via cron edit
2026-01-21 02:29:20 +00:00
Peter SteinbergerandVignesh 9d7087168f fix(gateway): improve validation errors (#1347)
Thanks @vignesh07.

Co-authored-by: Vignesh <vignesh07@users.noreply.github.com>
2026-01-21 02:28:33 +00:00
Vignesh Natarajan daceeaa24c fix(gateway): clarify schema validation errors
Improve validation error formatting for strict schemas.
In particular, additionalProperties errors now surface the
unexpected property name and where it occurred, which makes
handshake/connect failures easier to debug.
2026-01-21 02:28:33 +00:00
Peter Steinberger 778800be70 fix(macos): prefer tailnet ip for auto bind 2026-01-21 02:28:21 +00:00
Peter Steinberger 1b973caf7a fix: preserve cron edit delivery payloads (#1322) (thanks @KrauseFx) 2026-01-21 02:27:18 +00:00
ClawdFx ea775025c0 Run oxfmt formatting 2026-01-21 02:18:43 +00:00
ClawdFx 0b2830470c Fix: Preserve delivery settings when updating message via cron edit
- Add failing tests for delivery field preservation
- Fix register.cron-edit to conditionally build payload object
- Only include delivery fields (deliver, channel, to, bestEffortDeliver) when explicitly provided
- Previously undefined values were included, wiping out existing delivery settings
- Now --message alone preserves existing delivery config
- Tests verify both preservation and explicit override scenarios
2026-01-21 02:18:33 +00:00
Peter Steinberger e81ca7ab00 fix: tame invalid config logging 2026-01-21 01:58:47 +00:00
Peter Steinberger 27acfa59c5 chore: update a2ui bundle 2026-01-21 01:33:31 +00:00
Peter Steinberger b333c4a994 fix: address config settings viewbuilder updates 2026-01-21 01:33:31 +00:00
Peter Steinberger 23f7dd8b25 fix: pass threadId in restart sentinel 2026-01-21 01:26:15 +00:00
Peter Steinberger 77d9451712 docs: fix mdx in security cli doc 2026-01-21 01:24:29 +00:00
Peter Steinberger a4fc2b4536 fix: handle fetch streams safely 2026-01-21 01:24:29 +00:00
Peter Steinberger 20a7dd8a80 feat: add config subsections in control ui 2026-01-21 01:22:19 +00:00
Peter Steinberger 450d2d25e2 feat: add sectioned config layout in mac app 2026-01-21 01:22:19 +00:00
Peter Steinberger df024afc97 fix: note gatewayUrl param in control ui (#1342) (thanks @ameno-) 2026-01-21 01:21:57 +00:00
Peter Steinberger 12168dc64f Merge pull request #1337 from John-Rood/fix/restart-channel-routing
fix: preserve channel routing across gateway restart
2026-01-21 01:21:35 +00:00
Peter Steinberger 4232081fcb Merge pull request #1342 from ameno-/fix-webchat-gateway-url
fix(ui): parse gatewayUrl from URL params
2026-01-21 01:20:25 +00:00
17f3635109 fix: preserve restart routing + thread replies (#1337) (thanks @John-Rood)
Co-authored-by: John-Rood <John-Rood@users.noreply.github.com>
Co-authored-by: Outdoor <outdoor@users.noreply.github.com>
2026-01-21 01:20:25 +00:00
Peter Steinberger 9206d21c76 fix: narrow cron payload merge types 2026-01-21 01:14:24 +00:00
Peter SteinbergerandFelix Krause 96be166bd6 fix: refactor cron edit payload patches
Co-authored-by: Felix Krause <869950+KrauseFx@users.noreply.github.com>
2026-01-21 01:14:24 +00:00
Peter SteinbergerandSeb Slight d8abd53a1d fix: reschedule heartbeat on hot reload
Co-authored-by: Seb Slight <sebslight@users.noreply.github.com>
2026-01-21 00:53:54 +00:00
Peter Steinberger eff292eda4 Merge pull request #1338 from MaudeCode/fix/message-command-plugins
fix(cli): load plugin registry for message/channels commands
2026-01-21 00:49:04 +00:00
Ameno OsmanandClaude Opus 4.5 c74551c2ae fix(ui): parse gatewayUrl from URL params for remote gateway access
Adds support for passing `gatewayUrl` as a URL parameter to the WebChat UI,
allowing the control-ui to connect to a remote gateway (e.g., VPS) instead
of defaulting to localhost.

Usage: http://localhost:5173/?gatewayUrl=ws://<vps-ip>:18789&token=<token>

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 16:35:02 -08:00
Peter Steinberger 48b0d08493 fix: resolve ReadableStream cast in skills install 2026-01-21 00:33:22 +00:00
Peter Steinberger dd38185e6c docs: add diagnostics changelog entry 2026-01-21 00:30:59 +00:00
Peter Steinberger ec01e5c7e6 fix: emit diagnostics across channels 2026-01-21 00:30:34 +00:00
Peter Steinberger e447233533 chore: sync protocol outputs 2026-01-21 00:19:53 +00:00
Peter Steinberger 00bcb01bb4 docs: clarify whatsapp voice notes and wsl portproxy 2026-01-21 00:14:36 +00:00
Peter Steinberger 458850483a feat: add sherpa-onnx-tts skill 2026-01-21 00:14:36 +00:00
Peter Steinberger 76bae8da40 feat: add download installs for skills 2026-01-21 00:14:36 +00:00
Peter Steinberger c33c0629ec fix: treat tailnet host as local for pairing 2026-01-21 00:14:15 +00:00
Peter Steinberger e5ea8a0d22 docs: add network hub + pairing locality 2026-01-21 00:14:14 +00:00
Peter SteinbergerandBradley Priest e083f678fd fix(ui): preserve ordered list numbering (#1341) - thanks @bradleypriest
Co-authored-by: Bradley Priest <bradleypriest@gmail.com>
2026-01-21 00:12:21 +00:00
Peter Steinberger 51dfd6efdb fix: tighten small-model audit guardrails 2026-01-20 23:52:26 +00:00
Peter Steinberger 4fad74738a fix: prefer loopback for auto bind fallback 2026-01-20 23:48:26 +00:00
Peter Steinberger 69f0469530 Merge pull request #1332 from clawdbot/temp/landpr-model-catalog-improvements
fix(model-catalog): improve cache resilience
2026-01-20 23:07:51 +00:00
Peter Steinberger eb1ee36f59 fix: relax diagnostic event typing (#1332) (thanks @steipete) 2026-01-20 23:07:28 +00:00
Peter Steinberger b341512564 fix: model catalog cache + TUI editor ctor (#1326) (thanks @dougvk) 2026-01-20 22:58:41 +00:00
Peter Steinberger 6734f2d71c fix: wire OTLP logs for diagnostics 2026-01-20 22:51:47 +00:00
Peter Steinberger e12abf3114 fix: update CustomEditor constructor 2026-01-20 22:36:06 +00:00
Maude Bot 4ad9622efb fix(cli): load plugin registry for message/channels commands
Fixes #1327 - 'clawdbot message --channel telegram' fails with
'Unknown channel: telegram' because plugins weren't loaded.

The Commander code path (non-route-first) calls ensureConfigReady() in
preAction but doesn't load the plugin registry. Channel plugins like
telegram are registered during plugin loading, so getChannelPlugin()
returns undefined without it.

This adds ensurePluginRegistryLoaded() call for commands that need
channel plugin access: message, channels, directory.
2026-01-20 16:41:15 -05:00
Peter Steinberger 2dfd3b9a81 chore: drop nostr node_modules links 2026-01-20 20:15:56 +00:00
Peter Steinbergerandjoelklabo 7b6cbf5869 feat: add Nostr channel plugin and onboarding install defaults
Co-authored-by: joelklabo <joelklabo@users.noreply.github.com>
2026-01-20 20:15:56 +00:00
Peter Steinberger 8686b3b951 Merge pull request #1326 from dougvk/fix/model-catalog-cache-poison
fix(model-catalog): avoid caching import failures
2026-01-20 20:14:52 +00:00
Peter Steinberger 2e7e135bc0 fix: config form semantics + editor ctor (#1315) (thanks @MaudeBot) 2026-01-20 20:14:22 +00:00
Peter Steinberger c287664923 Merge pull request #1315 from MaudeCode/feat/config-ui-sections
feat(ui): config page overhaul with sidebar nav, search, and improved fields
2026-01-20 20:12:42 +00:00
Doug von Kohorn e4f9555f21 fix(model-catalog): avoid caching import failures
Move dynamic import of @mariozechner/pi-coding-agent into the try/catch so transient module resolution errors don't poison the model catalog cache with a rejected promise.

This previously caused Discord/Telegram handlers and heartbeat to fail until process restart if the import failed once.
2026-01-20 20:09:55 +01:00
Sebastian Slight 7e59c15496 CLI: avoid logs follow echo 2026-01-20 11:51:53 -05:00
Maude Bot e74fd9196c feat(ui): add icons for all config sections
Added SVG icons for: meta, logging, browser, ui, models, bindings,
broadcast, audio, session, cron, web, discovery, canvasHost, talk, plugins

Also added descriptions for all new sections in metadata.
2026-01-20 11:47:22 -05:00
David HurleyandClaude Opus 4.5 079af0d0b0 fix: allow token auth to bypass device identity requirement
The device identity check was rejecting connections before token
authentication could be attempted. This broke the control-ui (web UI)
which uses token-based authentication via URL parameter.

Changes:
- Skip device identity requirement when a token is provided
- Guard device token verification to only run when device is present

Fixes control-ui showing "device identity required" error when
connecting with a valid token.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:46:47 -05:00
Maude Bot f6abe62e5f feat(ui): major config form UX overhaul
Sidebar:
- SVG icons instead of emoji (consistent rendering)
- Clean navigation with active states

Form fields completely redesigned:
- Toggle rows: full-width clickable with label + description
- Segmented controls: for enum values with ≤5 options
- Number inputs: with +/- stepper buttons
- Text inputs: with reset-to-default button
- Select dropdowns: clean styling with custom arrow
- Arrays: card-based with clear add/remove, item numbering
- Objects: collapsible sections with chevron animation
- Maps: key-value editor with inline editing

Visual improvements:
- Consistent border radius and spacing
- Better color contrast for labels vs help text
- Hover and focus states throughout
- Icons for common actions (add, remove, reset)

Mobile:
- Horizontal scrolling nav on small screens
- Stacked layouts for complex fields
2026-01-20 11:40:13 -05:00
Maude Bot bd8f4b052d chore: remove duplicate config styles from components.css 2026-01-20 11:29:19 -05:00
Maude Bot 929d50b7d1 feat(ui): complete config page overhaul with sidebar nav, search, toggles, and diff view
Major redesign of the config page:

Layout:
- Sidebar navigation with section list
- Search input to filter settings
- Section cards with icons and descriptions
- Responsive design for mobile (stacked layout)

Fields:
- New toggle switches for booleans (replaces checkboxes)
- Improved field-row layout with label, help text, and control
- Better fieldset and array styling

Features:
- Diff view showing pending changes before save
- Original value tracking for comparison
- Section filtering via sidebar nav
- Search across setting names, descriptions, and nested properties

Styling:
- Dedicated config.css with all new styles
- Dark and light theme support
- Smooth animations and transitions
- Mobile-first responsive breakpoints
2026-01-20 11:28:41 -05:00
Maude Bot 716546824f feat(ui): improve config page with collapsible sections
- Group config settings into logical sections (Core, Agents, Communication, etc.)
- Add collapsible accordion UI for each section group
- Add icons and labels for each config category
- Improve mobile responsiveness with better button layout
- Style improvements for nested fieldsets and arrays
2026-01-20 10:56:44 -05:00
314 changed files with 35913 additions and 17710 deletions

No files matched your search

+1 -1
View File
@@ -100,7 +100,7 @@
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
+25 -43
View File
@@ -2,53 +2,39 @@
Docs: https://docs.clawd.bot
## 2026.1.21
### Fixes
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
## 2026.1.20-1
### Fixes
- Install: include pnpm patch files in the npm package to avoid postinstall failures.
## 2026.1.20
### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
### Highlights
- Installer: npm packages now ship pnpm patch files again, fixing `curl | bash` installs.
### Changes
- Agents: add auto-notify-on-completion guidance for coding-agent background runs.
- Build: remove the legacy Peekaboo submodule pointer (SPM release already in use).
### Fixes
- Installer: ship pnpm patch files in the npm tarball so postinstall patches apply correctly.
- Agents: suppress duplicate assistant text blocks that only differ in trailing whitespace; add a regression test.
- Slack: fix Bolt ESM/CJS import resolution on Node 25.x and remove duplicate thread metadata. (#1360) — thanks @SocialNerd42069.
- CLI: fix a duplicate UpdateStepResult import that broke `pnpm build`.
- macOS: mark Tailscale IP fallback helpers nonisolated to fix Swift 6.2 build failures.
## 2026.1.19-3
### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
- Slack: respect verbose tool summaries and keep tool notifications threaded. (#1360) — thanks @SocialNerd42069.
## 2026.1.19-2
@@ -67,11 +53,9 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
@@ -80,11 +64,9 @@ Docs: https://docs.clawd.bot
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5
@@ -110,8 +92,8 @@ Docs: https://docs.clawd.bot
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall.
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
+23 -23
View File
@@ -479,27 +479,27 @@ Core contributors:
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a>
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
+29 -172
View File
@@ -2,6 +2,35 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.20</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7116</sparkle:version>
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
<h3>Highlights</h3>
<ul>
<li>Installer: npm packages now ship pnpm patch files again, fixing <code>curl | bash</code> installs.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Agents: add auto-notify-on-completion guidance for coding-agent background runs.</li>
<li>Build: remove the legacy Peekaboo submodule pointer (SPM release already in use).</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Installer: ship pnpm patch files in the npm tarball so postinstall patches apply correctly.</li>
<li>Agents: suppress duplicate assistant text blocks that only differ in trailing whitespace; add a regression test.</li>
<li>Slack: fix Bolt ESM/CJS import resolution on Node 25.x and remove duplicate thread metadata. (#1360) — thanks @SocialNerd42069.</li>
<li>CLI: fix a duplicate UpdateStepResult import that broke <code>pnpm build</code>.</li>
<li>macOS: mark Tailscale IP fallback helpers nonisolated to fix Swift 6.2 build failures.</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.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
@@ -99,177 +128,5 @@
]]></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>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</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: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</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.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
</channel>
</rss>
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(),
commands = buildInvokeCommands(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(),
)
}
@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
}
private func startRecognition() throws {
#if targetEnvironment(simulator)
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
])
#endif
self.stopRecognition()
self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else {
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
let input = self.audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format",
])
}
input.removeTap(onBus: 0)
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
+321 -24
View File
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView {
self.content
HStack(spacing: 16) {
self.sidebar
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema()
await self.store.loadConfig()
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
}
}
extension ConfigSettings {
private var content: some View {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
Group {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode)
} else {
Text("Schema unavailable.")
private enum SubsectionSelection: Hashable {
case all
case key(String)
}
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
var id: String { self.key }
}
private struct ConfigSubsection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
}
}
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}
@ViewBuilder
@@ -71,6 +158,18 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
@@ -85,6 +184,204 @@ extension ConfigSettings {
}
.buttonStyle(.bordered)
}
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
struct ConfigSettings_Previews: PreviewProvider {
@@ -87,15 +87,7 @@ final class ControlChannel {
func configure() async {
self.logger.info("control channel configure mode=local")
self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
await self.refreshEndpoint(reason: "configure")
}
func configure(mode: Mode = .local) async throws {
@@ -111,7 +103,7 @@ final class ControlChannel {
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
await self.refreshEndpoint(reason: "configure")
} catch {
self.state = .degraded(error.localizedDescription)
throw error
@@ -119,6 +111,19 @@ final class ControlChannel {
}
}
func refreshEndpoint(reason: String) async {
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
self.state = .connecting
do {
try await self.establishGatewayConnection()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
}
func disconnect() async {
await GatewayConnection.shared.shutdown()
self.state = .disconnected
@@ -275,18 +280,28 @@ final class ControlChannel {
}
}
do {
try await GatewayConnection.shared.refresh()
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
if case .connected = self.state {
self.logger.info("control channel recovery finished")
} catch {
self.logger.error(
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
} else if case let .degraded(message) = self.state {
self.logger.error("control channel recovery failed \(message, privacy: .public)")
}
self.recoveryTask = nil
}
}
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
try await GatewayConnection.shared.refresh()
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
if ok == false {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params
merged["text"] = AnyHashable(text)
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
@@ -148,6 +148,27 @@ actor GatewayConnection {
}
}
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError
case .remote:
let nsError = error as NSError
@@ -0,0 +1,63 @@
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class GatewayConnectivityCoordinator {
static let shared = GatewayConnectivityCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
private var endpointTask: Task<Void, Never>?
private var lastResolvedURL: URL?
private(set) var endpointState: GatewayEndpointState?
private(set) var resolvedURL: URL?
private(set) var resolvedMode: AppState.ConnectionMode?
private(set) var resolvedHostLabel: String?
private init() {
self.start()
}
func start() {
guard self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
await MainActor.run { self.handleEndpointState(state) }
}
}
}
var localEndpointHostLabel: String? {
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
return Self.hostLabel(for: url)
}
private func handleEndpointState(_ state: GatewayEndpointState) {
self.endpointState = state
switch state {
case let .ready(mode, url, _, _):
self.resolvedMode = mode
self.resolvedURL = url
self.resolvedHostLabel = Self.hostLabel(for: url)
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
if urlChanged {
self.lastResolvedURL = url
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
}
case let .connecting(mode, _):
self.resolvedMode = mode
case let .unavailable(mode, _):
self.resolvedMode = mode
}
}
private static func hostLabel(for url: URL) -> String {
let host = url.host ?? url.absoluteString
if let port = url.port { return "\(host):\(port)" }
return host
}
}
@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
@@ -172,6 +173,10 @@ actor GatewayEndpointStore {
return configToken
}
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
@@ -469,6 +474,36 @@ actor GatewayEndpointStore {
}
}
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
let mode = await self.deps.mode()
guard mode == .local else { return nil }
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: root,
env: ProcessInfo.processInfo.environment)
let port = self.deps.localPort()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
self.setState(.ready(mode: .local, url: url, token: token, password: password))
return (url, token, password)
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
@@ -524,12 +559,17 @@ actor GatewayEndpointStore {
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
case "tailnet":
return tailscaleIP ?? "127.0.0.1"
case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty {
return tailscaleIP
}
return "127.0.0.1"
case "custom":
customBindHost ?? "127.0.0.1"
return customBindHost ?? "127.0.0.1"
default:
"127.0.0.1"
return "127.0.0.1"
}
}
}
@@ -600,11 +640,12 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
tailscaleIP: String?,
customBindHost: String? = nil) -> String
{
self.resolveLocalGatewayHost(
bindMode: bindMode,
customBindHost: nil,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
}
}
@@ -235,8 +235,8 @@ final class HealthStore {
let lower = error.lowercased()
if lower.contains("connection refused") {
let port = GatewayEnvironment.gatewayPort()
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
"restart Clawdbot to bring it back."
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
}
if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
@@ -13,6 +13,7 @@ struct ClawdbotApp: App {
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
}
case .local:
platform = "local"
host = "127.0.0.1:\(port)"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
case .unconfigured:
platform = nil
host = nil
@@ -2,6 +2,9 @@ import AppKit
import Foundation
import Observation
import os
#if canImport(Darwin)
import Darwin
#endif
/// Manages Tailscale integration and status checking.
@Observable
@@ -100,16 +103,14 @@ final class TailscaleService {
}
func checkTailscaleStatus() async {
let previousIP = self.tailscaleIP
self.isInstalled = self.checkAppInstallation()
guard self.isInstalled else {
if !self.isInstalled {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not installed"
return
}
if let apiResponse = await fetchTailscaleStatus() {
} else if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning {
@@ -138,6 +139,19 @@ final class TailscaleService {
self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running")
}
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
}
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
if previousIP != self.tailscaleIP {
await GatewayEndpointStore.shared.refresh()
}
}
func openTailscaleApp() {
@@ -163,4 +177,50 @@ final class TailscaleService {
NSWorkspace.shared.open(url)
}
}
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private nonisolated static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if Self.isTailnetIPv4(ip) { return ip }
}
return nil
}
nonisolated static func fallbackTailnetIPv4() -> String? {
Self.detectTailnetIPv4()
}
}
@@ -1,13 +1,16 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
#if canImport(Darwin)
import Darwin
#endif
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15_000
var timeoutMs: Int = 15000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
@@ -19,53 +22,43 @@ struct ConnectOptions {
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--probe":
opts.probe = true
case "--url":
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
if let handler = flagHandlers[arg] {
handler(&opts)
i += 1
continue
}
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
}
i += 1
}
@@ -254,8 +247,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
!raw.isEmpty
else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
@@ -268,9 +265,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
}
let port = config.port ?? 18789
let host = "127.0.0.1"
let host = resolveLocalHost(bind: config.bind)
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
return try? resolveGatewayEndpoint(opts: opts, config: config)
try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
@@ -304,3 +304,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
}
return config.password
}
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet", "auto":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}
private func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if isTailnetIPv4(ip) { return ip }
}
return nil
}
private func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?,
accountid: String?,
replyaccountid: String?,
threadid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public let search: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
spawnedby: String?,
agentid: String?
agentid: String?,
search: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
self.search = search
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
case search
}
}
@@ -3,6 +3,8 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": AnyCodable([
"whatsapp": SnapshotAnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000,
"lastError": "needs login",
]),
"telegram": AnyCodable([
"telegram": SnapshotAnyCodable([
"configured": true,
"tokenSource": "env",
"running": true,
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": AnyCodable([
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
"running": true,
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"imessage": AnyCodable([
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": AnyCodable([
"whatsapp": SnapshotAnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
]),
"telegram": AnyCodable([
"telegram": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "bot missing",
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
"signal": AnyCodable([
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
"running": false,
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_200_000,
]),
"imessage": AnyCodable([
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
}
@Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob(
id: "job-1",
agentId: "ops",
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
job: job,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true)
}
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import Testing
@testable import Clawdbot
@@ -139,4 +139,40 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
}
@@ -7,15 +7,17 @@ import Testing
@Suite(.serialized)
struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws {
let payload: [String: AnyCodable] = [
"title": AnyCodable("Hello"),
"flag": AnyCodable(true),
"count": AnyCodable(3),
"ratio": AnyCodable(1.25),
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
let payload: [String: ProtoAnyCodable] = [
"title": ProtoAnyCodable("Hello"),
"flag": ProtoAnyCodable(true),
"count": ProtoAnyCodable(3),
"ratio": ProtoAnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
]
let any = AnyCodable(payload)
let any = ProtoAnyCodable(payload)
let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true)
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999"))
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
@Test func nodeInfoCodableRoundTrip() throws {
let info = NodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro",
token: "token",
createdAtMs: 1,
lastSeenAtMs: nil)
try await store.upsert(node)
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
try await store.touchSeen(nodeId: "node-1")
let updated = await store.find(nodeId: "node-1")
#expect(updated?.lastSeenAtMs != nil)
remoteIp: "192.168.1.2",
caps: ["chat"],
commands: ["send"],
permissions: ["send": true],
paired: true,
connected: false)
let data = try JSONEncoder().encode(info)
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
#expect(decoded.nodeId == "node-1")
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
}
@Test @MainActor func presenceReporterHelpers() {
@@ -21,6 +21,7 @@ import Testing
features: [:],
snapshot: snapshot,
canvashosturl: nil,
auth: nil,
policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,13 +3,15 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized)
@MainActor
struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() {
let step = WizardStep(
id: "step-1",
type: AnyCodable("note"),
type: ProtoAnyCodable("note"),
title: "Welcome",
message: "Hello",
options: nil,
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
}
@Test func selectStepBuilds() {
let options: [[String: AnyCodable]] = [
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
let options: [[String: ProtoAnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
]
let step = WizardStep(
id: "step-2",
type: AnyCodable("select"),
type: ProtoAnyCodable("select"),
title: "Mode",
message: "Choose a mode",
options: options,
initialvalue: AnyCodable("local"),
initialvalue: ProtoAnyCodable("local"),
placeholder: nil,
sensitive: nil,
executor: nil)
@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state }
@@ -23,6 +23,35 @@ public actor GatewayNodeSession {
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
let timeout = max(0, timeoutMs ?? 0)
guard timeout > 0 else {
return await onInvoke(request)
}
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
group.addTask { await onInvoke(request) }
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "node invoke timed out")
)
}
let first = await group.next()!
group.cancelAll()
return first
}
}
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String?
@@ -167,7 +196,11 @@ public actor GatewayNodeSession {
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await onInvoke(req)
let response = await Self.invokeWithTimeout(
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
await self.sendInvokeResult(request: request, response: response)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -180,8 +213,10 @@ public actor GatewayNodeSession {
"id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId),
"ok": AnyCodable(response.ok),
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
]
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
if let error = response.error {
params["error"] = AnyCodable([
"code": AnyCodable(error.code.rawValue),
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let threadid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?,
accountid: String?,
replyaccountid: String?,
threadid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
public let spawnedby: String?
public let agentid: String?
public let search: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
spawnedby: String?,
agentid: String?
agentid: String?,
search: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
self.spawnedby = spawnedby
self.agentid = agentid
self.search = search
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label
case spawnedby = "spawnedBy"
case agentid = "agentId"
case search
}
}
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int
public let channelorder: [String]
public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable]
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int,
channelorder: [String],
channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable]
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts
self.channelorder = channelorder
self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels
self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts
case channelorder = "channelOrder"
case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels
case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId"
@@ -0,0 +1,56 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
struct GatewayNodeSessionTests {
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 50,
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
#expect(response.payloadJSON == "{}")
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 10,
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
#expect(response.error?.message.contains("timed out") == true)
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 0,
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
}
}
+1
View File
@@ -21,6 +21,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
+235
View File
@@ -0,0 +1,235 @@
---
summary: "Nostr DM channel via NIP-04 encrypted messages"
read_when:
- You want Clawdbot to receive DMs via Nostr
- You're setting up decentralized messaging
---
# Nostr
**Status:** Optional plugin (disabled by default).
Nostr is a decentralized protocol for social networking. This channel enables Clawdbot to receive and respond to encrypted direct messages (DMs) via NIP-04.
## Install (on demand)
### Onboarding (recommended)
- The onboarding wizard (`clawdbot onboard`) and `clawdbot channels add` list optional channel plugins.
- Selecting Nostr prompts you to install the plugin on demand.
Install defaults:
- **Dev channel + git checkout available:** uses the local plugin path.
- **Stable/Beta:** downloads from npm.
You can always override the choice in the prompt.
### Manual install
```bash
clawdbot plugins install @clawdbot/nostr
```
Use a local checkout (dev workflows):
```bash
clawdbot plugins install --link <path-to-clawdbot>/extensions/nostr
```
Restart the Gateway after installing or enabling plugins.
## Quick setup
1) Generate a Nostr keypair (if needed):
```bash
# Using nak
nak key generate
```
2) Add to config:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}"
}
}
}
```
3) Export the key:
```bash
export NOSTR_PRIVATE_KEY="nsec1..."
```
4) Restart the Gateway.
## Configuration reference
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `privateKey` | string | required | Private key in `nsec` or hex format |
| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) |
| `dmPolicy` | string | `pairing` | DM access policy |
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys |
| `enabled` | boolean | `true` | Enable/disable channel |
| `name` | string | - | Display name |
| `profile` | object | - | NIP-01 profile metadata |
## Profile metadata
Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config.
Example:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"profile": {
"name": "clawdbot",
"displayName": "Clawdbot",
"about": "Personal assistant DM bot",
"picture": "https://example.com/avatar.png",
"banner": "https://example.com/banner.png",
"website": "https://example.com",
"nip05": "clawdbot@example.com",
"lud16": "clawdbot@example.com"
}
}
}
}
```
Notes:
- Profile URLs must use `https://`.
- Importing from relays merges fields and preserves local overrides.
## Access control
### DM policies
- **pairing** (default): unknown senders get a pairing code.
- **allowlist**: only pubkeys in `allowFrom` can DM.
- **open**: public inbound DMs (requires `allowFrom: ["*"]`).
- **disabled**: ignore inbound DMs.
### Allowlist example
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": ["npub1abc...", "npub1xyz..."]
}
}
}
```
## Key formats
Accepted formats:
- **Private key:** `nsec...` or 64-char hex
- **Pubkeys (`allowFrom`):** `npub...` or hex
## Relays
Defaults: `relay.damus.io` and `nos.lol`.
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.wine"
]
}
}
}
```
Tips:
- Use 2-3 relays for redundancy.
- Avoid too many relays (latency, duplication).
- Paid relays can improve reliability.
- Local relays are fine for testing (`ws://localhost:7777`).
## Protocol support
| NIP | Status | Description |
| --- | --- | --- |
| NIP-01 | Supported | Basic event format + profile metadata |
| NIP-04 | Supported | Encrypted DMs (`kind:4`) |
| NIP-17 | Planned | Gift-wrapped DMs |
| NIP-44 | Planned | Versioned encryption |
## Testing
### Local relay
```bash
# Start strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry
```
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["ws://localhost:7777"]
}
}
}
```
### Manual test
1) Note the bot pubkey (npub) from logs.
2) Open a Nostr client (Damus, Amethyst, etc.).
3) DM the bot pubkey.
4) Verify the response.
## Troubleshooting
### Not receiving messages
- Verify the private key is valid.
- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local).
- Confirm `enabled` is not `false`.
- Check Gateway logs for relay connection errors.
### Not sending responses
- Check relay accepts writes.
- Verify outbound connectivity.
- Watch for relay rate limits.
### Duplicate responses
- Expected when using multiple relays.
- Messages are deduplicated by event ID; only the first delivery triggers a response.
## Security
- Never commit private keys.
- Use environment variables for keys.
- Consider `allowlist` for production bots.
## Limitations (MVP)
- Direct messages only (no group chats).
- No media attachments.
- NIP-04 only (NIP-17 gift-wrap planned).
+5
View File
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
## Voice notes (PTT audio)
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization
- Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`.
+13
View File
@@ -14,3 +14,16 @@ Related:
Tip: run `clawdbot cron --help` for the full command surface.
## Common edits
Update delivery settings without changing the message:
```bash
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
```bash
clawdbot cron edit <job-id> --no-deliver
```
+2 -2
View File
@@ -825,9 +825,9 @@ Common options:
- `--url`, `--token`, `--timeout`, `--json`
Subcommands:
- `nodes status`
- `nodes status [--connected] [--last-connected <duration>]`
- `nodes describe --node <id|name|ip>`
- `nodes list`
- `nodes list [--connected] [--last-connected <duration>]`
- `nodes pending`
- `nodes approve <requestId>`
- `nodes reject <requestId>`
+8 -1
View File
@@ -18,15 +18,22 @@ Related:
```bash
clawdbot nodes list
clawdbot nodes list --connected
clawdbot nodes list --last-connected 24h
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes status
clawdbot nodes status --connected
clawdbot nodes status --last-connected 24h
```
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
## Invoke / run
```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
clawdbot nodes run --node <id|name|ip> <command...>
```
+1
View File
@@ -21,3 +21,4 @@ clawdbot security audit --fix
```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
+12 -3
View File
@@ -7,9 +7,9 @@ read_when:
# `clawdbot update`
Safely update a **source checkout** (git install) of Clawdbot.
Safely update Clawdbot and switch between stable/beta/dev channels.
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
## Usage
@@ -48,7 +48,16 @@ Options:
- `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does (git checkout)
## What it does
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
install method aligned:
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` → installs from npm using the matching dist-tag.
## Git checkout flow
Channels:
+15
View File
@@ -77,6 +77,21 @@ Client Gateway
safely retry; the server keeps a short‑lived dedupe cache.
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
## Pairing + local trust
- All WS clients (operators + nodes) include a **device identity** on `connect`.
- New device IDs require pairing approval; the Gateway issues a **device token**
for subsequent connects.
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
auto‑approved to keep same‑host UX smooth.
- **Non‑local** connects must sign the `connect.challenge` nonce and require
explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
[Security](/gateway/security).
## Protocol typing and codegen
- TypeBox schemas define the protocol.
+4
View File
@@ -1774,6 +1774,7 @@ Note: `applyPatch` is only under `tools.exec`.
- `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.maxRedirects` (default 3)
- `tools.web.fetch.userAgent` (optional override)
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
@@ -2614,10 +2615,13 @@ Defaults:
// noSandbox: false,
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
}
}
```
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
### `ui` (Appearance)
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
+2
View File
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval
is enabled.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node).
- Non-local connections must sign the server-provided `connect.challenge` nonce.
+7
View File
@@ -177,6 +177,7 @@ 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).
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
## Reasoning & verbose output in groups
@@ -269,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Local device pairing:
- Device pairing is auto‑approved for **local** connects (loopback or the
gateway host’s own tailnet address) to keep same‑host clients smooth.
- Other tailnet peers are **not** treated as local; they still need pairing
approval.
Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
+8 -1
View File
@@ -7,7 +7,7 @@ read_when:
# Development channels
Last updated: 2026-01-20
Last updated: 2026-01-21
Clawdbot ships three update channels:
@@ -38,6 +38,13 @@ clawdbot update --channel dev
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
the install method:
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` installs from npm using the matching dist-tag.
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Plugins and channels
+119 -12
View File
@@ -138,17 +138,45 @@ Redaction affects **console output only** and does not alter file logs.
## Diagnostics + OpenTelemetry
Diagnostics are **opt-in** structured events for model runs (usage + cost +
context + duration). They do **not** replace logs; they exist to feed metrics,
traces, and other exporters.
Diagnostics are structured, machine-readable events for model runs **and**
message-flow telemetry (webhooks, queueing, session state). They do **not**
replace logs; they exist to feed metrics, traces, and other exporters.
Clawdbot currently emits a `model.usage` event after each agent run with:
Diagnostics events are emitted in-process, but exporters only attach when
diagnostics + the exporter plugin are enabled.
- Token counts (input/output/cache/prompt/total)
- Estimated cost (USD)
- Context window used/limit
- Duration (ms)
- Provider/channel/model + session identifiers
### OpenTelemetry vs OTLP
- **OpenTelemetry (OTel)**: the data model + SDKs for traces, metrics, and logs.
- **OTLP**: the wire protocol used to export OTel data to a collector/backend.
- Clawdbot exports via **OTLP/HTTP (protobuf)** today.
### Signals exported
- **Metrics**: counters + histograms (token usage, message flow, queueing).
- **Traces**: spans for model usage + webhook/message processing.
- **Logs**: exported over OTLP when `diagnostics.otel.logs` is enabled. Log
volume can be high; keep `logging.level` and exporter filters in mind.
### Diagnostic event catalog
Model usage:
- `model.usage`: tokens, cost, duration, context, provider/model/channel, session ids.
Message flow:
- `webhook.received`: webhook ingress per channel.
- `webhook.processed`: webhook handled + duration.
- `webhook.error`: webhook handler errors.
- `message.queued`: message enqueued for processing.
- `message.processed`: outcome + duration + optional error.
Queue + session:
- `queue.lane.enqueue`: command queue lane enqueue + depth.
- `queue.lane.dequeue`: command queue lane dequeue + wait time.
- `session.state`: session state transition + reason.
- `session.stuck`: session stuck warning + age.
- `run.attempt`: run retry/attempt metadata.
- `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session).
### Enable diagnostics (no exporter)
@@ -186,6 +214,7 @@ works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
"serviceName": "clawdbot-gateway",
"traces": true,
"metrics": true,
"logs": true,
"sampleRate": 0.2,
"flushIntervalMs": 60000
}
@@ -195,13 +224,91 @@ works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
Notes:
- You can also enable the plugin with `clawdbot plugins enable diagnostics-otel`.
- `protocol` currently supports `http/protobuf`.
- Metrics include token usage, cost, context size, and run duration.
- Traces/metrics can be toggled with `traces` / `metrics` (default: on).
- `protocol` currently supports `http/protobuf` only. `grpc` is ignored.
- Metrics include token usage, cost, context size, run duration, and message-flow
counters/histograms (webhooks, queueing, session state, queue depth/wait).
- Traces/metrics can be toggled with `traces` / `metrics` (default: on). Traces
include model usage spans plus webhook/message processing spans when enabled.
- Set `headers` when your collector requires auth.
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
### Exported metrics (names + types)
Model usage:
- `clawdbot.tokens` (counter, attrs: `clawdbot.token`, `clawdbot.channel`,
`clawdbot.provider`, `clawdbot.model`)
- `clawdbot.cost.usd` (counter, attrs: `clawdbot.channel`, `clawdbot.provider`,
`clawdbot.model`)
- `clawdbot.run.duration_ms` (histogram, attrs: `clawdbot.channel`,
`clawdbot.provider`, `clawdbot.model`)
- `clawdbot.context.tokens` (histogram, attrs: `clawdbot.context`,
`clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`)
Message flow:
- `clawdbot.webhook.received` (counter, attrs: `clawdbot.channel`,
`clawdbot.webhook`)
- `clawdbot.webhook.error` (counter, attrs: `clawdbot.channel`,
`clawdbot.webhook`)
- `clawdbot.webhook.duration_ms` (histogram, attrs: `clawdbot.channel`,
`clawdbot.webhook`)
- `clawdbot.message.queued` (counter, attrs: `clawdbot.channel`,
`clawdbot.source`)
- `clawdbot.message.processed` (counter, attrs: `clawdbot.channel`,
`clawdbot.outcome`)
- `clawdbot.message.duration_ms` (histogram, attrs: `clawdbot.channel`,
`clawdbot.outcome`)
Queues + sessions:
- `clawdbot.queue.lane.enqueue` (counter, attrs: `clawdbot.lane`)
- `clawdbot.queue.lane.dequeue` (counter, attrs: `clawdbot.lane`)
- `clawdbot.queue.depth` (histogram, attrs: `clawdbot.lane` or
`clawdbot.channel=heartbeat`)
- `clawdbot.queue.wait_ms` (histogram, attrs: `clawdbot.lane`)
- `clawdbot.session.state` (counter, attrs: `clawdbot.state`, `clawdbot.reason`)
- `clawdbot.session.stuck` (counter, attrs: `clawdbot.state`)
- `clawdbot.session.stuck_age_ms` (histogram, attrs: `clawdbot.state`)
- `clawdbot.run.attempt` (counter, attrs: `clawdbot.attempt`)
### Exported spans (names + key attributes)
- `clawdbot.model.usage`
- `clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`
- `clawdbot.sessionKey`, `clawdbot.sessionId`
- `clawdbot.tokens.*` (input/output/cache_read/cache_write/total)
- `clawdbot.webhook.processed`
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`
- `clawdbot.webhook.error`
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`,
`clawdbot.error`
- `clawdbot.message.processed`
- `clawdbot.channel`, `clawdbot.outcome`, `clawdbot.chatId`,
`clawdbot.messageId`, `clawdbot.sessionKey`, `clawdbot.sessionId`,
`clawdbot.reason`
- `clawdbot.session.stuck`
- `clawdbot.state`, `clawdbot.ageMs`, `clawdbot.queueDepth`,
`clawdbot.sessionKey`, `clawdbot.sessionId`
### Sampling + flushing
- Trace sampling: `diagnostics.otel.sampleRate` (0.0–1.0, root spans only).
- Metric export interval: `diagnostics.otel.flushIntervalMs` (min 1000ms).
### Protocol notes
- OTLP/HTTP endpoints can be set via `diagnostics.otel.endpoint` or
`OTEL_EXPORTER_OTLP_ENDPOINT`.
- If the endpoint already contains `/v1/traces` or `/v1/metrics`, it is used as-is.
- If the endpoint already contains `/v1/logs`, it is used as-is for logs.
- `diagnostics.otel.logs` enables OTLP log export for the main logger output.
### Log export behavior
- OTLP logs use the same structured records written to `logging.file`.
- Respect `logging.level` (file log level). Console redaction does **not** apply
to OTLP logs.
- High-volume installs should prefer OTLP collector sampling/filtering.
## Troubleshooting tips
- **Gateway not reachable?** Run `clawdbot doctor` first.
+51
View File
@@ -0,0 +1,51 @@
---
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
read_when:
- You need the network architecture + security overview
- You are debugging local vs tailnet access or pairing
- You want the canonical list of networking docs
---
# Network hub
This hub links the core docs for how Clawdbot connects, pairs, and secures
devices across localhost, LAN, and tailnet.
## Core model
- [Gateway architecture](/concepts/architecture)
- [Gateway protocol](/gateway/protocol)
- [Gateway runbook](/gateway)
- [Web surfaces + bind modes](/web)
## Pairing + identity
- [Pairing overview (DM + nodes)](/start/pairing)
- [Gateway-owned node pairing](/gateway/pairing)
- [Devices CLI (pairing + token rotation)](/cli/devices)
- [Pairing CLI (DM approvals)](/cli/pairing)
Local trust:
- Local connections (loopback or the gateway host’s own tailnet address) can be
auto‑approved for pairing to keep same‑host UX smooth.
- Non‑local tailnet/LAN clients still require explicit pairing approval.
## Discovery + transports
- [Discovery & transports](/gateway/discovery)
- [Bonjour / mDNS](/gateway/bonjour)
- [Remote access (SSH)](/gateway/remote)
- [Tailscale](/gateway/tailscale)
## Nodes + bridge
- [Nodes overview](/nodes)
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
- [Node runbook: iOS](/platforms/ios)
- [Node runbook: Android](/platforms/android)
## Security
- [Security overview](/gateway/security)
- [Gateway config reference](/gateway/configuration)
- [Troubleshooting](/gateway/troubleshooting)
- [Doctor](/gateway/doctor)
+78
View File
@@ -34,6 +34,81 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
Notes:
- `nodes rename` stores a display name override in the gateway pairing store.
## Remote node host (system.run)
Use a **node host** when your Gateway runs on one machine and you want commands
to execute on another. The model still talks to the **gateway**; the gateway
forwards `exec` calls to the **node host** when `host=node` is selected.
### What runs where
- **Gateway host**: receives messages, runs the model, routes tool calls.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
### Start a node host (foreground)
On the node machine:
```bash
clawdbot node start --host <gateway-host> --port 18789 --display-name "Build Node"
```
### Start a node host (service)
```bash
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node service start
```
### Pair + name
On the gateway host:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes list
```
Naming options:
- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node).
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
### Allowlist the commands
Exec approvals are **per node host**. Add allowlist entries from the gateway:
```bash
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
```
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
### Point exec at the node
Configure defaults (gateway config):
```bash
clawdbot config set tools.exec.host node
clawdbot config set tools.exec.security allowlist
clawdbot config set tools.exec.node "<id-or-name>"
```
Or per session:
```
/exec host=node security=allowlist node=<id-or-name>
```
Once set, any `exec` call with `host=node` runs on the node host (subject to the
node allowlist/approvals).
Related:
- [Node host CLI](/cli/node)
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)
## Invoking commands
Low-level (raw RPC):
@@ -214,6 +289,9 @@ Notes:
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode
+44
View File
@@ -49,6 +49,50 @@ Repair/migrate:
clawdbot doctor
```
## Advanced: expose WSL services over LAN (portproxy)
WSL has its own virtual network. If another machine needs to reach a service
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
so you may need to refresh the forwarding rule.
Example (PowerShell **as Administrator**):
```powershell
$Distro = "Ubuntu-24.04"
$ListenPort = 2222
$TargetPort = 22
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
if (-not $WslIp) { throw "WSL IP not found." }
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
connectaddress=$WslIp connectport=$TargetPort
```
Allow the port through Windows Firewall (one-time):
```powershell
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
-Protocol TCP -LocalPort $ListenPort -Action Allow
```
Refresh the portproxy after WSL restarts:
```powershell
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
connectaddress=$WslIp connectport=$TargetPort | Out-Null
```
Notes:
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
`clawdbot status --all` to confirm.
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
- If you want this automatic, register a Scheduled Task to run the refresh
step at login.
## Step-by-step WSL2 install
### 1) Install WSL2 + Ubuntu
+1
View File
@@ -41,6 +41,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Nostr](/channels/nostr) — `@clawdbot/nostr`
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
+1
View File
@@ -26,6 +26,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
2) **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
+1
View File
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Core concepts
- [Architecture](/concepts/architecture)
- [Network hub](/network)
- [Agent runtime](/concepts/agent)
- [Agent workspace](/concepts/agent-workspace)
- [Memory](/concepts/memory)
+1
View File
@@ -500,6 +500,7 @@ Notes:
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
- `--format aria`: returns the accessibility tree (no refs; inspection only).
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
+1 -1
View File
@@ -39,7 +39,7 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.host` (default: `sandbox`)
- `tools.exec.security` (default: `deny`)
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
+4 -1
View File
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
- `requires.env` — list; env var must exist **or** be provided in config.
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time.
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun is not recommended for WhatsApp/Telegram).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
If no `metadata.clawdbot` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills).
+2
View File
@@ -60,6 +60,7 @@ Text + native (when enabled):
- `/commands`
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
@@ -93,6 +94,7 @@ Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
+2
View File
@@ -215,6 +215,7 @@ Fetch a URL and extract readable content.
maxChars: 50000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
maxRedirects: 3,
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
readability: true,
firecrawl: {
@@ -241,6 +242,7 @@ Notes:
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
- Firecrawl requests use bot-circumvention mode and cache results by default.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
- Responses are cached (default 15 minutes) to reduce repeated fetches.
+4 -2
View File
@@ -1,10 +1,12 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.15",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
"extensions": ["./index.ts"],
"extensions": [
"./index.ts"
],
"channel": {
"id": "bluebubbles",
"label": "BlueBubbles",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
+7 -2
View File
@@ -1,16 +1,21 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.210.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.210.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
"@opentelemetry/resources": "^2.4.0",
"@opentelemetry/sdk-logs": "^0.210.0",
"@opentelemetry/sdk-metrics": "^2.4.0",
"@opentelemetry/sdk-node": "^0.210.0",
"@opentelemetry/sdk-trace-base": "^2.4.0",
@@ -0,0 +1,220 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const registerLogTransportMock = vi.hoisted(() => vi.fn());
const telemetryState = vi.hoisted(() => {
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
const tracer = {
startSpan: vi.fn((_name: string, _opts?: unknown) => ({
end: vi.fn(),
setStatus: vi.fn(),
})),
};
const meter = {
createCounter: vi.fn((name: string) => {
const counter = { add: vi.fn() };
counters.set(name, counter);
return counter;
}),
createHistogram: vi.fn((name: string) => {
const histogram = { record: vi.fn() };
histograms.set(name, histogram);
return histogram;
}),
};
return { counters, histograms, tracer, meter };
});
const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const logEmit = vi.hoisted(() => vi.fn());
const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@opentelemetry/api", () => ({
metrics: {
getMeter: () => telemetryState.meter,
},
trace: {
getTracer: () => telemetryState.tracer,
},
SpanStatusCode: {
ERROR: 2,
},
}));
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class {
start = sdkStart;
shutdown = sdkShutdown;
},
}));
vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
OTLPMetricExporter: class {},
}));
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
OTLPTraceExporter: class {},
}));
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
OTLPLogExporter: class {},
}));
vi.mock("@opentelemetry/sdk-logs", () => ({
BatchLogRecordProcessor: class {},
LoggerProvider: class {
addLogRecordProcessor = vi.fn();
getLogger = vi.fn(() => ({
emit: logEmit,
}));
shutdown = logShutdown;
},
}));
vi.mock("@opentelemetry/sdk-metrics", () => ({
PeriodicExportingMetricReader: class {},
}));
vi.mock("@opentelemetry/sdk-trace-base", () => ({
ParentBasedSampler: class {},
TraceIdRatioBasedSampler: class {},
}));
vi.mock("@opentelemetry/resources", () => ({
Resource: class {
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(_value?: unknown) {}
},
}));
vi.mock("@opentelemetry/semantic-conventions", () => ({
SemanticResourceAttributes: {
SERVICE_NAME: "service.name",
},
}));
vi.mock("clawdbot/plugin-sdk", async () => {
const actual = await vi.importActual<typeof import("clawdbot/plugin-sdk")>("clawdbot/plugin-sdk");
return {
...actual,
registerLogTransport: registerLogTransportMock,
};
});
import { createDiagnosticsOtelService } from "./service.js";
import { emitDiagnosticEvent } from "clawdbot/plugin-sdk";
describe("diagnostics-otel service", () => {
beforeEach(() => {
telemetryState.counters.clear();
telemetryState.histograms.clear();
telemetryState.tracer.startSpan.mockClear();
telemetryState.meter.createCounter.mockClear();
telemetryState.meter.createHistogram.mockClear();
sdkStart.mockClear();
sdkShutdown.mockClear();
logEmit.mockClear();
logShutdown.mockClear();
registerLogTransportMock.mockReset();
});
test("records message-flow metrics and spans", async () => {
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
const service = createDiagnosticsOtelService();
await service.start({
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
traces: true,
metrics: true,
logs: true,
},
},
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
});
emitDiagnosticEvent({
type: "webhook.received",
channel: "telegram",
updateType: "telegram-post",
});
emitDiagnosticEvent({
type: "webhook.processed",
channel: "telegram",
updateType: "telegram-post",
durationMs: 120,
});
emitDiagnosticEvent({
type: "message.queued",
channel: "telegram",
source: "telegram",
queueDepth: 2,
});
emitDiagnosticEvent({
type: "message.processed",
channel: "telegram",
outcome: "completed",
durationMs: 55,
});
emitDiagnosticEvent({
type: "queue.lane.dequeue",
lane: "main",
queueSize: 3,
waitMs: 10,
});
emitDiagnosticEvent({
type: "session.stuck",
state: "processing",
ageMs: 125_000,
});
emitDiagnosticEvent({
type: "run.attempt",
runId: "run-1",
attempt: 2,
});
expect(telemetryState.counters.get("clawdbot.webhook.received")?.add).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.webhook.duration_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.message.queued")?.add).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.message.processed")?.add).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.message.duration_ms")?.record).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.queue.wait_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.session.stuck")?.add).toHaveBeenCalled();
expect(telemetryState.histograms.get("clawdbot.session.stuck_age_ms")?.record).toHaveBeenCalled();
expect(telemetryState.counters.get("clawdbot.run.attempt")?.add).toHaveBeenCalled();
const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]);
expect(spanNames).toContain("clawdbot.webhook.processed");
expect(spanNames).toContain("clawdbot.message.processed");
expect(spanNames).toContain("clawdbot.session.stuck");
expect(registerLogTransportMock).toHaveBeenCalledTimes(1);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.({
0: "{\"subsystem\":\"diagnostic\"}",
1: "hello",
_meta: { logLevelName: "INFO", date: new Date() },
});
expect(logEmit).toHaveBeenCalled();
await service.stop?.();
});
});
+418 -48
View File
@@ -1,17 +1,17 @@
import { metrics, trace } from "@opentelemetry/api";
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
import type { SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import type {
ClawdbotPluginService,
DiagnosticUsageEvent,
} from "clawdbot/plugin-sdk";
import { onDiagnosticEvent } from "clawdbot/plugin-sdk";
import type { ClawdbotPluginService, DiagnosticEventPayload } from "clawdbot/plugin-sdk";
import { onDiagnosticEvent, registerLogTransport } from "clawdbot/plugin-sdk";
const DEFAULT_SERVICE_NAME = "clawdbot";
@@ -34,6 +34,8 @@ function resolveSampleRate(value: number | undefined): number | undefined {
export function createDiagnosticsOtelService(): ClawdbotPluginService {
let sdk: NodeSDK | null = null;
let logProvider: LoggerProvider | null = null;
let stopLogTransport: (() => void) | null = null;
let unsubscribe: (() => void) | null = null;
return {
@@ -57,7 +59,8 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
const tracesEnabled = otel.traces !== false;
const metricsEnabled = otel.metrics !== false;
if (!tracesEnabled && !metricsEnabled) return;
const logsEnabled = otel.logs === true;
if (!tracesEnabled && !metricsEnabled && !logsEnabled) return;
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
@@ -65,6 +68,7 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
const logUrl = resolveOtelUrl(endpoint, "v1/logs");
const traceExporter = tracesEnabled
? new OTLPTraceExporter({
...(traceUrl ? { url: traceUrl } : {}),
@@ -88,20 +92,31 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
})
: undefined;
sdk = new NodeSDK({
resource,
...(traceExporter ? { traceExporter } : {}),
...(metricReader ? { metricReader } : {}),
...(sampleRate !== undefined
? {
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(sampleRate),
}),
}
: {}),
});
if (tracesEnabled || metricsEnabled) {
sdk = new NodeSDK({
resource,
...(traceExporter ? { traceExporter } : {}),
...(metricReader ? { metricReader } : {}),
...(sampleRate !== undefined
? {
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(sampleRate),
}),
}
: {}),
});
await sdk.start();
await sdk.start();
}
const logSeverityMap: Record<string, SeverityNumber> = {
TRACE: 1 as SeverityNumber,
DEBUG: 5 as SeverityNumber,
INFO: 9 as SeverityNumber,
WARN: 13 as SeverityNumber,
ERROR: 17 as SeverityNumber,
FATAL: 21 as SeverityNumber,
};
const meter = metrics.getMeter("clawdbot");
const tracer = trace.getTracer("clawdbot");
@@ -122,38 +137,210 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
unit: "1",
description: "Context window size and usage",
});
const webhookReceivedCounter = meter.createCounter("clawdbot.webhook.received", {
unit: "1",
description: "Webhook requests received",
});
const webhookErrorCounter = meter.createCounter("clawdbot.webhook.error", {
unit: "1",
description: "Webhook processing errors",
});
const webhookDurationHistogram = meter.createHistogram("clawdbot.webhook.duration_ms", {
unit: "ms",
description: "Webhook processing duration",
});
const messageQueuedCounter = meter.createCounter("clawdbot.message.queued", {
unit: "1",
description: "Messages queued for processing",
});
const messageProcessedCounter = meter.createCounter("clawdbot.message.processed", {
unit: "1",
description: "Messages processed by outcome",
});
const messageDurationHistogram = meter.createHistogram("clawdbot.message.duration_ms", {
unit: "ms",
description: "Message processing duration",
});
const queueDepthHistogram = meter.createHistogram("clawdbot.queue.depth", {
unit: "1",
description: "Queue depth on enqueue/dequeue",
});
const queueWaitHistogram = meter.createHistogram("clawdbot.queue.wait_ms", {
unit: "ms",
description: "Queue wait time before execution",
});
const laneEnqueueCounter = meter.createCounter("clawdbot.queue.lane.enqueue", {
unit: "1",
description: "Command queue lane enqueue events",
});
const laneDequeueCounter = meter.createCounter("clawdbot.queue.lane.dequeue", {
unit: "1",
description: "Command queue lane dequeue events",
});
const sessionStateCounter = meter.createCounter("clawdbot.session.state", {
unit: "1",
description: "Session state transitions",
});
const sessionStuckCounter = meter.createCounter("clawdbot.session.stuck", {
unit: "1",
description: "Sessions stuck in processing",
});
const sessionStuckAgeHistogram = meter.createHistogram("clawdbot.session.stuck_age_ms", {
unit: "ms",
description: "Age of stuck sessions",
});
const runAttemptCounter = meter.createCounter("clawdbot.run.attempt", {
unit: "1",
description: "Run attempts",
});
unsubscribe = onDiagnosticEvent((evt) => {
if (evt.type !== "model.usage") return;
const usageEvent = evt as DiagnosticUsageEvent;
if (logsEnabled) {
const logExporter = new OTLPLogExporter({
...(logUrl ? { url: logUrl } : {}),
...(headers ? { headers } : {}),
});
logProvider = new LoggerProvider({ resource });
logProvider.addLogRecordProcessor(
new BatchLogRecordProcessor(logExporter, {
...(typeof otel.flushIntervalMs === "number"
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
: {}),
}),
);
const otelLogger = logProvider.getLogger("clawdbot");
stopLogTransport = registerLogTransport((logObj) => {
const safeStringify = (value: unknown) => {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const meta = (logObj as Record<string, unknown>)._meta as
| {
logLevelName?: string;
date?: Date;
name?: string;
parentNames?: string[];
path?: {
filePath?: string;
fileLine?: string;
fileColumn?: string;
filePathWithLine?: string;
method?: string;
};
}
| undefined;
const logLevelName = meta?.logLevelName ?? "INFO";
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
const numericArgs = Object.entries(logObj)
.filter(([key]) => /^\d+$/.test(key))
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map(([, value]) => value);
let bindings: Record<string, unknown> | undefined;
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
try {
const parsed = JSON.parse(numericArgs[0]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
bindings = parsed as Record<string, unknown>;
numericArgs.shift();
}
} catch {
// ignore malformed json bindings
}
}
let message = "";
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
message = String(numericArgs.pop());
} else if (numericArgs.length === 1) {
message = safeStringify(numericArgs[0]);
numericArgs.length = 0;
}
if (!message) {
message = "log";
}
const attributes: Record<string, string | number | boolean> = {
"clawdbot.log.level": logLevelName,
};
if (meta?.name) attributes["clawdbot.logger"] = meta.name;
if (meta?.parentNames?.length) {
attributes["clawdbot.logger.parents"] = meta.parentNames.join(".");
}
if (bindings) {
for (const [key, value] of Object.entries(bindings)) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
attributes[`clawdbot.${key}`] = value;
} else if (value != null) {
attributes[`clawdbot.${key}`] = safeStringify(value);
}
}
}
if (numericArgs.length > 0) {
attributes["clawdbot.log.args"] = safeStringify(numericArgs);
}
if (meta?.path?.filePath) attributes["code.filepath"] = meta.path.filePath;
if (meta?.path?.fileLine) attributes["code.lineno"] = Number(meta.path.fileLine);
if (meta?.path?.method) attributes["code.function"] = meta.path.method;
if (meta?.path?.filePathWithLine) {
attributes["clawdbot.code.location"] = meta.path.filePathWithLine;
}
otelLogger.emit({
body: message,
severityText: logLevelName,
severityNumber,
attributes,
timestamp: meta?.date ?? new Date(),
});
});
}
const spanWithDuration = (
name: string,
attributes: Record<string, string | number>,
durationMs?: number,
) => {
const startTime =
typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined;
const span = tracer.startSpan(name, {
attributes,
...(startTime ? { startTime } : {}),
});
return span;
};
const recordModelUsage = (evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) => {
const attrs = {
"clawdbot.channel": usageEvent.channel ?? "unknown",
"clawdbot.provider": usageEvent.provider ?? "unknown",
"clawdbot.model": usageEvent.model ?? "unknown",
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.provider": evt.provider ?? "unknown",
"clawdbot.model": evt.model ?? "unknown",
};
const usage = usageEvent.usage;
const usage = evt.usage;
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "clawdbot.token": "input" });
if (usage.output)
tokensCounter.add(usage.output, { ...attrs, "clawdbot.token": "output" });
if (usage.output) tokensCounter.add(usage.output, { ...attrs, "clawdbot.token": "output" });
if (usage.cacheRead)
tokensCounter.add(usage.cacheRead, { ...attrs, "clawdbot.token": "cache_read" });
if (usage.cacheWrite)
tokensCounter.add(usage.cacheWrite, { ...attrs, "clawdbot.token": "cache_write" });
if (usage.promptTokens)
tokensCounter.add(usage.promptTokens, { ...attrs, "clawdbot.token": "prompt" });
if (usage.total)
tokensCounter.add(usage.total, { ...attrs, "clawdbot.token": "total" });
if (usage.total) tokensCounter.add(usage.total, { ...attrs, "clawdbot.token": "total" });
if (usageEvent.costUsd) costCounter.add(usageEvent.costUsd, attrs);
if (usageEvent.durationMs) durationHistogram.record(usageEvent.durationMs, attrs);
if (usageEvent.context?.limit)
contextHistogram.record(usageEvent.context.limit, {
if (evt.costUsd) costCounter.add(evt.costUsd, attrs);
if (evt.durationMs) durationHistogram.record(evt.durationMs, attrs);
if (evt.context?.limit)
contextHistogram.record(evt.context.limit, {
...attrs,
"clawdbot.context": "limit",
});
if (usageEvent.context?.used)
contextHistogram.record(usageEvent.context.used, {
if (evt.context?.used)
contextHistogram.record(evt.context.used, {
...attrs,
"clawdbot.context": "used",
});
@@ -161,8 +348,8 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = {
...attrs,
"clawdbot.sessionKey": usageEvent.sessionKey ?? "",
"clawdbot.sessionId": usageEvent.sessionId ?? "",
"clawdbot.sessionKey": evt.sessionKey ?? "",
"clawdbot.sessionId": evt.sessionId ?? "",
"clawdbot.tokens.input": usage.input ?? 0,
"clawdbot.tokens.output": usage.output ?? 0,
"clawdbot.tokens.cache_read": usage.cacheRead ?? 0,
@@ -170,23 +357,206 @@ export function createDiagnosticsOtelService(): ClawdbotPluginService {
"clawdbot.tokens.total": usage.total ?? 0,
};
const startTime = usageEvent.durationMs
? Date.now() - Math.max(0, usageEvent.durationMs)
: undefined;
const span = tracer.startSpan("clawdbot.model.usage", {
attributes: spanAttrs,
...(startTime ? { startTime } : {}),
});
const span = spanWithDuration("clawdbot.model.usage", spanAttrs, evt.durationMs);
span.end();
};
const recordWebhookReceived = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.received" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.webhook": evt.updateType ?? "unknown",
};
webhookReceivedCounter.add(1, attrs);
};
const recordWebhookProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.processed" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.webhook": evt.updateType ?? "unknown",
};
if (typeof evt.durationMs === "number") {
webhookDurationHistogram.record(evt.durationMs, attrs);
}
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
const span = spanWithDuration("clawdbot.webhook.processed", spanAttrs, evt.durationMs);
span.end();
};
const recordWebhookError = (
evt: Extract<DiagnosticEventPayload, { type: "webhook.error" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.webhook": evt.updateType ?? "unknown",
};
webhookErrorCounter.add(1, attrs);
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = {
...attrs,
"clawdbot.error": evt.error,
};
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
const span = tracer.startSpan("clawdbot.webhook.error", {
attributes: spanAttrs,
});
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
span.end();
};
const recordMessageQueued = (
evt: Extract<DiagnosticEventPayload, { type: "message.queued" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.source": evt.source ?? "unknown",
};
messageQueuedCounter.add(1, attrs);
if (typeof evt.queueDepth === "number") {
queueDepthHistogram.record(evt.queueDepth, attrs);
}
};
const recordMessageProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
) => {
const attrs = {
"clawdbot.channel": evt.channel ?? "unknown",
"clawdbot.outcome": evt.outcome ?? "unknown",
};
messageProcessedCounter.add(1, attrs);
if (typeof evt.durationMs === "number") {
messageDurationHistogram.record(evt.durationMs, attrs);
}
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) spanAttrs["clawdbot.sessionKey"] = evt.sessionKey;
if (evt.sessionId) spanAttrs["clawdbot.sessionId"] = evt.sessionId;
if (evt.chatId !== undefined) spanAttrs["clawdbot.chatId"] = String(evt.chatId);
if (evt.messageId !== undefined) spanAttrs["clawdbot.messageId"] = String(evt.messageId);
if (evt.reason) spanAttrs["clawdbot.reason"] = evt.reason;
const span = spanWithDuration("clawdbot.message.processed", spanAttrs, evt.durationMs);
if (evt.outcome === "error") {
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
}
span.end();
};
const recordLaneEnqueue = (
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.enqueue" }>,
) => {
const attrs = { "clawdbot.lane": evt.lane };
laneEnqueueCounter.add(1, attrs);
queueDepthHistogram.record(evt.queueSize, attrs);
};
const recordLaneDequeue = (
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.dequeue" }>,
) => {
const attrs = { "clawdbot.lane": evt.lane };
laneDequeueCounter.add(1, attrs);
queueDepthHistogram.record(evt.queueSize, attrs);
if (typeof evt.waitMs === "number") {
queueWaitHistogram.record(evt.waitMs, attrs);
}
};
const recordSessionState = (
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
) => {
const attrs: Record<string, string> = { "clawdbot.state": evt.state };
if (evt.reason) attrs["clawdbot.reason"] = evt.reason;
sessionStateCounter.add(1, attrs);
};
const recordSessionStuck = (
evt: Extract<DiagnosticEventPayload, { type: "session.stuck" }>,
) => {
const attrs: Record<string, string> = { "clawdbot.state": evt.state };
sessionStuckCounter.add(1, attrs);
if (typeof evt.ageMs === "number") {
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
}
if (!tracesEnabled) return;
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) spanAttrs["clawdbot.sessionKey"] = evt.sessionKey;
if (evt.sessionId) spanAttrs["clawdbot.sessionId"] = evt.sessionId;
spanAttrs["clawdbot.queueDepth"] = evt.queueDepth ?? 0;
spanAttrs["clawdbot.ageMs"] = evt.ageMs;
const span = tracer.startSpan("clawdbot.session.stuck", { attributes: spanAttrs });
span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" });
span.end();
};
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
runAttemptCounter.add(1, { "clawdbot.attempt": evt.attempt });
};
const recordHeartbeat = (
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
) => {
queueDepthHistogram.record(evt.queued, { "clawdbot.channel": "heartbeat" });
};
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
switch (evt.type) {
case "model.usage":
recordModelUsage(evt);
return;
case "webhook.received":
recordWebhookReceived(evt);
return;
case "webhook.processed":
recordWebhookProcessed(evt);
return;
case "webhook.error":
recordWebhookError(evt);
return;
case "message.queued":
recordMessageQueued(evt);
return;
case "message.processed":
recordMessageProcessed(evt);
return;
case "queue.lane.enqueue":
recordLaneEnqueue(evt);
return;
case "queue.lane.dequeue":
recordLaneDequeue(evt);
return;
case "session.state":
recordSessionState(evt);
return;
case "session.stuck":
recordSessionStuck(evt);
return;
case "run.attempt":
recordRunAttempt(evt);
return;
case "diagnostic.heartbeat":
recordHeartbeat(evt);
return;
}
});
if (otel.logs) {
ctx.logger.warn("diagnostics-otel: logs exporter not wired yet");
if (logsEnabled) {
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)");
}
},
async stop() {
unsubscribe?.();
unsubscribe = null;
stopLogTransport?.();
stopLogTransport = null;
if (logProvider) {
await logProvider.shutdown().catch(() => undefined);
logProvider = null;
}
if (sdk) {
await sdk.shutdown().catch(() => undefined);
sdk = null;
+4 -2
View File
@@ -1,9 +1,11 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+4 -2
View File
@@ -1,9 +1,11 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "0.0.1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17-1
### Changes
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
@@ -14,7 +14,9 @@
"docsPath": "/channels/msteams",
"docsLabel": "msteams",
"blurb": "Bot Framework; enterprise support.",
"aliases": ["teams"],
"aliases": [
"teams"
],
"order": 60
},
"install": {
+8 -3
View File
@@ -1,10 +1,12 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.17-1",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
"extensions": ["./index.ts"],
"extensions": [
"./index.ts"
],
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
@@ -12,7 +14,10 @@
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"aliases": ["nc-talk", "nc"],
"aliases": [
"nc-talk",
"nc"
],
"order": 65,
"quickstartAllowFrom": true
},
+31
View File
@@ -0,0 +1,31 @@
# Changelog
## 2026.1.20
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.19-1
Initial release.
### Features
- NIP-04 encrypted DM support (kind:4 events)
- Key validation (hex and nsec formats)
- Multi-relay support with sequential fallback
- Event signature verification
- TTL-based deduplication (24h)
- Access control via dmPolicy (pairing, allowlist, open, disabled)
- Pubkey normalization (hex/npub)
### Protocol Support
- NIP-01: Basic event structure
- NIP-04: Encrypted direct messages
### Planned for v2
- NIP-17: Gift-wrapped DMs
- NIP-44: Versioned encryption
- Media attachments
+136
View File
@@ -0,0 +1,136 @@
# @clawdbot/nostr
Nostr DM channel plugin for Clawdbot using NIP-04 encrypted direct messages.
## Overview
This extension adds Nostr as a messaging channel to Clawdbot. It enables your bot to:
- Receive encrypted DMs from Nostr users
- Send encrypted responses back
- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.)
## Installation
```bash
clawdbot plugins install @clawdbot/nostr
```
## Quick Setup
1. Generate a Nostr keypair (if you don't have one):
```bash
# Using nak CLI
nak key generate
# Or use any Nostr key generator
```
2. Add to your config:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
}
}
}
```
3. Set the environment variable:
```bash
export NOSTR_PRIVATE_KEY="nsec1..." # or hex format
```
4. Restart the gateway
## Configuration
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `privateKey` | string | required | Bot's private key (nsec or hex format) |
| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
| `enabled` | boolean | `true` | Enable/disable the channel |
| `name` | string | - | Display name for the account |
## Access Control
### DM Policies
- **pairing** (default): Unknown senders receive a pairing code to request access
- **allowlist**: Only pubkeys in `allowFrom` can message the bot
- **open**: Anyone can message the bot (use with caution)
- **disabled**: DMs are disabled
### Example: Allowlist Mode
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": [
"npub1abc...",
"0123456789abcdef..."
]
}
}
}
```
## Testing
### Local Relay (Recommended)
```bash
# Using strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry
# Configure clawdbot to use local relay
"relays": ["ws://localhost:7777"]
```
### Manual Test
1. Start the gateway with Nostr configured
2. Open Damus, Amethyst, or another Nostr client
3. Send a DM to your bot's npub
4. Verify the bot responds
## Protocol Support
| NIP | Status | Notes |
|-----|--------|-------|
| NIP-01 | Supported | Basic event structure |
| NIP-04 | Supported | Encrypted DMs (kind:4) |
| NIP-17 | Planned | Gift-wrapped DMs (v2) |
## Security Notes
- Private keys are never logged
- Event signatures are verified before processing
- Use environment variables for keys, never commit to config files
- Consider using `allowlist` mode in production
## Troubleshooting
### Bot not receiving messages
1. Verify private key is correctly configured
2. Check relay connectivity
3. Ensure `enabled` is not set to `false`
4. Check the bot's public key matches what you're sending to
### Messages not being delivered
1. Check relay URLs are correct (must use `wss://`)
2. Verify relays are online and accepting connections
3. Check for rate limiting (reduce message frequency)
## License
MIT
+11
View File
@@ -0,0 +1,11 @@
{
"id": "nostr",
"channels": [
"nostr"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { ClawdbotPluginApi, ClawdbotConfig } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { nostrPlugin } from "./src/channel.js";
import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
import { resolveNostrAccount } from "./src/types.js";
import type { NostrProfile } from "./src/config-schema.js";
const plugin = {
id: "nostr",
name: "Nostr",
description: "Nostr DM channel plugin via NIP-04",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setNostrRuntime(api.runtime);
api.registerChannel({ plugin: nostrPlugin });
// Register HTTP handler for profile management
const httpHandler = createNostrProfileHttpHandler({
getConfigProfile: (accountId: string) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
return account.profile;
},
updateConfigProfile: async (accountId: string, profile: NostrProfile) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
// Build the config patch for channels.nostr.profile
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
const updatedNostrConfig = {
...nostrConfig,
profile,
};
const updatedChannels = {
...channels,
nostr: updatedNostrConfig,
};
await runtime.config.writeConfigFile({
...cfg,
channels: updatedChannels,
});
},
getAccountInfo: (accountId: string) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
if (!account.configured || !account.publicKey) {
return null;
}
return {
pubkey: account.publicKey,
relays: account.relays,
};
},
log: api.logger,
});
api.registerHttpHandler(httpHandler);
},
};
export default plugin;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.20",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "nostr",
"label": "Nostr",
"selectionLabel": "Nostr (NIP-04 DMs)",
"docsPath": "/channels/nostr",
"docsLabel": "nostr",
"blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
"order": 55,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/nostr",
"localPath": "extensions/nostr",
"defaultChoice": "npm"
}
},
"dependencies": {
"clawdbot": "workspace:*",
"nostr-tools": "^2.10.4",
"zod": "^4.3.5"
}
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { nostrPlugin } from "./channel.js";
describe("nostrPlugin", () => {
describe("meta", () => {
it("has correct id", () => {
expect(nostrPlugin.id).toBe("nostr");
});
it("has required meta fields", () => {
expect(nostrPlugin.meta.label).toBe("Nostr");
expect(nostrPlugin.meta.docsPath).toBe("/channels/nostr");
expect(nostrPlugin.meta.blurb).toContain("NIP-04");
});
});
describe("capabilities", () => {
it("supports direct messages", () => {
expect(nostrPlugin.capabilities.chatTypes).toContain("direct");
});
it("does not support groups (MVP)", () => {
expect(nostrPlugin.capabilities.chatTypes).not.toContain("group");
});
it("does not support media (MVP)", () => {
expect(nostrPlugin.capabilities.media).toBe(false);
});
});
describe("config adapter", () => {
it("has required config functions", () => {
expect(nostrPlugin.config.listAccountIds).toBeTypeOf("function");
expect(nostrPlugin.config.resolveAccount).toBeTypeOf("function");
expect(nostrPlugin.config.isConfigured).toBeTypeOf("function");
});
it("listAccountIds returns empty array for unconfigured", () => {
const cfg = { channels: {} };
const ids = nostrPlugin.config.listAccountIds(cfg);
expect(ids).toEqual([]);
});
it("listAccountIds returns default for configured", () => {
const cfg = {
channels: {
nostr: {
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
},
};
const ids = nostrPlugin.config.listAccountIds(cfg);
expect(ids).toContain("default");
});
});
describe("messaging", () => {
it("has target resolver", () => {
expect(nostrPlugin.messaging?.targetResolver?.looksLikeId).toBeTypeOf("function");
});
it("recognizes npub as valid target", () => {
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
if (!looksLikeId) return;
expect(looksLikeId("npub1xyz123")).toBe(true);
});
it("recognizes hex pubkey as valid target", () => {
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
if (!looksLikeId) return;
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(looksLikeId(hexPubkey)).toBe(true);
});
it("rejects invalid input", () => {
const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId;
if (!looksLikeId) return;
expect(looksLikeId("not-a-pubkey")).toBe(false);
expect(looksLikeId("")).toBe(false);
});
it("normalizeTarget strips nostr: prefix", () => {
const normalize = nostrPlugin.messaging?.normalizeTarget;
if (!normalize) return;
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
});
});
describe("outbound", () => {
it("has correct delivery mode", () => {
expect(nostrPlugin.outbound?.deliveryMode).toBe("direct");
});
it("has reasonable text chunk limit", () => {
expect(nostrPlugin.outbound?.textChunkLimit).toBe(4000);
});
});
describe("pairing", () => {
it("has id label for pairing", () => {
expect(nostrPlugin.pairing?.idLabel).toBe("nostrPubkey");
});
it("normalizes nostr: prefix in allow entries", () => {
const normalize = nostrPlugin.pairing?.normalizeAllowEntry;
if (!normalize) return;
const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey);
});
});
describe("security", () => {
it("has resolveDmPolicy function", () => {
expect(nostrPlugin.security?.resolveDmPolicy).toBeTypeOf("function");
});
});
describe("gateway", () => {
it("has startAccount function", () => {
expect(nostrPlugin.gateway?.startAccount).toBeTypeOf("function");
});
});
describe("status", () => {
it("has default runtime", () => {
expect(nostrPlugin.status?.defaultRuntime).toBeDefined();
expect(nostrPlugin.status?.defaultRuntime?.accountId).toBe("default");
expect(nostrPlugin.status?.defaultRuntime?.running).toBe(false);
});
it("has buildAccountSnapshot function", () => {
expect(nostrPlugin.status?.buildAccountSnapshot).toBeTypeOf("function");
});
});
});
+335
View File
@@ -0,0 +1,335 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
formatPairingApproveHint,
type ChannelPlugin,
} from "clawdbot/plugin-sdk";
import { NostrConfigSchema } from "./config-schema.js";
import { getNostrRuntime } from "./runtime.js";
import {
listNostrAccountIds,
resolveDefaultNostrAccountId,
resolveNostrAccount,
type ResolvedNostrAccount,
} from "./types.js";
import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
import type { NostrProfile } from "./config-schema.js";
import type { ProfilePublishResult } from "./nostr-profile.js";
// Store active bus handles per account
const activeBuses = new Map<string, NostrBusHandle>();
// Store metrics snapshots per account (for status reporting)
const metricsSnapshots = new Map<string, MetricsSnapshot>();
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
id: "nostr",
meta: {
id: "nostr",
label: "Nostr",
selectionLabel: "Nostr",
docsPath: "/channels/nostr",
docsLabel: "nostr",
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
order: 100,
},
capabilities: {
chatTypes: ["direct"], // DMs only for MVP
media: false, // No media for MVP
},
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
config: {
listAccountIds: (cfg) => listNostrAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
publicKey: account.publicKey,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveNostrAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry)
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
if (entry === "*") return "*";
try {
return normalizePubkey(entry);
} catch {
return entry; // Keep as-is if normalization fails
}
})
.filter(Boolean),
},
pairing: {
idLabel: "nostrPubkey",
normalizeAllowEntry: (entry) => {
try {
return normalizePubkey(entry.replace(/^nostr:/i, ""));
} catch {
return entry;
}
},
notifyApproval: async ({ id }) => {
// Get the default account's bus and send approval message
const bus = activeBuses.get(DEFAULT_ACCOUNT_ID);
if (bus) {
await bus.sendDm(id, "Your pairing request has been approved!");
}
},
},
security: {
resolveDmPolicy: ({ account }) => {
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: "channels.nostr.dmPolicy",
allowFromPath: "channels.nostr.allowFrom",
approveHint: formatPairingApproveHint("nostr"),
normalizeEntry: (raw) => {
try {
return normalizePubkey(raw.replace(/^nostr:/i, "").trim());
} catch {
return raw.trim();
}
},
};
},
},
messaging: {
normalizeTarget: (target) => {
// Strip nostr: prefix if present
const cleaned = target.replace(/^nostr:/i, "").trim();
try {
return normalizePubkey(cleaned);
} catch {
return cleaned;
}
},
targetResolver: {
looksLikeId: (input) => {
const trimmed = input.trim();
return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
},
hint: "<npub|hex pubkey|nostr:npub...>",
},
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => {
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`);
}
const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, text);
return { channel: "nostr", to: normalizedTo };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "nostr",
accountId: account.accountId,
kind: "runtime" as const,
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
publicKey: snapshot.publicKey ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
publicKey: account.publicKey,
profile: account.profile,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
publicKey: account.publicKey,
});
ctx.log?.info(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`);
if (!account.configured) {
throw new Error("Nostr private key not configured");
}
const runtime = getNostrRuntime();
// Track bus handle for metrics callback
let busHandle: NostrBusHandle | null = null;
const bus = await startNostrBus({
accountId: account.accountId,
privateKey: account.privateKey,
relays: account.relays,
onMessage: async (senderPubkey, text, reply) => {
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);
// Forward to clawdbot's message pipeline
await runtime.channel.reply.handleInboundMessage({
channel: "nostr",
accountId: account.accountId,
senderId: senderPubkey,
chatType: "direct",
chatId: senderPubkey, // For DMs, chatId is the sender's pubkey
text,
reply: async (responseText: string) => {
await reply(responseText);
},
});
},
onError: (error, context) => {
ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
},
onConnect: (relay) => {
ctx.log?.debug(`[${account.accountId}] Connected to relay: ${relay}`);
},
onDisconnect: (relay) => {
ctx.log?.debug(`[${account.accountId}] Disconnected from relay: ${relay}`);
},
onEose: (relays) => {
ctx.log?.debug(`[${account.accountId}] EOSE received from relays: ${relays}`);
},
onMetric: (event: MetricEvent) => {
// Log significant metrics at appropriate levels
if (event.name.startsWith("event.rejected.")) {
ctx.log?.debug(`[${account.accountId}] Metric: ${event.name}`, event.labels);
} else if (event.name === "relay.circuit_breaker.open") {
ctx.log?.warn(`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`);
} else if (event.name === "relay.circuit_breaker.close") {
ctx.log?.info(`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`);
} else if (event.name === "relay.error") {
ctx.log?.debug(`[${account.accountId}] Relay error: ${event.labels?.relay}`);
}
// Update cached metrics snapshot
if (busHandle) {
metricsSnapshots.set(account.accountId, busHandle.getMetrics());
}
},
});
busHandle = bus;
// Store the bus handle
activeBuses.set(account.accountId, bus);
ctx.log?.info(`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`);
// Return cleanup function
return {
stop: () => {
bus.close();
activeBuses.delete(account.accountId);
metricsSnapshots.delete(account.accountId);
ctx.log?.info(`[${account.accountId}] Nostr provider stopped`);
},
};
},
},
};
/**
* Get metrics snapshot for a Nostr account.
* Returns undefined if account is not running.
*/
export function getNostrMetrics(accountId: string = DEFAULT_ACCOUNT_ID): MetricsSnapshot | undefined {
const bus = activeBuses.get(accountId);
if (bus) {
return bus.getMetrics();
}
return metricsSnapshots.get(accountId);
}
/**
* Get all active Nostr bus handles.
* Useful for debugging and status reporting.
*/
export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
return new Map(activeBuses);
}
/**
* Publish a profile (kind:0) for a Nostr account.
* @param accountId - Account ID (defaults to "default")
* @param profile - Profile data to publish
* @returns Publish results with successes and failures
* @throws Error if account is not running
*/
export async function publishNostrProfile(
accountId: string = DEFAULT_ACCOUNT_ID,
profile: NostrProfile
): Promise<ProfilePublishResult> {
const bus = activeBuses.get(accountId);
if (!bus) {
throw new Error(`Nostr bus not running for account ${accountId}`);
}
return bus.publishProfile(profile);
}
/**
* Get profile publish state for a Nostr account.
* @param accountId - Account ID (defaults to "default")
* @returns Profile publish state or null if account not running
*/
export async function getNostrProfileState(
accountId: string = DEFAULT_ACCOUNT_ID
): Promise<{
lastPublishedAt: number | null;
lastPublishedEventId: string | null;
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
} | null> {
const bus = activeBuses.get(accountId);
if (!bus) {
return null;
}
return bus.getProfileState();
}
+87
View File
@@ -0,0 +1,87 @@
import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const allowFromEntry = z.union([z.string(), z.number()]);
/**
* Validates https:// URLs only (no javascript:, data:, file:, etc.)
*/
const safeUrlSchema = z
.string()
.url()
.refine(
(url) => {
try {
const parsed = new URL(url);
return parsed.protocol === "https:";
} catch {
return false;
}
},
{ message: "URL must use https:// protocol" }
);
/**
* NIP-01 profile metadata schema
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
export const NostrProfileSchema = z.object({
/** Username (NIP-01: name) - max 256 chars */
name: z.string().max(256).optional(),
/** Display name (NIP-01: display_name) - max 256 chars */
displayName: z.string().max(256).optional(),
/** Bio/description (NIP-01: about) - max 2000 chars */
about: z.string().max(2000).optional(),
/** Profile picture URL (must be https) */
picture: safeUrlSchema.optional(),
/** Banner image URL (must be https) */
banner: safeUrlSchema.optional(),
/** Website URL (must be https) */
website: safeUrlSchema.optional(),
/** NIP-05 identifier (e.g., "user@example.com") */
nip05: z.string().optional(),
/** Lightning address (LUD-16) */
lud16: z.string().optional(),
});
export type NostrProfile = z.infer<typeof NostrProfileSchema>;
/**
* Zod schema for channels.nostr.* configuration
*/
export const NostrConfigSchema = z.object({
/** Account name (optional display name) */
name: z.string().optional(),
/** Whether this channel is enabled */
enabled: z.boolean().optional(),
/** Private key in hex or nsec bech32 format */
privateKey: z.string().optional(),
/** WebSocket relay URLs to connect to */
relays: z.array(z.string()).optional(),
/** DM access policy: pairing, allowlist, open, or disabled */
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
/** Allowed sender pubkeys (npub or hex format) */
allowFrom: z.array(allowFromEntry).optional(),
/** Profile metadata (NIP-01 kind:0 content) */
profile: NostrProfileSchema.optional(),
});
export type NostrConfig = z.infer<typeof NostrConfigSchema>;
/**
* JSON Schema for Control UI (converted from Zod)
*/
export const nostrChannelConfigSchema = buildChannelConfigSchema(NostrConfigSchema);
+464
View File
@@ -0,0 +1,464 @@
/**
* Comprehensive metrics system for Nostr bus observability.
* Provides clear insight into what's happening with events, relays, and operations.
*/
// ============================================================================
// Metric Types
// ============================================================================
export type EventMetricName =
| "event.received"
| "event.processed"
| "event.duplicate"
| "event.rejected.invalid_shape"
| "event.rejected.wrong_kind"
| "event.rejected.stale"
| "event.rejected.future"
| "event.rejected.rate_limited"
| "event.rejected.invalid_signature"
| "event.rejected.oversized_ciphertext"
| "event.rejected.oversized_plaintext"
| "event.rejected.decrypt_failed"
| "event.rejected.self_message";
export type RelayMetricName =
| "relay.connect"
| "relay.disconnect"
| "relay.reconnect"
| "relay.error"
| "relay.message.event"
| "relay.message.eose"
| "relay.message.closed"
| "relay.message.notice"
| "relay.message.ok"
| "relay.message.auth"
| "relay.circuit_breaker.open"
| "relay.circuit_breaker.close"
| "relay.circuit_breaker.half_open";
export type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global";
export type DecryptMetricName = "decrypt.success" | "decrypt.failure";
export type MemoryMetricName =
| "memory.seen_tracker_size"
| "memory.rate_limiter_entries";
export type MetricName =
| EventMetricName
| RelayMetricName
| RateLimitMetricName
| DecryptMetricName
| MemoryMetricName;
// ============================================================================
// Metric Event
// ============================================================================
export interface MetricEvent {
/** Metric name (e.g., "event.received", "relay.connect") */
name: MetricName;
/** Metric value (usually 1 for counters, or a measured value) */
value: number;
/** Unix timestamp in milliseconds */
timestamp: number;
/** Optional labels for additional context */
labels?: Record<string, string | number>;
}
export type OnMetricCallback = (event: MetricEvent) => void;
// ============================================================================
// Metrics Snapshot (for getMetrics())
// ============================================================================
export interface MetricsSnapshot {
/** Total events received (before any filtering) */
eventsReceived: number;
/** Events successfully processed */
eventsProcessed: number;
/** Duplicate events skipped */
eventsDuplicate: number;
/** Events rejected by reason */
eventsRejected: {
invalidShape: number;
wrongKind: number;
stale: number;
future: number;
rateLimited: number;
invalidSignature: number;
oversizedCiphertext: number;
oversizedPlaintext: number;
decryptFailed: number;
selfMessage: number;
};
/** Relay stats by URL */
relays: Record<
string,
{
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
}
>;
/** Rate limiting stats */
rateLimiting: {
perSenderHits: number;
globalHits: number;
};
/** Decrypt stats */
decrypt: {
success: number;
failure: number;
};
/** Memory/capacity stats */
memory: {
seenTrackerSize: number;
rateLimiterEntries: number;
};
/** Snapshot timestamp */
snapshotAt: number;
}
// ============================================================================
// Metrics Collector
// ============================================================================
export interface NostrMetrics {
/** Emit a metric event */
emit: (
name: MetricName,
value?: number,
labels?: Record<string, string | number>
) => void;
/** Get current metrics snapshot */
getSnapshot: () => MetricsSnapshot;
/** Reset all metrics to zero */
reset: () => void;
}
/**
* Create a metrics collector instance.
* Optionally pass an onMetric callback to receive real-time metric events.
*/
export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
// Counters
let eventsReceived = 0;
let eventsProcessed = 0;
let eventsDuplicate = 0;
const eventsRejected = {
invalidShape: 0,
wrongKind: 0,
stale: 0,
future: 0,
rateLimited: 0,
invalidSignature: 0,
oversizedCiphertext: 0,
oversizedPlaintext: 0,
decryptFailed: 0,
selfMessage: 0,
};
// Per-relay stats
const relays = new Map<
string,
{
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
}
>();
// Rate limiting stats
const rateLimiting = {
perSenderHits: 0,
globalHits: 0,
};
// Decrypt stats
const decrypt = {
success: 0,
failure: 0,
};
// Memory stats (updated via gauge-style metrics)
const memory = {
seenTrackerSize: 0,
rateLimiterEntries: 0,
};
function getOrCreateRelay(url: string) {
let relay = relays.get(url);
if (!relay) {
relay = {
connects: 0,
disconnects: 0,
reconnects: 0,
errors: 0,
messagesReceived: {
event: 0,
eose: 0,
closed: 0,
notice: 0,
ok: 0,
auth: 0,
},
circuitBreakerState: "closed",
circuitBreakerOpens: 0,
circuitBreakerCloses: 0,
};
relays.set(url, relay);
}
return relay;
}
function emit(
name: MetricName,
value: number = 1,
labels?: Record<string, string | number>
): void {
// Fire callback if provided
if (onMetric) {
onMetric({
name,
value,
timestamp: Date.now(),
labels,
});
}
// Update internal counters
const relayUrl = labels?.relay as string | undefined;
switch (name) {
// Event metrics
case "event.received":
eventsReceived += value;
break;
case "event.processed":
eventsProcessed += value;
break;
case "event.duplicate":
eventsDuplicate += value;
break;
case "event.rejected.invalid_shape":
eventsRejected.invalidShape += value;
break;
case "event.rejected.wrong_kind":
eventsRejected.wrongKind += value;
break;
case "event.rejected.stale":
eventsRejected.stale += value;
break;
case "event.rejected.future":
eventsRejected.future += value;
break;
case "event.rejected.rate_limited":
eventsRejected.rateLimited += value;
break;
case "event.rejected.invalid_signature":
eventsRejected.invalidSignature += value;
break;
case "event.rejected.oversized_ciphertext":
eventsRejected.oversizedCiphertext += value;
break;
case "event.rejected.oversized_plaintext":
eventsRejected.oversizedPlaintext += value;
break;
case "event.rejected.decrypt_failed":
eventsRejected.decryptFailed += value;
break;
case "event.rejected.self_message":
eventsRejected.selfMessage += value;
break;
// Relay metrics
case "relay.connect":
if (relayUrl) getOrCreateRelay(relayUrl).connects += value;
break;
case "relay.disconnect":
if (relayUrl) getOrCreateRelay(relayUrl).disconnects += value;
break;
case "relay.reconnect":
if (relayUrl) getOrCreateRelay(relayUrl).reconnects += value;
break;
case "relay.error":
if (relayUrl) getOrCreateRelay(relayUrl).errors += value;
break;
case "relay.message.event":
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.event += value;
break;
case "relay.message.eose":
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.eose += value;
break;
case "relay.message.closed":
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.closed += value;
break;
case "relay.message.notice":
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.notice += value;
break;
case "relay.message.ok":
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.ok += value;
break;
case "relay.message.auth":
if (relayUrl) getOrCreateRelay(relayUrl).messagesReceived.auth += value;
break;
case "relay.circuit_breaker.open":
if (relayUrl) {
const r = getOrCreateRelay(relayUrl);
r.circuitBreakerState = "open";
r.circuitBreakerOpens += value;
}
break;
case "relay.circuit_breaker.close":
if (relayUrl) {
const r = getOrCreateRelay(relayUrl);
r.circuitBreakerState = "closed";
r.circuitBreakerCloses += value;
}
break;
case "relay.circuit_breaker.half_open":
if (relayUrl) {
getOrCreateRelay(relayUrl).circuitBreakerState = "half_open";
}
break;
// Rate limiting
case "rate_limit.per_sender":
rateLimiting.perSenderHits += value;
break;
case "rate_limit.global":
rateLimiting.globalHits += value;
break;
// Decrypt
case "decrypt.success":
decrypt.success += value;
break;
case "decrypt.failure":
decrypt.failure += value;
break;
// Memory (gauge-style - value replaces, not adds)
case "memory.seen_tracker_size":
memory.seenTrackerSize = value;
break;
case "memory.rate_limiter_entries":
memory.rateLimiterEntries = value;
break;
}
}
function getSnapshot(): MetricsSnapshot {
// Convert relay map to object
const relaysObj: MetricsSnapshot["relays"] = {};
for (const [url, stats] of relays) {
relaysObj[url] = { ...stats, messagesReceived: { ...stats.messagesReceived } };
}
return {
eventsReceived,
eventsProcessed,
eventsDuplicate,
eventsRejected: { ...eventsRejected },
relays: relaysObj,
rateLimiting: { ...rateLimiting },
decrypt: { ...decrypt },
memory: { ...memory },
snapshotAt: Date.now(),
};
}
function reset(): void {
eventsReceived = 0;
eventsProcessed = 0;
eventsDuplicate = 0;
Object.assign(eventsRejected, {
invalidShape: 0,
wrongKind: 0,
stale: 0,
future: 0,
rateLimited: 0,
invalidSignature: 0,
oversizedCiphertext: 0,
oversizedPlaintext: 0,
decryptFailed: 0,
selfMessage: 0,
});
relays.clear();
rateLimiting.perSenderHits = 0;
rateLimiting.globalHits = 0;
decrypt.success = 0;
decrypt.failure = 0;
memory.seenTrackerSize = 0;
memory.rateLimiterEntries = 0;
}
return { emit, getSnapshot, reset };
}
/**
* Create a no-op metrics instance (for when metrics are disabled).
*/
export function createNoopMetrics(): NostrMetrics {
const emptySnapshot: MetricsSnapshot = {
eventsReceived: 0,
eventsProcessed: 0,
eventsDuplicate: 0,
eventsRejected: {
invalidShape: 0,
wrongKind: 0,
stale: 0,
future: 0,
rateLimited: 0,
invalidSignature: 0,
oversizedCiphertext: 0,
oversizedPlaintext: 0,
decryptFailed: 0,
selfMessage: 0,
},
relays: {},
rateLimiting: { perSenderHits: 0, globalHits: 0 },
decrypt: { success: 0, failure: 0 },
memory: { seenTrackerSize: 0, rateLimiterEntries: 0 },
snapshotAt: 0,
};
return {
emit: () => {},
getSnapshot: () => ({ ...emptySnapshot, snapshotAt: Date.now() }),
reset: () => {},
};
}
+544
View File
@@ -0,0 +1,544 @@
import { describe, expect, it } from "vitest";
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js";
import { createSeenTracker } from "./seen-tracker.js";
import { createMetrics, type MetricName } from "./metrics.js";
// ============================================================================
// Fuzz Tests for validatePrivateKey
// ============================================================================
describe("validatePrivateKey fuzz", () => {
describe("type confusion", () => {
it("rejects null input", () => {
expect(() => validatePrivateKey(null as unknown as string)).toThrow();
});
it("rejects undefined input", () => {
expect(() => validatePrivateKey(undefined as unknown as string)).toThrow();
});
it("rejects number input", () => {
expect(() => validatePrivateKey(123 as unknown as string)).toThrow();
});
it("rejects boolean input", () => {
expect(() => validatePrivateKey(true as unknown as string)).toThrow();
});
it("rejects object input", () => {
expect(() => validatePrivateKey({} as unknown as string)).toThrow();
});
it("rejects array input", () => {
expect(() => validatePrivateKey([] as unknown as string)).toThrow();
});
it("rejects function input", () => {
expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow();
});
});
describe("unicode attacks", () => {
it("rejects unicode lookalike characters", () => {
// Using zero-width characters
const withZeroWidth =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf";
expect(() => validatePrivateKey(withZeroWidth)).toThrow();
});
it("rejects RTL override", () => {
const withRtl =
"\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(() => validatePrivateKey(withRtl)).toThrow();
});
it("rejects homoglyph 'a' (Cyrillic а)", () => {
// Using Cyrillic 'а' (U+0430) instead of Latin 'a'
const withCyrillicA =
"0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(() => validatePrivateKey(withCyrillicA)).toThrow();
});
it("rejects emoji", () => {
const withEmoji =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀";
expect(() => validatePrivateKey(withEmoji)).toThrow();
});
it("rejects combining characters", () => {
// 'a' followed by combining acute accent
const withCombining =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301";
expect(() => validatePrivateKey(withCombining)).toThrow();
});
});
describe("injection attempts", () => {
it("rejects null byte injection", () => {
const withNullByte =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f";
expect(() => validatePrivateKey(withNullByte)).toThrow();
});
it("rejects newline injection", () => {
const withNewline =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf";
expect(() => validatePrivateKey(withNewline)).toThrow();
});
it("rejects carriage return injection", () => {
const withCR =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf";
expect(() => validatePrivateKey(withCR)).toThrow();
});
it("rejects tab injection", () => {
const withTab =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf";
expect(() => validatePrivateKey(withTab)).toThrow();
});
it("rejects form feed injection", () => {
const withFormFeed =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff";
expect(() => validatePrivateKey(withFormFeed)).toThrow();
});
});
describe("edge cases", () => {
it("rejects very long string", () => {
const veryLong = "a".repeat(10000);
expect(() => validatePrivateKey(veryLong)).toThrow();
});
it("rejects string of spaces matching length", () => {
const spaces = " ".repeat(64);
expect(() => validatePrivateKey(spaces)).toThrow();
});
it("rejects hex with spaces between characters", () => {
const withSpaces =
"01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef";
expect(() => validatePrivateKey(withSpaces)).toThrow();
});
});
describe("nsec format edge cases", () => {
it("rejects nsec with invalid bech32 characters", () => {
// 'b', 'i', 'o' are not valid bech32 characters
const invalidBech32 = "nsec1qypqxpq9qtpqscx7peytbfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
expect(() => validatePrivateKey(invalidBech32)).toThrow();
});
it("rejects nsec with wrong prefix", () => {
expect(() => validatePrivateKey("nsec0aaaa")).toThrow();
});
it("rejects partial nsec", () => {
expect(() => validatePrivateKey("nsec1")).toThrow();
});
});
});
// ============================================================================
// Fuzz Tests for isValidPubkey
// ============================================================================
describe("isValidPubkey fuzz", () => {
describe("type confusion", () => {
it("handles null gracefully", () => {
expect(isValidPubkey(null as unknown as string)).toBe(false);
});
it("handles undefined gracefully", () => {
expect(isValidPubkey(undefined as unknown as string)).toBe(false);
});
it("handles number gracefully", () => {
expect(isValidPubkey(123 as unknown as string)).toBe(false);
});
it("handles object gracefully", () => {
expect(isValidPubkey({} as unknown as string)).toBe(false);
});
});
describe("malicious inputs", () => {
it("rejects __proto__ key", () => {
expect(isValidPubkey("__proto__")).toBe(false);
});
it("rejects constructor key", () => {
expect(isValidPubkey("constructor")).toBe(false);
});
it("rejects toString key", () => {
expect(isValidPubkey("toString")).toBe(false);
});
});
});
// ============================================================================
// Fuzz Tests for normalizePubkey
// ============================================================================
describe("normalizePubkey fuzz", () => {
describe("prototype pollution attempts", () => {
it("throws for __proto__", () => {
expect(() => normalizePubkey("__proto__")).toThrow();
});
it("throws for constructor", () => {
expect(() => normalizePubkey("constructor")).toThrow();
});
it("throws for prototype", () => {
expect(() => normalizePubkey("prototype")).toThrow();
});
});
describe("case sensitivity", () => {
it("normalizes uppercase to lowercase", () => {
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalizePubkey(upper)).toBe(lower);
});
it("normalizes mixed case to lowercase", () => {
const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf";
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalizePubkey(mixed)).toBe(lower);
});
});
});
// ============================================================================
// Fuzz Tests for SeenTracker
// ============================================================================
describe("SeenTracker fuzz", () => {
describe("malformed IDs", () => {
it("handles empty string IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
expect(() => tracker.add("")).not.toThrow();
expect(tracker.peek("")).toBe(true);
tracker.stop();
});
it("handles very long IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const longId = "a".repeat(100000);
expect(() => tracker.add(longId)).not.toThrow();
expect(tracker.peek(longId)).toBe(true);
tracker.stop();
});
it("handles unicode IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const unicodeId = "事件ID_🎉_тест";
expect(() => tracker.add(unicodeId)).not.toThrow();
expect(tracker.peek(unicodeId)).toBe(true);
tracker.stop();
});
it("handles IDs with null bytes", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
const idWithNull = "event\x00id";
expect(() => tracker.add(idWithNull)).not.toThrow();
expect(tracker.peek(idWithNull)).toBe(true);
tracker.stop();
});
it("handles prototype property names as IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
// These should not affect the tracker's internal operation
expect(() => tracker.add("__proto__")).not.toThrow();
expect(() => tracker.add("constructor")).not.toThrow();
expect(() => tracker.add("toString")).not.toThrow();
expect(() => tracker.add("hasOwnProperty")).not.toThrow();
expect(tracker.peek("__proto__")).toBe(true);
expect(tracker.peek("constructor")).toBe(true);
expect(tracker.peek("toString")).toBe(true);
expect(tracker.peek("hasOwnProperty")).toBe(true);
tracker.stop();
});
});
describe("rapid operations", () => {
it("handles rapid add/check cycles", () => {
const tracker = createSeenTracker({ maxEntries: 1000 });
for (let i = 0; i < 10000; i++) {
const id = `event-${i}`;
tracker.add(id);
// Recently added should be findable
if (i < 1000) {
tracker.peek(id);
}
}
// Size should be capped at maxEntries
expect(tracker.size()).toBeLessThanOrEqual(1000);
tracker.stop();
});
it("handles concurrent-style operations", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
// Simulate interleaved operations
for (let i = 0; i < 100; i++) {
tracker.add(`add-${i}`);
tracker.peek(`peek-${i}`);
tracker.has(`has-${i}`);
if (i % 10 === 0) {
tracker.delete(`add-${i - 5}`);
}
}
expect(() => tracker.size()).not.toThrow();
tracker.stop();
});
});
describe("seed edge cases", () => {
it("handles empty seed array", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
expect(() => tracker.seed([])).not.toThrow();
expect(tracker.size()).toBe(0);
tracker.stop();
});
it("handles seed with duplicate IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100 });
tracker.seed(["id1", "id1", "id1", "id2", "id2"]);
expect(tracker.size()).toBe(2);
tracker.stop();
});
it("handles seed larger than maxEntries", () => {
const tracker = createSeenTracker({ maxEntries: 5 });
const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`);
tracker.seed(ids);
expect(tracker.size()).toBeLessThanOrEqual(5);
tracker.stop();
});
});
});
// ============================================================================
// Fuzz Tests for Metrics
// ============================================================================
describe("Metrics fuzz", () => {
describe("invalid metric names", () => {
it("handles unknown metric names gracefully", () => {
const metrics = createMetrics();
// Cast to bypass type checking - testing runtime behavior
expect(() => {
metrics.emit("invalid.metric.name" as MetricName);
}).not.toThrow();
});
});
describe("invalid label values", () => {
it("handles null relay label", () => {
const metrics = createMetrics();
expect(() => {
metrics.emit("relay.connect", 1, { relay: null as unknown as string });
}).not.toThrow();
});
it("handles undefined relay label", () => {
const metrics = createMetrics();
expect(() => {
metrics.emit("relay.connect", 1, { relay: undefined as unknown as string });
}).not.toThrow();
});
it("handles very long relay URL", () => {
const metrics = createMetrics();
const longUrl = "wss://" + "a".repeat(10000) + ".com";
expect(() => {
metrics.emit("relay.connect", 1, { relay: longUrl });
}).not.toThrow();
const snapshot = metrics.getSnapshot();
expect(snapshot.relays[longUrl]).toBeDefined();
});
});
describe("extreme values", () => {
it("handles NaN value", () => {
const metrics = createMetrics();
expect(() => metrics.emit("event.received", NaN)).not.toThrow();
const snapshot = metrics.getSnapshot();
expect(isNaN(snapshot.eventsReceived)).toBe(true);
});
it("handles Infinity value", () => {
const metrics = createMetrics();
expect(() => metrics.emit("event.received", Infinity)).not.toThrow();
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(Infinity);
});
it("handles negative value", () => {
const metrics = createMetrics();
metrics.emit("event.received", -1);
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(-1);
});
it("handles very large value", () => {
const metrics = createMetrics();
metrics.emit("event.received", Number.MAX_SAFE_INTEGER);
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(Number.MAX_SAFE_INTEGER);
});
});
describe("rapid emissions", () => {
it("handles many rapid emissions", () => {
const events: unknown[] = [];
const metrics = createMetrics((e) => events.push(e));
for (let i = 0; i < 10000; i++) {
metrics.emit("event.received");
}
expect(events).toHaveLength(10000);
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(10000);
});
});
describe("reset during operation", () => {
it("handles reset mid-operation safely", () => {
const metrics = createMetrics();
metrics.emit("event.received");
metrics.emit("event.received");
metrics.reset();
metrics.emit("event.received");
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(1);
});
});
});
// ============================================================================
// Event Shape Validation (simulating malformed events)
// ============================================================================
describe("Event shape validation", () => {
describe("malformed event structures", () => {
// These test what happens if malformed data somehow gets through
it("identifies missing required fields", () => {
const malformedEvents = [
{}, // empty
{ id: "abc" }, // missing pubkey, created_at, etc.
{ id: null, pubkey: null }, // null values
{ id: 123, pubkey: 456 }, // wrong types
{ tags: "not-an-array" }, // wrong type for tags
{ tags: [[1, 2, 3]] }, // wrong type for tag elements
];
for (const event of malformedEvents) {
// These should be caught by shape validation before processing
const hasId = typeof event?.id === "string";
const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string";
const hasTags = Array.isArray((event as { tags?: unknown })?.tags);
// At least one should be invalid
expect(hasId && hasPubkey && hasTags).toBe(false);
}
});
});
describe("timestamp edge cases", () => {
const testTimestamps = [
{ value: NaN, desc: "NaN" },
{ value: Infinity, desc: "Infinity" },
{ value: -Infinity, desc: "-Infinity" },
{ value: -1, desc: "negative" },
{ value: 0, desc: "zero" },
{ value: 253402300800, desc: "year 10000" }, // Far future
{ value: -62135596800, desc: "year 0001" }, // Far past
{ value: 1.5, desc: "float" },
];
for (const { value, desc } of testTimestamps) {
it(`handles ${desc} timestamp`, () => {
const isValidTimestamp =
typeof value === "number" &&
!isNaN(value) &&
isFinite(value) &&
value >= 0 &&
Number.isInteger(value);
// Timestamps should be validated as positive integers
if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) {
expect(isValidTimestamp).toBe(false);
}
});
}
});
});
// ============================================================================
// JSON parsing edge cases (simulating relay responses)
// ============================================================================
describe("JSON parsing edge cases", () => {
const malformedJsonCases = [
{ input: "", desc: "empty string" },
{ input: "null", desc: "null literal" },
{ input: "undefined", desc: "undefined literal" },
{ input: "{", desc: "incomplete object" },
{ input: "[", desc: "incomplete array" },
{ input: '{"key": undefined}', desc: "undefined value" },
{ input: "{'key': 'value'}", desc: "single quotes" },
{ input: '{"key": NaN}', desc: "NaN value" },
{ input: '{"key": Infinity}', desc: "Infinity value" },
{ input: "\x00", desc: "null byte" },
{ input: "abc", desc: "plain string" },
{ input: "123", desc: "plain number" },
];
for (const { input, desc } of malformedJsonCases) {
it(`handles malformed JSON: ${desc}`, () => {
let parsed: unknown;
let parseError = false;
try {
parsed = JSON.parse(input);
} catch {
parseError = true;
}
// Either it throws or produces something that needs validation
if (!parseError) {
// If it parsed, we need to validate the structure
const isValidRelayMessage =
Array.isArray(parsed) &&
parsed.length >= 2 &&
typeof parsed[0] === "string";
// Most malformed cases won't produce valid relay messages
if (["null literal", "plain number", "plain string"].includes(desc)) {
expect(isValidRelayMessage).toBe(false);
}
}
});
}
});
@@ -0,0 +1,452 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createSeenTracker } from "./seen-tracker.js";
import {
createMetrics,
createNoopMetrics,
type MetricEvent,
} from "./metrics.js";
// ============================================================================
// Seen Tracker Integration Tests
// ============================================================================
describe("SeenTracker", () => {
describe("basic operations", () => {
it("tracks seen IDs", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
// First check returns false and adds
expect(tracker.has("id1")).toBe(false);
// Second check returns true (already seen)
expect(tracker.has("id1")).toBe(true);
tracker.stop();
});
it("peek does not add", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
expect(tracker.peek("id1")).toBe(false);
expect(tracker.peek("id1")).toBe(false); // Still false
tracker.add("id1");
expect(tracker.peek("id1")).toBe(true);
tracker.stop();
});
it("delete removes entries", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
tracker.add("id1");
expect(tracker.peek("id1")).toBe(true);
tracker.delete("id1");
expect(tracker.peek("id1")).toBe(false);
tracker.stop();
});
it("clear removes all entries", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
tracker.add("id1");
tracker.add("id2");
tracker.add("id3");
expect(tracker.size()).toBe(3);
tracker.clear();
expect(tracker.size()).toBe(0);
expect(tracker.peek("id1")).toBe(false);
tracker.stop();
});
it("seed pre-populates entries", () => {
const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
tracker.seed(["id1", "id2", "id3"]);
expect(tracker.size()).toBe(3);
expect(tracker.peek("id1")).toBe(true);
expect(tracker.peek("id2")).toBe(true);
expect(tracker.peek("id3")).toBe(true);
tracker.stop();
});
});
describe("LRU eviction", () => {
it("evicts least recently used when at capacity", () => {
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
tracker.add("id1");
tracker.add("id2");
tracker.add("id3");
expect(tracker.size()).toBe(3);
// Adding fourth should evict oldest (id1)
tracker.add("id4");
expect(tracker.size()).toBe(3);
expect(tracker.peek("id1")).toBe(false); // Evicted
expect(tracker.peek("id2")).toBe(true);
expect(tracker.peek("id3")).toBe(true);
expect(tracker.peek("id4")).toBe(true);
tracker.stop();
});
it("accessing an entry moves it to front (prevents eviction)", () => {
const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
tracker.add("id1");
tracker.add("id2");
tracker.add("id3");
// Access id1, moving it to front
tracker.has("id1");
// Add id4 - should evict id2 (now oldest)
tracker.add("id4");
expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed
expect(tracker.peek("id2")).toBe(false); // Evicted
expect(tracker.peek("id3")).toBe(true);
expect(tracker.peek("id4")).toBe(true);
tracker.stop();
});
it("handles capacity of 1", () => {
const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 });
tracker.add("id1");
expect(tracker.peek("id1")).toBe(true);
tracker.add("id2");
expect(tracker.peek("id1")).toBe(false);
expect(tracker.peek("id2")).toBe(true);
tracker.stop();
});
it("seed respects maxEntries", () => {
const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 });
tracker.seed(["id1", "id2", "id3", "id4"]);
expect(tracker.size()).toBe(2);
// Seed stops when maxEntries reached, processing from end to start
// So id4 and id3 get added first, then we're at capacity
expect(tracker.peek("id3")).toBe(true);
expect(tracker.peek("id4")).toBe(true);
tracker.stop();
});
});
describe("TTL expiration", () => {
it("expires entries after TTL", async () => {
vi.useFakeTimers();
const tracker = createSeenTracker({
maxEntries: 100,
ttlMs: 100,
pruneIntervalMs: 50,
});
tracker.add("id1");
expect(tracker.peek("id1")).toBe(true);
// Advance past TTL
vi.advanceTimersByTime(150);
// Entry should be expired
expect(tracker.peek("id1")).toBe(false);
tracker.stop();
vi.useRealTimers();
});
it("has() refreshes TTL", async () => {
vi.useFakeTimers();
const tracker = createSeenTracker({
maxEntries: 100,
ttlMs: 100,
pruneIntervalMs: 50,
});
tracker.add("id1");
// Advance halfway
vi.advanceTimersByTime(50);
// Access to refresh
expect(tracker.has("id1")).toBe(true);
// Advance another 75ms (total 125ms from add, but only 75ms from last access)
vi.advanceTimersByTime(75);
// Should still be valid (refreshed at 50ms)
expect(tracker.peek("id1")).toBe(true);
tracker.stop();
vi.useRealTimers();
});
});
});
// ============================================================================
// Metrics Integration Tests
// ============================================================================
describe("Metrics", () => {
describe("createMetrics", () => {
it("emits metric events to callback", () => {
const events: MetricEvent[] = [];
const metrics = createMetrics((event) => events.push(event));
metrics.emit("event.received");
metrics.emit("event.processed");
metrics.emit("event.duplicate");
expect(events).toHaveLength(3);
expect(events[0].name).toBe("event.received");
expect(events[1].name).toBe("event.processed");
expect(events[2].name).toBe("event.duplicate");
});
it("includes labels in metric events", () => {
const events: MetricEvent[] = [];
const metrics = createMetrics((event) => events.push(event));
metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" });
expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" });
});
it("accumulates counters in snapshot", () => {
const metrics = createMetrics();
metrics.emit("event.received");
metrics.emit("event.received");
metrics.emit("event.processed");
metrics.emit("event.duplicate");
metrics.emit("event.duplicate");
metrics.emit("event.duplicate");
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(2);
expect(snapshot.eventsProcessed).toBe(1);
expect(snapshot.eventsDuplicate).toBe(3);
});
it("tracks per-relay stats", () => {
const metrics = createMetrics();
metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" });
metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" });
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
const snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://relay1.com"]).toBeDefined();
expect(snapshot.relays["wss://relay1.com"].connects).toBe(1);
expect(snapshot.relays["wss://relay1.com"].errors).toBe(2);
expect(snapshot.relays["wss://relay2.com"].connects).toBe(1);
expect(snapshot.relays["wss://relay2.com"].errors).toBe(0);
});
it("tracks circuit breaker state changes", () => {
const metrics = createMetrics();
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
let snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open");
expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1);
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed");
expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1);
});
it("tracks all rejection reasons", () => {
const metrics = createMetrics();
metrics.emit("event.rejected.invalid_shape");
metrics.emit("event.rejected.wrong_kind");
metrics.emit("event.rejected.stale");
metrics.emit("event.rejected.future");
metrics.emit("event.rejected.rate_limited");
metrics.emit("event.rejected.invalid_signature");
metrics.emit("event.rejected.oversized_ciphertext");
metrics.emit("event.rejected.oversized_plaintext");
metrics.emit("event.rejected.decrypt_failed");
metrics.emit("event.rejected.self_message");
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsRejected.invalidShape).toBe(1);
expect(snapshot.eventsRejected.wrongKind).toBe(1);
expect(snapshot.eventsRejected.stale).toBe(1);
expect(snapshot.eventsRejected.future).toBe(1);
expect(snapshot.eventsRejected.rateLimited).toBe(1);
expect(snapshot.eventsRejected.invalidSignature).toBe(1);
expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1);
expect(snapshot.eventsRejected.decryptFailed).toBe(1);
expect(snapshot.eventsRejected.selfMessage).toBe(1);
});
it("tracks relay message types", () => {
const metrics = createMetrics();
metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" });
metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" });
const snapshot = metrics.getSnapshot();
const relay = snapshot.relays["wss://relay.com"];
expect(relay.messagesReceived.event).toBe(1);
expect(relay.messagesReceived.eose).toBe(1);
expect(relay.messagesReceived.closed).toBe(1);
expect(relay.messagesReceived.notice).toBe(1);
expect(relay.messagesReceived.ok).toBe(1);
expect(relay.messagesReceived.auth).toBe(1);
});
it("tracks decrypt success/failure", () => {
const metrics = createMetrics();
metrics.emit("decrypt.success");
metrics.emit("decrypt.success");
metrics.emit("decrypt.failure");
const snapshot = metrics.getSnapshot();
expect(snapshot.decrypt.success).toBe(2);
expect(snapshot.decrypt.failure).toBe(1);
});
it("tracks memory gauges (replaces rather than accumulates)", () => {
const metrics = createMetrics();
metrics.emit("memory.seen_tracker_size", 100);
metrics.emit("memory.seen_tracker_size", 150);
metrics.emit("memory.seen_tracker_size", 125);
const snapshot = metrics.getSnapshot();
expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum
});
it("reset clears all counters", () => {
const metrics = createMetrics();
metrics.emit("event.received");
metrics.emit("event.processed");
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
metrics.reset();
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(0);
expect(snapshot.eventsProcessed).toBe(0);
expect(Object.keys(snapshot.relays)).toHaveLength(0);
});
});
describe("createNoopMetrics", () => {
it("does not throw on emit", () => {
const metrics = createNoopMetrics();
expect(() => {
metrics.emit("event.received");
metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
}).not.toThrow();
});
it("returns empty snapshot", () => {
const metrics = createNoopMetrics();
const snapshot = metrics.getSnapshot();
expect(snapshot.eventsReceived).toBe(0);
expect(snapshot.eventsProcessed).toBe(0);
});
});
});
// ============================================================================
// Circuit Breaker Behavior Tests
// ============================================================================
describe("Circuit Breaker Behavior", () => {
// Test the circuit breaker logic through metrics emissions
it("emits circuit breaker metrics in correct sequence", () => {
const events: MetricEvent[] = [];
const metrics = createMetrics((event) => events.push(event));
// Simulate 5 failures -> open
for (let i = 0; i < 5; i++) {
metrics.emit("relay.error", 1, { relay: "wss://relay.com" });
}
metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
// Simulate recovery
metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" });
metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
expect(cbEvents).toHaveLength(3);
expect(cbEvents[0].name).toBe("relay.circuit_breaker.open");
expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open");
expect(cbEvents[2].name).toBe("relay.circuit_breaker.close");
});
});
// ============================================================================
// Health Scoring Behavior Tests
// ============================================================================
describe("Health Scoring", () => {
it("metrics track relay errors for health scoring", () => {
const metrics = createMetrics();
// Simulate mixed success/failure pattern
metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" });
metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
const snapshot = metrics.getSnapshot();
expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0);
expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3);
});
});
// ============================================================================
// Reconnect Backoff Tests
// ============================================================================
describe("Reconnect Backoff", () => {
it("computes delays within expected bounds", () => {
// Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap)
const BASE = 1000;
const MAX = 60000;
const JITTER = 0.3;
for (let attempt = 0; attempt < 10; attempt++) {
const exponential = BASE * Math.pow(2, attempt);
const capped = Math.min(exponential, MAX);
const minDelay = capped * (1 - JITTER);
const maxDelay = capped * (1 + JITTER);
// These are the expected bounds
expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7);
expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3);
}
});
});
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
validatePrivateKey,
getPublicKeyFromPrivate,
isValidPubkey,
normalizePubkey,
pubkeyToNpub,
} from "./nostr-bus.js";
// Test private key (DO NOT use in production - this is a known test key)
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l";
describe("validatePrivateKey", () => {
describe("hex format", () => {
it("accepts valid 64-char hex key", () => {
const result = validatePrivateKey(TEST_HEX_KEY);
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(32);
});
it("accepts lowercase hex", () => {
const result = validatePrivateKey(TEST_HEX_KEY.toLowerCase());
expect(result).toBeInstanceOf(Uint8Array);
});
it("accepts uppercase hex", () => {
const result = validatePrivateKey(TEST_HEX_KEY.toUpperCase());
expect(result).toBeInstanceOf(Uint8Array);
});
it("accepts mixed case hex", () => {
const mixed = "0123456789ABCdef0123456789abcDEF0123456789abcdef0123456789ABCDEF";
const result = validatePrivateKey(mixed);
expect(result).toBeInstanceOf(Uint8Array);
});
it("trims whitespace", () => {
const result = validatePrivateKey(` ${TEST_HEX_KEY} `);
expect(result).toBeInstanceOf(Uint8Array);
});
it("trims newlines", () => {
const result = validatePrivateKey(`${TEST_HEX_KEY}\n`);
expect(result).toBeInstanceOf(Uint8Array);
});
it("rejects 63-char hex (too short)", () => {
expect(() => validatePrivateKey(TEST_HEX_KEY.slice(0, 63))).toThrow(
"Private key must be 64 hex characters"
);
});
it("rejects 65-char hex (too long)", () => {
expect(() => validatePrivateKey(TEST_HEX_KEY + "0")).toThrow(
"Private key must be 64 hex characters"
);
});
it("rejects non-hex characters", () => {
const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"; // 'g' at end
expect(() => validatePrivateKey(invalid)).toThrow("Private key must be 64 hex characters");
});
it("rejects empty string", () => {
expect(() => validatePrivateKey("")).toThrow("Private key must be 64 hex characters");
});
it("rejects whitespace-only string", () => {
expect(() => validatePrivateKey(" ")).toThrow("Private key must be 64 hex characters");
});
it("rejects key with 0x prefix", () => {
expect(() => validatePrivateKey("0x" + TEST_HEX_KEY)).toThrow(
"Private key must be 64 hex characters"
);
});
});
describe("nsec format", () => {
it("rejects invalid nsec (wrong checksum)", () => {
const badNsec = "nsec1invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalid";
expect(() => validatePrivateKey(badNsec)).toThrow();
});
it("rejects npub (wrong type)", () => {
const npub = "npub1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8s5epk55";
expect(() => validatePrivateKey(npub)).toThrow();
});
});
});
describe("isValidPubkey", () => {
describe("hex format", () => {
it("accepts valid 64-char hex pubkey", () => {
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(isValidPubkey(validHex)).toBe(true);
});
it("accepts uppercase hex", () => {
const validHex = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
expect(isValidPubkey(validHex)).toBe(true);
});
it("rejects 63-char hex", () => {
const shortHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde";
expect(isValidPubkey(shortHex)).toBe(false);
});
it("rejects 65-char hex", () => {
const longHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0";
expect(isValidPubkey(longHex)).toBe(false);
});
it("rejects non-hex characters", () => {
const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg";
expect(isValidPubkey(invalid)).toBe(false);
});
});
describe("npub format", () => {
it("rejects invalid npub", () => {
expect(isValidPubkey("npub1invalid")).toBe(false);
});
it("rejects nsec (wrong type)", () => {
expect(isValidPubkey(TEST_NSEC)).toBe(false);
});
});
describe("edge cases", () => {
it("rejects empty string", () => {
expect(isValidPubkey("")).toBe(false);
});
it("handles whitespace-padded input", () => {
const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(isValidPubkey(` ${validHex} `)).toBe(true);
});
});
});
describe("normalizePubkey", () => {
describe("hex format", () => {
it("lowercases hex pubkey", () => {
const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
const result = normalizePubkey(upper);
expect(result).toBe(upper.toLowerCase());
});
it("trims whitespace", () => {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
expect(normalizePubkey(` ${hex} `)).toBe(hex);
});
it("rejects invalid hex", () => {
expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters");
});
});
});
describe("getPublicKeyFromPrivate", () => {
it("derives public key from hex private key", () => {
const pubkey = getPublicKeyFromPrivate(TEST_HEX_KEY);
expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
expect(pubkey.length).toBe(64);
});
it("derives consistent public key", () => {
const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_KEY);
const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_KEY);
expect(pubkey1).toBe(pubkey2);
});
it("throws for invalid private key", () => {
expect(() => getPublicKeyFromPrivate("invalid")).toThrow();
});
});
describe("pubkeyToNpub", () => {
it("converts hex pubkey to npub format", () => {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const npub = pubkeyToNpub(hex);
expect(npub).toMatch(/^npub1[a-z0-9]+$/);
});
it("produces consistent output", () => {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const npub1 = pubkeyToNpub(hex);
const npub2 = pubkeyToNpub(hex);
expect(npub1).toBe(npub2);
});
it("normalizes uppercase hex first", () => {
const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const upper = lower.toUpperCase();
expect(pubkeyToNpub(lower)).toBe(pubkeyToNpub(upper));
});
});
+741
View File
@@ -0,0 +1,741 @@
import {
SimplePool,
finalizeEvent,
getPublicKey,
verifyEvent,
nip19,
type Event,
} from "nostr-tools";
import { decrypt, encrypt } from "nostr-tools/nip04";
import {
readNostrBusState,
writeNostrBusState,
computeSinceTimestamp,
readNostrProfileState,
writeNostrProfileState,
} from "./nostr-state-store.js";
import {
publishProfile as publishProfileFn,
type ProfilePublishResult,
} from "./nostr-profile.js";
import type { NostrProfile } from "./config-schema.js";
import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
import {
createMetrics,
createNoopMetrics,
type NostrMetrics,
type MetricsSnapshot,
type MetricEvent,
} from "./metrics.js";
export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
// ============================================================================
// Constants
// ============================================================================
const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew
const MAX_PERSISTED_EVENT_IDS = 5000;
const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes
// Reconnect configuration (exponential backoff with jitter)
const RECONNECT_BASE_MS = 1000; // 1 second base
const RECONNECT_MAX_MS = 60000; // 60 seconds max
const RECONNECT_JITTER = 0.3; // ±30% jitter
// Circuit breaker configuration
const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening
const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open
// Health tracker configuration
const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
// ============================================================================
// Types
// ============================================================================
export interface NostrBusOptions {
/** Private key in hex or nsec format */
privateKey: string;
/** WebSocket relay URLs (defaults to damus + nos.lol) */
relays?: string[];
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
accountId?: string;
/** Called when a DM is received */
onMessage: (
pubkey: string,
text: string,
reply: (text: string) => Promise<void>
) => Promise<void>;
/** Called on errors (optional) */
onError?: (error: Error, context: string) => void;
/** Called on connection status changes (optional) */
onConnect?: (relay: string) => void;
/** Called on disconnection (optional) */
onDisconnect?: (relay: string) => void;
/** Called on EOSE (end of stored events) for initial sync (optional) */
onEose?: (relay: string) => void;
/** Called on each metric event (optional) */
onMetric?: (event: MetricEvent) => void;
/** Maximum entries in seen tracker (default: 100,000) */
maxSeenEntries?: number;
/** Seen tracker TTL in ms (default: 1 hour) */
seenTtlMs?: number;
}
export interface NostrBusHandle {
/** Stop the bus and close connections */
close: () => void;
/** Get the bot's public key */
publicKey: string;
/** Send a DM to a pubkey */
sendDm: (toPubkey: string, text: string) => Promise<void>;
/** Get current metrics snapshot */
getMetrics: () => MetricsSnapshot;
/** Publish a profile (kind:0) to all relays */
publishProfile: (profile: NostrProfile) => Promise<ProfilePublishResult>;
/** Get the last profile publish state */
getProfileState: () => Promise<{
lastPublishedAt: number | null;
lastPublishedEventId: string | null;
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
}>;
}
// ============================================================================
// Circuit Breaker
// ============================================================================
interface CircuitBreakerState {
state: "closed" | "open" | "half_open";
failures: number;
lastFailure: number;
lastSuccess: number;
}
interface CircuitBreaker {
/** Check if requests should be allowed */
canAttempt: () => boolean;
/** Record a success */
recordSuccess: () => void;
/** Record a failure */
recordFailure: () => void;
/** Get current state */
getState: () => CircuitBreakerState["state"];
}
function createCircuitBreaker(
relay: string,
metrics: NostrMetrics,
threshold: number = CIRCUIT_BREAKER_THRESHOLD,
resetMs: number = CIRCUIT_BREAKER_RESET_MS
): CircuitBreaker {
const state: CircuitBreakerState = {
state: "closed",
failures: 0,
lastFailure: 0,
lastSuccess: Date.now(),
};
return {
canAttempt(): boolean {
if (state.state === "closed") return true;
if (state.state === "open") {
// Check if enough time has passed to try half-open
if (Date.now() - state.lastFailure >= resetMs) {
state.state = "half_open";
metrics.emit("relay.circuit_breaker.half_open", 1, { relay });
return true;
}
return false;
}
// half_open: allow one attempt
return true;
},
recordSuccess(): void {
if (state.state === "half_open") {
state.state = "closed";
state.failures = 0;
metrics.emit("relay.circuit_breaker.close", 1, { relay });
} else if (state.state === "closed") {
state.failures = 0;
}
state.lastSuccess = Date.now();
},
recordFailure(): void {
state.failures++;
state.lastFailure = Date.now();
if (state.state === "half_open") {
state.state = "open";
metrics.emit("relay.circuit_breaker.open", 1, { relay });
} else if (state.state === "closed" && state.failures >= threshold) {
state.state = "open";
metrics.emit("relay.circuit_breaker.open", 1, { relay });
}
},
getState(): CircuitBreakerState["state"] {
return state.state;
},
};
}
// ============================================================================
// Relay Health Tracker
// ============================================================================
interface RelayHealthStats {
successCount: number;
failureCount: number;
latencySum: number;
latencyCount: number;
lastSuccess: number;
lastFailure: number;
}
interface RelayHealthTracker {
/** Record a successful operation */
recordSuccess: (relay: string, latencyMs: number) => void;
/** Record a failed operation */
recordFailure: (relay: string) => void;
/** Get health score (0-1, higher is better) */
getScore: (relay: string) => number;
/** Get relays sorted by health (best first) */
getSortedRelays: (relays: string[]) => string[];
}
function createRelayHealthTracker(): RelayHealthTracker {
const stats = new Map<string, RelayHealthStats>();
function getOrCreate(relay: string): RelayHealthStats {
let s = stats.get(relay);
if (!s) {
s = {
successCount: 0,
failureCount: 0,
latencySum: 0,
latencyCount: 0,
lastSuccess: 0,
lastFailure: 0,
};
stats.set(relay, s);
}
return s;
}
return {
recordSuccess(relay: string, latencyMs: number): void {
const s = getOrCreate(relay);
s.successCount++;
s.latencySum += latencyMs;
s.latencyCount++;
s.lastSuccess = Date.now();
},
recordFailure(relay: string): void {
const s = getOrCreate(relay);
s.failureCount++;
s.lastFailure = Date.now();
},
getScore(relay: string): number {
const s = stats.get(relay);
if (!s) return 0.5; // Unknown relay gets neutral score
const total = s.successCount + s.failureCount;
if (total === 0) return 0.5;
// Success rate (0-1)
const successRate = s.successCount / total;
// Recency bonus (prefer recently successful relays)
const now = Date.now();
const recencyBonus =
s.lastSuccess > s.lastFailure
? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * 0.2
: 0;
// Latency penalty (lower is better)
const avgLatency =
s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000;
const latencyPenalty = Math.min(0.2, avgLatency / 10000);
return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty));
},
getSortedRelays(relays: string[]): string[] {
return [...relays].sort((a, b) => this.getScore(b) - this.getScore(a));
},
};
}
// ============================================================================
// Reconnect with Exponential Backoff + Jitter
// ============================================================================
function computeReconnectDelay(attempt: number): number {
// Exponential backoff: base * 2^attempt
const exponential = RECONNECT_BASE_MS * Math.pow(2, attempt);
const capped = Math.min(exponential, RECONNECT_MAX_MS);
// Add jitter: ±JITTER%
const jitter = capped * RECONNECT_JITTER * (Math.random() * 2 - 1);
return Math.max(RECONNECT_BASE_MS, capped + jitter);
}
// ============================================================================
// Key Validation
// ============================================================================
/**
* Validate and normalize a private key (accepts hex or nsec format)
*/
export function validatePrivateKey(key: string): Uint8Array {
const trimmed = key.trim();
// Handle nsec (bech32) format
if (trimmed.startsWith("nsec1")) {
const decoded = nip19.decode(trimmed);
if (decoded.type !== "nsec") {
throw new Error("Invalid nsec key: wrong type");
}
return decoded.data;
}
// Handle hex format
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
throw new Error(
"Private key must be 64 hex characters or nsec bech32 format"
);
}
// Convert hex string to Uint8Array
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
/**
* Get public key from private key (hex or nsec format)
*/
export function getPublicKeyFromPrivate(privateKey: string): string {
const sk = validatePrivateKey(privateKey);
return getPublicKey(sk);
}
// ============================================================================
// Main Bus
// ============================================================================
/**
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
*/
export async function startNostrBus(
options: NostrBusOptions
): Promise<NostrBusHandle> {
const {
privateKey,
relays = DEFAULT_RELAYS,
onMessage,
onError,
onEose,
onMetric,
maxSeenEntries = 100_000,
seenTtlMs = 60 * 60 * 1000,
} = options;
const sk = validatePrivateKey(privateKey);
const pk = getPublicKey(sk);
const pool = new SimplePool();
const accountId = options.accountId ?? pk.slice(0, 16);
const gatewayStartedAt = Math.floor(Date.now() / 1000);
// Initialize metrics
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
// Initialize seen tracker with LRU
const seen: SeenTracker = createSeenTracker({
maxEntries: maxSeenEntries,
ttlMs: seenTtlMs,
});
// Initialize circuit breakers and health tracker
const circuitBreakers = new Map<string, CircuitBreaker>();
const healthTracker = createRelayHealthTracker();
for (const relay of relays) {
circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
}
// Read persisted state and compute `since` timestamp (with small overlap)
const state = await readNostrBusState({ accountId });
const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
// Seed in-memory dedupe with recent IDs from disk (prevents restart replay)
if (state?.recentEventIds?.length) {
seen.seed(state.recentEventIds);
}
// Persist startup timestamp
await writeNostrBusState({
accountId,
lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
gatewayStartedAt,
recentEventIds: state?.recentEventIds ?? [],
});
// Debounced state persistence
let pendingWrite: ReturnType<typeof setTimeout> | undefined;
let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
let recentEventIds = (state?.recentEventIds ?? []).slice(
-MAX_PERSISTED_EVENT_IDS
);
function scheduleStatePersist(eventCreatedAt: number, eventId: string): void {
lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
recentEventIds.push(eventId);
if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) {
recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS);
}
if (pendingWrite) clearTimeout(pendingWrite);
pendingWrite = setTimeout(() => {
writeNostrBusState({
accountId,
lastProcessedAt,
gatewayStartedAt,
recentEventIds,
}).catch((err) => onError?.(err as Error, "persist state"));
}, STATE_PERSIST_DEBOUNCE_MS);
}
const inflight = new Set<string>();
// Event handler
async function handleEvent(event: Event): Promise<void> {
try {
metrics.emit("event.received");
// Fast dedupe check (handles relay reconnections)
if (seen.peek(event.id) || inflight.has(event.id)) {
metrics.emit("event.duplicate");
return;
}
inflight.add(event.id);
// Self-message loop prevention: skip our own messages
if (event.pubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
// Skip events older than our `since` (relay may ignore filter)
if (event.created_at < since) {
metrics.emit("event.rejected.stale");
return;
}
// Fast p-tag check BEFORE crypto (no allocation, cheaper)
let targetsUs = false;
for (const t of event.tags) {
if (t[0] === "p" && t[1] === pk) {
targetsUs = true;
break;
}
}
if (!targetsUs) {
metrics.emit("event.rejected.wrong_kind");
return;
}
// Verify signature (must pass before we trust the event)
if (!verifyEvent(event)) {
metrics.emit("event.rejected.invalid_signature");
onError?.(new Error("Invalid signature"), `event ${event.id}`);
return;
}
// Mark seen AFTER verify (don't cache invalid IDs)
seen.add(event.id);
metrics.emit("memory.seen_tracker_size", seen.size());
// Decrypt the message
let plaintext: string;
try {
plaintext = await decrypt(sk, event.pubkey, event.content);
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `decrypt from ${event.pubkey}`);
return;
}
// Create reply function (try relays by health score)
const replyTo = async (text: string): Promise<void> => {
await sendEncryptedDm(
pool,
sk,
event.pubkey,
text,
relays,
metrics,
circuitBreakers,
healthTracker,
onError
);
};
// Call the message handler
await onMessage(event.pubkey, plaintext, replyTo);
// Mark as processed
metrics.emit("event.processed");
// Persist progress (debounced)
scheduleStatePersist(event.created_at, event.id);
} catch (err) {
onError?.(err as Error, `event ${event.id}`);
} finally {
inflight.delete(event.id);
}
}
const sub = pool.subscribeMany(
relays,
[{ kinds: [4], "#p": [pk], since }],
{
onevent: handleEvent,
oneose: () => {
// EOSE handler - called when all stored events have been received
for (const relay of relays) {
metrics.emit("relay.message.eose", 1, { relay });
}
onEose?.(relays.join(", "));
},
onclose: (reason) => {
// Handle subscription close
for (const relay of relays) {
metrics.emit("relay.message.closed", 1, { relay });
options.onDisconnect?.(relay);
}
onError?.(
new Error(`Subscription closed: ${reason}`),
"subscription"
);
},
}
);
// Public sendDm function
const sendDm = async (toPubkey: string, text: string): Promise<void> => {
await sendEncryptedDm(
pool,
sk,
toPubkey,
text,
relays,
metrics,
circuitBreakers,
healthTracker,
onError
);
};
// Profile publishing function
const publishProfile = async (profile: NostrProfile): Promise<ProfilePublishResult> => {
// Read last published timestamp for monotonic ordering
const profileState = await readNostrProfileState({ accountId });
const lastPublishedAt = profileState?.lastPublishedAt ?? undefined;
// Publish the profile
const result = await publishProfileFn(pool, sk, relays, profile, lastPublishedAt);
// Convert results to state format
const publishResults: Record<string, "ok" | "failed" | "timeout"> = {};
for (const relay of result.successes) {
publishResults[relay] = "ok";
}
for (const { relay, error } of result.failures) {
publishResults[relay] = error === "timeout" ? "timeout" : "failed";
}
// Persist the publish state
await writeNostrProfileState({
accountId,
lastPublishedAt: result.createdAt,
lastPublishedEventId: result.eventId,
lastPublishResults: publishResults,
});
return result;
};
// Get profile state function
const getProfileState = async () => {
const state = await readNostrProfileState({ accountId });
return {
lastPublishedAt: state?.lastPublishedAt ?? null,
lastPublishedEventId: state?.lastPublishedEventId ?? null,
lastPublishResults: state?.lastPublishResults ?? null,
};
};
return {
close: () => {
sub.close();
seen.stop();
// Flush pending state write synchronously on close
if (pendingWrite) {
clearTimeout(pendingWrite);
writeNostrBusState({
accountId,
lastProcessedAt,
gatewayStartedAt,
recentEventIds,
}).catch((err) => onError?.(err as Error, "persist state on close"));
}
},
publicKey: pk,
sendDm,
getMetrics: () => metrics.getSnapshot(),
publishProfile,
getProfileState,
};
}
// ============================================================================
// Send DM with Circuit Breaker + Health Scoring
// ============================================================================
/**
* Send an encrypted DM to a pubkey
*/
async function sendEncryptedDm(
pool: SimplePool,
sk: Uint8Array,
toPubkey: string,
text: string,
relays: string[],
metrics: NostrMetrics,
circuitBreakers: Map<string, CircuitBreaker>,
healthTracker: RelayHealthTracker,
onError?: (error: Error, context: string) => void
): Promise<void> {
const ciphertext = await encrypt(sk, toPubkey, text);
const reply = finalizeEvent(
{
kind: 4,
content: ciphertext,
tags: [["p", toPubkey]],
created_at: Math.floor(Date.now() / 1000),
},
sk
);
// Sort relays by health score (best first)
const sortedRelays = healthTracker.getSortedRelays(relays);
// Try relays in order of health, respecting circuit breakers
let lastError: Error | undefined;
for (const relay of sortedRelays) {
const cb = circuitBreakers.get(relay);
// Skip if circuit breaker is open
if (cb && !cb.canAttempt()) {
continue;
}
const startTime = Date.now();
try {
await pool.publish([relay], reply);
const latency = Date.now() - startTime;
// Record success
cb?.recordSuccess();
healthTracker.recordSuccess(relay, latency);
return; // Success - exit early
} catch (err) {
lastError = err as Error;
const latency = Date.now() - startTime;
// Record failure
cb?.recordFailure();
healthTracker.recordFailure(relay);
metrics.emit("relay.error", 1, { relay, latency });
onError?.(lastError, `publish to ${relay}`);
}
}
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
}
// ============================================================================
// Pubkey Utilities
// ============================================================================
/**
* Check if a string looks like a valid Nostr pubkey (hex or npub)
*/
export function isValidPubkey(input: string): boolean {
if (typeof input !== "string") return false;
const trimmed = input.trim();
// npub format
if (trimmed.startsWith("npub1")) {
try {
const decoded = nip19.decode(trimmed);
return decoded.type === "npub";
} catch {
return false;
}
}
// Hex format
return /^[0-9a-fA-F]{64}$/.test(trimmed);
}
/**
* Normalize a pubkey to hex format (accepts npub or hex)
*/
export function normalizePubkey(input: string): string {
const trimmed = input.trim();
// npub format - decode to hex
if (trimmed.startsWith("npub1")) {
const decoded = nip19.decode(trimmed);
if (decoded.type !== "npub") {
throw new Error("Invalid npub key");
}
// Convert Uint8Array to hex string
return Array.from(decoded.data)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Already hex - validate and return lowercase
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
throw new Error("Pubkey must be 64 hex characters or npub format");
}
return trimmed.toLowerCase();
}
/**
* Convert a hex pubkey to npub format
*/
export function pubkeyToNpub(hexPubkey: string): string {
const normalized = normalizePubkey(hexPubkey);
// npubEncode expects a hex string, not Uint8Array
return nip19.npubEncode(normalized);
}
@@ -0,0 +1,378 @@
/**
* Tests for Nostr Profile HTTP Handler
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import {
createNostrProfileHttpHandler,
type NostrProfileHttpContext,
} from "./nostr-profile-http.js";
// Mock the channel exports
vi.mock("./channel.js", () => ({
publishNostrProfile: vi.fn(),
getNostrProfileState: vi.fn(),
}));
// Mock the import module
vi.mock("./nostr-profile-import.js", () => ({
importProfileFromRelays: vi.fn(),
mergeProfiles: vi.fn((local, imported) => ({ ...imported, ...local })),
}));
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
import { importProfileFromRelays } from "./nostr-profile-import.js";
// ============================================================================
// Test Helpers
// ============================================================================
function createMockRequest(
method: string,
url: string,
body?: unknown
): IncomingMessage {
const socket = new Socket();
const req = new IncomingMessage(socket);
req.method = method;
req.url = url;
req.headers = { host: "localhost:3000" };
if (body) {
const bodyStr = JSON.stringify(body);
process.nextTick(() => {
req.emit("data", Buffer.from(bodyStr));
req.emit("end");
});
} else {
process.nextTick(() => {
req.emit("end");
});
}
return req;
}
function createMockResponse(): ServerResponse & { _getData: () => string; _getStatusCode: () => number } {
const socket = new Socket();
const res = new ServerResponse({} as IncomingMessage);
let data = "";
let statusCode = 200;
res.write = function (chunk: unknown) {
data += String(chunk);
return true;
};
res.end = function (chunk?: unknown) {
if (chunk) data += String(chunk);
return this;
};
Object.defineProperty(res, "statusCode", {
get: () => statusCode,
set: (code: number) => {
statusCode = code;
},
});
(res as unknown as { _getData: () => string })._getData = () => data;
(res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode;
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
}
function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrProfileHttpContext {
return {
getConfigProfile: vi.fn().mockReturnValue(undefined),
updateConfigProfile: vi.fn().mockResolvedValue(undefined),
getAccountInfo: vi.fn().mockReturnValue({
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
relays: ["wss://relay.damus.io"],
}),
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
...overrides,
};
}
// ============================================================================
// Tests
// ============================================================================
describe("nostr-profile-http", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("route matching", () => {
it("returns false for non-nostr paths", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/telegram/profile");
const res = createMockResponse();
const result = await handler(req, res);
expect(result).toBe(false);
});
it("returns false for paths without accountId", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/nostr/");
const res = createMockResponse();
const result = await handler(req, res);
expect(result).toBe(false);
});
it("handles /api/channels/nostr/:accountId/profile", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
const res = createMockResponse();
vi.mocked(getNostrProfileState).mockResolvedValue(null);
const result = await handler(req, res);
expect(result).toBe(true);
});
});
describe("GET /api/channels/nostr/:accountId/profile", () => {
it("returns profile and publish state", async () => {
const ctx = createMockContext({
getConfigProfile: vi.fn().mockReturnValue({
name: "testuser",
displayName: "Test User",
}),
});
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("GET", "/api/channels/nostr/default/profile");
const res = createMockResponse();
vi.mocked(getNostrProfileState).mockResolvedValue({
lastPublishedAt: 1234567890,
lastPublishedEventId: "abc123",
lastPublishResults: { "wss://relay.damus.io": "ok" },
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(true);
expect(data.profile.name).toBe("testuser");
expect(data.publishState.lastPublishedAt).toBe(1234567890);
});
});
describe("PUT /api/channels/nostr/:accountId/profile", () => {
it("validates profile and publishes", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "satoshi",
displayName: "Satoshi Nakamoto",
about: "Creator of Bitcoin",
});
const res = createMockResponse();
vi.mocked(publishNostrProfile).mockResolvedValue({
eventId: "event123",
createdAt: 1234567890,
successes: ["wss://relay.damus.io"],
failures: [],
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(true);
expect(data.eventId).toBe("event123");
expect(data.successes).toContain("wss://relay.damus.io");
expect(data.persisted).toBe(true);
expect(ctx.updateConfigProfile).toHaveBeenCalled();
});
it("rejects private IP in picture URL (SSRF protection)", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: "https://127.0.0.1/evil.jpg",
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
});
it("rejects non-https URLs", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "test",
picture: "http://example.com/pic.jpg",
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
// The schema validation catches non-https URLs before SSRF check
expect(data.error).toBe("Validation failed");
expect(data.details).toBeDefined();
expect(data.details.some((d: string) => d.includes("https"))).toBe(true);
});
it("does not persist if all relays fail", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "test",
});
const res = createMockResponse();
vi.mocked(publishNostrProfile).mockResolvedValue({
eventId: "event123",
createdAt: 1234567890,
successes: [],
failures: [{ relay: "wss://relay.damus.io", error: "timeout" }],
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data.persisted).toBe(false);
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
});
it("enforces rate limiting", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
vi.mocked(publishNostrProfile).mockResolvedValue({
eventId: "event123",
createdAt: 1234567890,
successes: ["wss://relay.damus.io"],
failures: [],
});
// Make 6 requests (limit is 5/min)
for (let i = 0; i < 6; i++) {
const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", {
name: `user${i}`,
});
const res = createMockResponse();
await handler(req, res);
if (i < 5) {
expect(res._getStatusCode()).toBe(200);
} else {
expect(res._getStatusCode()).toBe(429);
const data = JSON.parse(res._getData());
expect(data.error).toContain("Rate limit");
}
}
});
});
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
it("imports profile from relays", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
const res = createMockResponse();
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(true);
expect(data.imported.name).toBe("imported");
expect(data.saved).toBe(false); // autoMerge not requested
});
it("auto-merges when requested", async () => {
const ctx = createMockContext({
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
});
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {
autoMerge: true,
});
const res = createMockResponse();
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data.saved).toBe(true);
expect(ctx.updateConfigProfile).toHaveBeenCalled();
});
it("returns error when account not found", async () => {
const ctx = createMockContext({
getAccountInfo: vi.fn().mockReturnValue(null),
});
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(404);
const data = JSON.parse(res._getData());
expect(data.error).toContain("not found");
});
});
});
+500
View File
@@ -0,0 +1,500 @@
/**
* Nostr Profile HTTP Handler
*
* Handles HTTP requests for profile management:
* - PUT /api/channels/nostr/:accountId/profile - Update and publish profile
* - POST /api/channels/nostr/:accountId/profile/import - Import from relays
* - GET /api/channels/nostr/:accountId/profile - Get current profile state
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { z } from "zod";
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
// ============================================================================
// Types
// ============================================================================
export interface NostrProfileHttpContext {
/** Get current profile from config */
getConfigProfile: (accountId: string) => NostrProfile | undefined;
/** Update profile in config (after successful publish) */
updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>;
/** Get account's public key and relays */
getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null;
/** Logger */
log?: {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
};
}
// ============================================================================
// Rate Limiting
// ============================================================================
interface RateLimitEntry {
count: number;
windowStart: number;
}
const rateLimitMap = new Map<string, RateLimitEntry>();
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
function checkRateLimit(accountId: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(accountId);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(accountId, { count: 1, windowStart: now });
return true;
}
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
return false;
}
entry.count++;
return true;
}
// ============================================================================
// Mutex for Concurrent Publish Prevention
// ============================================================================
const publishLocks = new Map<string, Promise<void>>();
async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
// Atomic mutex using promise chaining - prevents TOCTOU race condition
const prev = publishLocks.get(accountId) ?? Promise.resolve();
let resolve: () => void;
const next = new Promise<void>((r) => {
resolve = r;
});
// Atomically replace the lock before awaiting - any concurrent request
// will now wait on our `next` promise
publishLocks.set(accountId, next);
// Wait for previous operation to complete
await prev.catch(() => {});
try {
return await fn();
} finally {
resolve!();
// Clean up if we're the last in chain
if (publishLocks.get(accountId) === next) {
publishLocks.delete(accountId);
}
}
}
// ============================================================================
// SSRF Protection
// ============================================================================
// Block common private/internal hostnames (quick string check)
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"127.0.0.1",
"::1",
"[::1]",
"0.0.0.0",
]);
// Check if an IP address (resolved) is in a private range
function isPrivateIp(ip: string): boolean {
// Handle IPv4
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipv4Match) {
const [, a, b, c] = ipv4Match.map(Number);
// 127.0.0.0/8 (loopback)
if (a === 127) return true;
// 10.0.0.0/8 (private)
if (a === 10) return true;
// 172.16.0.0/12 (private)
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 (private)
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true;
// 0.0.0.0/8
if (a === 0) return true;
return false;
}
// Handle IPv6
const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, "");
// ::1 (loopback)
if (ipLower === "::1") return true;
// fe80::/10 (link-local)
if (ipLower.startsWith("fe80:")) return true;
// fc00::/7 (unique local)
if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) return true;
// ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4Mapped) return isPrivateIp(v4Mapped[1]);
return false;
}
function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
try {
const url = new URL(urlStr);
if (url.protocol !== "https:") {
return { ok: false, error: "URL must use https:// protocol" };
}
const hostname = url.hostname.toLowerCase();
// Quick hostname block check
if (BLOCKED_HOSTNAMES.has(hostname)) {
return { ok: false, error: "URL must not point to private/internal addresses" };
}
// Check if hostname is an IP address directly
if (isPrivateIp(hostname)) {
return { ok: false, error: "URL must not point to private/internal addresses" };
}
// Block suspicious TLDs that resolve to localhost
if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
return { ok: false, error: "URL must not point to private/internal addresses" };
}
return { ok: true };
} catch {
return { ok: false, error: "Invalid URL format" };
}
}
// Export for use in import validation
export { validateUrlSafety }
// ============================================================================
// Validation Schemas
// ============================================================================
// NIP-05 format: user@domain.com
const nip05FormatSchema = z
.string()
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)")
.optional();
// LUD-16 Lightning address format: user@domain.com
const lud16FormatSchema = z
.string()
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format")
.optional();
// Extended profile schema with additional format validation
const ProfileUpdateSchema = NostrProfileSchema.extend({
nip05: nip05FormatSchema,
lud16: lud16FormatSchema,
});
// ============================================================================
// Request Helpers
// ============================================================================
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
reject(new Error("Request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const body = Buffer.concat(chunks).toString("utf-8");
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Invalid JSON"));
}
});
req.on("error", reject);
});
}
function parseAccountIdFromPath(pathname: string): string | null {
// Match: /api/channels/nostr/:accountId/profile
const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
return match?.[1] ?? null;
}
// ============================================================================
// HTTP Handler
// ============================================================================
export function createNostrProfileHttpHandler(
ctx: NostrProfileHttpContext
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
return async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
// Only handle /api/channels/nostr/:accountId/profile paths
if (!url.pathname.startsWith("/api/channels/nostr/")) {
return false;
}
const accountId = parseAccountIdFromPath(url.pathname);
if (!accountId) {
return false;
}
const isImport = url.pathname.endsWith("/profile/import");
const isProfilePath = url.pathname.endsWith("/profile") || isImport;
if (!isProfilePath) {
return false;
}
// Handle different HTTP methods
try {
if (req.method === "GET" && !isImport) {
return await handleGetProfile(accountId, ctx, res);
}
if (req.method === "PUT" && !isImport) {
return await handleUpdateProfile(accountId, ctx, req, res);
}
if (req.method === "POST" && isImport) {
return await handleImportProfile(accountId, ctx, req, res);
}
// Method not allowed
sendJson(res, 405, { ok: false, error: "Method not allowed" });
return true;
} catch (err) {
ctx.log?.error(`Profile HTTP error: ${String(err)}`);
sendJson(res, 500, { ok: false, error: "Internal server error" });
return true;
}
};
}
// ============================================================================
// GET /api/channels/nostr/:accountId/profile
// ============================================================================
async function handleGetProfile(
accountId: string,
ctx: NostrProfileHttpContext,
res: ServerResponse
): Promise<true> {
const configProfile = ctx.getConfigProfile(accountId);
const publishState = await getNostrProfileState(accountId);
sendJson(res, 200, {
ok: true,
profile: configProfile ?? null,
publishState: publishState ?? null,
});
return true;
}
// ============================================================================
// PUT /api/channels/nostr/:accountId/profile
// ============================================================================
async function handleUpdateProfile(
accountId: string,
ctx: NostrProfileHttpContext,
req: IncomingMessage,
res: ServerResponse
): Promise<true> {
// Rate limiting
if (!checkRateLimit(accountId)) {
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
return true;
}
// Parse body
let body: unknown;
try {
body = await readJsonBody(req);
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err) });
return true;
}
// Validate profile
const parseResult = ProfileUpdateSchema.safeParse(body);
if (!parseResult.success) {
const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
sendJson(res, 400, { ok: false, error: "Validation failed", details: errors });
return true;
}
const profile = parseResult.data;
// SSRF check for picture URL
if (profile.picture) {
const pictureCheck = validateUrlSafety(profile.picture);
if (!pictureCheck.ok) {
sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` });
return true;
}
}
// SSRF check for banner URL
if (profile.banner) {
const bannerCheck = validateUrlSafety(profile.banner);
if (!bannerCheck.ok) {
sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` });
return true;
}
}
// SSRF check for website URL
if (profile.website) {
const websiteCheck = validateUrlSafety(profile.website);
if (!websiteCheck.ok) {
sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` });
return true;
}
}
// Merge with existing profile to preserve unknown fields
const existingProfile = ctx.getConfigProfile(accountId) ?? {};
const mergedProfile: NostrProfile = {
...existingProfile,
...profile,
};
// Publish with mutex to prevent concurrent publishes
try {
const result = await withPublishLock(accountId, async () => {
return await publishNostrProfile(accountId, mergedProfile);
});
// Only persist if at least one relay succeeded
if (result.successes.length > 0) {
await ctx.updateConfigProfile(accountId, mergedProfile);
ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
} else {
ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
}
sendJson(res, 200, {
ok: true,
eventId: result.eventId,
createdAt: result.createdAt,
successes: result.successes,
failures: result.failures,
persisted: result.successes.length > 0,
});
} catch (err) {
ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` });
}
return true;
}
// ============================================================================
// POST /api/channels/nostr/:accountId/profile/import
// ============================================================================
async function handleImportProfile(
accountId: string,
ctx: NostrProfileHttpContext,
req: IncomingMessage,
res: ServerResponse
): Promise<true> {
// Get account info
const accountInfo = ctx.getAccountInfo(accountId);
if (!accountInfo) {
sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` });
return true;
}
const { pubkey, relays } = accountInfo;
if (!pubkey) {
sendJson(res, 400, { ok: false, error: "Account has no public key configured" });
return true;
}
// Parse options from body
let autoMerge = false;
try {
const body = await readJsonBody(req);
if (typeof body === "object" && body !== null) {
autoMerge = (body as { autoMerge?: boolean }).autoMerge === true;
}
} catch {
// Ignore body parse errors - use defaults
}
ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);
// Import from relays
const result = await importProfileFromRelays({
pubkey,
relays,
timeoutMs: 10_000, // 10 seconds for import
});
if (!result.ok) {
sendJson(res, 200, {
ok: false,
error: result.error,
relaysQueried: result.relaysQueried,
});
return true;
}
// If autoMerge is requested, merge and save
if (autoMerge && result.profile) {
const localProfile = ctx.getConfigProfile(accountId);
const merged = mergeProfiles(localProfile, result.profile);
await ctx.updateConfigProfile(accountId, merged);
ctx.log?.info(`[${accountId}] Profile imported and merged`);
sendJson(res, 200, {
ok: true,
imported: result.profile,
merged,
saved: true,
event: result.event,
sourceRelay: result.sourceRelay,
relaysQueried: result.relaysQueried,
});
return true;
}
// Otherwise, just return the imported profile for review
sendJson(res, 200, {
ok: true,
imported: result.profile,
saved: false,
event: result.event,
sourceRelay: result.sourceRelay,
relaysQueried: result.relaysQueried,
});
return true;
}
@@ -0,0 +1,120 @@
/**
* Tests for Nostr Profile Import
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mergeProfiles, type ProfileImportOptions } from "./nostr-profile-import.js";
import type { NostrProfile } from "./config-schema.js";
// Note: importProfileFromRelays requires real network calls or complex mocking
// of nostr-tools SimplePool, so we focus on unit testing mergeProfiles
describe("nostr-profile-import", () => {
describe("mergeProfiles", () => {
it("returns empty object when both are undefined", () => {
const result = mergeProfiles(undefined, undefined);
expect(result).toEqual({});
});
it("returns imported when local is undefined", () => {
const imported: NostrProfile = {
name: "imported",
displayName: "Imported User",
about: "Bio from relay",
};
const result = mergeProfiles(undefined, imported);
expect(result).toEqual(imported);
});
it("returns local when imported is undefined", () => {
const local: NostrProfile = {
name: "local",
displayName: "Local User",
};
const result = mergeProfiles(local, undefined);
expect(result).toEqual(local);
});
it("prefers local values over imported", () => {
const local: NostrProfile = {
name: "localname",
about: "Local bio",
};
const imported: NostrProfile = {
name: "importedname",
displayName: "Imported Display",
about: "Imported bio",
picture: "https://example.com/pic.jpg",
};
const result = mergeProfiles(local, imported);
expect(result.name).toBe("localname"); // local wins
expect(result.displayName).toBe("Imported Display"); // imported fills gap
expect(result.about).toBe("Local bio"); // local wins
expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap
});
it("fills all missing fields from imported", () => {
const local: NostrProfile = {
name: "myname",
};
const imported: NostrProfile = {
name: "theirname",
displayName: "Their Name",
about: "Their bio",
picture: "https://example.com/pic.jpg",
banner: "https://example.com/banner.jpg",
website: "https://example.com",
nip05: "user@example.com",
lud16: "user@getalby.com",
};
const result = mergeProfiles(local, imported);
expect(result.name).toBe("myname");
expect(result.displayName).toBe("Their Name");
expect(result.about).toBe("Their bio");
expect(result.picture).toBe("https://example.com/pic.jpg");
expect(result.banner).toBe("https://example.com/banner.jpg");
expect(result.website).toBe("https://example.com");
expect(result.nip05).toBe("user@example.com");
expect(result.lud16).toBe("user@getalby.com");
});
it("handles empty strings as falsy (prefers imported)", () => {
const local: NostrProfile = {
name: "",
displayName: "",
};
const imported: NostrProfile = {
name: "imported",
displayName: "Imported",
};
const result = mergeProfiles(local, imported);
// Empty strings are still strings, so they "win" over imported
// This is JavaScript nullish coalescing behavior
expect(result.name).toBe("");
expect(result.displayName).toBe("");
});
it("handles null values in local (prefers imported)", () => {
const local: NostrProfile = {
name: undefined,
displayName: undefined,
};
const imported: NostrProfile = {
name: "imported",
displayName: "Imported",
};
const result = mergeProfiles(local, imported);
expect(result.name).toBe("imported");
expect(result.displayName).toBe("Imported");
});
});
});
@@ -0,0 +1,259 @@
/**
* Nostr Profile Import
*
* Fetches and verifies kind:0 profile events from relays.
* Used to import existing profiles before editing.
*/
import { SimplePool, verifyEvent, type Event } from "nostr-tools";
import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
import type { NostrProfile } from "./config-schema.js";
import { validateUrlSafety } from "./nostr-profile-http.js";
// ============================================================================
// Types
// ============================================================================
export interface ProfileImportResult {
/** Whether the import was successful */
ok: boolean;
/** The imported profile (if found and valid) */
profile?: NostrProfile;
/** The raw event (for advanced users) */
event?: {
id: string;
pubkey: string;
created_at: number;
};
/** Error message if import failed */
error?: string;
/** Which relays responded */
relaysQueried: string[];
/** Which relay provided the winning event */
sourceRelay?: string;
}
export interface ProfileImportOptions {
/** The public key to fetch profile for */
pubkey: string;
/** Relay URLs to query */
relays: string[];
/** Timeout per relay in milliseconds (default: 5000) */
timeoutMs?: number;
}
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_TIMEOUT_MS = 5000;
// ============================================================================
// Profile Import
// ============================================================================
/**
* Sanitize URLs in an imported profile to prevent SSRF attacks.
* Removes any URLs that don't pass SSRF validation.
*/
function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
const result = { ...profile };
const urlFields = ["picture", "banner", "website"] as const;
for (const field of urlFields) {
const value = result[field];
if (value && typeof value === "string") {
const validation = validateUrlSafety(value);
if (!validation.ok) {
// Remove unsafe URL
delete result[field];
}
}
}
return result;
}
/**
* Fetch the latest kind:0 profile event for a pubkey from relays.
*
* - Queries all relays in parallel
* - Takes the event with the highest created_at
* - Verifies the event signature
* - Parses and returns the profile
*/
export async function importProfileFromRelays(
opts: ProfileImportOptions
): Promise<ProfileImportResult> {
const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
return {
ok: false,
error: "Invalid pubkey format (must be 64 hex characters)",
relaysQueried: [],
};
}
if (relays.length === 0) {
return {
ok: false,
error: "No relays configured",
relaysQueried: [],
};
}
const pool = new SimplePool();
const relaysQueried: string[] = [];
try {
// Query all relays for kind:0 events from this pubkey
const events: Array<{ event: Event; relay: string }> = [];
// Create timeout promise
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(resolve, timeoutMs);
});
// Create subscription promise
const subscriptionPromise = new Promise<void>((resolve) => {
let completed = 0;
for (const relay of relays) {
relaysQueried.push(relay);
const sub = pool.subscribeMany(
[relay],
[
{
kinds: [0],
authors: [pubkey],
limit: 1,
},
],
{
onevent(event) {
events.push({ event, relay });
},
oneose() {
completed++;
if (completed >= relays.length) {
resolve();
}
},
onclose() {
completed++;
if (completed >= relays.length) {
resolve();
}
},
}
);
// Clean up subscription after timeout
setTimeout(() => {
sub.close();
}, timeoutMs);
}
});
// Wait for either all relays to respond or timeout
await Promise.race([subscriptionPromise, timeoutPromise]);
// No events found
if (events.length === 0) {
return {
ok: false,
error: "No profile found on any relay",
relaysQueried,
};
}
// Find the event with the highest created_at (newest wins for replaceable events)
let bestEvent: { event: Event; relay: string } | null = null;
for (const item of events) {
if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
bestEvent = item;
}
}
if (!bestEvent) {
return {
ok: false,
error: "No valid profile event found",
relaysQueried,
};
}
// Verify the event signature
const isValid = verifyEvent(bestEvent.event);
if (!isValid) {
return {
ok: false,
error: "Profile event has invalid signature",
relaysQueried,
sourceRelay: bestEvent.relay,
};
}
// Parse the profile content
let content: ProfileContent;
try {
content = JSON.parse(bestEvent.event.content) as ProfileContent;
} catch {
return {
ok: false,
error: "Profile event has invalid JSON content",
relaysQueried,
sourceRelay: bestEvent.relay,
};
}
// Convert to our profile format
const profile = contentToProfile(content);
// Sanitize URLs from imported profile to prevent SSRF when auto-merging
const sanitizedProfile = sanitizeProfileUrls(profile);
return {
ok: true,
profile: sanitizedProfile,
event: {
id: bestEvent.event.id,
pubkey: bestEvent.event.pubkey,
created_at: bestEvent.event.created_at,
},
relaysQueried,
sourceRelay: bestEvent.relay,
};
} finally {
pool.close(relays);
}
}
/**
* Merge imported profile with local profile.
*
* Strategy:
* - For each field, prefer local if set, otherwise use imported
* - This preserves user customizations while filling in missing data
*/
export function mergeProfiles(
local: NostrProfile | undefined,
imported: NostrProfile | undefined
): NostrProfile {
if (!imported) return local ?? {};
if (!local) return imported;
return {
name: local.name ?? imported.name,
displayName: local.displayName ?? imported.displayName,
about: local.about ?? imported.about,
picture: local.picture ?? imported.picture,
banner: local.banner ?? imported.banner,
website: local.website ?? imported.website,
nip05: local.nip05 ?? imported.nip05,
lud16: local.lud16 ?? imported.lud16,
};
}
@@ -0,0 +1,479 @@
import { describe, expect, it } from "vitest";
import { getPublicKey } from "nostr-tools";
import {
createProfileEvent,
profileToContent,
validateProfile,
sanitizeProfileForDisplay,
} from "./nostr-profile.js";
import type { NostrProfile } from "./config-schema.js";
// Test private key
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_SK = new Uint8Array(
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
);
// ============================================================================
// Unicode Attack Vectors
// ============================================================================
describe("profile unicode attacks", () => {
describe("zero-width characters", () => {
it("handles zero-width space in name", () => {
const profile: NostrProfile = {
name: "test\u200Buser", // Zero-width space
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
// The character should be preserved (not stripped)
expect(result.profile?.name).toBe("test\u200Buser");
});
it("handles zero-width joiner in name", () => {
const profile: NostrProfile = {
name: "test\u200Duser", // Zero-width joiner
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles zero-width non-joiner in about", () => {
const profile: NostrProfile = {
about: "test\u200Cabout", // Zero-width non-joiner
};
const content = profileToContent(profile);
expect(content.about).toBe("test\u200Cabout");
});
});
describe("RTL override attacks", () => {
it("handles RTL override in name", () => {
const profile: NostrProfile = {
name: "\u202Eevil\u202C", // Right-to-left override + pop direction
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
// UI should escape or handle this
const sanitized = sanitizeProfileForDisplay(result.profile!);
expect(sanitized.name).toBeDefined();
});
it("handles bidi embedding in about", () => {
const profile: NostrProfile = {
about: "Normal \u202Breversed\u202C text", // LTR embedding
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
});
describe("homoglyph attacks", () => {
it("handles Cyrillic homoglyphs", () => {
const profile: NostrProfile = {
// Cyrillic 'а' (U+0430) looks like Latin 'a'
name: "\u0430dmin", // Fake "admin"
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
// Profile is accepted but apps should be aware
});
it("handles Greek homoglyphs", () => {
const profile: NostrProfile = {
// Greek 'ο' (U+03BF) looks like Latin 'o'
name: "b\u03BFt", // Looks like "bot"
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
});
describe("combining characters", () => {
it("handles combining diacritics", () => {
const profile: NostrProfile = {
name: "cafe\u0301", // 'e' + combining acute = 'é'
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
expect(result.profile?.name).toBe("cafe\u0301");
});
it("handles excessive combining characters (Zalgo text)", () => {
const zalgo =
"t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
const profile: NostrProfile = {
name: zalgo.slice(0, 256), // Truncate to fit limit
};
const result = validateProfile(profile);
// Should be valid but may look weird
expect(result.valid).toBe(true);
});
});
describe("CJK and other scripts", () => {
it("handles Chinese characters", () => {
const profile: NostrProfile = {
name: "中文用户",
about: "我是一个机器人",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles Japanese hiragana and katakana", () => {
const profile: NostrProfile = {
name: "ボット",
about: "これはテストです",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles Korean characters", () => {
const profile: NostrProfile = {
name: "한국어사용자",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles Arabic text", () => {
const profile: NostrProfile = {
name: "مستخدم",
about: "مرحبا بالعالم",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles Hebrew text", () => {
const profile: NostrProfile = {
name: "משתמש",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles Thai text", () => {
const profile: NostrProfile = {
name: "ผู้ใช้",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
});
describe("emoji edge cases", () => {
it("handles emoji sequences (ZWJ)", () => {
const profile: NostrProfile = {
name: "👨‍👩‍👧‍👦", // Family emoji using ZWJ
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles flag emojis", () => {
const profile: NostrProfile = {
name: "🇺🇸🇯🇵🇬🇧",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
it("handles skin tone modifiers", () => {
const profile: NostrProfile = {
name: "👋🏻👋🏽👋🏿",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
});
});
});
// ============================================================================
// XSS Attack Vectors
// ============================================================================
describe("profile XSS attacks", () => {
describe("script injection", () => {
it("escapes script tags", () => {
const profile: NostrProfile = {
name: '<script>alert("xss")</script>',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.name).not.toContain("<script>");
expect(sanitized.name).toContain("&lt;script&gt;");
});
it("escapes nested script tags", () => {
const profile: NostrProfile = {
about: '<<script>script>alert("xss")<</script>/script>',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).not.toContain("<script>");
});
});
describe("event handler injection", () => {
it("escapes img onerror", () => {
const profile: NostrProfile = {
about: '<img src="x" onerror="alert(1)">',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).toContain("&lt;img");
expect(sanitized.about).not.toContain('onerror="alert');
});
it("escapes svg onload", () => {
const profile: NostrProfile = {
about: '<svg onload="alert(1)">',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).toContain("&lt;svg");
});
it("escapes body onload", () => {
const profile: NostrProfile = {
about: '<body onload="alert(1)">',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).toContain("&lt;body");
});
});
describe("URL-based attacks", () => {
it("rejects javascript: URL in picture", () => {
const profile = {
picture: "javascript:alert('xss')",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
it("rejects javascript: URL with encoding", () => {
const profile = {
picture: "java&#115;cript:alert('xss')",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
it("rejects data: URL", () => {
const profile = {
picture: "data:text/html,<script>alert('xss')</script>",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
it("rejects vbscript: URL", () => {
const profile = {
website: "vbscript:msgbox('xss')",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
it("rejects file: URL", () => {
const profile = {
picture: "file:///etc/passwd",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
});
describe("HTML attribute injection", () => {
it("escapes double quotes in fields", () => {
const profile: NostrProfile = {
name: '" onclick="alert(1)" data-x="',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.name).toContain("&quot;");
expect(sanitized.name).not.toContain('onclick="alert');
});
it("escapes single quotes in fields", () => {
const profile: NostrProfile = {
name: "' onclick='alert(1)' data-x='",
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.name).toContain("&#039;");
});
});
describe("CSS injection", () => {
it("escapes style tags", () => {
const profile: NostrProfile = {
about: '<style>body{background:url("javascript:alert(1)")}</style>',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).toContain("&lt;style&gt;");
});
});
});
// ============================================================================
// Length Boundary Tests
// ============================================================================
describe("profile length boundaries", () => {
describe("name field (max 256)", () => {
it("accepts exactly 256 characters", () => {
const result = validateProfile({ name: "a".repeat(256) });
expect(result.valid).toBe(true);
});
it("rejects 257 characters", () => {
const result = validateProfile({ name: "a".repeat(257) });
expect(result.valid).toBe(false);
});
it("accepts empty string", () => {
const result = validateProfile({ name: "" });
expect(result.valid).toBe(true);
});
});
describe("displayName field (max 256)", () => {
it("accepts exactly 256 characters", () => {
const result = validateProfile({ displayName: "b".repeat(256) });
expect(result.valid).toBe(true);
});
it("rejects 257 characters", () => {
const result = validateProfile({ displayName: "b".repeat(257) });
expect(result.valid).toBe(false);
});
});
describe("about field (max 2000)", () => {
it("accepts exactly 2000 characters", () => {
const result = validateProfile({ about: "c".repeat(2000) });
expect(result.valid).toBe(true);
});
it("rejects 2001 characters", () => {
const result = validateProfile({ about: "c".repeat(2001) });
expect(result.valid).toBe(false);
});
});
describe("URL fields", () => {
it("accepts long valid HTTPS URLs", () => {
const longPath = "a".repeat(1000);
const result = validateProfile({
picture: `https://example.com/${longPath}.png`,
});
expect(result.valid).toBe(true);
});
it("rejects invalid URL format", () => {
const result = validateProfile({
picture: "not-a-url",
});
expect(result.valid).toBe(false);
});
it("rejects URL without protocol", () => {
const result = validateProfile({
picture: "example.com/pic.png",
});
expect(result.valid).toBe(false);
});
});
});
// ============================================================================
// Type Confusion Tests
// ============================================================================
describe("profile type confusion", () => {
it("rejects number as name", () => {
const result = validateProfile({ name: 123 as unknown as string });
expect(result.valid).toBe(false);
});
it("rejects array as about", () => {
const result = validateProfile({ about: ["hello"] as unknown as string });
expect(result.valid).toBe(false);
});
it("rejects object as picture", () => {
const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string });
expect(result.valid).toBe(false);
});
it("rejects null as name", () => {
const result = validateProfile({ name: null as unknown as string });
expect(result.valid).toBe(false);
});
it("rejects boolean as about", () => {
const result = validateProfile({ about: true as unknown as string });
expect(result.valid).toBe(false);
});
it("rejects function as name", () => {
const result = validateProfile({ name: (() => "test") as unknown as string });
expect(result.valid).toBe(false);
});
it("handles prototype pollution attempt", () => {
const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown;
const result = validateProfile(malicious);
// Should not pollute Object.prototype
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
// ============================================================================
// Event Creation Edge Cases
// ============================================================================
describe("event creation edge cases", () => {
it("handles profile with all fields at max length", () => {
const profile: NostrProfile = {
name: "a".repeat(256),
displayName: "b".repeat(256),
about: "c".repeat(2000),
nip05: "d".repeat(200) + "@example.com",
lud16: "e".repeat(200) + "@example.com",
};
const event = createProfileEvent(TEST_SK, profile);
expect(event.kind).toBe(0);
// Content should be parseable JSON
expect(() => JSON.parse(event.content)).not.toThrow();
});
it("handles rapid sequential events with monotonic timestamps", () => {
const profile: NostrProfile = { name: "rapid" };
// Create events in quick succession
let lastTimestamp = 0;
for (let i = 0; i < 100; i++) {
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
expect(event.created_at).toBeGreaterThan(lastTimestamp);
lastTimestamp = event.created_at;
}
});
it("handles JSON special characters in content", () => {
const profile: NostrProfile = {
name: 'test"user',
about: "line1\nline2\ttab\\backslash",
};
const event = createProfileEvent(TEST_SK, profile);
const parsed = JSON.parse(event.content) as { name: string; about: string };
expect(parsed.name).toBe('test"user');
expect(parsed.about).toContain("\n");
expect(parsed.about).toContain("\t");
expect(parsed.about).toContain("\\");
});
});
+410
View File
@@ -0,0 +1,410 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { verifyEvent, getPublicKey } from "nostr-tools";
import {
createProfileEvent,
profileToContent,
contentToProfile,
validateProfile,
sanitizeProfileForDisplay,
type ProfileContent,
} from "./nostr-profile.js";
import type { NostrProfile } from "./config-schema.js";
// Test private key (DO NOT use in production - this is a known test key)
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_SK = new Uint8Array(
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
);
const TEST_PUBKEY = getPublicKey(TEST_SK);
// ============================================================================
// Profile Content Conversion Tests
// ============================================================================
describe("profileToContent", () => {
it("converts full profile to NIP-01 content format", () => {
const profile: NostrProfile = {
name: "testuser",
displayName: "Test User",
about: "A test user for unit testing",
picture: "https://example.com/avatar.png",
banner: "https://example.com/banner.png",
website: "https://example.com",
nip05: "testuser@example.com",
lud16: "testuser@walletofsatoshi.com",
};
const content = profileToContent(profile);
expect(content.name).toBe("testuser");
expect(content.display_name).toBe("Test User");
expect(content.about).toBe("A test user for unit testing");
expect(content.picture).toBe("https://example.com/avatar.png");
expect(content.banner).toBe("https://example.com/banner.png");
expect(content.website).toBe("https://example.com");
expect(content.nip05).toBe("testuser@example.com");
expect(content.lud16).toBe("testuser@walletofsatoshi.com");
});
it("omits undefined fields from content", () => {
const profile: NostrProfile = {
name: "minimaluser",
};
const content = profileToContent(profile);
expect(content.name).toBe("minimaluser");
expect("display_name" in content).toBe(false);
expect("about" in content).toBe(false);
expect("picture" in content).toBe(false);
});
it("handles empty profile", () => {
const profile: NostrProfile = {};
const content = profileToContent(profile);
expect(Object.keys(content)).toHaveLength(0);
});
});
describe("contentToProfile", () => {
it("converts NIP-01 content to profile format", () => {
const content: ProfileContent = {
name: "testuser",
display_name: "Test User",
about: "A test user",
picture: "https://example.com/avatar.png",
nip05: "test@example.com",
};
const profile = contentToProfile(content);
expect(profile.name).toBe("testuser");
expect(profile.displayName).toBe("Test User");
expect(profile.about).toBe("A test user");
expect(profile.picture).toBe("https://example.com/avatar.png");
expect(profile.nip05).toBe("test@example.com");
});
it("handles empty content", () => {
const content: ProfileContent = {};
const profile = contentToProfile(content);
expect(Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined)).toHaveLength(0);
});
it("round-trips profile data", () => {
const original: NostrProfile = {
name: "roundtrip",
displayName: "Round Trip Test",
about: "Testing round-trip conversion",
};
const content = profileToContent(original);
const restored = contentToProfile(content);
expect(restored.name).toBe(original.name);
expect(restored.displayName).toBe(original.displayName);
expect(restored.about).toBe(original.about);
});
});
// ============================================================================
// Event Creation Tests
// ============================================================================
describe("createProfileEvent", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
});
it("creates a valid kind:0 event", () => {
const profile: NostrProfile = {
name: "testbot",
about: "A test bot",
};
const event = createProfileEvent(TEST_SK, profile);
expect(event.kind).toBe(0);
expect(event.pubkey).toBe(TEST_PUBKEY);
expect(event.tags).toEqual([]);
expect(event.id).toMatch(/^[0-9a-f]{64}$/);
expect(event.sig).toMatch(/^[0-9a-f]{128}$/);
});
it("includes profile content as JSON in event content", () => {
const profile: NostrProfile = {
name: "jsontest",
displayName: "JSON Test User",
about: "Testing JSON serialization",
};
const event = createProfileEvent(TEST_SK, profile);
const parsedContent = JSON.parse(event.content) as ProfileContent;
expect(parsedContent.name).toBe("jsontest");
expect(parsedContent.display_name).toBe("JSON Test User");
expect(parsedContent.about).toBe("Testing JSON serialization");
});
it("produces a verifiable signature", () => {
const profile: NostrProfile = { name: "signaturetest" };
const event = createProfileEvent(TEST_SK, profile);
expect(verifyEvent(event)).toBe(true);
});
it("uses current timestamp when no lastPublishedAt provided", () => {
const profile: NostrProfile = { name: "timestamptest" };
const event = createProfileEvent(TEST_SK, profile);
const expectedTimestamp = Math.floor(Date.now() / 1000);
expect(event.created_at).toBe(expectedTimestamp);
});
it("ensures monotonic timestamp when lastPublishedAt is in the future", () => {
// Current time is 2024-01-15T12:00:00Z = 1705320000
const futureTimestamp = 1705320000 + 3600; // 1 hour in the future
const profile: NostrProfile = { name: "monotonictest" };
const event = createProfileEvent(TEST_SK, profile, futureTimestamp);
expect(event.created_at).toBe(futureTimestamp + 1);
});
it("uses current time when lastPublishedAt is in the past", () => {
const pastTimestamp = 1705320000 - 3600; // 1 hour in the past
const profile: NostrProfile = { name: "pasttest" };
const event = createProfileEvent(TEST_SK, profile, pastTimestamp);
const expectedTimestamp = Math.floor(Date.now() / 1000);
expect(event.created_at).toBe(expectedTimestamp);
});
vi.useRealTimers();
});
// ============================================================================
// Profile Validation Tests
// ============================================================================
describe("validateProfile", () => {
it("validates a correct profile", () => {
const profile = {
name: "validuser",
about: "A valid user",
picture: "https://example.com/pic.png",
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
expect(result.profile).toBeDefined();
expect(result.errors).toBeUndefined();
});
it("rejects profile with invalid URL", () => {
const profile = {
name: "invalidurl",
picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes("https://"))).toBe(true);
});
it("rejects profile with javascript: URL", () => {
const profile = {
name: "xssattempt",
picture: "javascript:alert('xss')",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
it("rejects profile with data: URL", () => {
const profile = {
name: "dataurl",
picture: "data:image/png;base64,abc123",
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
});
it("rejects name exceeding 256 characters", () => {
const profile = {
name: "a".repeat(257),
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
expect(result.errors!.some((e) => e.includes("256"))).toBe(true);
});
it("rejects about exceeding 2000 characters", () => {
const profile = {
about: "a".repeat(2001),
};
const result = validateProfile(profile);
expect(result.valid).toBe(false);
expect(result.errors!.some((e) => e.includes("2000"))).toBe(true);
});
it("accepts empty profile", () => {
const result = validateProfile({});
expect(result.valid).toBe(true);
});
it("rejects null input", () => {
const result = validateProfile(null);
expect(result.valid).toBe(false);
});
it("rejects non-object input", () => {
const result = validateProfile("not an object");
expect(result.valid).toBe(false);
});
});
// ============================================================================
// Sanitization Tests
// ============================================================================
describe("sanitizeProfileForDisplay", () => {
it("escapes HTML in name field", () => {
const profile: NostrProfile = {
name: "<script>alert('xss')</script>",
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.name).toBe("&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;");
});
it("escapes HTML in about field", () => {
const profile: NostrProfile = {
about: 'Check out <img src="x" onerror="alert(1)">',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).toBe(
'Check out &lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;'
);
});
it("preserves URLs without modification", () => {
const profile: NostrProfile = {
picture: "https://example.com/pic.png",
website: "https://example.com",
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.picture).toBe("https://example.com/pic.png");
expect(sanitized.website).toBe("https://example.com");
});
it("handles undefined fields", () => {
const profile: NostrProfile = {
name: "test",
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.name).toBe("test");
expect(sanitized.about).toBeUndefined();
expect(sanitized.picture).toBeUndefined();
});
it("escapes ampersands", () => {
const profile: NostrProfile = {
name: "Tom & Jerry",
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.name).toBe("Tom &amp; Jerry");
});
it("escapes quotes", () => {
const profile: NostrProfile = {
about: 'Say "hello" to everyone',
};
const sanitized = sanitizeProfileForDisplay(profile);
expect(sanitized.about).toBe("Say &quot;hello&quot; to everyone");
});
});
// ============================================================================
// Edge Cases
// ============================================================================
describe("edge cases", () => {
it("handles emoji in profile fields", () => {
const profile: NostrProfile = {
name: "🤖 Bot",
about: "I am a 🤖 robot! 🎉",
};
const content = profileToContent(profile);
expect(content.name).toBe("🤖 Bot");
expect(content.about).toBe("I am a 🤖 robot! 🎉");
const event = createProfileEvent(TEST_SK, profile);
const parsed = JSON.parse(event.content) as ProfileContent;
expect(parsed.name).toBe("🤖 Bot");
});
it("handles unicode in profile fields", () => {
const profile: NostrProfile = {
name: "日本語ユーザー",
about: "Привет мир! 你好世界!",
};
const content = profileToContent(profile);
expect(content.name).toBe("日本語ユーザー");
const event = createProfileEvent(TEST_SK, profile);
expect(verifyEvent(event)).toBe(true);
});
it("handles newlines in about field", () => {
const profile: NostrProfile = {
about: "Line 1\nLine 2\nLine 3",
};
const content = profileToContent(profile);
expect(content.about).toBe("Line 1\nLine 2\nLine 3");
const event = createProfileEvent(TEST_SK, profile);
const parsed = JSON.parse(event.content) as ProfileContent;
expect(parsed.about).toBe("Line 1\nLine 2\nLine 3");
});
it("handles maximum length fields", () => {
const profile: NostrProfile = {
name: "a".repeat(256),
about: "b".repeat(2000),
};
const result = validateProfile(profile);
expect(result.valid).toBe(true);
const event = createProfileEvent(TEST_SK, profile);
expect(verifyEvent(event)).toBe(true);
});
});
+242
View File
@@ -0,0 +1,242 @@
/**
* Nostr Profile Management (NIP-01 kind:0)
*
* Profile events are "replaceable" - the latest created_at wins.
* This module handles profile event creation and publishing.
*/
import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
// ============================================================================
// Types
// ============================================================================
/** Result of a profile publish attempt */
export interface ProfilePublishResult {
/** Event ID of the published profile */
eventId: string;
/** Relays that successfully received the event */
successes: string[];
/** Relays that failed with their error messages */
failures: Array<{ relay: string; error: string }>;
/** Unix timestamp when the event was created */
createdAt: number;
}
/** NIP-01 profile content (JSON inside kind:0 event) */
export interface ProfileContent {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
nip05?: string;
lud16?: string;
}
// ============================================================================
// Profile Content Conversion
// ============================================================================
/**
* Convert our config profile schema to NIP-01 content format.
* Strips undefined fields and validates URLs.
*/
export function profileToContent(profile: NostrProfile): ProfileContent {
const validated = NostrProfileSchema.parse(profile);
const content: ProfileContent = {};
if (validated.name !== undefined) content.name = validated.name;
if (validated.displayName !== undefined) content.display_name = validated.displayName;
if (validated.about !== undefined) content.about = validated.about;
if (validated.picture !== undefined) content.picture = validated.picture;
if (validated.banner !== undefined) content.banner = validated.banner;
if (validated.website !== undefined) content.website = validated.website;
if (validated.nip05 !== undefined) content.nip05 = validated.nip05;
if (validated.lud16 !== undefined) content.lud16 = validated.lud16;
return content;
}
/**
* Convert NIP-01 content format back to our config profile schema.
* Useful for importing existing profiles from relays.
*/
export function contentToProfile(content: ProfileContent): NostrProfile {
const profile: NostrProfile = {};
if (content.name !== undefined) profile.name = content.name;
if (content.display_name !== undefined) profile.displayName = content.display_name;
if (content.about !== undefined) profile.about = content.about;
if (content.picture !== undefined) profile.picture = content.picture;
if (content.banner !== undefined) profile.banner = content.banner;
if (content.website !== undefined) profile.website = content.website;
if (content.nip05 !== undefined) profile.nip05 = content.nip05;
if (content.lud16 !== undefined) profile.lud16 = content.lud16;
return profile;
}
// ============================================================================
// Event Creation
// ============================================================================
/**
* Create a signed kind:0 profile event.
*
* @param sk - Private key as Uint8Array (32 bytes)
* @param profile - Profile data to include
* @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee)
* @returns Signed Nostr event
*/
export function createProfileEvent(
sk: Uint8Array,
profile: NostrProfile,
lastPublishedAt?: number
): Event {
const content = profileToContent(profile);
const contentJson = JSON.stringify(content);
// Ensure monotonic timestamp (new event > previous)
const now = Math.floor(Date.now() / 1000);
const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now;
const event = finalizeEvent(
{
kind: 0,
content: contentJson,
tags: [],
created_at: createdAt,
},
sk
);
return event;
}
// ============================================================================
// Profile Publishing
// ============================================================================
/** Per-relay publish timeout (ms) */
const RELAY_PUBLISH_TIMEOUT_MS = 5000;
/**
* Publish a profile event to multiple relays.
*
* Best-effort: publishes to all relays in parallel, reports per-relay results.
* Does NOT retry automatically - caller should handle retries if needed.
*
* @param pool - SimplePool instance for relay connections
* @param relays - Array of relay WebSocket URLs
* @param event - Signed profile event (kind:0)
* @returns Publish results with successes and failures
*/
export async function publishProfileEvent(
pool: SimplePool,
relays: string[],
event: Event
): Promise<ProfilePublishResult> {
const successes: string[] = [];
const failures: Array<{ relay: string; error: string }> = [];
// Publish to each relay in parallel with timeout
const publishPromises = relays.map(async (relay) => {
try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
});
await Promise.race([pool.publish([relay], event), timeoutPromise]);
successes.push(relay);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
failures.push({ relay, error: errorMessage });
}
});
await Promise.all(publishPromises);
return {
eventId: event.id,
successes,
failures,
createdAt: event.created_at,
};
}
/**
* Create and publish a profile event in one call.
*
* @param pool - SimplePool instance
* @param sk - Private key as Uint8Array
* @param relays - Array of relay URLs
* @param profile - Profile data
* @param lastPublishedAt - Previous timestamp for monotonic ordering
* @returns Publish results
*/
export async function publishProfile(
pool: SimplePool,
sk: Uint8Array,
relays: string[],
profile: NostrProfile,
lastPublishedAt?: number
): Promise<ProfilePublishResult> {
const event = createProfileEvent(sk, profile, lastPublishedAt);
return publishProfileEvent(pool, relays, event);
}
// ============================================================================
// Profile Validation Helpers
// ============================================================================
/**
* Validate a profile without throwing (returns result object).
*/
export function validateProfile(profile: unknown): {
valid: boolean;
profile?: NostrProfile;
errors?: string[];
} {
const result = NostrProfileSchema.safeParse(profile);
if (result.success) {
return { valid: true, profile: result.data };
}
return {
valid: false,
errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
};
}
/**
* Sanitize profile text fields to prevent XSS when displaying in UI.
* Escapes HTML special characters.
*/
export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
const escapeHtml = (str: string | undefined): string | undefined => {
if (str === undefined) return undefined;
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
return {
name: escapeHtml(profile.name),
displayName: escapeHtml(profile.displayName),
about: escapeHtml(profile.about),
picture: profile.picture, // URLs already validated by schema
banner: profile.banner,
website: profile.website,
nip05: escapeHtml(profile.nip05),
lud16: escapeHtml(profile.lud16),
};
}
@@ -0,0 +1,128 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import {
readNostrBusState,
writeNostrBusState,
computeSinceTimestamp,
} from "./nostr-state-store.js";
import { setNostrRuntime } from "./runtime.js";
async function withTempStateDir<T>(fn: (dir: string) => Promise<T>) {
const previous = process.env.CLAWDBOT_STATE_DIR;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-nostr-"));
process.env.CLAWDBOT_STATE_DIR = dir;
setNostrRuntime({
state: {
resolveStateDir: (env, homedir) => {
const override = env.CLAWDBOT_STATE_DIR?.trim();
if (override) return override;
return path.join(homedir(), ".clawdbot");
},
},
} as PluginRuntime);
try {
return await fn(dir);
} finally {
if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previous;
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("nostr bus state store", () => {
it("persists and reloads state across restarts", async () => {
await withTempStateDir(async () => {
// Fresh start - no state
expect(await readNostrBusState({ accountId: "test-bot" })).toBeNull();
// Write state
await writeNostrBusState({
accountId: "test-bot",
lastProcessedAt: 1700000000,
gatewayStartedAt: 1700000100,
});
// Read it back
const state = await readNostrBusState({ accountId: "test-bot" });
expect(state).toEqual({
version: 2,
lastProcessedAt: 1700000000,
gatewayStartedAt: 1700000100,
recentEventIds: [],
});
});
});
it("isolates state by accountId", async () => {
await withTempStateDir(async () => {
await writeNostrBusState({
accountId: "bot-a",
lastProcessedAt: 1000,
gatewayStartedAt: 1000,
});
await writeNostrBusState({
accountId: "bot-b",
lastProcessedAt: 2000,
gatewayStartedAt: 2000,
});
const stateA = await readNostrBusState({ accountId: "bot-a" });
const stateB = await readNostrBusState({ accountId: "bot-b" });
expect(stateA?.lastProcessedAt).toBe(1000);
expect(stateB?.lastProcessedAt).toBe(2000);
});
});
});
describe("computeSinceTimestamp", () => {
it("returns now for null state (fresh start)", () => {
const now = 1700000000;
expect(computeSinceTimestamp(null, now)).toBe(now);
});
it("uses lastProcessedAt when available", () => {
const state = {
version: 2,
lastProcessedAt: 1699999000,
gatewayStartedAt: null,
recentEventIds: [],
};
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000);
});
it("uses gatewayStartedAt when lastProcessedAt is null", () => {
const state = {
version: 2,
lastProcessedAt: null,
gatewayStartedAt: 1699998000,
recentEventIds: [],
};
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699998000);
});
it("uses the max of both timestamps", () => {
const state = {
version: 2,
lastProcessedAt: 1699999000,
gatewayStartedAt: 1699998000,
recentEventIds: [],
};
expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000);
});
it("falls back to now if both are null", () => {
const state = {
version: 2,
lastProcessedAt: null,
gatewayStartedAt: null,
recentEventIds: [],
};
expect(computeSinceTimestamp(state, 1700000000)).toBe(1700000000);
});
});
+226
View File
@@ -0,0 +1,226 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { getNostrRuntime } from "./runtime.js";
const STORE_VERSION = 2;
const PROFILE_STATE_VERSION = 1;
type NostrBusStateV1 = {
version: 1;
/** Unix timestamp (seconds) of the last processed event */
lastProcessedAt: number | null;
/** Gateway startup timestamp (seconds) - events before this are old */
gatewayStartedAt: number | null;
};
type NostrBusState = {
version: 2;
/** Unix timestamp (seconds) of the last processed event */
lastProcessedAt: number | null;
/** Gateway startup timestamp (seconds) - events before this are old */
gatewayStartedAt: number | null;
/** Recent processed event IDs for overlap dedupe across restarts */
recentEventIds: string[];
};
/** Profile publish state (separate from bus state) */
export type NostrProfileState = {
version: 1;
/** Unix timestamp (seconds) of last successful profile publish */
lastPublishedAt: number | null;
/** Event ID of the last published profile */
lastPublishedEventId: string | null;
/** Per-relay publish results from last attempt */
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
};
function normalizeAccountId(accountId?: string): string {
const trimmed = accountId?.trim();
if (!trimmed) return "default";
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
}
function resolveNostrStatePath(
accountId?: string,
env: NodeJS.ProcessEnv = process.env
): string {
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
const normalized = normalizeAccountId(accountId);
return path.join(stateDir, "nostr", `bus-state-${normalized}.json`);
}
function resolveNostrProfileStatePath(
accountId?: string,
env: NodeJS.ProcessEnv = process.env
): string {
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
const normalized = normalizeAccountId(accountId);
return path.join(stateDir, "nostr", `profile-state-${normalized}.json`);
}
function safeParseState(raw: string): NostrBusState | null {
try {
const parsed = JSON.parse(raw) as Partial<NostrBusState> & Partial<NostrBusStateV1>;
if (parsed?.version === 2) {
return {
version: 2,
lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null,
gatewayStartedAt: typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null,
recentEventIds: Array.isArray(parsed.recentEventIds)
? parsed.recentEventIds.filter((x): x is string => typeof x === "string")
: [],
};
}
// Back-compat: v1 state files
if (parsed?.version === 1) {
return {
version: 2,
lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null,
gatewayStartedAt: typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null,
recentEventIds: [],
};
}
return null;
} catch {
return null;
}
}
export async function readNostrBusState(params: {
accountId?: string;
env?: NodeJS.ProcessEnv;
}): Promise<NostrBusState | null> {
const filePath = resolveNostrStatePath(params.accountId, params.env);
try {
const raw = await fs.readFile(filePath, "utf-8");
return safeParseState(raw);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") return null;
return null;
}
}
export async function writeNostrBusState(params: {
accountId?: string;
lastProcessedAt: number;
gatewayStartedAt: number;
recentEventIds?: string[];
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const filePath = resolveNostrStatePath(params.accountId, params.env);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(
dir,
`${path.basename(filePath)}.${crypto.randomUUID()}.tmp`
);
const payload: NostrBusState = {
version: STORE_VERSION,
lastProcessedAt: params.lastProcessedAt,
gatewayStartedAt: params.gatewayStartedAt,
recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"),
};
await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.chmod(tmp, 0o600);
await fs.rename(tmp, filePath);
}
/**
* Determine the `since` timestamp for subscription.
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk),
* falling back to `now` for fresh starts.
*/
export function computeSinceTimestamp(
state: NostrBusState | null,
nowSec: number = Math.floor(Date.now() / 1000)
): number {
if (!state) return nowSec;
// Use the most recent timestamp we have
const candidates = [
state.lastProcessedAt,
state.gatewayStartedAt,
].filter((t): t is number => t !== null && t > 0);
if (candidates.length === 0) return nowSec;
return Math.max(...candidates);
}
// ============================================================================
// Profile State Management
// ============================================================================
function safeParseProfileState(raw: string): NostrProfileState | null {
try {
const parsed = JSON.parse(raw) as Partial<NostrProfileState>;
if (parsed?.version === 1) {
return {
version: 1,
lastPublishedAt:
typeof parsed.lastPublishedAt === "number" ? parsed.lastPublishedAt : null,
lastPublishedEventId:
typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
lastPublishResults:
parsed.lastPublishResults && typeof parsed.lastPublishResults === "object"
? (parsed.lastPublishResults as Record<string, "ok" | "failed" | "timeout">)
: null,
};
}
return null;
} catch {
return null;
}
}
export async function readNostrProfileState(params: {
accountId?: string;
env?: NodeJS.ProcessEnv;
}): Promise<NostrProfileState | null> {
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
try {
const raw = await fs.readFile(filePath, "utf-8");
return safeParseProfileState(raw);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") return null;
return null;
}
}
export async function writeNostrProfileState(params: {
accountId?: string;
lastPublishedAt: number;
lastPublishedEventId: string;
lastPublishResults: Record<string, "ok" | "failed" | "timeout">;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(
dir,
`${path.basename(filePath)}.${crypto.randomUUID()}.tmp`
);
const payload: NostrProfileState = {
version: PROFILE_STATE_VERSION,
lastPublishedAt: params.lastPublishedAt,
lastPublishedEventId: params.lastPublishedEventId,
lastPublishResults: params.lastPublishResults,
};
await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.chmod(tmp, 0o600);
await fs.rename(tmp, filePath);
}
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setNostrRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getNostrRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Nostr runtime not initialized");
}
return runtime;
}
+271
View File
@@ -0,0 +1,271 @@
/**
* LRU-based seen event tracker with TTL support.
* Prevents unbounded memory growth under high load or abuse.
*/
export interface SeenTrackerOptions {
/** Maximum number of entries to track (default: 100,000) */
maxEntries?: number;
/** TTL in milliseconds (default: 1 hour) */
ttlMs?: number;
/** Prune interval in milliseconds (default: 10 minutes) */
pruneIntervalMs?: number;
}
export interface SeenTracker {
/** Check if an ID has been seen (also marks it as seen if not) */
has: (id: string) => boolean;
/** Mark an ID as seen */
add: (id: string) => void;
/** Check if ID exists without marking */
peek: (id: string) => boolean;
/** Delete an ID */
delete: (id: string) => void;
/** Clear all entries */
clear: () => void;
/** Get current size */
size: () => number;
/** Stop the pruning timer */
stop: () => void;
/** Pre-seed with IDs (useful for restart recovery) */
seed: (ids: string[]) => void;
}
interface Entry {
seenAt: number;
// For LRU: track order via doubly-linked list
prev: string | null;
next: string | null;
}
/**
* Create a new seen tracker with LRU eviction and TTL expiration.
*/
export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
const maxEntries = options?.maxEntries ?? 100_000;
const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour
const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes
// Main storage
const entries = new Map<string, Entry>();
// LRU tracking: head = most recent, tail = least recent
let head: string | null = null;
let tail: string | null = null;
// Move an entry to the front (most recently used)
function moveToFront(id: string): void {
const entry = entries.get(id);
if (!entry) return;
// Already at front
if (head === id) return;
// Remove from current position
if (entry.prev) {
const prevEntry = entries.get(entry.prev);
if (prevEntry) prevEntry.next = entry.next;
}
if (entry.next) {
const nextEntry = entries.get(entry.next);
if (nextEntry) nextEntry.prev = entry.prev;
}
// Update tail if this was the tail
if (tail === id) {
tail = entry.prev;
}
// Move to front
entry.prev = null;
entry.next = head;
if (head) {
const headEntry = entries.get(head);
if (headEntry) headEntry.prev = id;
}
head = id;
// If no tail, this is also the tail
if (!tail) tail = id;
}
// Remove an entry from the linked list
function removeFromList(id: string): void {
const entry = entries.get(id);
if (!entry) return;
if (entry.prev) {
const prevEntry = entries.get(entry.prev);
if (prevEntry) prevEntry.next = entry.next;
} else {
head = entry.next;
}
if (entry.next) {
const nextEntry = entries.get(entry.next);
if (nextEntry) nextEntry.prev = entry.prev;
} else {
tail = entry.prev;
}
}
// Evict the least recently used entry
function evictLRU(): void {
if (!tail) return;
const idToEvict = tail;
removeFromList(idToEvict);
entries.delete(idToEvict);
}
// Prune expired entries
function pruneExpired(): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [id, entry] of entries) {
if (now - entry.seenAt > ttlMs) {
toDelete.push(id);
}
}
for (const id of toDelete) {
removeFromList(id);
entries.delete(id);
}
}
// Start pruning timer
let pruneTimer: ReturnType<typeof setInterval> | undefined;
if (pruneIntervalMs > 0) {
pruneTimer = setInterval(pruneExpired, pruneIntervalMs);
// Don't keep process alive just for pruning
if (pruneTimer.unref) pruneTimer.unref();
}
function add(id: string): void {
const now = Date.now();
// If already exists, update and move to front
const existing = entries.get(id);
if (existing) {
existing.seenAt = now;
moveToFront(id);
return;
}
// Evict if at capacity
while (entries.size >= maxEntries) {
evictLRU();
}
// Add new entry at front
const newEntry: Entry = {
seenAt: now,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) headEntry.prev = id;
}
entries.set(id, newEntry);
head = id;
if (!tail) tail = id;
}
function has(id: string): boolean {
const entry = entries.get(id);
if (!entry) {
add(id);
return false;
}
// Check if expired
if (Date.now() - entry.seenAt > ttlMs) {
removeFromList(id);
entries.delete(id);
add(id);
return false;
}
// Mark as recently used
entry.seenAt = Date.now();
moveToFront(id);
return true;
}
function peek(id: string): boolean {
const entry = entries.get(id);
if (!entry) return false;
// Check if expired
if (Date.now() - entry.seenAt > ttlMs) {
removeFromList(id);
entries.delete(id);
return false;
}
return true;
}
function deleteEntry(id: string): void {
if (entries.has(id)) {
removeFromList(id);
entries.delete(id);
}
}
function clear(): void {
entries.clear();
head = null;
tail = null;
}
function size(): number {
return entries.size;
}
function stop(): void {
if (pruneTimer) {
clearInterval(pruneTimer);
pruneTimer = undefined;
}
}
function seed(ids: string[]): void {
const now = Date.now();
// Seed in reverse order so first IDs end up at front
for (let i = ids.length - 1; i >= 0; i--) {
const id = ids[i];
if (!entries.has(id) && entries.size < maxEntries) {
const newEntry: Entry = {
seenAt: now,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) headEntry.prev = id;
}
entries.set(id, newEntry);
head = id;
if (!tail) tail = id;
}
}
}
return {
has,
add,
peek,
delete: deleteEntry,
clear,
size,
stop,
seed,
};
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import {
listNostrAccountIds,
resolveDefaultNostrAccountId,
resolveNostrAccount,
} from "./types.js";
const TEST_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
describe("listNostrAccountIds", () => {
it("returns empty array when not configured", () => {
const cfg = { channels: {} };
expect(listNostrAccountIds(cfg)).toEqual([]);
});
it("returns empty array when nostr section exists but no privateKey", () => {
const cfg = { channels: { nostr: { enabled: true } } };
expect(listNostrAccountIds(cfg)).toEqual([]);
});
it("returns default when privateKey is configured", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY },
},
};
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
});
});
describe("resolveDefaultNostrAccountId", () => {
it("returns default when configured", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY },
},
};
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
});
it("returns default when not configured", () => {
const cfg = { channels: {} };
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
});
});
describe("resolveNostrAccount", () => {
it("resolves configured account", () => {
const cfg = {
channels: {
nostr: {
privateKey: TEST_PRIVATE_KEY,
name: "Test Bot",
relays: ["wss://test.relay"],
dmPolicy: "pairing" as const,
},
},
};
const account = resolveNostrAccount({ cfg });
expect(account.accountId).toBe("default");
expect(account.name).toBe("Test Bot");
expect(account.enabled).toBe(true);
expect(account.configured).toBe(true);
expect(account.privateKey).toBe(TEST_PRIVATE_KEY);
expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/);
expect(account.relays).toEqual(["wss://test.relay"]);
});
it("resolves unconfigured account with defaults", () => {
const cfg = { channels: {} };
const account = resolveNostrAccount({ cfg });
expect(account.accountId).toBe("default");
expect(account.enabled).toBe(true);
expect(account.configured).toBe(false);
expect(account.privateKey).toBe("");
expect(account.publicKey).toBe("");
expect(account.relays).toContain("wss://relay.damus.io");
expect(account.relays).toContain("wss://nos.lol");
});
it("handles disabled channel", () => {
const cfg = {
channels: {
nostr: {
enabled: false,
privateKey: TEST_PRIVATE_KEY,
},
},
};
const account = resolveNostrAccount({ cfg });
expect(account.enabled).toBe(false);
expect(account.configured).toBe(true);
});
it("handles custom accountId parameter", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY },
},
};
const account = resolveNostrAccount({ cfg, accountId: "custom" });
expect(account.accountId).toBe("custom");
});
it("handles allowFrom config", () => {
const cfg = {
channels: {
nostr: {
privateKey: TEST_PRIVATE_KEY,
allowFrom: ["npub1test", "0123456789abcdef"],
},
},
};
const account = resolveNostrAccount({ cfg });
expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]);
});
it("handles invalid private key gracefully", () => {
const cfg = {
channels: {
nostr: {
privateKey: "invalid-key",
},
},
};
const account = resolveNostrAccount({ cfg });
expect(account.configured).toBe(true); // key is present
expect(account.publicKey).toBe(""); // but can't derive pubkey
});
it("preserves all config options", () => {
const cfg = {
channels: {
nostr: {
privateKey: TEST_PRIVATE_KEY,
name: "Bot",
enabled: true,
relays: ["wss://relay1", "wss://relay2"],
dmPolicy: "allowlist" as const,
allowFrom: ["pubkey1", "pubkey2"],
},
},
};
const account = resolveNostrAccount({ cfg });
expect(account.config).toEqual({
privateKey: TEST_PRIVATE_KEY,
name: "Bot",
enabled: true,
relays: ["wss://relay1", "wss://relay2"],
dmPolicy: "allowlist",
allowFrom: ["pubkey1", "pubkey2"],
});
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { getPublicKeyFromPrivate } from "./nostr-bus.js";
import { DEFAULT_RELAYS } from "./nostr-bus.js";
import type { NostrProfile } from "./config-schema.js";
export interface NostrAccountConfig {
enabled?: boolean;
name?: string;
privateKey?: string;
relays?: string[];
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
profile?: NostrProfile;
}
export interface ResolvedNostrAccount {
accountId: string;
name?: string;
enabled: boolean;
configured: boolean;
privateKey: string;
publicKey: string;
relays: string[];
profile?: NostrProfile;
config: NostrAccountConfig;
}
const DEFAULT_ACCOUNT_ID = "default";
/**
* List all configured Nostr account IDs
*/
export function listNostrAccountIds(cfg: ClawdbotConfig): string[] {
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
// If privateKey is configured at top level, we have a default account
if (nostrCfg?.privateKey) {
return [DEFAULT_ACCOUNT_ID];
}
return [];
}
/**
* Get the default account ID
*/
export function resolveDefaultNostrAccountId(cfg: ClawdbotConfig): string {
const ids = listNostrAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/**
* Resolve a Nostr account from config
*/
export function resolveNostrAccount(opts: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedNostrAccount {
const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
const nostrCfg = (opts.cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
const baseEnabled = nostrCfg?.enabled !== false;
const privateKey = nostrCfg?.privateKey ?? "";
const configured = Boolean(privateKey.trim());
let publicKey = "";
if (configured) {
try {
publicKey = getPublicKeyFromPrivate(privateKey);
} catch {
// Invalid key - leave publicKey empty, configured will indicate issues
}
}
return {
accountId,
name: nostrCfg?.name?.trim() || undefined,
enabled: baseEnabled,
configured,
privateKey,
publicKey,
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
profile: nostrCfg?.profile,
config: {
enabled: nostrCfg?.enabled,
name: nostrCfg?.name,
privateKey: nostrCfg?.privateKey,
relays: nostrCfg?.relays,
dmPolicy: nostrCfg?.dmPolicy,
allowFrom: nostrCfg?.allowFrom,
profile: nostrCfg?.profile,
},
};
}
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more