Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfea2991c9 | ||
|
|
d51a9ebb0e | ||
|
|
536d3d76a3 | ||
|
|
5c52dbf661 | ||
|
|
8f797f213e | ||
|
|
ed68f378d7 | ||
|
|
7e8de907f8 | ||
|
|
f3c9252840 | ||
|
|
da9e27f466 | ||
|
|
aa74e28112 | ||
|
|
e569f15631 | ||
|
|
eaace34233 | ||
|
|
7a839e7eb6 | ||
|
|
1b24b6a02b | ||
|
|
2b4a68e276 | ||
|
|
b1e3d79eaa | ||
|
|
2fb2035dbf | ||
|
|
3c51290e0d | ||
|
|
765196d5c3 | ||
|
|
12c2d37e62 |
No files matched your search
@@ -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"
|
||||
@@ -141,6 +141,31 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
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:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
defaults:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -6,10 +6,19 @@
|
||||
- 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
|
||||
|
||||
|
||||
@@ -300,6 +300,7 @@ ack reaction after the bot replies.
|
||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||
- `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).
|
||||
- `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).
|
||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||
- `reactions` (covers react + read reactions)
|
||||
|
||||
@@ -189,6 +189,7 @@ Provider options:
|
||||
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: 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.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.includeAttachments`: ingest attachments into context.
|
||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
@@ -208,6 +208,7 @@ This is often easier than hand-editing JSON manifests.
|
||||
## History context
|
||||
- `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).
|
||||
- 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)
|
||||
These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed.
|
||||
|
||||
@@ -124,6 +124,7 @@ Provider options:
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `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.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ Or via config:
|
||||
## History context
|
||||
- `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).
|
||||
- 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`.
|
||||
|
||||
@@ -105,6 +105,7 @@ group messages, so use admin if you need full visibility.
|
||||
- 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).
|
||||
- DM history can be limited with `channels.telegram.dmHistoryLimit` (user turns). Per-user overrides: `channels.telegram.dms["<user_id>"].historyLimit`.
|
||||
|
||||
## Group activation modes
|
||||
|
||||
|
||||
@@ -292,6 +292,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
- `channels.whatsapp.groupAllowFrom` (group sender allowlist).
|
||||
- `channels.whatsapp.groupPolicy` (group policy).
|
||||
- `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.actions.reactions` (gate WhatsApp tool reactions).
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
|
||||
|
||||
@@ -67,6 +67,10 @@ clawdbot doctor
|
||||
This should create (or refresh) an auth profile like `anthropic:claude-cli` in
|
||||
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:
|
||||
|
||||
```
|
||||
|
||||
@@ -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`
|
||||
|
||||
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.
|
||||
|
||||
#### 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 `[]`):
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -439,6 +439,32 @@ If your AI does something bad:
|
||||
- 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
|
||||
|
||||
```
|
||||
|
||||
@@ -31,6 +31,11 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## 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
|
||||
|
||||
If the gateway service is installed but the process exits immediately, the daemon
|
||||
|
||||
@@ -434,3 +434,7 @@ Example:
|
||||
- Container not running: it will auto-create per session on demand.
|
||||
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
|
||||
mounted workspace ownership (or chown the workspace folder).
|
||||
- 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
@@ -165,6 +165,121 @@ Plugins export either:
|
||||
- A function: `(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
|
||||
|
||||
```ts
|
||||
|
||||
@@ -59,4 +59,7 @@ clawdbot onboard --auth-choice claude-cli
|
||||
## Notes
|
||||
|
||||
- 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).
|
||||
@@ -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).
|
||||
|
||||
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)?
|
||||
|
||||
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).
|
||||
|
||||
+77
@@ -154,4 +154,81 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,12 @@ export function buildDockerExecArgs(params: {
|
||||
for (const [key, value] of Object.entries(params.env)) {
|
||||
args.push("-e", `${key}=${value}`);
|
||||
}
|
||||
args.push(params.containerName, "sh", "-lc", params.command);
|
||||
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
||||
// overriding both Docker ENV and -e PATH=... environment variables.
|
||||
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
||||
// while preserving system paths that /etc/profile may have added.
|
||||
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
|
||||
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
|
||||
import { buildDockerExecArgs } from "./bash-tools.shared.js";
|
||||
import { sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
@@ -239,3 +240,73 @@ describe("exec tool backgrounding", () => {
|
||||
expect(pollB.details.status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDockerExecArgs", () => {
|
||||
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "echo hello",
|
||||
env: {
|
||||
PATH: "/custom/bin:/usr/local/bin:/usr/bin",
|
||||
HOME: "/home/user",
|
||||
},
|
||||
tty: false,
|
||||
});
|
||||
|
||||
const commandArg = args[args.length - 1];
|
||||
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
|
||||
expect(commandArg).toContain("echo hello");
|
||||
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
|
||||
});
|
||||
|
||||
it("does not add PATH export when PATH is not in env", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "echo hello",
|
||||
env: {
|
||||
HOME: "/home/user",
|
||||
},
|
||||
tty: false,
|
||||
});
|
||||
|
||||
const commandArg = args[args.length - 1];
|
||||
expect(commandArg).toBe("echo hello");
|
||||
expect(commandArg).not.toContain("export PATH");
|
||||
});
|
||||
|
||||
it("includes workdir flag when specified", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "pwd",
|
||||
workdir: "/workspace",
|
||||
env: { HOME: "/home/user" },
|
||||
tty: false,
|
||||
});
|
||||
|
||||
expect(args).toContain("-w");
|
||||
expect(args).toContain("/workspace");
|
||||
});
|
||||
|
||||
it("uses login shell for consistent environment", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "echo test",
|
||||
env: { HOME: "/home/user" },
|
||||
tty: false,
|
||||
});
|
||||
|
||||
expect(args).toContain("sh");
|
||||
expect(args).toContain("-lc");
|
||||
});
|
||||
|
||||
it("includes tty flag when requested", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "bash",
|
||||
env: { HOME: "/home/user" },
|
||||
tty: true,
|
||||
});
|
||||
|
||||
expect(args).toContain("-t");
|
||||
});
|
||||
});
|
||||
+24
@@ -156,6 +156,30 @@ describe("createClawdbotCodingTools", () => {
|
||||
enum: ["a", "b"],
|
||||
});
|
||||
});
|
||||
it("cleans tuple items schemas", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
tuples: {
|
||||
type: "array",
|
||||
items: [
|
||||
{ type: "string", format: "uuid" },
|
||||
{ type: "number", minimum: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||
const first = items[0] as { format?: unknown } | undefined;
|
||||
const second = items[1] as { minimum?: unknown } | undefined;
|
||||
|
||||
expect(first?.format).toBeUndefined();
|
||||
expect(second?.minimum).toBeUndefined();
|
||||
});
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
|
||||
@@ -280,8 +280,16 @@ function cleanSchemaForGeminiWithDefs(
|
||||
cleanSchemaForGeminiWithDefs(v, nextDefs, refStack),
|
||||
]),
|
||||
);
|
||||
} else if (key === "items" && value && typeof value === "object") {
|
||||
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
|
||||
} else if (key === "items" && value) {
|
||||
if (Array.isArray(value)) {
|
||||
cleaned[key] = value.map((entry) =>
|
||||
cleanSchemaForGeminiWithDefs(entry, nextDefs, refStack),
|
||||
);
|
||||
} else if (typeof value === "object") {
|
||||
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
|
||||
} else {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
} else if (key === "anyOf" && Array.isArray(value)) {
|
||||
cleaned[key] =
|
||||
cleanedAnyOf ??
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
|
||||
const MESSAGE_CHANNEL_OPTIONS = CHANNEL_IDS.join("|");
|
||||
|
||||
export function buildAgentSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
defaultThinkLevel?: ThinkLevel;
|
||||
@@ -169,6 +167,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
.filter(Boolean);
|
||||
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
|
||||
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
|
||||
const messageChannelOptions = listDeliverableMessageChannels().join("|");
|
||||
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
|
||||
const skillsSection = skillsPrompt
|
||||
? [
|
||||
@@ -319,7 +318,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
"### message tool",
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${MESSAGE_CHANNEL_OPTIONS}).`,
|
||||
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
|
||||
inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: runtimeChannel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import { getChannelDock, listChannelDocks } from "../channels/dock.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../channels/registry.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NormalizedUsage } from "../../agents/usage.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { CHANNEL_IDS, normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
listDeliverableMessageChannels,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
|
||||
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
||||
...CHANNEL_IDS,
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
]);
|
||||
const getBlockChunkProviders = () =>
|
||||
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
|
||||
|
||||
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
|
||||
if (!provider) return undefined;
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
|
||||
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
|
||||
? (cleaned as TextChunkProvider)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { getChatChannelMeta, normalizeChannelId } from "../../channels/registry.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -50,7 +50,7 @@ export function buildGroupIntro(params: {
|
||||
const providerLabel = (() => {
|
||||
if (!providerKey) return "chat";
|
||||
if (isInternalMessageChannel(providerKey)) return "WebChat";
|
||||
if (providerId) return getChatChannelMeta(providerId).label;
|
||||
if (providerId) return getChannelPlugin(providerId)?.meta.label ?? providerId;
|
||||
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
|
||||
})();
|
||||
const subjectLine = subject
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { CHAT_CHANNEL_ORDER, normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { CHAT_CHANNEL_ORDER } from "../../channels/registry.js";
|
||||
import type { AgentElevatedAllowFromConfig, ClawdbotConfig } from "../../config/config.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
|
||||
+67
-4
@@ -7,6 +7,7 @@ import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveIMessageGroupRequireMention,
|
||||
@@ -21,9 +22,10 @@ import type {
|
||||
ChannelGroupAdapter,
|
||||
ChannelId,
|
||||
ChannelMentionAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelThreadingAdapter,
|
||||
} from "./plugins/types.js";
|
||||
import { CHAT_CHANNEL_ORDER } from "./registry.js";
|
||||
import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js";
|
||||
|
||||
export type ChannelDock = {
|
||||
id: ChannelId;
|
||||
@@ -75,7 +77,7 @@ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\
|
||||
// Adding a channel:
|
||||
// - add a new entry to `DOCKS`
|
||||
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
|
||||
const DOCKS: Record<ChannelId, ChannelDock> = {
|
||||
const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
capabilities: {
|
||||
@@ -311,10 +313,71 @@ const DOCKS: Record<ChannelId, ChannelDock> = {
|
||||
},
|
||||
};
|
||||
|
||||
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
|
||||
return {
|
||||
id: plugin.id,
|
||||
capabilities: plugin.capabilities,
|
||||
commands: plugin.commands,
|
||||
outbound: plugin.outbound?.textChunkLimit
|
||||
? { textChunkLimit: plugin.outbound.textChunkLimit }
|
||||
: undefined,
|
||||
streaming: plugin.streaming
|
||||
? { blockStreamingCoalesceDefaults: plugin.streaming.blockStreamingCoalesceDefaults }
|
||||
: undefined,
|
||||
elevated: plugin.elevated,
|
||||
config: plugin.config
|
||||
? {
|
||||
resolveAllowFrom: plugin.config.resolveAllowFrom,
|
||||
formatAllowFrom: plugin.config.formatAllowFrom,
|
||||
}
|
||||
: undefined,
|
||||
groups: plugin.groups,
|
||||
mentions: plugin.mentions,
|
||||
threading: plugin.threading,
|
||||
};
|
||||
}
|
||||
|
||||
function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (!registry) return [];
|
||||
const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of registry.channels) {
|
||||
const plugin = entry.plugin;
|
||||
const id = String(plugin.id).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
if (CHAT_CHANNEL_ORDER.includes(plugin.id as ChatChannelId)) continue;
|
||||
const dock = entry.dock ?? buildDockFromPlugin(plugin);
|
||||
entries.push({ id: plugin.id, dock, order: plugin.meta.order });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function listChannelDocks(): ChannelDock[] {
|
||||
return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]);
|
||||
const baseEntries = CHAT_CHANNEL_ORDER.map((id) => ({
|
||||
id,
|
||||
dock: DOCKS[id],
|
||||
order: getChatChannelMeta(id).order,
|
||||
}));
|
||||
const pluginEntries = listPluginDockEntries();
|
||||
const combined = [...baseEntries, ...pluginEntries];
|
||||
combined.sort((a, b) => {
|
||||
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
|
||||
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
|
||||
const orderA = a.order ?? (indexA === -1 ? 999 : indexA);
|
||||
const orderB = b.order ?? (indexB === -1 ? 999 : indexB);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return String(a.id).localeCompare(String(b.id));
|
||||
});
|
||||
return combined.map((entry) => entry.dock);
|
||||
}
|
||||
|
||||
export function getChannelDock(id: ChannelId): ChannelDock | undefined {
|
||||
return DOCKS[id];
|
||||
const core = DOCKS[id as ChatChannelId];
|
||||
if (core) return core;
|
||||
const registry = getActivePluginRegistry();
|
||||
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
|
||||
if (!pluginEntry) return undefined;
|
||||
return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin);
|
||||
}
|
||||
@@ -3,12 +3,10 @@ import { CHANNEL_IDS } from "../registry.js";
|
||||
import { listChannelPlugins } from "./index.js";
|
||||
|
||||
describe("channel plugin registry", () => {
|
||||
it("stays in sync with channel ids", () => {
|
||||
const pluginIds = listChannelPlugins()
|
||||
.map((plugin) => plugin.id)
|
||||
.slice()
|
||||
.sort();
|
||||
const channelIds = [...CHANNEL_IDS].slice().sort();
|
||||
expect(pluginIds).toEqual(channelIds);
|
||||
it("includes the built-in channel ids", () => {
|
||||
const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
|
||||
for (const id of CHANNEL_IDS) {
|
||||
expect(pluginIds).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { slackPlugin } from "./slack.js";
|
||||
import { telegramPlugin } from "./telegram.js";
|
||||
import type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
import { whatsappPlugin } from "./whatsapp.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
|
||||
// Channel plugins registry (runtime).
|
||||
//
|
||||
@@ -18,7 +19,7 @@ import { whatsappPlugin } from "./whatsapp.js";
|
||||
// - add `<id>Plugin` import + entry in `resolveChannels()`
|
||||
// - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …)
|
||||
// - add ids/aliases in `src/channels/registry.ts`
|
||||
function resolveChannels(): ChannelPlugin[] {
|
||||
function resolveCoreChannels(): ChannelPlugin[] {
|
||||
return [
|
||||
telegramPlugin,
|
||||
whatsappPlugin,
|
||||
@@ -30,8 +31,27 @@ function resolveChannels(): ChannelPlugin[] {
|
||||
];
|
||||
}
|
||||
|
||||
function listPluginChannels(): ChannelPlugin[] {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (!registry) return [];
|
||||
return registry.channels.map((entry) => entry.plugin);
|
||||
}
|
||||
|
||||
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
|
||||
const seen = new Set<string>();
|
||||
const resolved: ChannelPlugin[] = [];
|
||||
for (const plugin of channels) {
|
||||
const id = String(plugin.id).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
resolved.push(plugin);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function listChannelPlugins(): ChannelPlugin[] {
|
||||
return resolveChannels().sort((a, b) => {
|
||||
const combined = dedupeChannels([...resolveCoreChannels(), ...listPluginChannels()]);
|
||||
return combined.sort((a, b) => {
|
||||
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
|
||||
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
|
||||
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
|
||||
@@ -42,13 +62,24 @@ export function listChannelPlugins(): ChannelPlugin[] {
|
||||
}
|
||||
|
||||
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
|
||||
return resolveChannels().find((plugin) => plugin.id === id);
|
||||
const resolvedId = String(id).trim();
|
||||
if (!resolvedId) return undefined;
|
||||
return listChannelPlugins().find((plugin) => plugin.id === resolvedId);
|
||||
}
|
||||
|
||||
export function normalizeChannelId(raw?: string | null): ChannelId | null {
|
||||
// Channel docking: keep input normalization centralized in src/channels/registry.ts
|
||||
// so CLI/API/protocol can rely on stable aliases without plugin init side effects.
|
||||
return normalizeChatChannelId(raw);
|
||||
const normalized = normalizeChatChannelId(raw);
|
||||
if (normalized) return normalized;
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return null;
|
||||
const key = trimmed.toLowerCase();
|
||||
const plugin = listChannelPlugins().find((entry) => {
|
||||
if (entry.id.toLowerCase() === key) return true;
|
||||
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key);
|
||||
});
|
||||
return plugin?.id ?? null;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
import type { ChatChannelId } from "../registry.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
|
||||
type PluginLoader = () => Promise<ChannelPlugin>;
|
||||
|
||||
@@ -6,7 +8,7 @@ type PluginLoader = () => Promise<ChannelPlugin>;
|
||||
//
|
||||
// This avoids importing `src/channels/plugins/index.ts` (intentionally heavy)
|
||||
// from shared flows like outbound delivery / followup routing.
|
||||
const LOADERS: Record<ChannelId, PluginLoader> = {
|
||||
const LOADERS: Record<ChatChannelId, PluginLoader> = {
|
||||
telegram: async () => (await import("./telegram.js")).telegramPlugin,
|
||||
whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin,
|
||||
discord: async () => (await import("./discord.js")).discordPlugin,
|
||||
@@ -21,7 +23,13 @@ const cache = new Map<ChannelId, ChannelPlugin>();
|
||||
export async function loadChannelPlugin(id: ChannelId): Promise<ChannelPlugin | undefined> {
|
||||
const cached = cache.get(id);
|
||||
if (cached) return cached;
|
||||
const loader = LOADERS[id];
|
||||
const registry = getActivePluginRegistry();
|
||||
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
|
||||
if (pluginEntry) {
|
||||
cache.set(id, pluginEntry.plugin);
|
||||
return pluginEntry.plugin;
|
||||
}
|
||||
const loader = LOADERS[id as ChatChannelId];
|
||||
if (!loader) return undefined;
|
||||
const plugin = await loader();
|
||||
cache.set(id, plugin);
|
||||
|
||||
@@ -2,23 +2,23 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { DmPolicy } from "../../config/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import type { ChatChannelId } from "../registry.js";
|
||||
import type { ChannelId } from "./types.js";
|
||||
|
||||
export type SetupChannelsOptions = {
|
||||
allowDisable?: boolean;
|
||||
allowSignalInstall?: boolean;
|
||||
onSelection?: (selection: ChatChannelId[]) => void;
|
||||
accountIds?: Partial<Record<ChatChannelId, string>>;
|
||||
onAccountId?: (channel: ChatChannelId, accountId: string) => void;
|
||||
onSelection?: (selection: ChannelId[]) => void;
|
||||
accountIds?: Partial<Record<ChannelId, string>>;
|
||||
onAccountId?: (channel: ChannelId, accountId: string) => void;
|
||||
promptAccountIds?: boolean;
|
||||
whatsappAccountId?: string;
|
||||
promptWhatsAppAccountId?: boolean;
|
||||
onWhatsAppAccountId?: (accountId: string) => void;
|
||||
forceAllowFromChannels?: ChatChannelId[];
|
||||
forceAllowFromChannels?: ChannelId[];
|
||||
skipDmPolicyPrompt?: boolean;
|
||||
skipConfirm?: boolean;
|
||||
quickstartDefaults?: boolean;
|
||||
initialSelection?: ChatChannelId[];
|
||||
initialSelection?: ChannelId[];
|
||||
};
|
||||
|
||||
export type PromptAccountIdParams = {
|
||||
@@ -33,7 +33,7 @@ export type PromptAccountIdParams = {
|
||||
export type PromptAccountId = (params: PromptAccountIdParams) => Promise<string>;
|
||||
|
||||
export type ChannelOnboardingStatus = {
|
||||
channel: ChatChannelId;
|
||||
channel: ChannelId;
|
||||
configured: boolean;
|
||||
statusLines: string[];
|
||||
selectionHint?: string;
|
||||
@@ -43,7 +43,7 @@ export type ChannelOnboardingStatus = {
|
||||
export type ChannelOnboardingStatusContext = {
|
||||
cfg: ClawdbotConfig;
|
||||
options?: SetupChannelsOptions;
|
||||
accountOverrides: Partial<Record<ChatChannelId, string>>;
|
||||
accountOverrides: Partial<Record<ChannelId, string>>;
|
||||
};
|
||||
|
||||
export type ChannelOnboardingConfigureContext = {
|
||||
@@ -51,7 +51,7 @@ export type ChannelOnboardingConfigureContext = {
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
options?: SetupChannelsOptions;
|
||||
accountOverrides: Partial<Record<ChatChannelId, string>>;
|
||||
accountOverrides: Partial<Record<ChannelId, string>>;
|
||||
shouldPromptAccountIds: boolean;
|
||||
forceAllowFrom: boolean;
|
||||
};
|
||||
@@ -63,7 +63,7 @@ export type ChannelOnboardingResult = {
|
||||
|
||||
export type ChannelOnboardingDmPolicy = {
|
||||
label: string;
|
||||
channel: ChatChannelId;
|
||||
channel: ChannelId;
|
||||
policyKey: string;
|
||||
allowFromKey: string;
|
||||
getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
|
||||
@@ -71,7 +71,7 @@ export type ChannelOnboardingDmPolicy = {
|
||||
};
|
||||
|
||||
export type ChannelOnboardingAdapter = {
|
||||
channel: ChatChannelId;
|
||||
channel: ChannelId;
|
||||
getStatus: (ctx: ChannelOnboardingStatusContext) => Promise<ChannelOnboardingStatus>;
|
||||
configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>;
|
||||
dmPolicy?: ChannelOnboardingDmPolicy;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ChannelId, ChannelOutboundAdapter } from "../types.js";
|
||||
import type { ChatChannelId } from "../../registry.js";
|
||||
import { getActivePluginRegistry } from "../../../plugins/runtime.js";
|
||||
|
||||
type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
|
||||
|
||||
@@ -7,7 +9,7 @@ type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
|
||||
// The full channel plugins (src/channels/plugins/*.ts) pull in status,
|
||||
// onboarding, gateway monitors, etc. Outbound delivery only needs chunking +
|
||||
// send primitives, so we keep a dedicated, lightweight loader here.
|
||||
const LOADERS: Record<ChannelId, OutboundLoader> = {
|
||||
const LOADERS: Record<ChatChannelId, OutboundLoader> = {
|
||||
telegram: async () => (await import("./telegram.js")).telegramOutbound,
|
||||
whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound,
|
||||
discord: async () => (await import("./discord.js")).discordOutbound,
|
||||
@@ -24,9 +26,16 @@ export async function loadChannelOutboundAdapter(
|
||||
): Promise<ChannelOutboundAdapter | undefined> {
|
||||
const cached = cache.get(id);
|
||||
if (cached) return cached;
|
||||
const loader = LOADERS[id];
|
||||
const registry = getActivePluginRegistry();
|
||||
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
|
||||
const outbound = pluginEntry?.plugin.outbound;
|
||||
if (outbound) {
|
||||
cache.set(id, outbound);
|
||||
return outbound;
|
||||
}
|
||||
const loader = LOADERS[id as ChatChannelId];
|
||||
if (!loader) return undefined;
|
||||
const outbound = await loader();
|
||||
cache.set(id, outbound);
|
||||
return outbound;
|
||||
const loaded = await loader();
|
||||
cache.set(id, loaded);
|
||||
return loaded;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { GatewayClientMode, GatewayClientName } from "../../utils/message-c
|
||||
import type { ChatChannelId } from "../registry.js";
|
||||
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
|
||||
|
||||
export type ChannelId = ChatChannelId;
|
||||
export type ChannelId = ChatChannelId | (string & {});
|
||||
|
||||
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
|
||||
|
||||
@@ -62,6 +62,10 @@ export type ChannelMeta = {
|
||||
docsLabel?: string;
|
||||
blurb: string;
|
||||
order?: number;
|
||||
aliases?: string[];
|
||||
selectionDocsPrefix?: string;
|
||||
selectionDocsOmitLabel?: boolean;
|
||||
selectionExtras?: string[];
|
||||
showConfigured?: boolean;
|
||||
quickstartAllowFrom?: boolean;
|
||||
forceAccountBinding?: boolean;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChannelMeta } from "./plugins/types.js";
|
||||
|
||||
// Channel docking: add new channels here (order + meta + aliases), then
|
||||
// register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync.
|
||||
export const CHAT_CHANNEL_ORDER = [
|
||||
@@ -16,23 +18,11 @@ export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;
|
||||
|
||||
export const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp";
|
||||
|
||||
export type ChatChannelMeta = {
|
||||
id: ChatChannelId;
|
||||
label: string;
|
||||
selectionLabel: string;
|
||||
docsPath: string;
|
||||
docsLabel?: string;
|
||||
blurb: string;
|
||||
// Channel docking: selection-line formatting for onboarding prompts.
|
||||
// Keep this data-driven to avoid channel-specific branches in shared code.
|
||||
selectionDocsPrefix?: string;
|
||||
selectionDocsOmitLabel?: boolean;
|
||||
selectionExtras?: string[];
|
||||
};
|
||||
export type ChatChannelMeta = ChannelMeta;
|
||||
|
||||
const WEBSITE_URL = "https://clawd.bot";
|
||||
|
||||
const CHAT_CHANNEL_META: Record<ChatChannelId, ChatChannelMeta> = {
|
||||
const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { listChatChannels } from "../channels/registry.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import {
|
||||
channelsAddCommand,
|
||||
channelsListCommand,
|
||||
@@ -36,11 +36,10 @@ const optionNamesAdd = [
|
||||
|
||||
const optionNamesRemove = ["channel", "account", "delete"] as const;
|
||||
|
||||
const channelNames = listChatChannels()
|
||||
.map((meta) => meta.id)
|
||||
.join("|");
|
||||
|
||||
export function registerChannelsCli(program: Command) {
|
||||
const channelNames = listChannelPlugins()
|
||||
.map((plugin) => plugin.id)
|
||||
.join("|");
|
||||
const channels = program
|
||||
.command("channels")
|
||||
.description("Manage chat channel accounts")
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
import {
|
||||
CRON_CHANNEL_OPTIONS,
|
||||
getCronChannelOptions,
|
||||
parseAtMs,
|
||||
parseDurationMs,
|
||||
printCronList,
|
||||
@@ -81,7 +81,7 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option("--channel <channel>", `Delivery channel (${CRON_CHANNEL_OPTIONS})`, "last")
|
||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
|
||||
.option(
|
||||
"--to <dest>",
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import {
|
||||
CRON_CHANNEL_OPTIONS,
|
||||
getCronChannelOptions,
|
||||
parseAtMs,
|
||||
parseDurationMs,
|
||||
warnIfCronSchedulerDisabled,
|
||||
@@ -36,7 +36,7 @@ export function registerCronEditCommand(cron: Command) {
|
||||
.option("--model <model>", "Model override for agent jobs")
|
||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option("--channel <channel>", `Delivery channel (${CRON_CHANNEL_OPTIONS})`)
|
||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CHANNEL_IDS } from "../../channels/registry.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@@ -6,7 +6,8 @@ import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||
|
||||
export const CRON_CHANNEL_OPTIONS = ["last", ...CHANNEL_IDS].join("|");
|
||||
export const getCronChannelOptions = () =>
|
||||
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
|
||||
|
||||
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
|
||||
export type ProgramContext = {
|
||||
@@ -8,7 +12,25 @@ export type ProgramContext = {
|
||||
agentChannelOptions: string;
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("plugins");
|
||||
|
||||
function primePluginRegistry() {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
loadClawdbotPlugins({
|
||||
config,
|
||||
workspaceDir,
|
||||
logger: {
|
||||
info: (msg) => log.info(msg),
|
||||
warn: (msg) => log.warn(msg),
|
||||
error: (msg) => log.error(msg),
|
||||
debug: (msg) => log.debug(msg),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createProgramContext(): ProgramContext {
|
||||
primePluginRegistry();
|
||||
const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
|
||||
return {
|
||||
programVersion: VERSION,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import type { ChatChannelId } from "../channels/registry.js";
|
||||
import { normalizeChatChannelId } from "../channels/registry.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
@@ -82,7 +81,7 @@ export function applyAgentBindings(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string {
|
||||
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string {
|
||||
const plugin = getChannelPlugin(provider);
|
||||
if (!plugin) return DEFAULT_ACCOUNT_ID;
|
||||
return resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
@@ -125,7 +124,7 @@ export function parseBindingSpecs(params: {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) continue;
|
||||
const [channelRaw, accountRaw] = trimmed.split(":", 2);
|
||||
const channel = normalizeChatChannelId(channelRaw);
|
||||
const channel = normalizeChannelId(channelRaw);
|
||||
if (!channel) {
|
||||
errors.push(`Unknown channel "${channelRaw}".`);
|
||||
continue;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChatChannelId } from "../channels/registry.js";
|
||||
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
listChannelPlugins,
|
||||
normalizeChannelId,
|
||||
} from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
|
||||
type ProviderAccountStatus = {
|
||||
provider: ChatChannelId;
|
||||
provider: ChannelId;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled";
|
||||
@@ -15,16 +18,16 @@ type ProviderAccountStatus = {
|
||||
configured?: boolean;
|
||||
};
|
||||
|
||||
function providerAccountKey(provider: ChatChannelId, accountId?: string) {
|
||||
function providerAccountKey(provider: ChannelId, accountId?: string) {
|
||||
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
|
||||
}
|
||||
|
||||
function formatChannelAccountLabel(params: {
|
||||
provider: ChatChannelId;
|
||||
provider: ChannelId;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}): string {
|
||||
const label = getChatChannelMeta(params.provider).label;
|
||||
const label = getChannelPlugin(params.provider)?.meta.label ?? params.provider;
|
||||
const account = params.name?.trim()
|
||||
? `${params.accountId} (${params.name.trim()})`
|
||||
: params.accountId;
|
||||
@@ -88,7 +91,7 @@ export async function buildProviderStatusIndex(
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string {
|
||||
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string {
|
||||
const plugin = getChannelPlugin(provider);
|
||||
if (!plugin) return DEFAULT_ACCOUNT_ID;
|
||||
return resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
@@ -117,7 +120,7 @@ export function summarizeBindings(cfg: ClawdbotConfig, bindings: AgentBinding[])
|
||||
if (bindings.length === 0) return [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const binding of bindings) {
|
||||
const channel = normalizeChatChannelId(binding.match.channel);
|
||||
const channel = normalizeChannelId(binding.match.channel);
|
||||
if (!channel) continue;
|
||||
const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
|
||||
const key = providerAccountKey(channel, accountId);
|
||||
@@ -143,7 +146,7 @@ export function listProvidersForAgent(params: {
|
||||
if (params.bindings.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const binding of params.bindings) {
|
||||
const channel = normalizeChatChannelId(binding.match.channel);
|
||||
const channel = normalizeChannelId(binding.match.channel);
|
||||
if (!channel) continue;
|
||||
const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
|
||||
const key = providerAccountKey(channel, accountId);
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function applyAuthChoiceAnthropic(
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
mode: "oauth",
|
||||
});
|
||||
return { config: nextConfig };
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export async function applyAuthChoiceAnthropic(
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
mode: "oauth",
|
||||
});
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ type LogLine = ReturnType<typeof parseLogLine>;
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_BYTES = 1_000_000;
|
||||
const CHANNELS = new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
|
||||
|
||||
const getChannelSet = () =>
|
||||
new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
|
||||
|
||||
function parseChannelFilter(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase();
|
||||
if (!trimmed) return "all";
|
||||
return CHANNELS.has(trimmed) ? trimmed : "all";
|
||||
return getChannelSet().has(trimmed) ? trimmed : "all";
|
||||
}
|
||||
|
||||
function matchesChannel(line: NonNullable<LogLine>, channel: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listChatChannels } from "../channels/registry.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -13,7 +13,9 @@ export async function removeChannelConfigWizard(
|
||||
let next = { ...cfg };
|
||||
|
||||
const listConfiguredChannels = () =>
|
||||
listChatChannels().filter((meta) => next.channels?.[meta.id] !== undefined);
|
||||
listChannelPlugins()
|
||||
.map((plugin) => plugin.meta)
|
||||
.filter((meta) => next.channels?.[meta.id] !== undefined);
|
||||
|
||||
while (true) {
|
||||
const configured = listConfiguredChannels();
|
||||
@@ -45,7 +47,7 @@ export async function removeChannelConfigWizard(
|
||||
|
||||
if (channel === "done") return next;
|
||||
|
||||
const label = listChatChannels().find((meta) => meta.id === channel)?.label ?? channel;
|
||||
const label = getChannelPlugin(channel)?.meta.label ?? channel;
|
||||
const confirmed = guardCancel(
|
||||
await confirm({
|
||||
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
|
||||
|
||||
@@ -91,12 +91,12 @@ export async function modelsAuthSetupTokenCommand(
|
||||
applyAuthProfileConfig(cfg, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
mode: "oauth",
|
||||
}),
|
||||
);
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`);
|
||||
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
|
||||
}
|
||||
|
||||
export async function modelsAuthPasteTokenCommand(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
formatChannelPrimerLine,
|
||||
formatChannelSelectionLine,
|
||||
getChatChannelMeta,
|
||||
listChatChannels,
|
||||
} from "../channels/registry.js";
|
||||
import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { DmPolicy } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -17,7 +13,7 @@ import {
|
||||
import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js";
|
||||
|
||||
async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> {
|
||||
const channelLines = listChatChannels().map((meta) => formatChannelPrimerLine(meta));
|
||||
const channelLines = listChannelPlugins().map((plugin) => formatChannelPrimerLine(plugin.meta));
|
||||
await prompter.note(
|
||||
[
|
||||
"DM security: default is pairing; unknown DMs get a pairing code.",
|
||||
@@ -130,11 +126,12 @@ export async function setupChannels(
|
||||
|
||||
await noteChannelPrimer(prompter);
|
||||
|
||||
const selectionOptions = listChatChannels().map((meta) => {
|
||||
const selectionOptions = listChannelPlugins().map((plugin) => {
|
||||
const meta = plugin.meta;
|
||||
const status = statusByChannel.get(meta.id as ChannelChoice);
|
||||
return {
|
||||
value: meta.id,
|
||||
label: meta.selectionLabel,
|
||||
label: meta.selectionLabel ?? meta.label,
|
||||
...(status?.selectionHint ? { hint: status.selectionHint } : {}),
|
||||
};
|
||||
});
|
||||
@@ -169,7 +166,10 @@ export async function setupChannels(
|
||||
options?.onSelection?.(selection);
|
||||
|
||||
const selectionNotes = new Map(
|
||||
listChatChannels().map((meta) => [meta.id, formatChannelSelectionLine(meta, formatDocsLink)]),
|
||||
listChannelPlugins().map((plugin) => [
|
||||
plugin.id,
|
||||
formatChannelSelectionLine(plugin.meta, formatDocsLink),
|
||||
]),
|
||||
);
|
||||
const selectedLines = selection
|
||||
.map((channel) => selectionNotes.get(channel))
|
||||
@@ -214,9 +214,9 @@ export async function setupChannels(
|
||||
if (!status.configured) continue;
|
||||
const adapter = getChannelOnboardingAdapter(channelId);
|
||||
if (!adapter?.disable) continue;
|
||||
const meta = getChatChannelMeta(channelId);
|
||||
const meta = getChannelPlugin(channelId)?.meta;
|
||||
const disable = await prompter.confirm({
|
||||
message: `Disable ${meta.label} channel?`,
|
||||
message: `Disable ${meta?.label ?? channelId} channel?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (disable) {
|
||||
|
||||
@@ -270,7 +270,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
mode: "oauth",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatChannelId } from "../channels/registry.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||
|
||||
export type OnboardMode = "local" | "remote";
|
||||
@@ -31,7 +31,7 @@ export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
|
||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
|
||||
export type ChannelChoice = ChatChannelId;
|
||||
export type ChannelChoice = ChannelId;
|
||||
// Legacy alias (pre-rename).
|
||||
export type ProviderChoice = ChannelChoice;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxToolPolicyForAgent,
|
||||
} from "../agents/sandbox.js";
|
||||
import { normalizeChannelId } from "../channels/registry.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeChannelId } from "../channels/registry.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { ClawdbotConfig } from "./config.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../channels/registry.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { NativeCommandsSetting } from "./types.js";
|
||||
|
||||
function resolveAutoDefault(providerId?: ChannelId): boolean {
|
||||
|
||||
@@ -200,6 +200,46 @@ describe("legacy config detection", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
it("auto-migrates claude-cli auth profile mode to oauth", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:claude-cli": { provider: "anthropic", mode: "token" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { loadConfig } = await import("./config.js");
|
||||
const cfg = loadConfig();
|
||||
expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth");
|
||||
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
auth?: { profiles?: Record<string, { mode?: string }> };
|
||||
};
|
||||
expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth");
|
||||
expect(
|
||||
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
it("auto-migrates legacy provider sections on load and writes back", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("sandbox docker config", () => {
|
||||
it("accepts binds array in sandbox.docker config", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"],
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
sandbox: {
|
||||
docker: {
|
||||
image: "custom-sandbox:latest",
|
||||
binds: ["/home/user/projects:/projects:ro"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/home/user/source:/source:rw",
|
||||
]);
|
||||
expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([
|
||||
"/home/user/projects:/projects:ro",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-string values in binds array", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
binds: [123, "/valid/path:/path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,20 @@ import {
|
||||
} from "./legacy.shared.js";
|
||||
|
||||
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
{
|
||||
id: "auth.anthropic-claude-cli-mode-oauth",
|
||||
describe: "Switch anthropic:claude-cli auth profile mode to oauth",
|
||||
apply: (raw, changes) => {
|
||||
const auth = getRecord(raw.auth);
|
||||
const profiles = getRecord(auth?.profiles);
|
||||
if (!profiles) return;
|
||||
const claudeCli = getRecord(profiles["anthropic:claude-cli"]);
|
||||
if (!claudeCli) return;
|
||||
if (claudeCli.mode !== "token") return;
|
||||
claudeCli.mode = "oauth";
|
||||
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent.defaults-v2",
|
||||
describe: "Move agent config to agents.defaults and tools",
|
||||
|
||||
+33
-3
@@ -33,6 +33,12 @@ export type PluginUiMetadata = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type ChannelUiMetadata = {
|
||||
id: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
wizard: "Wizard",
|
||||
logging: "Logging",
|
||||
@@ -413,6 +419,24 @@ function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): Co
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): ConfigUiHints {
|
||||
const next: ConfigUiHints = { ...hints };
|
||||
for (const channel of channels) {
|
||||
const id = channel.id.trim();
|
||||
if (!id) continue;
|
||||
const basePath = `channels.${id}`;
|
||||
const current = next[basePath] ?? {};
|
||||
const label = channel.label?.trim();
|
||||
const help = channel.description?.trim();
|
||||
next[basePath] = {
|
||||
...current,
|
||||
...(label ? { label } : {}),
|
||||
...(help ? { help } : {}),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
let cachedBase: ConfigSchemaResponse | null = null;
|
||||
|
||||
function buildBaseConfigSchema(): ConfigSchemaResponse {
|
||||
@@ -433,11 +457,17 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildConfigSchema(params?: { plugins?: PluginUiMetadata[] }): ConfigSchemaResponse {
|
||||
export function buildConfigSchema(params?: {
|
||||
plugins?: PluginUiMetadata[];
|
||||
channels?: ChannelUiMetadata[];
|
||||
}): ConfigSchemaResponse {
|
||||
const base = buildBaseConfigSchema();
|
||||
const plugins = params?.plugins ?? [];
|
||||
if (plugins.length === 0) return base;
|
||||
const merged = applySensitiveHints(applyPluginHints(base.uiHints, plugins));
|
||||
const channels = params?.channels ?? [];
|
||||
if (plugins.length === 0 && channels.length === 0) return base;
|
||||
const merged = applySensitiveHints(
|
||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
uiHints: merged,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { CHANNEL_IDS } from "../../channels/registry.js";
|
||||
import { listDeliverableMessageChannels } from "../../utils/message-channel.js";
|
||||
import type { GroupKeyResolution } from "./types.js";
|
||||
|
||||
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]);
|
||||
const getGroupSurfaces = () => new Set<string>([...listDeliverableMessageChannels(), "webchat"]);
|
||||
|
||||
function normalizeGroupLabel(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
@@ -76,7 +76,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
|
||||
};
|
||||
|
||||
const parseParts = (parts: string[]) => {
|
||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||
if (parts.length >= 2 && getGroupSurfaces().has(parts[0])) {
|
||||
provider = parts[0];
|
||||
if (parts.length >= 3) {
|
||||
const kindCandidate = parts[1];
|
||||
|
||||
@@ -14,4 +14,5 @@ export type ChannelsConfig = {
|
||||
signal?: SignalConfig;
|
||||
imessage?: IMessageConfig;
|
||||
msteams?: MSTeamsConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -77,6 +77,7 @@ export const SandboxDockerSchema = z
|
||||
apparmorProfile: z.string().optional(),
|
||||
dns: z.array(z.string()).optional(),
|
||||
extraHosts: z.array(z.string()).optional(),
|
||||
binds: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -23,4 +23,5 @@ export const ChannelsSchema = z
|
||||
imessage: IMessageConfigSchema.optional(),
|
||||
msteams: MSTeamsConfigSchema.optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.optional();
|
||||
@@ -1,6 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLaunchctlPrint } from "./launchd.js";
|
||||
import { installLaunchAgent, parseLaunchctlPrint } from "./launchd.js";
|
||||
|
||||
describe("launchd runtime parsing", () => {
|
||||
it("parses state, pid, and exit status", () => {
|
||||
@@ -18,3 +23,92 @@ describe("launchd runtime parsing", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchd install", () => {
|
||||
it("enables service before bootstrap (clears persisted disabled state)", async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-"));
|
||||
try {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
const homeDir = path.join(tmpDir, "home");
|
||||
const logPath = path.join(tmpDir, "launchctl.log");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
|
||||
const stubJsPath = path.join(binDir, "launchctl.js");
|
||||
await fs.writeFile(
|
||||
stubJsPath,
|
||||
[
|
||||
'import fs from "node:fs";',
|
||||
"const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;",
|
||||
"if (logPath) {",
|
||||
" fs.appendFileSync(logPath, JSON.stringify(process.argv.slice(2)) + \"\\n\", \"utf8\");",
|
||||
"}",
|
||||
"process.exit(0);",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
path.join(binDir, "launchctl.cmd"),
|
||||
`@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
} else {
|
||||
const shPath = path.join(binDir, "launchctl");
|
||||
await fs.writeFile(
|
||||
shPath,
|
||||
`#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(shPath, 0o755);
|
||||
}
|
||||
|
||||
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
HOME: homeDir,
|
||||
CLAWDBOT_PROFILE: "default",
|
||||
};
|
||||
await installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
programArguments: ["node", "-e", "process.exit(0)"],
|
||||
});
|
||||
|
||||
const calls = (await fs.readFile(logPath, "utf8"))
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as string[]);
|
||||
|
||||
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
||||
const label = "com.clawdbot.gateway";
|
||||
const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`);
|
||||
const serviceId = `${domain}/${label}`;
|
||||
|
||||
const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId);
|
||||
expect(enableCalls).toHaveLength(1);
|
||||
|
||||
const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId);
|
||||
const bootstrapIndex = calls.findIndex(
|
||||
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
|
||||
);
|
||||
expect(enableIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(enableIndex).toBeLessThan(bootstrapIndex);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalLogPath === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -410,11 +410,12 @@ export async function installLaunchAgent({
|
||||
|
||||
await execLaunchctl(["bootout", domain, plistPath]);
|
||||
await execLaunchctl(["unload", plistPath]);
|
||||
// launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.
|
||||
await execLaunchctl(["enable", `${domain}/${label}`]);
|
||||
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
|
||||
if (boot.code !== 0) {
|
||||
throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim());
|
||||
}
|
||||
await execLaunchctl(["enable", `${domain}/${label}`]);
|
||||
await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
|
||||
|
||||
stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);
|
||||
|
||||
@@ -136,18 +136,18 @@ export type HookAgentPayload = {
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
const HOOK_CHANNEL_VALUES = ["last", ...listChannelPlugins().map((plugin) => plugin.id)];
|
||||
const listHookChannelValues = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)];
|
||||
|
||||
export type HookMessageChannel = ChannelId | "last";
|
||||
|
||||
const hookChannelSet = new Set<string>(HOOK_CHANNEL_VALUES);
|
||||
export const HOOK_CHANNEL_ERROR = `channel must be ${HOOK_CHANNEL_VALUES.join("|")}`;
|
||||
const getHookChannelSet = () => new Set<string>(listHookChannelValues());
|
||||
export const getHookChannelError = () => `channel must be ${listHookChannelValues().join("|")}`;
|
||||
|
||||
export function resolveHookChannel(raw: unknown): HookMessageChannel | null {
|
||||
if (raw === undefined) return "last";
|
||||
if (typeof raw !== "string") return null;
|
||||
const normalized = normalizeMessageChannel(raw);
|
||||
if (!normalized || !hookChannelSet.has(normalized)) return null;
|
||||
if (!normalized || !getHookChannelSet().has(normalized)) return null;
|
||||
return normalized as HookMessageChannel;
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ export function normalizeAgentPayload(
|
||||
? sessionKeyRaw.trim()
|
||||
: `hook:${idFactory()}`;
|
||||
const channel = resolveHookChannel(payload.channel);
|
||||
if (!channel) return { ok: false, error: HOOK_CHANNEL_ERROR };
|
||||
if (!channel) return { ok: false, error: getHookChannelError() };
|
||||
const toRaw = payload.to;
|
||||
const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
||||
const modelRaw = payload.model;
|
||||
|
||||
@@ -67,6 +67,11 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||
description: plugin.description,
|
||||
configUiHints: plugin.configUiHints,
|
||||
})),
|
||||
channels: pluginRegistry.channels.map((entry) => ({
|
||||
id: entry.plugin.id,
|
||||
label: entry.plugin.meta.label,
|
||||
description: entry.plugin.meta.blurb,
|
||||
})),
|
||||
});
|
||||
return { ok: true, payloadJSON: JSON.stringify(schema) };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { createSubsystemLogger } from "../logging.js";
|
||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
HOOK_CHANNEL_ERROR,
|
||||
getHookChannelError,
|
||||
type HookMessageChannel,
|
||||
type HooksConfigResolved,
|
||||
normalizeAgentPayload,
|
||||
@@ -152,7 +152,7 @@ export function createHooksRequestHandler(
|
||||
}
|
||||
const channel = resolveHookChannel(mapped.action.channel);
|
||||
if (!channel) {
|
||||
sendJson(res, 400, { ok: false, error: HOOK_CHANNEL_ERROR });
|
||||
sendJson(res, 400, { ok: false, error: getHookChannelError() });
|
||||
return true;
|
||||
}
|
||||
const runId = dispatchAgentHook({
|
||||
|
||||
@@ -59,9 +59,10 @@ const BASE_METHODS = [
|
||||
"chat.send",
|
||||
];
|
||||
|
||||
const CHANNEL_METHODS = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
|
||||
|
||||
export const GATEWAY_METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS]));
|
||||
export function listGatewayMethods(): string[] {
|
||||
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
|
||||
return Array.from(new Set([...BASE_METHODS, ...channelMethods]));
|
||||
}
|
||||
|
||||
export const GATEWAY_EVENTS = [
|
||||
"agent",
|
||||
|
||||
@@ -73,6 +73,11 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
description: plugin.description,
|
||||
configUiHints: plugin.configUiHints,
|
||||
})),
|
||||
channels: pluginRegistry.channels.map((entry) => ({
|
||||
id: entry.plugin.id,
|
||||
label: entry.plugin.meta.label,
|
||||
description: entry.plugin.meta.blurb,
|
||||
})),
|
||||
});
|
||||
respond(true, schema, undefined);
|
||||
},
|
||||
|
||||
+12
-11
@@ -38,7 +38,7 @@ import { buildGatewayCronService } from "./server-cron.js";
|
||||
import { applyGatewayLaneConcurrency } from "./server-lanes.js";
|
||||
import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
|
||||
import { coreGatewayHandlers } from "./server-methods.js";
|
||||
import { GATEWAY_EVENTS, GATEWAY_METHODS } from "./server-methods-list.js";
|
||||
import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js";
|
||||
import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
|
||||
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||
import { loadGatewayPlugins } from "./server-plugins.js";
|
||||
@@ -69,14 +69,6 @@ const logReload = log.child("reload");
|
||||
const logHooks = log.child("hooks");
|
||||
const logWsControl = log.child("ws");
|
||||
const canvasRuntime = runtimeForLogger(logCanvas);
|
||||
const channelLogs = Object.fromEntries(
|
||||
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
|
||||
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
|
||||
const channelRuntimeEnvs = Object.fromEntries(
|
||||
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
|
||||
) as Record<ChannelId, RuntimeEnv>;
|
||||
|
||||
const METHODS = GATEWAY_METHODS;
|
||||
|
||||
export type GatewayServer = {
|
||||
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
|
||||
@@ -163,13 +155,22 @@ export async function startGatewayServer(
|
||||
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
|
||||
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
||||
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
||||
const { pluginRegistry, gatewayMethods } = loadGatewayPlugins({
|
||||
const baseMethods = listGatewayMethods();
|
||||
const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({
|
||||
cfg: cfgAtStart,
|
||||
workspaceDir: defaultWorkspaceDir,
|
||||
log,
|
||||
coreGatewayHandlers,
|
||||
baseMethods: METHODS,
|
||||
baseMethods,
|
||||
});
|
||||
const channelLogs = Object.fromEntries(
|
||||
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
|
||||
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
|
||||
const channelRuntimeEnvs = Object.fromEntries(
|
||||
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
|
||||
) as Record<ChannelId, RuntimeEnv>;
|
||||
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
|
||||
const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods]));
|
||||
let pluginServices: PluginServicesHandle | null = null;
|
||||
const runtimeConfig = await resolveGatewayRuntimeConfig({
|
||||
cfg: cfgAtStart,
|
||||
|
||||
@@ -2,17 +2,17 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
DELIVERABLE_MESSAGE_CHANNELS,
|
||||
listDeliverableMessageChannels,
|
||||
type DeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
|
||||
export type MessageChannelId = DeliverableMessageChannel;
|
||||
|
||||
const MESSAGE_CHANNELS = [...DELIVERABLE_MESSAGE_CHANNELS];
|
||||
const getMessageChannels = () => listDeliverableMessageChannels();
|
||||
|
||||
function isKnownChannel(value: string): value is MessageChannelId {
|
||||
return (MESSAGE_CHANNELS as readonly string[]).includes(value);
|
||||
return getMessageChannels().includes(value as MessageChannelId);
|
||||
}
|
||||
|
||||
function isAccountEnabled(account: unknown): boolean {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import crypto from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
enableConsoleCapture,
|
||||
resetLogger,
|
||||
routeLogsToStderr,
|
||||
setLoggerOverride,
|
||||
} from "../logging.js";
|
||||
import { loggingState } from "./state.js";
|
||||
|
||||
type ConsoleSnapshot = {
|
||||
log: typeof console.log;
|
||||
info: typeof console.info;
|
||||
warn: typeof console.warn;
|
||||
error: typeof console.error;
|
||||
debug: typeof console.debug;
|
||||
trace: typeof console.trace;
|
||||
};
|
||||
|
||||
let snapshot: ConsoleSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
snapshot = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
trace: console.trace,
|
||||
};
|
||||
loggingState.consolePatched = false;
|
||||
loggingState.forceConsoleToStderr = false;
|
||||
loggingState.rawConsole = null;
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = snapshot.log;
|
||||
console.info = snapshot.info;
|
||||
console.warn = snapshot.warn;
|
||||
console.error = snapshot.error;
|
||||
console.debug = snapshot.debug;
|
||||
console.trace = snapshot.trace;
|
||||
loggingState.consolePatched = false;
|
||||
loggingState.forceConsoleToStderr = false;
|
||||
loggingState.rawConsole = null;
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("enableConsoleCapture", () => {
|
||||
it("swallows EIO from stderr writes", () => {
|
||||
setLoggerOverride({ level: "info", file: tempLogPath() });
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => {
|
||||
throw eioError();
|
||||
});
|
||||
routeLogsToStderr();
|
||||
enableConsoleCapture();
|
||||
expect(() => console.log("hello")).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows EIO from original console writes", () => {
|
||||
setLoggerOverride({ level: "info", file: tempLogPath() });
|
||||
console.log = () => {
|
||||
throw eioError();
|
||||
};
|
||||
enableConsoleCapture();
|
||||
expect(() => console.log("hello")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function tempLogPath() {
|
||||
return path.join(os.tmpdir(), `clawdbot-log-${crypto.randomUUID()}.log`);
|
||||
}
|
||||
|
||||
function eioError() {
|
||||
const err = new Error("EIO") as NodeJS.ErrnoException;
|
||||
err.code = "EIO";
|
||||
return err;
|
||||
}
|
||||
@@ -89,7 +89,8 @@ function shouldSuppressConsoleMessage(message: string): boolean {
|
||||
}
|
||||
|
||||
function isEpipeError(err: unknown): boolean {
|
||||
return Boolean((err as { code?: string })?.code === "EPIPE");
|
||||
const code = (err as { code?: string })?.code;
|
||||
return code === "EPIPE" || code === "EIO";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,4 +102,44 @@ describe("loadClawdbotPlugins", () => {
|
||||
expect(registry.plugins[0]?.status).toBe("error");
|
||||
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers channel plugins", () => {
|
||||
const plugin = writePlugin({
|
||||
id: "channel-demo",
|
||||
body: `export default function (api) {
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
id: "demo",
|
||||
meta: {
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
selectionLabel: "Demo",
|
||||
docsPath: "/channels/demo",
|
||||
blurb: "demo channel"
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" })
|
||||
},
|
||||
outbound: { deliveryMode: "direct" }
|
||||
}
|
||||
});
|
||||
};`,
|
||||
});
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
cache: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["channel-demo"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.channels.length).toBe(1);
|
||||
expect(registry.channels[0]?.plugin.id).toBe("demo");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { discoverClawdbotPlugins } from "./discovery.js";
|
||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
import type {
|
||||
ClawdbotPluginConfigSchema,
|
||||
ClawdbotPluginDefinition,
|
||||
@@ -188,6 +189,7 @@ function createPluginRecord(params: {
|
||||
enabled: params.enabled,
|
||||
status: params.enabled ? "loaded" : "disabled",
|
||||
toolNames: [],
|
||||
channelIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
@@ -211,7 +213,10 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
const cacheEnabled = options.cache !== false;
|
||||
if (cacheEnabled) {
|
||||
const cached = registryCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
setActivePluginRegistry(cached, cacheKey);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const { registry, createApi } = createPluginRegistry({
|
||||
@@ -359,5 +364,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
registryCache.set(cacheKey, registry);
|
||||
}
|
||||
setActivePluginRegistry(registry, cacheKey);
|
||||
return registry;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type {
|
||||
GatewayRequestHandler,
|
||||
GatewayRequestHandlers,
|
||||
@@ -6,6 +8,7 @@ import type {
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type {
|
||||
ClawdbotPluginApi,
|
||||
ClawdbotPluginChannelRegistration,
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginService,
|
||||
ClawdbotPluginToolContext,
|
||||
@@ -30,6 +33,13 @@ export type PluginCliRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginChannelRegistration = {
|
||||
pluginId: string;
|
||||
plugin: ChannelPlugin;
|
||||
dock?: ChannelDock;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginServiceRegistration = {
|
||||
pluginId: string;
|
||||
service: ClawdbotPluginService;
|
||||
@@ -48,6 +58,7 @@ export type PluginRecord = {
|
||||
status: "loaded" | "disabled" | "error";
|
||||
error?: string;
|
||||
toolNames: string[];
|
||||
channelIds: string[];
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
@@ -58,6 +69,7 @@ export type PluginRecord = {
|
||||
export type PluginRegistry = {
|
||||
plugins: PluginRecord[];
|
||||
tools: PluginToolRegistration[];
|
||||
channels: PluginChannelRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
@@ -73,6 +85,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const registry: PluginRegistry = {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
channels: [],
|
||||
gatewayHandlers: {},
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
@@ -129,6 +142,34 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
record.gatewayMethods.push(trimmed);
|
||||
};
|
||||
|
||||
const registerChannel = (
|
||||
record: PluginRecord,
|
||||
registration: ClawdbotPluginChannelRegistration | ChannelPlugin,
|
||||
) => {
|
||||
const normalized =
|
||||
typeof (registration as ClawdbotPluginChannelRegistration).plugin === "object"
|
||||
? (registration as ClawdbotPluginChannelRegistration)
|
||||
: { plugin: registration as ChannelPlugin };
|
||||
const plugin = normalized.plugin;
|
||||
const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim();
|
||||
if (!id) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "channel registration missing id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.channelIds.push(id);
|
||||
registry.channels.push({
|
||||
pluginId: record.id,
|
||||
plugin,
|
||||
dock: normalized.dock,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
const registerCli = (
|
||||
record: PluginRecord,
|
||||
registrar: ClawdbotPluginCliRegistrar,
|
||||
@@ -179,6 +220,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
pluginConfig: params.pluginConfig,
|
||||
logger: normalizeLogger(registryParams.logger),
|
||||
registerTool: (tool, opts) => registerTool(record, tool, opts),
|
||||
registerChannel: (registration) => registerChannel(record, registration),
|
||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
@@ -191,6 +233,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
createApi,
|
||||
pushDiagnostic,
|
||||
registerTool,
|
||||
registerChannel,
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerService,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PluginRegistry } from "./registry.js";
|
||||
|
||||
let activeRegistry: PluginRegistry | null = null;
|
||||
let activeRegistryKey: string | null = null;
|
||||
|
||||
export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) {
|
||||
activeRegistry = registry;
|
||||
activeRegistryKey = cacheKey ?? null;
|
||||
}
|
||||
|
||||
export function getActivePluginRegistry(): PluginRegistry | null {
|
||||
return activeRegistry;
|
||||
}
|
||||
|
||||
export function getActivePluginRegistryKey(): string | null {
|
||||
return activeRegistryKey;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
|
||||
@@ -78,6 +80,11 @@ export type ClawdbotPluginService = {
|
||||
stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginChannelRegistration = {
|
||||
plugin: ChannelPlugin;
|
||||
dock?: ChannelDock;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginDefinition = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -105,6 +112,7 @@ export type ClawdbotPluginApi = {
|
||||
tool: AnyAgentTool | ClawdbotPluginToolFactory,
|
||||
opts?: { name?: string; names?: string[] },
|
||||
) => void;
|
||||
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
|
||||
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
|
||||
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
registerService: (service: ClawdbotPluginService) => void;
|
||||
|
||||
@@ -29,4 +29,3 @@ describe("resolveSlackChannelConfig", () => {
|
||||
expect(res).toMatchObject({ requireMention: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,9 +26,23 @@ export const registerTelegramHandlers = ({
|
||||
processMessage,
|
||||
logger,
|
||||
}) => {
|
||||
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
|
||||
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
|
||||
const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1;
|
||||
const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12;
|
||||
const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000;
|
||||
|
||||
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
|
||||
let mediaGroupProcessing: Promise<void> = Promise.resolve();
|
||||
|
||||
type TextFragmentEntry = {
|
||||
key: string;
|
||||
messages: Array<{ msg: TelegramMessage; ctx: unknown; receivedAtMs: number }>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
|
||||
let textFragmentProcessing: Promise<void> = Promise.resolve();
|
||||
|
||||
const processMediaGroup = async (entry: MediaGroupEntry) => {
|
||||
try {
|
||||
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
|
||||
@@ -51,6 +65,55 @@ export const registerTelegramHandlers = ({
|
||||
}
|
||||
};
|
||||
|
||||
const flushTextFragments = async (entry: TextFragmentEntry) => {
|
||||
try {
|
||||
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
|
||||
|
||||
const first = entry.messages[0];
|
||||
const last = entry.messages.at(-1);
|
||||
if (!first || !last) return;
|
||||
|
||||
const combinedText = entry.messages.map((m) => m.msg.text ?? "").join("");
|
||||
if (!combinedText.trim()) return;
|
||||
|
||||
const syntheticMessage: TelegramMessage = {
|
||||
...first.msg,
|
||||
text: combinedText,
|
||||
caption: undefined,
|
||||
caption_entities: undefined,
|
||||
entities: undefined,
|
||||
date: last.msg.date ?? first.msg.date,
|
||||
};
|
||||
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const baseCtx = first.ctx as { me?: unknown; getFile?: unknown } & Record<string, unknown>;
|
||||
const getFile =
|
||||
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
|
||||
|
||||
await processMessage(
|
||||
{ message: syntheticMessage, me: baseCtx.me, getFile },
|
||||
[],
|
||||
storeAllowFrom,
|
||||
{ messageIdOverride: String(last.msg.message_id) },
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = setTimeout(async () => {
|
||||
textFragmentBuffer.delete(entry.key);
|
||||
textFragmentProcessing = textFragmentProcessing
|
||||
.then(async () => {
|
||||
await flushTextFragments(entry);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await textFragmentProcessing;
|
||||
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
|
||||
};
|
||||
|
||||
bot.on("callback_query", async (ctx) => {
|
||||
const callback = ctx.callbackQuery;
|
||||
if (!callback) return;
|
||||
@@ -226,6 +289,68 @@ export const registerTelegramHandlers = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
|
||||
// We buffer “near-limit” messages and append immediately-following parts.
|
||||
const text = typeof msg.text === "string" ? msg.text : undefined;
|
||||
const isCommandLike = (text ?? "").trim().startsWith("/");
|
||||
if (text && !isCommandLike) {
|
||||
const nowMs = Date.now();
|
||||
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
|
||||
const key = `text:${chatId}:${resolvedThreadId ?? "main"}:${senderId}`;
|
||||
const existing = textFragmentBuffer.get(key);
|
||||
|
||||
if (existing) {
|
||||
const last = existing.messages.at(-1);
|
||||
const lastMsgId = last?.msg.message_id;
|
||||
const lastReceivedAtMs = last?.receivedAtMs ?? nowMs;
|
||||
const idGap = typeof lastMsgId === "number" ? msg.message_id - lastMsgId : Infinity;
|
||||
const timeGapMs = nowMs - lastReceivedAtMs;
|
||||
const canAppend =
|
||||
idGap > 0 &&
|
||||
idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP &&
|
||||
timeGapMs >= 0 &&
|
||||
timeGapMs <= TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS;
|
||||
|
||||
if (canAppend) {
|
||||
const currentTotalChars = existing.messages.reduce(
|
||||
(sum, m) => sum + (m.msg.text?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
const nextTotalChars = currentTotalChars + text.length;
|
||||
if (
|
||||
existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS &&
|
||||
nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS
|
||||
) {
|
||||
existing.messages.push({ msg, ctx, receivedAtMs: nowMs });
|
||||
scheduleTextFragmentFlush(existing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not appendable (or limits exceeded): flush buffered entry first, then continue normally.
|
||||
clearTimeout(existing.timer);
|
||||
textFragmentBuffer.delete(key);
|
||||
textFragmentProcessing = textFragmentProcessing
|
||||
.then(async () => {
|
||||
await flushTextFragments(existing);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await textFragmentProcessing;
|
||||
}
|
||||
|
||||
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
|
||||
if (shouldStart) {
|
||||
const entry: TextFragmentEntry = {
|
||||
key,
|
||||
messages: [{ msg, ctx, receivedAtMs: nowMs }],
|
||||
timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS),
|
||||
};
|
||||
textFragmentBuffer.set(key, entry);
|
||||
scheduleTextFragmentFlush(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Media group handling - buffer multi-image messages
|
||||
const mediaGroupId = (msg as { media_group_id?: string }).media_group_id;
|
||||
if (mediaGroupId) {
|
||||
|
||||
@@ -409,3 +409,74 @@ describe("telegram media groups", () => {
|
||||
MEDIA_GROUP_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("telegram text fragments", () => {
|
||||
beforeEach(() => {
|
||||
// These tests rely on real setTimeout aggregation; guard against leaked fake timers.
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const TEXT_FRAGMENT_POLL_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 15_000;
|
||||
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
|
||||
|
||||
const waitForFragmentProcessing = async (
|
||||
replySpy: ReturnType<typeof vi.fn>,
|
||||
expectedCalls: number,
|
||||
) => {
|
||||
await expect
|
||||
.poll(() => replySpy.mock.calls.length, { timeout: TEXT_FRAGMENT_POLL_TIMEOUT_MS })
|
||||
.toBe(expectedCalls);
|
||||
};
|
||||
|
||||
it(
|
||||
"buffers near-limit text and processes sequential parts as one message",
|
||||
async () => {
|
||||
const { createTelegramBot } = await import("./bot.js");
|
||||
const replyModule = await import("../auto-reply/reply.js");
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
onSpy.mockReset();
|
||||
replySpy.mockReset();
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const part1 = "A".repeat(4050);
|
||||
const part2 = "B".repeat(50);
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
message_id: 10,
|
||||
date: 1736380800,
|
||||
text: part1,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "private" },
|
||||
message_id: 11,
|
||||
date: 1736380801,
|
||||
text: part2,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
await waitForFragmentProcessing(replySpy, 1);
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string };
|
||||
expect(payload.RawBody).toContain(part1.slice(0, 32));
|
||||
expect(payload.RawBody).toContain(part2.slice(0, 32));
|
||||
},
|
||||
TEXT_FRAGMENT_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
listChatChannelAliases,
|
||||
normalizeChatChannelId,
|
||||
} from "../channels/registry.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
normalizeGatewayClientMode,
|
||||
normalizeGatewayClientName,
|
||||
} from "../gateway/protocol/client-info.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
|
||||
export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const;
|
||||
export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL;
|
||||
@@ -42,34 +44,58 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL;
|
||||
return normalizeChatChannelId(normalized) ?? normalized;
|
||||
const builtIn = normalizeChatChannelId(normalized);
|
||||
if (builtIn) return builtIn;
|
||||
const registry = getActivePluginRegistry();
|
||||
const pluginMatch = registry?.channels.find((entry) => {
|
||||
if (entry.plugin.id.toLowerCase() === normalized) return true;
|
||||
return (entry.plugin.meta.aliases ?? []).some(
|
||||
(alias) => alias.trim().toLowerCase() === normalized,
|
||||
);
|
||||
});
|
||||
return pluginMatch?.plugin.id ?? normalized;
|
||||
}
|
||||
|
||||
export const DELIVERABLE_MESSAGE_CHANNELS = CHANNEL_IDS;
|
||||
const listPluginChannelIds = (): string[] => {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (!registry) return [];
|
||||
return registry.channels.map((entry) => entry.plugin.id);
|
||||
};
|
||||
|
||||
export type DeliverableMessageChannel = (typeof DELIVERABLE_MESSAGE_CHANNELS)[number];
|
||||
const listPluginChannelAliases = (): string[] => {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (!registry) return [];
|
||||
return registry.channels.flatMap((entry) => entry.plugin.meta.aliases ?? []);
|
||||
};
|
||||
|
||||
export const listDeliverableMessageChannels = (): ChannelId[] =>
|
||||
Array.from(new Set([...CHANNEL_IDS, ...listPluginChannelIds()]));
|
||||
|
||||
export type DeliverableMessageChannel = ChannelId;
|
||||
|
||||
export type GatewayMessageChannel = DeliverableMessageChannel | InternalMessageChannel;
|
||||
|
||||
export const GATEWAY_MESSAGE_CHANNELS = [
|
||||
...DELIVERABLE_MESSAGE_CHANNELS,
|
||||
export const listGatewayMessageChannels = (): GatewayMessageChannel[] => [
|
||||
...listDeliverableMessageChannels(),
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
] as const;
|
||||
];
|
||||
|
||||
export const GATEWAY_AGENT_CHANNEL_ALIASES = listChatChannelAliases();
|
||||
export const listGatewayAgentChannelAliases = (): string[] =>
|
||||
Array.from(new Set([...listChatChannelAliases(), ...listPluginChannelAliases()]));
|
||||
|
||||
export type GatewayAgentChannelHint = GatewayMessageChannel | "last";
|
||||
|
||||
export const GATEWAY_AGENT_CHANNEL_VALUES = Array.from(
|
||||
new Set([...GATEWAY_MESSAGE_CHANNELS, "last", ...GATEWAY_AGENT_CHANNEL_ALIASES]),
|
||||
);
|
||||
export const listGatewayAgentChannelValues = (): string[] =>
|
||||
Array.from(
|
||||
new Set([...listGatewayMessageChannels(), "last", ...listGatewayAgentChannelAliases()]),
|
||||
);
|
||||
|
||||
export function isGatewayMessageChannel(value: string): value is GatewayMessageChannel {
|
||||
return (GATEWAY_MESSAGE_CHANNELS as readonly string[]).includes(value);
|
||||
return listGatewayMessageChannels().includes(value as GatewayMessageChannel);
|
||||
}
|
||||
|
||||
export function isDeliverableMessageChannel(value: string): value is DeliverableMessageChannel {
|
||||
return (DELIVERABLE_MESSAGE_CHANNELS as readonly string[]).includes(value);
|
||||
return listDeliverableMessageChannels().includes(value as DeliverableMessageChannel);
|
||||
}
|
||||
|
||||
export function resolveGatewayMessageChannel(
|
||||
|
||||
Reference in new issue
Block a user