Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke authored and GitHub committed 2026-01-08 19:33:56 -05:00
commit daafa9e3bc
216 files changed
+6174 -2822

No files matched your search

+63 -8
View File
@@ -1,3 +1,4 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@@ -276,10 +277,23 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
}
/**
* Read Anthropic OAuth credentials from Claude CLI's credential file.
* Claude CLI stores credentials at ~/.claude/.credentials.json
* Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS)
* or credential file (Linux/Windows).
*
* On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
* On Linux/Windows, it uses ~/.claude/.credentials.json
*/
function readClaudeCliCredentials(): OAuthCredential | null {
function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
}): OAuthCredential | null {
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) {
log.info("read anthropic credentials from claude cli keychain");
return keychainCreds;
}
}
const credPath = path.join(
resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
@@ -308,6 +322,41 @@ function readClaudeCliCredentials(): OAuthCredential | null {
};
}
/**
* Read Claude Code credentials from macOS keychain.
* Uses the `security` CLI to access keychain without native dependencies.
*/
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
try {
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w',
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
const data = JSON.parse(result.trim());
const claudeOauth = data?.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
} catch {
return null;
}
}
/**
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
* Codex CLI stores credentials at ~/.codex/auth.json
@@ -374,12 +423,15 @@ function shallowEqualOAuthCredentials(
*
* Returns true if any credentials were updated.
*/
function syncExternalCliCredentials(store: AuthProfileStore): boolean {
function syncExternalCliCredentials(
store: AuthProfileStore,
options?: { allowKeychainPrompt?: boolean },
): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude CLI
const claudeCreds = readClaudeCliCredentials();
const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
@@ -486,13 +538,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
return store;
}
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore);
const synced = syncExternalCliCredentials(asStore, options);
if (synced) {
saveJsonFile(authPath, asStore);
}
@@ -532,7 +587,7 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store);
const syncedCli = syncExternalCliCredentials(store, options);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
+2 -1
View File
@@ -10,6 +10,7 @@ import { sanitizeBinaryOutput } from "./shell-utils.js";
const isWin = process.platform === "win32";
const shortDelayCmd = isWin ? "ping -n 2 127.0.0.1 > nul" : "sleep 0.05";
const yieldDelayCmd = isWin ? "ping -n 3 127.0.0.1 > nul" : "sleep 0.2";
const longDelayCmd = isWin ? "ping -n 4 127.0.0.1 > nul" : "sleep 2";
const joinCommands = (commands: string[]) =>
commands.join(isWin ? " & " : "; ");
@@ -51,7 +52,7 @@ beforeEach(() => {
describe("bash tool backgrounding", () => {
it("backgrounds after yield and can be polled", async () => {
const result = await bashTool.execute("call1", {
command: echoAfterDelay("done"),
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
+70
View File
@@ -1180,6 +1180,76 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1);
});
it("includes browser action metadata in tool summaries", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-browser-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "browser",
toolCallId: "tool-browser-1",
args: { action: "snapshot", targetUrl: "https://example.com" },
});
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🌐");
expect(payload.text).toContain("browser");
expect(payload.text).toContain("snapshot");
expect(payload.text).toContain("https://example.com");
});
it("includes canvas action metadata in tool summaries", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-canvas-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "canvas",
toolCallId: "tool-canvas-1",
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
});
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🖼️");
expect(payload.text).toContain("canvas");
expect(payload.text).toContain("A2UI push");
expect(payload.text).toContain("/tmp/a2ui.jsonl");
});
it("skips tool summaries when shouldEmitToolResult is false", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
+27
View File
@@ -19,6 +19,7 @@ import { STATE_DIR_CLAWDBOT } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -1048,6 +1049,19 @@ export async function resolveSandboxContext(params: {
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
await syncSkillsToWorkspace({
sourceWorkspaceDir: agentWorkspaceDir,
targetWorkspaceDir: sandboxWorkspaceDir,
config: params.config,
});
} catch (error) {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
}
}
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
@@ -1109,6 +1123,19 @@ export async function ensureSandboxWorkspaceForSession(params: {
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
await syncSkillsToWorkspace({
sourceWorkspaceDir: agentWorkspaceDir,
targetWorkspaceDir: sandboxWorkspaceDir,
config: params.config,
});
} catch (error) {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
}
}
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
+55
View File
@@ -10,6 +10,7 @@ import {
buildWorkspaceSkillSnapshot,
buildWorkspaceSkillsPrompt,
loadWorkspaceSkillEntries,
syncSkillsToWorkspace,
} from "./skills.js";
import { buildWorkspaceSkillStatus } from "./skills-status.js";
@@ -130,6 +131,60 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
});
it("syncs merged skills into a target workspace", async () => {
const sourceWorkspace = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-"),
);
const targetWorkspace = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-"),
);
const extraDir = path.join(sourceWorkspace, ".extra");
const bundledDir = path.join(sourceWorkspace, ".bundled");
const managedDir = path.join(sourceWorkspace, ".managed");
await writeSkill({
dir: path.join(extraDir, "demo-skill"),
name: "demo-skill",
description: "Extra version",
});
await writeSkill({
dir: path.join(bundledDir, "demo-skill"),
name: "demo-skill",
description: "Bundled version",
});
await writeSkill({
dir: path.join(managedDir, "demo-skill"),
name: "demo-skill",
description: "Managed version",
});
await writeSkill({
dir: path.join(sourceWorkspace, "skills", "demo-skill"),
name: "demo-skill",
description: "Workspace version",
});
await syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
config: { skills: { load: { extraDirs: [extraDir] } } },
bundledSkillsDir: bundledDir,
managedSkillsDir: managedDir,
});
const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, {
bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
managedSkillsDir: path.join(targetWorkspace, ".managed"),
});
expect(prompt).toContain("Workspace version");
expect(prompt).not.toContain("Managed version");
expect(prompt).not.toContain("Bundled version");
expect(prompt).not.toContain("Extra version");
expect(prompt).toContain(
path.join(targetWorkspace, "skills", "demo-skill", "SKILL.md"),
);
});
it("filters skills based on env/config gates", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
+37
View File
@@ -11,6 +11,8 @@ import {
import type { ClawdbotConfig, SkillConfig } from "../config/config.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
const fsp = fs.promises;
export type SkillInstallSpec = {
id?: string;
kind: "brew" | "node" | "go" | "uv";
@@ -619,6 +621,41 @@ export function loadWorkspaceSkillEntries(
return loadSkillEntries(workspaceDir, opts);
}
export async function syncSkillsToWorkspace(params: {
sourceWorkspaceDir: string;
targetWorkspaceDir: string;
config?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
}) {
const sourceDir = resolveUserPath(params.sourceWorkspaceDir);
const targetDir = resolveUserPath(params.targetWorkspaceDir);
if (sourceDir === targetDir) return;
const targetSkillsDir = path.join(targetDir, "skills");
const entries = loadSkillEntries(sourceDir, {
config: params.config,
managedSkillsDir: params.managedSkillsDir,
bundledSkillsDir: params.bundledSkillsDir,
});
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
await fsp.mkdir(targetSkillsDir, { recursive: true });
for (const entry of entries) {
const dest = path.join(targetSkillsDir, entry.skill.name);
try {
await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true });
} catch (error) {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
console.warn(
`[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`,
);
}
}
}
export function filterWorkspaceSkillEntries(
entries: SkillEntry[],
config?: ClawdbotConfig,
+41 -22
View File
@@ -25,6 +25,45 @@ function expectFencesBalanced(chunks: string[]) {
}
}
type ChunkCase = {
name: string;
text: string;
limit: number;
expected: string[];
};
function runChunkCases(
chunker: (text: string, limit: number) => string[],
cases: ChunkCase[],
) {
for (const { name, text, limit, expected } of cases) {
it(name, () => {
expect(chunker(text, limit)).toEqual(expected);
});
}
}
const parentheticalCases: ChunkCase[] = [
{
name: "keeps parenthetical phrases together",
text: "Heads up now (Though now I'm curious)ok",
limit: 35,
expected: ["Heads up now", "(Though now I'm curious)ok"],
},
{
name: "handles nested parentheses",
text: "Hello (outer (inner) end) world",
limit: 26,
expected: ["Hello (outer (inner) end)", "world"],
},
{
name: "ignores unmatched closing parentheses",
text: "Hello) world (ok)",
limit: 12,
expected: ["Hello)", "world (ok)"],
},
];
describe("chunkText", () => {
it("keeps multi-line text in one chunk when under limit", () => {
const text = "Line one\n\nLine two\n\nLine three";
@@ -68,11 +107,7 @@ describe("chunkText", () => {
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
});
it("keeps parenthetical phrases together", () => {
const text = "Heads up now (Though now I'm curious)ok";
const chunks = chunkText(text, 35);
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
});
runChunkCases(chunkText, [parentheticalCases[0]]);
});
describe("resolveTextChunkLimit", () => {
@@ -191,17 +226,7 @@ describe("chunkMarkdownText", () => {
}
});
it("keeps parenthetical phrases together", () => {
const text = "Heads up now (Though now I'm curious)ok";
const chunks = chunkMarkdownText(text, 35);
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
});
it("handles nested parentheses", () => {
const text = "Hello (outer (inner) end) world";
const chunks = chunkMarkdownText(text, 26);
expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]);
});
runChunkCases(chunkMarkdownText, parentheticalCases);
it("hard-breaks when a parenthetical exceeds the limit", () => {
const text = `(${"a".repeat(80)})`;
@@ -209,10 +234,4 @@ describe("chunkMarkdownText", () => {
expect(chunks[0]?.length).toBe(20);
expect(chunks.join("")).toBe(text);
});
it("ignores unmatched closing parentheses", () => {
const text = "Hello) world (ok)";
const chunks = chunkMarkdownText(text, 12);
expect(chunks).toEqual(["Hello)", "world (ok)"]);
});
});
+17 -21
View File
@@ -91,23 +91,7 @@ export function chunkText(text: string, limit: number): string[] {
const window = remaining.slice(0, limit);
// 1) Prefer a newline break inside the window (outside parentheses).
let lastNewline = -1;
let lastWhitespace = -1;
let depth = 0;
for (let i = 0; i < window.length; i++) {
const char = window[i];
if (char === "(") {
depth += 1;
continue;
}
if (char === ")" && depth > 0) {
depth -= 1;
continue;
}
if (depth !== 0) continue;
if (char === "\n") lastNewline = i;
else if (/\s/.test(char)) lastWhitespace = i;
}
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace;
@@ -243,12 +227,26 @@ function pickSafeBreakIndex(
window: string,
spans: ReturnType<typeof parseFenceSpans>,
): number {
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(
window,
(index) => isSafeFenceBreak(spans, index),
);
if (lastNewline > 0) return lastNewline;
if (lastWhitespace > 0) return lastWhitespace;
return -1;
}
function scanParenAwareBreakpoints(
window: string,
isAllowed: (index: number) => boolean = () => true,
): { lastNewline: number; lastWhitespace: number } {
let lastNewline = -1;
let lastWhitespace = -1;
let depth = 0;
for (let i = 0; i < window.length; i++) {
if (!isSafeFenceBreak(spans, i)) continue;
if (!isAllowed(i)) continue;
const char = window[i];
if (char === "(") {
depth += 1;
@@ -263,7 +261,5 @@ function pickSafeBreakIndex(
else if (/\s/.test(char)) lastWhitespace = i;
}
if (lastNewline > 0) return lastNewline;
if (lastWhitespace > 0) return lastWhitespace;
return -1;
return { lastNewline, lastWhitespace };
}
+6
View File
@@ -107,6 +107,12 @@ describe("extractModelDirective", () => {
});
describe("edge cases", () => {
it("preserves spacing when /model is followed by a path segment", () => {
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
expect(result.hasDirective).toBe(true);
expect(result.cleaned).toBe("thats not /hello");
});
it("handles alias with special regex characters", () => {
const result = extractModelDirective("/test.alias", {
aliases: ["test.alias"],
+1 -1
View File
@@ -42,7 +42,7 @@ export function extractModelDirective(
}
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
return {
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { extractStatusDirective } from "./reply/directives.js";
import {
extractElevatedDirective,
extractQueueDirective,
@@ -119,6 +120,30 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
it("preserves spacing when stripping think directives before paths", () => {
const res = extractThinkDirective("thats not /think high/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping verbose directives before paths", () => {
const res = extractVerboseDirective("thats not /verbose on/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping reasoning directives before paths", () => {
const res = extractReasoningDirective("thats not /reasoning on/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping status directives before paths", () => {
const res = extractStatusDirective("thats not /status:/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("parses queue options and modes", () => {
const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
+126
View File
@@ -340,6 +340,132 @@ describe("trigger handling", () => {
});
});
it("ignores elevated directive in groups when not mentioned", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: false,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled");
});
});
it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: true } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: true,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
"on",
);
});
});
it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
+8 -1
View File
@@ -329,8 +329,10 @@ export async function getReplyFromConfig(
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
let parsedDirectives = parseInlineDirectives(rawBody, {
modelAliases: configuredAliases,
disableElevated: disableElevatedInGroup,
});
const hasDirective =
parsedDirectives.hasThinkDirective ||
@@ -342,7 +344,9 @@ export async function getReplyFromConfig(
parsedDirectives.hasQueueDirective;
if (hasDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
@@ -467,6 +471,7 @@ export async function getReplyFromConfig(
cleanedBody: directives.cleaned,
ctx,
cfg,
agentId,
isGroup,
})
) {
@@ -549,6 +554,7 @@ export async function getReplyFromConfig(
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey,
isGroup,
triggerBodyNormalized,
@@ -579,6 +585,7 @@ export async function getReplyFromConfig(
ctx,
cfg,
command,
agentId,
directives,
sessionEntry,
sessionStore,
+10 -3
View File
@@ -126,11 +126,12 @@ function extractCompactInstructions(params: {
rawBody?: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}): string | undefined {
const raw = stripStructuralPrefixes(params.rawBody ?? "");
const stripped = params.isGroup
? stripMentions(raw, params.ctx, params.cfg)
? stripMentions(raw, params.ctx, params.cfg, params.agentId)
: raw;
const trimmed = stripped.trim();
if (!trimmed) return undefined;
@@ -145,12 +146,14 @@ function extractCompactInstructions(params: {
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
params;
const auth = resolveCommandAuthorization({
ctx,
cfg,
@@ -162,7 +165,9 @@ export function buildCommandContext(params: {
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
isGroup
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
: rawBodyNormalized,
);
return {
@@ -207,6 +212,7 @@ export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
command: CommandContext;
agentId?: string;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
@@ -542,6 +548,7 @@ export async function handleCommands(params: {
rawBody: ctx.Body,
ctx,
cfg,
agentId: params.agentId,
isGroup,
});
const result = await compactEmbeddedPiSession({
+14 -4
View File
@@ -184,7 +184,7 @@ export type InlineDirectives = {
export function parseInlineDirectives(
body: string,
options?: { modelAliases?: string[] },
options?: { modelAliases?: string[]; disableElevated?: boolean },
): InlineDirectives {
const {
cleaned: thinkCleaned,
@@ -209,7 +209,14 @@ export function parseInlineDirectives(
elevatedLevel,
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
} = extractElevatedDirective(reasoningCleaned);
} = options?.disableElevated
? {
cleaned: reasoningCleaned,
elevatedLevel: undefined,
rawLevel: undefined,
hasDirective: false,
}
: extractElevatedDirective(reasoningCleaned);
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
extractStatusDirective(elevatedCleaned);
const {
@@ -272,9 +279,10 @@ export function isDirectiveOnly(params: {
cleanedBody: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}): boolean {
const { directives, cleanedBody, ctx, cfg, isGroup } = params;
const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params;
if (
!directives.hasThinkDirective &&
!directives.hasVerboseDirective &&
@@ -285,7 +293,9 @@ export function isDirectiveOnly(params: {
)
return false;
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
return noMentions.length === 0;
}
+2 -1
View File
@@ -56,6 +56,7 @@ const extractLevelDirective = <T>(
const level = normalize(rawLevel);
const cleaned = body
.slice(0, match.start)
.concat(" ")
.concat(body.slice(match.end))
.replace(/\s+/g, " ")
.trim();
@@ -76,7 +77,7 @@ const extractSimpleDirective = (
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
+16
View File
@@ -27,4 +27,20 @@ describe("mention helpers", () => {
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
routing: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: {
work: { mentionPatterns: ["\\bworkbot\\b"] },
},
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});
+19 -3
View File
@@ -1,8 +1,23 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string[] {
if (!cfg) return [];
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
return agentConfig.mentionPatterns ?? [];
}
return cfg.routing?.groupChat?.mentionPatterns ?? [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId);
return patterns
.map((pattern) => {
try {
@@ -48,9 +63,10 @@ export function stripMentions(
text: string,
ctx: MsgContext,
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string {
let result = text;
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
const patterns = resolveMentionPatterns(cfg, agentId);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
+3 -2
View File
@@ -271,8 +271,9 @@ export function extractQueueDirective(body?: string): {
const argsStart = start + "/queue".length;
const args = body.slice(argsStart);
const parsed = parseQueueDirectiveArgs(args);
const cleanedRaw =
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
const cleanedRaw = `${body.slice(0, start)} ${body.slice(
argsStart + parsed.consumed,
)}`;
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
return {
cleaned,
+1 -1
View File
@@ -136,7 +136,7 @@ export async function initSessionState(params: {
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg)
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;
+13 -9
View File
@@ -32,9 +32,11 @@ import {
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { callGateway } from "../gateway/call.js";
import { resolveGatewayBindHost } from "../gateway/net.js";
import {
@@ -807,25 +809,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: runtimeRaw,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeRaw,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
opts.token ||
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
try {
await service.install({
+87
View File
@@ -0,0 +1,87 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const listProviderPairingRequests = vi.fn();
vi.mock("../pairing/pairing-store.js", () => ({
listProviderPairingRequests,
approveProviderPairingCode: vi.fn(),
}));
vi.mock("../telegram/send.js", () => ({
sendMessageTelegram: vi.fn(),
}));
vi.mock("../discord/send.js", () => ({
sendMessageDiscord: vi.fn(),
}));
vi.mock("../slack/send.js", () => ({
sendMessageSlack: vi.fn(),
}));
vi.mock("../signal/send.js", () => ({
sendMessageSignal: vi.fn(),
}));
vi.mock("../imessage/send.js", () => ({
sendMessageIMessage: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn().mockReturnValue({}),
}));
vi.mock("../telegram/token.js", () => ({
resolveTelegramToken: vi.fn().mockReturnValue({ token: "t" }),
}));
describe("pairing cli", () => {
it("labels Telegram ids as telegramUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([
{
id: "123",
code: "ABC123",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
meta: { username: "peter" },
},
]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--provider", "telegram"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(
expect.stringContaining("telegramUserId=123"),
);
});
it("labels Discord ids as discordUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([
{
id: "999",
code: "DEF456",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
meta: { tag: "Ada#0001" },
},
]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--provider", "discord"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(
expect.stringContaining("discordUserId=999"),
);
});
});
+3 -1
View File
@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
listProviderPairingRequests,
@@ -93,8 +94,9 @@ export function registerPairingCli(program: Command) {
}
for (const r of requests) {
const meta = r.meta ? JSON.stringify(r.meta) : "";
const idLabel = PROVIDER_ID_LABELS[provider];
console.log(
`${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
);
}
});
+4 -1
View File
@@ -955,12 +955,15 @@ export async function agentsAddCommand(
initialValue: false,
});
if (wantsAuth) {
const authStore = ensureAuthProfileStore(agentDir);
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
})) as AuthChoice;
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
describe("buildAuthChoiceOptions", () => {
it("includes Claude CLI option on macOS even when missing", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli).toBeDefined();
expect(claudeCli?.hint).toBe("requires Keychain access");
});
it("skips missing Claude CLI option off macOS", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "linux",
});
expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined();
});
it("uses token hint when Claude CLI credentials exist", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "anthropic",
access: "token",
refresh: "refresh",
expires: Date.now() + 60 * 60 * 1000,
},
},
};
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli?.hint).toContain("token ok");
});
});
+9
View File
@@ -45,8 +45,11 @@ function formatOAuthHint(
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): AuthChoiceOption[] {
const options: AuthChoiceOption[] = [];
const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
@@ -64,6 +67,12 @@ export function buildAuthChoiceOptions(params: {
label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires),
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
hint: "requires Keychain access",
});
}
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
+32 -3
View File
@@ -168,10 +168,39 @@ export async function applyAuthChoice(params: {
);
}
} else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note(
"No Claude CLI credentials found at ~/.claude/.credentials.json.",
[
"macOS will show a Keychain prompt next.",
'Choose "Always Allow" so the launchd gateway can start without prompts.',
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
].join("\n"),
"Claude CLI Keychain",
);
const proceed = await params.prompter.confirm({
message: "Check Keychain for Claude CLI credentials now?",
initialValue: true,
});
if (!proceed) {
return { config: nextConfig, agentModelOverride };
}
}
const storeWithKeychain = hasClaudeCli
? store
: ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
+17 -6
View File
@@ -31,7 +31,9 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -284,8 +286,11 @@ async function promptAuthConfig(
await select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: ensureAuthProfileStore(),
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
}),
runtime,
@@ -611,18 +616,24 @@ async function maybeInstallDaemon(params: {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port: params.port,
token: params.gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
+1 -1
View File
@@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
{
value: "node",
label: "Node (recommended)",
hint: "Required for WhatsApp (Baileys WebSocket). Bun can corrupt memory on reconnect.",
hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
},
];
+39 -21
View File
@@ -14,8 +14,16 @@ import {
uninstallLegacyGatewayServices,
} from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import {
resolvePreferredNodePath,
resolveSystemNodePath,
} from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
import {
auditGatewayServiceConfig,
needsNodeRuntimeMigration,
} from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
@@ -103,19 +111,24 @@ export async function maybeMigrateLegacyGatewayService(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
@@ -170,9 +183,6 @@ export async function maybeRepairGatewayServiceConfig(
const aggressiveIssues = audit.issues.filter(
(issue) => issue.level === "aggressive",
);
const _recommendedIssues = audit.issues.filter(
(issue) => issue.level !== "aggressive",
);
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
@@ -194,6 +204,17 @@ export async function maybeRepairGatewayServiceConfig(
});
if (!repair) return;
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
@@ -203,19 +224,16 @@ export async function maybeRepairGatewayServiceConfig(
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeChoice,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
try {
await service.install({
+9
View File
@@ -94,6 +94,7 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const callGateway = vi.fn().mockRejectedValue(new Error("gateway closed"));
vi.mock("@clack/prompts", () => ({
confirm,
@@ -133,6 +134,14 @@ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments,
}));
vi.mock("../gateway/call.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/call.js")>();
return {
...actual,
callGateway,
};
});
vi.mock("../process/exec.js", () => ({
runExec,
runCommandWithTimeout,
+39 -10
View File
@@ -12,9 +12,12 @@ import {
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -237,6 +240,30 @@ export async function doctorCommand(
}
}
if (healthOk) {
try {
const status = await callGateway<Record<string, unknown>>({
method: "providers.status",
params: { probe: true, timeoutMs: 5000 },
timeoutMs: 6000,
});
const issues = collectProvidersStatusIssues(status);
if (issues.length > 0) {
note(
issues
.map(
(issue) =>
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
)
.join("\n"),
"Provider warnings",
);
}
} catch {
// ignore: doctor already reported gateway health
}
}
if (!healthOk) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
@@ -280,25 +307,27 @@ export async function doctorCommand(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
+19 -7
View File
@@ -13,7 +13,9 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -118,10 +120,14 @@ export async function runNonInteractiveOnboarding(
mode: "api_key",
});
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
"No Claude CLI credentials found at ~/.claude/.credentials.json",
process.platform === "darwin"
? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
: "No Claude CLI credentials found at ~/.claude/.credentials.json",
);
runtime.exit(1);
return;
@@ -272,18 +278,24 @@ export async function runNonInteractiveOnboarding(
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntimeRaw,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntimeRaw,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
+108
View File
@@ -323,4 +323,112 @@ describe("providers command", () => {
expect(whatsappIndex).toBeGreaterThan(-1);
expect(telegramIndex).toBeLessThan(whatsappIndex);
});
it("surfaces Discord privileged intent issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
discordAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
application: { intents: { messageContent: "limited" } },
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Message Content Intent is limited/i);
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
});
it("surfaces Discord permission audit issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
discordAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
audit: {
unresolvedChannels: 1,
channels: [
{
channelId: "111",
ok: false,
missing: ["ViewChannel", "SendMessages"],
},
],
},
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/permission audit/i);
expect(lines.join("\n")).toMatch(/Channel 111/i);
});
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
const lines = formatGatewayProvidersStatusLines({
telegramAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
allowUnmentionedGroups: true,
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
});
it("surfaces Telegram group membership audit issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
telegramAccounts: [
{
accountId: "default",
enabled: true,
configured: true,
audit: {
hasWildcardUnmentionedGroups: true,
unresolvedGroups: 1,
groups: [
{
chatId: "-1001",
ok: false,
status: "left",
error: "not in group",
},
],
},
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
expect(lines.join("\n")).toMatch(/Group -1001/i);
});
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
const unlinked = formatGatewayProvidersStatusLines({
whatsappAccounts: [
{ accountId: "default", enabled: true, linked: false },
],
});
expect(unlinked.join("\n")).toMatch(/WhatsApp/i);
expect(unlinked.join("\n")).toMatch(/Not linked/i);
const disconnected = formatGatewayProvidersStatusLines({
whatsappAccounts: [
{
accountId: "default",
enabled: true,
linked: true,
running: true,
connected: false,
reconnectAttempts: 5,
lastError: "connection closed",
},
],
});
expect(disconnected.join("\n")).toMatch(/disconnected/i);
});
});
+51 -1
View File
@@ -13,6 +13,7 @@ import {
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { formatAge } from "../../infra/provider-summary.js";
import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
import { listChatProviders } from "../../providers/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import {
@@ -74,6 +75,21 @@ export function formatGatewayProvidersStatusLines(
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
if (typeof account.connected === "boolean") {
bits.push(account.connected ? "connected" : "disconnected");
}
const inboundAt =
typeof account.lastInboundAt === "number" &&
Number.isFinite(account.lastInboundAt)
? account.lastInboundAt
: null;
const outboundAt =
typeof account.lastOutboundAt === "number" &&
Number.isFinite(account.lastOutboundAt)
? account.lastOutboundAt
: null;
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
@@ -98,6 +114,20 @@ export function formatGatewayProvidersStatusLines(
) {
bits.push(`app:${account.appTokenSource}`);
}
const application = account.application as
| { intents?: { messageContent?: string } }
| undefined;
const messageContent = application?.intents?.messageContent;
if (
typeof messageContent === "string" &&
messageContent.length > 0 &&
messageContent !== "enabled"
) {
bits.push(`intents:content=${messageContent}`);
}
if (account.allowUnmentionedGroups === true) {
bits.push("groups:unmentioned");
}
if (typeof account.baseUrl === "string" && account.baseUrl) {
bits.push(`url:${account.baseUrl}`);
}
@@ -105,6 +135,10 @@ export function formatGatewayProvidersStatusLines(
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
const audit = account.audit as { ok?: boolean } | undefined;
if (audit && typeof audit.ok === "boolean") {
bits.push(audit.ok ? "audit ok" : "audit failed");
}
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
@@ -150,6 +184,17 @@ export function formatGatewayProvidersStatusLines(
}
lines.push("");
const issues = collectProvidersStatusIssues(payload);
if (issues.length > 0) {
lines.push(theme.warn("Warnings:"));
for (const issue of issues) {
lines.push(
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
);
}
lines.push(`- Run: clawdbot doctor`);
lines.push("");
}
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
);
@@ -329,10 +374,15 @@ export async function providersStatusCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
const statusLabel = opts.probe
? "Checking provider status (probe)…"
: "Checking provider status…";
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
if (shouldLogStatus) runtime.log(statusLabel);
try {
const payload = await withProgress(
{
label: "Checking provider status…",
label: statusLabel,
indeterminate: true,
enabled: opts.json !== true,
},
+80 -7
View File
@@ -269,7 +269,7 @@ describe("config identity defaults", () => {
});
});
it("does not synthesize session when absent", async () => {
it("does not synthesize agent/session when absent", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -295,7 +295,7 @@ describe("config identity defaults", () => {
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.agent?.contextPruning?.mode).toBe("adaptive");
expect(cfg.agent).toBeUndefined();
expect(cfg.session).toBeUndefined();
});
});
@@ -327,6 +327,83 @@ describe("config identity defaults", () => {
});
});
describe("config env vars", () => {
it("applies env vars from env block when missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
env: { OPENROUTER_API_KEY: "config-key" },
},
null,
2,
),
"utf-8",
);
await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
});
});
});
it("does not override existing env vars", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
env: { OPENROUTER_API_KEY: "config-key" },
},
null,
2,
),
"utf-8",
);
await withEnvOverride(
{ OPENROUTER_API_KEY: "existing-key" },
async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
},
);
});
});
it("applies env vars from env.vars when missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
env: { vars: { GROQ_API_KEY: "gsk-config" } },
},
null,
2,
),
"utf-8",
);
await withEnvOverride({ GROQ_API_KEY: undefined }, async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.GROQ_API_KEY).toBe("gsk-config");
});
});
});
});
describe("config pruning defaults", () => {
it("defaults contextPruning mode to adaptive", async () => {
await withTempHome(async (home) => {
@@ -352,11 +429,7 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{ agent: { contextPruning: { mode: "off" } } },
null,
2,
),
JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2),
"utf-8",
);
+1
View File
@@ -165,6 +165,7 @@ export function applyContextPruningDefaults(
cfg: ClawdbotConfig,
): ClawdbotConfig {
const agent = cfg.agent;
if (!agent) return cfg;
const contextPruning = agent?.contextPruning;
if (contextPruning?.mode) return cfg;
+29 -1
View File
@@ -13,11 +13,11 @@ import {
findDuplicateAgentDirs,
} from "./agent-dirs.js";
import {
applyContextPruningDefaults,
applyIdentityDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
applyContextPruningDefaults,
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
@@ -41,6 +41,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
"ANTHROPIC_OAUTH_TOKEN",
"GEMINI_API_KEY",
"ZAI_API_KEY",
"OPENROUTER_API_KEY",
"MINIMAX_API_KEY",
"ELEVENLABS_API_KEY",
"TELEGRAM_BOT_TOKEN",
@@ -78,6 +79,31 @@ function warnOnConfigMiskeys(
}
}
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
const envConfig = cfg.env;
if (!envConfig) return;
const entries: Record<string, string> = {};
if (envConfig.vars) {
for (const [key, value] of Object.entries(envConfig.vars)) {
if (!value) continue;
entries[key] = value;
}
}
for (const [key, value] of Object.entries(envConfig)) {
if (key === "shellEnv" || key === "vars") continue;
if (typeof value !== "string" || !value.trim()) continue;
entries[key] = value;
}
for (const [key, value] of Object.entries(entries)) {
if (env[key]?.trim()) continue;
env[key] = value;
}
}
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
if (deps.configPath) return deps.configPath;
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
@@ -155,6 +181,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
throw new DuplicateAgentDirError(duplicates);
}
applyConfigEnv(cfg, deps.env);
const enabled =
shouldEnableShellEnvFallback(deps.env) ||
cfg.env?.shellEnv?.enabled === true;
+10
View File
@@ -723,6 +723,8 @@ export type RoutingConfig = {
workspace?: string;
agentDir?: string;
model?: string;
/** Per-agent override for group mention patterns. */
mentionPatterns?: string[];
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
@@ -1031,6 +1033,14 @@ export type ClawdbotConfig = {
/** Timeout for the login shell exec (ms). Default: 15000. */
timeoutMs?: number;
};
/** Inline env vars to apply when not already present in the process env. */
vars?: Record<string, string>;
/** Sugar: allow env vars directly under env (string values only). */
[key: string]:
| string
| Record<string, string>
| { enabled?: boolean; timeoutMs?: number }
| undefined;
};
identity?: {
name?: string;
+3
View File
@@ -641,6 +641,7 @@ const RoutingSchema = z
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
mentionPatterns: z.array(z.string()).optional(),
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
@@ -793,7 +794,9 @@ export const ClawdbotSchema = z.object({
timeoutMs: z.number().int().nonnegative().optional(),
})
.optional(),
vars: z.record(z.string(), z.string()).optional(),
})
.catchall(z.string())
.optional(),
identity: z
.object({
+45
View File
@@ -450,6 +450,51 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("delivers telegram shorthand topic suffixes with messageThreadId", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "-1001234567890",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
provider: "telegram",
to: "-1001234567890:321",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"-1001234567890",
"hello from cron",
expect.objectContaining({ messageThreadId: 321 }),
);
});
});
it("delivers via discord when configured", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
+131 -136
View File
@@ -57,6 +57,12 @@ export type RunCronAgentTurnResult = {
error?: string;
};
type DeliveryPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
};
function pickSummaryFromOutput(text: string | undefined) {
const clean = (text ?? "").trim();
if (!clean) return undefined;
@@ -79,7 +85,7 @@ function pickSummaryFromPayloads(
* Returns true if delivery should be skipped because there's no real content.
*/
function isHeartbeatOnlyResponse(
payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>,
payloads: DeliveryPayload[],
ackMaxChars: number,
) {
if (payloads.length === 0) return true;
@@ -96,6 +102,53 @@ function isHeartbeatOnlyResponse(
return result.shouldSkip;
});
}
function getMediaList(payload: DeliveryPayload) {
return payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
}
async function deliverPayloadsWithMedia(params: {
payloads: DeliveryPayload[];
sendText: (text: string) => Promise<unknown>;
sendMedia: (caption: string, mediaUrl: string) => Promise<unknown>;
}) {
for (const payload of params.payloads) {
const mediaList = getMediaList(payload);
if (mediaList.length === 0) {
await params.sendText(payload.text ?? "");
continue;
}
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.sendMedia(caption, url);
}
}
}
async function deliverChunkedPayloads(params: {
payloads: DeliveryPayload[];
chunkText: (text: string) => string[];
sendText: (text: string) => Promise<unknown>;
sendMedia: (caption: string, mediaUrl: string) => Promise<unknown>;
}) {
for (const payload of params.payloads) {
const mediaList = getMediaList(payload);
if (mediaList.length === 0) {
for (const chunk of params.chunkText(payload.text ?? "")) {
await params.sendText(chunk);
}
continue;
}
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.sendMedia(caption, url);
}
}
}
function resolveDeliveryTarget(
cfg: ClawdbotConfig,
jobPayload: {
@@ -143,28 +196,34 @@ function resolveDeliveryTarget(
return lastProvider ?? "whatsapp";
})();
const to = (() => {
if (explicitTo) return explicitTo;
return lastTo || undefined;
})();
const rawTo = explicitTo ?? (lastTo || undefined);
const telegramTarget =
provider === "telegram" && rawTo ? parseTelegramTarget(rawTo) : undefined;
const sanitizedWhatsappTo = (() => {
if (provider !== "whatsapp") return to;
if (provider !== "whatsapp") return rawTo;
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return to;
if (rawAllow.includes("*")) return rawTo;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
if (allowFrom.length === 0) return to;
if (!to) return allowFrom[0];
const normalized = normalizeE164(to);
if (allowFrom.length === 0) return rawTo;
if (!rawTo) return allowFrom[0];
const normalized = normalizeE164(rawTo);
if (allowFrom.includes(normalized)) return normalized;
return allowFrom[0];
})();
const to = (() => {
if (provider === "telegram" && telegramTarget) return telegramTarget.chatId;
if (provider === "whatsapp") return sanitizedWhatsappTo;
return rawTo;
})();
return {
provider,
to: provider === "whatsapp" ? sanitizedWhatsappTo : to,
to,
messageThreadId: telegramTarget?.messageThreadId,
};
}
@@ -455,21 +514,16 @@ export async function runCronIsolatedAgentTurn(params: {
}
const to = normalizeE164(resolvedDelivery.to);
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const primaryMedia = mediaList[0];
await params.deps.sendMessageWhatsApp(to, payload.text ?? "", {
verbose: false,
mediaUrl: primaryMedia,
});
for (const extra of mediaList.slice(1)) {
await params.deps.sendMessageWhatsApp(to, "", {
await deliverPayloadsWithMedia({
payloads,
sendText: (text) =>
params.deps.sendMessageWhatsApp(to, text, { verbose: false }),
sendMedia: (caption, mediaUrl) =>
params.deps.sendMessageWhatsApp(to, caption, {
verbose: false,
mediaUrl: extra,
});
}
}
mediaUrl,
}),
});
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -488,39 +542,27 @@ export async function runCronIsolatedAgentTurn(params: {
summary: "Delivery skipped (no Telegram chatId).",
};
}
const telegramTarget = parseTelegramTarget(resolvedDelivery.to);
const chatId = telegramTarget.chatId;
const messageThreadId = telegramTarget.messageThreadId;
const chatId = resolvedDelivery.to;
const messageThreadId = resolvedDelivery.messageThreadId;
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(
payload.text ?? "",
textLimit,
)) {
await params.deps.sendMessageTelegram(chatId, chunk, {
verbose: false,
token: telegramToken || undefined,
messageThreadId,
});
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageTelegram(chatId, caption, {
verbose: false,
mediaUrl: url,
token: telegramToken || undefined,
messageThreadId,
});
}
}
}
await deliverChunkedPayloads({
payloads,
chunkText: (text) => chunkMarkdownText(text, textLimit),
sendText: (text) =>
params.deps.sendMessageTelegram(chatId, text, {
verbose: false,
token: telegramToken || undefined,
messageThreadId,
}),
sendMedia: (caption, mediaUrl) =>
params.deps.sendMessageTelegram(chatId, caption, {
verbose: false,
mediaUrl,
token: telegramToken || undefined,
messageThreadId,
}),
});
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -542,29 +584,18 @@ export async function runCronIsolatedAgentTurn(params: {
}
const discordTarget = resolvedDelivery.to;
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
await params.deps.sendMessageDiscord(
discordTarget,
payload.text ?? "",
{
token: process.env.DISCORD_BOT_TOKEN,
},
);
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageDiscord(discordTarget, caption, {
token: process.env.DISCORD_BOT_TOKEN,
mediaUrl: url,
});
}
}
}
await deliverPayloadsWithMedia({
payloads,
sendText: (text) =>
params.deps.sendMessageDiscord(discordTarget, text, {
token: process.env.DISCORD_BOT_TOKEN,
}),
sendMedia: (caption, mediaUrl) =>
params.deps.sendMessageDiscord(discordTarget, caption, {
token: process.env.DISCORD_BOT_TOKEN,
mediaUrl,
}),
});
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -587,27 +618,13 @@ export async function runCronIsolatedAgentTurn(params: {
const slackTarget = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "slack");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(
payload.text ?? "",
textLimit,
)) {
await params.deps.sendMessageSlack(slackTarget, chunk);
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageSlack(slackTarget, caption, {
mediaUrl: url,
});
}
}
}
await deliverChunkedPayloads({
payloads,
chunkText: (text) => chunkMarkdownText(text, textLimit),
sendText: (text) => params.deps.sendMessageSlack(slackTarget, text),
sendMedia: (caption, mediaUrl) =>
params.deps.sendMessageSlack(slackTarget, caption, { mediaUrl }),
});
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -629,24 +646,13 @@ export async function runCronIsolatedAgentTurn(params: {
const to = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "signal");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
await params.deps.sendMessageSignal(to, chunk);
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageSignal(to, caption, {
mediaUrl: url,
});
}
}
}
await deliverChunkedPayloads({
payloads,
chunkText: (text) => chunkText(text, textLimit),
sendText: (text) => params.deps.sendMessageSignal(to, text),
sendMedia: (caption, mediaUrl) =>
params.deps.sendMessageSignal(to, caption, { mediaUrl }),
});
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -668,24 +674,13 @@ export async function runCronIsolatedAgentTurn(params: {
const to = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "imessage");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
await params.deps.sendMessageIMessage(to, chunk);
}
} else {
let first = true;
for (const url of mediaList) {
const caption = first ? (payload.text ?? "") : "";
first = false;
await params.deps.sendMessageIMessage(to, caption, {
mediaUrl: url,
});
}
}
}
await deliverChunkedPayloads({
payloads,
chunkText: (text) => chunkText(text, textLimit),
sendText: (text) => params.deps.sendMessageIMessage(to, text),
sendMedia: (caption, mediaUrl) =>
params.deps.sendMessageIMessage(to, caption, { mediaUrl }),
});
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
+4 -3
View File
@@ -146,15 +146,16 @@ export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway", "--port", String(params.port)];
const execPath = process.execPath;
const runtime = params.runtime ?? "auto";
if (runtime === "node") {
const nodePath = isNodeRuntime(execPath)
? execPath
: await resolveNodePath();
const nodePath =
params.nodePath ??
(isNodeRuntime(execPath) ? execPath : await resolveNodePath());
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
+94
View File
@@ -0,0 +1,94 @@
import fs from "node:fs/promises";
import path from "node:path";
const VERSION_MANAGER_MARKERS = [
"/.nvm/",
"/.fnm/",
"/.volta/",
"/.asdf/",
"/.n/",
"/.nodenv/",
"/.nodebrew/",
"/nvs/",
];
function getPathModule(platform: NodeJS.Platform) {
return platform === "win32" ? path.win32 : path.posix;
}
function normalizeForCompare(input: string, platform: NodeJS.Platform): string {
const pathModule = getPathModule(platform);
const normalized = pathModule.normalize(input).replaceAll("\\", "/");
if (platform === "win32") {
return normalized.toLowerCase();
}
return normalized;
}
function buildSystemNodeCandidates(
env: Record<string, string | undefined>,
platform: NodeJS.Platform,
): string[] {
if (platform === "darwin") {
return ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
}
if (platform === "linux") {
return ["/usr/local/bin/node", "/usr/bin/node"];
}
if (platform === "win32") {
const pathModule = getPathModule(platform);
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 =
env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
return [
pathModule.join(programFiles, "nodejs", "node.exe"),
pathModule.join(programFilesX86, "nodejs", "node.exe"),
];
}
return [];
}
export function isVersionManagedNodePath(
nodePath: string,
platform: NodeJS.Platform = process.platform,
): boolean {
const normalized = normalizeForCompare(nodePath, platform);
return VERSION_MANAGER_MARKERS.some((marker) => normalized.includes(marker));
}
export function isSystemNodePath(
nodePath: string,
env: Record<string, string | undefined> = process.env,
platform: NodeJS.Platform = process.platform,
): boolean {
const normalized = normalizeForCompare(nodePath, platform);
return buildSystemNodeCandidates(env, platform).some((candidate) => {
const normalizedCandidate = normalizeForCompare(candidate, platform);
return normalized === normalizedCandidate;
});
}
export async function resolveSystemNodePath(
env: Record<string, string | undefined> = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<string | null> {
const candidates = buildSystemNodeCandidates(env, platform);
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// keep going
}
}
return null;
}
export async function resolvePreferredNodePath(params: {
env?: Record<string, string | undefined>;
runtime?: string;
}): Promise<string | undefined> {
if (params.runtime !== "node") return undefined;
const systemNode = await resolveSystemNodePath(params.env);
return systemNode ?? undefined;
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
auditGatewayServiceConfig,
SERVICE_AUDIT_CODES,
} from "./service-audit.js";
describe("auditGatewayServiceConfig", () => {
it("flags bun runtime", async () => {
const audit = await auditGatewayServiceConfig({
env: { HOME: "/tmp" },
platform: "darwin",
command: {
programArguments: ["/opt/homebrew/bin/bun", "gateway"],
environment: { PATH: "/usr/bin:/bin" },
},
});
expect(
audit.issues.some(
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun,
),
).toBe(true);
});
it("flags version-managed node paths", async () => {
const audit = await auditGatewayServiceConfig({
env: { HOME: "/tmp" },
platform: "darwin",
command: {
programArguments: [
"/Users/test/.nvm/versions/node/v22.0.0/bin/node",
"gateway",
],
environment: {
PATH: "/usr/bin:/bin:/Users/test/.nvm/versions/node/v22.0.0/bin",
},
},
});
expect(
audit.issues.some(
(issue) =>
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
),
).toBe(true);
expect(
audit.issues.some(
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
),
).toBe(true);
expect(
audit.issues.some(
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
),
).toBe(true);
});
});
+171 -6
View File
@@ -1,5 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveLaunchAgentPlistPath } from "./launchd.js";
import {
isSystemNodePath,
isVersionManagedNodePath,
resolveSystemNodePath,
} from "./runtime-paths.js";
import { getMinimalServicePathParts } from "./service-env.js";
import { resolveSystemdUserUnitPath } from "./systemd.js";
export type GatewayServiceCommand = {
@@ -21,6 +28,31 @@ export type ServiceConfigAudit = {
issues: ServiceConfigIssue[];
};
export const SERVICE_AUDIT_CODES = {
gatewayCommandMissing: "gateway-command-missing",
gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal",
gatewayRuntimeBun: "gateway-runtime-bun",
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing",
launchdKeepAlive: "launchd-keep-alive",
launchdRunAtLoad: "launchd-run-at-load",
systemdAfterNetworkOnline: "systemd-after-network-online",
systemdRestartSec: "systemd-restart-sec",
systemdWantsNetworkOnline: "systemd-wants-network-online",
} as const;
export function needsNodeRuntimeMigration(
issues: ServiceConfigIssue[],
): boolean {
return issues.some(
(issue) =>
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun ||
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
);
}
function hasGatewaySubcommand(programArguments?: string[]): boolean {
return Boolean(programArguments?.some((arg) => arg === "gateway"));
}
@@ -82,7 +114,7 @@ async function auditSystemdUnit(
const parsed = parseSystemdUnit(content);
if (!parsed.after.has("network-online.target")) {
issues.push({
code: "systemd-after-network-online",
code: SERVICE_AUDIT_CODES.systemdAfterNetworkOnline,
message: "Missing systemd After=network-online.target",
detail: unitPath,
level: "recommended",
@@ -90,7 +122,7 @@ async function auditSystemdUnit(
}
if (!parsed.wants.has("network-online.target")) {
issues.push({
code: "systemd-wants-network-online",
code: SERVICE_AUDIT_CODES.systemdWantsNetworkOnline,
message: "Missing systemd Wants=network-online.target",
detail: unitPath,
level: "recommended",
@@ -98,7 +130,7 @@ async function auditSystemdUnit(
}
if (!isRestartSecPreferred(parsed.restartSec)) {
issues.push({
code: "systemd-restart-sec",
code: SERVICE_AUDIT_CODES.systemdRestartSec,
message: "RestartSec does not match the recommended 5s",
detail: unitPath,
level: "recommended",
@@ -122,7 +154,7 @@ async function auditLaunchdPlist(
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
if (!hasRunAtLoad) {
issues.push({
code: "launchd-run-at-load",
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
message: "LaunchAgent is missing RunAtLoad=true",
detail: plistPath,
level: "recommended",
@@ -130,7 +162,7 @@ async function auditLaunchdPlist(
}
if (!hasKeepAlive) {
issues.push({
code: "launchd-keep-alive",
code: SERVICE_AUDIT_CODES.launchdKeepAlive,
message: "LaunchAgent is missing KeepAlive=true",
detail: plistPath,
level: "recommended",
@@ -145,13 +177,144 @@ function auditGatewayCommand(
if (!programArguments || programArguments.length === 0) return;
if (!hasGatewaySubcommand(programArguments)) {
issues.push({
code: "gateway-command-missing",
code: SERVICE_AUDIT_CODES.gatewayCommandMissing,
message: "Service command does not include the gateway subcommand",
level: "aggressive",
});
}
}
function isNodeRuntime(execPath: string): boolean {
const base = path.basename(execPath).toLowerCase();
return base === "node" || base === "node.exe";
}
function isBunRuntime(execPath: string): boolean {
const base = path.basename(execPath).toLowerCase();
return base === "bun" || base === "bun.exe";
}
function getPathModule(platform: NodeJS.Platform) {
return platform === "win32" ? path.win32 : path.posix;
}
function normalizePathEntry(entry: string, platform: NodeJS.Platform): string {
const pathModule = getPathModule(platform);
const normalized = pathModule.normalize(entry).replaceAll("\\", "/");
if (platform === "win32") {
return normalized.toLowerCase();
}
return normalized;
}
function auditGatewayServicePath(
command: GatewayServiceCommand,
issues: ServiceConfigIssue[],
platform: NodeJS.Platform,
) {
if (platform === "win32") return;
const servicePath = command?.environment?.PATH;
if (!servicePath) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayPathMissing,
message:
"Gateway service PATH is not set; the daemon should use a minimal PATH.",
level: "recommended",
});
return;
}
const expected = getMinimalServicePathParts({ platform });
const parts = servicePath
.split(getPathModule(platform).delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
const normalizedParts = parts.map((entry) =>
normalizePathEntry(entry, platform),
);
const missing = expected.filter((entry) => {
const normalized = normalizePathEntry(entry, platform);
return !normalizedParts.includes(normalized);
});
if (missing.length > 0) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
message: `Gateway service PATH missing required dirs: ${missing.join(", ")}`,
level: "recommended",
});
}
const nonMinimal = parts.filter((entry) => {
const normalized = normalizePathEntry(entry, platform);
return (
normalized.includes("/.nvm/") ||
normalized.includes("/.fnm/") ||
normalized.includes("/.volta/") ||
normalized.includes("/.asdf/") ||
normalized.includes("/.n/") ||
normalized.includes("/.nodenv/") ||
normalized.includes("/.nodebrew/") ||
normalized.includes("/nvs/") ||
normalized.includes("/.local/share/pnpm/") ||
normalized.includes("/pnpm/") ||
normalized.endsWith("/pnpm")
);
});
if (nonMinimal.length > 0) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
message:
"Gateway service PATH includes version managers or package managers; recommend a minimal PATH.",
detail: nonMinimal.join(", "),
level: "recommended",
});
}
}
async function auditGatewayRuntime(
env: Record<string, string | undefined>,
command: GatewayServiceCommand,
issues: ServiceConfigIssue[],
platform: NodeJS.Platform,
) {
const execPath = command?.programArguments?.[0];
if (!execPath) return;
if (isBunRuntime(execPath)) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
message:
"Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram providers.",
detail: execPath,
level: "recommended",
});
return;
}
if (!isNodeRuntime(execPath)) return;
if (isVersionManagedNodePath(execPath, platform)) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
message:
"Gateway service uses Node from a version manager; it can break after upgrades.",
detail: execPath,
level: "recommended",
});
if (!isSystemNodePath(execPath, env, platform)) {
const systemNode = await resolveSystemNodePath(env, platform);
if (!systemNode) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
message:
"System Node 22+ not found; install it before migrating away from version managers.",
level: "recommended",
});
}
}
}
}
export async function auditGatewayServiceConfig(params: {
env: Record<string, string | undefined>;
command: GatewayServiceCommand;
@@ -161,6 +324,8 @@ export async function auditGatewayServiceConfig(params: {
const platform = params.platform ?? process.platform;
auditGatewayCommand(params.command?.programArguments, issues);
auditGatewayServicePath(params.command, issues, platform);
await auditGatewayRuntime(params.env, params.command, issues, platform);
if (platform === "linux") {
await auditSystemdUnit(params.env, issues);
+62
View File
@@ -0,0 +1,62 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildMinimalServicePath,
buildServiceEnvironment,
} from "./service-env.js";
describe("buildMinimalServicePath", () => {
it("includes Homebrew + system dirs on macOS", () => {
const result = buildMinimalServicePath({
platform: "darwin",
});
const parts = result.split(path.delimiter);
expect(parts).toContain("/opt/homebrew/bin");
expect(parts).toContain("/usr/local/bin");
expect(parts).toContain("/usr/bin");
expect(parts).toContain("/bin");
});
it("returns PATH as-is on Windows", () => {
const result = buildMinimalServicePath({
env: { PATH: "C:\\\\Windows\\\\System32" },
platform: "win32",
});
expect(result).toBe("C:\\\\Windows\\\\System32");
});
it("includes extra directories when provided", () => {
const result = buildMinimalServicePath({
platform: "linux",
extraDirs: ["/custom/tools"],
});
expect(result.split(path.delimiter)).toContain("/custom/tools");
});
it("deduplicates directories", () => {
const result = buildMinimalServicePath({
platform: "linux",
extraDirs: ["/usr/bin"],
});
const parts = result.split(path.delimiter);
const unique = [...new Set(parts)];
expect(parts.length).toBe(unique.length);
});
});
describe("buildServiceEnvironment", () => {
it("sets minimal PATH and gateway vars", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user" },
port: 18789,
token: "secret",
});
if (process.platform === "win32") {
expect(env.PATH).toBe("");
} else {
expect(env.PATH).toContain("/usr/bin");
}
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("18789");
expect(env.CLAWDBOT_GATEWAY_TOKEN).toBe("secret");
});
});
+71
View File
@@ -0,0 +1,71 @@
import path from "node:path";
export type MinimalServicePathOptions = {
platform?: NodeJS.Platform;
extraDirs?: string[];
};
type BuildServicePathOptions = MinimalServicePathOptions & {
env?: Record<string, string | undefined>;
};
function resolveSystemPathDirs(platform: NodeJS.Platform): string[] {
if (platform === "darwin") {
return ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
}
if (platform === "linux") {
return ["/usr/local/bin", "/usr/bin", "/bin"];
}
return [];
}
export function getMinimalServicePathParts(
options: MinimalServicePathOptions = {},
): string[] {
const platform = options.platform ?? process.platform;
if (platform === "win32") return [];
const parts: string[] = [];
const extraDirs = options.extraDirs ?? [];
const systemDirs = resolveSystemPathDirs(platform);
const add = (dir: string) => {
if (!dir) return;
if (!parts.includes(dir)) parts.push(dir);
};
for (const dir of extraDirs) add(dir);
for (const dir of systemDirs) add(dir);
return parts;
}
export function buildMinimalServicePath(
options: BuildServicePathOptions = {},
): string {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
if (platform === "win32") {
return env.PATH ?? "";
}
return getMinimalServicePathParts(options).join(path.delimiter);
}
export function buildServiceEnvironment(params: {
env: Record<string, string | undefined>;
port: number;
token?: string;
launchdLabel?: string;
}): Record<string, string | undefined> {
const { env, port, token, launchdLabel } = params;
return {
PATH: buildMinimalServicePath({ env }),
CLAWDBOT_PROFILE: env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN: token,
CLAWDBOT_LAUNCHD_LABEL: launchdLabel,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./send.js", () => ({
fetchChannelPermissionsDiscord: vi.fn(),
}));
describe("discord audit", () => {
it("collects numeric channel ids and counts unresolved keys", async () => {
const { collectDiscordAuditChannelIds, auditDiscordChannelPermissions } =
await import("./audit.js");
const { fetchChannelPermissionsDiscord } = await import("./send.js");
const cfg = {
discord: {
enabled: true,
token: "t",
groupPolicy: "allowlist",
guilds: {
"123": {
channels: {
"111": { allow: true },
general: { allow: true },
"222": { allow: false },
},
},
},
},
} as unknown as import("../config/config.js").ClawdbotConfig;
const collected = collectDiscordAuditChannelIds({
cfg,
accountId: "default",
});
expect(collected.channelIds).toEqual(["111"]);
expect(collected.unresolvedChannels).toBe(1);
(
fetchChannelPermissionsDiscord as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce({
channelId: "111",
permissions: ["ViewChannel"],
raw: "0",
isDm: false,
});
const audit = await auditDiscordChannelPermissions({
token: "t",
accountId: "default",
channelIds: collected.channelIds,
timeoutMs: 1000,
});
expect(audit.ok).toBe(false);
expect(audit.channels[0]?.channelId).toBe("111");
expect(audit.channels[0]?.missing).toContain("SendMessages");
});
});
+127
View File
@@ -0,0 +1,127 @@
import type { ClawdbotConfig } from "../config/config.js";
import type {
DiscordGuildChannelConfig,
DiscordGuildEntry,
} from "../config/types.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
export type DiscordChannelPermissionsAuditEntry = {
channelId: string;
ok: boolean;
missing?: string[];
error?: string | null;
};
export type DiscordChannelPermissionsAudit = {
ok: boolean;
checkedChannels: number;
unresolvedChannels: number;
channels: DiscordChannelPermissionsAuditEntry[];
elapsedMs: number;
};
const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function shouldAuditChannelConfig(
config: DiscordGuildChannelConfig | undefined,
) {
if (!config) return true;
if (config.allow === false) return false;
if (config.enabled === false) return false;
return true;
}
function listConfiguredGuildChannelKeys(
guilds: Record<string, DiscordGuildEntry> | undefined,
): string[] {
if (!guilds) return [];
const ids = new Set<string>();
for (const entry of Object.values(guilds)) {
if (!entry || typeof entry !== "object") continue;
const channelsRaw = (entry as { channels?: unknown }).channels;
if (!isRecord(channelsRaw)) continue;
for (const [key, value] of Object.entries(channelsRaw)) {
const channelId = String(key).trim();
if (!channelId) continue;
if (
!shouldAuditChannelConfig(
value as DiscordGuildChannelConfig | undefined,
)
)
continue;
ids.add(channelId);
}
}
return [...ids].sort((a, b) => a.localeCompare(b));
}
export function collectDiscordAuditChannelIds(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}) {
const account = resolveDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const keys = listConfiguredGuildChannelKeys(account.config.guilds);
const channelIds = keys.filter((key) => /^\d+$/.test(key));
const unresolvedChannels = keys.length - channelIds.length;
return { channelIds, unresolvedChannels };
}
export async function auditDiscordChannelPermissions(params: {
token: string;
accountId?: string | null;
channelIds: string[];
timeoutMs: number;
}): Promise<DiscordChannelPermissionsAudit> {
const started = Date.now();
const token = params.token?.trim() ?? "";
if (!token || params.channelIds.length === 0) {
return {
ok: true,
checkedChannels: 0,
unresolvedChannels: 0,
channels: [],
elapsedMs: Date.now() - started,
};
}
const required = [...REQUIRED_CHANNEL_PERMISSIONS];
const channels: DiscordChannelPermissionsAuditEntry[] = [];
for (const channelId of params.channelIds) {
try {
const perms = await fetchChannelPermissionsDiscord(channelId, {
token,
accountId: params.accountId ?? undefined,
});
const missing = required.filter((p) => !perms.permissions.includes(p));
channels.push({
channelId,
ok: missing.length === 0,
missing: missing.length ? missing : undefined,
error: null,
});
} catch (err) {
channels.push({
channelId,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
return {
ok: channels.every((c) => c.ok),
checkedChannels: channels.length,
unresolvedChannels: 0,
channels,
elapsedMs: Date.now() - started,
};
}
+25
View File
@@ -47,6 +47,31 @@ describe("waitForDiscordGatewayStop", () => {
expect(disconnect).toHaveBeenCalledTimes(1);
});
it("ignores gateway errors when instructed", async () => {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const onGatewayError = vi.fn();
const abort = new AbortController();
const err = new Error("transient");
const promise = waitForDiscordGatewayStop({
gateway: { emitter, disconnect },
abortSignal: abort.signal,
onGatewayError,
shouldStopOnError: () => false,
});
emitter.emit("error", err);
expect(onGatewayError).toHaveBeenCalledWith(err);
expect(disconnect).toHaveBeenCalledTimes(0);
expect(emitter.listenerCount("error")).toBe(1);
abort.abort();
await expect(promise).resolves.toBeUndefined();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(emitter.listenerCount("error")).toBe(0);
});
it("resolves on abort without a gateway", async () => {
const abort = new AbortController();
+6 -2
View File
@@ -15,8 +15,9 @@ export async function waitForDiscordGatewayStop(params: {
gateway?: DiscordGatewayHandle;
abortSignal?: AbortSignal;
onGatewayError?: (err: unknown) => void;
shouldStopOnError?: (err: unknown) => boolean;
}): Promise<void> {
const { gateway, abortSignal, onGatewayError } = params;
const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
const emitter = gateway?.emitter;
return await new Promise<void>((resolve, reject) => {
let settled = false;
@@ -49,7 +50,10 @@ export async function waitForDiscordGatewayStop(params: {
};
const onGatewayErrorEvent = (err: unknown) => {
onGatewayError?.(err);
finishReject(err);
const shouldStop = shouldStopOnError?.(err) ?? true;
if (shouldStop) {
finishReject(err);
}
};
if (abortSignal?.aborted) {
+82
View File
@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const updateLastRouteMock = vi.fn();
const dispatchMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
vi.mock("./send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
@@ -12,6 +14,12 @@ vi.mock("./send.js", () => ({
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
@@ -29,6 +37,10 @@ beforeEach(() => {
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
vi.resetModules();
});
@@ -99,6 +111,76 @@ describe("discord tool result dispatch", () => {
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000);
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
});
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.DM,
name: "dm",
}),
} as unknown as Client;
await handler(
{
message: {
id: "m1",
content: "hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u2", bot: false, username: "Ada" },
},
author: { id: "u2", bot: false, username: "Ada" },
guild_id: null,
},
client,
);
expect(dispatchMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Your Discord user id: u2",
);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
}, 10000);
it("accepts guild messages when mentionPatterns match", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
+60 -39
View File
@@ -44,10 +44,12 @@ import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatDurationSeconds } from "../infra/format-duration.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -330,6 +332,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
[
new GatewayPlugin({
reconnect: {
maxAttempts: Number.POSITIVE_INFINITY,
},
intents:
GatewayIntents.Guilds |
GatewayIntents.GuildMessages |
@@ -408,18 +413,35 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const gateway = client.getPlugin<GatewayPlugin>("gateway");
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal: opts.abortSignal,
onGatewayError: (err) => {
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
},
});
const onGatewayWarning = (warning: unknown) => {
logVerbose(`discord gateway warning: ${String(warning)}`);
};
if (shouldLogVerbose()) {
gatewayEmitter?.on("warning", onGatewayWarning);
}
try {
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal: opts.abortSignal,
onGatewayError: (err) => {
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
},
shouldStopOnError: (err) => {
const message = String(err);
return (
message.includes("Max reconnect attempts") ||
message.includes("Fatal Gateway error")
);
},
});
} finally {
gatewayEmitter?.removeListener("warning", onGatewayWarning);
}
}
async function clearDiscordNativeCommands(params: {
@@ -479,7 +501,6 @@ export function createDiscordMessageHandler(params: {
guildEntries,
} = params;
const logger = getChildLogger({ module: "discord-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = discordConfig?.groupPolicy ?? "open";
@@ -548,14 +569,11 @@ export function createDiscordMessageHandler(params: {
try {
await sendMessageDiscord(
`user:${author.id}`,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>",
].join("\n"),
buildPairingReply({
provider: "discord",
idLine: `Your Discord user id: ${author.id}`,
code,
}),
{ token, rest: client.rest, accountId },
);
} catch (err) {
@@ -576,6 +594,22 @@ export function createDiscordMessageHandler(params: {
}
const botId = botUserId;
const baseText = resolveDiscordMessageText(message);
recordProviderActivity({
provider: "discord",
accountId,
direction: "inbound",
});
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: data.guild_id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : message.channelId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
!isDirectMessage &&
(Boolean(
@@ -647,16 +681,6 @@ export function createDiscordMessageHandler(params: {
guildInfo?.slug ||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: data.guild_id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : message.channelId,
},
});
const baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
@@ -1384,14 +1408,11 @@ function createDiscordNativeCommand(params: {
});
if (created) {
await interaction.reply({
content: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>",
].join("\n"),
content: buildPairingReply({
provider: "discord",
idLine: `Your Discord user id: ${user.id}`,
code,
}),
ephemeral: true,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveDiscordPrivilegedIntentsFromFlags } from "./probe.js";
describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
it("reports disabled when no bits set", () => {
expect(resolveDiscordPrivilegedIntentsFromFlags(0)).toEqual({
presence: "disabled",
guildMembers: "disabled",
messageContent: "disabled",
});
});
it("reports enabled when full intent bits set", () => {
const flags = (1 << 12) | (1 << 14) | (1 << 18);
expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
presence: "enabled",
guildMembers: "enabled",
messageContent: "enabled",
});
});
it("reports limited when limited intent bits set", () => {
const flags = (1 << 13) | (1 << 15) | (1 << 19);
expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
presence: "limited",
guildMembers: "limited",
messageContent: "limited",
});
});
it("prefers enabled over limited when both set", () => {
const flags =
(1 << 12) | (1 << 13) | (1 << 14) | (1 << 15) | (1 << 18) | (1 << 19);
expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
presence: "enabled",
guildMembers: "enabled",
messageContent: "enabled",
});
});
});
+93 -1
View File
@@ -8,8 +8,89 @@ export type DiscordProbe = {
error?: string | null;
elapsedMs: number;
bot?: { id?: string | null; username?: string | null };
application?: DiscordApplicationSummary;
};
export type DiscordPrivilegedIntentStatus = "enabled" | "limited" | "disabled";
export type DiscordPrivilegedIntentsSummary = {
messageContent: DiscordPrivilegedIntentStatus;
guildMembers: DiscordPrivilegedIntentStatus;
presence: DiscordPrivilegedIntentStatus;
};
export type DiscordApplicationSummary = {
id?: string | null;
flags?: number | null;
intents?: DiscordPrivilegedIntentsSummary;
};
const DISCORD_APP_FLAG_GATEWAY_PRESENCE = 1 << 12;
const DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED = 1 << 13;
const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS = 1 << 14;
const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15;
const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT = 1 << 18;
const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19;
export function resolveDiscordPrivilegedIntentsFromFlags(
flags: number,
): DiscordPrivilegedIntentsSummary {
const resolve = (enabledBit: number, limitedBit: number) => {
if ((flags & enabledBit) !== 0) return "enabled";
if ((flags & limitedBit) !== 0) return "limited";
return "disabled";
};
return {
presence: resolve(
DISCORD_APP_FLAG_GATEWAY_PRESENCE,
DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED,
),
guildMembers: resolve(
DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS,
DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED,
),
messageContent: resolve(
DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT,
DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED,
),
};
}
export async function fetchDiscordApplicationSummary(
token: string,
timeoutMs: number,
fetcher: typeof fetch = fetch,
): Promise<DiscordApplicationSummary | undefined> {
const normalized = normalizeDiscordToken(token);
if (!normalized) return undefined;
try {
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/oauth2/applications/@me`,
timeoutMs,
fetcher,
{
Authorization: `Bot ${normalized}`,
},
);
if (!res.ok) return undefined;
const json = (await res.json()) as { id?: string; flags?: number };
const flags =
typeof json.flags === "number" && Number.isFinite(json.flags)
? json.flags
: undefined;
return {
id: json.id ?? null,
flags: flags ?? null,
intents:
typeof flags === "number"
? resolveDiscordPrivilegedIntentsFromFlags(flags)
: undefined,
};
} catch {
return undefined;
}
}
async function fetchWithTimeout(
url: string,
timeoutMs: number,
@@ -28,8 +109,11 @@ async function fetchWithTimeout(
export async function probeDiscord(
token: string,
timeoutMs: number,
opts?: { fetcher?: typeof fetch; includeApplication?: boolean },
): Promise<DiscordProbe> {
const started = Date.now();
const fetcher = opts?.fetcher ?? fetch;
const includeApplication = opts?.includeApplication === true;
const normalized = normalizeDiscordToken(token);
const result: DiscordProbe = {
ok: false,
@@ -48,7 +132,7 @@ export async function probeDiscord(
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/users/@me`,
timeoutMs,
fetch,
fetcher,
{
Authorization: `Bot ${normalized}`,
},
@@ -64,6 +148,14 @@ export async function probeDiscord(
id: json.id ?? null,
username: json.username ?? null,
};
if (includeApplication) {
result.application =
(await fetchDiscordApplicationSummary(
normalized,
timeoutMs,
fetcher,
)) ?? undefined;
}
return { ...result, elapsedMs: Date.now() - started };
} catch (err) {
return {
+6
View File
@@ -18,6 +18,7 @@ import {
} from "discord-api-types/v10";
import { loadConfig } from "../config/config.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import type { RetryConfig } from "../infra/retry.js";
import {
createDiscordRetryRunner,
@@ -589,6 +590,11 @@ export async function sendMessageDiscord(
});
}
recordProviderActivity({
provider: "discord",
accountId: accountInfo.accountId,
direction: "outbound",
});
return {
messageId: result.id ? String(result.id) : "unknown",
channelId: String(result.channel_id ?? channelId),
+80
View File
@@ -12,4 +12,84 @@ describe("gateway auth", () => {
});
expect(res.ok).toBe(true);
});
it("reports missing and mismatched token reasons", async () => {
const missing = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: null,
});
expect(missing.ok).toBe(false);
expect(missing.reason).toBe("token_missing");
const mismatch = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "wrong" },
});
expect(mismatch.ok).toBe(false);
expect(mismatch.reason).toBe("token_mismatch");
});
it("reports missing token config reason", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", allowTailscale: false },
connectAuth: { token: "anything" },
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_missing_config");
});
it("reports missing and mismatched password reasons", async () => {
const missing = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },
connectAuth: null,
});
expect(missing.ok).toBe(false);
expect(missing.reason).toBe("password_missing");
const mismatch = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },
connectAuth: { password: "wrong" },
});
expect(mismatch.ok).toBe(false);
expect(mismatch.reason).toBe("password_mismatch");
});
it("reports missing password config reason", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "password", allowTailscale: false },
connectAuth: { password: "secret" },
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("password_missing_config");
});
it("reports tailscale auth reasons when required", async () => {
const reqBase = {
socket: { remoteAddress: "100.100.100.100" },
headers: { host: "gateway.local" },
};
const missingUser = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: reqBase as never,
});
expect(missingUser.ok).toBe(false);
expect(missingUser.reason).toBe("tailscale_user_missing");
const missingProxy = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
...reqBase,
headers: {
host: "gateway.local",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(missingProxy.ok).toBe(false);
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
});
});
+27 -13
View File
@@ -150,10 +150,10 @@ export async function authorizeGatewayConnect(params: {
if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) {
return { ok: false, reason: "unauthorized" };
return { ok: false, reason: "tailscale_user_missing" };
}
if (!isTailscaleProxyRequest(req)) {
return { ok: false, reason: "unauthorized" };
return { ok: false, reason: "tailscale_proxy_missing" };
}
return {
ok: true,
@@ -165,31 +165,45 @@ export async function authorizeGatewayConnect(params: {
}
if (auth.mode === "token") {
if (auth.token && connectAuth?.token === auth.token) {
return { ok: true, method: "token" };
if (!auth.token) {
return { ok: false, reason: "token_missing_config" };
}
if (!connectAuth?.token) {
return { ok: false, reason: "token_missing" };
}
if (connectAuth.token !== auth.token) {
return { ok: false, reason: "token_mismatch" };
}
return { ok: true, method: "token" };
}
if (auth.mode === "password") {
const password = connectAuth?.password;
if (!password || !auth.password) {
return { ok: false, reason: "unauthorized" };
if (!auth.password) {
return { ok: false, reason: "password_missing_config" };
}
if (!password) {
return { ok: false, reason: "password_missing" };
}
if (!safeEqual(password, auth.password)) {
return { ok: false, reason: "unauthorized" };
return { ok: false, reason: "password_mismatch" };
}
return { ok: true, method: "password" };
}
if (auth.allowTailscale) {
const tailscaleUser = getTailscaleUser(req);
if (tailscaleUser && isTailscaleProxyRequest(req)) {
return {
ok: true,
method: "tailscale",
user: tailscaleUser.login,
};
if (!tailscaleUser) {
return { ok: false, reason: "tailscale_user_missing" };
}
if (!isTailscaleProxyRequest(req)) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
return {
ok: true,
method: "tailscale",
user: tailscaleUser.login,
};
}
return { ok: false, reason: "unauthorized" };
+15
View File
@@ -3,6 +3,12 @@ import {
type AgentEvent,
AgentEventSchema,
AgentParamsSchema,
type AgentSummary,
AgentSummarySchema,
type AgentsListParams,
AgentsListParamsSchema,
type AgentsListResult,
AgentsListResultSchema,
type AgentWaitParams,
AgentWaitParamsSchema,
type ChatAbortParams,
@@ -163,6 +169,9 @@ export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
AgentWaitParamsSchema,
);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(
AgentsListParamsSchema,
);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema,
);
@@ -332,6 +341,9 @@ export {
ProvidersStatusParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
AgentSummarySchema,
AgentsListParamsSchema,
AgentsListResultSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
@@ -394,6 +406,9 @@ export type {
ProvidersStatusParams,
WebLoginStartParams,
WebLoginWaitParams,
AgentSummary,
AgentsListParams,
AgentsListResult,
SkillsStatusParams,
SkillsInstallParams,
SkillsUpdateParams,
+30
View File
@@ -314,6 +314,7 @@ export const SessionsListParamsSchema = Type.Object(
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -590,6 +591,29 @@ export const ModelChoiceSchema = Type.Object(
{ additionalProperties: false },
);
export const AgentSummarySchema = Type.Object(
{
id: NonEmptyString,
name: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const AgentsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const AgentsListResultSchema = Type.Object(
{
defaultId: NonEmptyString,
mainKey: NonEmptyString,
scope: Type.Union([Type.Literal("per-sender"), Type.Literal("global")]),
agents: Type.Array(AgentSummarySchema),
},
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
@@ -927,6 +951,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema,
AgentsListParams: AgentsListParamsSchema,
AgentsListResult: AgentsListResultSchema,
ModelChoice: ModelChoiceSchema,
ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema,
@@ -1000,6 +1027,9 @@ export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type AgentSummary = Static<typeof AgentSummarySchema>;
export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
+2
View File
@@ -1,5 +1,6 @@
import { ErrorCodes, errorShape } from "./protocol/index.js";
import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
@@ -45,6 +46,7 @@ const handlers: GatewayRequestHandlers = {
...sendHandlers,
...usageHandlers,
...agentHandlers,
...agentsHandlers,
};
export async function handleGatewayRequest(
+29
View File
@@ -0,0 +1,29 @@
import { loadConfig } from "../../config/config.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateAgentsListParams,
} from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.js";
import type { GatewayRequestHandlers } from "./types.js";
export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const result = listAgentsForGateway(cfg);
respond(true, result, undefined);
},
};
+106 -1
View File
@@ -4,11 +4,16 @@ import {
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import type { TelegramGroupConfig } from "../../config/types.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../discord/accounts.js";
import {
auditDiscordChannelPermissions,
collectDiscordAuditChannelIds,
} from "../../discord/audit.js";
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
import {
listIMessageAccountIds,
@@ -16,6 +21,7 @@ import {
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
import { getProviderActivity } from "../../infra/provider-activity.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
@@ -33,6 +39,10 @@ import {
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../telegram/accounts.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../telegram/audit.js";
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
import {
listEnabledWhatsAppAccounts,
@@ -89,6 +99,16 @@ export const providersHandlers: GatewayRequestHandlers = {
const configured = Boolean(account.token);
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
const groups =
cfg.telegram?.accounts?.[account.accountId]?.groups ??
cfg.telegram?.groups;
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
collectTelegramUnmentionedGroupIds(
groups as Record<string, TelegramGroupConfig> | undefined,
);
let audit:
| Awaited<ReturnType<typeof auditTelegramGroupMembership>>
| undefined;
if (probe && configured && account.enabled) {
telegramProbe = await probeTelegram(
account.token,
@@ -96,7 +116,47 @@ export const providersHandlers: GatewayRequestHandlers = {
account.config.proxy,
);
lastProbeAt = Date.now();
const botId =
telegramProbe.ok && telegramProbe.bot?.id != null
? telegramProbe.bot.id
: null;
if (botId && (groupIds.length > 0 || unresolvedGroups > 0)) {
const auditRes = await auditTelegramGroupMembership({
token: account.token,
botId,
groupIds,
proxyUrl: account.config.proxy,
timeoutMs,
});
audit = {
...auditRes,
unresolvedGroups,
hasWildcardUnmentionedGroups,
};
} else if (unresolvedGroups > 0 || hasWildcardUnmentionedGroups) {
audit = {
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
checkedGroups: 0,
unresolvedGroups,
hasWildcardUnmentionedGroups,
groups: [],
elapsedMs: 0,
};
}
}
const allowUnmentionedGroups =
Boolean(
groups?.["*"] &&
(groups["*"] as { requireMention?: boolean }).requireMention ===
false,
) ||
Object.entries(groups ?? {}).some(
([key, value]) =>
key !== "*" &&
Boolean(value) &&
typeof value === "object" &&
(value as { requireMention?: boolean }).requireMention === false,
);
return {
accountId: account.accountId,
name: account.name,
@@ -110,6 +170,16 @@ export const providersHandlers: GatewayRequestHandlers = {
lastError: rt?.lastError ?? null,
probe: telegramProbe,
lastProbeAt,
audit,
allowUnmentionedGroups,
lastInboundAt: getProviderActivity({
provider: "telegram",
accountId: account.accountId,
}).inboundAt,
lastOutboundAt: getProviderActivity({
provider: "telegram",
accountId: account.accountId,
}).outboundAt,
};
}),
);
@@ -129,9 +199,25 @@ export const providersHandlers: GatewayRequestHandlers = {
const configured = Boolean(account.token);
let discordProbe: DiscordProbe | undefined;
let lastProbeAt: number | null = null;
const { channelIds: auditChannelIds, unresolvedChannels } =
collectDiscordAuditChannelIds({ cfg, accountId: account.accountId });
let audit:
| Awaited<ReturnType<typeof auditDiscordChannelPermissions>>
| undefined;
if (probe && configured && account.enabled) {
discordProbe = await probeDiscord(account.token, timeoutMs);
discordProbe = await probeDiscord(account.token, timeoutMs, {
includeApplication: true,
});
lastProbeAt = Date.now();
if (auditChannelIds.length > 0 || unresolvedChannels > 0) {
const auditRes = await auditDiscordChannelPermissions({
token: account.token,
accountId: account.accountId,
channelIds: auditChannelIds,
timeoutMs,
});
audit = { ...auditRes, unresolvedChannels };
}
}
return {
accountId: account.accountId,
@@ -139,12 +225,23 @@ export const providersHandlers: GatewayRequestHandlers = {
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
bot: rt?.bot ?? null,
application: rt?.application ?? null,
running: rt?.running ?? false,
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
probe: discordProbe,
lastProbeAt,
audit,
lastInboundAt: getProviderActivity({
provider: "discord",
accountId: account.accountId,
}).inboundAt,
lastOutboundAt: getProviderActivity({
provider: "discord",
accountId: account.accountId,
}).outboundAt,
};
}),
);
@@ -302,6 +399,14 @@ export const providersHandlers: GatewayRequestHandlers = {
lastMessageAt: rt.lastMessageAt ?? null,
lastEventAt: rt.lastEventAt ?? null,
lastError: rt.lastError ?? null,
lastInboundAt: getProviderActivity({
provider: "whatsapp",
accountId: account.accountId,
}).inboundAt,
lastOutboundAt: getProviderActivity({
provider: "whatsapp",
accountId: account.accountId,
}).outboundAt,
};
}),
);
+24 -1
View File
@@ -5,6 +5,10 @@ import {
resolveDiscordAccount,
} from "../discord/accounts.js";
import { monitorDiscordProvider } from "../discord/index.js";
import type {
DiscordApplicationSummary,
DiscordProbe,
} from "../discord/probe.js";
import { probeDiscord } from "../discord/probe.js";
import { shouldLogVerbose } from "../globals.js";
import {
@@ -56,6 +60,8 @@ export type DiscordRuntimeStatus = {
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
bot?: DiscordProbe["bot"];
application?: DiscordApplicationSummary;
};
export type SlackRuntimeStatus = {
@@ -194,6 +200,8 @@ export function createProviderManager(
lastStartAt: null,
lastStopAt: null,
lastError: null,
bot: undefined,
application: undefined,
});
const defaultSlackStatus = (): SlackRuntimeStatus => ({
running: false,
@@ -544,9 +552,24 @@ export function createProviderManager(
}
let discordBotLabel = "";
try {
const probe = await probeDiscord(token, 2500);
const probe = await probeDiscord(token, 2500, {
includeApplication: true,
});
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) discordBotLabel = ` (@${username})`;
const latest =
discordRuntimes.get(account.accountId) ?? defaultDiscordStatus();
discordRuntimes.set(account.accountId, {
...latest,
bot: probe.bot,
application: probe.application,
});
const messageContent = probe.application?.intents?.messageContent;
if (messageContent && messageContent !== "enabled") {
logDiscord.warn(
`[${account.accountId}] Discord Message Content Intent is ${messageContent}; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
);
}
} catch (err) {
if (shouldLogVerbose()) {
logDiscord.debug(
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
testState,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway server agents", () => {
test("lists configured agents via agents.list RPC", async () => {
testState.routingConfig = {
defaultAgentId: "work",
agents: {
work: { name: "Work" },
home: { name: "Home" },
},
};
const { ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect(
(hello as unknown as { features?: { methods?: string[] } }).features
?.methods,
).toEqual(expect.arrayContaining(["agents.list"]));
const res = await rpcReq<{
defaultId: string;
mainKey: string;
scope: string;
agents: Array<{ id: string; name?: string }>;
}>(ws, "agents.list", {});
expect(res.ok).toBe(true);
expect(res.payload?.defaultId).toBe("work");
expect(res.payload?.mainKey).toBe("main");
expect(res.payload?.scope).toBe("per-sender");
expect(res.payload?.agents.map((agent) => agent.id)).toEqual([
"work",
"home",
]);
const work = res.payload?.agents.find((agent) => agent.id === "work");
const home = res.payload?.agents.find((agent) => agent.id === "home");
expect(work?.name).toBe("Work");
expect(home?.name).toBe("Home");
});
});
+80
View File
@@ -320,4 +320,84 @@ describe("gateway server sessions", () => {
ws.close();
await server.close();
});
test("filters sessions by agentId", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-sessions-agents-"),
);
testState.sessionConfig = {
store: path.join(dir, "{agentId}", "sessions.json"),
};
testState.routingConfig = {
defaultAgentId: "home",
agents: {
home: {},
work: {},
},
};
const homeDir = path.join(dir, "home");
const workDir = path.join(dir, "work");
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await fs.writeFile(
path.join(homeDir, "sessions.json"),
JSON.stringify(
{
"agent:home:main": {
sessionId: "sess-home-main",
updatedAt: Date.now(),
},
"agent:home:discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(workDir, "sessions.json"),
JSON.stringify(
{
"agent:work:main": {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { ws } = await startServerWithClient();
await connectOk(ws);
const homeSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "home",
});
expect(homeSessions.ok).toBe(true);
expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
"agent:home:discord:group:dev",
"agent:home:main",
]);
const workSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "work",
});
expect(workSessions.ok).toBe(true);
expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual([
"agent:work:main",
]);
});
});
+106
View File
@@ -227,6 +227,7 @@ const METHODS = [
"wizard.status",
"talk.mode",
"models.list",
"agents.list",
"skills.status",
"skills.install",
"skills.update",
@@ -1202,10 +1203,17 @@ export async function startGatewayServer(
wss.on("connection", (socket, upgradeReq) => {
let client: Client | null = null;
let closed = false;
const openedAt = Date.now();
const connId = randomUUID();
const remoteAddr = (
socket as WebSocket & { _socket?: { remoteAddress?: string } }
)._socket?.remoteAddress;
const headerValue = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
const requestHost = headerValue(upgradeReq.headers.host);
const requestOrigin = headerValue(upgradeReq.headers.origin);
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
const canvasHostPortForWs =
canvasHostServer?.port ?? (canvasHost ? port : undefined);
const canvasHostOverride =
@@ -1223,6 +1231,19 @@ export async function startGatewayServer(
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
params?.client?.mode === "webchat" ||
params?.client?.name === "webchat-ui";
let handshakeState: "pending" | "connected" | "failed" = "pending";
let closeCause: string | undefined;
let closeMeta: Record<string, unknown> = {};
let lastFrameType: string | undefined;
let lastFrameMethod: string | undefined;
let lastFrameId: string | undefined;
const setCloseCause = (cause: string, meta?: Record<string, unknown>) => {
if (!closeCause) closeCause = cause;
if (meta && Object.keys(meta).length > 0) {
closeMeta = { ...closeMeta, ...meta };
}
};
const send = (obj: unknown) => {
try {
@@ -1251,9 +1272,24 @@ export async function startGatewayServer(
close();
});
socket.once("close", (code, reason) => {
const durationMs = Date.now() - openedAt;
const closeContext = {
cause: closeCause,
handshake: handshakeState,
durationMs,
lastFrameType,
lastFrameMethod,
lastFrameId,
host: requestHost,
origin: requestOrigin,
userAgent: requestUserAgent,
forwardedFor,
...closeMeta,
};
if (!client) {
logWsControl.warn(
`closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
closeContext,
);
}
if (client && isWebchatConnect(client.connect)) {
@@ -1280,12 +1316,22 @@ export async function startGatewayServer(
connId,
code,
reason: reason?.toString(),
durationMs,
cause: closeCause,
handshake: handshakeState,
lastFrameType,
lastFrameMethod,
lastFrameId,
});
close();
});
const handshakeTimer = setTimeout(() => {
if (!client) {
handshakeState = "failed";
setCloseCause("handshake-timeout", {
handshakeMs: Date.now() - openedAt,
});
logWsControl.warn(
`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`,
);
@@ -1298,6 +1344,29 @@ export async function startGatewayServer(
const text = rawDataToString(data);
try {
const parsed = JSON.parse(text);
const frameType =
parsed && typeof parsed === "object" && "type" in parsed
? typeof (parsed as { type?: unknown }).type === "string"
? String((parsed as { type?: unknown }).type)
: undefined
: undefined;
const frameMethod =
parsed && typeof parsed === "object" && "method" in parsed
? typeof (parsed as { method?: unknown }).method === "string"
? String((parsed as { method?: unknown }).method)
: undefined
: undefined;
const frameId =
parsed && typeof parsed === "object" && "id" in parsed
? typeof (parsed as { id?: unknown }).id === "string"
? String((parsed as { id?: unknown }).id)
: undefined
: undefined;
if (frameType || frameMethod || frameId) {
lastFrameType = frameType;
lastFrameMethod = frameMethod;
lastFrameId = frameId;
}
if (!client) {
// Handshake must be a normal request:
// { type:"req", method:"connect", params: ConnectParams }.
@@ -1324,6 +1393,18 @@ export async function startGatewayServer(
`invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`,
);
}
handshakeState = "failed";
const handshakeError = validateRequestFrame(parsed)
? (parsed as RequestFrame).method === "connect"
? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
: "invalid handshake: first request must be connect"
: "invalid request frame";
setCloseCause("invalid-handshake", {
frameType,
frameMethod,
frameId,
handshakeError,
});
socket.close(1008, "invalid handshake");
close();
return;
@@ -1338,9 +1419,18 @@ export async function startGatewayServer(
maxProtocol < PROTOCOL_VERSION ||
minProtocol > PROTOCOL_VERSION
) {
handshakeState = "failed";
logWsControl.warn(
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
);
setCloseCause("protocol-mismatch", {
minProtocol,
maxProtocol,
expectedProtocol: PROTOCOL_VERSION,
client: connectParams.client.name,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
@@ -1364,9 +1454,24 @@ export async function startGatewayServer(
req: upgradeReq,
});
if (!authResult.ok) {
handshakeState = "failed";
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
);
const authProvided = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.name,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
@@ -1444,6 +1549,7 @@ export async function startGatewayServer(
clearTimeout(handshakeTimer);
client = { socket, connect: connectParams, connId, presenceKey };
handshakeState = "connected";
logWs("out", "hello-ok", {
connId,
+48
View File
@@ -17,8 +17,10 @@ import {
resolveSessionTranscriptPath,
resolveStorePath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
@@ -56,6 +58,11 @@ export type GatewaySessionRow = {
lastAccountId?: string;
};
export type GatewayAgentRow = {
id: string;
name?: string;
};
export type SessionsListResult = {
ts: number;
path: string;
@@ -237,6 +244,39 @@ function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
return sorted;
}
export function listAgentsForGateway(cfg: ClawdbotConfig): {
defaultId: string;
mainKey: string;
scope: SessionScope;
agents: GatewayAgentRow[];
} {
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
const mainKey =
(cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
const scope = cfg.session?.scope ?? "per-sender";
const configured = cfg.routing?.agents;
const configuredById = new Map<string, { name?: string }>();
if (configured && typeof configured === "object") {
for (const [key, value] of Object.entries(configured)) {
if (!value || typeof value !== "object") continue;
configuredById.set(normalizeAgentId(key), {
name:
typeof value.name === "string" && value.name.trim()
? value.name.trim()
: undefined,
});
}
}
const agents = listConfiguredAgentIds(cfg).map((id) => {
const meta = configuredById.get(id);
return {
id,
name: meta?.name,
};
});
return { defaultId, mainKey, scope, agents };
}
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") return key;
if (key.startsWith("agent:")) return key;
@@ -394,6 +434,8 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const agentId =
typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const activeMinutes =
typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes)
@@ -404,6 +446,12 @@ export function listSessionsFromStore(params: {
.filter(([key]) => {
if (!includeGlobal && key === "global") return false;
if (!includeUnknown && key === "unknown") return false;
if (agentId) {
if (key === "global" || key === "unknown") return false;
const parsed = parseAgentSessionKey(key);
if (!parsed) return false;
return normalizeAgentId(parsed.agentId) === agentId;
}
return true;
})
.filter(([key, entry]) => {
+3
View File
@@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand;
export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
routingConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined,
@@ -246,6 +247,7 @@ vi.mock("../config/config.js", async () => {
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
},
routing: testState.routingConfig,
whatsapp: {
allowFrom: testState.allowFrom,
},
@@ -354,6 +356,7 @@ export function installGatewayTestHooks() {
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.routingConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
+3
View File
@@ -284,6 +284,9 @@ describe("monitorIMessageProvider", () => {
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Your iMessage sender id: +15550001111",
);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
+18 -20
View File
@@ -17,6 +17,7 @@ import {
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { mediaKindFromMime } from "../media/constants.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -149,7 +150,6 @@ export async function monitorIMessageProvider(
);
const groupPolicy = imessageCfg.groupPolicy ?? "open";
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes =
@@ -257,14 +257,11 @@ export async function monitorIMessageProvider(
try {
await sendMessageIMessage(
sender,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider imessage <code>",
].join("\n"),
buildPairingReply({
provider: "imessage",
idLine: `Your iMessage sender id: ${senderId}`,
code,
}),
{
client,
maxBytes: mediaMaxBytes,
@@ -287,6 +284,18 @@ export async function monitorIMessageProvider(
}
}
const route = resolveAgentRoute({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup
? String(chatId ?? "unknown")
: normalizeIMessageHandle(sender),
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const messageText = (message.text ?? "").trim();
const mentioned = isGroup
? matchesMentionPatterns(messageText, mentionRegexes)
@@ -357,17 +366,6 @@ export async function monitorIMessageProvider(
body: bodyText,
});
const route = resolveAgentRoute({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup
? String(chatId ?? "unknown")
: normalizeIMessageHandle(sender),
},
});
const imessageTo = chatTarget || `imessage:${sender}`;
const ctxPayload = {
Body: body,
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getProviderActivity,
recordProviderActivity,
resetProviderActivityForTest,
} from "./provider-activity.js";
describe("provider activity", () => {
beforeEach(() => {
resetProviderActivityForTest();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-08T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("records inbound/outbound separately", () => {
recordProviderActivity({ provider: "telegram", direction: "inbound" });
vi.advanceTimersByTime(1000);
recordProviderActivity({ provider: "telegram", direction: "outbound" });
const res = getProviderActivity({ provider: "telegram" });
expect(res.inboundAt).toBe(1767830400000);
expect(res.outboundAt).toBe(1767830401000);
});
it("isolates accounts", () => {
recordProviderActivity({
provider: "whatsapp",
accountId: "a",
direction: "inbound",
at: 1,
});
recordProviderActivity({
provider: "whatsapp",
accountId: "b",
direction: "inbound",
at: 2,
});
expect(
getProviderActivity({ provider: "whatsapp", accountId: "a" }),
).toEqual({
inboundAt: 1,
outboundAt: null,
});
expect(
getProviderActivity({ provider: "whatsapp", accountId: "b" }),
).toEqual({
inboundAt: 2,
outboundAt: null,
});
});
});
+52
View File
@@ -0,0 +1,52 @@
export type ProviderId = "discord" | "telegram" | "whatsapp";
export type ProviderDirection = "inbound" | "outbound";
type ActivityEntry = {
inboundAt: number | null;
outboundAt: number | null;
};
const activity = new Map<string, ActivityEntry>();
function keyFor(provider: ProviderId, accountId: string) {
return `${provider}:${accountId || "default"}`;
}
function ensureEntry(provider: ProviderId, accountId: string): ActivityEntry {
const key = keyFor(provider, accountId);
const existing = activity.get(key);
if (existing) return existing;
const created: ActivityEntry = { inboundAt: null, outboundAt: null };
activity.set(key, created);
return created;
}
export function recordProviderActivity(params: {
provider: ProviderId;
accountId?: string | null;
direction: ProviderDirection;
at?: number;
}) {
const at = typeof params.at === "number" ? params.at : Date.now();
const accountId = params.accountId?.trim() || "default";
const entry = ensureEntry(params.provider, accountId);
if (params.direction === "inbound") entry.inboundAt = at;
if (params.direction === "outbound") entry.outboundAt = at;
}
export function getProviderActivity(params: {
provider: ProviderId;
accountId?: string | null;
}): ActivityEntry {
const accountId = params.accountId?.trim() || "default";
return (
activity.get(keyFor(params.provider, accountId)) ?? {
inboundAt: null,
outboundAt: null,
}
);
}
export function resetProviderActivityForTest() {
activity.clear();
}
+345
View File
@@ -0,0 +1,345 @@
export type ProviderStatusIssue = {
provider: "discord" | "telegram" | "whatsapp";
accountId: string;
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
message: string;
fix?: string;
};
type DiscordIntentSummary = {
messageContent?: "enabled" | "limited" | "disabled";
};
type DiscordApplicationSummary = {
intents?: DiscordIntentSummary;
};
type DiscordAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
application?: unknown;
audit?: unknown;
};
type TelegramAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
allowUnmentionedGroups?: unknown;
audit?: unknown;
};
type WhatsAppAccountStatus = {
accountId?: unknown;
enabled?: unknown;
linked?: unknown;
connected?: unknown;
running?: unknown;
reconnectAttempts?: unknown;
lastError?: unknown;
};
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
application: value.application,
audit: value.audit,
};
}
function readDiscordApplicationSummary(
value: unknown,
): DiscordApplicationSummary {
if (!isRecord(value)) return {};
const intentsRaw = value.intents;
if (!isRecord(intentsRaw)) return {};
return {
intents: {
messageContent:
intentsRaw.messageContent === "enabled" ||
intentsRaw.messageContent === "limited" ||
intentsRaw.messageContent === "disabled"
? intentsRaw.messageContent
: undefined,
},
};
}
type DiscordPermissionsAuditSummary = {
unresolvedChannels?: number;
channels?: Array<{
channelId: string;
ok?: boolean;
missing?: string[];
error?: string | null;
}>;
};
function readDiscordPermissionsAuditSummary(
value: unknown,
): DiscordPermissionsAuditSummary {
if (!isRecord(value)) return {};
const unresolvedChannels =
typeof value.unresolvedChannels === "number" &&
Number.isFinite(value.unresolvedChannels)
? value.unresolvedChannels
: undefined;
const channelsRaw = value.channels;
const channels = Array.isArray(channelsRaw)
? (channelsRaw
.map((entry) => {
if (!isRecord(entry)) return null;
const channelId = asString(entry.channelId);
if (!channelId) return null;
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const missing = Array.isArray(entry.missing)
? entry.missing.map((v) => asString(v)).filter(Boolean)
: undefined;
const error = asString(entry.error) ?? null;
return {
channelId,
ok,
missing: missing?.length ? missing : undefined,
error,
};
})
.filter(Boolean) as DiscordPermissionsAuditSummary["channels"])
: undefined;
return { unresolvedChannels, channels };
}
function readTelegramAccountStatus(
value: unknown,
): TelegramAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
allowUnmentionedGroups: value.allowUnmentionedGroups,
audit: value.audit,
};
}
type TelegramGroupMembershipAuditSummary = {
unresolvedGroups?: number;
hasWildcardUnmentionedGroups?: boolean;
groups?: Array<{
chatId: string;
ok?: boolean;
status?: string | null;
error?: string | null;
}>;
};
function readTelegramGroupMembershipAuditSummary(
value: unknown,
): TelegramGroupMembershipAuditSummary {
if (!isRecord(value)) return {};
const unresolvedGroups =
typeof value.unresolvedGroups === "number" &&
Number.isFinite(value.unresolvedGroups)
? value.unresolvedGroups
: undefined;
const hasWildcardUnmentionedGroups =
typeof value.hasWildcardUnmentionedGroups === "boolean"
? value.hasWildcardUnmentionedGroups
: undefined;
const groupsRaw = value.groups;
const groups = Array.isArray(groupsRaw)
? (groupsRaw
.map((entry) => {
if (!isRecord(entry)) return null;
const chatId = asString(entry.chatId);
if (!chatId) return null;
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const status = asString(entry.status) ?? null;
const error = asString(entry.error) ?? null;
return { chatId, ok, status, error };
})
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
: undefined;
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
}
function readWhatsAppAccountStatus(
value: unknown,
): WhatsAppAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
linked: value.linked,
connected: value.connected,
running: value.running,
reconnectAttempts: value.reconnectAttempts,
lastError: value.lastError,
};
}
export function collectProvidersStatusIssues(
payload: Record<string, unknown>,
): ProviderStatusIssue[] {
const issues: ProviderStatusIssue[] = [];
const discordAccountsRaw = payload.discordAccounts;
if (Array.isArray(discordAccountsRaw)) {
for (const entry of discordAccountsRaw) {
const account = readDiscordAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
const app = readDiscordApplicationSummary(account.application);
const messageContent = app.intents?.messageContent;
if (messageContent && messageContent !== "enabled") {
issues.push({
provider: "discord",
accountId,
kind: "intent",
message: `Message Content Intent is ${messageContent}. Bot may not see normal channel messages.`,
fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.",
});
}
const audit = readDiscordPermissionsAuditSummary(account.audit);
if (audit.unresolvedChannels && audit.unresolvedChannels > 0) {
issues.push({
provider: "discord",
accountId,
kind: "config",
message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`,
fix: "Use numeric channel IDs as keys in discord.guilds.*.channels (then rerun providers status --probe).",
});
}
for (const channel of audit.channels ?? []) {
if (channel.ok === true) continue;
const missing = channel.missing?.length
? ` missing ${channel.missing.join(", ")}`
: "";
const error = channel.error ? `: ${channel.error}` : "";
issues.push({
provider: "discord",
accountId,
kind: "permissions",
message: `Channel ${channel.channelId} permission check failed.${missing}${error}`,
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
});
}
}
}
const telegramAccountsRaw = payload.telegramAccounts;
if (Array.isArray(telegramAccountsRaw)) {
for (const entry of telegramAccountsRaw) {
const account = readTelegramAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
if (account.allowUnmentionedGroups === true) {
issues.push({
provider: "telegram",
accountId,
kind: "config",
message:
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
});
}
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
if (audit.hasWildcardUnmentionedGroups === true) {
issues.push({
provider: "telegram",
accountId,
kind: "config",
message:
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
fix: "Add explicit numeric group ids under telegram.groups (or per-account groups) to enable probing.",
});
}
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
issues.push({
provider: "telegram",
accountId,
kind: "config",
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
fix: "Use numeric chat IDs (e.g. -100...) as keys in telegram.groups for requireMention=false groups.",
});
}
for (const group of audit.groups ?? []) {
if (group.ok === true) continue;
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
issues.push({
provider: "telegram",
accountId,
kind: "runtime",
message: `Group ${group.chatId} not reachable by bot.${status}${err}`,
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
});
}
}
}
const whatsappAccountsRaw = payload.whatsappAccounts;
if (Array.isArray(whatsappAccountsRaw)) {
for (const entry of whatsappAccountsRaw) {
const account = readWhatsAppAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
if (!enabled) continue;
const linked = account.linked === true;
const running = account.running === true;
const connected = account.connected === true;
const reconnectAttempts =
typeof account.reconnectAttempts === "number"
? account.reconnectAttempts
: null;
const lastError = asString(account.lastError);
if (!linked) {
issues.push({
provider: "whatsapp",
accountId,
kind: "auth",
message: "Not linked (no WhatsApp Web session).",
fix: "Run: clawdbot providers login (scan QR on the gateway host).",
});
continue;
}
if (running && !connected) {
issues.push({
provider: "whatsapp",
accountId,
kind: "runtime",
message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`,
fix: "Run: clawdbot doctor (or restart the gateway). If it persists, relink via providers login and check logs.",
});
}
}
}
return issues;
}
+10
View File
@@ -0,0 +1,10 @@
import type { PairingProvider } from "./pairing-store.js";
export const PROVIDER_ID_LABELS: Record<PairingProvider, string> = {
telegram: "telegramUserId",
discord: "discordUserId",
slack: "slackUserId",
signal: "signalNumber",
imessage: "imessageSenderId",
whatsapp: "whatsappSenderId",
};
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { buildPairingReply } from "./pairing-messages.js";
describe("buildPairingReply", () => {
const cases = [
{
provider: "discord",
idLine: "Your Discord user id: 1",
code: "ABC123",
},
{
provider: "slack",
idLine: "Your Slack user id: U1",
code: "DEF456",
},
{
provider: "signal",
idLine: "Your Signal number: +15550001111",
code: "GHI789",
},
{
provider: "imessage",
idLine: "Your iMessage sender id: +15550002222",
code: "JKL012",
},
{
provider: "whatsapp",
idLine: "Your WhatsApp sender id: +15550003333",
code: "MNO345",
},
] as const;
for (const testCase of cases) {
it(`formats pairing reply for ${testCase.provider}`, () => {
const text = buildPairingReply(testCase);
expect(text).toContain(testCase.idLine);
expect(text).toContain(`Pairing code: ${testCase.code}`);
expect(text).toContain(
`clawdbot pairing approve --provider ${testCase.provider} <code>`,
);
});
}
});
+19
View File
@@ -0,0 +1,19 @@
import type { PairingProvider } from "./pairing-store.js";
export function buildPairingReply(params: {
provider: PairingProvider;
idLine: string;
code: string;
}): string {
const { provider, idLine, code } = params;
return [
"Clawdbot: access not configured.",
"",
idLine,
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
`clawdbot pairing approve --provider ${provider} <code>`,
].join("\n");
}
+121
View File
@@ -0,0 +1,121 @@
import { normalizeE164 } from "../utils.js";
export type SignalSender =
| { kind: "phone"; raw: string; e164: string }
| { kind: "uuid"; raw: string };
type SignalAllowEntry =
| { kind: "any" }
| { kind: "phone"; e164: string }
| { kind: "uuid"; raw: string };
const UUID_HYPHENATED_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i;
function looksLikeUuid(value: string): boolean {
if (UUID_HYPHENATED_RE.test(value) || UUID_COMPACT_RE.test(value)) {
return true;
}
const compact = value.replace(/-/g, "");
if (!/^[0-9a-f]+$/i.test(compact)) return false;
return /[a-f]/i.test(compact);
}
function stripSignalPrefix(value: string): string {
return value.replace(/^signal:/i, "").trim();
}
export function resolveSignalSender(params: {
sourceNumber?: string | null;
sourceUuid?: string | null;
}): SignalSender | null {
const sourceNumber = params.sourceNumber?.trim();
if (sourceNumber) {
return {
kind: "phone",
raw: sourceNumber,
e164: normalizeE164(sourceNumber),
};
}
const sourceUuid = params.sourceUuid?.trim();
if (sourceUuid) {
return { kind: "uuid", raw: sourceUuid };
}
return null;
}
export function formatSignalSenderId(sender: SignalSender): string {
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
}
export function formatSignalSenderDisplay(sender: SignalSender): string {
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
}
export function formatSignalPairingIdLine(sender: SignalSender): string {
if (sender.kind === "phone") {
return `Your Signal number: ${sender.e164}`;
}
return `Your Signal sender id: ${formatSignalSenderId(sender)}`;
}
export function resolveSignalRecipient(sender: SignalSender): string {
return sender.kind === "phone" ? sender.e164 : sender.raw;
}
export function resolveSignalPeerId(sender: SignalSender): string {
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
}
function parseSignalAllowEntry(entry: string): SignalAllowEntry | null {
const trimmed = entry.trim();
if (!trimmed) return null;
if (trimmed === "*") return { kind: "any" };
const stripped = stripSignalPrefix(trimmed);
const lower = stripped.toLowerCase();
if (lower.startsWith("uuid:")) {
const raw = stripped.slice("uuid:".length).trim();
if (!raw) return null;
return { kind: "uuid", raw };
}
if (looksLikeUuid(stripped)) {
return { kind: "uuid", raw: stripped };
}
return { kind: "phone", e164: normalizeE164(stripped) };
}
export function isSignalSenderAllowed(
sender: SignalSender,
allowFrom: string[],
): boolean {
if (allowFrom.length === 0) return false;
const parsed = allowFrom
.map(parseSignalAllowEntry)
.filter((entry): entry is SignalAllowEntry => entry !== null);
if (parsed.some((entry) => entry.kind === "any")) return true;
return parsed.some((entry) => {
if (entry.kind === "phone" && sender.kind === "phone") {
return entry.e164 === sender.e164;
}
if (entry.kind === "uuid" && sender.kind === "uuid") {
return entry.raw === sender.raw;
}
return false;
});
}
export function isSignalGroupAllowed(params: {
groupPolicy: "open" | "disabled" | "allowlist";
allowFrom: string[];
sender: SignalSender;
}): boolean {
const { groupPolicy, allowFrom, sender } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (allowFrom.length === 0) return false;
return isSignalSenderAllowed(sender, allowFrom);
}
+19 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isSignalGroupAllowed } from "./monitor.js";
import { isSignalGroupAllowed } from "./identity.js";
describe("signal groupPolicy gating", () => {
it("allows when policy is open", () => {
@@ -8,7 +8,7 @@ describe("signal groupPolicy gating", () => {
isSignalGroupAllowed({
groupPolicy: "open",
allowFrom: [],
sender: "+15550001111",
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(true);
});
@@ -18,7 +18,7 @@ describe("signal groupPolicy gating", () => {
isSignalGroupAllowed({
groupPolicy: "disabled",
allowFrom: ["+15550001111"],
sender: "+15550001111",
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(false);
});
@@ -28,7 +28,7 @@ describe("signal groupPolicy gating", () => {
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: [],
sender: "+15550001111",
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(false);
});
@@ -38,7 +38,7 @@ describe("signal groupPolicy gating", () => {
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["+15550001111"],
sender: "+15550001111",
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(true);
});
@@ -48,7 +48,20 @@ describe("signal groupPolicy gating", () => {
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["*"],
sender: "+15550002222",
sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" },
}),
).toBe(true);
});
it("allows allowlist when uuid sender matches", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
sender: {
kind: "uuid",
raw: "123e4567-e89b-12d3-a456-426614174000",
},
}),
).toBe(true);
});
+48
View File
@@ -146,6 +146,9 @@ describe("monitorSignalProvider tool results", () => {
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Your Signal number: +15550001111",
);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
@@ -197,6 +200,51 @@ describe("monitorSignalProvider tool results", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
config = {
...config,
signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] },
};
const abortController = new AbortController();
const uuid = "123e4567-e89b-12d3-a456-426614174000";
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
envelope: {
sourceUuid: uuid,
sourceName: "Ada",
timestamp: 1,
dataMessage: {
message: "hello",
},
},
};
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
abortController.abort();
});
await monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
abortSignal: abortController.signal,
});
await flush();
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ provider: "signal", id: `uuid:${uuid}` }),
);
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
`Your Signal sender id: uuid:${uuid}`,
);
});
it("reconnects after stream errors until aborted", async () => {
vi.useFakeTimers();
const abortController = new AbortController();
+48 -48
View File
@@ -9,6 +9,7 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -19,11 +20,21 @@ import { normalizeE164 } from "../utils.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalCheck, signalRpcRequest } from "./client.js";
import { spawnSignalDaemon } from "./daemon.js";
import {
formatSignalPairingIdLine,
formatSignalSenderDisplay,
formatSignalSenderId,
isSignalSenderAllowed,
resolveSignalPeerId,
resolveSignalRecipient,
resolveSignalSender,
} from "./identity.js";
import { sendMessageSignal } from "./send.js";
import { runSignalSseLoop } from "./sse-reconnect.js";
type SignalEnvelope = {
sourceNumber?: string | null;
sourceUuid?: string | null;
sourceName?: string | null;
timestamp?: number | null;
dataMessage?: SignalDataMessage | null;
@@ -91,28 +102,6 @@ function normalizeAllowList(raw?: Array<string | number>): string[] {
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
if (allowFrom.length === 0) return false;
if (allowFrom.includes("*")) return true;
const normalizedAllow = allowFrom
.map((entry) => entry.replace(/^signal:/i, ""))
.map((entry) => normalizeE164(entry));
const normalizedSender = normalizeE164(sender);
return normalizedAllow.includes(normalizedSender);
}
export function isSignalGroupAllowed(params: {
groupPolicy: "open" | "disabled" | "allowlist";
allowFrom: string[];
sender: string;
}): boolean {
const { groupPolicy, allowFrom, sender } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (allowFrom.length === 0) return false;
return isAllowedSender(sender, allowFrom);
}
async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
@@ -319,11 +308,19 @@ export async function monitorSignalProvider(
envelope.dataMessage ?? envelope.editMessage?.dataMessage;
if (!dataMessage) return;
const sender = envelope.sourceNumber?.trim();
const sender = resolveSignalSender(envelope);
if (!sender) return;
if (account && normalizeE164(sender) === normalizeE164(account)) {
return;
if (account && sender.kind === "phone") {
if (sender.e164 === normalizeE164(account)) {
return;
}
}
const senderDisplay = formatSignalSenderDisplay(sender);
const senderRecipient = resolveSignalRecipient(sender);
const senderPeerId = resolveSignalPeerId(sender);
const senderAllowId = formatSignalSenderId(sender);
if (!senderRecipient) return;
const senderIdLine = formatSignalPairingIdLine(sender);
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
const isGroup = Boolean(groupId);
@@ -333,13 +330,15 @@ export async function monitorSignalProvider(
const effectiveDmAllow = [...allowFrom, ...storeAllowFrom];
const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom];
const dmAllowed =
dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow);
dmPolicy === "open"
? true
: isSignalSenderAllowed(sender, effectiveDmAllow);
if (!isGroup) {
if (dmPolicy === "disabled") return;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const senderId = normalizeE164(sender);
const senderId = senderAllowId;
const { code, created } = await upsertProviderPairingRequest({
provider: "signal",
id: senderId,
@@ -351,15 +350,12 @@ export async function monitorSignalProvider(
logVerbose(`signal pairing request sender=${senderId}`);
try {
await sendMessageSignal(
senderId,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider signal <code>",
].join("\n"),
`signal:${senderRecipient}`,
buildPairingReply({
provider: "signal",
idLine: senderIdLine,
code,
}),
{
baseUrl,
account,
@@ -375,7 +371,7 @@ export async function monitorSignalProvider(
}
} else {
logVerbose(
`Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`,
`Blocked signal sender ${senderDisplay} (dmPolicy=${dmPolicy})`,
);
}
return;
@@ -392,9 +388,9 @@ export async function monitorSignalProvider(
);
return;
}
if (!isAllowedSender(sender, effectiveGroupAllow)) {
if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) {
logVerbose(
`Blocked signal group sender ${sender} (not in groupAllowFrom)`,
`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`,
);
return;
}
@@ -402,7 +398,7 @@ export async function monitorSignalProvider(
const commandAuthorized = isGroup
? effectiveGroupAllow.length > 0
? isAllowedSender(sender, effectiveGroupAllow)
? isSignalSenderAllowed(sender, effectiveGroupAllow)
: true
: dmAllowed;
const messageText = (dataMessage.message ?? "").trim();
@@ -417,7 +413,7 @@ export async function monitorSignalProvider(
baseUrl,
account,
attachment: firstAttachment,
sender,
sender: senderRecipient,
groupId,
maxBytes: mediaMaxBytes,
});
@@ -444,7 +440,7 @@ export async function monitorSignalProvider(
const fromLabel = isGroup
? `${groupName ?? "Signal Group"} id:${groupId}`
: `${envelope.sourceName ?? sender} id:${sender}`;
: `${envelope.sourceName ?? senderDisplay} id:${senderDisplay}`;
const body = formatAgentEnvelope({
provider: "Signal",
from: fromLabel,
@@ -458,20 +454,24 @@ export async function monitorSignalProvider(
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
id: isGroup ? (groupId ?? "unknown") : senderPeerId,
},
});
const signalTo = isGroup ? `group:${groupId}` : `signal:${sender}`;
const signalTo = isGroup
? `group:${groupId}`
: `signal:${senderRecipient}`;
const ctxPayload = {
Body: body,
From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`,
From: isGroup
? `group:${groupId ?? "unknown"}`
: `signal:${senderRecipient}`,
To: signalTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? (groupName ?? undefined) : undefined,
SenderName: envelope.sourceName ?? sender,
SenderId: sender,
SenderName: envelope.sourceName ?? senderDisplay,
SenderId: senderDisplay,
Provider: "signal" as const,
Surface: "signal" as const,
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
@@ -494,7 +494,7 @@ export async function monitorSignalProvider(
storePath,
sessionKey: route.mainSessionKey,
provider: "signal",
to: normalizeE164(sender),
to: senderRecipient,
accountId: route.accountId,
});
}
+3
View File
@@ -567,6 +567,9 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Your Slack user id: U1",
);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
+22 -27
View File
@@ -43,6 +43,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -508,7 +509,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts.slashCommand ?? slackCfg.slashCommand,
);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes =
@@ -825,14 +825,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
try {
await sendMessageSlack(
message.channel,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
buildPairingReply({
provider: "slack",
idLine: `Your Slack user id: ${directUserId}`,
code,
}),
{
token: botToken,
client: app.client,
@@ -855,6 +852,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
const route = resolveAgentRoute({
cfg,
provider: "slack",
accountId: account.accountId,
teamId: teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
@@ -963,16 +971,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
: isRoom
? `slack:channel:${message.channel}`
: `slack:group:${message.channel}`;
const route = resolveAgentRoute({
cfg,
provider: "slack",
accountId: account.accountId,
teamId: teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
const baseSessionKey = route.sessionKey;
const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
@@ -1717,14 +1715,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
});
if (created) {
await respond({
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
text: buildPairingReply({
provider: "slack",
idLine: `Your Slack user id: ${command.user_id}`,
code,
}),
response_type: "ephemeral",
});
}
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("telegram audit", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("collects unmentioned numeric group ids and flags wildcard", async () => {
const { collectTelegramUnmentionedGroupIds } = await import("./audit.js");
const res = collectTelegramUnmentionedGroupIds({
"*": { requireMention: false },
"-1001": { requireMention: false },
"@group": { requireMention: false },
"-1002": { requireMention: true },
"-1003": { requireMention: false, enabled: false },
});
expect(res.hasWildcardUnmentionedGroups).toBe(true);
expect(res.groupIds).toEqual(["-1001"]);
expect(res.unresolvedGroups).toBe(1);
});
it("audits membership via getChatMember", async () => {
const { auditTelegramGroupMembership } = await import("./audit.js");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({ ok: true, result: { status: "member" } }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
),
);
const res = await auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
expect(res.ok).toBe(true);
expect(res.groups[0]?.chatId).toBe("-1001");
expect(res.groups[0]?.status).toBe("member");
});
it("reports bot not in group when status is left", async () => {
const { auditTelegramGroupMembership } = await import("./audit.js");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status: "left" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const res = await auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
expect(res.ok).toBe(false);
expect(res.groups[0]?.ok).toBe(false);
expect(res.groups[0]?.status).toBe("left");
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { TelegramGroupConfig } from "../config/types.js";
import { makeProxyFetch } from "./proxy.js";
const TELEGRAM_API_BASE = "https://api.telegram.org";
export type TelegramGroupMembershipAuditEntry = {
chatId: string;
ok: boolean;
status?: string | null;
error?: string | null;
};
export type TelegramGroupMembershipAudit = {
ok: boolean;
checkedGroups: number;
unresolvedGroups: number;
hasWildcardUnmentionedGroups: boolean;
groups: TelegramGroupMembershipAuditEntry[];
elapsedMs: number;
};
type TelegramApiOk<T> = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
async function fetchWithTimeout(
url: string,
timeoutMs: number,
fetcher: typeof fetch,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetcher(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function collectTelegramUnmentionedGroupIds(
groups: Record<string, TelegramGroupConfig> | undefined,
) {
if (!groups || typeof groups !== "object") {
return {
groupIds: [] as string[],
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
};
}
const hasWildcardUnmentionedGroups =
Boolean(groups["*"]?.requireMention === false) &&
groups["*"]?.enabled !== false;
const groupIds: string[] = [];
let unresolvedGroups = 0;
for (const [key, value] of Object.entries(groups)) {
if (key === "*") continue;
if (!value || typeof value !== "object") continue;
if ((value as TelegramGroupConfig).enabled === false) continue;
if ((value as TelegramGroupConfig).requireMention !== false) continue;
const id = String(key).trim();
if (!id) continue;
if (/^-?\d+$/.test(id)) {
groupIds.push(id);
} else {
unresolvedGroups += 1;
}
}
groupIds.sort((a, b) => a.localeCompare(b));
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
}
export async function auditTelegramGroupMembership(params: {
token: string;
botId: number;
groupIds: string[];
proxyUrl?: string;
timeoutMs: number;
}): Promise<TelegramGroupMembershipAudit> {
const started = Date.now();
const token = params.token?.trim() ?? "";
if (!token || params.groupIds.length === 0) {
return {
ok: true,
checkedGroups: 0,
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
groups: [],
elapsedMs: Date.now() - started,
};
}
const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch;
const base = `${TELEGRAM_API_BASE}/bot${token}`;
const groups: TelegramGroupMembershipAuditEntry[] = [];
for (const chatId of params.groupIds) {
try {
const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`;
const res = await fetchWithTimeout(url, params.timeoutMs, fetcher);
const json = (await res.json()) as
| TelegramApiOk<{ status?: string }>
| TelegramApiErr;
if (!res.ok || !isRecord(json) || json.ok !== true) {
const desc =
isRecord(json) &&
json.ok === false &&
typeof json.description === "string"
? json.description
: `getChatMember failed (${res.status})`;
groups.push({ chatId, ok: false, status: null, error: desc });
continue;
}
const status = isRecord((json as TelegramApiOk<unknown>).result)
? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null)
: null;
const ok =
status === "creator" ||
status === "administrator" ||
status === "member";
groups.push({
chatId,
ok,
status,
error: ok ? null : "bot not in group",
});
} catch (err) {
groups.push({
chatId,
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
});
}
}
return {
ok: groups.every((g) => g.ok),
checkedGroups: groups.length,
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
groups,
elapsedMs: Date.now() - started,
};
}
+3
View File
@@ -284,6 +284,9 @@ describe("createTelegramBot", () => {
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
"Your Telegram user id: 999",
);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
"Pairing code:",
);
+11 -1
View File
@@ -38,6 +38,7 @@ import {
} from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { getChildLogger } from "../logging.js";
import { mediaKindFromMime } from "../media/constants.js";
import { detectMime, isGifMedia } from "../media/mime.js";
@@ -225,7 +226,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const mediaMaxBytes =
(opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
let botHasTopicsEnabled: boolean | undefined;
const resolveBotTopicsEnabled = async (ctx?: TelegramContext) => {
const fromCtx = ctx?.me as { has_topics_enabled?: boolean } | undefined;
@@ -301,6 +301,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
storeAllowFrom: string[],
) => {
const msg = primaryCtx.message;
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "inbound",
});
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number })
@@ -322,6 +327,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
id: peerId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const effectiveDmAllow = normalizeAllowFrom([
...(allowFrom ?? []),
...storeAllowFrom,
@@ -384,8 +390,10 @@ export function createTelegramBot(opts: TelegramBotOptions) {
first_name?: string;
last_name?: string;
username?: string;
id?: number;
}
| undefined;
const telegramUserId = from?.id ? String(from.id) : candidate;
const { code, created } = await upsertTelegramPairingRequest({
chatId: candidate,
username: from?.username,
@@ -407,6 +415,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
[
"Clawdbot: access not configured.",
"",
`Your Telegram user id: ${telegramUserId}`,
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
+11
View File
@@ -3,6 +3,7 @@ import type { ApiClientOptions } from "grammy";
import { Bot, InputFile } from "grammy";
import { loadConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import type { RetryConfig } from "../infra/retry.js";
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import { mediaKindFromMime } from "../media/constants.js";
@@ -227,6 +228,11 @@ export async function sendMessageTelegram(
});
}
const messageId = String(result?.message_id ?? "unknown");
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
}
@@ -263,6 +269,11 @@ export async function sendMessageTelegram(
throw wrapChatNotFound(err);
});
const messageId = String(res?.message_id ?? "unknown");
recordProviderActivity({
provider: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
}
+3
View File
@@ -31,6 +31,8 @@ export function getSlashCommands(): SlashCommand[] {
return [
{ name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" },
{ name: "agent", description: "Switch agent (or open picker)" },
{ name: "agents", description: "Open agent picker" },
{ name: "session", description: "Switch session (or open picker)" },
{ name: "sessions", description: "Open session picker" },
{
@@ -108,6 +110,7 @@ export function helpText(): string {
"Slash commands:",
"/help",
"/status",
"/agent <id> (or /agents)",
"/session <key> (or /sessions)",
"/model <provider/model> (or /models)",
"/think <off|minimal|low|medium|high>",
+5
View File
@@ -4,6 +4,7 @@ export class CustomEditor extends Editor {
onEscape?: () => void;
onCtrlC?: () => void;
onCtrlD?: () => void;
onCtrlG?: () => void;
onCtrlL?: () => void;
onCtrlO?: () => void;
onCtrlP?: () => void;
@@ -28,6 +29,10 @@ export class CustomEditor extends Editor {
this.onCtrlP();
return;
}
if (matchesKey(data, Key.ctrl("g")) && this.onCtrlG) {
this.onCtrlG();
return;
}
if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) {
this.onCtrlT();
return;
+15
View File
@@ -57,6 +57,16 @@ export type GatewaySessionList = {
}>;
};
export type GatewayAgentsList = {
defaultId: string;
mainKey: string;
scope: "per-sender" | "global";
agents: Array<{
id: string;
name?: string;
}>;
};
export type GatewayModelChoice = {
id: string;
name: string;
@@ -165,9 +175,14 @@ export class GatewayChatClient {
activeMinutes: opts?.activeMinutes,
includeGlobal: opts?.includeGlobal,
includeUnknown: opts?.includeUnknown,
agentId: opts?.agentId,
});
}
async listAgents() {
return await this.client.request<GatewayAgentsList>("agents.list", {});
}
async patchSession(opts: SessionsPatchParams) {
return await this.client.request("sessions.patch", opts);
}
+196 -23
View File
@@ -7,6 +7,11 @@ import {
TUI,
} from "@mariozechner/pi-tui";
import { loadConfig } from "../config/config.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
import { ChatLog } from "./components/chat-log.js";
import { CustomEditor } from "./components/custom-editor.js";
@@ -14,7 +19,7 @@ import {
createSelectList,
createSettingsList,
} from "./components/selectors.js";
import { GatewayChatClient } from "./gateway-chat.js";
import { type GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
import { editorTheme, theme } from "./theme/theme.js";
export type TuiOptions = {
@@ -53,6 +58,13 @@ type SessionInfo = {
displayName?: string;
};
type SessionScope = "per-sender" | "global";
type AgentSummary = {
id: string;
name?: string;
};
function extractTextBlocks(
content: unknown,
opts?: { includeThinking?: boolean },
@@ -106,9 +118,18 @@ function asString(value: unknown, fallback = ""): string {
export async function runTui(opts: TuiOptions) {
const config = loadConfig();
const defaultSession =
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
let currentSessionKey = defaultSession;
const initialSessionInput = (opts.session ?? "").trim();
let sessionScope: SessionScope = (config.session?.scope ??
"per-sender") as SessionScope;
let sessionMainKey = (config.session?.mainKey ?? "main").trim() || "main";
let agentDefaultId = normalizeAgentId(
config.routing?.defaultAgentId ?? "main",
);
let currentAgentId = agentDefaultId;
let agents: AgentSummary[] = [];
const agentNames = new Map<string, string>();
let currentSessionKey = "";
let initialSessionApplied = false;
let currentSessionId: string | null = null;
let activeChatRunId: string | null = null;
const finalizedRuns = new Map<string, number>();
@@ -144,10 +165,39 @@ export async function runTui(opts: TuiOptions) {
tui.addChild(root);
tui.setFocus(editor);
const formatSessionKey = (key: string) => {
if (key === "global" || key === "unknown") return key;
const parsed = parseAgentSessionKey(key);
return parsed?.rest ?? key;
};
const formatAgentLabel = (id: string) => {
const name = agentNames.get(id);
return name ? `${id} (${name})` : id;
};
const resolveSessionKey = (raw?: string) => {
const trimmed = (raw ?? "").trim();
if (sessionScope === "global") return "global";
if (!trimmed) {
return buildAgentMainSessionKey({
agentId: currentAgentId,
mainKey: sessionMainKey,
});
}
if (trimmed === "global" || trimmed === "unknown") return trimmed;
if (trimmed.startsWith("agent:")) return trimmed;
return `agent:${currentAgentId}:${trimmed}`;
};
currentSessionKey = resolveSessionKey(initialSessionInput);
const updateHeader = () => {
const sessionLabel = formatSessionKey(currentSessionKey);
const agentLabel = formatAgentLabel(currentAgentId);
header.setText(
theme.header(
`clawdbot tui - ${client.connection.url} - session ${currentSessionKey}`,
`clawdbot tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`,
),
);
};
@@ -158,9 +208,11 @@ export async function runTui(opts: TuiOptions) {
const updateFooter = () => {
const connection = isConnected ? "connected" : "disconnected";
const sessionKeyLabel = formatSessionKey(currentSessionKey);
const sessionLabel = sessionInfo.displayName
? `${currentSessionKey} (${sessionInfo.displayName})`
: currentSessionKey;
? `${sessionKeyLabel} (${sessionInfo.displayName})`
: sessionKeyLabel;
const agentLabel = formatAgentLabel(currentAgentId);
const modelLabel = sessionInfo.model ?? "unknown";
const tokens = formatTokens(
sessionInfo.totalTokens ?? null,
@@ -172,7 +224,7 @@ export async function runTui(opts: TuiOptions) {
const deliver = deliverDefault ? "on" : "off";
footer.setText(
theme.dim(
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`,
`${connection} | agent ${agentLabel} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`,
),
);
};
@@ -188,15 +240,82 @@ export async function runTui(opts: TuiOptions) {
tui.setFocus(component);
};
const initialSessionAgentId = (() => {
if (!initialSessionInput) return null;
const parsed = parseAgentSessionKey(initialSessionInput);
return parsed ? normalizeAgentId(parsed.agentId) : null;
})();
const applyAgentsResult = (result: GatewayAgentsList) => {
agentDefaultId = normalizeAgentId(result.defaultId);
sessionMainKey = result.mainKey.trim() || sessionMainKey;
sessionScope = result.scope ?? sessionScope;
agents = result.agents.map((agent) => ({
id: normalizeAgentId(agent.id),
name: agent.name?.trim() || undefined,
}));
agentNames.clear();
for (const agent of agents) {
if (agent.name) agentNames.set(agent.id, agent.name);
}
if (!initialSessionApplied) {
if (initialSessionAgentId) {
if (agents.some((agent) => agent.id === initialSessionAgentId)) {
currentAgentId = initialSessionAgentId;
}
} else if (!agents.some((agent) => agent.id === currentAgentId)) {
currentAgentId =
agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId);
}
const nextSessionKey = resolveSessionKey(initialSessionInput);
if (nextSessionKey !== currentSessionKey) {
currentSessionKey = nextSessionKey;
}
initialSessionApplied = true;
} else if (!agents.some((agent) => agent.id === currentAgentId)) {
currentAgentId =
agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId);
}
updateHeader();
updateFooter();
};
const refreshAgents = async () => {
try {
const result = await client.listAgents();
applyAgentsResult(result);
} catch (err) {
chatLog.addSystem(`agents list failed: ${String(err)}`);
}
};
const updateAgentFromSessionKey = (key: string) => {
const parsed = parseAgentSessionKey(key);
if (!parsed) return;
const next = normalizeAgentId(parsed.agentId);
if (next !== currentAgentId) {
currentAgentId = next;
}
};
const refreshSessionInfo = async () => {
try {
const listAgentId =
currentSessionKey === "global" || currentSessionKey === "unknown"
? undefined
: currentAgentId;
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: listAgentId,
});
const entry = result.sessions.find((row) => {
// Exact match
if (row.key === currentSessionKey) return true;
// Also match canonical keys like "agent:default:main" against "main"
const parsed = parseAgentSessionKey(row.key);
return parsed?.rest === currentSessionKey;
});
const entry = result.sessions.find(
(row) => row.key === currentSessionKey,
);
sessionInfo = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
@@ -272,12 +391,15 @@ export async function runTui(opts: TuiOptions) {
tui.requestRender();
};
const setSession = async (key: string) => {
currentSessionKey = key;
const setSession = async (rawKey: string) => {
const nextKey = resolveSessionKey(rawKey);
updateAgentFromSessionKey(nextKey);
currentSessionKey = nextKey;
activeChatRunId = null;
currentSessionId = null;
historyLoaded = false;
updateHeader();
updateFooter();
await loadHistory();
};
@@ -429,15 +551,51 @@ export async function runTui(opts: TuiOptions) {
}
};
const setAgent = async (id: string) => {
currentAgentId = normalizeAgentId(id);
await setSession("");
};
const openAgentSelector = async () => {
await refreshAgents();
if (agents.length === 0) {
chatLog.addSystem("no agents found");
tui.requestRender();
return;
}
const items = agents.map((agent) => ({
value: agent.id,
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
description: agent.id === agentDefaultId ? "default" : "",
}));
const selector = createSelectList(items, 9);
selector.onSelect = (item) => {
void (async () => {
closeOverlay();
await setAgent(item.value);
tui.requestRender();
})();
};
selector.onCancel = () => {
closeOverlay();
tui.requestRender();
};
openOverlay(selector);
tui.requestRender();
};
const openSessionSelector = async () => {
try {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
agentId: currentAgentId,
});
const items = result.sessions.map((session) => ({
value: session.key,
label: session.displayName ?? session.key,
label: session.displayName
? `${session.displayName} (${formatSessionKey(session.key)})`
: formatSessionKey(session.key),
description: session.updatedAt
? new Date(session.updatedAt).toLocaleString()
: "",
@@ -528,6 +686,16 @@ export async function runTui(opts: TuiOptions) {
chatLog.addSystem(`status failed: ${String(err)}`);
}
break;
case "agent":
if (!args) {
await openAgentSelector();
} else {
await setAgent(args);
}
break;
case "agents":
await openAgentSelector();
break;
case "session":
if (!args) {
await openSessionSelector();
@@ -744,6 +912,9 @@ export async function runTui(opts: TuiOptions) {
editor.onCtrlL = () => {
void openModelSelector();
};
editor.onCtrlG = () => {
void openAgentSelector();
};
editor.onCtrlP = () => {
void openSessionSelector();
};
@@ -760,17 +931,19 @@ export async function runTui(opts: TuiOptions) {
client.onConnected = () => {
isConnected = true;
setStatus("connected");
updateHeader();
if (!historyLoaded) {
void loadHistory().then(() => {
void (async () => {
await refreshAgents();
updateHeader();
if (!historyLoaded) {
await loadHistory();
chatLog.addSystem("gateway connected");
tui.requestRender();
});
} else {
chatLog.addSystem("gateway reconnected");
}
updateFooter();
tui.requestRender();
} else {
chatLog.addSystem("gateway reconnected");
}
updateFooter();
tui.requestRender();
})();
};
client.onDisconnected = (reason) => {
+78
View File
@@ -1132,6 +1132,84 @@ describe("web auto-reply", () => {
expect(payload.Body).toContain("[from: Bob (+222)]");
});
it("uses per-agent mention patterns for group gating", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
setLoadConfigMock(() => ({
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
routing: {
groupChat: { mentionPatterns: ["@global"] },
agents: {
work: { mentionPatterns: ["@workbot"] },
},
bindings: [
{
agentId: "work",
match: {
provider: "whatsapp",
peer: { kind: "group", id: "123@g.us" },
},
},
],
},
}));
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "@global ping",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g1",
senderE164: "+111",
senderName: "Alice",
selfE164: "+999",
sendComposing,
reply,
sendMedia,
});
expect(resolver).not.toHaveBeenCalled();
await capturedOnMessage?.({
body: "@workbot ping",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g2",
senderE164: "+222",
senderName: "Bob",
selfE164: "+999",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(1);
});
it("allows group messages when whatsapp groups default disables mention gating", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
+21 -7
View File
@@ -162,8 +162,11 @@ type MentionConfig = {
allowFrom?: Array<string | number>;
};
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
const mentionRegexes = buildMentionRegexes(cfg);
function buildMentionConfig(
cfg: ReturnType<typeof loadConfig>,
agentId?: string,
): MentionConfig {
const mentionRegexes = buildMentionRegexes(cfg, agentId);
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
}
@@ -793,7 +796,9 @@ export async function monitorWebProvider(
tuning.heartbeatSeconds,
);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const mentionConfig = buildMentionConfig(cfg);
const resolveMentionConfig = (agentId?: string) =>
buildMentionConfig(cfg, agentId);
const baseMentionConfig = resolveMentionConfig();
const groupHistoryLimit =
cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
const groupHistories = new Map<
@@ -913,7 +918,7 @@ export async function monitorWebProvider(
};
const resolveOwnerList = (selfE164?: string | null) => {
const allowFrom = mentionConfig.allowFrom;
const allowFrom = baseMentionConfig.allowFrom;
const raw =
Array.isArray(allowFrom) && allowFrom.length > 0
? allowFrom
@@ -943,9 +948,13 @@ export async function monitorWebProvider(
);
};
const stripMentionsForCommand = (text: string, selfE164?: string | null) => {
const stripMentionsForCommand = (
text: string,
mentionRegexes: RegExp[],
selfE164?: string | null,
) => {
let result = text;
for (const re of mentionConfig.mentionRegexes) {
for (const re of mentionRegexes) {
result = result.replace(re, " ");
}
if (selfE164) {
@@ -1362,7 +1371,12 @@ export async function monitorWebProvider(
});
}
noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName);
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
const mentionConfig = resolveMentionConfig(route.agentId);
const commandBody = stripMentionsForCommand(
msg.body,
mentionConfig.mentionRegexes,
msg.selfE164,
);
const activationCommand = parseActivationCommand(commandBody);
const isOwner = isOwnerSender(msg);
const statusCommand = isStatusCommand(commandBody);
+24 -10
View File
@@ -14,8 +14,10 @@ import {
import { loadConfig } from "../config/config.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -171,6 +173,11 @@ export async function monitorWebInbox(options: {
}) => {
if (upsert.type !== "notify" && upsert.type !== "append") return;
for (const msg of upsert.messages ?? []) {
recordProviderActivity({
provider: "whatsapp",
accountId: options.accountId,
direction: "inbound",
});
const id = msg.key?.id ?? undefined;
// De-dupe on message id; Baileys can emit retries.
if (id && seen.has(id)) continue;
@@ -304,14 +311,11 @@ export async function monitorWebInbox(options: {
);
try {
await sock.sendMessage(remoteJid, {
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider whatsapp <code>",
].join("\n"),
text: buildPairingReply({
provider: "whatsapp",
idLine: `Your WhatsApp sender id: ${candidate}`,
code,
}),
});
} catch (err) {
logVerbose(
@@ -536,7 +540,7 @@ export async function monitorWebInbox(options: {
text: string,
mediaBuffer?: Buffer,
mediaType?: string,
options?: ActiveWebSendOptions,
sendOptions?: ActiveWebSendOptions,
): Promise<{ messageId: string }> => {
const jid = toWhatsappJid(to);
let payload: AnyMessageContent;
@@ -554,7 +558,7 @@ export async function monitorWebInbox(options: {
mimetype: mediaType,
};
} else if (mediaType.startsWith("video/")) {
const gifPlayback = options?.gifPlayback;
const gifPlayback = sendOptions?.gifPlayback;
payload = {
video: mediaBuffer,
caption: text || undefined,
@@ -573,6 +577,11 @@ export async function monitorWebInbox(options: {
payload = { text };
}
const result = await sock.sendMessage(jid, payload);
recordProviderActivity({
provider: "whatsapp",
accountId: options.accountId,
direction: "outbound",
});
return { messageId: result?.key?.id ?? "unknown" };
},
/**
@@ -591,6 +600,11 @@ export async function monitorWebInbox(options: {
selectableCount: poll.maxSelections ?? 1,
},
});
recordProviderActivity({
provider: "whatsapp",
accountId: options.accountId,
direction: "outbound",
});
return { messageId: result?.key?.id ?? "unknown" };
},
/**
+9
View File
@@ -670,6 +670,9 @@ describe("web monitor inbox", () => {
// Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn).
expect(sock.readMessages).not.toHaveBeenCalled();
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Your WhatsApp sender id: +999"),
});
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Pairing code: PAIRCODE"),
});
@@ -1121,6 +1124,9 @@ describe("web monitor inbox", () => {
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Your WhatsApp sender id: +999"),
});
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Pairing code: PAIRCODE"),
});
@@ -1274,6 +1280,9 @@ describe("web monitor inbox", () => {
expect(onMessage).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Your WhatsApp sender id: +999"),
});
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Pairing code: PAIRCODE"),
});
+21 -7
View File
@@ -46,7 +46,9 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -221,10 +223,16 @@ export async function runOnboardingWizard(
},
};
const authStore = ensureAuthProfileStore();
const authStore = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({ store: authStore, includeSkip: true }),
options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
})) as AuthChoice;
const authResult = await applyAuthChoice({
@@ -480,20 +488,26 @@ export async function runOnboardingWizard(
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,