Merge branch 'clawdbot:main' into feat/add-extensions-feishu
This commit is contained in:
commit
ffe86a14a8
2
.gitignore
vendored
2
.gitignore
vendored
@ -40,6 +40,8 @@ apps/ios/*.xcfilelist
|
|||||||
|
|
||||||
# Vendor build artifacts
|
# Vendor build artifacts
|
||||||
vendor/a2ui/renderers/lit/dist/
|
vendor/a2ui/renderers/lit/dist/
|
||||||
|
src/canvas-host/a2ui/*.bundle.js
|
||||||
|
src/canvas-host/a2ui/*.map
|
||||||
.bundle.hash
|
.bundle.hash
|
||||||
|
|
||||||
# fastlane (iOS)
|
# fastlane (iOS)
|
||||||
|
|||||||
@ -6,6 +6,8 @@ Docs: https://docs.clawd.bot
|
|||||||
Status: unreleased.
|
Status: unreleased.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
||||||
|
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
|
||||||
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
||||||
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
@ -39,6 +41,7 @@ Status: unreleased.
|
|||||||
- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.
|
- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.
|
||||||
- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`.
|
- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`.
|
||||||
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
||||||
|
- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro.
|
||||||
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||||
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
||||||
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||||
@ -58,6 +61,8 @@ Status: unreleased.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
|
||||||
|
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
||||||
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
|
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
|
||||||
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
|
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
|
||||||
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
||||||
|
|||||||
@ -83,7 +83,10 @@ enum CommandResolver {
|
|||||||
"/usr/bin",
|
"/usr/bin",
|
||||||
"/bin",
|
"/bin",
|
||||||
]
|
]
|
||||||
|
#if DEBUG
|
||||||
|
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
|
||||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||||
|
#endif
|
||||||
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
||||||
if !clawdbotPaths.isEmpty {
|
if !clawdbotPaths.isEmpty {
|
||||||
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
||||||
@ -189,9 +192,13 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||||
|
#if DEBUG
|
||||||
let root = projectRoot ?? self.projectRoot()
|
let root = projectRoot ?? self.projectRoot()
|
||||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||||
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nodeCliPath() -> String? {
|
static func nodeCliPath() -> String? {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
@State private var isPinnedToBottom = true
|
@State private var isPinnedToBottom = true
|
||||||
|
@State private var lastUserMessageID: UUID?
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
private let markdownVariant: ChatMarkdownVariant
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
@ -132,8 +133,28 @@ public struct ClawdbotChatView: View {
|
|||||||
self.hasPerformedInitialScroll = false
|
self.hasPerformedInitialScroll = false
|
||||||
self.isPinnedToBottom = true
|
self.isPinnedToBottom = true
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.viewModel.isSending) { _, isSending in
|
||||||
|
// Scroll to bottom when user sends a message, even if scrolled up.
|
||||||
|
guard isSending, self.hasPerformedInitialScroll else { return }
|
||||||
|
self.isPinnedToBottom = true
|
||||||
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
|
self.scrollPosition = self.scrollerBottomID
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
|
if let lastMessage = self.viewModel.messages.last,
|
||||||
|
lastMessage.role.lowercased() == "user",
|
||||||
|
lastMessage.id != self.lastUserMessageID {
|
||||||
|
self.lastUserMessageID = lastMessage.id
|
||||||
|
self.isPinnedToBottom = true
|
||||||
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
|
self.scrollPosition = self.scrollerBottomID
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard self.isPinnedToBottom else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
file: outputFile,
|
file: outputFile,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
inlineDynamicImports: true,
|
codeSplitting: false,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
1
dist/control-ui/assets/index-08nzABV3.css
vendored
1
dist/control-ui/assets/index-08nzABV3.css
vendored
File diff suppressed because one or more lines are too long
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
File diff suppressed because one or more lines are too long
15
dist/control-ui/index.html
vendored
15
dist/control-ui/index.html
vendored
@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Clawdbot Control</title>
|
|
||||||
<meta name="color-scheme" content="dark light" />
|
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
|
||||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<clawdbot-app></clawdbot-app>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -298,8 +298,12 @@ ack reaction after the bot replies.
|
|||||||
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
||||||
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
||||||
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||||
|
- `guilds.<id>.tools`: optional per-guild tool policy overrides (`allow`/`deny`/`alsoAllow`) used when the channel override is missing.
|
||||||
|
- `guilds.<id>.toolsBySender`: optional per-sender tool policy overrides at the guild level (applies when the channel override is missing; `"*"` wildcard supported).
|
||||||
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
|
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
|
||||||
|
- `guilds.<id>.channels.<channel>.tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `guilds.<id>.channels.<channel>.toolsBySender`: optional per-sender tool policy overrides within the channel (`"*"` wildcard supported).
|
||||||
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
|
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
|
||||||
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
|
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
|
||||||
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
|
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
|
||||||
|
|||||||
@ -421,8 +421,12 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
|
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
|
||||||
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
||||||
|
- `channels.msteams.teams.<teamId>.tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing.
|
||||||
|
- `channels.msteams.teams.<teamId>.toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported).
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
||||||
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported).
|
||||||
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
||||||
|
|
||||||
## Routing & Sessions
|
## Routing & Sessions
|
||||||
|
|||||||
@ -464,6 +464,8 @@ For fine-grained control, use these tags in agent responses:
|
|||||||
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
- `requireMention`: mention gating for the channel.
|
- `requireMention`: mention gating for the channel.
|
||||||
|
- `tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `toolsBySender`: optional per-sender tool policy overrides within the channel (keys are sender ids/@handles/emails; `"*"` wildcard supported).
|
||||||
- `allowBots`: allow bot-authored messages in this channel (default: false).
|
- `allowBots`: allow bot-authored messages in this channel (default: false).
|
||||||
- `users`: optional per-channel user allowlist.
|
- `users`: optional per-channel user allowlist.
|
||||||
- `skills`: skill filter (omit = all skills, empty = none).
|
- `skills`: skill filter (omit = all skills, empty = none).
|
||||||
|
|||||||
@ -232,6 +232,42 @@ Notes:
|
|||||||
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
||||||
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||||
|
|
||||||
|
## Group/channel tool restrictions (optional)
|
||||||
|
Some channel configs support restricting which tools are available **inside a specific group/room/channel**.
|
||||||
|
|
||||||
|
- `tools`: allow/deny tools for the whole group.
|
||||||
|
- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard.
|
||||||
|
|
||||||
|
Resolution order (most specific wins):
|
||||||
|
1) group/channel `toolsBySender` match
|
||||||
|
2) group/channel `tools`
|
||||||
|
3) default (`"*"`) `toolsBySender` match
|
||||||
|
4) default (`"*"`) `tools`
|
||||||
|
|
||||||
|
Example (Telegram):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"*": { tools: { deny: ["exec"] } },
|
||||||
|
"-1001234567890": {
|
||||||
|
tools: { deny: ["exec", "read", "write"] },
|
||||||
|
toolsBySender: {
|
||||||
|
"123456789": { alsoAllow: ["exec"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
|
||||||
|
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`).
|
||||||
|
|
||||||
## Group allowlists
|
## Group allowlists
|
||||||
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
||||||
|
|
||||||
|
|||||||
@ -956,6 +956,7 @@
|
|||||||
"gateway/doctor",
|
"gateway/doctor",
|
||||||
"gateway/logging",
|
"gateway/logging",
|
||||||
"gateway/security",
|
"gateway/security",
|
||||||
|
"security/formal-verification",
|
||||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||||
"gateway/sandboxing",
|
"gateway/sandboxing",
|
||||||
"gateway/troubleshooting",
|
"gateway/troubleshooting",
|
||||||
|
|||||||
@ -2768,6 +2768,7 @@ scheme/host for profiles that only set `cdpPort`.
|
|||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- enabled: `true`
|
- enabled: `true`
|
||||||
|
- evaluateEnabled: `true` (set `false` to disable `act:evaluate` and `wait --fn`)
|
||||||
- control service: loopback only (port derived from `gateway.port`, default `18791`)
|
- control service: loopback only (port derived from `gateway.port`, default `18791`)
|
||||||
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
|
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
|
||||||
- profile color: `#FF4500` (lobster-orange)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
@ -2778,6 +2779,7 @@ Defaults:
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
evaluateEnabled: true,
|
||||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
12
docs/gateway/security-formal-verification.md
Normal file
12
docs/gateway/security-formal-verification.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
title: Formal Verification (Security Models)
|
||||||
|
summary: Redirect to the canonical Formal Verification page.
|
||||||
|
permalink: /gateway/security/formal-verification/
|
||||||
|
---
|
||||||
|
|
||||||
|
This page moved to: [/security/formal-verification/](/security/formal-verification/)
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Best-effort client-side redirect for Mintlify/Next.
|
||||||
|
window.location.replace("/security/formal-verification/");
|
||||||
|
</script>
|
||||||
@ -7,6 +7,8 @@ read_when:
|
|||||||
|
|
||||||
## Quick check: `clawdbot security audit`
|
## Quick check: `clawdbot security audit`
|
||||||
|
|
||||||
|
See also: [Formal Verification (Security Models)](/security/formal-verification/)
|
||||||
|
|
||||||
Run this regularly (especially after changing config or exposing network surfaces):
|
Run this regularly (especially after changing config or exposing network surfaces):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -570,6 +572,9 @@ If that browser profile already contains logged-in sessions, the model can
|
|||||||
access those accounts and data. Treat browser profiles as **sensitive state**:
|
access those accounts and data. Treat browser profiles as **sensitive state**:
|
||||||
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
||||||
- Avoid pointing the agent at your personal daily-driver profile.
|
- Avoid pointing the agent at your personal daily-driver profile.
|
||||||
|
- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context.
|
||||||
|
Prompt injection can steer the model into calling them. If you do not need
|
||||||
|
them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||||
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
||||||
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
||||||
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||||
|
|||||||
107
docs/security/formal-verification.md
Normal file
107
docs/security/formal-verification.md
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
title: Formal Verification (Security Models)
|
||||||
|
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
||||||
|
permalink: /security/formal-verification/
|
||||||
|
---
|
||||||
|
|
||||||
|
# Formal Verification (Security Models)
|
||||||
|
|
||||||
|
This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed).
|
||||||
|
|
||||||
|
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
|
||||||
|
intended security policy (authorization, session isolation, tool gating, and
|
||||||
|
misconfiguration safety), under explicit assumptions.
|
||||||
|
|
||||||
|
**What this is (today):** an executable, attacker-driven **security regression suite**:
|
||||||
|
- Each claim has a runnable model-check over a finite state space.
|
||||||
|
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
|
||||||
|
|
||||||
|
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
|
||||||
|
|
||||||
|
## Where the models live
|
||||||
|
|
||||||
|
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
|
||||||
|
|
||||||
|
## Important caveats
|
||||||
|
|
||||||
|
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
|
||||||
|
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
|
||||||
|
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
|
||||||
|
|
||||||
|
## Reproducing results
|
||||||
|
|
||||||
|
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
|
||||||
|
- CI-run models with public artifacts (counterexample traces, run logs)
|
||||||
|
- a hosted “run this model” workflow for small, bounded checks
|
||||||
|
|
||||||
|
Getting started:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/vignesh07/clawdbot-formal-models
|
||||||
|
cd clawdbot-formal-models
|
||||||
|
|
||||||
|
# Java 11+ required (TLC runs on the JVM).
|
||||||
|
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
|
||||||
|
|
||||||
|
make <target>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gateway exposure and open gateway misconfiguration
|
||||||
|
|
||||||
|
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make gateway-exposure-v2`
|
||||||
|
- `make gateway-exposure-v2-protected`
|
||||||
|
- Red (expected):
|
||||||
|
- `make gateway-exposure-v2-negative`
|
||||||
|
|
||||||
|
See also: `docs/gateway-exposure-matrix.md` in the models repo.
|
||||||
|
|
||||||
|
### Nodes.run pipeline (highest-risk capability)
|
||||||
|
|
||||||
|
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make nodes-pipeline`
|
||||||
|
- `make approvals-token`
|
||||||
|
- Red (expected):
|
||||||
|
- `make nodes-pipeline-negative`
|
||||||
|
- `make approvals-token-negative`
|
||||||
|
|
||||||
|
### Pairing store (DM gating)
|
||||||
|
|
||||||
|
**Claim:** pairing requests respect TTL and pending-request caps.
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make pairing`
|
||||||
|
- `make pairing-cap`
|
||||||
|
- Red (expected):
|
||||||
|
- `make pairing-negative`
|
||||||
|
- `make pairing-cap-negative`
|
||||||
|
|
||||||
|
### Ingress gating (mentions + control-command bypass)
|
||||||
|
|
||||||
|
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
|
||||||
|
|
||||||
|
- Green:
|
||||||
|
- `make ingress-gating`
|
||||||
|
- Red (expected):
|
||||||
|
- `make ingress-gating-negative`
|
||||||
|
|
||||||
|
### Routing/session-key isolation
|
||||||
|
|
||||||
|
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
|
||||||
|
|
||||||
|
- Green:
|
||||||
|
- `make routing-isolation`
|
||||||
|
- Red (expected):
|
||||||
|
- `make routing-isolation-negative`
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Next models to deepen fidelity:
|
||||||
|
- Pairing store concurrency/locking/idempotency
|
||||||
|
- Provider-specific ingress preflight modeling
|
||||||
|
- Routing identity-links + dmScope variants + binding precedence
|
||||||
|
- Gateway auth conformance (proxy/tailscale specifics)
|
||||||
@ -175,6 +175,7 @@ clawdbot onboard --install-daemon
|
|||||||
```
|
```
|
||||||
|
|
||||||
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
|
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
|
||||||
|
`pnpm build` also bundles A2UI assets; if you need to run just that step, use `pnpm canvas:a2ui:bundle`.
|
||||||
|
|
||||||
Gateway (from this repo):
|
Gateway (from this repo):
|
||||||
|
|
||||||
|
|||||||
@ -505,6 +505,9 @@ These are useful for “make the site behave like X” workflows:
|
|||||||
## Security & privacy
|
## Security & privacy
|
||||||
|
|
||||||
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
||||||
|
- `browser act kind=evaluate` / `clawdbot browser evaluate` and `wait --fn`
|
||||||
|
execute arbitrary JavaScript in the page context. Prompt injection can steer
|
||||||
|
this. Disable it with `browser.evaluateEnabled=false` if you do not need it.
|
||||||
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
|
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
|
||||||
- Keep the Gateway/node host private (loopback or tailnet-only).
|
- Keep the Gateway/node host private (loopback or tailnet-only).
|
||||||
- Remote CDP endpoints are powerful; tunnel and protect them.
|
- Remote CDP endpoints are powerful; tunnel and protect them.
|
||||||
|
|||||||
@ -67,7 +67,8 @@ Example:
|
|||||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
Clawdbot prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
|
||||||
|
`tools.exec.pathPrepend` applies here too.
|
||||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||||
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
|
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
|
||||||
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
normalizeChannelSlug,
|
normalizeChannelSlug,
|
||||||
|
resolveToolsBySender,
|
||||||
resolveChannelEntryMatchWithFallback,
|
resolveChannelEntryMatchWithFallback,
|
||||||
resolveNestedAllowlistDecision,
|
resolveNestedAllowlistDecision,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@ -106,9 +107,36 @@ export function resolveMSTeamsGroupToolPolicy(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resolved.channelConfig) {
|
if (resolved.channelConfig) {
|
||||||
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.channelConfig.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
|
if (resolved.channelConfig.tools) return resolved.channelConfig.tools;
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.teamConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
return resolved.teamConfig?.tools;
|
||||||
|
}
|
||||||
|
if (resolved.teamConfig) {
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.teamConfig.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
if (resolved.teamConfig.tools) return resolved.teamConfig.tools;
|
||||||
}
|
}
|
||||||
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
|
|
||||||
|
|
||||||
if (!groupId) return undefined;
|
if (!groupId) return undefined;
|
||||||
|
|
||||||
@ -125,7 +153,24 @@ export function resolveMSTeamsGroupToolPolicy(
|
|||||||
normalizeKey: normalizeChannelSlug,
|
normalizeKey: normalizeChannelSlug,
|
||||||
});
|
});
|
||||||
if (match.entry) {
|
if (match.entry) {
|
||||||
return match.entry.tools ?? teamConfig?.tools;
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: match.entry.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
|
if (match.entry.tools) return match.entry.tools;
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: teamConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
return teamConfig?.tools;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,7 +82,7 @@
|
|||||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||||
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
"build": "pnpm canvas:a2ui:bundle && tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||||
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||||
"release:check": "node --import tsx scripts/release-check.ts",
|
"release:check": "node --import tsx scripts/release-check.ts",
|
||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
on_error() {
|
||||||
|
echo "A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle" >&2
|
||||||
|
echo "If this persists, verify pnpm deps and try again." >&2
|
||||||
|
}
|
||||||
|
trap on_error ERR
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
||||||
|
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
|
||||||
|
|
||||||
INPUT_PATHS=(
|
INPUT_PATHS=(
|
||||||
"$ROOT_DIR/package.json"
|
"$ROOT_DIR/package.json"
|
||||||
@ -33,7 +40,7 @@ compute_hash() {
|
|||||||
current_hash="$(compute_hash)"
|
current_hash="$(compute_hash)"
|
||||||
if [[ -f "$HASH_FILE" ]]; then
|
if [[ -f "$HASH_FILE" ]]; then
|
||||||
previous_hash="$(cat "$HASH_FILE")"
|
previous_hash="$(cat "$HASH_FILE")"
|
||||||
if [[ "$previous_hash" == "$current_hash" ]]; then
|
if [[ "$previous_hash" == "$current_hash" && -f "$OUTPUT_FILE" ]]; then
|
||||||
echo "A2UI bundle up to date; skipping."
|
echo "A2UI bundle up to date; skipping."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -1,19 +1,44 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const srcDir = path.join(repoRoot, "src", "canvas-host", "a2ui");
|
|
||||||
const outDir = path.join(repoRoot, "dist", "canvas-host", "a2ui");
|
|
||||||
|
|
||||||
async function main() {
|
export function getA2uiPaths(env = process.env) {
|
||||||
await fs.stat(path.join(srcDir, "index.html"));
|
const srcDir =
|
||||||
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
|
env.CLAWDBOT_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui");
|
||||||
|
const outDir =
|
||||||
|
env.CLAWDBOT_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui");
|
||||||
|
return { srcDir, outDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyA2uiAssets({
|
||||||
|
srcDir,
|
||||||
|
outDir,
|
||||||
|
}: {
|
||||||
|
srcDir: string;
|
||||||
|
outDir: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await fs.stat(path.join(srcDir, "index.html"));
|
||||||
|
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
|
||||||
|
throw new Error(message, { cause: err });
|
||||||
|
}
|
||||||
await fs.mkdir(path.dirname(outDir), { recursive: true });
|
await fs.mkdir(path.dirname(outDir), { recursive: true });
|
||||||
await fs.cp(srcDir, outDir, { recursive: true });
|
await fs.cp(srcDir, outDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
async function main() {
|
||||||
console.error(String(err));
|
const { srcDir, outDir } = getA2uiPaths();
|
||||||
process.exit(1);
|
await copyA2uiAssets({ srcDir, outDir });
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -60,11 +60,18 @@ export function buildDockerExecArgs(params: {
|
|||||||
for (const [key, value] of Object.entries(params.env)) {
|
for (const [key, value] of Object.entries(params.env)) {
|
||||||
args.push("-e", `${key}=${value}`);
|
args.push("-e", `${key}=${value}`);
|
||||||
}
|
}
|
||||||
|
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
|
||||||
|
if (hasCustomPath) {
|
||||||
|
// Avoid interpolating PATH into the shell command; pass it via env instead.
|
||||||
|
args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`);
|
||||||
|
}
|
||||||
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
||||||
// overriding both Docker ENV and -e PATH=... environment variables.
|
// overriding both Docker ENV and -e PATH=... environment variables.
|
||||||
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
||||||
// while preserving system paths that /etc/profile may have added.
|
// while preserving system paths that /etc/profile may have added.
|
||||||
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
|
const pathExport = hasCustomPath
|
||||||
|
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
|
||||||
|
: "";
|
||||||
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -318,9 +318,30 @@ describe("buildDockerExecArgs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const commandArg = args[args.length - 1];
|
const commandArg = args[args.length - 1];
|
||||||
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
|
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
|
||||||
|
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
|
||||||
expect(commandArg).toContain("echo hello");
|
expect(commandArg).toContain("echo hello");
|
||||||
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
|
expect(commandArg).toBe(
|
||||||
|
'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not interpolate PATH into the shell command", () => {
|
||||||
|
const injectedPath = "$(touch /tmp/clawdbot-path-injection)";
|
||||||
|
const args = buildDockerExecArgs({
|
||||||
|
containerName: "test-container",
|
||||||
|
command: "echo hello",
|
||||||
|
env: {
|
||||||
|
PATH: injectedPath,
|
||||||
|
HOME: "/home/user",
|
||||||
|
},
|
||||||
|
tty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandArg = args[args.length - 1];
|
||||||
|
expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`);
|
||||||
|
expect(commandArg).not.toContain(injectedPath);
|
||||||
|
expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add PATH export when PATH is not in env", () => {
|
it("does not add PATH export when PATH is not in env", () => {
|
||||||
|
|||||||
@ -20,8 +20,10 @@ describe("gateway tool", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
const previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
|
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tool = createClawdbotTools({
|
const tool = createClawdbotTools({
|
||||||
@ -62,6 +64,11 @@ describe("gateway tool", () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||||
}
|
}
|
||||||
|
if (previousProfile === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_PROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -119,9 +119,16 @@ function mergeConfig(
|
|||||||
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
||||||
const defaultRemote = defaults?.remote;
|
const defaultRemote = defaults?.remote;
|
||||||
const overrideRemote = overrides?.remote;
|
const overrideRemote = overrides?.remote;
|
||||||
const hasRemote = Boolean(defaultRemote || overrideRemote);
|
const hasRemoteConfig = Boolean(
|
||||||
|
overrideRemote?.baseUrl ||
|
||||||
|
overrideRemote?.apiKey ||
|
||||||
|
overrideRemote?.headers ||
|
||||||
|
defaultRemote?.baseUrl ||
|
||||||
|
defaultRemote?.apiKey ||
|
||||||
|
defaultRemote?.headers,
|
||||||
|
);
|
||||||
const includeRemote =
|
const includeRemote =
|
||||||
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||||
const batch = {
|
const batch = {
|
||||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||||
|
|
||||||
|
|||||||
@ -215,6 +215,10 @@ export async function runEmbeddedAttempt(
|
|||||||
groupChannel: params.groupChannel,
|
groupChannel: params.groupChannel,
|
||||||
groupSpace: params.groupSpace,
|
groupSpace: params.groupSpace,
|
||||||
spawnedBy: params.spawnedBy,
|
spawnedBy: params.spawnedBy,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
agentDir,
|
agentDir,
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
|
|||||||
@ -35,6 +35,10 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent policy inheritance. */
|
/** Parent session key for subagent policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
/** Current channel ID for auto-threading (Slack). */
|
/** Current channel ID for auto-threading (Slack). */
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
/** Current thread timestamp for auto-threading (Slack). */
|
/** Current thread timestamp for auto-threading (Slack). */
|
||||||
|
|||||||
@ -31,6 +31,10 @@ export type EmbeddedRunAttemptParams = {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent policy inheritance. */
|
/** Parent session key for subagent policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import type { SandboxDockerConfig } from "./sandbox.js";
|
import type { SandboxDockerConfig } from "./sandbox.js";
|
||||||
@ -270,6 +271,75 @@ describe("Agent-specific tool filtering", () => {
|
|||||||
expect(defaultNames).not.toContain("exec");
|
expect(defaultNames).not.toContain("exec");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should apply per-sender tool policies for group tools", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groups: {
|
||||||
|
"*": {
|
||||||
|
tools: { allow: ["read"] },
|
||||||
|
toolsBySender: {
|
||||||
|
alice: { allow: ["read", "exec"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aliceTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:family",
|
||||||
|
senderId: "alice",
|
||||||
|
workspaceDir: "/tmp/test-group-sender",
|
||||||
|
agentDir: "/tmp/agent-group-sender",
|
||||||
|
});
|
||||||
|
const aliceNames = aliceTools.map((t) => t.name);
|
||||||
|
expect(aliceNames).toContain("read");
|
||||||
|
expect(aliceNames).toContain("exec");
|
||||||
|
|
||||||
|
const bobTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:family",
|
||||||
|
senderId: "bob",
|
||||||
|
workspaceDir: "/tmp/test-group-sender-bob",
|
||||||
|
agentDir: "/tmp/agent-group-sender",
|
||||||
|
});
|
||||||
|
const bobNames = bobTools.map((t) => t.name);
|
||||||
|
expect(bobNames).toContain("read");
|
||||||
|
expect(bobNames).not.toContain("exec");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not let default sender policy override group tools", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groups: {
|
||||||
|
"*": {
|
||||||
|
toolsBySender: {
|
||||||
|
admin: { allow: ["read", "exec"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
tools: { allow: ["read"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:locked",
|
||||||
|
senderId: "admin",
|
||||||
|
workspaceDir: "/tmp/test-group-default-override",
|
||||||
|
agentDir: "/tmp/agent-group-default-override",
|
||||||
|
});
|
||||||
|
const adminNames = adminTools.map((t) => t.name);
|
||||||
|
expect(adminNames).toContain("read");
|
||||||
|
expect(adminNames).not.toContain("exec");
|
||||||
|
});
|
||||||
|
|
||||||
it("should resolve telegram group tool policy for topic session keys", () => {
|
it("should resolve telegram group tool policy for topic session keys", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@ -233,6 +233,10 @@ export function resolveGroupToolPolicy(params: {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
}): SandboxToolPolicy | undefined {
|
}): SandboxToolPolicy | undefined {
|
||||||
if (!params.config) return undefined;
|
if (!params.config) return undefined;
|
||||||
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
||||||
@ -255,12 +259,20 @@ export function resolveGroupToolPolicy(params: {
|
|||||||
groupChannel: params.groupChannel,
|
groupChannel: params.groupChannel,
|
||||||
groupSpace: params.groupSpace,
|
groupSpace: params.groupSpace,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
}) ??
|
}) ??
|
||||||
resolveChannelGroupToolsPolicy({
|
resolveChannelGroupToolsPolicy({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
channel,
|
channel,
|
||||||
groupId,
|
groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
return pickToolPolicy(toolsConfig);
|
return pickToolPolicy(toolsConfig);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,6 +140,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent group policy inheritance. */
|
/** Parent session key for subagent group policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
/** Reply-to mode for Slack auto-threading. */
|
/** Reply-to mode for Slack auto-threading. */
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||||
@ -174,6 +178,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
groupChannel: options?.groupChannel,
|
groupChannel: options?.groupChannel,
|
||||||
groupSpace: options?.groupSpace,
|
groupSpace: options?.groupSpace,
|
||||||
accountId: options?.agentAccountId,
|
accountId: options?.agentAccountId,
|
||||||
|
senderId: options?.senderId,
|
||||||
|
senderName: options?.senderName,
|
||||||
|
senderUsername: options?.senderUsername,
|
||||||
|
senderE164: options?.senderE164,
|
||||||
});
|
});
|
||||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
||||||
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
||||||
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../../browser/constants.js";
|
import {
|
||||||
|
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
|
} from "../../browser/constants.js";
|
||||||
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
||||||
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||||
import {
|
import {
|
||||||
@ -39,10 +42,12 @@ function buildSandboxBrowserResolvedConfig(params: {
|
|||||||
controlPort: number;
|
controlPort: number;
|
||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
|
evaluateEnabled: boolean;
|
||||||
}): ResolvedBrowserConfig {
|
}): ResolvedBrowserConfig {
|
||||||
const cdpHost = "127.0.0.1";
|
const cdpHost = "127.0.0.1";
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
evaluateEnabled: params.evaluateEnabled,
|
||||||
controlPort: params.controlPort,
|
controlPort: params.controlPort,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost,
|
cdpHost,
|
||||||
@ -76,6 +81,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
agentWorkspaceDir: string;
|
agentWorkspaceDir: string;
|
||||||
cfg: SandboxConfig;
|
cfg: SandboxConfig;
|
||||||
|
evaluateEnabled?: boolean;
|
||||||
}): Promise<SandboxBrowserContext | null> {
|
}): Promise<SandboxBrowserContext | null> {
|
||||||
if (!params.cfg.browser.enabled) return null;
|
if (!params.cfg.browser.enabled) return null;
|
||||||
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
||||||
@ -170,6 +176,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
controlPort: 0,
|
controlPort: 0,
|
||||||
cdpPort: mappedCdp,
|
cdpPort: mappedCdp,
|
||||||
headless: params.cfg.browser.headless,
|
headless: params.cfg.browser.headless,
|
||||||
|
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
}),
|
}),
|
||||||
onEnsureAttachTarget,
|
onEnsureAttachTarget,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
|
import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js";
|
||||||
import { syncSkillsToWorkspace } from "../skills.js";
|
import { syncSkillsToWorkspace } from "../skills.js";
|
||||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
||||||
import { ensureSandboxBrowser } from "./browser.js";
|
import { ensureSandboxBrowser } from "./browser.js";
|
||||||
@ -69,11 +70,14 @@ export async function resolveSandboxContext(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const evaluateEnabled =
|
||||||
|
params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||||
const browser = await ensureSandboxBrowser({
|
const browser = await ensureSandboxBrowser({
|
||||||
scopeKey,
|
scopeKey,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
agentWorkspaceDir,
|
agentWorkspaceDir,
|
||||||
cfg,
|
cfg,
|
||||||
|
evaluateEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { SkillEligibilityContext, SkillEntry } from "./types.js";
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||||
"browser.enabled": true,
|
"browser.enabled": true,
|
||||||
|
"browser.evaluateEnabled": true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function isTruthy(value: unknown): boolean {
|
function isTruthy(value: unknown): boolean {
|
||||||
|
|||||||
@ -232,6 +232,10 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
groupChannel:
|
groupChannel:
|
||||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
||||||
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||||
|
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||||
// Provider threading context for tool auto-injection
|
// Provider threading context for tool auto-injection
|
||||||
...buildThreadingToolContext({
|
...buildThreadingToolContext({
|
||||||
sessionCtx: params.sessionCtx,
|
sessionCtx: params.sessionCtx,
|
||||||
|
|||||||
@ -115,6 +115,10 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
config: params.followupRun.run.config,
|
config: params.followupRun.run.config,
|
||||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
|
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||||
sessionFile: params.followupRun.run.sessionFile,
|
sessionFile: params.followupRun.run.sessionFile,
|
||||||
workspaceDir: params.followupRun.run.workspaceDir,
|
workspaceDir: params.followupRun.run.workspaceDir,
|
||||||
agentDir: params.followupRun.run.agentDir,
|
agentDir: params.followupRun.run.agentDir,
|
||||||
|
|||||||
@ -147,6 +147,10 @@ export function createFollowupRunner(params: {
|
|||||||
groupId: queued.run.groupId,
|
groupId: queued.run.groupId,
|
||||||
groupChannel: queued.run.groupChannel,
|
groupChannel: queued.run.groupChannel,
|
||||||
groupSpace: queued.run.groupSpace,
|
groupSpace: queued.run.groupSpace,
|
||||||
|
senderId: queued.run.senderId,
|
||||||
|
senderName: queued.run.senderName,
|
||||||
|
senderUsername: queued.run.senderUsername,
|
||||||
|
senderE164: queued.run.senderE164,
|
||||||
sessionFile: queued.run.sessionFile,
|
sessionFile: queued.run.sessionFile,
|
||||||
workspaceDir: queued.run.workspaceDir,
|
workspaceDir: queued.run.workspaceDir,
|
||||||
config: queued.run.config,
|
config: queued.run.config,
|
||||||
|
|||||||
@ -370,6 +370,10 @@ export async function runPreparedReply(
|
|||||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||||
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
||||||
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||||
|
senderId: sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: sessionCtx.SenderE164?.trim() || undefined,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
@ -51,6 +51,10 @@ export type FollowupRun = {
|
|||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupChannel?: string;
|
groupChannel?: string;
|
||||||
groupSpace?: string;
|
groupSpace?: string;
|
||||||
|
senderId?: string;
|
||||||
|
senderName?: string;
|
||||||
|
senderUsername?: string;
|
||||||
|
senderE164?: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
|
|||||||
@ -14,7 +14,14 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number):
|
|||||||
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
||||||
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
if (msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("timeout")) {
|
const msgLower = msg.toLowerCase();
|
||||||
|
const looksLikeTimeout =
|
||||||
|
msgLower.includes("timed out") ||
|
||||||
|
msgLower.includes("timeout") ||
|
||||||
|
msgLower.includes("aborted") ||
|
||||||
|
msgLower.includes("abort") ||
|
||||||
|
msgLower.includes("aborterror");
|
||||||
|
if (looksLikeTimeout) {
|
||||||
return new Error(
|
return new Error(
|
||||||
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
||||||
);
|
);
|
||||||
@ -48,7 +55,7 @@ export async function fetchBrowserJson<T>(
|
|||||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||||
try {
|
try {
|
||||||
if (isAbsoluteHttp(url)) {
|
if (isAbsoluteHttp(url)) {
|
||||||
return await fetchHttpJson<T>(url, { ...(init ?? {}), timeoutMs });
|
return await fetchHttpJson<T>(url, { ...init, timeoutMs });
|
||||||
}
|
}
|
||||||
const started = await startBrowserControlServiceFromConfig();
|
const started = await startBrowserControlServiceFromConfig();
|
||||||
if (!started) {
|
if (!started) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { resolveGatewayPort } from "../config/paths.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
DEFAULT_CLAWD_BROWSER_ENABLED,
|
DEFAULT_CLAWD_BROWSER_ENABLED,
|
||||||
|
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
@ -15,6 +16,7 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
|
|||||||
|
|
||||||
export type ResolvedBrowserConfig = {
|
export type ResolvedBrowserConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
evaluateEnabled: boolean;
|
||||||
controlPort: number;
|
controlPort: number;
|
||||||
cdpProtocol: "http" | "https";
|
cdpProtocol: "http" | "https";
|
||||||
cdpHost: string;
|
cdpHost: string;
|
||||||
@ -140,6 +142,7 @@ export function resolveBrowserConfig(
|
|||||||
rootConfig?: ClawdbotConfig,
|
rootConfig?: ClawdbotConfig,
|
||||||
): ResolvedBrowserConfig {
|
): ResolvedBrowserConfig {
|
||||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||||
|
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
const gatewayPort = resolveGatewayPort(rootConfig);
|
||||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
||||||
const defaultColor = normalizeHexColor(cfg?.color);
|
const defaultColor = normalizeHexColor(cfg?.color);
|
||||||
@ -197,6 +200,7 @@ export function resolveBrowserConfig(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
evaluateEnabled,
|
||||||
controlPort,
|
controlPort,
|
||||||
cdpProtocol,
|
cdpProtocol,
|
||||||
cdpHost: cdpInfo.parsed.hostname,
|
cdpHost: cdpInfo.parsed.hostname,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
|
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
|
||||||
|
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||||
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
|
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
|
||||||
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
||||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export function registerBrowserAgentActRoutes(
|
|||||||
const cdpUrl = profileCtx.profile.cdpUrl;
|
const cdpUrl = profileCtx.profile.cdpUrl;
|
||||||
const pw = await requirePwAi(res, `act:${kind}`);
|
const pw = await requirePwAi(res, `act:${kind}`);
|
||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
|
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
|
||||||
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "click": {
|
case "click": {
|
||||||
@ -210,6 +211,16 @@ export function registerBrowserAgentActRoutes(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const fn = toStringOrEmpty(body.fn) || undefined;
|
const fn = toStringOrEmpty(body.fn) || undefined;
|
||||||
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
|
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
|
||||||
|
if (fn && !evaluateEnabled) {
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
403,
|
||||||
|
[
|
||||||
|
"wait --fn is disabled by config (browser.evaluateEnabled=false).",
|
||||||
|
"Docs: /gateway/configuration#browser-clawd-managed-browser",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
timeMs === undefined &&
|
timeMs === undefined &&
|
||||||
!text &&
|
!text &&
|
||||||
@ -240,6 +251,16 @@ export function registerBrowserAgentActRoutes(
|
|||||||
return res.json({ ok: true, targetId: tab.targetId });
|
return res.json({ ok: true, targetId: tab.targetId });
|
||||||
}
|
}
|
||||||
case "evaluate": {
|
case "evaluate": {
|
||||||
|
if (!evaluateEnabled) {
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
403,
|
||||||
|
[
|
||||||
|
"act:evaluate is disabled by config (browser.evaluateEnabled=false).",
|
||||||
|
"Docs: /gateway/configuration#browser-clawd-managed-browser",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
const fn = toStringOrEmpty(body.fn);
|
const fn = toStringOrEmpty(body.fn);
|
||||||
if (!fn) return jsonError(res, 400, "fn is required");
|
if (!fn) return jsonError(res, 400, "fn is required");
|
||||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ let testPort = 0;
|
|||||||
let cdpBaseUrl = "";
|
let cdpBaseUrl = "";
|
||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
|
let cfgEvaluateEnabled = true;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
let prevGatewayPort: string | undefined;
|
||||||
|
|
||||||
@ -89,6 +90,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
evaluateEnabled: cfgEvaluateEnabled,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@ -185,6 +187,7 @@ describe("browser control server", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
reachable = false;
|
reachable = false;
|
||||||
cfgAttachOnly = false;
|
cfgAttachOnly = false;
|
||||||
|
cfgEvaluateEnabled = true;
|
||||||
createTargetId = null;
|
createTargetId = null;
|
||||||
|
|
||||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||||
@ -349,6 +352,30 @@ describe("browser control server", () => {
|
|||||||
slowTimeoutMs,
|
slowTimeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"blocks act:evaluate when browser.evaluateEnabled=false",
|
||||||
|
async () => {
|
||||||
|
cfgEvaluateEnabled = false;
|
||||||
|
const base = await startServerAndBase();
|
||||||
|
|
||||||
|
const waitRes = (await postJson(`${base}/act`, {
|
||||||
|
kind: "wait",
|
||||||
|
fn: "() => window.ready === true",
|
||||||
|
})) as { error?: string };
|
||||||
|
expect(waitRes.error).toContain("browser.evaluateEnabled=false");
|
||||||
|
expect(pwMocks.waitForViaPlaywright).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const res = (await postJson(`${base}/act`, {
|
||||||
|
kind: "evaluate",
|
||||||
|
fn: "() => 1",
|
||||||
|
})) as { error?: string };
|
||||||
|
|
||||||
|
expect(res.error).toContain("browser.evaluateEnabled=false");
|
||||||
|
expect(pwMocks.evaluateViaPlaywright).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
slowTimeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
it("agent contract: hooks + response + downloads + screenshot", async () => {
|
it("agent contract: hooks + response + downloads + screenshot", async () => {
|
||||||
const base = await startServerAndBase();
|
const base = await startServerAndBase();
|
||||||
|
|
||||||
|
|||||||
@ -308,6 +308,11 @@ describe("backward compatibility (profile parameter)", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -344,6 +349,11 @@ describe("backward compatibility (profile parameter)", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
if (prevGatewayPort === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||||
|
}
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -285,6 +285,11 @@ describe("profile CRUD endpoints", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -299,6 +304,11 @@ describe("profile CRUD endpoints", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
if (prevGatewayPort === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||||
|
}
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
|
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -2,9 +2,13 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import {
|
import {
|
||||||
resolveChannelGroupRequireMention,
|
resolveChannelGroupRequireMention,
|
||||||
resolveChannelGroupToolsPolicy,
|
resolveChannelGroupToolsPolicy,
|
||||||
|
resolveToolsBySender,
|
||||||
} from "../../config/group-policy.js";
|
} from "../../config/group-policy.js";
|
||||||
import type { DiscordConfig } from "../../config/types.js";
|
import type { DiscordConfig } from "../../config/types.js";
|
||||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
import type {
|
||||||
|
GroupToolPolicyBySenderConfig,
|
||||||
|
GroupToolPolicyConfig,
|
||||||
|
} from "../../config/types.tools.js";
|
||||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
|
|
||||||
type GroupMentionParams = {
|
type GroupMentionParams = {
|
||||||
@ -13,6 +17,10 @@ type GroupMentionParams = {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeDiscordSlug(value?: string | null) {
|
function normalizeDiscordSlug(value?: string | null) {
|
||||||
@ -172,6 +180,10 @@ export function resolveGoogleChatGroupToolPolicy(
|
|||||||
channel: "googlechat",
|
channel: "googlechat",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,6 +238,10 @@ export function resolveTelegramGroupToolPolicy(
|
|||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
groupId: chatId ?? params.groupId,
|
groupId: chatId ?? params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,6 +253,10 @@ export function resolveWhatsAppGroupToolPolicy(
|
|||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,6 +268,10 @@ export function resolveIMessageGroupToolPolicy(
|
|||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,8 +292,24 @@ export function resolveDiscordGroupToolPolicy(
|
|||||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
||||||
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: entry?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
if (entry?.tools) return entry.tools;
|
if (entry?.tools) return entry.tools;
|
||||||
}
|
}
|
||||||
|
const guildSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: guildEntry?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (guildSenderPolicy) return guildSenderPolicy;
|
||||||
if (guildEntry?.tools) return guildEntry.tools;
|
if (guildEntry?.tools) return guildEntry.tools;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -294,7 +334,9 @@ export function resolveSlackGroupToolPolicy(
|
|||||||
channelName ?? "",
|
channelName ?? "",
|
||||||
normalizedName,
|
normalizedName,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
let matched: { tools?: GroupToolPolicyConfig } | undefined;
|
let matched:
|
||||||
|
| { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig }
|
||||||
|
| undefined;
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (candidate && channels[candidate]) {
|
if (candidate && channels[candidate]) {
|
||||||
matched = channels[candidate];
|
matched = channels[candidate];
|
||||||
@ -302,6 +344,14 @@ export function resolveSlackGroupToolPolicy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved = matched ?? channels["*"];
|
const resolved = matched ?? channels["*"];
|
||||||
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
if (resolved?.tools) return resolved.tools;
|
if (resolved?.tools) return resolved.tools;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -314,5 +364,9 @@ export function resolveBlueBubblesGroupToolPolicy(
|
|||||||
channel: "bluebubbles",
|
channel: "bluebubbles",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -155,6 +155,10 @@ export type ChannelGroupContext = {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelCapabilities = {
|
export type ChannelCapabilities = {
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export function registerBrowserElementCommands(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await callBrowserAct<{ url?: string }>({
|
||||||
parent,
|
parent,
|
||||||
profile,
|
profile,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||||
const result = await callBrowserRequest(
|
const result = await callBrowserRequest<{ download: { path: string } }>(
|
||||||
parent,
|
parent,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -68,7 +68,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||||
const result = await callBrowserRequest(
|
const result = await callBrowserRequest<{ download: { path: string } }>(
|
||||||
parent,
|
parent,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -108,7 +108,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||||
const result = await callBrowserRequest(
|
const result = await callBrowserRequest<{ download: { path: string } }>(
|
||||||
parent,
|
parent,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
fields: opts.fields,
|
fields: opts.fields,
|
||||||
fieldsFile: opts.fieldsFile,
|
fieldsFile: opts.fieldsFile,
|
||||||
});
|
});
|
||||||
const result = await callBrowserAct({
|
const result = await callBrowserAct<{ result?: unknown }>({
|
||||||
parent,
|
parent,
|
||||||
profile,
|
profile,
|
||||||
body: {
|
body: {
|
||||||
@ -66,7 +66,7 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
||||||
: undefined;
|
: undefined;
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
||||||
const result = await callBrowserAct({
|
const result = await callBrowserAct<{ result?: unknown }>({
|
||||||
parent,
|
parent,
|
||||||
profile,
|
profile,
|
||||||
body: {
|
body: {
|
||||||
@ -108,7 +108,7 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await callBrowserAct<{ result?: unknown }>({
|
||||||
parent,
|
parent,
|
||||||
profile,
|
profile,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@ -1,23 +1,52 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
|
||||||
const clientMocks = vi.hoisted(() => ({
|
const gatewayMocks = vi.hoisted(() => ({
|
||||||
browserSnapshot: vi.fn(async () => ({
|
callGatewayFromCli: vi.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
format: "ai",
|
format: "ai",
|
||||||
targetId: "t1",
|
targetId: "t1",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
snapshot: "ok",
|
snapshot: "ok",
|
||||||
})),
|
})),
|
||||||
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("../browser/client.js", () => clientMocks);
|
|
||||||
|
vi.mock("./gateway-rpc.js", () => ({
|
||||||
|
callGatewayFromCli: gatewayMocks.callGatewayFromCli,
|
||||||
|
}));
|
||||||
|
|
||||||
const configMocks = vi.hoisted(() => ({
|
const configMocks = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
}));
|
}));
|
||||||
vi.mock("../config/config.js", () => configMocks);
|
vi.mock("../config/config.js", () => configMocks);
|
||||||
|
|
||||||
|
const sharedMocks = vi.hoisted(() => ({
|
||||||
|
callBrowserRequest: vi.fn(
|
||||||
|
async (_opts: unknown, params: { path?: string; query?: Record<string, unknown> }) => {
|
||||||
|
const format = params.query?.format === "aria" ? "aria" : "ai";
|
||||||
|
if (format === "aria") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
format: "aria",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
format: "ai",
|
||||||
|
targetId: "t1",
|
||||||
|
url: "https://example.com",
|
||||||
|
snapshot: "ok",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("./browser-cli-shared.js", () => ({
|
||||||
|
callBrowserRequest: sharedMocks.callBrowserRequest,
|
||||||
|
}));
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@ -37,6 +66,7 @@ describe("browser cli snapshot defaults", () => {
|
|||||||
configMocks.loadConfig.mockReturnValue({
|
configMocks.loadConfig.mockReturnValue({
|
||||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const browser = program.command("browser").option("--json", false);
|
const browser = program.command("browser").option("--json", false);
|
||||||
@ -44,26 +74,28 @@ describe("browser cli snapshot defaults", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
||||||
|
|
||||||
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
|
||||||
"http://127.0.0.1:18791",
|
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
||||||
expect.objectContaining({
|
expect(params?.path).toBe("/snapshot");
|
||||||
format: "ai",
|
expect(params?.query).toMatchObject({
|
||||||
mode: "efficient",
|
format: "ai",
|
||||||
}),
|
mode: "efficient",
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||||
configMocks.loadConfig.mockReturnValue({
|
configMocks.loadConfig.mockReturnValue({
|
||||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
});
|
});
|
||||||
clientMocks.browserSnapshot.mockResolvedValueOnce({
|
|
||||||
|
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
format: "aria",
|
format: "aria",
|
||||||
targetId: "t1",
|
targetId: "t1",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
nodes: [],
|
nodes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const browser = program.command("browser").option("--json", false);
|
const browser = program.command("browser").option("--json", false);
|
||||||
@ -71,8 +103,9 @@ describe("browser cli snapshot defaults", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
||||||
|
|
||||||
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
|
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
|
||||||
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
||||||
expect(opts?.mode).toBeUndefined();
|
expect(params?.path).toBe("/snapshot");
|
||||||
|
expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
let previousProfile: string | undefined;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||||
|
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (previousProfile === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_PROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
loadSessionStore: vi.fn().mockReturnValue({
|
loadSessionStore: vi.fn().mockReturnValue({
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import { normalizeAccountId } from "../routing/session-key.js";
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
import type { ClawdbotConfig } from "./config.js";
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type GroupPolicyChannel = ChannelId;
|
export type GroupPolicyChannel = ChannelId;
|
||||||
|
|
||||||
export type ChannelGroupConfig = {
|
export type ChannelGroupConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelGroupPolicy = {
|
export type ChannelGroupPolicy = {
|
||||||
@ -19,6 +20,65 @@ export type ChannelGroupPolicy = {
|
|||||||
|
|
||||||
type ChannelGroups = Record<string, ChannelGroupConfig>;
|
type ChannelGroups = Record<string, ChannelGroupConfig>;
|
||||||
|
|
||||||
|
export type GroupToolPolicySender = {
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSenderKey(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
||||||
|
return withoutAt.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolsBySender(
|
||||||
|
params: {
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
|
} & GroupToolPolicySender,
|
||||||
|
): GroupToolPolicyConfig | undefined {
|
||||||
|
const toolsBySender = params.toolsBySender;
|
||||||
|
if (!toolsBySender) return undefined;
|
||||||
|
const entries = Object.entries(toolsBySender);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
|
||||||
|
const normalized = new Map<string, GroupToolPolicyConfig>();
|
||||||
|
let wildcard: GroupToolPolicyConfig | undefined;
|
||||||
|
for (const [rawKey, policy] of entries) {
|
||||||
|
if (!policy) continue;
|
||||||
|
const key = normalizeSenderKey(rawKey);
|
||||||
|
if (!key) continue;
|
||||||
|
if (key === "*") {
|
||||||
|
wildcard = policy;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!normalized.has(key)) {
|
||||||
|
normalized.set(key, policy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const pushCandidate = (value?: string | null) => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
candidates.push(trimmed);
|
||||||
|
};
|
||||||
|
pushCandidate(params.senderId);
|
||||||
|
pushCandidate(params.senderE164);
|
||||||
|
pushCandidate(params.senderUsername);
|
||||||
|
pushCandidate(params.senderName);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const key = normalizeSenderKey(candidate);
|
||||||
|
if (!key) continue;
|
||||||
|
const match = normalized.get(key);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return wildcard;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveChannelGroups(
|
function resolveChannelGroups(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
channel: GroupPolicyChannel,
|
channel: GroupPolicyChannel,
|
||||||
@ -94,14 +154,32 @@ export function resolveChannelGroupRequireMention(params: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveChannelGroupToolsPolicy(params: {
|
export function resolveChannelGroupToolsPolicy(
|
||||||
cfg: ClawdbotConfig;
|
params: {
|
||||||
channel: GroupPolicyChannel;
|
cfg: ClawdbotConfig;
|
||||||
groupId?: string | null;
|
channel: GroupPolicyChannel;
|
||||||
accountId?: string | null;
|
groupId?: string | null;
|
||||||
}): GroupToolPolicyConfig | undefined {
|
accountId?: string | null;
|
||||||
|
} & GroupToolPolicySender,
|
||||||
|
): GroupToolPolicyConfig | undefined {
|
||||||
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
||||||
|
const groupSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: groupConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (groupSenderPolicy) return groupSenderPolicy;
|
||||||
if (groupConfig?.tools) return groupConfig.tools;
|
if (groupConfig?.tools) return groupConfig.tools;
|
||||||
|
const defaultSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: defaultConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (defaultSenderPolicy) return defaultSenderPolicy;
|
||||||
if (defaultConfig?.tools) return defaultConfig.tools;
|
if (defaultConfig?.tools) return defaultConfig.tools;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -279,6 +279,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
"ui.assistant.name": "Assistant Name",
|
"ui.assistant.name": "Assistant Name",
|
||||||
"ui.assistant.avatar": "Assistant Avatar",
|
"ui.assistant.avatar": "Assistant Avatar",
|
||||||
|
"browser.evaluateEnabled": "Browser Evaluate Enabled",
|
||||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export type BrowserSnapshotDefaults = {
|
|||||||
};
|
};
|
||||||
export type BrowserConfig = {
|
export type BrowserConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** If false, disable browser act:evaluate (arbitrary JS). Default: true */
|
||||||
|
evaluateEnabled?: boolean;
|
||||||
/** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */
|
/** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
/** Remote CDP HTTP timeout (ms). Default: 1500. */
|
/** Remote CDP HTTP timeout (ms). Default: 1500. */
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type DiscordDmConfig = {
|
export type DiscordDmConfig = {
|
||||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||||
@ -28,6 +28,7 @@ export type DiscordGuildChannelConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this channel. */
|
/** Optional tool policy overrides for this channel. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
/** If false, disable the bot for this channel. */
|
/** If false, disable the bot for this channel. */
|
||||||
@ -45,6 +46,7 @@ export type DiscordGuildEntry = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this guild (used when channel override is missing). */
|
/** Optional tool policy overrides for this guild (used when channel override is missing). */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: DiscordReactionNotificationMode;
|
reactionNotifications?: DiscordReactionNotificationMode;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type IMessageAccountConfig = {
|
export type IMessageAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
@ -64,6 +64,7 @@ export type IMessageAccountConfig = {
|
|||||||
{
|
{
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
/** Heartbeat visibility settings for this channel. */
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type MSTeamsWebhookConfig = {
|
export type MSTeamsWebhookConfig = {
|
||||||
/** Port for the webhook server. Default: 3978. */
|
/** Port for the webhook server. Default: 3978. */
|
||||||
@ -24,6 +24,7 @@ export type MSTeamsChannelConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this channel. */
|
/** Optional tool policy overrides for this channel. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
||||||
replyStyle?: MSTeamsReplyStyle;
|
replyStyle?: MSTeamsReplyStyle;
|
||||||
};
|
};
|
||||||
@ -34,6 +35,7 @@ export type MSTeamsTeamConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Default tool policy for channels in this team. */
|
/** Default tool policy for channels in this team. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Default reply style for channels in this team. */
|
/** Default reply style for channels in this team. */
|
||||||
replyStyle?: MSTeamsReplyStyle;
|
replyStyle?: MSTeamsReplyStyle;
|
||||||
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type SlackDmConfig = {
|
export type SlackDmConfig = {
|
||||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||||
@ -33,6 +33,7 @@ export type SlackChannelConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this channel. */
|
/** Optional tool policy overrides for this channel. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||||
allowBots?: boolean;
|
allowBots?: boolean;
|
||||||
/** Allowlist of users that can invoke the bot in this channel. */
|
/** Allowlist of users that can invoke the bot in this channel. */
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type TelegramActionConfig = {
|
export type TelegramActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
@ -146,6 +146,7 @@ export type TelegramGroupConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this group. */
|
/** Optional tool policy overrides for this group. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
/** Per-topic configuration (key is message_thread_id as string) */
|
/** Per-topic configuration (key is message_thread_id as string) */
|
||||||
|
|||||||
@ -158,6 +158,8 @@ export type GroupToolPolicyConfig = {
|
|||||||
deny?: string[];
|
deny?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupToolPolicyBySenderConfig = Record<string, GroupToolPolicyConfig>;
|
||||||
|
|
||||||
export type ExecToolConfig = {
|
export type ExecToolConfig = {
|
||||||
/** Exec host routing (default: sandbox). */
|
/** Exec host routing (default: sandbox). */
|
||||||
host?: "sandbox" | "gateway" | "node";
|
host?: "sandbox" | "gateway" | "node";
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type WhatsAppActionConfig = {
|
export type WhatsAppActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
@ -70,6 +70,7 @@ export type WhatsAppConfig = {
|
|||||||
{
|
{
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||||
@ -135,6 +136,7 @@ export type WhatsAppAccountConfig = {
|
|||||||
{
|
{
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import {
|
|||||||
resolveTelegramCustomCommands,
|
resolveTelegramCustomCommands,
|
||||||
} from "./telegram-custom-commands.js";
|
} from "./telegram-custom-commands.js";
|
||||||
|
|
||||||
|
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||||
|
|
||||||
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
||||||
|
|
||||||
const TelegramCapabilitiesSchema = z.union([
|
const TelegramCapabilitiesSchema = z.union([
|
||||||
@ -47,6 +49,7 @@ export const TelegramGroupSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
@ -186,6 +189,7 @@ export const DiscordGuildChannelSchema = z
|
|||||||
allow: z.boolean().optional(),
|
allow: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
@ -199,6 +203,7 @@ export const DiscordGuildSchema = z
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
||||||
@ -374,6 +379,7 @@ export const SlackChannelSchema = z
|
|||||||
allow: z.boolean().optional(),
|
allow: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
@ -584,6 +590,7 @@ export const IMessageAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -640,6 +647,7 @@ const BlueBubblesGroupConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@ -699,6 +707,7 @@ export const MSTeamsChannelSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@ -707,6 +716,7 @@ export const MSTeamsTeamSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||||
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
||||||
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||||
|
|
||||||
|
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||||
|
|
||||||
export const WhatsAppAccountSchema = z
|
export const WhatsAppAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@ -41,6 +43,7 @@ export const WhatsAppAccountSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -105,6 +108,7 @@ export const WhatsAppConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export const ClawdbotSchema = z
|
|||||||
browser: z
|
browser: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
evaluateEnabled: z.boolean().optional(),
|
||||||
cdpUrl: z.string().optional(),
|
cdpUrl: z.string().optional(),
|
||||||
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
|
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
|
||||||
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
|
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { HookEligibilityContext, HookEntry } from "./types.js";
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||||
"browser.enabled": true,
|
"browser.enabled": true,
|
||||||
|
"browser.evaluateEnabled": true,
|
||||||
"workspace.dir": true,
|
"workspace.dir": true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
|
||||||
|
|
||||||
vi.mock("../agents/model-auth.js", () => ({
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
resolveApiKeyForProvider: vi.fn(),
|
resolveApiKeyForProvider: vi.fn(),
|
||||||
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
||||||
@ -193,6 +195,13 @@ describe("embedding provider auto selection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses gemini when openai is missing", async () => {
|
it("uses gemini when openai is missing", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ embedding: { values: [1, 2, 3] } }),
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
const { createEmbeddingProvider } = await import("./embeddings.js");
|
const { createEmbeddingProvider } = await import("./embeddings.js");
|
||||||
const authModule = await import("../agents/model-auth.js");
|
const authModule = await import("../agents/model-auth.js");
|
||||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||||
@ -214,6 +223,44 @@ describe("embedding provider auto selection", () => {
|
|||||||
|
|
||||||
expect(result.requestedProvider).toBe("auto");
|
expect(result.requestedProvider).toBe("auto");
|
||||||
expect(result.provider.id).toBe("gemini");
|
expect(result.provider.id).toBe("gemini");
|
||||||
|
await result.provider.embedQuery("hello");
|
||||||
|
const [url] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(url).toBe(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_EMBEDDING_MODEL}:embedContent`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit model when openai is selected", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: [{ embedding: [1, 2, 3] }] }),
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { createEmbeddingProvider } = await import("./embeddings.js");
|
||||||
|
const authModule = await import("../agents/model-auth.js");
|
||||||
|
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||||
|
if (provider === "openai") {
|
||||||
|
return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected provider ${provider}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createEmbeddingProvider({
|
||||||
|
config: {} as never,
|
||||||
|
provider: "auto",
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
fallback: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.requestedProvider).toBe("auto");
|
||||||
|
expect(result.provider.id).toBe("openai");
|
||||||
|
await result.provider.embedQuery("hello");
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(url).toBe("https://api.openai.com/v1/embeddings");
|
||||||
|
const payload = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||||
|
expect(payload.model).toBe("text-embedding-3-small");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { buildPairingReply } from "./pairing-messages.js";
|
import { buildPairingReply } from "./pairing-messages.js";
|
||||||
|
|
||||||
describe("buildPairingReply", () => {
|
describe("buildPairingReply", () => {
|
||||||
|
let previousProfile: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||||
|
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousProfile === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_PROFILE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||||
|
});
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
{
|
{
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export type {
|
|||||||
DmConfig,
|
DmConfig,
|
||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
GroupToolPolicyConfig,
|
GroupToolPolicyConfig,
|
||||||
|
GroupToolPolicyBySenderConfig,
|
||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
MarkdownTableMode,
|
MarkdownTableMode,
|
||||||
GoogleChatAccountConfig,
|
GoogleChatAccountConfig,
|
||||||
@ -121,6 +122,7 @@ export { resolveAckReaction } from "../agents/identity.js";
|
|||||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
export type { ChunkMode } from "../auto-reply/chunk.js";
|
export type { ChunkMode } from "../auto-reply/chunk.js";
|
||||||
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
||||||
|
export { resolveToolsBySender } from "../config/group-policy.js";
|
||||||
export {
|
export {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
|
|||||||
40
src/scripts/canvas-a2ui-copy.test.ts
Normal file
40
src/scripts/canvas-a2ui-copy.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { copyA2uiAssets } from "../../scripts/canvas-a2ui-copy.js";
|
||||||
|
|
||||||
|
describe("canvas a2ui copy", () => {
|
||||||
|
it("throws a helpful error when assets are missing", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-a2ui-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") })).rejects.toThrow(
|
||||||
|
'Run "pnpm canvas:a2ui:bundle"',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies bundled assets to dist", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-a2ui-"));
|
||||||
|
const srcDir = path.join(dir, "src");
|
||||||
|
const outDir = path.join(dir, "dist");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(srcDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
|
||||||
|
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
|
||||||
|
|
||||||
|
await copyA2uiAssets({ srcDir, outDir });
|
||||||
|
|
||||||
|
await expect(fs.stat(path.join(outDir, "index.html"))).resolves.toBeTruthy();
|
||||||
|
await expect(fs.stat(path.join(outDir, "a2ui.bundle.js"))).resolves.toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
|
|||||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveBrowserConfig } from "../browser/config.js";
|
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { generateUUID } from "./uuid";
|
import { generateUUID } from "./uuid";
|
||||||
|
|
||||||
@ -26,7 +26,13 @@ describe("generateUUID", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("still returns a v4 UUID when crypto is missing", () => {
|
it("still returns a v4 UUID when crypto is missing", () => {
|
||||||
const id = generateUUID(null);
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
try {
|
||||||
|
const id = generateUUID(null);
|
||||||
|
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@ export type CryptoLike = {
|
|||||||
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
|
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let warnedWeakCrypto = false;
|
||||||
|
|
||||||
function uuidFromBytes(bytes: Uint8Array): string {
|
function uuidFromBytes(bytes: Uint8Array): string {
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||||
@ -29,6 +31,12 @@ function weakRandomBytes(): Uint8Array {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warnWeakCryptoOnce() {
|
||||||
|
if (warnedWeakCrypto) return;
|
||||||
|
warnedWeakCrypto = true;
|
||||||
|
console.warn("[uuid] crypto API missing; falling back to weak randomness");
|
||||||
|
}
|
||||||
|
|
||||||
export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
|
export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
|
||||||
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
|
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
|
||||||
|
|
||||||
@ -38,5 +46,6 @@ export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto):
|
|||||||
return uuidFromBytes(bytes);
|
return uuidFromBytes(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warnWeakCryptoOnce();
|
||||||
return uuidFromBytes(weakRandomBytes());
|
return uuidFromBytes(weakRandomBytes());
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user