Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b13c4d0f | ||
|
|
45d2debd3f | ||
|
|
53c4bc106c | ||
|
|
0f7db91256 | ||
|
|
969510fc60 | ||
|
|
704af78a5d |
No files matched your search
@@ -14,12 +14,10 @@ Docs: https://docs.clawd.bot
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||
|
||||
@@ -27,21 +25,17 @@ Docs: https://docs.clawd.bot
|
||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||
- Agents: use the active auth profile for auto-compaction recovery.
|
||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
|
||||
@@ -17,7 +17,7 @@ read_when:
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
|
||||
@@ -74,22 +74,6 @@ Example:
|
||||
|
||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## External daemon mode (httpUrl)
|
||||
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
signal: {
|
||||
httpUrl: "http://127.0.0.1:8080",
|
||||
autoStart: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||
@@ -158,7 +142,6 @@ Provider options:
|
||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||
|
||||
@@ -525,7 +525,6 @@ Provider options:
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
|
||||
@@ -1048,7 +1048,6 @@
|
||||
"pages": [
|
||||
"platforms",
|
||||
"platforms/macos",
|
||||
"platforms/macos-vm",
|
||||
"platforms/ios",
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
|
||||
@@ -1021,7 +1021,6 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
||||
],
|
||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||
replyToMode: "first", // off | first | all
|
||||
linkPreview: true, // toggle outbound link previews
|
||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||
draftChunk: { // optional; only for streamMode=block
|
||||
minChars: 200,
|
||||
|
||||
+176
-338
File diff suppressed because it is too large.
Load diff
@@ -114,9 +114,3 @@ Git requirement:
|
||||
|
||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||
|
||||
Common Windows issues:
|
||||
|
||||
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
|
||||
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
|
||||
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
|
||||
read_when:
|
||||
- You want Clawdbot isolated from your main macOS environment
|
||||
- You want iMessage integration (BlueBubbles) in a sandbox
|
||||
- You want a resettable macOS environment you can clone
|
||||
- You want to compare local vs hosted macOS VM options
|
||||
---
|
||||
|
||||
# Clawdbot on macOS VMs (Sandboxing)
|
||||
|
||||
## Recommended default (most users)
|
||||
|
||||
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
|
||||
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
|
||||
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
|
||||
|
||||
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
|
||||
|
||||
## macOS VM options
|
||||
|
||||
### Local VM on your Apple Silicon Mac (Lume)
|
||||
|
||||
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
|
||||
|
||||
This gives you:
|
||||
- Full macOS environment in isolation (your host stays clean)
|
||||
- iMessage support via BlueBubbles (impossible on Linux/Windows)
|
||||
- Instant reset by cloning VMs
|
||||
- No extra hardware or cloud costs
|
||||
|
||||
### Hosted Mac providers (cloud)
|
||||
|
||||
If you want macOS in the cloud, hosted Mac providers work too:
|
||||
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
|
||||
- Other hosted Mac vendors also work; follow their VM + SSH docs
|
||||
|
||||
Once you have SSH access to a macOS VM, continue at step 6 below.
|
||||
|
||||
---
|
||||
|
||||
## Quick path (Lume, experienced users)
|
||||
|
||||
1. Install Lume
|
||||
2. `lume create clawdbot --os macos --ipsw latest`
|
||||
3. Complete Setup Assistant, enable Remote Login (SSH)
|
||||
4. `lume run clawdbot --no-display`
|
||||
5. SSH in, install Clawdbot, configure channels
|
||||
6. Done
|
||||
|
||||
---
|
||||
|
||||
## What you need (Lume)
|
||||
|
||||
- Apple Silicon Mac (M1/M2/M3/M4)
|
||||
- macOS Sequoia or later on the host
|
||||
- ~60 GB free disk space per VM
|
||||
- ~20 minutes
|
||||
|
||||
---
|
||||
|
||||
## 1) Install Lume
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
|
||||
```
|
||||
|
||||
If `~/.local/bin` isn't in your PATH:
|
||||
|
||||
```bash
|
||||
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
lume --version
|
||||
```
|
||||
|
||||
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
|
||||
|
||||
---
|
||||
|
||||
## 2) Create the macOS VM
|
||||
|
||||
```bash
|
||||
lume create clawdbot --os macos --ipsw latest
|
||||
```
|
||||
|
||||
This downloads macOS and creates the VM. A VNC window opens automatically.
|
||||
|
||||
Note: The download can take a while depending on your connection.
|
||||
|
||||
---
|
||||
|
||||
## 3) Complete Setup Assistant
|
||||
|
||||
In the VNC window:
|
||||
1. Select language and region
|
||||
2. Skip Apple ID (or sign in if you want iMessage later)
|
||||
3. Create a user account (remember the username and password)
|
||||
4. Skip all optional features
|
||||
|
||||
After setup completes, enable SSH:
|
||||
1. Open System Settings → General → Sharing
|
||||
2. Enable "Remote Login"
|
||||
|
||||
---
|
||||
|
||||
## 4) Get the VM's IP address
|
||||
|
||||
```bash
|
||||
lume get clawdbot
|
||||
```
|
||||
|
||||
Look for the IP address (usually `192.168.64.x`).
|
||||
|
||||
---
|
||||
|
||||
## 5) SSH into the VM
|
||||
|
||||
```bash
|
||||
ssh youruser@192.168.64.X
|
||||
```
|
||||
|
||||
Replace `youruser` with the account you created, and the IP with your VM's IP.
|
||||
|
||||
---
|
||||
|
||||
## 6) Install Clawdbot
|
||||
|
||||
Inside the VM:
|
||||
|
||||
```bash
|
||||
npm install -g clawdbot@latest
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 7) Configure channels
|
||||
|
||||
Edit the config file:
|
||||
|
||||
```bash
|
||||
nano ~/.clawdbot/clawdbot.json
|
||||
```
|
||||
|
||||
Add your channels:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["+15551234567"]
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "YOUR_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then login to WhatsApp (scan QR):
|
||||
|
||||
```bash
|
||||
clawdbot channels login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Run the VM headlessly
|
||||
|
||||
Stop the VM and restart without display:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot
|
||||
lume run clawdbot --no-display
|
||||
```
|
||||
|
||||
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
|
||||
|
||||
To check status:
|
||||
|
||||
```bash
|
||||
ssh youruser@192.168.64.X "clawdbot status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bonus: iMessage integration
|
||||
|
||||
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
|
||||
|
||||
Inside the VM:
|
||||
|
||||
1. Download BlueBubbles from bluebubbles.app
|
||||
2. Sign in with your Apple ID
|
||||
3. Enable the Web API and set a password
|
||||
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
|
||||
|
||||
Add to your Clawdbot config:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"bluebubbles": {
|
||||
"serverUrl": "http://localhost:1234",
|
||||
"password": "your-api-password",
|
||||
"webhookPath": "/bluebubbles-webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway. Now your agent can send and receive iMessages.
|
||||
|
||||
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
|
||||
|
||||
---
|
||||
|
||||
## Save a golden image
|
||||
|
||||
Before customizing further, snapshot your clean state:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot
|
||||
lume clone clawdbot clawdbot-golden
|
||||
```
|
||||
|
||||
Reset anytime:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot && lume delete clawdbot
|
||||
lume clone clawdbot-golden clawdbot
|
||||
lume run clawdbot --no-display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running 24/7
|
||||
|
||||
Keep the VM running by:
|
||||
- Keeping your Mac plugged in
|
||||
- Disabling sleep in System Settings → Energy Saver
|
||||
- Using `caffeinate` if needed
|
||||
|
||||
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
|
||||
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
|
||||
| Lume command not found | Add `~/.local/bin` to your PATH |
|
||||
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [VPS hosting](/vps)
|
||||
- [Nodes](/nodes)
|
||||
- [Gateway remote](/gateway/remote)
|
||||
- [BlueBubbles channel](/channels/bluebubbles)
|
||||
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
|
||||
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
|
||||
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
|
||||
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { NoticeLevel, ReasoningLevel } from "../thinking.js";
|
||||
import type { ReasoningLevel } from "../thinking.js";
|
||||
import {
|
||||
type ElevatedLevel,
|
||||
normalizeElevatedLevel,
|
||||
normalizeNoticeLevel,
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
@@ -113,22 +112,6 @@ export function extractVerboseDirective(body?: string): {
|
||||
};
|
||||
}
|
||||
|
||||
export function extractNoticeDirective(body?: string): {
|
||||
cleaned: string;
|
||||
noticeLevel?: NoticeLevel;
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
noticeLevel: extracted.level,
|
||||
rawLevel: extracted.rawLevel,
|
||||
hasDirective: extracted.hasDirective,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractElevatedDirective(body?: string): {
|
||||
cleaned: string;
|
||||
elevatedLevel?: ElevatedLevel;
|
||||
@@ -169,5 +152,5 @@ export function extractStatusDirective(body?: string): {
|
||||
return extractSimpleDirective(body, ["status"]);
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
@@ -1,6 +1,5 @@
|
||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
export type VerboseLevel = "off" | "on" | "full";
|
||||
export type NoticeLevel = "off" | "on" | "full";
|
||||
export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
||||
export type ElevatedMode = "off" | "ask" | "full";
|
||||
export type ReasoningLevel = "off" | "on" | "stream";
|
||||
@@ -94,16 +93,6 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize system notice flags used to toggle system notifications.
|
||||
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "all", "everything"].includes(key)) return "full";
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize response-usage display modes used to toggle per-response usage footers.
|
||||
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
|
||||
@@ -33,8 +33,6 @@ export type SignalAccountConfig = {
|
||||
cliPath?: string;
|
||||
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
||||
autoStart?: boolean;
|
||||
/** Max time to wait for signal-cli daemon startup (ms, cap 120000). */
|
||||
startupTimeoutMs?: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
|
||||
@@ -118,8 +118,6 @@ export type TelegramAccountConfig = {
|
||||
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Controls whether link previews are shown in outbound messages. Default: true. */
|
||||
linkPreview?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramTopicConfig = {
|
||||
|
||||
@@ -125,7 +125,6 @@ export const TelegramAccountSchemaBase = z
|
||||
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
linkPreview: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -487,7 +486,6 @@ export const SignalAccountSchemaBase = z
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
cliPath: ExecutableTokenSchema.optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
startupTimeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().optional(),
|
||||
|
||||
+68
-19
@@ -1,28 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { resolveGatewayListenHosts } from "./net.js";
|
||||
const testTailnetIPv4 = { value: undefined as string | undefined };
|
||||
const testTailnetIPv6 = { value: undefined as string | undefined };
|
||||
|
||||
describe("resolveGatewayListenHosts", () => {
|
||||
it("returns the input host when not loopback", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||
canBindToHost: async () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
});
|
||||
expect(hosts).toEqual(["0.0.0.0"]);
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
||||
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
||||
}));
|
||||
|
||||
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
|
||||
|
||||
describe("gateway net", () => {
|
||||
beforeEach(() => {
|
||||
testTailnetIPv4.value = undefined;
|
||||
testTailnetIPv6.value = undefined;
|
||||
});
|
||||
|
||||
it("adds ::1 when IPv6 loopback is available", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||
canBindToHost: async () => true,
|
||||
});
|
||||
expect(hosts).toEqual(["127.0.0.1", "::1"]);
|
||||
test("treats loopback as local", () => {
|
||||
expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("::1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||
canBindToHost: async () => false,
|
||||
test("treats local tailnet IPv4 as local", () => {
|
||||
testTailnetIPv4.value = "100.64.0.1";
|
||||
expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores non-matching tailnet IPv4", () => {
|
||||
testTailnetIPv4.value = "100.64.0.1";
|
||||
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
|
||||
});
|
||||
|
||||
test("treats local tailnet IPv6 as local", () => {
|
||||
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
|
||||
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses forwarded-for when remote is a trusted proxy", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.2",
|
||||
forwardedFor: "203.0.113.9, 10.0.0.2",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(hosts).toEqual(["127.0.0.1"]);
|
||||
expect(clientIp).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
test("ignores forwarded-for from untrusted proxies", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.3",
|
||||
forwardedFor: "203.0.113.9",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("10.0.0.3");
|
||||
});
|
||||
|
||||
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "::ffff:10.0.0.2",
|
||||
forwardedFor: "203.0.113.9:1234",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
test("falls back to x-real-ip when forwarded-for is missing", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.2",
|
||||
realIp: "203.0.113.10",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.10");
|
||||
});
|
||||
});
|
||||
+6
-16
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
|
||||
|
||||
if (mode === "loopback") {
|
||||
// 127.0.0.1 rarely fails, but handle gracefully
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0"; // extreme fallback
|
||||
}
|
||||
|
||||
if (mode === "tailnet") {
|
||||
const tailnetIP = pickPrimaryTailnetIPv4();
|
||||
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP;
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
|
||||
const host = customHost?.trim();
|
||||
if (!host) return "0.0.0.0"; // invalid config → fall back to all
|
||||
|
||||
if (isValidIPv4(host) && (await canBindToHost(host))) return host;
|
||||
if (isValidIPv4(host) && (await canBindTo(host))) return host;
|
||||
// Custom IP failed → fall back to LAN
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
|
||||
* @param host - The host address to test
|
||||
* @returns True if we can successfully bind to this address
|
||||
*/
|
||||
export async function canBindToHost(host: string): Promise<boolean> {
|
||||
async function canBindTo(host: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const testServer = net.createServer();
|
||||
testServer.once("error", () => {
|
||||
@@ -151,16 +151,6 @@ export async function canBindToHost(host: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveGatewayListenHosts(
|
||||
bindHost: string,
|
||||
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
|
||||
): Promise<string[]> {
|
||||
if (bindHost !== "127.0.0.1") return [bindHost];
|
||||
const canBind = opts?.canBindToHost ?? canBindToHost;
|
||||
if (await canBind("::1")) return [bindHost, "::1"];
|
||||
return [bindHost];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid IPv4 address.
|
||||
*
|
||||
|
||||
@@ -28,7 +28,6 @@ export function createGatewayCloseHandler(params: {
|
||||
browserControl: { stop: () => Promise<void> } | null;
|
||||
wss: WebSocketServer;
|
||||
httpServer: HttpServer;
|
||||
httpServers?: HttpServer[];
|
||||
}) {
|
||||
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
|
||||
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
||||
@@ -109,20 +108,14 @@ export function createGatewayCloseHandler(params: {
|
||||
await params.browserControl.stop().catch(() => {});
|
||||
}
|
||||
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
||||
const servers =
|
||||
params.httpServers && params.httpServers.length > 0
|
||||
? params.httpServers
|
||||
: [params.httpServer];
|
||||
for (const server of servers) {
|
||||
const httpServer = server as HttpServer & {
|
||||
closeIdleConnections?: () => void;
|
||||
};
|
||||
if (typeof httpServer.closeIdleConnections === "function") {
|
||||
httpServer.closeIdleConnections();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
const httpServer = params.httpServer as HttpServer & {
|
||||
closeIdleConnections?: () => void;
|
||||
};
|
||||
if (typeof httpServer.closeIdleConnections === "function") {
|
||||
httpServer.closeIdleConnections();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
params.httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
import type { HooksConfigResolved } from "./hooks.js";
|
||||
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||
import { resolveGatewayListenHosts } from "./net.js";
|
||||
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||
@@ -39,14 +38,11 @@ export async function createGatewayRuntimeState(params: {
|
||||
canvasHostEnabled: boolean;
|
||||
allowCanvasHostInTests?: boolean;
|
||||
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
log: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
logHooks: ReturnType<typeof createSubsystemLogger>;
|
||||
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
||||
}): Promise<{
|
||||
canvasHost: CanvasHostHandler | null;
|
||||
httpServer: HttpServer;
|
||||
httpServers: HttpServer[];
|
||||
httpBindHosts: string[];
|
||||
wss: WebSocketServer;
|
||||
clients: Set<GatewayWsClient>;
|
||||
broadcast: (
|
||||
@@ -104,49 +100,30 @@ export async function createGatewayRuntimeState(params: {
|
||||
log: params.logPlugins,
|
||||
});
|
||||
|
||||
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
||||
const httpServers: HttpServer[] = [];
|
||||
const httpBindHosts: string[] = [];
|
||||
for (const host of bindHosts) {
|
||||
const httpServer = createGatewayHttpServer({
|
||||
canvasHost,
|
||||
controlUiEnabled: params.controlUiEnabled,
|
||||
controlUiBasePath: params.controlUiBasePath,
|
||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||
openResponsesEnabled: params.openResponsesEnabled,
|
||||
openResponsesConfig: params.openResponsesConfig,
|
||||
handleHooksRequest,
|
||||
handlePluginRequest,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
});
|
||||
try {
|
||||
await listenGatewayHttpServer({
|
||||
httpServer,
|
||||
bindHost: host,
|
||||
port: params.port,
|
||||
});
|
||||
httpServers.push(httpServer);
|
||||
httpBindHosts.push(host);
|
||||
} catch (err) {
|
||||
if (host === bindHosts[0]) throw err;
|
||||
params.log.warn(
|
||||
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const httpServer = httpServers[0];
|
||||
if (!httpServer) {
|
||||
throw new Error("Gateway HTTP server failed to start");
|
||||
}
|
||||
const httpServer = createGatewayHttpServer({
|
||||
canvasHost,
|
||||
controlUiEnabled: params.controlUiEnabled,
|
||||
controlUiBasePath: params.controlUiBasePath,
|
||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||
openResponsesEnabled: params.openResponsesEnabled,
|
||||
openResponsesConfig: params.openResponsesConfig,
|
||||
handleHooksRequest,
|
||||
handlePluginRequest,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
});
|
||||
|
||||
await listenGatewayHttpServer({
|
||||
httpServer,
|
||||
bindHost: params.bindHost,
|
||||
port: params.port,
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
});
|
||||
for (const server of httpServers) {
|
||||
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
|
||||
}
|
||||
attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
|
||||
|
||||
const clients = new Set<GatewayWsClient>();
|
||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||
@@ -163,8 +140,6 @@ export async function createGatewayRuntimeState(params: {
|
||||
return {
|
||||
canvasHost,
|
||||
httpServer,
|
||||
httpServers,
|
||||
httpBindHosts,
|
||||
wss,
|
||||
clients,
|
||||
broadcast,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getResolvedLoggerSettings } from "../logging.js";
|
||||
export function logGatewayStartup(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
bindHost: string;
|
||||
bindHosts?: string[];
|
||||
port: number;
|
||||
tlsEnabled?: boolean;
|
||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
@@ -23,16 +22,9 @@ export function logGatewayStartup(params: {
|
||||
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
||||
});
|
||||
const scheme = params.tlsEnabled ? "wss" : "ws";
|
||||
const formatHost = (host: string) => (host.includes(":") ? `[${host}]` : host);
|
||||
const hosts =
|
||||
params.bindHosts && params.bindHosts.length > 0 ? params.bindHosts : [params.bindHost];
|
||||
const primaryHost = hosts[0] ?? params.bindHost;
|
||||
params.log.info(
|
||||
`listening on ${scheme}://${formatHost(primaryHost)}:${params.port} (PID ${process.pid})`,
|
||||
`listening on ${scheme}://${params.bindHost}:${params.port} (PID ${process.pid})`,
|
||||
);
|
||||
for (const host of hosts.slice(1)) {
|
||||
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
|
||||
}
|
||||
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||
if (params.isNixMode) {
|
||||
params.log.info("gateway: running in Nix mode (config managed externally)");
|
||||
|
||||
@@ -230,21 +230,6 @@ describe("gateway server auth/connect", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("returns control ui hint when token is missing", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("Control UI settings");
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects control ui without device identity by default", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, {
|
||||
|
||||
@@ -263,8 +263,6 @@ export async function startGatewayServer(
|
||||
const {
|
||||
canvasHost,
|
||||
httpServer,
|
||||
httpServers,
|
||||
httpBindHosts,
|
||||
wss,
|
||||
clients,
|
||||
broadcast,
|
||||
@@ -294,7 +292,6 @@ export async function startGatewayServer(
|
||||
canvasHostEnabled,
|
||||
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
||||
logCanvas,
|
||||
log,
|
||||
logHooks,
|
||||
logPlugins,
|
||||
});
|
||||
@@ -467,7 +464,6 @@ export async function startGatewayServer(
|
||||
logGatewayStartup({
|
||||
cfg: cfgAtStart,
|
||||
bindHost,
|
||||
bindHosts: httpBindHosts,
|
||||
port,
|
||||
tlsEnabled: gatewayTls.enabled,
|
||||
log,
|
||||
@@ -556,7 +552,6 @@ export async function startGatewayServer(
|
||||
browserControl,
|
||||
wss,
|
||||
httpServer,
|
||||
httpServers,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -66,34 +66,19 @@ function formatGatewayAuthFailureMessage(params: {
|
||||
authMode: ResolvedGatewayAuth["mode"];
|
||||
authProvided: AuthProvidedKind;
|
||||
reason?: string;
|
||||
client?: { id?: string | null; mode?: string | null };
|
||||
}): string {
|
||||
const { authMode, authProvided, reason, client } = params;
|
||||
const isCli = isGatewayCliClient(client);
|
||||
const isControlUi = client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||
const isWebchat = isWebchatClient(client);
|
||||
const uiHint = "open a tokenized dashboard URL or paste token in Control UI settings";
|
||||
const tokenHint = isCli
|
||||
? "set gateway.remote.token to match gateway.auth.token"
|
||||
: isControlUi || isWebchat
|
||||
? uiHint
|
||||
: "provide gateway auth token";
|
||||
const passwordHint = isCli
|
||||
? "set gateway.remote.password to match gateway.auth.password"
|
||||
: isControlUi || isWebchat
|
||||
? "enter the password in Control UI settings"
|
||||
: "provide gateway auth password";
|
||||
const { authMode, authProvided, reason } = params;
|
||||
switch (reason) {
|
||||
case "token_missing":
|
||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
||||
return "unauthorized: gateway token missing (set gateway.remote.token to match gateway.auth.token)";
|
||||
case "token_mismatch":
|
||||
return `unauthorized: gateway token mismatch (${tokenHint})`;
|
||||
return "unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)";
|
||||
case "token_missing_config":
|
||||
return "unauthorized: gateway token not configured on gateway (set gateway.auth.token)";
|
||||
case "password_missing":
|
||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
||||
return "unauthorized: gateway password missing (set gateway.remote.password to match gateway.auth.password)";
|
||||
case "password_mismatch":
|
||||
return `unauthorized: gateway password mismatch (${passwordHint})`;
|
||||
return "unauthorized: gateway password mismatch (set gateway.remote.password to match gateway.auth.password)";
|
||||
case "password_missing_config":
|
||||
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
|
||||
case "tailscale_user_missing":
|
||||
@@ -105,10 +90,10 @@ function formatGatewayAuthFailureMessage(params: {
|
||||
}
|
||||
|
||||
if (authMode === "token" && authProvided === "none") {
|
||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
||||
return "unauthorized: gateway token missing (set gateway.remote.token to match gateway.auth.token)";
|
||||
}
|
||||
if (authMode === "password" && authProvided === "none") {
|
||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
||||
return "unauthorized: gateway password missing (set gateway.remote.password to match gateway.auth.password)";
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
@@ -547,7 +532,6 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
reason: authResult.reason,
|
||||
client: connectParams.client,
|
||||
});
|
||||
setCloseCause("unauthorized", {
|
||||
authMode: resolvedAuth.mode,
|
||||
|
||||
@@ -126,90 +126,6 @@ describe("monitorSignalProvider tool results", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses startupTimeoutMs override when provided", async () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
config = {
|
||||
...config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
signal: {
|
||||
autoStart: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
return;
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
startupTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 90_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("caps startupTimeoutMs at 2 minutes", async () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
config = {
|
||||
...config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
signal: {
|
||||
autoStart: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
startupTimeoutMs: 180_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
return;
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 120_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
const abortController = new AbortController();
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
|
||||
@@ -43,7 +43,6 @@ export type MonitorSignalOpts = {
|
||||
config?: ClawdbotConfig;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
cliPath?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: number;
|
||||
@@ -286,10 +285,6 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const startupTimeoutMs = Math.min(
|
||||
120_000,
|
||||
Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000),
|
||||
);
|
||||
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||
|
||||
@@ -320,7 +315,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
await waitForSignalDaemonReady({
|
||||
baseUrl,
|
||||
abortSignal: opts.abortSignal,
|
||||
timeoutMs: startupTimeoutMs,
|
||||
timeoutMs: 30_000,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
|
||||
@@ -151,7 +151,6 @@ export const dispatchTelegramMessage = async ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@@ -348,7 +348,6 @@ export const registerTelegramNativeCommands = ({
|
||||
messageThreadId: resolvedThreadId,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@@ -108,60 +108,4 @@ describe("deliverReplies", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes link_preview_options when linkPreview is false", async () => {
|
||||
const runtime = { error: vi.fn(), log: vi.fn() };
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 3,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
linkPreview: false,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
link_preview_options: { is_disabled: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include link_preview_options when linkPreview is true", async () => {
|
||||
const runtime = { error: vi.fn(), log: vi.fn() };
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 4,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
linkPreview: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
link_preview_options: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,11 +36,8 @@ export async function deliverReplies(params: {
|
||||
chunkMode?: ChunkMode;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
}) {
|
||||
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } =
|
||||
params;
|
||||
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params;
|
||||
const chunkMode = params.chunkMode ?? "length";
|
||||
const threadParams = buildTelegramThreadParams(messageThreadId);
|
||||
let hasReplied = false;
|
||||
@@ -88,7 +85,6 @@ export async function deliverReplies(params: {
|
||||
messageThreadId,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -184,7 +180,6 @@ export async function deliverReplies(params: {
|
||||
messageThreadId,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -253,22 +248,17 @@ async function sendTelegramText(
|
||||
messageThreadId?: number;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
},
|
||||
): Promise<number | undefined> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
messageThreadId: opts?.messageThreadId,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
||||
try {
|
||||
const res = await bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...baseParams,
|
||||
});
|
||||
return res.message_id;
|
||||
@@ -278,7 +268,6 @@ async function sendTelegramText(
|
||||
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
|
||||
const fallbackText = opts?.plainText ?? text;
|
||||
const res = await bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...baseParams,
|
||||
});
|
||||
return res.message_id;
|
||||
|
||||
@@ -152,62 +152,6 @@ describe("sendMessageTelegram", () => {
|
||||
expect(res.messageId).toBe("42");
|
||||
});
|
||||
|
||||
it("adds link_preview_options when previews are disabled in config", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 7,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { linkPreview: false } },
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, "hi", { token: "tok", api });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
|
||||
parse_mode: "HTML",
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps link_preview_options on plain-text fallback when disabled", async () => {
|
||||
const chatId = "123";
|
||||
const parseErr = new Error(
|
||||
"400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9",
|
||||
);
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(parseErr)
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 42,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { linkPreview: false } },
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, "_oops_", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "<i>oops</i>", {
|
||||
parse_mode: "HTML",
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_", {
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses native fetch for BAN compatibility when api is omitted", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||
|
||||
+9
-14
@@ -198,25 +198,20 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
|
||||
|
||||
// Resolve link preview setting from config (default: enabled).
|
||||
const linkPreviewEnabled = account.config.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
|
||||
const sendTelegramText = async (
|
||||
rawText: string,
|
||||
params?: Record<string, unknown>,
|
||||
fallbackText?: string,
|
||||
) => {
|
||||
const htmlText = renderHtmlText(rawText);
|
||||
const baseParams = params ? { ...params } : {};
|
||||
if (linkPreviewOptions) {
|
||||
baseParams.link_preview_options = linkPreviewOptions;
|
||||
}
|
||||
const hasBaseParams = Object.keys(baseParams).length > 0;
|
||||
const sendParams = {
|
||||
parse_mode: "HTML" as const,
|
||||
...baseParams,
|
||||
};
|
||||
const sendParams = params
|
||||
? {
|
||||
parse_mode: "HTML" as const,
|
||||
...params,
|
||||
}
|
||||
: {
|
||||
parse_mode: "HTML" as const,
|
||||
};
|
||||
const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch(
|
||||
async (err) => {
|
||||
// Telegram rejects malformed HTML (e.g., unsupported tags or entities).
|
||||
@@ -227,7 +222,7 @@ export async function sendMessageTelegram(
|
||||
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
|
||||
}
|
||||
const fallback = fallbackText ?? rawText;
|
||||
const plainParams = hasBaseParams ? baseParams : undefined;
|
||||
const plainParams = params && Object.keys(params).length > 0 ? { ...params } : undefined;
|
||||
return await request(
|
||||
() =>
|
||||
plainParams
|
||||
|
||||
@@ -124,7 +124,6 @@ export function connectGateway(host: GatewayHost) {
|
||||
mode: "webchat",
|
||||
onHello: (hello) => {
|
||||
host.connected = true;
|
||||
host.lastError = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
||||
@@ -135,10 +134,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
host.connected = false;
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
if (code !== 1012) {
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
}
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
},
|
||||
onEvent: (evt) => handleGatewayEvent(host, evt),
|
||||
onGap: ({ expected, received }) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("config view", () => {
|
||||
onSubsectionChange: vi.fn(),
|
||||
});
|
||||
|
||||
it("allows save when form is unsafe", () => {
|
||||
it("disables save when form is unsafe", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
@@ -59,28 +59,6 @@ describe("config view", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
expect(saveButton).not.toBeUndefined();
|
||||
expect(saveButton?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("disables save when schema is missing", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
...baseProps(),
|
||||
schema: null,
|
||||
formMode: "form",
|
||||
formValue: { gateway: { mode: "local" } },
|
||||
originalValue: {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||
|
||||
@@ -233,10 +233,9 @@ export function renderConfig(props: ConfigProps) {
|
||||
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
|
||||
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
|
||||
|
||||
// Save/apply buttons require actual changes to be enabled.
|
||||
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
|
||||
// Save/apply buttons require actual changes to be enabled
|
||||
const canSaveForm =
|
||||
Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
|
||||
Boolean(props.formValue) && !props.loading && !formUnsafe;
|
||||
const canSave =
|
||||
props.connected &&
|
||||
!props.saving &&
|
||||
|
||||
Reference in new issue
Block a user