Compare commits

..
Author SHA1 Message Date
Peter Steinberger dfea2991c9 fix(daemon): clear launchd disabled state before bootstrap (#849) (thanks @ndraiman) 2026-01-15 03:35:24 +00:00
Netanel Draiman d51a9ebb0e fix(daemon): enable launchd service before bootstrap
When a launchd service is uninstalled via bootout, macOS marks it as
disabled in /var/db/com.apple.xpc.launchd/disabled.*.plist. This persists
across reboots and plist recreation. Future bootstrap calls fail with
error 5 (I/O error) because the service is still marked disabled.

Fix: Call 'launchctl enable' before 'launchctl bootstrap' to clear the
disabled state, matching the fix already applied to the macOS Swift app
in GatewayLaunchAgentManager.swift.

Related: #306
2026-01-15 03:31:52 +00:00
Peter Steinberger 536d3d76a3 Merge pull request #925 from grp06/fix/console-eio
Logging: tolerate EIO console writes (AI)
2026-01-15 03:28:56 +00:00
Peter Steinberger 5c52dbf661 style: oxfmt fixes (#925) (thanks @grp06) 2026-01-15 03:22:54 +00:00
George Pickett 8f797f213e Logging: tolerate EIO console writes 2026-01-15 03:20:48 +00:00
Peter Steinberger ed68f378d7 Merge pull request #227 from Hyaxia/detect-secrets
add detect-secrets CI job + baseline guidance
2026-01-15 03:17:14 +00:00
Peter Steinberger 7e8de907f8 fix: note detect-secrets CI scan (#227) (thanks @Hyaxia) 2026-01-15 03:16:41 +00:00
hyaxia f3c9252840 Security: add detect-secrets scan 2026-01-15 03:14:43 +00:00
Peter Steinberger da9e27f466 docs: update changelog for telegram aggregation 2026-01-15 03:05:46 +00:00
Peter Steinberger aa74e28112 fix(telegram): aggregate split inbound messages 2026-01-15 03:04:59 +00:00
Peter SteinbergerandGeorge Pickett e569f15631 fix: scrub tuple items schemas for Gemini tools (#926) — thanks @grp06
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 02:59:35 +00:00
Peter SteinbergerandAaron Konyer eaace34233 fix: restore docker binds and PATH in sandbox exec (#873)
Thanks @akonyer.

Co-authored-by: Aaron Konyer <aaronk@gomodular.ca>
2026-01-15 02:58:20 +00:00
Peter Steinberger 7a839e7eb6 docs: add messaging channel guide 2026-01-15 02:50:03 +00:00
Peter Steinberger 1b24b6a02b fix: add plugin runtime registry 2026-01-15 02:44:45 +00:00
Peter Steinberger 2b4a68e276 feat: load channel plugins 2026-01-15 02:42:44 +00:00
Peter Steinberger b1e3d79eaa docs: clarify Claude CLI auth mode 2026-01-15 02:35:20 +00:00
Peter SteinbergerandSebastian 2fb2035dbf fix: normalize Claude CLI auth mode to oauth (#855)
Thanks @sebslight.

Co-authored-by: Sebastian <sebslight@gmail.com>
2026-01-15 02:29:43 +00:00
Peter Steinberger 3c51290e0d Merge pull request #850 from evalexpr/fix/slack-top-level-require-mention
fix(slack): respect top-level requireMention config
2026-01-15 02:28:25 +00:00
Peter Steinberger 765196d5c3 style(slack): satisfy oxfmt (#850) 2026-01-15 02:23:22 +00:00
Peter SteinbergerandMarc Terns 12c2d37e62 docs: document DM history limits (#883)
Thanks @pkrmf.

Co-authored-by: Marc Terns <tenxurz@gmail.com>
2026-01-15 02:22:29 +00:00
Peter Steinberger 151a551be9 fix(slack): honor channels.slack.requireMention default (#850) (thanks @evalexpr) 2026-01-15 02:19:24 +00:00
Jonathan WilkinsandClaude Opus 4.5 09ce6ff99e fix(slack): respect top-level requireMention config
The `channels.slack.requireMention` setting was defined in the schema
but never passed to `resolveSlackChannelConfig()`, which always
defaulted to `true`. This meant setting `requireMention: false` at the
top level had no effect—channels still required mentions.

Pass `slackCfg.requireMention` as `defaultRequireMention` to the
resolver and use it as the fallback instead of hardcoded `true`.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:17:27 +00:00
Peter Steinberger 6ffd7111a6 fix: add TUI status spinner 2026-01-15 02:11:12 +00:00
Peter Steinberger 7904a14af1 fix: render TUI pickers as overlays 2026-01-15 01:59:05 +00:00
Peter Steinberger 1b79730db8 style: apply oxfmt fixes 2026-01-15 01:53:14 +00:00
Peter Steinberger ad8799522c feat(config): gate channel config writes 2026-01-15 01:41:15 +00:00
Peter SteinbergerandGeorge Pickett f65668cb5f fix: suppress raw API error payloads (#924) (thanks @grp06)
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 01:34:19 +00:00
George Pickett 393d21d86c Format: fix report + telegram formatting 2026-01-15 01:27:16 +00:00
George Pickett 232c512502 Format: apply oxfmt fixes 2026-01-15 01:27:16 +00:00
George Pickett 8c1e6a82b2 Tests: add Gemini thoughtSignature for tool-call ids 2026-01-15 01:27:16 +00:00
George Pickett 2d54efe851 Embedded runner: suppress raw API error payloads (#919) 2026-01-15 01:27:16 +00:00
Peter Steinberger c2a4f256c8 feat: add security audit + onboarding checkpoint 2026-01-15 01:25:11 +00:00
Peter Steinberger c91c85532a docs: add context concept page 2026-01-15 01:12:59 +00:00
Peter SteinbergerandStan 326d4049da fix(telegram): migrate group config on supergroup IDs (#906)
Thanks @sleontenko.

Co-authored-by: Stan <sleontenko@users.noreply.github.com>
2026-01-15 01:10:30 +00:00
sleontenko 9b7c4b3884 feat(telegram): auto-migrate group config on supergroup migration
When a Telegram group is upgraded to a supergroup, the chat ID changes
(e.g., -123456 → -100123456). This causes the bot to lose its group
configuration since it's keyed by chat ID.

This change:
- Adds handler for `message:migrate_to_chat_id` event
- Logs the migration (old_id → new_id) for visibility
- If the old chat ID has config in channels.telegram.groups, automatically:
  - Copies the config to the new chat ID
  - Removes the old chat ID entry
  - Saves the updated config file

This eliminates the need to manually update clawdbot.json when groups
migrate to supergroups.
2026-01-15 01:10:30 +00:00
Peter Steinberger bcde09ae91 feat: add /context prompt breakdown 2026-01-15 01:06:35 +00:00
Peter Steinberger 632651aee2 docs: add markdown IR example 2026-01-15 00:37:04 +00:00
Peter Steinberger d2b76acb72 docs: expand markdown formatting pipeline 2026-01-15 00:34:34 +00:00
Peter Steinberger daf69e8154 chore: update clawtributors 2026-01-15 00:31:07 +00:00
Peter Steinberger e5c8abab9e fix: preserve markdown code fences 2026-01-15 00:31:07 +00:00
Peter Steinberger 2c312e20f1 fix: finalize markdown IR formatting 2026-01-15 00:31:07 +00:00
Peter Steinberger 9f1a8be2bf refactor: unify markdown formatting pipeline 2026-01-15 00:31:07 +00:00
Peter Steinberger bd7d362d3b refactor: unify markdown formatting pipeline 2026-01-15 00:31:07 +00:00
Peter SteinbergerandStan 0d0b77ded6 fix(telegram): wire delete action for message tool (#903) - thanks @sleontenko
Co-authored-by: Stan <sleontenko@users.noreply.github.com>
2026-01-15 00:29:53 +00:00
sleontenko 83a25d26fc feat(telegram): add deleteMessage action
Add ability to delete messages in Telegram chats via the message tool.

Changes:
- Add deleteMessageTelegram function in send.ts
- Add deleteMessage action handler in telegram-actions.ts
- Add delete action support in telegram message plugin adapter
- Add deleteMessage to TelegramActionConfig type
- Update message tool description to mention delete action

Usage:
- Via message tool: action="delete", chatId, messageId
- Can be disabled via channels.telegram.actions.deleteMessage=false

Limitations (Telegram API):
- Bot can delete its own messages in any chat
- Bot can delete others' messages only if admin with "Delete Messages"
- Messages older than 48h in groups may fail to delete
2026-01-15 00:29:53 +00:00
Peter Steinberger 9b7df414e6 test: add gemini 3 antigravity switch live repro 2026-01-15 00:29:53 +00:00
Peter Steinberger f87016a5fe fix: handle unsigned tool calls for gemini 3 2026-01-15 00:29:53 +00:00
Peter Steinberger 5894ffe82e refactor(auth): streamline allowFrom normalization 2026-01-14 23:42:50 +00:00
Peter Steinberger 50fa106d87 refactor: centralize dashboard url + ws close code 2026-01-14 23:42:12 +00:00
Peter SteinbergerandRahul Thakor 983e1b2303 fix: dashboard auth query items (#918) - thanks @rahthakor
Co-authored-by: Rahul Thakor <rahthakor@users.noreply.github.com>
2026-01-14 23:36:23 +00:00
rahthakor de7f567b9a docs(changelog): add gateway fix entries 2026-01-14 23:34:48 +00:00
rahthakor 0eabc89840 fix(ui): use application-defined WebSocket close code 2026-01-14 23:34:48 +00:00
rahthakor 400e901c9c fix(mac): pass auth token to dashboard URL 2026-01-14 23:34:48 +00:00
Peter SteinbergerandPeter Siska 57b4865ab3 fix(whatsapp): normalize user JIDs for group allowlists (#838)
Thanks @peschee.

Co-authored-by: Peter Siska <63866+peschee@users.noreply.github.com>
2026-01-14 23:25:42 +00:00
Peter Steinberger fd41000bc3 test(whatsapp): add context isolation coverage
Includes outbound gating, threading fallback, and web auto-reply context assertions.
2026-01-14 23:23:36 +00:00
Peter Steinberger a70937c926 refactor(discord): centralize autoThread reply plan (#856) 2026-01-14 23:07:05 +00:00
Shadow 4ec2222fa9 Revert "Clarify coding-agent skill trigger"
This reverts commit 0adcb68092.
2026-01-14 15:58:01 -06:00
Peter Steinberger fe974f420d chore: standardize Claude Code CLI naming (#915)
Follow-up to #915.
2026-01-14 20:07:35 +00:00
Peter SteinbergerandTristan Manchester e65e5f40c9 fix(whatsapp): use conversation id for context isolation (#911)
Thanks @tristanmanchester.

Co-authored-by: Tristan Manchester <tmanchester96@gmail.com>
2026-01-14 20:06:20 +00:00
Peter SteinbergerandDavid Guttman 0235eb6c72 refactor(discord): clean autoThread context wiring (#856)
Build reply/session context once (no post-hoc ctx mutation) and type into the actual delivery target.

Thanks @davidguttman.

Co-authored-by: David Guttman <david@davidguttman.com>
2026-01-14 20:04:25 +00:00
Peter SteinbergerandSean Katz e943e63174 chore(auth): rename Claude CLI to Claude Code CLI (#915)
Thanks @SeanZoR.

Co-authored-by: Sean Katz <connect@sean8.com>
2026-01-14 19:57:42 +00:00
Shadow b4ba6e4eaf Discord: isolate autoThread thread context (#856) 2026-01-14 12:25:35 -06:00
Mariano Belinky 0adcb68092 Clarify coding-agent skill trigger 2026-01-14 19:10:09 +01:00
Peter SteinbergerandRoshan Singh dadef27d7a fix(slack): drop mismatched Socket Mode events (#889)
Filter Slack Socket Mode events by api_app_id/team_id.
Refs: #828
Contributor: @roshanasingh4

Co-authored-by: Roshan Singh <roshanasingh4@users.noreply.github.com>
2026-01-14 15:54:37 +00:00
Peter SteinbergerandJosh Lehman 53465a4d2d fix: split long Telegram captions (#907) - thanks @jalehman
Co-authored-by: Josh Lehman <josh@martian.engineering>
2026-01-14 15:52:54 +00:00
Peter SteinbergerandWes 4e837cfa2d docs(changelog): credit macOS cron channel fix (#867)
Co-authored-by: Wes <wesleydavis@me.com>
2026-01-14 15:37:00 +00:00
Peter Steinberger 964e6169cb docs(changelog): add minimax usage entry 2026-01-14 15:02:20 +00:00
Peter SteinbergerandChristoph Nakazawa c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00
Nimrod Gutman 912ebffc63 fix(macos): update cron testing channel arg (#896) 2026-01-14 08:53:23 -06:00
Peter SteinbergerandSnaver b7a11b7bd4 test(telegram): cover per-account timeoutSeconds (#863)
Co-authored-by: Snaver <194855+Snaver@users.noreply.github.com>
2026-01-14 10:35:42 +00:00
Peter Steinberger 95bdb28a05 test(doctor): bump timeouts for slow cases 2026-01-14 10:35:39 +00:00
Peter Steinberger 9930ba91c5 fix(telegram): honor timeoutSeconds (thanks @Snaver) (#863) 2026-01-14 10:10:05 +00:00
Peter Steinberger 802c02eb74 style(lint): fix minimax usage lint 2026-01-14 10:10:04 +00:00
Peter Steinberger 2d4e3253ca fix(lint): tidy minimax usage fetch 2026-01-14 10:04:26 +00:00
Peter Steinberger da8d45d6c6 docs(usage): note minimax usage key 2026-01-14 09:57:54 +00:00
Peter Steinberger 18b4575e4d feat(usage): add minimax usage snapshot 2026-01-14 09:57:32 +00:00
Peter Steinberger 40fb59e5f7 refactor(live-tests): stabilize docker live suites 2026-01-14 09:52:39 +00:00
Peter Steinberger e3ff8c4d28 refactor(ui): split app modules 2026-01-14 09:11:36 +00:00
Peter Steinberger ce59e2dd76 refactor(telegram): split bot handlers 2026-01-14 09:11:32 +00:00
Peter Steinberger 32cfc49002 refactor(tui): split handlers 2026-01-14 09:11:28 +00:00
Peter Steinberger d19bc1562b refactor(gateway): split server runtime 2026-01-14 09:11:21 +00:00
Peter Steinberger ea018a68cc refactor(auto-reply): split reply pipeline 2026-01-14 09:11:16 +00:00
Peter Steinberger 1089444807 fix(gateway): reduce flaky ws rpc timeouts 2026-01-14 05:41:02 +00:00
Peter Steinberger 4af8228c34 test(web): rename split suites 2026-01-14 05:40:58 +00:00
Peter Steinberger ebea98b8ec test(slack): rename split suites 2026-01-14 05:40:55 +00:00
Peter Steinberger 51683071e8 test(signal): rename split suites 2026-01-14 05:40:52 +00:00
Peter Steinberger bfa46b2471 test(providers): rename split suites 2026-01-14 05:40:49 +00:00
Peter Steinberger de62797128 test(imessage): rename split suites 2026-01-14 05:40:45 +00:00
Peter Steinberger 05673fb6cf test(cron): rename split suites 2026-01-14 05:40:42 +00:00
Peter Steinberger 350f4709b7 test(auto-reply): rename split suites 2026-01-14 05:40:39 +00:00
Peter Steinberger b5f7ba502d refactor(voice-call): split manager 2026-01-14 05:40:19 +00:00
Peter Steinberger 8ba80d2dac refactor(ui): split render + connections 2026-01-14 05:40:14 +00:00
Peter Steinberger b11eea07b0 refactor(wizard): split onboarding 2026-01-14 05:40:10 +00:00
Peter Steinberger 35cea9be25 refactor(telegram): split bot helpers 2026-01-14 05:40:07 +00:00
Peter Steinberger 3e0e608110 refactor(infra): split provider usage 2026-01-14 05:40:03 +00:00
Peter Steinberger e2f8909982 refactor(agents): split tools + PI subscribe 2026-01-14 05:39:59 +00:00
Peter Steinberger ac613b6632 refactor(discord): split send pipeline 2026-01-14 05:39:55 +00:00
Peter Steinberger 5323652cfd refactor(config): split legacy handling 2026-01-14 05:39:51 +00:00
Peter Steinberger a58ff1ac63 refactor(commands): split CLI commands 2026-01-14 05:39:47 +00:00
Peter Steinberger 2b60ee96f2 refactor(browser): split pw tools + agent routes 2026-01-14 05:39:44 +00:00
Peter Steinberger da6f07b7c1 refactor(auto-reply): split directive handling 2026-01-14 05:39:41 +00:00
Peter Steinberger f0b624d6c9 chore: prep 2026.1.14 release 2026-01-14 01:39:29 +00:00
Peter Steinberger e4c3c02a36 refactor(voice-call): split twilio provider 2026-01-14 01:17:56 +00:00
Peter Steinberger ae796b1194 docs(tools): update slash commands 2026-01-14 01:17:56 +00:00
Peter Steinberger c892f38d3c refactor(scripts): extract clawtributors types 2026-01-14 01:17:56 +00:00
Peter Steinberger d98b6beb4d chore(e2e): reduce doctor-switch install noise 2026-01-14 01:17:56 +00:00
Peter Steinberger b80abf8dd1 refactor(vendor): align a2ui renderer typings 2026-01-14 01:17:56 +00:00
Peter Steinberger acfa762617 refactor(ui): split config and connections views 2026-01-14 01:17:56 +00:00
Peter Steinberger a44f1912b3 chore(repo): drop .DS_Store 2026-01-14 01:17:56 +00:00
Peter Steinberger bcbfb357be refactor(src): split oversized modules 2026-01-14 01:17:56 +00:00
Peter Steinberger b2179de839 fix(docker): harden docker e2e scripts 2026-01-14 01:17:08 +00:00
Peter Steinberger b1102cedd7 fix: support non-interactive token auth 2026-01-14 00:56:34 +00:00
Peter Steinberger 571f8c78bd chore: allow install e2e previous override 2026-01-14 00:38:26 +00:00
1819 changed files with 151337 additions and 136069 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# detect-secrets exclusion patterns (regex)
#
# Note: detect-secrets does not read this file by default. If you want these
# applied, wire them into your scan command (e.g. translate to --exclude-files
# / --exclude-lines) or into a baseline's filters_used.
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
# UI label string for Anthropic auth mode.
pattern = case \.apiKeyEnv: "API key \(env var\)"
# CodingKeys mapping uses apiKey literal.
pattern = case apikey = "apiKey"
# Schema labels referencing password fields (not actual secrets).
pattern = "gateway\.remote\.password"
pattern = "gateway\.auth\.password"
# Schema label for talk API key (label text only).
pattern = "talk\.apiKey"
# checking for typeof is not something we care about.
pattern = === "string"
# specific optional-chaining password check that didn't match the line above.
pattern = typeof remote\?\.password === "string"
+28 -3
View File
@@ -74,9 +74,9 @@ jobs:
- runtime: node - runtime: node
task: protocol task: protocol
command: pnpm protocol:check command: pnpm protocol:check
- runtime: bun - runtime: node
task: lint task: format
command: bunx biome check src command: pnpm format
- runtime: bun - runtime: bun
task: test task: test
command: bunx vitest run command: bunx vitest run
@@ -141,6 +141,31 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }} run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
checks-windows: checks-windows:
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
defaults: defaults:
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"indentWidth": 2,
"printWidth": 100
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/oxlintrc",
"extends": ["recommended"]
}
+518
View File
@@ -0,0 +1,518 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"(^|/)pnpm-lock\\.yaml$"
]
},
{
"path": "detect_secrets.filters.regex.should_exclude_line",
"pattern": [
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
"case apikey = \"apiKey\"",
"\"gateway\\.remote\\.password\"",
"\"gateway\\.auth\\.password\"",
"\"talk\\.apiKey\"",
"=== \"string\"",
"typeof remote\\?\\.password === \"string\""
]
}
],
"results": {
".env.example": [
{
"type": "Twilio API Key",
"filename": ".env.example",
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
"is_verified": false,
"line_number": 2
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
"is_verified": false,
"line_number": 19
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
"is_verified": false,
"line_number": 52
}
],
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
"is_verified": false,
"line_number": 26
},
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
"is_verified": false,
"line_number": 42
}
],
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 83
}
],
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 27
}
],
"docs/configuration.md": [
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 268
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 465
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 718
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 760
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 859
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 982
}
],
"docs/faq.md": [
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 593
},
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 650
}
],
"docs/skills-config.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills-config.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 28
}
],
"docs/skills.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 97
}
],
"docs/tailscale.md": [
{
"type": "Secret Keyword",
"filename": "docs/tailscale.md",
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
"is_verified": false,
"line_number": 52
}
],
"docs/talk.md": [
{
"type": "Secret Keyword",
"filename": "docs/talk.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 50
}
],
"docs/telegram.md": [
{
"type": "Secret Keyword",
"filename": "docs/telegram.md",
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
"is_verified": false,
"line_number": 57
}
],
"skills/local-places/SERVER_README.md": [
{
"type": "Secret Keyword",
"filename": "skills/local-places/SERVER_README.md",
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
"is_verified": false,
"line_number": 28
}
],
"skills/openai-whisper-api/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/openai-whisper-api/SKILL.md",
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
"is_verified": false,
"line_number": 39
}
],
"skills/trello/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/trello/SKILL.md",
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
"is_verified": false,
"line_number": 18
}
],
"src/agents/models-config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
"is_verified": false,
"line_number": 90
}
],
"src/agents/skills.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 158
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
"is_verified": false,
"line_number": 265
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
"is_verified": false,
"line_number": 462
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
"is_verified": false,
"line_number": 490
}
],
"src/browser/target-id.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/browser/target-id.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 16
}
],
"src/commands/antigravity-oauth.ts": [
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
"is_verified": false,
"line_number": 17
},
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
"is_verified": false,
"line_number": 20
}
],
"src/commands/onboard-auth.ts": [
{
"type": "Secret Keyword",
"filename": "src/commands/onboard-auth.ts",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 50
}
],
"src/config/config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/config/config.test.ts",
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
"is_verified": false,
"line_number": 520
}
],
"src/gateway/server.auth.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 89
},
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
"is_verified": false,
"line_number": 109
}
],
"src/infra/env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
"is_verified": false,
"line_number": 10
},
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
"is_verified": false,
"line_number": 25
}
],
"src/infra/shell-env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
"is_verified": false,
"line_number": 56
},
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
"is_verified": false,
"line_number": 73
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
"is_verified": false,
"line_number": 75
}
],
"src/web/qr-image.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/web/qr-image.test.ts",
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
"is_verified": false,
"line_number": 12
}
],
"vendor/a2ui/README.md": [
{
"type": "Secret Keyword",
"filename": "vendor/a2ui/README.md",
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
"is_verified": false,
"line_number": 123
}
]
},
"generated_at": "2026-01-05T13:01:00Z"
}
+2 -2
View File
@@ -25,12 +25,12 @@
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`. - Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs. - Node remains supported for running built output (`dist/*`) and production installs.
- Type-check/build: `pnpm build` (tsc) - Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format) - Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`. - Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits. - Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic. - Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. - Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
+46 -1
View File
@@ -1,5 +1,50 @@
# Changelog # Changelog
## 2026.1.15
### Changes
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
### Fixes
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
## 2026.1.14
### Changes
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
## 2026.1.13 ## 2026.1.13
### Fixes ### Fixes
@@ -309,7 +354,7 @@
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj. - Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
- Build: Docker build cache layer (#605) — thanks @zknicker. - Build: Docker build cache layer (#605) — thanks @zknicker.
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt). - Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
## 2026.1.8 ## 2026.1.8
+18 -17
View File
@@ -471,21 +471,22 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></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/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/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/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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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=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/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=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></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/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/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/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=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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=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/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/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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/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/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/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/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+34 -34
View File
@@ -67,20 +67,20 @@ enum CronSchedule: Codable, Equatable {
} }
} }
enum CronPayload: Codable, Equatable { enum CronPayload: Codable, Equatable {
case systemEvent(text: String) case systemEvent(text: String)
case agentTurn( case agentTurn(
message: String, message: String,
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
channel: String?, channel: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
} }
var kind: String { var kind: String {
switch self { switch self {
@@ -95,16 +95,16 @@ enum CronSchedule: Codable, Equatable {
switch kind { switch kind {
case "systemEvent": case "systemEvent":
self = try .systemEvent(text: container.decode(String.self, forKey: .text)) self = try .systemEvent(text: container.decode(String.self, forKey: .text))
case "agentTurn": case "agentTurn":
self = try .agentTurn( self = try .agentTurn(
message: container.decode(String.self, forKey: .message), message: container.decode(String.self, forKey: .message),
thinking: container.decodeIfPresent(String.self, forKey: .thinking), thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel) channel: container.decodeIfPresent(String.self, forKey: .channel)
?? container.decodeIfPresent(String.self, forKey: .provider), ?? container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to), to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default: default:
throw DecodingError.dataCorruptedError( throw DecodingError.dataCorruptedError(
forKey: .kind, forKey: .kind,
@@ -119,17 +119,17 @@ enum CronSchedule: Codable, Equatable {
switch self { switch self {
case let .systemEvent(text): case let .systemEvent(text):
try container.encode(text, forKey: .text) try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking) try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver) try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel) try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to) try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
} }
} }
} }
struct CronIsolation: Codable, Equatable { struct CronIsolation: Codable, Equatable {
var postToMainPrefix: String? var postToMainPrefix: String?
@@ -22,7 +22,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low", thinking: "low",
timeoutSeconds: 600, timeoutSeconds: 600,
deliver: true, deliver: true,
provider: "last", channel: "last",
to: nil, to: nil,
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -76,7 +76,7 @@ extension CronSettings {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
provider: "whatsapp", channel: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),
@@ -388,6 +388,43 @@ actor GatewayEndpointStore {
} }
} }
extension GatewayEndpointStore {
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
var queryItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
queryItems.append(URLQueryItem(name: "token", value: token))
}
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
queryItems.append(URLQueryItem(name: "password", value: password))
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
return url
}
}
#if DEBUG #if DEBUG
extension GatewayEndpointStore { extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
@@ -313,27 +313,7 @@ struct MenuContent: View {
private func openDashboard() async { private func openDashboard() async {
do { do {
let config = try await GatewayEndpointStore.shared.requireConfig() let config = try await GatewayEndpointStore.shared.requireConfig()
let wsURL = config.url let url = try GatewayEndpointStore.dashboardURL(for: config)
guard var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
])
}
switch components.scheme?.lowercased() {
case "ws":
components.scheme = "http"
case "wss":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
components.query = nil
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
])
}
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} catch { } catch {
let alert = NSAlert() let alert = NSAlert()
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/biome.json",
"formatter": {
"enabled": true,
"indentWidth": 2,
"indentStyle": "space"
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
+11
View File
@@ -61,6 +61,16 @@ Note: Discord does not provide a simple username → id lookup without extra gui
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy. Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
## Config writes
By default, Discord is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { discord: { configWrites: false } }
}
```
## How to create your own bot ## How to create your own bot
This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`. This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`.
@@ -290,6 +300,7 @@ ack reaction after the bot replies.
- `maxLinesPerMessage`: soft max line count per message. Default: 17. - `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk. - `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables). - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter). - `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `actions`: per-action tool gates; omit to allow all (set `false` to disable). - `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions) - `reactions` (covers react + read reactions)
+11
View File
@@ -35,6 +35,16 @@ Minimal config:
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:imessage:group:<chat_id>`). - DMs share the agent's main session; groups are isolated (`agent:<agentId>:imessage:group:<chat_id>`).
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `channels.imessage.groups` (see “Group-ish threads” below). - If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `channels.imessage.groups` (see “Group-ish threads” below).
## Config writes
By default, iMessage is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { imessage: { configWrites: false } }
}
```
## Requirements ## Requirements
- macOS with Messages signed in. - macOS with Messages signed in.
- Full Disk Access for Clawdbot + `imsg` (Messages DB access). - Full Disk Access for Clawdbot + `imsg` (Messages DB access).
@@ -179,6 +189,7 @@ Provider options:
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.imessage.groupAllowFrom`: group sender allowlist. - `channels.imessage.groupAllowFrom`: group sender allowlist.
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables). - `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
- `channels.imessage.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.imessage.dms["<handle>"].historyLimit`.
- `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults). - `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `channels.imessage.includeAttachments`: ingest attachments into context. - `channels.imessage.includeAttachments`: ingest attachments into context.
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
+11
View File
@@ -39,6 +39,16 @@ Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowl
- Keep routing deterministic: replies always go back to the channel they arrived on. - Keep routing deterministic: replies always go back to the channel they arrived on.
- Default to safe channel behavior (mentions required unless configured otherwise). - Default to safe channel behavior (mentions required unless configured otherwise).
## Config writes
By default, Microsoft Teams is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { msteams: { configWrites: false } }
}
```
## Access control (DMs + groups) ## Access control (DMs + groups)
**DM access** **DM access**
@@ -198,6 +208,7 @@ This is often easier than hand-editing JSON manifests.
## History context ## History context
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt. - `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.msteams.dmHistoryLimit` (user turns). Per-user overrides: `channels.msteams.dms["<user_id>"].historyLimit`.
## Current Teams RSC Permissions (Manifest) ## Current Teams RSC Permissions (Manifest)
These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed. These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed.
+11
View File
@@ -36,6 +36,16 @@ Minimal config:
- Deterministic routing: replies always go back to Signal. - Deterministic routing: replies always go back to Signal.
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:signal:group:<groupId>`). - DMs share the agent's main session; groups are isolated (`agent:<agentId>:signal:group:<groupId>`).
## Config writes
By default, Signal is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { signal: { configWrites: false } }
}
```
## The number model (important) ## The number model (important)
- The gateway connects to a **Signal device** (the `signal-cli` account). - The gateway connects to a **Signal device** (the `signal-cli` account).
- If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection). - If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection).
@@ -114,6 +124,7 @@ Provider options:
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.signal.groupAllowFrom`: group sender allowlist. - `channels.signal.groupAllowFrom`: group sender allowlist.
- `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size (chars). - `channels.signal.textChunkLimit`: outbound chunk size (chars).
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
+17
View File
@@ -32,6 +32,7 @@ Minimal config:
- `app_mention` - `app_mention`
- `reaction_added`, `reaction_removed` - `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel` - `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename` - `channel_rename`
- `pin_added`, `pin_removed` - `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read. 5) Invite the bot to channels you want it to read.
@@ -65,6 +66,21 @@ Or via config:
## History context ## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
## Config writes
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
This happens when:
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
Disable with:
```json5
{
channels: { slack: { configWrites: false } }
}
```
## Manifest (optional) ## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
@@ -133,6 +149,7 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"reaction_removed", "reaction_removed",
"member_joined_channel", "member_joined_channel",
"member_left_channel", "member_left_channel",
"channel_id_changed",
"channel_rename", "channel_rename",
"pin_added", "pin_added",
"pin_removed" "pin_removed"
+19 -1
View File
@@ -103,7 +103,9 @@ group messages, so use admin if you need full visibility.
## Limits ## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.telegram.dmHistoryLimit` (user turns). Per-user overrides: `channels.telegram.dms["<user_id>"].historyLimit`.
## Group activation modes ## Group activation modes
@@ -167,6 +169,20 @@ Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram
**Privacy note:** `@userinfobot` is a third-party bot. If you prefer, use gateway logs (`clawdbot logs`) or Telegram developer tools to find user/chat IDs. **Privacy note:** `@userinfobot` is a third-party bot. If you prefer, use gateway logs (`clawdbot logs`) or Telegram developer tools to find user/chat IDs.
## Config writes
By default, Telegram is allowed to write config updates triggered by channel events or `/config set|unset`.
This happens when:
- A group is upgraded to a supergroup and Telegram emits `migrate_to_chat_id` (chat ID changes). Clawdbot can migrate `channels.telegram.groups` automatically.
- You run `/config set` or `/config unset` in a Telegram chat (requires `commands.config: true`).
Disable with:
```json5
{
channels: { telegram: { configWrites: false } }
}
```
## Topics (forum supergroups) ## Topics (forum supergroups)
Telegram forum topics include a `message_thread_id` per message. Clawdbot: Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated. - Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
@@ -261,8 +277,9 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
## Agent tool (messages + reactions) ## Agent tool (messages + reactions)
- Tool: `telegram` with `sendMessage` action (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`). - Tool: `telegram` with `sendMessage` action (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`).
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`). - Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions` and `channels.telegram.actions.sendMessage` (default: enabled). - Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target. - Use a chat id (`123456789`) or a username (`@name`) as the target.
@@ -321,6 +338,7 @@ Provider options:
- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`). - `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
- `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
Related global options: Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns). - `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+11
View File
@@ -31,6 +31,16 @@ Minimal config:
- Deterministic routing: replies return to WhatsApp, no model routing. - Deterministic routing: replies return to WhatsApp, no model routing.
- Model sees enough context to understand quoted replies. - Model sees enough context to understand quoted replies.
## Config writes
By default, WhatsApp is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { whatsapp: { configWrites: false } }
}
```
## Architecture (who owns what) ## Architecture (who owns what)
- **Gateway** owns the Baileys socket and inbox loop. - **Gateway** owns the Baileys socket and inbox loop.
- **CLI / macOS app** talk to the gateway; no direct Baileys use. - **CLI / macOS app** talk to the gateway; no direct Baileys use.
@@ -282,6 +292,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- `channels.whatsapp.groupAllowFrom` (group sender allowlist). - `channels.whatsapp.groupAllowFrom` (group sender allowlist).
- `channels.whatsapp.groupPolicy` (group policy). - `channels.whatsapp.groupPolicy` (group policy).
- `channels.whatsapp.historyLimit` / `channels.whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables). - `channels.whatsapp.historyLimit` / `channels.whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
- `channels.whatsapp.dmHistoryLimit` (DM history limit in user turns). Per-user overrides: `channels.whatsapp.dms["<phone>"].historyLimit`.
- `channels.whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `channels.whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `channels.whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `channels.whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
+1 -1
View File
@@ -81,7 +81,7 @@ Target formats (`--to`):
- Optional: `--channel-id` - Optional: `--channel-id`
- `delete` - `delete`
- Channels: Discord/Slack - Channels: Discord/Slack/Telegram
- Required: `--message-id`, `--to` or `--channel-id` - Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id` - Optional: `--channel-id`
+152
View File
@@ -0,0 +1,152 @@
---
summary: "Context: what the model sees, how it is built, and how to inspect it"
read_when:
- You want to understand what “context” means in Clawdbot
- You are debugging why the model “knows” something (or forgot it)
- You want to reduce context overhead (/context, /status, /compact)
---
# Context
“Context” is **everything Clawdbot sends to the model for a run**. It is bounded by the model’s **context window** (token limit).
Beginner mental model:
- **System prompt** (Clawdbot-built): rules, tools, skills list, time/runtime, and injected workspace files.
- **Conversation history**: your messages + the assistant’s messages for this session.
- **Tool calls/results + attachments**: command output, file reads, images/audio, etc.
Context is *not the same thing* as “memory”: memory can be stored on disk and reloaded later; context is what’s inside the model’s current window.
## Quick start (inspect context)
- `/status` → quick “how full is my window?” view + session settings.
- `/context list` → what’s injected + rough sizes (per file + totals).
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
- `/cost on` → append per-reply usage line to normal replies.
- `/compact` → summarize older history into a compact entry to free window space.
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
## Example output
Values vary by model, provider, tool policy, and what’s in your workspace.
### `/context list`
```
🧠 Context breakdown
Workspace: <workspaceDir>
Bootstrap max/file: 20,000 chars
Sandbox: mode=non-main sandboxed=false
System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok))
Injected workspace files:
- AGENTS.md: OK | raw 1,742 chars (~436 tok) | injected 1,742 chars (~436 tok)
- SOUL.md: OK | raw 912 chars (~228 tok) | injected 912 chars (~228 tok)
- TOOLS.md: TRUNCATED | raw 54,210 chars (~13,553 tok) | injected 20,962 chars (~5,241 tok)
- IDENTITY.md: OK | raw 211 chars (~53 tok) | injected 211 chars (~53 tok)
- USER.md: OK | raw 388 chars (~97 tok) | injected 388 chars (~97 tok)
- HEARTBEAT.md: MISSING | raw 0 | injected 0
- BOOTSTRAP.md: OK | raw 0 chars (~0 tok) | injected 0 chars (~0 tok)
Skills list (system prompt text): 2,184 chars (~546 tok) (12 skills)
Tools: read, edit, write, exec, process, browser, message, sessions_send, …
Tool list (system prompt text): 1,032 chars (~258 tok)
Tool schemas (JSON): 31,988 chars (~7,997 tok) (counts toward context; not shown as text)
Tools: (same as above)
Session tokens (cached): 14,250 total / ctx=32,000
```
### `/context detail`
```
🧠 Context breakdown (detailed)
…
Top skills (prompt entry size):
- frontend-design: 412 chars (~103 tok)
- oracle: 401 chars (~101 tok)
… (+10 more skills)
Top tools (schema size):
- browser: 9,812 chars (~2,453 tok)
- exec: 6,240 chars (~1,560 tok)
… (+N more tools)
```
## What counts toward the context window
Everything the model receives counts, including:
- System prompt (all sections).
- Conversation history.
- Tool calls + tool results.
- Attachments/transcripts (images/audio/files).
- Compaction summaries and pruning artifacts.
- Provider “wrappers” or hidden headers (not visible, still counted).
## How Clawdbot builds the system prompt
The system prompt is **Clawdbot-owned** and rebuilt each run. It includes:
- Tool list + short descriptions.
- Skills list (metadata only; see below).
- Workspace location.
- Time (UTC + converted user time if configured).
- Runtime metadata (host/OS/model/thinking).
- Injected workspace bootstrap files under **Project Context**.
Full breakdown: [System Prompt](/concepts/system-prompt).
## Injected workspace files (Project Context)
By default, Clawdbot injects a fixed set of workspace files (if present):
- `AGENTS.md`
- `SOUL.md`
- `TOOLS.md`
- `IDENTITY.md`
- `USER.md`
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (first-run only)
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
## Skills: what’s injected vs loaded on-demand
The system prompt includes a compact **skills list** (name + description + location). This list has real overhead.
Skill instructions are *not* included by default. The model is expected to `read` the skill’s `SKILL.md` **only when needed**.
## Tools: there are two costs
Tools affect context in two ways:
1) **Tool list text** in the system prompt (what you see as “Tooling”).
2) **Tool schemas** (JSON). These are sent to the model so it can call tools. They count toward context even though you don’t see them as plain text.
`/context detail` breaks down the biggest tool schemas so you can see what dominates.
## Commands, directives, and “inline shortcuts”
Slash commands are handled by the Gateway. There are a few different behaviors:
- **Standalone commands**: a message that is only `/...` runs as a command.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue` are stripped before the model sees the message.
- Directive-only messages persist session settings.
- Inline directives in a normal message act as per-message hints.
- **Inline shortcuts** (allowlisted senders only): certain `/...` tokens inside a normal message can run immediately (example: “hey /status”), and are stripped before the model sees the remaining text.
Details: [Slash commands](/tools/slash-commands).
## Sessions, compaction, and pruning (what persists)
What persists across messages depends on the mechanism:
- **Normal history** persists in the session transcript until compacted/pruned by policy.
- **Compaction** persists a summary into the transcript and keeps recent messages intact.
- **Pruning** removes old tool results from the *in-memory* prompt for a run, but does not rewrite the transcript.
Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning).
## What `/context` actually reports
`/context` prefers the latest **run-built** system prompt report when available:
- `System prompt (run)` = captured from the last embedded (tool-capable) run and persisted in the session store.
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
+108
View File
@@ -0,0 +1,108 @@
---
summary: "Markdown formatting pipeline for outbound channels"
read_when:
- You are changing markdown formatting or chunking for outbound channels
- You are adding a new channel formatter or style mapping
- You are debugging formatting regressions across channels
---
# Markdown formatting
Clawdbot formats outbound Markdown by converting it into a shared intermediate
representation (IR) before rendering channel-specific output. The IR keeps the
source text intact while carrying style/link spans so chunking and rendering can
stay consistent across channels.
## Goals
- **Consistency:** one parse step, multiple renderers.
- **Safe chunking:** split text before rendering so inline formatting never
breaks across chunks.
- **Channel fit:** map the same IR to Slack mrkdwn, Telegram HTML, and Signal
style ranges without re-parsing Markdown.
## Pipeline
1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
- Offsets are UTF-16 code units so Signal style ranges align with its API.
2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk.
3. **Render per channel**
- **Slack:** mrkdwn tokens (bold/italic/strike/code), links as `<url|label>`.
- **Telegram:** HTML tags (`<b>`, `<i>`, `<s>`, `<code>`, `<pre><code>`, `<a href>`).
- **Signal:** plain text + `text-style` ranges; links become `label (url)` when label differs.
## IR example
Input Markdown:
```markdown
Hello **world** — see [docs](https://docs.clawd.bot).
```
IR (schematic):
```json
{
"text": "Hello world — see docs.",
"styles": [
{ "start": 6, "end": 11, "style": "bold" }
],
"links": [
{ "start": 19, "end": 23, "href": "https://docs.clawd.bot" }
]
}
```
## Where it is used
- Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
their own formatting rules.
## Chunking rules
- Chunk limits come from channel adapters/config and are applied to the IR text.
- Code fences are preserved as a single block with a trailing newline so channels
render them correctly.
- List prefixes and blockquote prefixes are part of the IR text, so chunking
does not split mid-prefix.
- Inline styles (bold/italic/strike/inline-code/spoiler) are never split across
chunks; the renderer reopens styles inside each chunk.
If you need more on chunking behavior across channels, see
[Streaming + chunking](/concepts/streaming).
## Link policy
- **Slack:** `[label](url)` -> `<url|label>`; bare URLs remain bare. Autolink
is disabled during parse to avoid double-linking.
- **Telegram:** `[label](url)` -> `<a href="url">label</a>` (HTML parse mode).
- **Signal:** `[label](url)` -> `label (url)` unless label matches the URL.
## Spoilers
Spoiler markers (`||spoiler||`) are parsed only for Signal, where they map to
SPOILER style ranges. Other channels treat them as plain text.
## How to add or update a channel formatter
1. **Parse once:** use the shared `markdownToIR(...)` helper with channel-appropriate
options (autolink, heading style, blockquote prefix).
2. **Render:** implement a renderer with `renderMarkdownWithMarkers(...)` and a
style marker map (or Signal style ranges).
3. **Chunk:** call `chunkMarkdownIR(...)` before rendering; render each chunk.
4. **Wire adapter:** update the channel outbound adapter to use the new chunker
and renderer.
5. **Test:** add or update format tests and an outbound delivery test if the
channel uses chunking.
## Common gotchas
- Slack angle-bracket tokens (`<@U123>`, `<#C123>`, `<https://...>`) must be
preserved; escape raw HTML safely.
- Telegram HTML requires escaping text outside tags to avoid broken markup.
- Signal style ranges depend on UTF-16 offsets; do not use code point offsets.
- Preserve trailing newlines for fenced code blocks so closing markers land on
their own line.
+1 -1
View File
@@ -152,7 +152,7 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
(effective auth per provider). (effective auth per provider).
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring). Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
Preferred Anthropic auth is the Claude CLI setup-token (run on the gateway host): Preferred Anthropic auth is the Claude Code CLI setup-token (run on the gateway host):
```bash ```bash
claude setup-token claude setup-token
+1
View File
@@ -90,6 +90,7 @@ Send these as standalone messages so they register.
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`). - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
- Send `/stop` as a standalone message to abort the current run. - Send `/stop` as a standalone message to abort the current run.
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
- JSONL transcripts can be opened directly to review full turns. - JSONL transcripts can be opened directly to review full turns.
+2
View File
@@ -42,6 +42,8 @@ Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a `agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
short missing-file marker. short missing-file marker.
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
## Time handling ## Time handling
The Time line is compact and explicit: The Time line is compact and explicit:
+1
View File
@@ -23,6 +23,7 @@ read_when:
- **Gemini CLI**: OAuth tokens in auth profiles. - **Gemini CLI**: OAuth tokens in auth profiles.
- **Antigravity**: OAuth tokens in auth profiles. - **Antigravity**: OAuth tokens in auth profiles.
- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present). - **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present).
- **MiniMax**: API key (coding plan key; `MINIMAX_CODE_PLAN_KEY` or `MINIMAX_API_KEY`); uses the 5‑hour coding plan window.
- **z.ai**: API key via env/config/auth store. - **z.ai**: API key via env/config/auth store.
Usage is hidden if no matching OAuth/API credentials exist. Usage is hidden if no matching OAuth/API credentials exist.
+9
View File
@@ -45,6 +45,14 @@
"source": "/messages/", "source": "/messages/",
"destination": "/concepts/messages" "destination": "/concepts/messages"
}, },
{
"source": "/context",
"destination": "/concepts/context"
},
{
"source": "/context/",
"destination": "/concepts/context"
},
{ {
"source": "/minimax", "source": "/minimax",
"destination": "/providers/minimax" "destination": "/providers/minimax"
@@ -765,6 +773,7 @@
"concepts/agent", "concepts/agent",
"concepts/agent-loop", "concepts/agent-loop",
"concepts/system-prompt", "concepts/system-prompt",
"concepts/context",
"token-use", "token-use",
"concepts/oauth", "concepts/oauth",
"concepts/agent-workspace", "concepts/agent-workspace",
+7 -3
View File
@@ -47,10 +47,10 @@ API keys for daemon use: `clawdbot onboard`.
See [/start/faq](/start/faq) for details on env inheritance (`env.shellEnv`, See [/start/faq](/start/faq) for details on env inheritance (`env.shellEnv`,
`~/.clawdbot/.env`, systemd/launchd). `~/.clawdbot/.env`, systemd/launchd).
## Anthropic: Claude CLI setup-token (supported) ## Anthropic: Claude Code CLI setup-token (supported)
For Anthropic, the recommended path is an **API key**. If you’re already using For Anthropic, the recommended path is an **API key**. If you’re already using
Claude Code, the Claude CLI setup-token is also supported. Claude Code CLI, the setup-token flow is also supported.
Run it on the **gateway host**: Run it on the **gateway host**:
```bash ```bash
@@ -67,6 +67,10 @@ clawdbot doctor
This should create (or refresh) an auth profile like `anthropic:claude-cli` in This should create (or refresh) an auth profile like `anthropic:claude-cli` in
the agent auth store. the agent auth store.
Clawdbot config sets `auth.profiles["anthropic:claude-cli"].mode` to `"oauth"` so
the profile accepts both OAuth and setup-token credentials. Older configs that
used `"token"` are auto-migrated on load.
If you see an Anthropic error like: If you see an Anthropic error like:
``` ```
@@ -138,7 +142,7 @@ Use `--agent <id>` to target a specific agent; omit it to use the configured def
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
loaded. loaded.
3. Refreshable OAuth profiles can be refreshed automatically on use. Static 3. Refreshable OAuth profiles can be refreshed automatically on use. Static
token profiles (including Claude CLI setup-token) are not refreshable by token profiles (including Claude Code CLI setup-token) are not refreshable by
Clawdbot. Clawdbot.
## Troubleshooting ## Troubleshooting
+3 -3
View File
@@ -2,7 +2,7 @@
summary: "CLI backends: text-only fallback via local AI CLIs" summary: "CLI backends: text-only fallback via local AI CLIs"
read_when: read_when:
- You want a reliable fallback when API providers fail - You want a reliable fallback when API providers fail
- You are running Claude CLI or other local AI CLIs and want to reuse them - You are running Claude Code CLI or other local AI CLIs and want to reuse them
- You need a text-only, tool-free path that still supports sessions and images - You need a text-only, tool-free path that still supports sessions and images
--- ---
# CLI backends (fallback runtime) # CLI backends (fallback runtime)
@@ -20,7 +20,7 @@ want “always works” text responses without relying on external APIs.
## Beginner-friendly quick start ## Beginner-friendly quick start
You can use Claude CLI **without any config** (Clawdbot ships a built-in default): You can use Claude Code CLI **without any config** (Clawdbot ships a built-in default):
```bash ```bash
clawdbot agent --message "hi" --model claude-cli/opus-4.5 clawdbot agent --message "hi" --model claude-cli/opus-4.5
@@ -162,7 +162,7 @@ imageMode: "repeat"
Clawdbot will write base64 images to temp files. If `imageArg` is set, those Clawdbot will write base64 images to temp files. If `imageArg` is set, those
paths are passed as CLI args. If `imageArg` is missing, Clawdbot appends the paths are passed as CLI args. If `imageArg` is missing, Clawdbot appends the
file paths to the prompt (path injection), which is enough for CLIs that auto- file paths to the prompt (path injection), which is enough for CLIs that auto-
load local files from plain paths (Claude CLI behavior). load local files from plain paths (Claude Code CLI behavior).
## Inputs / outputs ## Inputs / outputs
+31
View File
@@ -306,6 +306,10 @@ rotation order used for failover.
} }
``` ```
Note: `anthropic:claude-cli` should use `mode: "oauth"` even when the stored
credential is a setup-token. Clawdbot auto-migrates older configs that used
`mode: "token"`.
### `agents.list[].identity` ### `agents.list[].identity`
Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant. Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
@@ -479,6 +483,30 @@ Group messages default to **require mention** (either metadata mention or regex
`messages.groupChat.historyLimit` sets the global default for group history context. Channels can override with `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit` for multi-account). Set `0` to disable history wrapping. `messages.groupChat.historyLimit` sets the global default for group history context. Channels can override with `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit` for multi-account). Set `0` to disable history wrapping.
#### DM history limits
DM conversations use session-based history managed by the agent. You can limit the number of user turns retained per DM session:
```json5
{
channels: {
telegram: {
dmHistoryLimit: 30, // limit DM sessions to 30 user turns
dms: {
"123456789": { historyLimit: 50 } // per-user override (user ID)
}
}
}
}
```
Resolution order:
1. Per-DM override: `channels.<provider>.dms[userId].historyLimit`
2. Provider default: `channels.<provider>.dmHistoryLimit`
3. No limit (all history retained)
Supported providers: `telegram`, `whatsapp`, `discord`, `slack`, `signal`, `imessage`, `msteams`.
Per-agent override (takes precedence when set, even `[]`): Per-agent override (takes precedence when set, even `[]`):
```json5 ```json5
{ {
@@ -780,6 +808,7 @@ Notes:
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`. - `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time). - `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
- `channels.<provider>.configWrites` gates config mutations initiated by that channel (default: true). This applies to `/config set|unset` plus provider-specific auto-migrations (Telegram supergroup ID changes, Slack channel ID changes).
- `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action. - `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. - `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
@@ -810,6 +839,7 @@ Set `web.enabled: false` to keep it off by default.
Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`. Clawdbot starts Telegram only when a `channels.telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`.
Set `channels.telegram.enabled: false` to disable automatic startup. Set `channels.telegram.enabled: false` to disable automatic startup.
Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account. Multi-account support lives under `channels.telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Set `channels.telegram.configWrites: false` to block Telegram-initiated config writes (including supergroup ID migrations and `/config set|unset`).
```json5 ```json5
{ {
@@ -1002,6 +1032,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account. Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`. Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
+138 -6
View File
@@ -42,6 +42,12 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
- Prefer explicit `plugins.allow` allowlists. - Prefer explicit `plugins.allow` allowlists.
- Review plugin config before enabling. - Review plugin config before enabling.
- Restart the Gateway after plugin changes. - Restart the Gateway after plugin changes.
- If you install plugins from npm (`clawdbot plugins install <npm-spec>`), treat it like running untrusted code:
- The install path is `~/.clawdbot/extensions/<pluginId>/` (or `$CLAWDBOT_STATE_DIR/extensions/<pluginId>/`).
- Clawdbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
Details: [Plugins](/plugin)
## DM access model (pairing / allowlist / open / disabled) ## DM access model (pairing / allowlist / open / disabled)
@@ -120,6 +126,21 @@ Keep config + state private on the gateway host:
`clawdbot doctor` can warn and offer to tighten these permissions. `clawdbot doctor` can warn and offer to tighten these permissions.
### 0.4) Network exposure (bind + port + firewall)
The Gateway multiplexes **WebSocket + HTTP** on a single port:
- Default: `18789`
- Config/flags/env: `gateway.port`, `--port`, `CLAWDBOT_GATEWAY_PORT`
Bind mode controls where the Gateway listens:
- `gateway.bind: "loopback"` (default): only local clients can connect.
- Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
Rules of thumb:
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
- If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly.
- Never expose the Gateway unauthenticated on `0.0.0.0`.
### 0.5) Lock down the Gateway WebSocket (local auth) ### 0.5) Lock down the Gateway WebSocket (local auth)
Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset, Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset,
@@ -145,6 +166,16 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
protect local WS access. protect local WS access.
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`).
Rotation checklist (token/password):
1. Generate/set a new secret (`gateway.auth.token` or `CLAWDBOT_GATEWAY_PASSWORD`).
2. Restart the Gateway (or restart the macOS app if it supervises the Gateway).
3. Update any remote clients (`gateway.remote.token` / `.password` on machines that call into the Gateway).
4. Verify you can no longer connect with the old credentials.
### 0.6) Tailscale Serve identity headers ### 0.6) Tailscale Serve identity headers
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
@@ -159,6 +190,36 @@ you terminate TLS or proxy in front of the gateway, disable
See [Tailscale](/gateway/tailscale) and [Web overview](/web). See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.7) Secrets on disk (what’s sensitive)
Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
- `clawdbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
- `agents/<agentId>/agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`).
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.
- `extensions/**`: installed plugins (plus their `node_modules/`).
- `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox.
Hardening tips:
- Keep permissions tight (`700` on dirs, `600` on files).
- Use full-disk encryption on the gateway host.
- Prefer a dedicated OS user account for the Gateway if the host is shared.
### 0.8) Logs + transcripts (redaction + retention)
Logs and transcripts can leak sensitive info even when access controls are correct:
- Gateway logs may include tool summaries, errors, and URLs.
- Session transcripts can include pasted secrets, file contents, command output, and links.
Recommendations:
- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default).
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
- When sharing diagnostics, prefer `clawdbot status --all` (pasteable, secrets redacted) over raw logs.
- Prune old session transcripts and log files if you don’t need long retention.
Details: [Logging](/gateway/logging)
### 1) DMs: pairing by default ### 1) DMs: pairing by default
```json5 ```json5
@@ -205,6 +266,29 @@ You can already build a read-only profile by combining:
We may add a single `readOnlyMode` flag later to simplify this configuration. We may add a single `readOnlyMode` flag later to simplify this configuration.
### 5) Secure baseline (copy/paste)
One “safe default” config that keeps the Gateway private, requires DM pairing, and avoids always-on group bots:
```json5
{
gateway: {
mode: "local",
bind: "loopback",
port: 18789,
auth: { mode: "token", token: "your-long-random-token" }
},
channels: {
whatsapp: {
dmPolicy: "pairing",
groups: { "*": { requireMention: true } }
}
}
}
```
If you want “safer by default” tool execution too, add a sandbox + deny dangerous tools for any non-owner agent (example below under “Per-agent access profiles”).
## Sandboxing (recommended) ## Sandboxing (recommended)
Dedicated doc: [Sandboxing](/gateway/sandboxing) Dedicated doc: [Sandboxing](/gateway/sandboxing)
@@ -233,6 +317,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
- Prefer a dedicated profile for the agent (the default `clawd` profile). - Prefer a dedicated profile for the agent (the default `clawd` profile).
- Avoid pointing the agent at your personal daily-driver profile. - Avoid pointing the agent at your personal daily-driver profile.
- Keep host browser control disabled for sandboxed agents unless you trust them. - Keep host browser control disabled for sandboxed agents unless you trust them.
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
## Per-agent access profiles (multi-agent) ## Per-agent access profiles (multi-agent)
@@ -301,7 +388,7 @@ Common use cases:
workspaceAccess: "none" workspaceAccess: "none"
}, },
tools: { tools: {
allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord", "gateway"], allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord"],
deny: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"] deny: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
} }
} }
@@ -327,11 +414,56 @@ Include security guidelines in your agent's system prompt:
If your AI does something bad: If your AI does something bad:
1. **Stop it:** stop the macOS app (if it’s supervising the Gateway) or terminate your `clawdbot gateway` process ### Contain
2. **Check logs:** `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`)
3. **Review session:** Check `~/.clawdbot/agents/<agentId>/sessions/` for what happened 1. **Stop it:** stop the macOS app (if it supervises the Gateway) or terminate your `clawdbot gateway` process.
4. **Rotate secrets:** If credentials were exposed 2. **Close exposure:** set `gateway.bind: "loopback"` (or disable Tailscale Funnel/Serve) until you understand what happened.
5. **Update rules:** Add to your security prompt 3. **Freeze access:** switch risky DMs/groups to `dmPolicy: "disabled"` / require mentions, and remove `"*"` allow-all entries if you had them.
### Rotate (assume compromise if secrets leaked)
1. Rotate Gateway auth (`gateway.auth.token` / `CLAWDBOT_GATEWAY_PASSWORD`) and restart.
2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway.
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`).
### Audit
1. Check Gateway logs: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or `logging.file`).
2. Review the relevant transcript(s): `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes).
### Collect for a report
- Timestamp, gateway host OS + Clawdbot version
- The session transcript(s) + a short log tail (after redacting)
- What the attacker sent + what the agent did
- Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve)
## Secret Scanning (detect-secrets)
CI runs `detect-secrets scan --baseline .secrets.baseline` in the `secrets` job.
If it fails, there are new candidates not yet in the baseline.
### If CI fails
1. Reproduce locally:
```bash
detect-secrets scan --baseline .secrets.baseline
```
2. Understand the tools:
- `detect-secrets scan` finds candidates and compares them to the baseline.
- `detect-secrets audit` opens an interactive review to mark each baseline
item as real or false positive.
3. For real secrets: rotate/remove them, then re-run the scan to update the baseline.
4. For false positives: run the interactive audit and mark them as false:
```bash
detect-secrets audit .secrets.baseline
```
5. If you need new excludes, add them to `.detect-secrets.cfg` and regenerate the
baseline with matching `--exclude-files` / `--exclude-lines` flags (the config
file is reference-only; detect-secrets doesn’t read it automatically).
Commit the updated `.secrets.baseline` once it reflects the intended state.
## The Trust Hierarchy ## The Trust Hierarchy
+5
View File
@@ -31,6 +31,11 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues ## Common Issues
### CI Secrets Scan Failed
This means `detect-secrets` found new candidates not yet in the baseline.
Follow [Secret scanning](/gateway/security#secret-scanning-detect-secrets).
### Service Installed but Nothing is Running ### Service Installed but Nothing is Running
If the gateway service is installed but the process exits immediately, the daemon If the gateway service is installed but the process exits immediately, the daemon
+4
View File
@@ -434,3 +434,7 @@ Example:
- Container not running: it will auto-create per session on demand. - Container not running: it will auto-create per session on demand.
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your - Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
mounted workspace ownership (or chown the workspace folder). mounted workspace ownership (or chown the workspace folder).
- Custom tools not found: Clawdbot runs commands with `sh -lc` (login shell), which
sources `/etc/profile` and may reset PATH. Set `docker.env.PATH` to prepend your
custom tool paths (e.g., `/custom/bin:/usr/local/share/npm-global/bin`), or add
a script under `/etc/profile.d/` in your Dockerfile.
+115
View File
@@ -165,6 +165,121 @@ Plugins export either:
- A function: `(api) => { ... }` - A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }` - An object: `{ id, name, configSchema, register(api) { ... } }`
### Register a messaging channel
Plugins can register **channel plugins** that behave like built‑in channels
(WhatsApp, Telegram, etc.). Channel config lives under `channels.<id>` and is
validated by your channel plugin code.
```ts
const myChannel = {
id: "acmechat",
meta: {
id: "acmechat",
label: "AcmeChat",
selectionLabel: "AcmeChat (API)",
docsPath: "/channels/acmechat",
blurb: "demo channel plugin.",
aliases: ["acme"],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
(cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }),
},
outbound: {
deliveryMode: "direct",
sendText: async () => ({ ok: true }),
},
};
export default function (api) {
api.registerChannel({ plugin: myChannel });
}
```
Notes:
- Put config under `channels.<id>` (not `plugins.entries`).
- `meta.label` is used for labels in CLI/UI lists.
- `meta.aliases` adds alternate ids for normalization and CLI inputs.
### Write a new messaging channel (step‑by‑step)
Use this when you want a **new chat surface** (a “messaging channel”), not a model provider.
Model provider docs live under `/providers/*`.
1) Pick an id + config shape
- All channel config lives under `channels.<id>`.
- Prefer `channels.<id>.accounts.<accountId>` for multi‑account setups.
2) Define the channel metadata
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
- `meta.docsPath` should point at a docs page like `/channels/<id>`.
3) Implement the required adapters
- `config.listAccountIds` + `config.resolveAccount`
- `capabilities` (chat types, media, threads, etc.)
- `outbound.deliveryMode` + `outbound.sendText` (for basic send)
4) Add optional adapters as needed
- `setup` (wizard), `security` (DM policy), `status` (health/diagnostics)
- `gateway` (start/stop/login), `mentions`, `threading`, `streaming`
- `actions` (message actions), `commands` (native command behavior)
5) Register the channel in your plugin
- `api.registerChannel({ plugin })`
Minimal config example:
```json5
{
channels: {
acmechat: {
accounts: {
default: { token: "ACME_TOKEN", enabled: true }
}
}
}
}
```
Minimal channel plugin (outbound‑only):
```ts
const plugin = {
id: "acmechat",
meta: {
id: "acmechat",
label: "AcmeChat",
selectionLabel: "AcmeChat (API)",
docsPath: "/channels/acmechat",
blurb: "AcmeChat messaging channel.",
aliases: ["acme"],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
(cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }),
},
outbound: {
deliveryMode: "direct",
sendText: async ({ text }) => {
// deliver `text` to your channel here
return { ok: true };
},
},
};
export default function (api) {
api.registerChannel({ plugin });
}
```
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
then configure `channels.<id>` in your config.
### Register a tool ### Register a tool
```ts ```ts
+10 -7
View File
@@ -1,14 +1,14 @@
--- ---
summary: "Use Anthropic Claude via API keys or Claude CLI auth in Clawdbot" summary: "Use Anthropic Claude via API keys or Claude Code CLI auth in Clawdbot"
read_when: read_when:
- You want to use Anthropic models in Clawdbot - You want to use Anthropic models in Clawdbot
- You want setup-token or Claude CLI auth instead of API keys - You want setup-token or Claude Code CLI auth instead of API keys
--- ---
# Anthropic (Claude) # Anthropic (Claude)
Anthropic builds the **Claude** model family and provides access via an API. Anthropic builds the **Claude** model family and provides access via an API.
In Clawdbot you can authenticate with an API key or reuse **Claude Code / Claude CLI** In Clawdbot you can authenticate with an API key or reuse **Claude Code CLI** credentials
credentials (setup-token or OAuth). (setup-token or OAuth).
## Option A: Anthropic API key ## Option A: Anthropic API key
@@ -34,9 +34,9 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
} }
``` ```
## Option B: Claude CLI (setup-token or OAuth) ## Option B: Claude Code CLI (setup-token or OAuth)
**Best for:** using your Claude subscription or existing Claude CLI login. **Best for:** using your Claude subscription or existing Claude Code CLI login.
### CLI setup ### CLI setup
@@ -44,7 +44,7 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
# Run setup-token on the gateway host (wizard can run it for you) # Run setup-token on the gateway host (wizard can run it for you)
clawdbot onboard --auth-choice setup-token clawdbot onboard --auth-choice setup-token
# Reuse Claude CLI OAuth credentials if already logged in # Reuse Claude Code CLI OAuth credentials if already logged in
clawdbot onboard --auth-choice claude-cli clawdbot onboard --auth-choice claude-cli
``` ```
@@ -59,4 +59,7 @@ clawdbot onboard --auth-choice claude-cli
## Notes ## Notes
- The wizard can run `claude setup-token` on the gateway host and store the token. - The wizard can run `claude setup-token` on the gateway host and store the token.
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
auto-migrated on load.
- Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth). - Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth).
+1 -1
View File
@@ -25,7 +25,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
## Provider docs ## Provider docs
- [OpenAI (API + Codex)](/providers/openai) - [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude CLI)](/providers/anthropic) - [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter) - [OpenRouter](/providers/openrouter)
- [Moonshot AI (Kimi)](/providers/moonshot) - [Moonshot AI (Kimi)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
+1 -1
View File
@@ -23,7 +23,7 @@ model as `provider/model`.
## Supported providers (starter set) ## Supported providers (starter set)
- [OpenAI (API + Codex)](/providers/openai) - [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude CLI)](/providers/anthropic) - [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter) - [OpenRouter](/providers/openrouter)
- [Moonshot AI (Kimi)](/providers/moonshot) - [Moonshot AI (Kimi)](/providers/moonshot)
- [Synthetic](/providers/synthetic) - [Synthetic](/providers/synthetic)
+7 -3
View File
@@ -239,6 +239,10 @@ It also warns if your configured model is unknown or missing auth.
The wizard can run `claude setup-token` on the gateway host (or you run it yourself), then stores the token as an auth profile for the **anthropic** provider. That profile is used for model calls the same way an API key or OAuth profile would be. If you already ran `claude setup-token`, pick **Anthropic token (paste setup-token)** and paste it. More detail: [OAuth](/concepts/oauth). The wizard can run `claude setup-token` on the gateway host (or you run it yourself), then stores the token as an auth profile for the **anthropic** provider. That profile is used for model calls the same way an API key or OAuth profile would be. If you already ran `claude setup-token`, pick **Anthropic token (paste setup-token)** and paste it. More detail: [OAuth](/concepts/oauth).
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
entries auto-migrate.
### Do you support Claude subscription auth (Claude Code OAuth)? ### Do you support Claude subscription auth (Claude Code OAuth)?
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth). Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
@@ -1017,11 +1021,11 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
### Fix checklist for `No credentials found for profile "anthropic:claude-cli"` ### Fix checklist for `No credentials found for profile "anthropic:claude-cli"`
This means the run is pinned to the **Claude CLI** profile, but the Gateway This means the run is pinned to the **Claude Code CLI** profile, but the Gateway
can’t find that profile in its auth store. can’t find that profile in its auth store.
- **Sync the Claude CLI token on the gateway host** - **Sync the Claude Code CLI token on the gateway host**
- Run `clawdbot models status` (it loads + syncs Claude CLI credentials). - Run `clawdbot models status` (it loads + syncs Claude Code CLI credentials).
- If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry. - If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry.
- **If you want to use an API key instead** - **If you want to use an API key instead**
- Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**. - Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**.
+1 -1
View File
@@ -74,7 +74,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
2) **Model/Auth** 2) **Model/Auth**
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use. - **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
- **Anthropic token (setup-token)**: run `claude setup-token` on the gateway host (the wizard can run it for you and reuse the token). - **Anthropic token (setup-token)**: run `claude setup-token` on the gateway host (the wizard can run it for you and reuse the token).
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. - **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default). - **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. - **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`. - **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
+3 -3
View File
@@ -157,7 +157,7 @@ pnpm clawdbot models list --json
## Live: Anthropic setup-token smoke ## Live: Anthropic setup-token smoke
- Test: `src/agents/anthropic.setup-token.live.test.ts` - Test: `src/agents/anthropic.setup-token.live.test.ts`
- Goal: verify Claude CLI setup-token (or a pasted setup-token profile) can complete an Anthropic prompt. - Goal: verify Claude Code CLI setup-token (or a pasted setup-token profile) can complete an Anthropic prompt.
- Enable: - Enable:
- `pnpm test:live` (or `CLAWDBOT_LIVE_TEST=1` if invoking Vitest directly) - `pnpm test:live` (or `CLAWDBOT_LIVE_TEST=1` if invoking Vitest directly)
- `CLAWDBOT_LIVE_SETUP_TOKEN=1` - `CLAWDBOT_LIVE_SETUP_TOKEN=1`
@@ -174,7 +174,7 @@ clawdbot models auth paste-token --provider anthropic --profile-id anthropic:set
CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-token-test pnpm test:live src/agents/anthropic.setup-token.live.test.ts CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-token-test pnpm test:live src/agents/anthropic.setup-token.live.test.ts
``` ```
## Live: CLI backend smoke (Claude CLI or other local CLIs) ## Live: CLI backend smoke (Claude Code CLI or other local CLIs)
- Test: `src/gateway/gateway-cli-backend.live.test.ts` - Test: `src/gateway/gateway-cli-backend.live.test.ts`
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config. - Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
@@ -195,7 +195,7 @@ CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-to
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection. - `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection.
- `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set. - `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set.
- `CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow. - `CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow.
- `CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=0` to keep Claude CLI MCP config enabled (default disables MCP config with a temporary empty file). - `CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=0` to keep Claude Code CLI MCP config enabled (default disables MCP config with a temporary empty file).
Example: Example:
+2
View File
@@ -34,6 +34,8 @@ Everything the model receives counts toward the context limit:
- Compaction summaries and pruning artifacts - Compaction summaries and pruning artifacts
- Provider wrappers or safety headers (not visible, but still counted) - Provider wrappers or safety headers (not visible, but still counted)
For a practical breakdown (per injected file, tools, skills, and system prompt size), use `/context list` or `/context detail`. See [Context](/concepts/context).
## How to see current token usage ## How to see current token usage
Use these in chat: Use these in chat:
+7 -1
View File
@@ -55,6 +55,7 @@ Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/status` (show current status; includes a short provider usage/quota line when available) - `/status` (show current status; includes a short provider usage/quota line when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`) - `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`) - `/whoami` (show your sender id; alias: `/id`)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
@@ -62,6 +63,9 @@ Text + native (when enabled):
- `/cost on|off` (toggle per-response usage line) - `/cost on|off` (toggle per-response usage line)
- `/stop` - `/stop`
- `/restart` - `/restart`
- `/dock-telegram` (switch replies to Telegram)
- `/dock-discord` (switch replies to Discord)
- `/dock-slack` (switch replies to Slack)
- `/activation mention|always` (groups only) - `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only) - `/send on|off|inherit` (owner-only)
- `/reset` or `/new` - `/reset` or `/new`
@@ -87,7 +91,9 @@ Notes:
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
- **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text. - **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
## Usage vs cost (what shows where) ## Usage vs cost (what shows where)
+42 -704
View File
@@ -1,23 +1,27 @@
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { resolveUserPath } from "./utils.js"; import { resolveUserPath } from "./utils.js";
import type { CallMode, VoiceCallConfig } from "./config.js"; import type { VoiceCallConfig } from "./config.js";
import type { VoiceCallProvider } from "./providers/base.js"; import type { VoiceCallProvider } from "./providers/base.js";
import { import {
type CallId, type CallId,
type CallRecord, type CallRecord,
CallRecordSchema,
type CallState,
type NormalizedEvent, type NormalizedEvent,
type OutboundCallOptions, type OutboundCallOptions,
TerminalStates,
type TranscriptEntry,
} from "./types.js"; } from "./types.js";
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js"; import type { CallManagerContext } from "./manager/context.js";
import { processEvent } from "./manager/events.js";
import { getCallByProviderCallId } from "./manager/lookup.js";
import {
continueCall,
endCall,
initiateCall,
speak,
speakInitialMessage,
} from "./manager/outbound.js";
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
/** /**
* Manages voice calls: state machine, persistence, and provider coordination. * Manages voice calls: state machine, persistence, and provider coordination.
@@ -51,6 +55,20 @@ export class CallManager {
this.storePath = resolveUserPath(rawPath); this.storePath = resolveUserPath(rawPath);
} }
private buildContext(): CallManagerContext {
return {
activeCalls: this.activeCalls,
providerCallIdMap: this.providerCallIdMap,
processedEventIds: this.processedEventIds,
provider: this.provider,
config: this.config,
storePath: this.storePath,
webhookUrl: this.webhookUrl,
transcriptWaiters: this.transcriptWaiters,
maxDurationTimers: this.maxDurationTimers,
};
}
/** /**
* Initialize the call manager with a provider. * Initialize the call manager with a provider.
*/ */
@@ -62,7 +80,10 @@ export class CallManager {
fs.mkdirSync(this.storePath, { recursive: true }); fs.mkdirSync(this.storePath, { recursive: true });
// Load any persisted active calls // Load any persisted active calls
this.loadActiveCalls(); const restored = loadActiveCallsFromStore(this.storePath);
this.activeCalls = restored.activeCalls;
this.providerCallIdMap = restored.providerCallIdMap;
this.processedEventIds = restored.processedEventIds;
} }
/** /**
@@ -83,102 +104,7 @@ export class CallManager {
sessionKey?: string, sessionKey?: string,
options?: OutboundCallOptions | string, options?: OutboundCallOptions | string,
): Promise<{ callId: CallId; success: boolean; error?: string }> { ): Promise<{ callId: CallId; success: boolean; error?: string }> {
// Support legacy string argument for initialMessage return await initiateCall(this.buildContext(), to, sessionKey, options);
const opts: OutboundCallOptions =
typeof options === "string" ? { message: options } : (options ?? {});
const initialMessage = opts.message;
const mode = opts.mode ?? this.config.outbound.defaultMode;
if (!this.provider) {
return { callId: "", success: false, error: "Provider not initialized" };
}
if (!this.webhookUrl) {
return {
callId: "",
success: false,
error: "Webhook URL not configured",
};
}
// Check concurrent call limit
const activeCalls = this.getActiveCalls();
if (activeCalls.length >= this.config.maxConcurrentCalls) {
return {
callId: "",
success: false,
error: `Maximum concurrent calls (${this.config.maxConcurrentCalls}) reached`,
};
}
const callId = crypto.randomUUID();
const from =
this.config.fromNumber ||
(this.provider?.name === "mock" ? "+15550000000" : undefined);
if (!from) {
return { callId: "", success: false, error: "fromNumber not configured" };
}
// Create call record with mode in metadata
const callRecord: CallRecord = {
callId,
provider: this.provider.name,
direction: "outbound",
state: "initiated",
from,
to,
sessionKey,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
...(initialMessage && { initialMessage }),
mode,
},
};
this.activeCalls.set(callId, callRecord);
this.persistCallRecord(callRecord);
try {
// For notify mode with a message, use inline TwiML with <Say>
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
console.log(
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
);
}
const result = await this.provider.initiateCall({
callId,
from,
to,
webhookUrl: this.webhookUrl,
inlineTwiml,
});
callRecord.providerCallId = result.providerCallId;
this.providerCallIdMap.set(result.providerCallId, callId); // Map providerCallId to internal callId
this.persistCallRecord(callRecord);
return { callId, success: true };
} catch (err) {
callRecord.state = "failed";
callRecord.endedAt = Date.now();
callRecord.endReason = "failed";
this.persistCallRecord(callRecord);
this.activeCalls.delete(callId);
if (callRecord.providerCallId) {
this.providerCallIdMap.delete(callRecord.providerCallId);
}
return {
callId,
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
} }
/** /**
@@ -188,42 +114,7 @@ export class CallManager {
callId: CallId, callId: CallId,
text: string, text: string,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const call = this.activeCalls.get(callId); return await speak(this.buildContext(), callId, text);
if (!call) {
return { success: false, error: "Call not found" };
}
if (!this.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
return { success: false, error: "Call has ended" };
}
try {
// Update state
call.state = "speaking";
this.persistCallRecord(call);
// Add to transcript
this.addTranscriptEntry(call, "bot", text);
// Play TTS
await this.provider.playTts({
callId,
providerCallId: call.providerCallId,
text,
voice: this.config.tts.voice,
});
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
} }
/** /**
@@ -232,135 +123,7 @@ export class CallManager {
* In notify mode, auto-hangup after the message is delivered. * In notify mode, auto-hangup after the message is delivered.
*/ */
async speakInitialMessage(providerCallId: string): Promise<void> { async speakInitialMessage(providerCallId: string): Promise<void> {
const call = this.getCallByProviderCallId(providerCallId); await speakInitialMessage(this.buildContext(), providerCallId);
if (!call) {
console.warn(
`[voice-call] speakInitialMessage: no call found for ${providerCallId}`,
);
return;
}
const initialMessage = call.metadata?.initialMessage as string | undefined;
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
if (!initialMessage) {
console.log(
`[voice-call] speakInitialMessage: no initial message for ${call.callId}`,
);
return;
}
// Clear the initial message so we don't speak it again
if (call.metadata) {
delete call.metadata.initialMessage;
this.persistCallRecord(call);
}
console.log(
`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`,
);
const result = await this.speak(call.callId, initialMessage);
if (!result.success) {
console.warn(
`[voice-call] Failed to speak initial message: ${result.error}`,
);
return;
}
// In notify mode, auto-hangup after delay
if (mode === "notify") {
const delaySec = this.config.outbound.notifyHangupDelaySec;
console.log(
`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`,
);
setTimeout(async () => {
const currentCall = this.getCall(call.callId);
if (currentCall && !TerminalStates.has(currentCall.state)) {
console.log(
`[voice-call] Notify mode: hanging up call ${call.callId}`,
);
await this.endCall(call.callId);
}
}, delaySec * 1000);
}
}
/**
* Start max duration timer for a call.
* Auto-hangup when maxDurationSeconds is reached.
*/
private startMaxDurationTimer(callId: CallId): void {
// Clear any existing timer
this.clearMaxDurationTimer(callId);
const maxDurationMs = this.config.maxDurationSeconds * 1000;
console.log(
`[voice-call] Starting max duration timer (${this.config.maxDurationSeconds}s) for call ${callId}`,
);
const timer = setTimeout(async () => {
this.maxDurationTimers.delete(callId);
const call = this.getCall(callId);
if (call && !TerminalStates.has(call.state)) {
console.log(
`[voice-call] Max duration reached (${this.config.maxDurationSeconds}s), ending call ${callId}`,
);
call.endReason = "timeout";
this.persistCallRecord(call);
await this.endCall(callId);
}
}, maxDurationMs);
this.maxDurationTimers.set(callId, timer);
}
/**
* Clear max duration timer for a call.
*/
private clearMaxDurationTimer(callId: CallId): void {
const timer = this.maxDurationTimers.get(callId);
if (timer) {
clearTimeout(timer);
this.maxDurationTimers.delete(callId);
}
}
private clearTranscriptWaiter(callId: CallId): void {
const waiter = this.transcriptWaiters.get(callId);
if (!waiter) return;
clearTimeout(waiter.timeout);
this.transcriptWaiters.delete(callId);
}
private rejectTranscriptWaiter(callId: CallId, reason: string): void {
const waiter = this.transcriptWaiters.get(callId);
if (!waiter) return;
this.clearTranscriptWaiter(callId);
waiter.reject(new Error(reason));
}
private resolveTranscriptWaiter(callId: CallId, transcript: string): void {
const waiter = this.transcriptWaiters.get(callId);
if (!waiter) return;
this.clearTranscriptWaiter(callId);
waiter.resolve(transcript);
}
private waitForFinalTranscript(callId: CallId): Promise<string> {
// Only allow one in-flight waiter per call.
this.rejectTranscriptWaiter(callId, "Transcript waiter replaced");
const timeoutMs = this.config.transcriptTimeoutMs;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.transcriptWaiters.delete(callId);
reject(
new Error(`Timed out waiting for transcript after ${timeoutMs}ms`),
);
}, timeoutMs);
this.transcriptWaiters.set(callId, { resolve, reject, timeout });
});
} }
/** /**
@@ -370,287 +133,21 @@ export class CallManager {
callId: CallId, callId: CallId,
prompt: string, prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> { ): Promise<{ success: boolean; transcript?: string; error?: string }> {
const call = this.activeCalls.get(callId); return await continueCall(this.buildContext(), callId, prompt);
if (!call) {
return { success: false, error: "Call not found" };
}
if (!this.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
return { success: false, error: "Call has ended" };
}
try {
await this.speak(callId, prompt);
call.state = "listening";
this.persistCallRecord(call);
await this.provider.startListening({
callId,
providerCallId: call.providerCallId,
});
const transcript = await this.waitForFinalTranscript(callId);
// Best-effort: stop listening after final transcript.
await this.provider.stopListening({
callId,
providerCallId: call.providerCallId,
});
return { success: true, transcript };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
} finally {
this.clearTranscriptWaiter(callId);
}
} }
/** /**
* End an active call. * End an active call.
*/ */
async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> { async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> {
const call = this.activeCalls.get(callId); return await endCall(this.buildContext(), callId);
if (!call) {
return { success: false, error: "Call not found" };
}
if (!this.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
return { success: true }; // Already ended
}
try {
await this.provider.hangupCall({
callId,
providerCallId: call.providerCallId,
reason: "hangup-bot",
});
call.state = "hangup-bot";
call.endedAt = Date.now();
call.endReason = "hangup-bot";
this.persistCallRecord(call);
this.clearMaxDurationTimer(callId);
this.rejectTranscriptWaiter(callId, "Call ended: hangup-bot");
this.activeCalls.delete(callId);
if (call.providerCallId) {
this.providerCallIdMap.delete(call.providerCallId);
}
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Check if an inbound call should be accepted based on policy.
*/
private shouldAcceptInbound(from: string | undefined): boolean {
const { inboundPolicy: policy, allowFrom } = this.config;
switch (policy) {
case "disabled":
console.log("[voice-call] Inbound call rejected: policy is disabled");
return false;
case "open":
console.log("[voice-call] Inbound call accepted: policy is open");
return true;
case "allowlist":
case "pairing": {
const normalized = from?.replace(/\D/g, "") || "";
const allowed = (allowFrom || []).some((num) => {
const normalizedAllow = num.replace(/\D/g, "");
return (
normalized.endsWith(normalizedAllow) ||
normalizedAllow.endsWith(normalized)
);
});
const status = allowed ? "accepted" : "rejected";
console.log(
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
);
return allowed;
}
default:
return false;
}
}
/**
* Create a call record for an inbound call.
*/
private createInboundCall(
providerCallId: string,
from: string,
to: string,
): CallRecord {
const callId = crypto.randomUUID();
const callRecord: CallRecord = {
callId,
providerCallId,
provider: this.provider?.name || "twilio",
direction: "inbound",
state: "ringing",
from,
to,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
initialMessage:
this.config.inboundGreeting || "Hello! How can I help you today?",
},
};
this.activeCalls.set(callId, callRecord);
this.providerCallIdMap.set(providerCallId, callId); // Map providerCallId to internal callId
this.persistCallRecord(callRecord);
console.log(
`[voice-call] Created inbound call record: ${callId} from ${from}`,
);
return callRecord;
}
/**
* Look up a call by either internal callId or providerCallId.
*/
private findCall(callIdOrProviderCallId: string): CallRecord | undefined {
// Try direct lookup by internal callId
const directCall = this.activeCalls.get(callIdOrProviderCallId);
if (directCall) return directCall;
// Try lookup by providerCallId
return this.getCallByProviderCallId(callIdOrProviderCallId);
} }
/** /**
* Process a webhook event. * Process a webhook event.
*/ */
processEvent(event: NormalizedEvent): void { processEvent(event: NormalizedEvent): void {
// Idempotency check processEvent(this.buildContext(), event);
if (this.processedEventIds.has(event.id)) {
return;
}
this.processedEventIds.add(event.id);
let call = this.findCall(event.callId);
// Handle inbound calls - create record if it doesn't exist
if (!call && event.direction === "inbound" && event.providerCallId) {
// Check if we should accept this inbound call
if (!this.shouldAcceptInbound(event.from)) {
// TODO: Could hang up the call here
return;
}
// Create a new call record for this inbound call
call = this.createInboundCall(
event.providerCallId,
event.from || "unknown",
event.to || this.config.fromNumber || "unknown",
);
// Update the event's callId to use our internal ID
event.callId = call.callId;
}
if (!call) {
// Still no call record - ignore event
return;
}
// Update provider call ID if we got it
if (event.providerCallId && !call.providerCallId) {
call.providerCallId = event.providerCallId;
}
// Track processed event
call.processedEventIds.push(event.id);
// Process event based on type
switch (event.type) {
case "call.initiated":
this.transitionState(call, "initiated");
break;
case "call.ringing":
this.transitionState(call, "ringing");
break;
case "call.answered":
call.answeredAt = event.timestamp;
this.transitionState(call, "answered");
// Start max duration timer when call is answered
this.startMaxDurationTimer(call.callId);
break;
case "call.active":
this.transitionState(call, "active");
break;
case "call.speaking":
this.transitionState(call, "speaking");
break;
case "call.speech":
if (event.isFinal) {
this.addTranscriptEntry(call, "user", event.transcript);
this.resolveTranscriptWaiter(call.callId, event.transcript);
}
this.transitionState(call, "listening");
break;
case "call.ended":
call.endedAt = event.timestamp;
call.endReason = event.reason;
this.transitionState(call, event.reason as CallState);
this.clearMaxDurationTimer(call.callId);
this.rejectTranscriptWaiter(call.callId, `Call ended: ${event.reason}`);
this.activeCalls.delete(call.callId);
if (call.providerCallId) {
this.providerCallIdMap.delete(call.providerCallId);
}
break;
case "call.error":
if (!event.retryable) {
call.endedAt = event.timestamp;
call.endReason = "error";
this.transitionState(call, "error");
this.clearMaxDurationTimer(call.callId);
this.rejectTranscriptWaiter(
call.callId,
`Call error: ${event.error}`,
);
this.activeCalls.delete(call.callId);
if (call.providerCallId) {
this.providerCallIdMap.delete(call.providerCallId);
}
}
break;
}
this.persistCallRecord(call);
} }
/** /**
@@ -664,20 +161,11 @@ export class CallManager {
* Get an active call by provider call ID (e.g., Twilio CallSid). * Get an active call by provider call ID (e.g., Twilio CallSid).
*/ */
getCallByProviderCallId(providerCallId: string): CallRecord | undefined { getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
// Fast path: use the providerCallIdMap for O(1) lookup return getCallByProviderCallId({
const callId = this.providerCallIdMap.get(providerCallId); activeCalls: this.activeCalls,
if (callId) { providerCallIdMap: this.providerCallIdMap,
return this.activeCalls.get(callId); providerCallId,
} });
// Fallback: linear search for cases where map wasn't populated
// (e.g., providerCallId set directly on call record)
for (const call of this.activeCalls.values()) {
if (call.providerCallId === providerCallId) {
return call;
}
}
return undefined;
} }
/** /**
@@ -691,156 +179,6 @@ export class CallManager {
* Get call history (from persisted logs). * Get call history (from persisted logs).
*/ */
async getCallHistory(limit = 50): Promise<CallRecord[]> { async getCallHistory(limit = 50): Promise<CallRecord[]> {
const logPath = path.join(this.storePath, "calls.jsonl"); return await getCallHistoryFromStore(this.storePath, limit);
try {
await fsp.access(logPath);
} catch {
return [];
}
const content = await fsp.readFile(logPath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
const calls: CallRecord[] = [];
// Parse last N lines
for (const line of lines.slice(-limit)) {
try {
const parsed = CallRecordSchema.parse(JSON.parse(line));
calls.push(parsed);
} catch {
// Skip invalid lines
}
}
return calls;
}
// States that can cycle during multi-turn conversations
private static readonly ConversationStates = new Set<CallState>([
"speaking",
"listening",
]);
// Non-terminal state order for monotonic transitions
private static readonly StateOrder: readonly CallState[] = [
"initiated",
"ringing",
"answered",
"active",
"speaking",
"listening",
];
/**
* Transition call state with monotonic enforcement.
*/
private transitionState(call: CallRecord, newState: CallState): void {
// No-op for same state or already terminal
if (call.state === newState || TerminalStates.has(call.state)) return;
// Terminal states can always be reached from non-terminal
if (TerminalStates.has(newState)) {
call.state = newState;
return;
}
// Allow cycling between speaking and listening (multi-turn conversations)
if (
CallManager.ConversationStates.has(call.state) &&
CallManager.ConversationStates.has(newState)
) {
call.state = newState;
return;
}
// Only allow forward transitions in state order
const currentIndex = CallManager.StateOrder.indexOf(call.state);
const newIndex = CallManager.StateOrder.indexOf(newState);
if (newIndex > currentIndex) {
call.state = newState;
}
}
/**
* Add an entry to the call transcript.
*/
private addTranscriptEntry(
call: CallRecord,
speaker: "bot" | "user",
text: string,
): void {
const entry: TranscriptEntry = {
timestamp: Date.now(),
speaker,
text,
isFinal: true,
};
call.transcript.push(entry);
}
/**
* Persist a call record to disk (fire-and-forget async).
*/
private persistCallRecord(call: CallRecord): void {
const logPath = path.join(this.storePath, "calls.jsonl");
const line = `${JSON.stringify(call)}\n`;
// Fire-and-forget async write to avoid blocking event loop
fsp.appendFile(logPath, line).catch((err) => {
console.error("[voice-call] Failed to persist call record:", err);
});
}
/**
* Load active calls from persistence (for crash recovery).
* Uses streaming to handle large log files efficiently.
*/
private loadActiveCalls(): void {
const logPath = path.join(this.storePath, "calls.jsonl");
if (!fs.existsSync(logPath)) return;
// Read file synchronously and parse lines
const content = fs.readFileSync(logPath, "utf-8");
const lines = content.split("\n");
// Build map of latest state per call
const callMap = new Map<CallId, CallRecord>();
for (const line of lines) {
if (!line.trim()) continue;
try {
const call = CallRecordSchema.parse(JSON.parse(line));
callMap.set(call.callId, call);
} catch {
// Skip invalid lines
}
}
// Only keep non-terminal calls
for (const [callId, call] of callMap) {
if (!TerminalStates.has(call.state)) {
this.activeCalls.set(callId, call);
// Populate providerCallId mapping for lookups
if (call.providerCallId) {
this.providerCallIdMap.set(call.providerCallId, callId);
}
// Populate processed event IDs
for (const eventId of call.processedEventIds) {
this.processedEventIds.add(eventId);
}
}
}
}
/**
* Generate TwiML for notify mode (speak message and hang up).
*/
private generateNotifyTwiml(message: string, voice: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${voice}">${escapeXml(message)}</Say>
<Hangup/>
</Response>`;
} }
} }
@@ -0,0 +1,22 @@
import type { CallId, CallRecord } from "../types.js";
import type { VoiceCallConfig } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
export type TranscriptWaiter = {
resolve: (text: string) => void;
reject: (err: Error) => void;
timeout: NodeJS.Timeout;
};
export type CallManagerContext = {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
processedEventIds: Set<string>;
provider: VoiceCallProvider | null;
config: VoiceCallConfig;
storePath: string;
webhookUrl: string | null;
transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
};
+178
View File
@@ -0,0 +1,178 @@
import crypto from "node:crypto";
import type { CallId, CallRecord, CallState, NormalizedEvent } from "../types.js";
import { TerminalStates } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { findCall } from "./lookup.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import {
clearMaxDurationTimer,
rejectTranscriptWaiter,
resolveTranscriptWaiter,
startMaxDurationTimer,
} from "./timers.js";
import { endCall } from "./outbound.js";
function shouldAcceptInbound(config: CallManagerContext["config"], from: string | undefined): boolean {
const { inboundPolicy: policy, allowFrom } = config;
switch (policy) {
case "disabled":
console.log("[voice-call] Inbound call rejected: policy is disabled");
return false;
case "open":
console.log("[voice-call] Inbound call accepted: policy is open");
return true;
case "allowlist":
case "pairing": {
const normalized = from?.replace(/\D/g, "") || "";
const allowed = (allowFrom || []).some((num) => {
const normalizedAllow = num.replace(/\D/g, "");
return normalized.endsWith(normalizedAllow) || normalizedAllow.endsWith(normalized);
});
const status = allowed ? "accepted" : "rejected";
console.log(
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
);
return allowed;
}
default:
return false;
}
}
function createInboundCall(params: {
ctx: CallManagerContext;
providerCallId: string;
from: string;
to: string;
}): CallRecord {
const callId = crypto.randomUUID();
const callRecord: CallRecord = {
callId,
providerCallId: params.providerCallId,
provider: params.ctx.provider?.name || "twilio",
direction: "inbound",
state: "ringing",
from: params.from,
to: params.to,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
initialMessage: params.ctx.config.inboundGreeting || "Hello! How can I help you today?",
},
};
params.ctx.activeCalls.set(callId, callRecord);
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
persistCallRecord(params.ctx.storePath, callRecord);
console.log(`[voice-call] Created inbound call record: ${callId} from ${params.from}`);
return callRecord;
}
export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): void {
if (ctx.processedEventIds.has(event.id)) return;
ctx.processedEventIds.add(event.id);
let call = findCall({
activeCalls: ctx.activeCalls,
providerCallIdMap: ctx.providerCallIdMap,
callIdOrProviderCallId: event.callId,
});
if (!call && event.direction === "inbound" && event.providerCallId) {
if (!shouldAcceptInbound(ctx.config, event.from)) {
// TODO: Could hang up the call here.
return;
}
call = createInboundCall({
ctx,
providerCallId: event.providerCallId,
from: event.from || "unknown",
to: event.to || ctx.config.fromNumber || "unknown",
});
// Normalize event to internal ID for downstream consumers.
event.callId = call.callId;
}
if (!call) return;
if (event.providerCallId && !call.providerCallId) {
call.providerCallId = event.providerCallId;
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
}
call.processedEventIds.push(event.id);
switch (event.type) {
case "call.initiated":
transitionState(call, "initiated");
break;
case "call.ringing":
transitionState(call, "ringing");
break;
case "call.answered":
call.answeredAt = event.timestamp;
transitionState(call, "answered");
startMaxDurationTimer({
ctx,
callId: call.callId,
onTimeout: async (callId) => {
await endCall(ctx, callId);
},
});
break;
case "call.active":
transitionState(call, "active");
break;
case "call.speaking":
transitionState(call, "speaking");
break;
case "call.speech":
if (event.isFinal) {
addTranscriptEntry(call, "user", event.transcript);
resolveTranscriptWaiter(ctx, call.callId, event.transcript);
}
transitionState(call, "listening");
break;
case "call.ended":
call.endedAt = event.timestamp;
call.endReason = event.reason;
transitionState(call, event.reason as CallState);
clearMaxDurationTimer(ctx, call.callId);
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
ctx.activeCalls.delete(call.callId);
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
break;
case "call.error":
if (!event.retryable) {
call.endedAt = event.timestamp;
call.endReason = "error";
transitionState(call, "error");
clearMaxDurationTimer(ctx, call.callId);
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
ctx.activeCalls.delete(call.callId);
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
}
break;
}
persistCallRecord(ctx.storePath, call);
}
@@ -0,0 +1,34 @@
import type { CallId, CallRecord } from "../types.js";
export function getCallByProviderCallId(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
providerCallId: string;
}): CallRecord | undefined {
const callId = params.providerCallIdMap.get(params.providerCallId);
if (callId) {
return params.activeCalls.get(callId);
}
for (const call of params.activeCalls.values()) {
if (call.providerCallId === params.providerCallId) {
return call;
}
}
return undefined;
}
export function findCall(params: {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
callIdOrProviderCallId: string;
}): CallRecord | undefined {
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
if (directCall) return directCall;
return getCallByProviderCallId({
activeCalls: params.activeCalls,
providerCallIdMap: params.providerCallIdMap,
providerCallId: params.callIdOrProviderCallId,
});
}
@@ -0,0 +1,247 @@
import crypto from "node:crypto";
import { TerminalStates, type CallId, type CallRecord, type OutboundCallOptions } from "../types.js";
import type { CallMode } from "../config.js";
import { mapVoiceToPolly } from "../voice-mapping.js";
import type { CallManagerContext } from "./context.js";
import { getCallByProviderCallId } from "./lookup.js";
import { generateNotifyTwiml } from "./twiml.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { clearMaxDurationTimer, clearTranscriptWaiter, rejectTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
export async function initiateCall(
ctx: CallManagerContext,
to: string,
sessionKey?: string,
options?: OutboundCallOptions | string,
): Promise<{ callId: CallId; success: boolean; error?: string }> {
const opts: OutboundCallOptions =
typeof options === "string" ? { message: options } : (options ?? {});
const initialMessage = opts.message;
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
if (!ctx.provider) {
return { callId: "", success: false, error: "Provider not initialized" };
}
if (!ctx.webhookUrl) {
return { callId: "", success: false, error: "Webhook URL not configured" };
}
if (ctx.activeCalls.size >= ctx.config.maxConcurrentCalls) {
return {
callId: "",
success: false,
error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`,
};
}
const callId = crypto.randomUUID();
const from =
ctx.config.fromNumber ||
(ctx.provider?.name === "mock" ? "+15550000000" : undefined);
if (!from) {
return { callId: "", success: false, error: "fromNumber not configured" };
}
const callRecord: CallRecord = {
callId,
provider: ctx.provider.name,
direction: "outbound",
state: "initiated",
from,
to,
sessionKey,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],
metadata: {
...(initialMessage && { initialMessage }),
mode,
},
};
ctx.activeCalls.set(callId, callRecord);
persistCallRecord(ctx.storePath, callRecord);
try {
// For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
}
const result = await ctx.provider.initiateCall({
callId,
from,
to,
webhookUrl: ctx.webhookUrl,
inlineTwiml,
});
callRecord.providerCallId = result.providerCallId;
ctx.providerCallIdMap.set(result.providerCallId, callId);
persistCallRecord(ctx.storePath, callRecord);
return { callId, success: true };
} catch (err) {
callRecord.state = "failed";
callRecord.endedAt = Date.now();
callRecord.endReason = "failed";
persistCallRecord(ctx.storePath, callRecord);
ctx.activeCalls.delete(callId);
if (callRecord.providerCallId) {
ctx.providerCallIdMap.delete(callRecord.providerCallId);
}
return {
callId,
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function speak(
ctx: CallManagerContext,
callId: CallId,
text: string,
): Promise<{ success: boolean; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) return { success: false, error: "Call not found" };
if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" };
try {
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
addTranscriptEntry(call, "bot", text);
await ctx.provider.playTts({
callId,
providerCallId: call.providerCallId,
text,
voice: ctx.config.tts.voice,
});
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function speakInitialMessage(
ctx: CallManagerContext,
providerCallId: string,
): Promise<void> {
const call = getCallByProviderCallId({
activeCalls: ctx.activeCalls,
providerCallIdMap: ctx.providerCallIdMap,
providerCallId,
});
if (!call) {
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
return;
}
const initialMessage = call.metadata?.initialMessage as string | undefined;
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
if (!initialMessage) {
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
return;
}
// Clear so we don't speak it again if the provider reconnects.
if (call.metadata) {
delete call.metadata.initialMessage;
persistCallRecord(ctx.storePath, call);
}
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
const result = await speak(ctx, call.callId, initialMessage);
if (!result.success) {
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
return;
}
if (mode === "notify") {
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
setTimeout(async () => {
const currentCall = ctx.activeCalls.get(call.callId);
if (currentCall && !TerminalStates.has(currentCall.state)) {
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
await endCall(ctx, call.callId);
}
}, delaySec * 1000);
}
}
export async function continueCall(
ctx: CallManagerContext,
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) return { success: false, error: "Call not found" };
if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" };
try {
await speak(ctx, callId, prompt);
transitionState(call, "listening");
persistCallRecord(ctx.storePath, call);
await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
const transcript = await waitForFinalTranscript(ctx, callId);
// Best-effort: stop listening after final transcript.
await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
return { success: true, transcript };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
} finally {
clearTranscriptWaiter(ctx, callId);
}
}
export async function endCall(
ctx: CallManagerContext,
callId: CallId,
): Promise<{ success: boolean; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) return { success: false, error: "Call not found" };
if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
if (TerminalStates.has(call.state)) return { success: true };
try {
await ctx.provider.hangupCall({
callId,
providerCallId: call.providerCallId,
reason: "hangup-bot",
});
call.state = "hangup-bot";
call.endedAt = Date.now();
call.endReason = "hangup-bot";
persistCallRecord(ctx.storePath, call);
clearMaxDurationTimer(ctx, callId);
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
ctx.activeCalls.delete(callId);
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
@@ -0,0 +1,51 @@
import { TerminalStates, type CallRecord, type CallState, type TranscriptEntry } from "../types.js";
const ConversationStates = new Set<CallState>(["speaking", "listening"]);
const StateOrder: readonly CallState[] = [
"initiated",
"ringing",
"answered",
"active",
"speaking",
"listening",
];
export function transitionState(call: CallRecord, newState: CallState): void {
// No-op for same state or already terminal.
if (call.state === newState || TerminalStates.has(call.state)) return;
// Terminal states can always be reached from non-terminal.
if (TerminalStates.has(newState)) {
call.state = newState;
return;
}
// Allow cycling between speaking and listening (multi-turn conversations).
if (ConversationStates.has(call.state) && ConversationStates.has(newState)) {
call.state = newState;
return;
}
// Only allow forward transitions in state order.
const currentIndex = StateOrder.indexOf(call.state);
const newIndex = StateOrder.indexOf(newState);
if (newIndex > currentIndex) {
call.state = newState;
}
}
export function addTranscriptEntry(
call: CallRecord,
speaker: "bot" | "user",
text: string,
): void {
const entry: TranscriptEntry = {
timestamp: Date.now(),
speaker,
text,
isFinal: true,
};
call.transcript.push(entry);
}
@@ -0,0 +1,89 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
export function persistCallRecord(storePath: string, call: CallRecord): void {
const logPath = path.join(storePath, "calls.jsonl");
const line = `${JSON.stringify(call)}\n`;
// Fire-and-forget async write to avoid blocking event loop.
fsp.appendFile(logPath, line).catch((err) => {
console.error("[voice-call] Failed to persist call record:", err);
});
}
export function loadActiveCallsFromStore(storePath: string): {
activeCalls: Map<CallId, CallRecord>;
providerCallIdMap: Map<string, CallId>;
processedEventIds: Set<string>;
} {
const logPath = path.join(storePath, "calls.jsonl");
if (!fs.existsSync(logPath)) {
return {
activeCalls: new Map(),
providerCallIdMap: new Map(),
processedEventIds: new Set(),
};
}
const content = fs.readFileSync(logPath, "utf-8");
const lines = content.split("\n");
const callMap = new Map<CallId, CallRecord>();
for (const line of lines) {
if (!line.trim()) continue;
try {
const call = CallRecordSchema.parse(JSON.parse(line));
callMap.set(call.callId, call);
} catch {
// Skip invalid lines.
}
}
const activeCalls = new Map<CallId, CallRecord>();
const providerCallIdMap = new Map<string, CallId>();
const processedEventIds = new Set<string>();
for (const [callId, call] of callMap) {
if (TerminalStates.has(call.state)) continue;
activeCalls.set(callId, call);
if (call.providerCallId) {
providerCallIdMap.set(call.providerCallId, callId);
}
for (const eventId of call.processedEventIds) {
processedEventIds.add(eventId);
}
}
return { activeCalls, providerCallIdMap, processedEventIds };
}
export async function getCallHistoryFromStore(
storePath: string,
limit = 50,
): Promise<CallRecord[]> {
const logPath = path.join(storePath, "calls.jsonl");
try {
await fsp.access(logPath);
} catch {
return [];
}
const content = await fsp.readFile(logPath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
const calls: CallRecord[] = [];
for (const line of lines.slice(-limit)) {
try {
const parsed = CallRecordSchema.parse(JSON.parse(line));
calls.push(parsed);
} catch {
// Skip invalid lines.
}
}
return calls;
}
@@ -0,0 +1,87 @@
import { TerminalStates, type CallId } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { persistCallRecord } from "./store.js";
export function clearMaxDurationTimer(ctx: CallManagerContext, callId: CallId): void {
const timer = ctx.maxDurationTimers.get(callId);
if (timer) {
clearTimeout(timer);
ctx.maxDurationTimers.delete(callId);
}
}
export function startMaxDurationTimer(params: {
ctx: CallManagerContext;
callId: CallId;
onTimeout: (callId: CallId) => Promise<void>;
}): void {
clearMaxDurationTimer(params.ctx, params.callId);
const maxDurationMs = params.ctx.config.maxDurationSeconds * 1000;
console.log(
`[voice-call] Starting max duration timer (${params.ctx.config.maxDurationSeconds}s) for call ${params.callId}`,
);
const timer = setTimeout(async () => {
params.ctx.maxDurationTimers.delete(params.callId);
const call = params.ctx.activeCalls.get(params.callId);
if (call && !TerminalStates.has(call.state)) {
console.log(
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
await params.onTimeout(params.callId);
}
}, maxDurationMs);
params.ctx.maxDurationTimers.set(params.callId, timer);
}
export function clearTranscriptWaiter(ctx: CallManagerContext, callId: CallId): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) return;
clearTimeout(waiter.timeout);
ctx.transcriptWaiters.delete(callId);
}
export function rejectTranscriptWaiter(
ctx: CallManagerContext,
callId: CallId,
reason: string,
): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) return;
clearTranscriptWaiter(ctx, callId);
waiter.reject(new Error(reason));
}
export function resolveTranscriptWaiter(
ctx: CallManagerContext,
callId: CallId,
transcript: string,
): void {
const waiter = ctx.transcriptWaiters.get(callId);
if (!waiter) return;
clearTranscriptWaiter(ctx, callId);
waiter.resolve(transcript);
}
export function waitForFinalTranscript(
ctx: CallManagerContext,
callId: CallId,
): Promise<string> {
// Only allow one in-flight waiter per call.
rejectTranscriptWaiter(ctx, callId, "Transcript waiter replaced");
const timeoutMs = ctx.config.transcriptTimeoutMs;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ctx.transcriptWaiters.delete(callId);
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
}, timeoutMs);
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
});
}
@@ -0,0 +1,10 @@
import { escapeXml } from "../voice-mapping.js";
export function generateNotifyTwiml(message: string, voice: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${voice}">${escapeXml(message)}</Say>
<Hangup/>
</Response>`;
}
+14 -54
View File
@@ -15,10 +15,11 @@ import type {
WebhookVerificationResult, WebhookVerificationResult,
} from "../types.js"; } from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import { verifyTwilioWebhook } from "../webhook-security.js";
import type { VoiceCallProvider } from "./base.js"; import type { VoiceCallProvider } from "./base.js";
import type { OpenAITTSProvider } from "./tts-openai.js"; import type { OpenAITTSProvider } from "./tts-openai.js";
import { chunkAudio } from "./tts-openai.js"; import { chunkAudio } from "./tts-openai.js";
import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
/** /**
* Twilio Voice API provider implementation. * Twilio Voice API provider implementation.
@@ -79,45 +80,26 @@ export class TwilioProvider implements VoiceCallProvider {
} }
} }
/**
* Set the current public webhook URL (called when tunnel starts).
*/
setPublicUrl(url: string): void { setPublicUrl(url: string): void {
this.currentPublicUrl = url; this.currentPublicUrl = url;
} }
/**
* Get the current public webhook URL.
*/
getPublicUrl(): string | null { getPublicUrl(): string | null {
return this.currentPublicUrl; return this.currentPublicUrl;
} }
/**
* Set the OpenAI TTS provider for streaming TTS.
* When set, playTts will use OpenAI audio via media streams.
*/
setTTSProvider(provider: OpenAITTSProvider): void { setTTSProvider(provider: OpenAITTSProvider): void {
this.ttsProvider = provider; this.ttsProvider = provider;
} }
/**
* Set the media stream handler for sending audio.
*/
setMediaStreamHandler(handler: MediaStreamHandler): void { setMediaStreamHandler(handler: MediaStreamHandler): void {
this.mediaStreamHandler = handler; this.mediaStreamHandler = handler;
} }
/**
* Register a call's stream SID for audio routing.
*/
registerCallStream(callSid: string, streamSid: string): void { registerCallStream(callSid: string, streamSid: string): void {
this.callStreamMap.set(callSid, streamSid); this.callStreamMap.set(callSid, streamSid);
} }
/**
* Unregister a call's stream SID.
*/
unregisterCallStream(callSid: string): void { unregisterCallStream(callSid: string): void {
this.callStreamMap.delete(callSid); this.callStreamMap.delete(callSid);
} }
@@ -130,25 +112,14 @@ export class TwilioProvider implements VoiceCallProvider {
params: Record<string, string>, params: Record<string, string>,
options?: { allowNotFound?: boolean }, options?: { allowNotFound?: boolean },
): Promise<T> { ): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, { return await twilioApiRequest<T>({
method: "POST", baseUrl: this.baseUrl,
headers: { accountSid: this.accountSid,
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`, authToken: this.authToken,
"Content-Type": "application/x-www-form-urlencoded", endpoint,
}, body: params,
body: new URLSearchParams(params), allowNotFound: options?.allowNotFound,
}); });
if (!response.ok) {
if (options?.allowNotFound && response.status === 404) {
return undefined as T;
}
const errorText = await response.text();
throw new Error(`Twilio API error: ${response.status} ${errorText}`);
}
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
} }
/** /**
@@ -160,23 +131,12 @@ export class TwilioProvider implements VoiceCallProvider {
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
*/ */
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
const result = verifyTwilioWebhook(ctx, this.authToken, { return verifyTwilioProviderWebhook({
publicUrl: this.currentPublicUrl || undefined, ctx,
allowNgrokFreeTier: this.options.allowNgrokFreeTier ?? true, authToken: this.authToken,
skipVerification: this.options.skipVerification, currentPublicUrl: this.currentPublicUrl,
options: this.options,
}); });
if (!result.ok) {
console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
if (result.verificationUrl) {
console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
}
}
return {
ok: result.ok,
reason: result.reason,
};
} }
/** /**
@@ -0,0 +1,29 @@
export async function twilioApiRequest<T = unknown>(params: {
baseUrl: string;
accountSid: string;
authToken: string;
endpoint: string;
body: Record<string, string>;
allowNotFound?: boolean;
}): Promise<T> {
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params.body),
});
if (!response.ok) {
if (params.allowNotFound && response.status === 404) {
return undefined as T;
}
const errorText = await response.text();
throw new Error(`Twilio API error: ${response.status} ${errorText}`);
}
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
}
@@ -0,0 +1,30 @@
import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
import { verifyTwilioWebhook } from "../../webhook-security.js";
import type { TwilioProviderOptions } from "../twilio.js";
export function verifyTwilioProviderWebhook(params: {
ctx: WebhookContext;
authToken: string;
currentPublicUrl?: string | null;
options: TwilioProviderOptions;
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? true,
skipVerification: params.options.skipVerification,
});
if (!result.ok) {
console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
if (result.verificationUrl) {
console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
}
}
return {
ok: result.ok,
reason: result.reason,
};
}
+11 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "clawdbot", "name": "clawdbot",
"version": "2026.1.13", "version": "2026.1.14",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -71,8 +71,8 @@
"start": "tsx src/entry.ts", "start": "tsx src/entry.ts",
"clawdbot": "tsx src/entry.ts", "clawdbot": "tsx src/entry.ts",
"gateway:watch": "tsx watch src/entry.ts gateway --force", "gateway:watch": "tsx watch src/entry.ts gateway --force",
"gateway:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts gateway --dev", "gateway:dev": "tsx src/entry.ts --dev gateway",
"gateway:dev:reset": "CLAWDBOT_PROFILE=dev tsx src/entry.ts gateway --reset", "gateway:dev:reset": "tsx src/entry.ts --dev gateway --reset",
"tui": "tsx src/entry.ts tui", "tui": "tsx src/entry.ts tui",
"tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui", "tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json", "clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
@@ -87,14 +87,14 @@
"mac:restart": "bash scripts/restart-mac.sh", "mac:restart": "bash scripts/restart-mac.sh",
"mac:package": "bash scripts/package-mac-app.sh", "mac:package": "bash scripts/package-mac-app.sh",
"mac:open": "open dist/Clawdbot.app", "mac:open": "open dist/Clawdbot.app",
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js", "lint": "oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:all": "pnpm lint && pnpm lint:swift", "lint:all": "pnpm lint && pnpm lint:swift",
"lint:fix": "biome check --write --unsafe src && biome format --write src", "lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"format": "biome format src", "format": "oxfmt --check src test",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources", "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:all": "pnpm format && pnpm format:swift", "format:all": "pnpm format && pnpm format:swift",
"format:fix": "biome format src --write", "format:fix": "oxfmt --write src test",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "pnpm --dir ui test", "test:ui": "pnpm --dir ui test",
@@ -119,7 +119,8 @@
"protocol:gen": "tsx scripts/protocol-gen.ts", "protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts", "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh" "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -175,7 +176,6 @@
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.11",
"@grammyjs/types": "^3.23.0", "@grammyjs/types": "^3.23.0",
"@lit-labs/signals": "^0.2.0", "@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6", "@lit/context": "^1.1.6",
@@ -193,7 +193,8 @@
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.562.0", "lucide": "^0.562.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"oxlint": "^1.38.0", "oxfmt": "0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.0", "oxlint-tsgolint": "^0.11.0",
"quicktype-core": "^23.2.6", "quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59", "rolldown": "1.0.0-beta.59",
+52
View File
@@ -79,3 +79,55 @@ index 5f9a17e..48631a7 100644
-//# sourceMappingURL=openai-responses.js.map -//# sourceMappingURL=openai-responses.js.map
\ No newline at end of file \ No newline at end of file
+//# sourceMappingURL=openai-responses.js.map +//# sourceMappingURL=openai-responses.js.map
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index 47dc045..706157a 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -130,18 +130,30 @@
}
}
else if (block.type === "toolCall") {
- const part = {
- functionCall: {
- name: block.name,
- args: block.arguments,
- ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
- },
- };
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
- if (thoughtSignature) {
- part.thoughtSignature = thoughtSignature;
+ // Gemini 3 requires thoughtSignature on all function calls when thinking mode is enabled.
+ // When switching from a provider that doesn't support signatures (e.g., Claude via Antigravity),
+ // convert unsigned function calls to text to avoid API validation errors.
+ const isGemini3 = model.id.toLowerCase().includes("gemini-3");
+ if (isGemini3 && !thoughtSignature) {
+ const argsStr = JSON.stringify(block.arguments, null, 2);
+ parts.push({
+ text: `[Tool Call: ${block.name}]\nArguments: ${argsStr}`,
+ });
+ }
+ else {
+ const part = {
+ functionCall: {
+ name: block.name,
+ args: block.arguments,
+ ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
+ },
+ };
+ if (thoughtSignature) {
+ part.thoughtSignature = thoughtSignature;
+ }
+ parts.push(part);
}
- parts.push(part);
}
}
if (parts.length === 0)
@@ -280,4 +292,4 @@
return "error";
}
}
-//# sourceMappingURL=google-shared.js.map
\ No newline at end of file
+//# sourceMappingURL=google-shared.js.map
+97 -97
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies: patchedDependencies:
'@mariozechner/pi-ai@0.45.7': '@mariozechner/pi-ai@0.45.7':
hash: 899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd hash: e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9
path: patches/@mariozechner__pi-ai@0.45.7.patch path: patches/@mariozechner__pi-ai@0.45.7.patch
importers: importers:
@@ -36,7 +36,7 @@ importers:
version: 0.45.3(ws@8.19.0)(zod@4.3.5) version: 0.45.3(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: 0.45.7 specifier: 0.45.7
version: 0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5) version: 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: ^0.45.3 specifier: ^0.45.3
version: 0.45.3(ws@8.19.0)(zod@4.3.5) version: 0.45.3(ws@8.19.0)(zod@4.3.5)
@@ -149,9 +149,6 @@ importers:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
devDependencies: devDependencies:
'@biomejs/biome':
specifier: ^2.3.11
version: 2.3.11
'@grammyjs/types': '@grammyjs/types':
specifier: ^3.23.0 specifier: ^3.23.0
version: 3.23.0 version: 3.23.0
@@ -203,8 +200,11 @@ importers:
ollama: ollama:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
oxfmt:
specifier: 0.24.0
version: 0.24.0
oxlint: oxlint:
specifier: ^1.38.0 specifier: ^1.39.0
version: 1.39.0(oxlint-tsgolint@0.11.0) version: 1.39.0(oxlint-tsgolint@0.11.0)
oxlint-tsgolint: oxlint-tsgolint:
specifier: ^0.11.0 specifier: ^0.11.0
@@ -452,59 +452,6 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@biomejs/biome@2.3.11':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.3.11':
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.3.11':
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.11':
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.3.11':
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.3.11':
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.3.11':
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.3.11':
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.3.11':
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@borewit/text-codec@0.2.1': '@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
@@ -1245,6 +1192,46 @@ packages:
'@oxc-project/types@0.107.0': '@oxc-project/types@0.107.0':
resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==} resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==}
'@oxfmt/darwin-arm64@0.24.0':
resolution: {integrity: sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==}
cpu: [arm64]
os: [darwin]
'@oxfmt/darwin-x64@0.24.0':
resolution: {integrity: sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==}
cpu: [x64]
os: [darwin]
'@oxfmt/linux-arm64-gnu@0.24.0':
resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-arm64-musl@0.24.0':
resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-x64-gnu@0.24.0':
resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
cpu: [x64]
os: [linux]
'@oxfmt/linux-x64-musl@0.24.0':
resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
cpu: [x64]
os: [linux]
'@oxfmt/win32-arm64@0.24.0':
resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
cpu: [arm64]
os: [win32]
'@oxfmt/win32-x64@0.24.0':
resolution: {integrity: sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.11.0': '@oxlint-tsgolint/darwin-arm64@0.11.0':
resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==} resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==}
cpu: [arm64] cpu: [arm64]
@@ -3324,6 +3311,11 @@ packages:
resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==} resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==}
engines: {node: '>=20'} engines: {node: '>=20'}
oxfmt@0.24.0:
resolution: {integrity: sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.11.0: oxlint-tsgolint@0.11.0:
resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==} resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==}
hasBin: true hasBin: true
@@ -3858,6 +3850,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinypool@2.0.0:
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@3.0.3: tinyrainbow@3.0.3:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -4641,41 +4637,6 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.3.11':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.11
'@biomejs/cli-darwin-x64': 2.3.11
'@biomejs/cli-linux-arm64': 2.3.11
'@biomejs/cli-linux-arm64-musl': 2.3.11
'@biomejs/cli-linux-x64': 2.3.11
'@biomejs/cli-linux-x64-musl': 2.3.11
'@biomejs/cli-win32-arm64': 2.3.11
'@biomejs/cli-win32-x64': 2.3.11
'@biomejs/cli-darwin-arm64@2.3.11':
optional: true
'@biomejs/cli-darwin-x64@2.3.11':
optional: true
'@biomejs/cli-linux-arm64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-arm64@2.3.11':
optional: true
'@biomejs/cli-linux-x64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-x64@2.3.11':
optional: true
'@biomejs/cli-win32-arm64@2.3.11':
optional: true
'@biomejs/cli-win32-x64@2.3.11':
optional: true
'@borewit/text-codec@0.2.1': {} '@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)': '@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)':
@@ -5106,7 +5067,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.45.3(ws@8.19.0)(zod@4.3.5)': '@mariozechner/pi-agent-core@0.45.3(ws@8.19.0)(zod@4.3.5)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.45.3 '@mariozechner/pi-tui': 0.45.3
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
@@ -5117,7 +5078,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5)': '@mariozechner/pi-ai@0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@aws-sdk/client-bedrock-runtime': 3.967.0 '@aws-sdk/client-bedrock-runtime': 3.967.0
@@ -5144,7 +5105,7 @@ snapshots:
'@mariozechner/clipboard': 0.3.0 '@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.2 '@mariozechner/jiti': 2.6.2
'@mariozechner/pi-agent-core': 0.45.3(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-agent-core': 0.45.3(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.45.7(patch_hash=899187c39b551e0b611a32006f5f06bc329d3478fe4bac9bac044b2df079e2cd)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.45.3 '@mariozechner/pi-tui': 0.45.3
chalk: 5.6.2 chalk: 5.6.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
@@ -5420,6 +5381,30 @@ snapshots:
'@oxc-project/types@0.107.0': {} '@oxc-project/types@0.107.0': {}
'@oxfmt/darwin-arm64@0.24.0':
optional: true
'@oxfmt/darwin-x64@0.24.0':
optional: true
'@oxfmt/linux-arm64-gnu@0.24.0':
optional: true
'@oxfmt/linux-arm64-musl@0.24.0':
optional: true
'@oxfmt/linux-x64-gnu@0.24.0':
optional: true
'@oxfmt/linux-x64-musl@0.24.0':
optional: true
'@oxfmt/win32-arm64@0.24.0':
optional: true
'@oxfmt/win32-x64@0.24.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.11.0': '@oxlint-tsgolint/darwin-arm64@0.11.0':
optional: true optional: true
@@ -7653,6 +7638,19 @@ snapshots:
osc-progress@0.2.0: {} osc-progress@0.2.0: {}
oxfmt@0.24.0:
dependencies:
tinypool: 2.0.0
optionalDependencies:
'@oxfmt/darwin-arm64': 0.24.0
'@oxfmt/darwin-x64': 0.24.0
'@oxfmt/linux-arm64-gnu': 0.24.0
'@oxfmt/linux-arm64-musl': 0.24.0
'@oxfmt/linux-x64-gnu': 0.24.0
'@oxfmt/linux-x64-musl': 0.24.0
'@oxfmt/win32-arm64': 0.24.0
'@oxfmt/win32-x64': 0.24.0
oxlint-tsgolint@0.11.0: oxlint-tsgolint@0.11.0:
optionalDependencies: optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.11.0 '@oxlint-tsgolint/darwin-arm64': 0.11.0
@@ -8291,6 +8289,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tinypool@2.0.0: {}
tinyrainbow@3.0.3: {} tinyrainbow@3.0.3: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
+74
View File
@@ -0,0 +1,74 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
type ParsedArgs = {
maxLines: number;
};
function parseArgs(argv: string[]): ParsedArgs {
let maxLines = 500;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--max") {
const next = argv[index + 1];
if (!next || Number.isNaN(Number(next))) throw new Error("Missing/invalid --max value");
maxLines = Number(next);
index++;
continue;
}
}
return { maxLines };
}
function gitLsFilesAll(): string[] {
// Include untracked files too so local refactors don’t “pass” by accident.
const stdout = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
encoding: "utf8",
});
return stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function countLines(filePath: string): Promise<number> {
const content = await readFile(filePath, "utf8");
// Count physical lines. Keeps the rule simple + predictable.
return content.split("\n").length;
}
async function main() {
// Makes `... | head` safe.
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") process.exit(0);
throw error;
});
const { maxLines } = parseArgs(process.argv.slice(2));
const files = gitLsFilesAll()
.filter((filePath) => existsSync(filePath))
.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
const results = await Promise.all(
files.map(async (filePath) => ({ filePath, lines: await countLines(filePath) })),
);
const offenders = results
.filter((result) => result.lines > maxLines)
.sort((a, b) => b.lines - a.lines);
if (!offenders.length) return;
// Minimal, grep-friendly output.
for (const offender of offenders) {
// eslint-disable-next-line no-console
console.log(`${offender.lines}\t${offender.filePath}`);
}
process.exitCode = 1;
}
await main();
+2 -2
View File
@@ -283,7 +283,7 @@ const main = async () => {
const keychain = readClaudeCliKeychain(); const keychain = readClaudeCliKeychain();
if (keychain) { if (keychain) {
console.log( console.log(
`Claude CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`, `Claude Code CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`,
); );
const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken); const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken);
console.log( console.log(
@@ -291,7 +291,7 @@ const main = async () => {
); );
console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim());
} else { } else {
console.log("Claude CLI keychain: missing/unreadable"); console.log("Claude Code CLI keychain: missing/unreadable");
} }
const anthropic = pickAnthropicTokens(store); const anthropic = pickAnthropicTokens(store);
+38 -9
View File
@@ -3,6 +3,11 @@ set -euo pipefail
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}" INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
MODELS_MODE="${CLAWDBOT_E2E_MODELS:-both}" # both|openai|anthropic MODELS_MODE="${CLAWDBOT_E2E_MODELS:-both}" # both|openai|anthropic
E2E_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_E2E_PREVIOUS:-}"
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}"
if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS must be one of: both|openai|anthropic" >&2 echo "ERROR: CLAWDBOT_E2E_MODELS must be one of: both|openai|anthropic" >&2
@@ -10,31 +15,43 @@ if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" !
fi fi
if [[ "$MODELS_MODE" == "both" ]]; then if [[ "$MODELS_MODE" == "both" ]]; then
if [[ -z "${OPENAI_API_KEY:-}" || -z "${ANTHROPIC_API_KEY:-}" ]]; then if [[ -z "$OPENAI_API_KEY" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=both requires OPENAI_API_KEY and ANTHROPIC_API_KEY." >&2 echo "ERROR: CLAWDBOT_E2E_MODELS=both requires OPENAI_API_KEY." >&2
exit 2 exit 2
fi fi
elif [[ "$MODELS_MODE" == "openai" && -z "${OPENAI_API_KEY:-}" ]]; then if [[ -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=both requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2
exit 2
fi
elif [[ "$MODELS_MODE" == "openai" && -z "$OPENAI_API_KEY" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=openai requires OPENAI_API_KEY." >&2 echo "ERROR: CLAWDBOT_E2E_MODELS=openai requires OPENAI_API_KEY." >&2
exit 2 exit 2
elif [[ "$MODELS_MODE" == "anthropic" && -z "${ANTHROPIC_API_KEY:-}" ]]; then elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=anthropic requires ANTHROPIC_API_KEY." >&2 echo "ERROR: CLAWDBOT_E2E_MODELS=anthropic requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2
exit 2 exit 2
fi fi
echo "==> Resolve npm versions" echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view clawdbot version)" LATEST_VERSION="$(npm view clawdbot version)"
PREVIOUS_VERSION="$(node - <<'NODE' if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then
PREVIOUS_VERSION="$E2E_PREVIOUS_VERSION"
else
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process"); const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" })); const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
if (!Array.isArray(versions) || versions.length === 0) process.exit(1); if (!Array.isArray(versions) || versions.length === 0) process.exit(1);
process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]); process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]);
NODE NODE
)" )"
fi
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION" echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)" if [[ "$SKIP_PREVIOUS" == "1" ]]; then
npm install -g "clawdbot@${PREVIOUS_VERSION}" echo "==> Skip preinstall previous (CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS=1)"
else
echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)"
npm install -g "clawdbot@${PREVIOUS_VERSION}"
fi
echo "==> Run official installer one-liner" echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash curl -fsSL "$INSTALL_URL" | bash
@@ -318,6 +335,18 @@ run_profile() {
--gateway-auth token \ --gateway-auth token \
--workspace "$workspace" \ --workspace "$workspace" \
--skip-health --skip-health
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
clawdbot --profile "$profile" onboard \
--non-interactive \
--flow quickstart \
--auth-choice token \
--token-provider anthropic \
--token "$ANTHROPIC_API_TOKEN" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
else else
clawdbot --profile "$profile" onboard \ clawdbot --profile "$profile" onboard \
--non-interactive \ --non-interactive \
+34 -26
View File
@@ -11,6 +11,11 @@ echo "Running doctor install switch E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc ' docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail set -euo pipefail
# Keep logs focused; the npm global install step can emit noisy deprecation warnings.
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
# Stub systemd/loginctl so doctor + daemon flows work in Docker. # Stub systemd/loginctl so doctor + daemon flows work in Docker.
export PATH="/tmp/clawdbot-bin:$PATH" export PATH="/tmp/clawdbot-bin:$PATH"
mkdir -p /tmp/clawdbot-bin mkdir -p /tmp/clawdbot-bin
@@ -64,14 +69,15 @@ if [[ "$*" == *"enable-linger"* ]]; then
fi fi
exit 0 exit 0
LOGINCTL LOGINCTL
chmod +x /tmp/clawdbot-bin/loginctl chmod +x /tmp/clawdbot-bin/loginctl
# Install the npm-global variant from the local /app source. # Install the npm-global variant from the local /app source.
npm install -g --prefix /tmp/npm-prefix /app pkg_tgz="$(npm pack --silent /app)"
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
npm_bin="/tmp/npm-prefix/bin/clawdbot" npm_bin="/tmp/npm-prefix/bin/clawdbot"
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js" npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
git_entry="/app/dist/entry.js" git_entry="/app/dist/entry.js"
assert_entrypoint() { assert_entrypoint() {
local unit_path="$1" local unit_path="$1"
@@ -81,14 +87,16 @@ LOGINCTL
if [ -z "$exec_line" ]; then if [ -z "$exec_line" ]; then
echo "Missing ExecStart in $unit_path" echo "Missing ExecStart in $unit_path"
exit 1 exit 1
fi fi
exec_line="${exec_line#ExecStart=}" exec_line="${exec_line#ExecStart=}"
entrypoint=$(echo "$exec_line" | awk "{print \$2}") entrypoint=$(echo "$exec_line" | awk "{print \$2}")
if [ "$entrypoint" != "$expected" ]; then entrypoint="${entrypoint%\"}"
echo "Expected entrypoint $expected, got $entrypoint" entrypoint="${entrypoint#\"}"
exit 1 if [ "$entrypoint" != "$expected" ]; then
fi echo "Expected entrypoint $expected, got $entrypoint"
} exit 1
fi
}
# Each flow: install service with one variant, run doctor from the other, # Each flow: install service with one variant, run doctor from the other,
# and verify ExecStart entrypoint switches accordingly. # and verify ExecStart entrypoint switches accordingly.
@@ -118,17 +126,17 @@ LOGINCTL
assert_entrypoint "$unit_path" "$doctor_expected" assert_entrypoint "$unit_path" "$doctor_expected"
} }
run_flow \ run_flow \
"npm-to-git" \ "npm-to-git" \
"$npm_bin daemon install --force" \ "$npm_bin daemon install --force" \
"$npm_entry" \ "$npm_entry" \
"node $git_entry doctor --repair" \ "node $git_entry doctor --repair --force" \
"$git_entry" "$git_entry"
run_flow \ run_flow \
"git-to-npm" \ "git-to-npm" \
"node $git_entry daemon install --force" \ "node $git_entry daemon install --force" \
"$git_entry" \ "$git_entry" \
"$npm_bin doctor --repair" \ "$npm_bin doctor --repair --force" \
"$npm_entry" "$npm_entry"
' '
+9 -9
View File
@@ -99,17 +99,17 @@ ws.send(
auth: { token }, auth: { token },
}, },
}), }),
); );
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\"); const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
if (!connectRes.ok) throw new Error(`connect failed: ${connectRes.error?.message ?? \"unknown\"}`); if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" })); ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000); const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
if (!healthRes.ok) throw new Error(`health failed: ${healthRes.error?.message ?? \"unknown\"}`); if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\"); if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
ws.close(); ws.close();
console.log(\"ok\"); console.log(\"ok\");
NODE" NODE"
echo "OK" echo "OK"
+11 -11
View File
@@ -12,7 +12,7 @@ docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail set -euo pipefail
trap "" PIPE trap "" PIPE
export TERM=xterm-256color export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-providers --skip-skills --skip-daemon --skip-ui" ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers. # Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
export PATH="/tmp/clawdbot-bin:$PATH" export PATH="/tmp/clawdbot-bin:$PATH"
@@ -135,7 +135,7 @@ TRASH
} }
send_local_basic() { send_local_basic() {
# Choose local gateway, accept defaults, skip provider/skills/daemon, skip UI. # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
send $'"'"'\r'"'"' 0.5 send $'"'"'\r'"'"' 0.5
} }
@@ -149,14 +149,14 @@ TRASH
send_local_basic send_local_basic
} }
send_providers_flow() { send_channels_flow() {
# Configure providers via configure wizard. # Configure channels via configure wizard.
send $'"'"'\r'"'"' 1.0 send $'"'"'\r'"'"' 1.0
send "" 1.5 send "" 1.5
# Provider mode (default Configure providers) # Mode (default Configure channels)
send $'"'"'\r'"'"' 0.8 send $'"'"'\r'"'"' 0.8
send "" 1.0 send "" 1.0
# Configure chat providers now? -> No # Configure chat channels now? -> No
send $'"'"'n\r'"'"' 0.6 send $'"'"'n\r'"'"' 0.6
} }
@@ -339,11 +339,11 @@ if (errors.length > 0) {
NODE NODE
} }
run_case_providers() { run_case_channels() {
local home_dir local home_dir
home_dir="$(make_home providers)" home_dir="$(make_home channels)"
# Providers-only configure flow. # Channels-only configure flow.
run_wizard_cmd providers "$home_dir" "node dist/index.js configure --section providers" send_providers_flow run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow
config_path="$HOME/.clawdbot/clawdbot.json" config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path" assert_file "$config_path"
@@ -440,7 +440,7 @@ NODE
run_case_local_basic run_case_local_basic
run_case_remote_non_interactive run_case_remote_non_interactive
run_case_reset run_case_reset
run_case_providers run_case_channels
run_case_skills run_case_skills
' '
+4
View File
@@ -7,6 +7,7 @@ INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}" OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}"
CLAWDBOT_E2E_MODELS="${CLAWDBOT_E2E_MODELS:-}" CLAWDBOT_E2E_MODELS="${CLAWDBOT_E2E_MODELS:-}"
echo "==> Build image: $IMAGE_NAME" echo "==> Build image: $IMAGE_NAME"
@@ -19,6 +20,9 @@ echo "==> Run E2E installer test"
docker run --rm -t \ docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \ -e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_E2E_MODELS="$CLAWDBOT_E2E_MODELS" \ -e CLAWDBOT_E2E_MODELS="$CLAWDBOT_E2E_MODELS" \
-e CLAWDBOT_INSTALL_E2E_PREVIOUS="${CLAWDBOT_INSTALL_E2E_PREVIOUS:-}" \
-e CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}" \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-e ANTHROPIC_API_TOKEN="$ANTHROPIC_API_TOKEN" \
"$IMAGE_NAME" "$IMAGE_NAME"
+1 -33
View File
@@ -1,43 +1,11 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { ApiContributor, Entry, MapConfig, User } from "./update-clawtributors.types.js";
const REPO = "clawdbot/clawdbot"; const REPO = "clawdbot/clawdbot";
const PER_LINE = 10; const PER_LINE = 10;
type MapConfig = {
ensureLogins?: string[];
displayName?: Record<string, string>;
nameToLogin?: Record<string, string>;
emailToLogin?: Record<string, string>;
placeholderAvatar?: string;
seedCommit?: string;
};
type ApiContributor = {
login?: string;
html_url?: string;
avatar_url?: string;
name?: string;
email?: string;
contributions?: number;
};
type User = {
login: string;
html_url: string;
avatar_url: string;
};
type Entry = {
key: string;
login?: string;
display: string;
html_url: string;
avatar_url: string;
lines: number;
};
const mapPath = resolve("scripts/clawtributors-map.json"); const mapPath = resolve("scripts/clawtributors-map.json");
const mapConfig = JSON.parse(readFileSync(mapPath, "utf8")) as MapConfig; const mapConfig = JSON.parse(readFileSync(mapPath, "utf8")) as MapConfig;
+33
View File
@@ -0,0 +1,33 @@
export type MapConfig = {
ensureLogins?: string[];
displayName?: Record<string, string>;
nameToLogin?: Record<string, string>;
emailToLogin?: Record<string, string>;
placeholderAvatar?: string;
seedCommit?: string;
};
export type ApiContributor = {
login?: string;
html_url?: string;
avatar_url?: string;
name?: string;
email?: string;
contributions?: number;
};
export type User = {
login: string;
html_url: string;
avatar_url: string;
};
export type Entry = {
key: string;
login?: string;
display: string;
html_url: string;
avatar_url: string;
lines: number;
};
+2 -8
View File
@@ -6,15 +6,9 @@ import { resolveUserPath } from "../utils.js";
export function resolveClawdbotAgentDir(): string { export function resolveClawdbotAgentDir(): string {
const override = const override =
process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
process.env.PI_CODING_AGENT_DIR?.trim();
if (override) return resolveUserPath(override); if (override) return resolveUserPath(override);
const defaultAgentDir = path.join( const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
resolveStateDir(),
"agents",
DEFAULT_AGENT_ID,
"agent",
);
return resolveUserPath(defaultAgentDir); return resolveUserPath(defaultAgentDir);
} }
+3 -9
View File
@@ -72,12 +72,8 @@ describe("resolveAgentConfig", () => {
}, },
}; };
expect(resolveAgentModelPrimary(cfg, "linus")).toBe( expect(resolveAgentModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
"anthropic/claude-opus-4", expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]);
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
// If fallbacks isn't present, we don't override the global fallbacks. // If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = { const cfgNoOverride: ClawdbotConfig = {
@@ -92,9 +88,7 @@ describe("resolveAgentConfig", () => {
], ],
}, },
}; };
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe( expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(undefined);
undefined,
);
// Explicit empty list disables global fallbacks for that agent. // Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = { const cfgDisable: ClawdbotConfig = {
+13 -32
View File
@@ -13,9 +13,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
type AgentEntry = NonNullable< type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = { type ResolvedAgentConfig = {
name?: string; name?: string;
@@ -36,9 +34,7 @@ let defaultAgentWarned = false;
function listAgents(cfg: ClawdbotConfig): AgentEntry[] { function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list; const list = cfg.agents?.list;
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry => return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
Boolean(entry && typeof entry === "object"),
);
} }
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string { export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
@@ -47,24 +43,20 @@ export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const defaults = agents.filter((agent) => agent?.default); const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) { if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true; defaultAgentWarned = true;
console.warn( console.warn("Multiple agents marked default=true; using the first entry as default.");
"Multiple agents marked default=true; using the first entry as default.",
);
} }
const chosen = (defaults[0] ?? agents[0])?.id?.trim(); const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID); return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
} }
export function resolveSessionAgentIds(params: { export function resolveSessionAgentIds(params: { sessionKey?: string; config?: ClawdbotConfig }): {
sessionKey?: string; defaultAgentId: string;
config?: ClawdbotConfig; sessionAgentId: string;
}): { defaultAgentId: string; sessionAgentId: string } { } {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {}); const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim(); const sessionKey = params.sessionKey?.trim();
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null; const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const sessionAgentId = parsed?.agentId const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
? normalizeAgentId(parsed.agentId)
: defaultAgentId;
return { defaultAgentId, sessionAgentId }; return { defaultAgentId, sessionAgentId };
} }
@@ -75,10 +67,7 @@ export function resolveSessionAgentId(params: {
return resolveSessionAgentIds(params).sessionAgentId; return resolveSessionAgentIds(params).sessionAgentId;
} }
function resolveAgentEntry( function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id); return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
} }
@@ -92,31 +81,23 @@ export function resolveAgentConfig(
if (!entry) return undefined; if (!entry) return undefined;
return { return {
name: typeof entry.name === "string" ? entry.name : undefined, name: typeof entry.name === "string" ? entry.name : undefined,
workspace: workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: model:
typeof entry.model === "string" || typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
(entry.model && typeof entry.model === "object")
? entry.model ? entry.model
: undefined, : undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
identity: entry.identity, identity: entry.identity,
groupChat: entry.groupChat, groupChat: entry.groupChat,
subagents: subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
: undefined,
sandbox: entry.sandbox, sandbox: entry.sandbox,
tools: entry.tools, tools: entry.tools,
}; };
} }
export function resolveAgentModelPrimary( export function resolveAgentModelPrimary(cfg: ClawdbotConfig, agentId: string): string | undefined {
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model; const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined; if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined; if (typeof raw === "string") return raw.trim() || undefined;
+13 -37
View File
@@ -4,10 +4,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai"; import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import { import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
ANTHROPIC_SETUP_TOKEN_PREFIX, ANTHROPIC_SETUP_TOKEN_PREFIX,
@@ -26,15 +23,11 @@ import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? ""; const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE = const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? ""; const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = const SETUP_TOKEN_MODEL = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const SETUP_TOKEN_MODEL =
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
const ENABLED = const ENABLED = LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
const describeLive = ENABLED ? describe : describe.skip; const describeLive = ENABLED ? describe : describe.skip;
type TokenSource = { type TokenSource = {
@@ -60,11 +53,7 @@ function listSetupTokenProfiles(store: {
} }
function pickSetupTokenProfile(candidates: string[]): string { function pickSetupTokenProfile(candidates: string[]): string {
const preferred = [ const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
"anthropic:setup-token-test",
"anthropic:setup-token",
"anthropic:default",
];
for (const id of preferred) { for (const id of preferred) {
if (candidates.includes(id)) return id; if (candidates.includes(id)) return id;
} }
@@ -73,17 +62,14 @@ function pickSetupTokenProfile(candidates: string[]): string {
async function resolveTokenSource(): Promise<TokenSource> { async function resolveTokenSource(): Promise<TokenSource> {
const explicitToken = const explicitToken =
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || (SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
SETUP_TOKEN_VALUE;
if (explicitToken) { if (explicitToken) {
const error = validateAnthropicSetupToken(explicitToken); const error = validateAnthropicSetupToken(explicitToken);
if (error) { if (error) {
throw new Error(`Invalid setup-token: ${error}`); throw new Error(`Invalid setup-token: ${error}`);
} }
const tempDir = await fs.mkdtemp( const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-setup-token-"));
path.join(os.tmpdir(), "clawdbot-setup-token-"),
);
const profileId = `anthropic:setup-token-live-${randomUUID()}`; const profileId = `anthropic:setup-token-live-${randomUUID()}`;
const store = ensureAuthProfileStore(tempDir, { const store = ensureAuthProfileStore(tempDir, {
allowKeychainPrompt: false, allowKeychainPrompt: false,
@@ -111,8 +97,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
const candidates = listSetupTokenProfiles(store); const candidates = listSetupTokenProfiles(store);
if (SETUP_TOKEN_PROFILE) { if (SETUP_TOKEN_PROFILE) {
if (!candidates.includes(SETUP_TOKEN_PROFILE)) { if (!candidates.includes(SETUP_TOKEN_PROFILE)) {
const available = const available = candidates.length > 0 ? candidates.join(", ") : "(none)";
candidates.length > 0 ? candidates.join(", ") : "(none)";
throw new Error( throw new Error(
`Setup-token profile "${SETUP_TOKEN_PROFILE}" not found. Available: ${available}.`, `Setup-token profile "${SETUP_TOKEN_PROFILE}" not found. Available: ${available}.`,
); );
@@ -120,11 +105,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
return { agentDir, profileId: SETUP_TOKEN_PROFILE }; return { agentDir, profileId: SETUP_TOKEN_PROFILE };
} }
if ( if (SETUP_TOKEN_RAW && SETUP_TOKEN_RAW !== "1" && SETUP_TOKEN_RAW !== "auto") {
SETUP_TOKEN_RAW &&
SETUP_TOKEN_RAW !== "1" &&
SETUP_TOKEN_RAW !== "auto"
) {
throw new Error( throw new Error(
"CLAWDBOT_LIVE_SETUP_TOKEN did not look like a setup-token. Use CLAWDBOT_LIVE_SETUP_TOKEN_VALUE for raw tokens.", "CLAWDBOT_LIVE_SETUP_TOKEN did not look like a setup-token. Use CLAWDBOT_LIVE_SETUP_TOKEN_VALUE for raw tokens.",
); );
@@ -146,8 +127,7 @@ function pickModel(models: Array<Model<Api>>, raw?: string): Model<Api> | null {
return ( return (
models.find( models.find(
(model) => (model) =>
normalizeProviderId(model.provider) === parsed.provider && normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model,
model.id === parsed.model,
) ?? null ) ?? null
); );
} }
@@ -176,9 +156,7 @@ describeLive("live anthropic setup-token", () => {
const authStorage = discoverAuthStorage(tokenSource.agentDir); const authStorage = discoverAuthStorage(tokenSource.agentDir);
const modelRegistry = discoverModels(authStorage, tokenSource.agentDir); const modelRegistry = discoverModels(authStorage, tokenSource.agentDir);
const all = Array.isArray(modelRegistry) const all = Array.isArray(modelRegistry) ? modelRegistry : modelRegistry.getAll();
? modelRegistry
: modelRegistry.getAll();
const candidates = all.filter( const candidates = all.filter(
(model) => normalizeProviderId(model.provider) === "anthropic", (model) => normalizeProviderId(model.provider) === "anthropic",
) as Array<Model<Api>>; ) as Array<Model<Api>>;
@@ -201,9 +179,7 @@ describeLive("live anthropic setup-token", () => {
}); });
const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey); const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey);
if (tokenError) { if (tokenError) {
throw new Error( throw new Error(`Resolved profile is not a setup-token: ${tokenError}`);
`Resolved profile is not a setup-token: ${tokenError}`,
);
} }
const res = await completeSimple( const res = await completeSimple(
+187
View File
@@ -0,0 +1,187 @@
import fs from "node:fs/promises";
type UpdateFileChunk = {
changeContext?: string;
oldLines: string[];
newLines: string[];
isEndOfFile: boolean;
};
export async function applyUpdateHunk(
filePath: string,
chunks: UpdateFileChunk[],
): Promise<string> {
const originalContents = await fs.readFile(filePath, "utf8").catch((err) => {
throw new Error(`Failed to read file to update ${filePath}: ${err}`);
});
const originalLines = originalContents.split("\n");
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
originalLines.pop();
}
const replacements = computeReplacements(originalLines, filePath, chunks);
let newLines = applyReplacements(originalLines, replacements);
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
newLines = [...newLines, ""];
}
return newLines.join("\n");
}
function computeReplacements(
originalLines: string[],
filePath: string,
chunks: UpdateFileChunk[],
): Array<[number, number, string[]]> {
const replacements: Array<[number, number, string[]]> = [];
let lineIndex = 0;
for (const chunk of chunks) {
if (chunk.changeContext) {
const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
if (ctxIndex === null) {
throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`);
}
lineIndex = ctxIndex + 1;
}
if (chunk.oldLines.length === 0) {
const insertionIndex =
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length;
replacements.push([insertionIndex, 0, chunk.newLines]);
continue;
}
let pattern = chunk.oldLines;
let newSlice = chunk.newLines;
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
if (found === null && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1);
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1);
}
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
}
if (found === null) {
throw new Error(
`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`,
);
}
replacements.push([found, pattern.length, newSlice]);
lineIndex = found + pattern.length;
}
replacements.sort((a, b) => a[0] - b[0]);
return replacements;
}
function applyReplacements(
lines: string[],
replacements: Array<[number, number, string[]]>,
): string[] {
const result = [...lines];
for (const [startIndex, oldLen, newLines] of [...replacements].reverse()) {
for (let i = 0; i < oldLen; i += 1) {
if (startIndex < result.length) {
result.splice(startIndex, 1);
}
}
for (let i = 0; i < newLines.length; i += 1) {
result.splice(startIndex + i, 0, newLines[i]);
}
}
return result;
}
function seekSequence(
lines: string[],
pattern: string[],
start: number,
eof: boolean,
): number | null {
if (pattern.length === 0) return start;
if (pattern.length > lines.length) return null;
const maxStart = lines.length - pattern.length;
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
if (searchStart > maxStart) return null;
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => value)) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => value.trim())) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) {
return i;
}
}
return null;
}
function linesMatch(
lines: string[],
pattern: string[],
start: number,
normalize: (value: string) => string,
): boolean {
for (let idx = 0; idx < pattern.length; idx += 1) {
if (normalize(lines[start + idx]) !== normalize(pattern[idx])) {
return false;
}
}
return true;
}
function normalizePunctuation(value: string): string {
return Array.from(value)
.map((char) => {
switch (char) {
case "\u2010":
case "\u2011":
case "\u2012":
case "\u2013":
case "\u2014":
case "\u2015":
case "\u2212":
return "-";
case "\u2018":
case "\u2019":
case "\u201A":
case "\u201B":
return "'";
case "\u201C":
case "\u201D":
case "\u201E":
case "\u201F":
return '"';
case "\u00A0":
case "\u2002":
case "\u2003":
case "\u2004":
case "\u2005":
case "\u2006":
case "\u2007":
case "\u2008":
case "\u2009":
case "\u200A":
case "\u202F":
case "\u205F":
case "\u3000":
return " ";
default:
return char;
}
})
.join("");
}
+3 -213
View File
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { applyUpdateHunk } from "./apply-patch-update.js";
import { assertSandboxPath } from "./sandbox-paths.js"; import { assertSandboxPath } from "./sandbox-paths.js";
const BEGIN_PATCH_MARKER = "*** Begin Patch"; const BEGIN_PATCH_MARKER = "*** Begin Patch";
@@ -282,10 +282,7 @@ function checkPatchBoundariesLenient(lines: string[]): string[] {
} }
const first = lines[0]; const first = lines[0];
const last = lines[lines.length - 1]; const last = lines[lines.length - 1];
if ( if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) {
(first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') &&
last.endsWith("EOF")
) {
const inner = lines.slice(1, lines.length - 1); const inner = lines.slice(1, lines.length - 1);
const innerError = checkPatchBoundariesStrict(inner); const innerError = checkPatchBoundariesStrict(inner);
if (!innerError) return inner; if (!innerError) return inner;
@@ -308,10 +305,7 @@ function checkPatchBoundariesStrict(lines: string[]): string | null {
return "The last line of the patch must be '*** End Patch'"; return "The last line of the patch must be '*** End Patch'";
} }
function parseOneHunk( function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
lines: string[],
lineNumber: number,
): { hunk: Hunk; consumed: number } {
if (lines.length === 0) { if (lines.length === 0) {
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`); throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
} }
@@ -483,207 +477,3 @@ function parseUpdateFileChunk(
return { chunk, consumed: parsedLines + startIndex }; return { chunk, consumed: parsedLines + startIndex };
} }
async function applyUpdateHunk(
filePath: string,
chunks: UpdateFileChunk[],
): Promise<string> {
const originalContents = await fs.readFile(filePath, "utf8").catch((err) => {
throw new Error(`Failed to read file to update ${filePath}: ${err}`);
});
const originalLines = originalContents.split("\n");
if (
originalLines.length > 0 &&
originalLines[originalLines.length - 1] === ""
) {
originalLines.pop();
}
const replacements = computeReplacements(originalLines, filePath, chunks);
let newLines = applyReplacements(originalLines, replacements);
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
newLines = [...newLines, ""];
}
return newLines.join("\n");
}
function computeReplacements(
originalLines: string[],
filePath: string,
chunks: UpdateFileChunk[],
): Array<[number, number, string[]]> {
const replacements: Array<[number, number, string[]]> = [];
let lineIndex = 0;
for (const chunk of chunks) {
if (chunk.changeContext) {
const ctxIndex = seekSequence(
originalLines,
[chunk.changeContext],
lineIndex,
false,
);
if (ctxIndex === null) {
throw new Error(
`Failed to find context '${chunk.changeContext}' in ${filePath}`,
);
}
lineIndex = ctxIndex + 1;
}
if (chunk.oldLines.length === 0) {
const insertionIndex =
originalLines.length > 0 &&
originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length;
replacements.push([insertionIndex, 0, chunk.newLines]);
continue;
}
let pattern = chunk.oldLines;
let newSlice = chunk.newLines;
let found = seekSequence(
originalLines,
pattern,
lineIndex,
chunk.isEndOfFile,
);
if (found === null && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1);
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1);
}
found = seekSequence(
originalLines,
pattern,
lineIndex,
chunk.isEndOfFile,
);
}
if (found === null) {
throw new Error(
`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`,
);
}
replacements.push([found, pattern.length, newSlice]);
lineIndex = found + pattern.length;
}
replacements.sort((a, b) => a[0] - b[0]);
return replacements;
}
function applyReplacements(
lines: string[],
replacements: Array<[number, number, string[]]>,
): string[] {
const result = [...lines];
for (const [startIndex, oldLen, newLines] of [...replacements].reverse()) {
for (let i = 0; i < oldLen; i += 1) {
if (startIndex < result.length) {
result.splice(startIndex, 1);
}
}
for (let i = 0; i < newLines.length; i += 1) {
result.splice(startIndex + i, 0, newLines[i]);
}
}
return result;
}
function seekSequence(
lines: string[],
pattern: string[],
start: number,
eof: boolean,
): number | null {
if (pattern.length === 0) return start;
if (pattern.length > lines.length) return null;
const maxStart = lines.length - pattern.length;
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
if (searchStart > maxStart) return null;
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => value)) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (linesMatch(lines, pattern, i, (value) => value.trim())) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (
linesMatch(lines, pattern, i, (value) =>
normalizePunctuation(value.trim()),
)
) {
return i;
}
}
return null;
}
function linesMatch(
lines: string[],
pattern: string[],
start: number,
normalize: (value: string) => string,
): boolean {
for (let idx = 0; idx < pattern.length; idx += 1) {
if (normalize(lines[start + idx]) !== normalize(pattern[idx])) {
return false;
}
}
return true;
}
function normalizePunctuation(value: string): string {
return Array.from(value)
.map((char) => {
switch (char) {
case "\u2010":
case "\u2011":
case "\u2012":
case "\u2013":
case "\u2014":
case "\u2015":
case "\u2212":
return "-";
case "\u2018":
case "\u2019":
case "\u201A":
case "\u201B":
return "'";
case "\u201C":
case "\u201D":
case "\u201E":
case "\u201F":
return '"';
case "\u00A0":
case "\u2002":
case "\u2003":
case "\u2004":
case "\u2005":
case "\u2006":
case "\u2007":
case "\u2008":
case "\u2009":
case "\u200A":
case "\u202F":
case "\u205F":
case "\u3000":
return " ";
default:
return char;
}
})
.join("");
}
+2 -7
View File
@@ -1,9 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import { buildAuthHealthSummary, DEFAULT_OAUTH_WARN_MS } from "./auth-health.js";
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
} from "./auth-health.js";
describe("buildAuthHealthSummary", () => { describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000; const now = 1_700_000_000_000;
@@ -59,9 +56,7 @@ describe("buildAuthHealthSummary", () => {
expect(statuses["anthropic:expired"]).toBe("expired"); expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static"); expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find( const provider = summary.providers.find((entry) => entry.provider === "anthropic");
(entry) => entry.provider === "anthropic",
);
expect(provider?.status).toBe("expired"); expect(provider?.status).toBe("expired");
}); });
}); });
+7 -30
View File
@@ -9,12 +9,7 @@ import {
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store"; export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileHealthStatus = export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProfileHealth = { export type AuthProfileHealth = {
profileId: string; profileId: string;
@@ -27,12 +22,7 @@ export type AuthProfileHealth = {
label: string; label: string;
}; };
export type AuthProviderHealthStatus = export type AuthProviderHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProviderHealth = { export type AuthProviderHealth = {
provider: string; provider: string;
@@ -111,8 +101,7 @@ function buildProfileHealth(params: {
if (credential.type === "token") { if (credential.type === "token") {
const expiresAt = const expiresAt =
typeof credential.expires === "number" && typeof credential.expires === "number" && Number.isFinite(credential.expires)
Number.isFinite(credential.expires)
? credential.expires ? credential.expires
: undefined; : undefined;
if (!expiresAt || expiresAt <= 0) { if (!expiresAt || expiresAt <= 0) {
@@ -125,11 +114,7 @@ function buildProfileHealth(params: {
label, label,
}; };
} }
const { status, remainingMs } = resolveOAuthStatus( const { status, remainingMs } = resolveOAuthStatus(expiresAt, now, warnAfterMs);
expiresAt,
now,
warnAfterMs,
);
return { return {
profileId, profileId,
provider: credential.provider, provider: credential.provider,
@@ -142,11 +127,7 @@ function buildProfileHealth(params: {
}; };
} }
const { status, remainingMs } = resolveOAuthStatus( const { status, remainingMs } = resolveOAuthStatus(credential.expires, now, warnAfterMs);
credential.expires,
now,
warnAfterMs,
);
return { return {
profileId, profileId,
provider: credential.provider, provider: credential.provider,
@@ -172,9 +153,7 @@ export function buildAuthHealthSummary(params: {
: null; : null;
const profiles = Object.entries(params.store.profiles) const profiles = Object.entries(params.store.profiles)
.filter(([_, cred]) => .filter(([_, cred]) => (providerFilter ? providerFilter.has(cred.provider) : true))
providerFilter ? providerFilter.has(cred.provider) : true,
)
.map(([profileId, credential]) => .map(([profileId, credential]) =>
buildProfileHealth({ buildProfileHealth({
profileId, profileId,
@@ -226,9 +205,7 @@ export function buildAuthHealthSummary(params: {
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth"); const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const tokenProfiles = provider.profiles.filter((p) => p.type === "token"); const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter( const apiKeyProfiles = provider.profiles.filter((p) => p.type === "api_key");
(p) => p.type === "api_key",
);
const expirable = [...oauthProfiles, ...tokenProfiles]; const expirable = [...oauthProfiles, ...tokenProfiles];
if (expirable.length === 0) { if (expirable.length === 0) {
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { calculateAuthProfileCooldownMs } from "./auth-profiles.js";
describe("auth profile cooldowns", () => {
it("applies exponential backoff with a 1h cap", () => {
expect(calculateAuthProfileCooldownMs(1)).toBe(60_000);
expect(calculateAuthProfileCooldownMs(2)).toBe(5 * 60_000);
expect(calculateAuthProfileCooldownMs(3)).toBe(25 * 60_000);
expect(calculateAuthProfileCooldownMs(4)).toBe(60 * 60_000);
expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000);
});
});
+7 -26
View File
@@ -8,10 +8,7 @@ import {
ensureAuthProfileStore, ensureAuthProfileStore,
resolveApiKeyForProfile, resolveApiKeyForProfile,
} from "./auth-profiles.js"; } from "./auth-profiles.js";
import { import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js";
CHUTES_TOKEN_ENDPOINT,
type ChutesStoredOAuth,
} from "./chutes-oauth.js";
describe("auth-profiles (chutes)", () => { describe("auth-profiles (chutes)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@@ -30,32 +27,19 @@ describe("auth-profiles (chutes)", () => {
else process.env.CLAWDBOT_STATE_DIR = previousStateDir; else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR; if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined) if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
if (previousChutesClientId === undefined) if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID;
delete process.env.CHUTES_CLIENT_ID;
else process.env.CHUTES_CLIENT_ID = previousChutesClientId; else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
}); });
it("refreshes expired Chutes OAuth credentials", async () => { it("refreshes expired Chutes OAuth credentials", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-")); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
process.env.CLAWDBOT_STATE_DIR = tempDir; process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join( process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
tempDir,
"agents",
"main",
"agent",
);
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join( const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
tempDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
await fs.mkdir(path.dirname(authProfilePath), { recursive: true }); await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = { const store: AuthProfileStore = {
@@ -75,8 +59,7 @@ describe("auth-profiles (chutes)", () => {
const fetchSpy = vi.fn(async (input: string | URL) => { const fetchSpy = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString(); const url = typeof input === "string" ? input : input.toString();
if (url !== CHUTES_TOKEN_ENDPOINT) if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
return new Response("not found", { status: 404 });
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
access_token: "at_new", access_token: "at_new",
@@ -96,9 +79,7 @@ describe("auth-profiles (chutes)", () => {
expect(resolved?.apiKey).toBe("at_new"); expect(resolved?.apiKey).toBe("at_new");
expect(fetchSpy).toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalled();
const persisted = JSON.parse( const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as {
await fs.readFile(authProfilePath, "utf8"),
) as {
profiles?: Record<string, { access?: string }>; profiles?: Record<string, { access?: string }>;
}; };
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new"); expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
@@ -0,0 +1,48 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js";
describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-profiles-"));
try {
const legacyPath = path.join(agentDir, "auth.json");
fs.writeFileSync(
legacyPath,
`${JSON.stringify(
{
anthropic: {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toMatchObject({
type: "oauth",
provider: "anthropic",
});
const migratedPath = path.join(agentDir, "auth-profiles.json");
expect(fs.existsSync(migratedPath)).toBe(true);
expect(fs.existsSync(legacyPath)).toBe(false);
// idempotent
const store2 = ensureAuthProfileStore(agentDir);
expect(store2.profiles["anthropic:default"]).toBeDefined();
expect(fs.existsSync(legacyPath)).toBe(false);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,102 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("does not overwrite API keys when syncing external CLI creds", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-no-overwrite-"));
try {
await withTempHome(
async (tempHome) => {
// Create Claude Code CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
};
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// Should keep the store's API key and still add the CLI profile.
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-store");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"));
try {
await withTempHome(
async (tempHome) => {
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
// CLI has OAuth credentials (with refresh token) expiring in 30 min
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-oauth-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
);
const authPath = path.join(agentDir, "auth-profiles.json");
// Store has token credentials expiring in 60 min (later than CLI)
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "store-token-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// OAuth should be preferred over token because it can auto-refresh
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe("cli-oauth-access");
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,106 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("does not overwrite fresher store oauth with older CLI oauth", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"));
try {
await withTempHome(
async (tempHome) => {
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
// CLI has OAuth credentials expiring in 30 min
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-oauth-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
);
const authPath = path.join(agentDir, "auth-profiles.json");
// Store has OAuth credentials expiring in 60 min (later than CLI)
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "anthropic",
access: "store-oauth-access",
refresh: "store-refresh",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// Fresher store oauth should be kept
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe("store-oauth-access");
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not downgrade store oauth to token when CLI lacks refresh token", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-"));
try {
await withTempHome(
async (tempHome) => {
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
// CLI has token-only credentials (no refresh token)
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-token-access",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
);
const authPath = path.join(agentDir, "auth-profiles.json");
// Store already has OAuth credentials with refresh token
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "anthropic",
access: "store-oauth-access",
refresh: "store-refresh",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// Keep oauth to preserve auto-refresh capability
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe("store-oauth-access");
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,96 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("syncs Claude Code CLI OAuth credentials into anthropic:claude-cli", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-sync-"));
try {
// Create a temp home with Claude Code CLI credentials
await withTempHome(
async (tempHome) => {
// Create Claude Code CLI credentials with refreshToken (OAuth)
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
};
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
// Load the store - should sync from CLI as OAuth credential
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-default");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
// Should be stored as OAuth credential (type: "oauth") for auto-refresh
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe("fresh-access-token");
expect((cliProfile as { refresh: string }).refresh).toBe("fresh-refresh-token");
expect((cliProfile as { expires: number }).expires).toBeGreaterThan(Date.now());
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("syncs Claude Code CLI credentials without refreshToken as token type", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-token-sync-"));
try {
await withTempHome(
async (tempHome) => {
// Create Claude Code CLI credentials WITHOUT refreshToken (fallback to token type)
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "access-only-token",
// No refreshToken - backward compatibility scenario
expiresAt: Date.now() + 60 * 60 * 1000,
},
};
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(authPath, JSON.stringify({ version: 1, profiles: {} }));
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
// Should be stored as token type (no refresh capability)
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("token");
expect((cliProfile as { token: string }).token).toBe("access-only-token");
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,56 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("updates codex-cli profile when Codex CLI refresh token changes", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "same-access",
refresh_token: "new-refresh",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh).toBe(
"new-refresh",
);
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,103 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("upgrades token to oauth when Claude Code CLI gets refreshToken", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-upgrade-"));
try {
await withTempHome(
async (tempHome) => {
// Create Claude Code CLI credentials with refreshToken
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "new-oauth-access",
refreshToken: "new-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000,
},
}),
);
// Create auth-profiles.json with existing token type credential
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "old-token",
expires: Date.now() + 30 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// Should upgrade from token to oauth
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe("new-oauth-access");
expect((cliProfile as { refresh: string }).refresh).toBe("new-refresh-token");
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-sync-"));
try {
await withTempHome(
async (tempHome) => {
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"codex-access-token",
);
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,131 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore, markAuthProfileFailure } from "./auth-profiles.js";
describe("markAuthProfileFailure", () => {
it("disables billing failures for ~5 hours by default", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
const startedAt = Date.now();
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "billing",
agentDir,
});
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(5.5 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("honors per-provider billing backoff overrides", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
const startedAt = Date.now();
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "billing",
agentDir,
cfg: {
auth: {
cooldowns: {
billingBackoffHoursByProvider: { Anthropic: 1 },
billingMaxHours: 2,
},
},
} as never,
});
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(1.2 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const now = Date.now();
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
usageStats: {
"anthropic:default": {
errorCount: 9,
failureCounts: { billing: 3 },
lastFailureAt: now - 48 * 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "billing",
agentDir,
cfg: {
auth: { cooldowns: { failureWindowHours: 24 } },
} as never,
});
expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(1);
expect(store.usageStats?.["anthropic:default"]?.failureCounts?.billing).toBe(1);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,234 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./auth-profiles.js";
describe("resolveAuthProfileOrder", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
};
const cfg = {
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
},
};
it("does not prioritize lastGood over round-robin ordering", () => {
const order = resolveAuthProfileOrder({
cfg,
store: {
...store,
lastGood: { anthropic: "anthropic:work" },
usageStats: {
"anthropic:default": { lastUsed: 100 },
"anthropic:work": { lastUsed: 200 },
},
},
provider: "anthropic",
});
expect(order[0]).toBe("anthropic:default");
});
it("uses explicit profiles when order is missing", () => {
const order = resolveAuthProfileOrder({
cfg,
store,
provider: "anthropic",
});
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
});
it("uses configured order when provided", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:work", "anthropic:default"] },
profiles: cfg.auth.profiles,
},
},
store,
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("prefers store order over config order", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
order: { anthropic: ["anthropic:work", "anthropic:default"] },
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes cooldown profiles to the end even with store order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
store: {
...store,
order: { anthropic: ["anthropic:default", "anthropic:work"] },
usageStats: {
"anthropic:default": { cooldownUntil: now + 60_000 },
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes cooldown profiles to the end even with configured order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
usageStats: {
"anthropic:default": { cooldownUntil: now + 60_000 },
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes disabled profiles to the end even with store order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
store: {
...store,
order: { anthropic: ["anthropic:default", "anthropic:work"] },
usageStats: {
"anthropic:default": {
disabledUntil: now + 60_000,
disabledReason: "billing",
},
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes disabled profiles to the end even with configured order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
usageStats: {
"anthropic:default": {
disabledUntil: now + 60_000,
disabledReason: "billing",
},
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("mode: oauth config accepts both oauth and token credentials (issue #559)", () => {
const now = Date.now();
const storeWithBothTypes: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:oauth-cred": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: now + 60_000,
},
"anthropic:token-cred": {
type: "token",
provider: "anthropic",
token: "just-a-token",
expires: now + 60_000,
},
},
};
const orderOauthCred = resolveAuthProfileOrder({
store: storeWithBothTypes,
provider: "anthropic",
cfg: {
auth: {
profiles: {
"anthropic:oauth-cred": { provider: "anthropic", mode: "oauth" },
},
},
},
});
expect(orderOauthCred).toContain("anthropic:oauth-cred");
const orderTokenCred = resolveAuthProfileOrder({
store: storeWithBothTypes,
provider: "anthropic",
cfg: {
auth: {
profiles: {
"anthropic:token-cred": { provider: "anthropic", mode: "oauth" },
},
},
},
});
expect(orderTokenCred).toContain("anthropic:token-cred");
});
it("mode: token config rejects oauth credentials (issue #559 root cause)", () => {
const now = Date.now();
const storeWithOauth: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:oauth-cred": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: now + 60_000,
},
},
};
const order = resolveAuthProfileOrder({
store: storeWithOauth,
provider: "anthropic",
cfg: {
auth: {
profiles: {
"anthropic:oauth-cred": { provider: "anthropic", mode: "token" },
},
},
},
});
expect(order).not.toContain("anthropic:oauth-cred");
});
});
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./auth-profiles.js";
describe("resolveAuthProfileOrder", () => {
const _store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
};
const _cfg = {
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
},
};
it("normalizes z.ai aliases in auth.order", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { "z.ai": ["zai:work", "zai:default"] },
profiles: {
"zai:default": { provider: "zai", mode: "api_key" },
"zai:work": { provider: "zai", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"zai:default": {
type: "api_key",
provider: "zai",
key: "sk-default",
},
"zai:work": {
type: "api_key",
provider: "zai",
key: "sk-work",
},
},
},
provider: "zai",
});
expect(order).toEqual(["zai:work", "zai:default"]);
});
it("normalizes provider casing in auth.order keys", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { OpenAI: ["openai:work", "openai:default"] },
profiles: {
"openai:default": { provider: "openai", mode: "api_key" },
"openai:work": { provider: "openai", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-default",
},
"openai:work": {
type: "api_key",
provider: "openai",
key: "sk-work",
},
},
},
provider: "openai",
});
expect(order).toEqual(["openai:work", "openai:default"]);
});
it("normalizes z.ai aliases in auth.profiles", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
profiles: {
"zai:default": { provider: "z.ai", mode: "api_key" },
"zai:work": { provider: "Z.AI", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"zai:default": {
type: "api_key",
provider: "zai",
key: "sk-default",
},
"zai:work": {
type: "api_key",
provider: "zai",
key: "sk-work",
},
},
},
provider: "zai",
});
expect(order).toEqual(["zai:default", "zai:work"]);
});
it("prioritizes oauth profiles when order missing", () => {
const mixedStore: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:oauth": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
};
const order = resolveAuthProfileOrder({
store: mixedStore,
provider: "anthropic",
});
expect(order).toEqual(["anthropic:oauth", "anthropic:default"]);
});
});
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./auth-profiles.js";
describe("resolveAuthProfileOrder", () => {
const _store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
};
const _cfg = {
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
},
};
it("orders by lastUsed when no explicit order exists", () => {
const order = resolveAuthProfileOrder({
store: {
version: 1,
profiles: {
"anthropic:a": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
"anthropic:b": {
type: "api_key",
provider: "anthropic",
key: "sk-b",
},
"anthropic:c": {
type: "api_key",
provider: "anthropic",
key: "sk-c",
},
},
usageStats: {
"anthropic:a": { lastUsed: 200 },
"anthropic:b": { lastUsed: 100 },
"anthropic:c": { lastUsed: 300 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:a", "anthropic:b", "anthropic:c"]);
});
it("pushes cooldown profiles to the end, ordered by cooldown expiry", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
store: {
version: 1,
profiles: {
"anthropic:ready": {
type: "api_key",
provider: "anthropic",
key: "sk-ready",
},
"anthropic:cool1": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: now + 60_000,
},
"anthropic:cool2": {
type: "api_key",
provider: "anthropic",
key: "sk-cool",
},
},
usageStats: {
"anthropic:ready": { lastUsed: 50 },
"anthropic:cool1": { cooldownUntil: now + 5_000 },
"anthropic:cool2": { cooldownUntil: now + 1_000 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:ready", "anthropic:cool2", "anthropic:cool1"]);
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./auth-profiles.js";
describe("resolveAuthProfileOrder", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
};
const cfg = {
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
},
};
it("uses stored profiles when no config exists", () => {
const order = resolveAuthProfileOrder({
store,
provider: "anthropic",
});
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
});
it("prioritizes preferred profiles", () => {
const order = resolveAuthProfileOrder({
cfg,
store,
provider: "anthropic",
preferredProfile: "anthropic:work",
});
expect(order[0]).toBe("anthropic:work");
expect(order).toContain("anthropic:default");
});
it("drops explicit order entries that are missing from the store", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default", "minimax:prod"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:prod": {
type: "api_key",
provider: "minimax",
key: "sk-prod",
},
},
},
provider: "minimax",
});
expect(order).toEqual(["minimax:prod"]);
});
it("drops explicit order entries that belong to another provider", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["openai:default", "minimax:prod"],
},
},
},
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai",
},
"minimax:prod": {
type: "api_key",
provider: "minimax",
key: "sk-mini",
},
},
},
provider: "minimax",
});
expect(order).toEqual(["minimax:prod"]);
});
it("drops token profiles with empty credentials", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:default": {
type: "token",
provider: "minimax",
token: " ",
},
},
},
provider: "minimax",
});
expect(order).toEqual([]);
});
it("drops token profiles that are already expired", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:default": {
type: "token",
provider: "minimax",
token: "sk-minimax",
expires: Date.now() - 1000,
},
},
},
provider: "minimax",
});
expect(order).toEqual([]);
});
it("keeps oauth profiles that can refresh", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
anthropic: ["anthropic:oauth"],
},
},
},
store: {
version: 1,
profiles: {
"anthropic:oauth": {
type: "oauth",
provider: "anthropic",
access: "",
refresh: "refresh-token",
expires: Date.now() - 1000,
},
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:oauth"]);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
import { createSubsystemLogger } from "../../logging.js";
export const AUTH_STORE_VERSION = 1;
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
export const LEGACY_AUTH_FILENAME = "auth.json";
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
export const AUTH_STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10_000,
randomize: true,
},
stale: 30_000,
} as const;
export const EXTERNAL_CLI_SYNC_TTL_MS = 15 * 60 * 1000;
export const EXTERNAL_CLI_NEAR_EXPIRY_MS = 10 * 60 * 1000;
export const log = createSubsystemLogger("agents/auth-profiles");
+17
View File
@@ -0,0 +1,17 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { AuthProfileStore } from "./types.js";
export function resolveAuthProfileDisplayLabel(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
profileId: string;
}): string {
const { cfg, store, profileId } = params;
const profile = store.profiles[profileId];
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
const email =
configEmail ||
(profile && "email" in profile ? (profile.email as string | undefined)?.trim() : undefined);
if (email) return `${profileId} (${email})`;
return profileId;
}
+42
View File
@@ -0,0 +1,42 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeProviderId } from "../model-selection.js";
import { listProfilesForProvider } from "./profiles.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import type { AuthProfileStore } from "./types.js";
export function formatAuthDoctorHint(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
provider: string;
profileId?: string;
}): string {
const providerKey = normalizeProviderId(params.provider);
if (providerKey !== "anthropic") return "";
const legacyProfileId = params.profileId ?? "anthropic:default";
const suggested = suggestOAuthProfileIdForLegacyDefault({
cfg: params.cfg,
store: params.store,
provider: providerKey,
legacyProfileId,
});
if (!suggested || suggested === legacyProfileId) return "";
const storeOauthProfiles = listProfilesForProvider(params.store, providerKey)
.filter((id) => params.store.profiles[id]?.type === "oauth")
.join(", ");
const cfgMode = params.cfg?.auth?.profiles?.[legacyProfileId]?.mode;
const cfgProvider = params.cfg?.auth?.profiles?.[legacyProfileId]?.provider;
return [
"Doctor hint (for GitHub issue):",
`- provider: ${providerKey}`,
`- config: ${legacyProfileId}${
cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
}`,
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
`- suggested profile: ${suggested}`,
'Fix: run "clawdbot doctor --yes"',
].join("\n");
}
@@ -0,0 +1,169 @@
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
} from "../cli-credentials.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
EXTERNAL_CLI_NEAR_EXPIRY_MS,
EXTERNAL_CLI_SYNC_TTL_MS,
log,
} from "./constants.js";
import type {
AuthProfileCredential,
AuthProfileStore,
OAuthCredential,
TokenCredential,
} from "./types.js";
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) return false;
if (a.type !== "oauth") return false;
return (
a.provider === b.provider &&
a.access === b.access &&
a.refresh === b.refresh &&
a.expires === b.expires &&
a.email === b.email &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId
);
}
function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
a.provider === b.provider &&
a.token === b.token &&
a.expires === b.expires &&
a.email === b.email
);
}
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
if (!cred) return false;
if (cred.type !== "oauth" && cred.type !== "token") return false;
if (cred.provider !== "anthropic" && cred.provider !== "openai-codex") {
return false;
}
if (typeof cred.expires !== "number") return true;
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
}
/**
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
* separate authentication, and keeps credentials in sync when CLI tools refresh tokens.
*
* Returns true if any credentials were updated.
*/
export function syncExternalCliCredentials(
store: AuthProfileStore,
options?: { allowKeychainPrompt?: boolean },
): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude Code CLI (supports both OAuth and Token credentials)
const existingClaude = store.profiles[CLAUDE_CLI_PROFILE_ID];
const shouldSyncClaude =
!existingClaude ||
existingClaude.provider !== "anthropic" ||
existingClaude.type === "token" ||
!isExternalProfileFresh(existingClaude, now);
const claudeCreds = shouldSyncClaude
? readClaudeCliCredentialsCached({
allowKeychainPrompt: options?.allowKeychainPrompt,
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
})
: null;
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const claudeCredsExpires = claudeCreds.expires ?? 0;
// Determine if we should update based on credential comparison
let shouldUpdate = false;
let isEqual = false;
if (claudeCreds.type === "oauth") {
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
isEqual = shallowEqualOAuthCredentials(existingOAuth, claudeCreds);
// Update if: no existing profile, type changed to oauth, expired, or CLI has newer token
shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCredsExpires > now && claudeCredsExpires > existingOAuth.expires);
} else {
const existingToken = existing?.type === "token" ? existing : undefined;
isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds);
// Update if: no existing profile, expired, or CLI has newer token
shouldUpdate =
!existingToken ||
existingToken.provider !== "anthropic" ||
(existingToken.expires ?? 0) <= now ||
(claudeCredsExpires > now && claudeCredsExpires > (existingToken.expires ?? 0));
}
// Also update if credential type changed (token -> oauth upgrade)
if (existing && existing.type !== claudeCreds.type) {
// Prefer oauth over token (enables auto-refresh)
if (claudeCreds.type === "oauth") {
shouldUpdate = true;
isEqual = false;
}
}
// Avoid downgrading from oauth to token-only credentials.
if (existing?.type === "oauth" && claudeCreds.type === "token") {
shouldUpdate = false;
}
if (shouldUpdate && !isEqual) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
type: claudeCreds.type,
expires:
typeof claudeCreds.expires === "number"
? new Date(claudeCreds.expires).toISOString()
: "unknown",
});
}
}
// Sync from Codex CLI
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const shouldSyncCodex =
!existingCodex ||
existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null;
if (codexCreds) {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(),
});
}
}
return mutated;
}
+213
View File
@@ -0,0 +1,213 @@
import { getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { writeClaudeCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
import type { AuthProfileStore } from "./types.js";
function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
return needsProjectId
? JSON.stringify({
token: credentials.access,
projectId: credentials.projectId,
})
: credentials.access;
}
async function refreshOAuthTokenWithLock(params: {
profileId: string;
agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const authPath = resolveAuthStorePath(params.agentDir);
ensureAuthStoreFile(authPath);
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(authPath, {
...AUTH_STORE_LOCK_OPTIONS,
});
const store = ensureAuthProfileStore(params.agentDir);
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") return null;
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
newCredentials: cred,
};
}
const oauthCreds: Record<string, OAuthCredentials> = {
[cred.provider]: cred,
};
const result =
String(cred.provider) === "chutes"
? await (async () => {
const newCredentials = await refreshChutesTokens({
credential: cred,
});
return { apiKey: newCredentials.access, newCredentials };
})()
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
if (!result) return null;
store.profiles[params.profileId] = {
...cred,
...result.newCredentials,
type: "oauth",
};
saveAuthProfileStore(store, params.agentDir);
// Sync refreshed credentials back to Claude Code CLI if this is the claude-cli profile
// This ensures Claude Code continues to work after ClawdBot refreshes the token
if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") {
writeClaudeCliCredentials(result.newCredentials);
}
return result;
} finally {
if (release) {
try {
await release();
} catch {
// ignore unlock errors
}
}
}
}
async function tryResolveOAuthProfile(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
profileId: string;
agentDir?: string;
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
const { cfg, store, profileId } = params;
const cred = store.profiles[profileId];
if (!cred || cred.type !== "oauth") return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
const refreshed = await refreshOAuthTokenWithLock({
profileId,
agentDir: params.agentDir,
});
if (!refreshed) return null;
return {
apiKey: refreshed.apiKey,
provider: cred.provider,
email: cred.email,
};
}
export async function resolveApiKeyForProfile(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
profileId: string;
agentDir?: string;
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
const { cfg, store, profileId } = params;
const cred = store.profiles[profileId];
if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) {
// Compatibility: treat "oauth" config as compatible with stored token profiles.
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
}
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
if (cred.type === "token") {
const token = cred.token?.trim();
if (!token) return null;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
Date.now() >= cred.expires
) {
return null;
}
return { apiKey: token, provider: cred.provider, email: cred.email };
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
try {
const result = await refreshOAuthTokenWithLock({
profileId,
agentDir: params.agentDir,
});
if (!result) return null;
return {
apiKey: result.apiKey,
provider: cred.provider,
email: cred.email,
};
} catch (error) {
const refreshedStore = ensureAuthProfileStore(params.agentDir);
const refreshed = refreshedStore.profiles[profileId];
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
return {
apiKey: buildOAuthApiKey(refreshed.provider, refreshed),
provider: refreshed.provider,
email: refreshed.email ?? cred.email,
};
}
const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({
cfg,
store: refreshedStore,
provider: cred.provider,
legacyProfileId: profileId,
});
if (fallbackProfileId && fallbackProfileId !== profileId) {
try {
const fallbackResolved = await tryResolveOAuthProfile({
cfg,
store: refreshedStore,
profileId: fallbackProfileId,
agentDir: params.agentDir,
});
if (fallbackResolved) return fallbackResolved;
} catch {
// keep original error
}
}
const message = error instanceof Error ? error.message : String(error);
const hint = formatAuthDoctorHint({
cfg,
store: refreshedStore,
provider: cred.provider,
profileId,
});
throw new Error(
`OAuth token refresh failed for ${cred.provider}: ${message}. ` +
"Please try again or re-authenticate." +
(hint ? `\n\n${hint}` : ""),
);
}
}
+184
View File
@@ -0,0 +1,184 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeProviderId } from "../model-selection.js";
import { listProfilesForProvider } from "./profiles.js";
import type { AuthProfileStore } from "./types.js";
import { isProfileInCooldown } from "./usage.js";
function resolveProfileUnusableUntil(stats: {
cooldownUntil?: number;
disabledUntil?: number;
}): number | null {
const values = [stats.cooldownUntil, stats.disabledUntil]
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value) && value > 0);
if (values.length === 0) return null;
return Math.max(...values);
}
export function resolveAuthProfileOrder(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
provider: string;
preferredProfile?: string;
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const now = Date.now();
const storedOrder = (() => {
const order = store.order;
if (!order) return undefined;
for (const [key, value] of Object.entries(order)) {
if (normalizeProviderId(key) === providerKey) return value;
}
return undefined;
})();
const configuredOrder = (() => {
const order = cfg?.auth?.order;
if (!order) return undefined;
for (const [key, value] of Object.entries(order)) {
if (normalizeProviderId(key) === providerKey) return value;
}
return undefined;
})();
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
.map(([profileId]) => profileId)
: [];
const baseOrder =
explicitOrder ??
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
if (baseOrder.length === 0) return [];
const filtered = baseOrder.filter((profileId) => {
const cred = store.profiles[profileId];
if (!cred) return false;
if (normalizeProviderId(cred.provider) !== providerKey) return false;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig) {
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
return false;
}
if (profileConfig.mode !== cred.type) {
const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
if (!oauthCompatible) return false;
}
}
if (cred.type === "api_key") return Boolean(cred.key?.trim());
if (cred.type === "token") {
if (!cred.token?.trim()) return false;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
now >= cred.expires
) {
return false;
}
return true;
}
if (cred.type === "oauth") {
return Boolean(cred.access?.trim() || cred.refresh?.trim());
}
return false;
});
const deduped: string[] = [];
for (const entry of filtered) {
if (!deduped.includes(entry)) deduped.push(entry);
}
// If user specified explicit order (store override or config), respect it
// exactly, but still apply cooldown sorting to avoid repeatedly selecting
// known-bad/rate-limited keys as the first candidate.
if (explicitOrder && explicitOrder.length > 0) {
// ...but still respect cooldown tracking to avoid repeatedly selecting a
// known-bad/rate-limited key as the first candidate.
const available: string[] = [];
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
for (const profileId of deduped) {
const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
if (
typeof cooldownUntil === "number" &&
Number.isFinite(cooldownUntil) &&
cooldownUntil > 0 &&
now < cooldownUntil
) {
inCooldown.push({ profileId, cooldownUntil });
} else {
available.push(profileId);
}
}
const cooldownSorted = inCooldown
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
.map((entry) => entry.profileId);
const ordered = [...available, ...cooldownSorted];
// Still put preferredProfile first if specified
if (preferredProfile && ordered.includes(preferredProfile)) {
return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)];
}
return ordered;
}
// Otherwise, use round-robin: sort by lastUsed (oldest first)
// preferredProfile goes first if specified (for explicit user choice)
// lastGood is NOT prioritized - that would defeat round-robin
const sorted = orderProfilesByMode(deduped, store);
if (preferredProfile && sorted.includes(preferredProfile)) {
return [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)];
}
return sorted;
}
function orderProfilesByMode(order: string[], store: AuthProfileStore): string[] {
const now = Date.now();
// Partition into available and in-cooldown
const available: string[] = [];
const inCooldown: string[] = [];
for (const profileId of order) {
if (isProfileInCooldown(store, profileId)) {
inCooldown.push(profileId);
} else {
available.push(profileId);
}
}
// Sort available profiles by lastUsed (oldest first = round-robin)
// Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
const typeScore = type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed };
});
// Primary sort: type preference (oauth > token > api_key).
// Secondary sort: lastUsed (oldest first for round-robin within type).
const sorted = scored
.sort((a, b) => {
// First by type (oauth > token > api_key)
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
// Then by lastUsed (oldest first)
return a.lastUsed - b.lastUsed;
})
.map((entry) => entry.profileId);
// Append cooldown profiles at the end (sorted by cooldown expiry, soonest first)
const cooldownSorted = inCooldown
.map((profileId) => ({
profileId,
cooldownUntil: resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
}))
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
.map((entry) => entry.profileId);
return [...sorted, ...cooldownSorted];
}
+32
View File
@@ -0,0 +1,32 @@
import fs from "node:fs";
import path from "node:path";
import { saveJsonFile } from "../../infra/json-file.js";
import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
import type { AuthProfileStore } from "./types.js";
export function resolveAuthStorePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
return path.join(resolved, AUTH_PROFILE_FILENAME);
}
export function resolveLegacyAuthStorePath(agentDir?: string): string {
const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
return path.join(resolved, LEGACY_AUTH_FILENAME);
}
export function resolveAuthStorePathForDisplay(agentDir?: string): string {
const pathname = resolveAuthStorePath(agentDir);
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
export function ensureAuthStoreFile(pathname: string) {
if (fs.existsSync(pathname)) return;
const payload: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
saveJsonFile(pathname, payload);
}
+84
View File
@@ -0,0 +1,84 @@
import { normalizeProviderId } from "../model-selection.js";
import {
ensureAuthProfileStore,
saveAuthProfileStore,
updateAuthProfileStoreWithLock,
} from "./store.js";
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
export async function setAuthProfileOrder(params: {
agentDir?: string;
provider: string;
order?: string[] | null;
}): Promise<AuthProfileStore | null> {
const providerKey = normalizeProviderId(params.provider);
const sanitized =
params.order && Array.isArray(params.order)
? params.order.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const deduped: string[] = [];
for (const entry of sanitized) {
if (!deduped.includes(entry)) deduped.push(entry);
}
return await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
updater: (store) => {
store.order = store.order ?? {};
if (deduped.length === 0) {
if (!store.order[providerKey]) return false;
delete store.order[providerKey];
if (Object.keys(store.order).length === 0) {
store.order = undefined;
}
return true;
}
store.order[providerKey] = deduped;
return true;
},
});
}
export function upsertAuthProfile(params: {
profileId: string;
credential: AuthProfileCredential;
agentDir?: string;
}): void {
const store = ensureAuthProfileStore(params.agentDir);
store.profiles[params.profileId] = params.credential;
saveAuthProfileStore(store, params.agentDir);
}
export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
const providerKey = normalizeProviderId(provider);
return Object.entries(store.profiles)
.filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey)
.map(([id]) => id);
}
export async function markAuthProfileGood(params: {
store: AuthProfileStore;
provider: string;
profileId: string;
agentDir?: string;
}): Promise<void> {
const { store, provider, profileId, agentDir } = params;
const updated = await updateAuthProfileStoreWithLock({
agentDir,
updater: (freshStore) => {
const profile = freshStore.profiles[profileId];
if (!profile || profile.provider !== provider) return false;
freshStore.lastGood = { ...freshStore.lastGood, [provider]: profileId };
return true;
},
});
if (updated) {
store.lastGood = updated.lastGood;
return;
}
const profile = store.profiles[profileId];
if (!profile || profile.provider !== provider) return;
store.lastGood = { ...store.lastGood, [provider]: profileId };
saveAuthProfileStore(store, agentDir);
}
+144
View File
@@ -0,0 +1,144 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { AuthProfileConfig } from "../../config/types.js";
import { normalizeProviderId } from "../model-selection.js";
import { listProfilesForProvider } from "./profiles.js";
import type { AuthProfileIdRepairResult, AuthProfileStore } from "./types.js";
function getProfileSuffix(profileId: string): string {
const idx = profileId.indexOf(":");
if (idx < 0) return "";
return profileId.slice(idx + 1);
}
function isEmailLike(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
return trimmed.includes("@") && trimmed.includes(".");
}
export function suggestOAuthProfileIdForLegacyDefault(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
provider: string;
legacyProfileId: string;
}): string | null {
const providerKey = normalizeProviderId(params.provider);
const legacySuffix = getProfileSuffix(params.legacyProfileId);
if (legacySuffix !== "default") return null;
const legacyCfg = params.cfg?.auth?.profiles?.[params.legacyProfileId];
if (
legacyCfg &&
normalizeProviderId(legacyCfg.provider) === providerKey &&
legacyCfg.mode !== "oauth"
) {
return null;
}
const oauthProfiles = listProfilesForProvider(params.store, providerKey).filter(
(id) => params.store.profiles[id]?.type === "oauth",
);
if (oauthProfiles.length === 0) return null;
const configuredEmail = legacyCfg?.email?.trim();
if (configuredEmail) {
const byEmail = oauthProfiles.find((id) => {
const cred = params.store.profiles[id];
if (!cred || cred.type !== "oauth") return false;
const email = (cred.email as string | undefined)?.trim();
return email === configuredEmail || id === `${providerKey}:${configuredEmail}`;
});
if (byEmail) return byEmail;
}
const lastGood = params.store.lastGood?.[providerKey] ?? params.store.lastGood?.[params.provider];
if (lastGood && oauthProfiles.includes(lastGood)) return lastGood;
const nonLegacy = oauthProfiles.filter((id) => id !== params.legacyProfileId);
if (nonLegacy.length === 1) return nonLegacy[0] ?? null;
const emailLike = nonLegacy.filter((id) => isEmailLike(getProfileSuffix(id)));
if (emailLike.length === 1) return emailLike[0] ?? null;
return null;
}
export function repairOAuthProfileIdMismatch(params: {
cfg: ClawdbotConfig;
store: AuthProfileStore;
provider: string;
legacyProfileId?: string;
}): AuthProfileIdRepairResult {
const legacyProfileId =
params.legacyProfileId ?? `${normalizeProviderId(params.provider)}:default`;
const legacyCfg = params.cfg.auth?.profiles?.[legacyProfileId];
if (!legacyCfg) {
return { config: params.cfg, changes: [], migrated: false };
}
if (legacyCfg.mode !== "oauth") {
return { config: params.cfg, changes: [], migrated: false };
}
if (normalizeProviderId(legacyCfg.provider) !== normalizeProviderId(params.provider)) {
return { config: params.cfg, changes: [], migrated: false };
}
const toProfileId = suggestOAuthProfileIdForLegacyDefault({
cfg: params.cfg,
store: params.store,
provider: params.provider,
legacyProfileId,
});
if (!toProfileId || toProfileId === legacyProfileId) {
return { config: params.cfg, changes: [], migrated: false };
}
const toCred = params.store.profiles[toProfileId];
const toEmail =
toCred?.type === "oauth" ? (toCred.email as string | undefined)?.trim() : undefined;
const nextProfiles = {
...(params.cfg.auth?.profiles as Record<string, AuthProfileConfig> | undefined),
} as Record<string, AuthProfileConfig>;
delete nextProfiles[legacyProfileId];
nextProfiles[toProfileId] = {
...legacyCfg,
...(toEmail ? { email: toEmail } : {}),
};
const providerKey = normalizeProviderId(params.provider);
const nextOrder = (() => {
const order = params.cfg.auth?.order;
if (!order) return undefined;
const resolvedKey = Object.keys(order).find((key) => normalizeProviderId(key) === providerKey);
if (!resolvedKey) return order;
const existing = order[resolvedKey];
if (!Array.isArray(existing)) return order;
const replaced = existing
.map((id) => (id === legacyProfileId ? toProfileId : id))
.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
const deduped: string[] = [];
for (const entry of replaced) {
if (!deduped.includes(entry)) deduped.push(entry);
}
return { ...order, [resolvedKey]: deduped };
})();
const nextCfg: ClawdbotConfig = {
...params.cfg,
auth: {
...params.cfg.auth,
profiles: nextProfiles,
...(nextOrder ? { order: nextOrder } : {}),
},
};
const changes = [`Auth: migrate ${legacyProfileId} → ${toProfileId} (OAuth profile id)`];
return {
config: nextCfg,
changes,
migrated: true,
fromProfileId: legacyProfileId,
toProfileId,
};
}
+287
View File
@@ -0,0 +1,287 @@
import fs from "node:fs";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import { syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
type LegacyAuthStore = Record<string, AuthProfileCredential>;
function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStore): void {
target.version = source.version;
target.profiles = source.profiles;
target.order = source.order;
target.lastGood = source.lastGood;
target.usageStats = source.usageStats;
}
export async function updateAuthProfileStoreWithLock(params: {
agentDir?: string;
updater: (store: AuthProfileStore) => boolean;
}): Promise<AuthProfileStore | null> {
const authPath = resolveAuthStorePath(params.agentDir);
ensureAuthStoreFile(authPath);
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(authPath, AUTH_STORE_LOCK_OPTIONS);
const store = ensureAuthProfileStore(params.agentDir);
const shouldSave = params.updater(store);
if (shouldSave) {
saveAuthProfileStore(store, params.agentDir);
}
return store;
} catch {
return null;
} finally {
if (release) {
try {
await release();
} catch {
// ignore unlock errors
}
}
}
}
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
if ("profiles" in record) return null;
const entries: LegacyAuthStore = {};
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
continue;
}
entries[key] = {
...typed,
provider: String(typed.provider ?? key),
} as AuthProfileCredential;
}
return Object.keys(entries).length > 0 ? entries : null;
}
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
if (!record.profiles || typeof record.profiles !== "object") return null;
const profiles = record.profiles as Record<string, unknown>;
const normalized: Record<string, AuthProfileCredential> = {};
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
continue;
}
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
const order =
record.order && typeof record.order === "object"
? Object.entries(record.order as Record<string, unknown>).reduce(
(acc, [provider, value]) => {
if (!Array.isArray(value)) return acc;
const list = value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (list.length === 0) return acc;
acc[provider] = list;
return acc;
},
{} as Record<string, string[]>,
)
: undefined;
return {
version: Number(record.version ?? AUTH_STORE_VERSION),
profiles: normalized,
order,
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
: undefined,
usageStats:
record.usageStats && typeof record.usageStats === "object"
? (record.usageStats as Record<string, ProfileUsageStats>)
: undefined,
};
}
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath);
if (!oauthRaw || typeof oauthRaw !== "object") return false;
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
let mutated = false;
for (const [provider, creds] of Object.entries(oauthEntries)) {
if (!creds || typeof creds !== "object") continue;
const profileId = `${provider}:default`;
if (store.profiles[profileId]) continue;
store.profiles[profileId] = {
type: "oauth",
provider,
...creds,
};
mutated = true;
}
return mutated;
}
export function loadAuthProfileStore(): AuthProfileStore {
const authPath = resolveAuthStorePath();
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
}
return asStore;
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
const legacy = coerceLegacyStore(legacyRaw);
if (legacy) {
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
for (const [provider, cred] of Object.entries(legacy)) {
const profileId = `${provider}:default`;
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: String(cred.provider ?? provider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
provider: String(cred.provider ?? provider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
...(cred.projectId ? { projectId: cred.projectId } : {}),
...(cred.accountId ? { accountId: cred.accountId } : {}),
...(cred.email ? { email: cred.email } : {}),
};
}
}
syncExternalCliCredentials(store);
return store;
}
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
syncExternalCliCredentials(store);
return store;
}
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore, options);
if (synced) {
saveJsonFile(authPath, asStore);
}
return asStore;
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw);
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
if (legacy) {
for (const [provider, cred] of Object.entries(legacy)) {
const profileId = `${provider}:default`;
if (cred.type === "api_key") {
store.profiles[profileId] = {
type: "api_key",
provider: String(cred.provider ?? provider),
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
provider: String(cred.provider ?? provider),
access: cred.access,
refresh: cred.refresh,
expires: cred.expires,
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
...(cred.projectId ? { projectId: cred.projectId } : {}),
...(cred.accountId ? { accountId: cred.accountId } : {}),
...(cred.email ? { email: cred.email } : {}),
};
}
}
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store, options);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
}
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
// overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
// after we've successfully written auth-profiles.json.
if (shouldWrite && legacy !== null) {
const legacyPath = resolveLegacyAuthStorePath(agentDir);
try {
fs.unlinkSync(legacyPath);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
log.warn("failed to delete legacy auth.json after migration", {
err,
legacyPath,
});
}
}
}
return store;
}
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
const authPath = resolveAuthStorePath(agentDir);
const payload = {
version: AUTH_STORE_VERSION,
profiles: store.profiles,
order: store.order ?? undefined,
lastGood: store.lastGood ?? undefined,
usageStats: store.usageStats ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
}
Loaded 100 of 1819 files, more files were not shown because too many files have changed in this diff. Show more