Merge branch 'main' into together-ai
This commit is contained in:
68 files changed
+2863
-709
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
|
||||
const approvals: ExecApprovalsResolved = {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
socketPath: "/tmp/exec-approvals.sock",
|
||||
token: "token",
|
||||
defaults: {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agent: {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
allowlist: [],
|
||||
file: {
|
||||
version: 1,
|
||||
socket: { path: "/tmp/exec-approvals.sock", token: "token" },
|
||||
defaults: {
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agents: {},
|
||||
},
|
||||
};
|
||||
return { ...mod, resolveExecApprovals: () => approvals };
|
||||
});
|
||||
|
||||
describe("createClawdbotCodingTools safeBins", () => {
|
||||
it("threads tools.exec.safeBins into exec allowlist checks", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-"));
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "off",
|
||||
safeBins: ["echo"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: tmpDir,
|
||||
agentDir: path.join(tmpDir, "agent"),
|
||||
});
|
||||
const execTool = tools.find((tool) => tool.name === "exec");
|
||||
expect(execTool).toBeDefined();
|
||||
|
||||
const marker = `safe-bins-${Date.now()}`;
|
||||
const result = await execTool!.execute("call1", {
|
||||
command: `echo ${marker}`,
|
||||
workdir: tmpDir,
|
||||
});
|
||||
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
||||
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(text).toContain(marker);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
||||
ask: globalExec?.ask,
|
||||
node: globalExec?.node,
|
||||
pathPrepend: globalExec?.pathPrepend,
|
||||
safeBins: globalExec?.safeBins,
|
||||
backgroundMs: globalExec?.backgroundMs,
|
||||
timeoutSec: globalExec?.timeoutSec,
|
||||
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
|
||||
@@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
ask: options?.exec?.ask ?? execConfig.ask,
|
||||
node: options?.exec?.node ?? execConfig.node,
|
||||
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
|
||||
agentId,
|
||||
cwd: options?.workspaceDir,
|
||||
allowBackground,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getPresence } from "../../discord/monitor/presence-cache.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
@@ -54,7 +55,10 @@ export async function handleDiscordGuildAction(
|
||||
const member = accountId
|
||||
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||
: await fetchMemberInfoDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, member });
|
||||
const presence = getPresence(accountId, userId);
|
||||
const activities = presence?.activities ?? undefined;
|
||||
const status = presence?.status ?? undefined;
|
||||
return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });
|
||||
}
|
||||
case "roleInfo": {
|
||||
if (!isActionEnabled("roleInfo")) {
|
||||
|
||||
+137
-114
@@ -1,7 +1,13 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
|
||||
import {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolvePinnedHostname,
|
||||
SsrFBlockedError,
|
||||
} from "../../infra/net/ssrf.js";
|
||||
import type { Dispatcher } from "undici";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
@@ -167,7 +173,7 @@ async function fetchWithRedirects(params: {
|
||||
maxRedirects: number;
|
||||
timeoutSeconds: number;
|
||||
userAgent: string;
|
||||
}): Promise<{ response: Response; finalUrl: string }> {
|
||||
}): Promise<{ response: Response; finalUrl: string; dispatcher: Dispatcher }> {
|
||||
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
|
||||
const visited = new Set<string>();
|
||||
let currentUrl = params.url;
|
||||
@@ -184,39 +190,50 @@ async function fetchWithRedirects(params: {
|
||||
throw new Error("Invalid URL: must be http or https");
|
||||
}
|
||||
|
||||
await assertPublicHostname(parsedUrl.hostname);
|
||||
|
||||
const res = await fetch(parsedUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
const pinned = await resolvePinnedHostname(parsedUrl.hostname);
|
||||
const dispatcher = createPinnedDispatcher(pinned);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(parsedUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"User-Agent": params.userAgent,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
signal,
|
||||
redirect: "manual",
|
||||
dispatcher,
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
await closeDispatcher(dispatcher);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (isRedirectStatus(res.status)) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
await closeDispatcher(dispatcher);
|
||||
throw new Error(`Redirect missing location header (${res.status})`);
|
||||
}
|
||||
redirectCount += 1;
|
||||
if (redirectCount > params.maxRedirects) {
|
||||
await closeDispatcher(dispatcher);
|
||||
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
||||
}
|
||||
const nextUrl = new URL(location, parsedUrl).toString();
|
||||
if (visited.has(nextUrl)) {
|
||||
await closeDispatcher(dispatcher);
|
||||
throw new Error("Redirect loop detected");
|
||||
}
|
||||
visited.add(nextUrl);
|
||||
void res.body?.cancel();
|
||||
await closeDispatcher(dispatcher);
|
||||
currentUrl = nextUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
return { response: res, finalUrl: currentUrl };
|
||||
return { response: res, finalUrl: currentUrl, dispatcher };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +365,7 @@ async function runWebFetch(params: {
|
||||
|
||||
const start = Date.now();
|
||||
let res: Response;
|
||||
let dispatcher: Dispatcher | null = null;
|
||||
let finalUrl = params.url;
|
||||
try {
|
||||
const result = await fetchWithRedirects({
|
||||
@@ -358,6 +376,7 @@ async function runWebFetch(params: {
|
||||
});
|
||||
res = result.response;
|
||||
finalUrl = result.finalUrl;
|
||||
dispatcher = result.dispatcher;
|
||||
} catch (error) {
|
||||
if (error instanceof SsrFBlockedError) {
|
||||
throw error;
|
||||
@@ -396,108 +415,112 @@ async function runWebFetch(params: {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||
status: firecrawl.status ?? res.status,
|
||||
contentType: "text/markdown",
|
||||
title: firecrawl.title,
|
||||
extractMode: params.extractMode,
|
||||
extractor: "firecrawl",
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: truncated.text,
|
||||
warning: firecrawl.warning,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
const rawDetail = await readResponseText(res);
|
||||
const detail = formatWebFetchErrorDetail({
|
||||
detail: rawDetail,
|
||||
contentType: res.headers.get("content-type"),
|
||||
maxChars: DEFAULT_ERROR_MAX_CHARS,
|
||||
});
|
||||
throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
const body = await readResponseText(res);
|
||||
|
||||
let title: string | undefined;
|
||||
let extractor = "raw";
|
||||
let text = body;
|
||||
if (contentType.includes("text/html")) {
|
||||
if (params.readabilityEnabled) {
|
||||
const readable = await extractReadableContent({
|
||||
html: body,
|
||||
url: finalUrl,
|
||||
extractMode: params.extractMode,
|
||||
});
|
||||
if (readable?.text) {
|
||||
text = readable.text;
|
||||
title = readable.title;
|
||||
extractor = "readability";
|
||||
} else {
|
||||
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
|
||||
if (firecrawl) {
|
||||
text = firecrawl.text;
|
||||
title = firecrawl.title;
|
||||
extractor = "firecrawl";
|
||||
} else {
|
||||
throw new Error(
|
||||
"Web fetch extraction failed: Readability and Firecrawl returned no content.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (!res.ok) {
|
||||
if (params.firecrawlEnabled && params.firecrawlApiKey) {
|
||||
const firecrawl = await fetchFirecrawlContent({
|
||||
url: params.url,
|
||||
extractMode: params.extractMode,
|
||||
apiKey: params.firecrawlApiKey,
|
||||
baseUrl: params.firecrawlBaseUrl,
|
||||
onlyMainContent: params.firecrawlOnlyMainContent,
|
||||
maxAgeMs: params.firecrawlMaxAgeMs,
|
||||
proxy: params.firecrawlProxy,
|
||||
storeInCache: params.firecrawlStoreInCache,
|
||||
timeoutSeconds: params.firecrawlTimeoutSeconds,
|
||||
});
|
||||
const truncated = truncateText(firecrawl.text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl: firecrawl.finalUrl || finalUrl,
|
||||
status: firecrawl.status ?? res.status,
|
||||
contentType: "text/markdown",
|
||||
title: firecrawl.title,
|
||||
extractMode: params.extractMode,
|
||||
extractor: "firecrawl",
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: truncated.text,
|
||||
warning: firecrawl.warning,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
|
||||
);
|
||||
const rawDetail = await readResponseText(res);
|
||||
const detail = formatWebFetchErrorDetail({
|
||||
detail: rawDetail,
|
||||
contentType: res.headers.get("content-type"),
|
||||
maxChars: DEFAULT_ERROR_MAX_CHARS,
|
||||
});
|
||||
throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
} else if (contentType.includes("application/json")) {
|
||||
try {
|
||||
text = JSON.stringify(JSON.parse(body), null, 2);
|
||||
extractor = "json";
|
||||
} catch {
|
||||
text = body;
|
||||
extractor = "raw";
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = truncateText(text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl,
|
||||
status: res.status,
|
||||
contentType,
|
||||
title,
|
||||
extractMode: params.extractMode,
|
||||
extractor,
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: truncated.text,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
const body = await readResponseText(res);
|
||||
|
||||
let title: string | undefined;
|
||||
let extractor = "raw";
|
||||
let text = body;
|
||||
if (contentType.includes("text/html")) {
|
||||
if (params.readabilityEnabled) {
|
||||
const readable = await extractReadableContent({
|
||||
html: body,
|
||||
url: finalUrl,
|
||||
extractMode: params.extractMode,
|
||||
});
|
||||
if (readable?.text) {
|
||||
text = readable.text;
|
||||
title = readable.title;
|
||||
extractor = "readability";
|
||||
} else {
|
||||
const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
|
||||
if (firecrawl) {
|
||||
text = firecrawl.text;
|
||||
title = firecrawl.title;
|
||||
extractor = "firecrawl";
|
||||
} else {
|
||||
throw new Error(
|
||||
"Web fetch extraction failed: Readability and Firecrawl returned no content.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
|
||||
);
|
||||
}
|
||||
} else if (contentType.includes("application/json")) {
|
||||
try {
|
||||
text = JSON.stringify(JSON.parse(body), null, 2);
|
||||
extractor = "json";
|
||||
} catch {
|
||||
text = body;
|
||||
extractor = "raw";
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = truncateText(text, params.maxChars);
|
||||
const payload = {
|
||||
url: params.url,
|
||||
finalUrl,
|
||||
status: res.status,
|
||||
contentType,
|
||||
title,
|
||||
extractMode: params.extractMode,
|
||||
extractor,
|
||||
truncated: truncated.truncated,
|
||||
length: truncated.text.length,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
tookMs: Date.now() - start,
|
||||
text: truncated.text,
|
||||
};
|
||||
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
} finally {
|
||||
await closeDispatcher(dispatcher);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryFirecrawlFallback(params: {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { telegramOutbound } from "./telegram.js";
|
||||
|
||||
describe("telegramOutbound.sendPayload", () => {
|
||||
it("sends text payload with buttons", async () => {
|
||||
const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" }));
|
||||
|
||||
const result = await telegramOutbound.sendPayload?.({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
to: "telegram:123",
|
||||
text: "ignored",
|
||||
payload: {
|
||||
text: "Hello",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Option", callback_data: "/option" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
deps: { sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"Hello",
|
||||
expect.objectContaining({
|
||||
buttons: [[{ text: "Option", callback_data: "/option" }]],
|
||||
textMode: "html",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" });
|
||||
});
|
||||
|
||||
it("sends media payloads and attaches buttons only to first", async () => {
|
||||
const sendTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m1", chatId: "c1" })
|
||||
.mockResolvedValueOnce({ messageId: "m2", chatId: "c1" });
|
||||
|
||||
const result = await telegramOutbound.sendPayload?.({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
to: "telegram:123",
|
||||
text: "ignored",
|
||||
payload: {
|
||||
text: "Caption",
|
||||
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Go", callback_data: "/go" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
deps: { sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||
expect(sendTelegram).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"telegram:123",
|
||||
"Caption",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
buttons: [[{ text: "Go", callback_data: "/go" }]],
|
||||
}),
|
||||
);
|
||||
const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined;
|
||||
expect(sendTelegram).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"telegram:123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/b.png",
|
||||
}),
|
||||
);
|
||||
expect(secondOpts?.buttons).toBeUndefined();
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" });
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) {
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: markdownToTelegramHtmlChunks,
|
||||
@@ -50,4 +51,46 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => {
|
||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseThreadId(threadId);
|
||||
const telegramData = payload.channelData?.telegram as
|
||||
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
|
||||
| undefined;
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
const baseOpts = {
|
||||
verbose: false,
|
||||
textMode: "html" as const,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
}
|
||||
|
||||
// Telegram allows reply_markup on media; attach buttons only to first send.
|
||||
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
|
||||
for (let i = 0; i < mediaUrls.length; i += 1) {
|
||||
const mediaUrl = mediaUrls[i];
|
||||
const isFirst = i === 0;
|
||||
finalResult = await send(to, isFirst ? text : "", {
|
||||
...baseOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
});
|
||||
}
|
||||
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
|
||||
},
|
||||
};
|
||||
@@ -79,7 +79,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: token|password")
|
||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||
|
||||
+17
-10
@@ -87,16 +87,23 @@ export function registerSecurityCli(program: Command) {
|
||||
lines.push(muted(` ${shortenHomeInString(change)}`));
|
||||
}
|
||||
for (const action of fixResult.actions) {
|
||||
const mode = action.mode.toString(8).padStart(3, "0");
|
||||
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
|
||||
else if (action.skipped)
|
||||
lines.push(
|
||||
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
|
||||
);
|
||||
else if (action.error)
|
||||
lines.push(
|
||||
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
|
||||
);
|
||||
if (action.kind === "chmod") {
|
||||
const mode = action.mode.toString(8).padStart(3, "0");
|
||||
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
|
||||
else if (action.skipped)
|
||||
lines.push(
|
||||
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
|
||||
);
|
||||
else if (action.error)
|
||||
lines.push(
|
||||
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const command = shortenHomeInString(action.command);
|
||||
if (action.ok) lines.push(muted(` ${command}`));
|
||||
else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
|
||||
else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
|
||||
}
|
||||
if (fixResult.errors.length > 0) {
|
||||
for (const err of fixResult.errors) {
|
||||
|
||||
@@ -3,26 +3,18 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildGatewayAuthConfig } from "./configure.js";
|
||||
|
||||
describe("buildGatewayAuthConfig", () => {
|
||||
it("clears token/password when auth is off", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
existing: { mode: "token", token: "abc", password: "secret" },
|
||||
mode: "off",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves allowTailscale when auth is off", () => {
|
||||
it("preserves allowTailscale when switching to token", () => {
|
||||
const result = buildGatewayAuthConfig({
|
||||
existing: {
|
||||
mode: "token",
|
||||
token: "abc",
|
||||
mode: "password",
|
||||
password: "secret",
|
||||
allowTailscale: true,
|
||||
},
|
||||
mode: "off",
|
||||
mode: "token",
|
||||
token: "abc",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allowTailscale: true });
|
||||
expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true });
|
||||
});
|
||||
|
||||
it("drops password when switching to token", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
promptModelAllowlist,
|
||||
} from "./model-picker.js";
|
||||
|
||||
type GatewayAuthChoice = "off" | "token" | "password";
|
||||
type GatewayAuthChoice = "token" | "password";
|
||||
|
||||
const ANTHROPIC_OAUTH_MODEL_KEYS = [
|
||||
"anthropic/claude-opus-4-5",
|
||||
@@ -30,9 +30,6 @@ export function buildGatewayAuthConfig(params: {
|
||||
const base: GatewayAuthConfig = {};
|
||||
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
|
||||
|
||||
if (params.mode === "off") {
|
||||
return Object.keys(base).length > 0 ? base : undefined;
|
||||
}
|
||||
if (params.mode === "token") {
|
||||
return { ...base, mode: "token", token: params.token };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
|
||||
import { confirm, select, text } from "./configure.shared.js";
|
||||
import { guardCancel, randomToken } from "./onboard-helpers.js";
|
||||
|
||||
type GatewayAuthChoice = "off" | "token" | "password";
|
||||
type GatewayAuthChoice = "token" | "password";
|
||||
|
||||
export async function promptGatewayConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
@@ -91,11 +91,6 @@ export async function promptGatewayConfig(
|
||||
await select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Not recommended unless you fully trust local processes",
|
||||
},
|
||||
{ value: "token", label: "Token", hint: "Recommended default" },
|
||||
{ value: "password", label: "Password" },
|
||||
],
|
||||
@@ -165,11 +160,6 @@ export async function promptGatewayConfig(
|
||||
bind = "loopback";
|
||||
}
|
||||
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
||||
authMode = "token";
|
||||
}
|
||||
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
note("Tailscale funnel requires password auth.", "Note");
|
||||
authMode = "password";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
const note = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../terminal/note.js", () => ({
|
||||
note,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: () => [],
|
||||
}));
|
||||
|
||||
import { noteSecurityWarnings } from "./doctor-security.js";
|
||||
|
||||
describe("noteSecurityWarnings gateway exposure", () => {
|
||||
let prevToken: string | undefined;
|
||||
let prevPassword: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
note.mockClear();
|
||||
prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
prevPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
if (prevPassword === undefined) delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
else process.env.CLAWDBOT_GATEWAY_PASSWORD = prevPassword;
|
||||
});
|
||||
|
||||
const lastMessage = () => String(note.mock.calls.at(-1)?.[0] ?? "");
|
||||
|
||||
it("warns when exposed without auth", async () => {
|
||||
const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig;
|
||||
await noteSecurityWarnings(cfg);
|
||||
const message = lastMessage();
|
||||
expect(message).toContain("CRITICAL");
|
||||
expect(message).toContain("without authentication");
|
||||
});
|
||||
|
||||
it("uses env token to avoid critical warning", async () => {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "token-123";
|
||||
const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig;
|
||||
await noteSecurityWarnings(cfg);
|
||||
const message = lastMessage();
|
||||
expect(message).toContain("WARNING");
|
||||
expect(message).not.toContain("CRITICAL");
|
||||
});
|
||||
|
||||
it("treats whitespace token as missing", async () => {
|
||||
const cfg = {
|
||||
gateway: { bind: "lan", auth: { mode: "token", token: " " } },
|
||||
} as ClawdbotConfig;
|
||||
await noteSecurityWarnings(cfg);
|
||||
const message = lastMessage();
|
||||
expect(message).toContain("CRITICAL");
|
||||
});
|
||||
|
||||
it("skips warning for loopback bind", async () => {
|
||||
const cfg = { gateway: { bind: "loopback" } } as ClawdbotConfig;
|
||||
await noteSecurityWarnings(cfg);
|
||||
const message = lastMessage();
|
||||
expect(message).toContain("No channel security warnings detected");
|
||||
expect(message).not.toContain("Gateway bound");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ClawdbotConfig, GatewayBindMode } from "../config/config.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
|
||||
|
||||
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
||||
const warnings: string[] = [];
|
||||
@@ -16,50 +18,55 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
||||
// Check for dangerous gateway binding configurations
|
||||
// that expose the gateway to network without proper auth
|
||||
|
||||
const gatewayBind = cfg.gateway?.bind ?? "loopback";
|
||||
const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string;
|
||||
const customBindHost = cfg.gateway?.customBindHost?.trim();
|
||||
const authMode = cfg.gateway?.auth?.mode ?? "off";
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"];
|
||||
const bindMode = bindModes.includes(gatewayBind as GatewayBindMode)
|
||||
? (gatewayBind as GatewayBindMode)
|
||||
: undefined;
|
||||
const resolvedBindHost = bindMode
|
||||
? await resolveGatewayBindHost(bindMode, customBindHost)
|
||||
: "0.0.0.0";
|
||||
const isExposed = !isLoopbackHost(resolvedBindHost);
|
||||
|
||||
const isLoopbackBindHost = (host: string) => {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "[::1]" ||
|
||||
normalized.startsWith("127.")
|
||||
);
|
||||
};
|
||||
|
||||
// Bindings that expose gateway beyond localhost
|
||||
const exposedBindings = ["all", "lan", "0.0.0.0"];
|
||||
const isExposed =
|
||||
exposedBindings.includes(gatewayBind) ||
|
||||
(gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost)));
|
||||
const resolvedAuth = resolveGatewayAuth({
|
||||
authConfig: cfg.gateway?.auth,
|
||||
env: process.env,
|
||||
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
||||
});
|
||||
const authToken = resolvedAuth.token?.trim() ?? "";
|
||||
const authPassword = resolvedAuth.password?.trim() ?? "";
|
||||
const hasToken = authToken.length > 0;
|
||||
const hasPassword = authPassword.length > 0;
|
||||
const hasSharedSecret =
|
||||
(resolvedAuth.mode === "token" && hasToken) ||
|
||||
(resolvedAuth.mode === "password" && hasPassword);
|
||||
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
|
||||
|
||||
if (isExposed) {
|
||||
if (authMode === "off") {
|
||||
if (!hasSharedSecret) {
|
||||
const authFixLines =
|
||||
resolvedAuth.mode === "password"
|
||||
? [
|
||||
` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
|
||||
` Or switch to token: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
|
||||
]
|
||||
: [
|
||||
` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
|
||||
` Or set token directly: ${formatCliCommand(
|
||||
"clawdbot config set gateway.auth.mode token",
|
||||
)}`,
|
||||
];
|
||||
warnings.push(
|
||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`,
|
||||
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
|
||||
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
||||
` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
|
||||
` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
|
||||
);
|
||||
} else if (authMode === "token" && !authToken) {
|
||||
warnings.push(
|
||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`,
|
||||
` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
|
||||
);
|
||||
} else if (authMode === "password" && !authPassword) {
|
||||
warnings.push(
|
||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`,
|
||||
` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
|
||||
...authFixLines,
|
||||
);
|
||||
} else {
|
||||
// Auth is configured, but still warn about network exposure
|
||||
warnings.push(
|
||||
`- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`,
|
||||
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
|
||||
` Ensure your auth credentials are strong and not exposed.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
||||
it("auto-generates token auth when binding LAN and persists the token", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
||||
return;
|
||||
@@ -242,7 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
installDaemon: false,
|
||||
gatewayPort: port,
|
||||
gatewayBind: "lan",
|
||||
gatewayAuth: "off",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
@@ -28,16 +28,20 @@ export function applyNonInteractiveGatewayConfig(params: {
|
||||
|
||||
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
|
||||
let bind = opts.gatewayBind ?? "loopback";
|
||||
let authMode = opts.gatewayAuth ?? "token";
|
||||
const authModeRaw = opts.gatewayAuth ?? "token";
|
||||
if (authModeRaw !== "token" && authModeRaw !== "password") {
|
||||
runtime.error("Invalid --gateway-auth (use token|password).");
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
let authMode = authModeRaw;
|
||||
const tailscaleMode = opts.tailscale ?? "off";
|
||||
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
|
||||
|
||||
// Tighten config to safe combos:
|
||||
// - If Tailscale is on, force loopback bind (the tunnel handles external access).
|
||||
// - If binding beyond loopback, disallow auth=off.
|
||||
// - If using Tailscale Funnel, require password auth.
|
||||
if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback";
|
||||
if (authMode === "off" && bind !== "loopback") authMode = "token";
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password";
|
||||
|
||||
let nextConfig = params.nextConfig;
|
||||
|
||||
@@ -33,7 +33,7 @@ export type AuthChoice =
|
||||
| "copilot-proxy"
|
||||
| "qwen-portal"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type GatewayAuthChoice = "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
|
||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||
|
||||
@@ -199,6 +199,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
@@ -321,6 +322,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
||||
"channels.discord.retry.jitter": "Discord Retry Jitter",
|
||||
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
|
||||
"channels.discord.intents.presence": "Discord Presence Intent",
|
||||
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
|
||||
"channels.slack.dm.policy": "Slack DM Policy",
|
||||
"channels.slack.allowBots": "Slack Allow Bot Messages",
|
||||
"channels.discord.token": "Discord Bot Token",
|
||||
@@ -379,6 +382,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
||||
"gateway.controlUi.allowInsecureAuth":
|
||||
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth":
|
||||
"DANGEROUS. Disable Control UI device identity checks (token/password only).",
|
||||
"gateway.http.endpoints.chatCompletions.enabled":
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
@@ -657,6 +662,10 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.",
|
||||
"channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.",
|
||||
"channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).",
|
||||
"channels.discord.intents.presence":
|
||||
"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
|
||||
"channels.discord.intents.guildMembers":
|
||||
"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
|
||||
"channels.slack.dm.policy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
||||
};
|
||||
|
||||
@@ -72,6 +72,13 @@ export type DiscordActionConfig = {
|
||||
channels?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordIntentsConfig = {
|
||||
/** Enable Guild Presences privileged intent (requires Portal opt-in). Default: false. */
|
||||
presence?: boolean;
|
||||
/** Enable Guild Members privileged intent (requires Portal opt-in). Default: false. */
|
||||
guildMembers?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordExecApprovalConfig = {
|
||||
/** Enable exec approval forwarding to Discord DMs. Default: false. */
|
||||
enabled?: boolean;
|
||||
@@ -139,6 +146,8 @@ export type DiscordAccountConfig = {
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Exec approval forwarding configuration. */
|
||||
execApprovals?: DiscordExecApprovalConfig;
|
||||
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
|
||||
intents?: DiscordIntentsConfig;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
|
||||
@@ -66,6 +66,8 @@ export type GatewayControlUiConfig = {
|
||||
basePath?: string;
|
||||
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||
allowInsecureAuth?: boolean;
|
||||
/** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
|
||||
dangerouslyDisableDeviceAuth?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
|
||||
@@ -256,6 +256,13 @@ export const DiscordAccountSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
intents: z
|
||||
.object({
|
||||
presence: z.boolean().optional(),
|
||||
guildMembers: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@ export const ClawdbotSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
basePath: z.string().optional(),
|
||||
allowInsecureAuth: z.boolean().optional(),
|
||||
dangerouslyDisableDeviceAuth: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("@buape/carbon", () => ({
|
||||
MessageCreateListener: class {},
|
||||
MessageReactionAddListener: class {},
|
||||
MessageReactionRemoveListener: class {},
|
||||
PresenceUpdateListener: class {},
|
||||
Row: class {
|
||||
constructor(_components: unknown[]) {}
|
||||
},
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
MessageCreateListener,
|
||||
MessageReactionAddListener,
|
||||
MessageReactionRemoveListener,
|
||||
PresenceUpdateListener,
|
||||
} from "@buape/carbon";
|
||||
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-duration.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { setPresence } from "./presence-cache.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
@@ -269,3 +271,34 @@ async function handleDiscordReactionEvent(params: {
|
||||
params.logger.error(danger(`discord reaction handler failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
type PresenceUpdateEvent = Parameters<PresenceUpdateListener["handle"]>[0];
|
||||
|
||||
export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
private logger?: Logger;
|
||||
private accountId?: string;
|
||||
|
||||
constructor(params: { logger?: Logger; accountId?: string }) {
|
||||
super();
|
||||
this.logger = params.logger;
|
||||
this.accountId = params.accountId;
|
||||
}
|
||||
|
||||
async handle(data: PresenceUpdateEvent) {
|
||||
try {
|
||||
const userId =
|
||||
"user" in data && data.user && typeof data.user === "object" && "id" in data.user
|
||||
? String(data.user.id)
|
||||
: undefined;
|
||||
if (!userId) return;
|
||||
setPresence(
|
||||
this.accountId,
|
||||
userId,
|
||||
data as import("discord-api-types/v10").GatewayPresenceUpdate,
|
||||
);
|
||||
} catch (err) {
|
||||
const logger = this.logger ?? discordEventQueueLog;
|
||||
logger.error(danger(`discord presence handler failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
import {
|
||||
clearPresences,
|
||||
getPresence,
|
||||
presenceCacheSize,
|
||||
setPresence,
|
||||
} from "./presence-cache.js";
|
||||
|
||||
describe("presence-cache", () => {
|
||||
beforeEach(() => {
|
||||
clearPresences();
|
||||
});
|
||||
|
||||
it("scopes presence entries by account", () => {
|
||||
const presenceA = { status: "online" } as GatewayPresenceUpdate;
|
||||
const presenceB = { status: "idle" } as GatewayPresenceUpdate;
|
||||
|
||||
setPresence("account-a", "user-1", presenceA);
|
||||
setPresence("account-b", "user-1", presenceB);
|
||||
|
||||
expect(getPresence("account-a", "user-1")).toBe(presenceA);
|
||||
expect(getPresence("account-b", "user-1")).toBe(presenceB);
|
||||
expect(getPresence("account-a", "user-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears presence per account", () => {
|
||||
const presence = { status: "dnd" } as GatewayPresenceUpdate;
|
||||
|
||||
setPresence("account-a", "user-1", presence);
|
||||
setPresence("account-b", "user-2", presence);
|
||||
|
||||
clearPresences("account-a");
|
||||
|
||||
expect(getPresence("account-a", "user-1")).toBeUndefined();
|
||||
expect(getPresence("account-b", "user-2")).toBe(presence);
|
||||
expect(presenceCacheSize()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
|
||||
/**
|
||||
* In-memory cache of Discord user presence data.
|
||||
* Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled.
|
||||
*/
|
||||
const presenceCache = new Map<string, Map<string, GatewayPresenceUpdate>>();
|
||||
|
||||
function resolveAccountKey(accountId?: string): string {
|
||||
return accountId ?? "default";
|
||||
}
|
||||
|
||||
/** Update cached presence for a user. */
|
||||
export function setPresence(
|
||||
accountId: string | undefined,
|
||||
userId: string,
|
||||
data: GatewayPresenceUpdate,
|
||||
): void {
|
||||
const accountKey = resolveAccountKey(accountId);
|
||||
let accountCache = presenceCache.get(accountKey);
|
||||
if (!accountCache) {
|
||||
accountCache = new Map();
|
||||
presenceCache.set(accountKey, accountCache);
|
||||
}
|
||||
accountCache.set(userId, data);
|
||||
}
|
||||
|
||||
/** Get cached presence for a user. Returns undefined if not cached. */
|
||||
export function getPresence(
|
||||
accountId: string | undefined,
|
||||
userId: string,
|
||||
): GatewayPresenceUpdate | undefined {
|
||||
return presenceCache.get(resolveAccountKey(accountId))?.get(userId);
|
||||
}
|
||||
|
||||
/** Clear cached presence data. */
|
||||
export function clearPresences(accountId?: string): void {
|
||||
if (accountId) {
|
||||
presenceCache.delete(resolveAccountKey(accountId));
|
||||
return;
|
||||
}
|
||||
presenceCache.clear();
|
||||
}
|
||||
|
||||
/** Get the number of cached presence entries. */
|
||||
export function presenceCacheSize(): number {
|
||||
let total = 0;
|
||||
for (const accountCache of presenceCache.values()) {
|
||||
total += accountCache.size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
DiscordReactionListener,
|
||||
DiscordReactionRemoveListener,
|
||||
registerDiscordListener,
|
||||
@@ -109,6 +110,25 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
return details.length > 0 ? ` (${details.join(", ")})` : "";
|
||||
}
|
||||
|
||||
function resolveDiscordGatewayIntents(
|
||||
intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig,
|
||||
): number {
|
||||
let intents =
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
GatewayIntents.MessageContent |
|
||||
GatewayIntents.DirectMessages |
|
||||
GatewayIntents.GuildMessageReactions |
|
||||
GatewayIntents.DirectMessageReactions;
|
||||
if (intentsConfig?.presence) {
|
||||
intents |= GatewayIntents.GuildPresences;
|
||||
}
|
||||
if (intentsConfig?.guildMembers) {
|
||||
intents |= GatewayIntents.GuildMembers;
|
||||
}
|
||||
return intents;
|
||||
}
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveDiscordAccount({
|
||||
@@ -451,13 +471,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
reconnect: {
|
||||
maxAttempts: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
intents:
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
GatewayIntents.MessageContent |
|
||||
GatewayIntents.DirectMessages |
|
||||
GatewayIntents.GuildMessageReactions |
|
||||
GatewayIntents.DirectMessageReactions,
|
||||
intents: resolveDiscordGatewayIntents(discordCfg.intents),
|
||||
autoInteractions: true,
|
||||
}),
|
||||
],
|
||||
@@ -527,6 +541,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordPresenceListener({ logger, accountId: account.accountId }),
|
||||
);
|
||||
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
|
||||
}
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
|
||||
@@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js";
|
||||
describe("gateway auth", () => {
|
||||
it("does not throw when req is missing socket", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: false },
|
||||
connectAuth: null,
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "secret" },
|
||||
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
|
||||
req: {} as never,
|
||||
});
|
||||
@@ -63,40 +63,10 @@ describe("gateway auth", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("treats local tailscale serve hostnames as direct", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
connectAuth: { token: "secret" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { host: "gateway.tailnet-1234.ts.net:443" },
|
||||
@@ -104,21 +74,7 @@ describe("gateway auth", () => {
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("none");
|
||||
});
|
||||
|
||||
it("does not treat tailscale clients as direct", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
req: {
|
||||
socket: { remoteAddress: "100.64.0.42" },
|
||||
headers: { host: "gateway.tailnet-1234.ts.net" },
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("tailscale_user_missing");
|
||||
expect(res.method).toBe("token");
|
||||
});
|
||||
|
||||
it("allows tailscale identity to satisfy token mode auth", async () => {
|
||||
@@ -143,41 +99,4 @@ describe("gateway auth", () => {
|
||||
expect(res.method).toBe("tailscale");
|
||||
expect(res.user).toBe("peter");
|
||||
});
|
||||
|
||||
it("rejects mismatched tailscale identity when required", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-for": "100.64.0.1",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
|
||||
"tailscale-user-login": "peter@example.com",
|
||||
"tailscale-user-name": "Peter",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("tailscale_user_mismatch");
|
||||
});
|
||||
|
||||
it("treats trusted proxy loopback clients as direct", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.2" },
|
||||
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("none");
|
||||
});
|
||||
});
|
||||
+3
-10
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
|
||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||
export type ResolvedGatewayAuthMode = "token" | "password";
|
||||
|
||||
export type ResolvedGatewayAuth = {
|
||||
mode: ResolvedGatewayAuthMode;
|
||||
@@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = {
|
||||
|
||||
export type GatewayAuthResult = {
|
||||
ok: boolean;
|
||||
method?: "none" | "token" | "password" | "tailscale" | "device-token";
|
||||
method?: "token" | "password" | "tailscale" | "device-token";
|
||||
user?: string;
|
||||
reason?: string;
|
||||
};
|
||||
@@ -84,7 +84,7 @@ function resolveRequestClientIp(
|
||||
});
|
||||
}
|
||||
|
||||
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
|
||||
export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
|
||||
if (!req) return false;
|
||||
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) return false;
|
||||
@@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: {
|
||||
user: tailscaleCheck.user.login,
|
||||
};
|
||||
}
|
||||
if (auth.mode === "none") {
|
||||
return { ok: false, reason: tailscaleCheck.reason };
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.mode === "none") {
|
||||
return { ok: true, method: "none" };
|
||||
}
|
||||
|
||||
if (auth.mode === "token") {
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("gateway e2e", () => {
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "none" },
|
||||
auth: { mode: "token", token: wizardToken },
|
||||
controlUiEnabled: false,
|
||||
wizardRunner: async (_opts, _runtime, prompter) => {
|
||||
await prompter.intro("Wizard E2E");
|
||||
@@ -197,6 +197,7 @@ describe("gateway e2e", () => {
|
||||
|
||||
const client = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token: wizardToken,
|
||||
clientDisplayName: "vitest-wizard",
|
||||
});
|
||||
|
||||
|
||||
@@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => {
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
});
|
||||
|
||||
test("requires nonce when host is non-local", async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: { host: "example.com" },
|
||||
});
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message).toBe("device nonce required");
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
});
|
||||
|
||||
test(
|
||||
"invalid connect params surface in response and close reason",
|
||||
{ timeout: 60_000 },
|
||||
@@ -290,6 +302,7 @@ describe("gateway server auth/connect", () => {
|
||||
|
||||
test("allows control ui with device identity when insecure auth is enabled", async () => {
|
||||
testState.gatewayControlUi = { allowInsecureAuth: true };
|
||||
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
gateway: {
|
||||
@@ -352,19 +365,45 @@ describe("gateway server auth/connect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
|
||||
testState.gatewayAuth = { mode: "none" };
|
||||
test("allows control ui with stale device identity when device auth is disabled", async () => {
|
||||
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
||||
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
const ws = await openWs(port);
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||
await import("../infra/device-identity.js");
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const signedAtMs = Date.now() - 60 * 60 * 1000;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: identity.deviceId,
|
||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
role: "operator",
|
||||
scopes: [],
|
||||
signedAtMs,
|
||||
token: "secret",
|
||||
});
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const res = await connectReq(ws, { skipDefaultAuth: true });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("gateway auth required");
|
||||
const device = {
|
||||
id: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
};
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
device,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
|
||||
@@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { authorizeGatewayConnect } from "../../auth.js";
|
||||
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||
import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
@@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
|
||||
|
||||
function resolveHostName(hostHeader?: string): string {
|
||||
const host = (hostHeader ?? "").trim().toLowerCase();
|
||||
if (!host) return "";
|
||||
if (host.startsWith("[")) {
|
||||
const end = host.indexOf("]");
|
||||
if (end !== -1) return host.slice(1, end);
|
||||
}
|
||||
const [name] = host.split(":");
|
||||
return name ?? "";
|
||||
}
|
||||
|
||||
type AuthProvidedKind = "token" | "password" | "none";
|
||||
|
||||
function formatGatewayAuthFailureMessage(params: {
|
||||
@@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const hasProxyHeaders = Boolean(forwardedFor || realIp);
|
||||
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
|
||||
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
|
||||
const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp);
|
||||
const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp;
|
||||
const hostName = resolveHostName(requestHost);
|
||||
const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
|
||||
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
|
||||
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
|
||||
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
|
||||
const reportedClientIp =
|
||||
isLocalClient || hasUntrustedProxyHeaders
|
||||
? undefined
|
||||
: clientIp && !isLoopbackAddress(clientIp)
|
||||
? clientIp
|
||||
: undefined;
|
||||
|
||||
if (hasUntrustedProxyHeaders) {
|
||||
logWsControl.warn(
|
||||
@@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
|
||||
);
|
||||
}
|
||||
if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) {
|
||||
logWsControl.warn(
|
||||
"Loopback connection with non-local Host header. " +
|
||||
"Treating it as remote. If you're behind a reverse proxy, " +
|
||||
"set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.",
|
||||
);
|
||||
}
|
||||
|
||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||
|
||||
@@ -335,7 +362,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectParams.role = role;
|
||||
connectParams.scopes = scopes;
|
||||
|
||||
const device = connectParams.device;
|
||||
const deviceRaw = connectParams.device;
|
||||
let devicePublicKey: string | null = null;
|
||||
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||
@@ -343,36 +370,14 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||
const allowInsecureControlUi =
|
||||
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
||||
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("proxy-auth-required", {
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"gateway auth required behind reverse proxy",
|
||||
{
|
||||
details: {
|
||||
hint: "set gateway.auth or configure gateway.trustedProxies",
|
||||
},
|
||||
},
|
||||
),
|
||||
});
|
||||
close(1008, "gateway auth required");
|
||||
return;
|
||||
}
|
||||
|
||||
const disableControlUiDeviceAuth =
|
||||
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
|
||||
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
|
||||
const device = disableControlUiDeviceAuth ? null : deviceRaw;
|
||||
if (!device) {
|
||||
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
|
||||
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
|
||||
|
||||
if (isControlUi && !allowInsecureControlUi) {
|
||||
if (isControlUi && !allowControlUiBypass) {
|
||||
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("control-ui-insecure-auth", {
|
||||
@@ -566,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
trustedProxies,
|
||||
});
|
||||
let authOk = authResult.ok;
|
||||
let authMethod = authResult.method ?? "none";
|
||||
let authMethod =
|
||||
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
|
||||
if (!authOk && connectParams.auth?.token && device) {
|
||||
const tokenCheck = await verifyDeviceToken({
|
||||
deviceId: device.id,
|
||||
@@ -615,7 +621,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipPairing = allowInsecureControlUi && hasSharedAuth;
|
||||
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
||||
if (device && devicePublicKey && !skipPairing) {
|
||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||
const pairing = await requestDevicePairing({
|
||||
@@ -736,9 +742,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||
const clientId = connectParams.client.id;
|
||||
const instanceId = connectParams.client.instanceId;
|
||||
const presenceKey = shouldTrackPresence
|
||||
? (connectParams.device?.id ?? instanceId ?? connId)
|
||||
: undefined;
|
||||
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
|
||||
|
||||
logWs("in", "connect", {
|
||||
connId,
|
||||
@@ -766,10 +770,10 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
modelIdentifier: connectParams.client.modelIdentifier,
|
||||
mode: connectParams.client.mode,
|
||||
deviceId: connectParams.device?.id,
|
||||
deviceId: device?.id,
|
||||
roles: [role],
|
||||
scopes,
|
||||
instanceId: connectParams.device?.id ?? instanceId,
|
||||
instanceId: device?.id ?? instanceId,
|
||||
reason: "connect",
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
|
||||
@@ -260,6 +260,9 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
|
||||
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
|
||||
let port = await getFreePort();
|
||||
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
if (typeof token === "string") {
|
||||
testState.gatewayAuth = { mode: "token", token };
|
||||
}
|
||||
const fallbackToken =
|
||||
token ??
|
||||
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPinnedLookup, resolvePinnedHostname } from "./ssrf.js";
|
||||
|
||||
describe("ssrf pinning", () => {
|
||||
it("pins resolved addresses for the target hostname", async () => {
|
||||
const lookup = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
]);
|
||||
|
||||
const pinned = await resolvePinnedHostname("Example.com.", lookup);
|
||||
expect(pinned.hostname).toBe("example.com");
|
||||
expect(pinned.addresses).toEqual(["93.184.216.34", "93.184.216.35"]);
|
||||
|
||||
const first = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
|
||||
pinned.lookup("example.com", (err, address, family) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ address: address as string, family });
|
||||
});
|
||||
});
|
||||
expect(first.address).toBe("93.184.216.34");
|
||||
expect(first.family).toBe(4);
|
||||
|
||||
const all = await new Promise<unknown>((resolve, reject) => {
|
||||
pinned.lookup("example.com", { all: true }, (err, addresses) => {
|
||||
if (err) reject(err);
|
||||
else resolve(addresses);
|
||||
});
|
||||
});
|
||||
expect(Array.isArray(all)).toBe(true);
|
||||
expect((all as Array<{ address: string }>).map((entry) => entry.address)).toEqual(
|
||||
pinned.addresses,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects private DNS results", async () => {
|
||||
const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]);
|
||||
await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i);
|
||||
});
|
||||
|
||||
it("falls back for non-matching hostnames", async () => {
|
||||
const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => {
|
||||
const cb = typeof options === "function" ? options : (callback as () => void);
|
||||
(cb as (err: null, address: string, family: number) => void)(null, "1.2.3.4", 4);
|
||||
});
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
fallback,
|
||||
});
|
||||
|
||||
const result = await new Promise<{ address: string }>((resolve, reject) => {
|
||||
lookup("other.test", (err, address) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ address: address as string });
|
||||
});
|
||||
});
|
||||
|
||||
expect(fallback).toHaveBeenCalledTimes(1);
|
||||
expect(result.address).toBe("1.2.3.4");
|
||||
});
|
||||
});
|
||||
+113
-2
@@ -1,4 +1,12 @@
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
|
||||
import { Agent, type Dispatcher } from "undici";
|
||||
|
||||
type LookupCallback = (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | LookupAddress[],
|
||||
family?: number,
|
||||
) => void;
|
||||
|
||||
export class SsrFBlockedError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -101,10 +109,71 @@ export function isBlockedHostname(hostname: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertPublicHostname(
|
||||
export function createPinnedLookup(params: {
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
fallback?: typeof dnsLookupCb;
|
||||
}): typeof dnsLookupCb {
|
||||
const normalizedHost = normalizeHostname(params.hostname);
|
||||
const fallback = params.fallback ?? dnsLookupCb;
|
||||
const fallbackLookup = fallback as unknown as (
|
||||
hostname: string,
|
||||
callback: LookupCallback,
|
||||
) => void;
|
||||
const fallbackWithOptions = fallback as unknown as (
|
||||
hostname: string,
|
||||
options: unknown,
|
||||
callback: LookupCallback,
|
||||
) => void;
|
||||
const records = params.addresses.map((address) => ({
|
||||
address,
|
||||
family: address.includes(":") ? 6 : 4,
|
||||
}));
|
||||
let index = 0;
|
||||
|
||||
return ((host: string, options?: unknown, callback?: unknown) => {
|
||||
const cb: LookupCallback =
|
||||
typeof options === "function" ? (options as LookupCallback) : (callback as LookupCallback);
|
||||
if (!cb) return;
|
||||
const normalized = normalizeHostname(host);
|
||||
if (!normalized || normalized !== normalizedHost) {
|
||||
if (typeof options === "function" || options === undefined) {
|
||||
return fallbackLookup(host, cb);
|
||||
}
|
||||
return fallbackWithOptions(host, options, cb);
|
||||
}
|
||||
|
||||
const opts =
|
||||
typeof options === "object" && options !== null
|
||||
? (options as { all?: boolean; family?: number })
|
||||
: {};
|
||||
const requestedFamily =
|
||||
typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0;
|
||||
const candidates =
|
||||
requestedFamily === 4 || requestedFamily === 6
|
||||
? records.filter((entry) => entry.family === requestedFamily)
|
||||
: records;
|
||||
const usable = candidates.length > 0 ? candidates : records;
|
||||
if (opts.all) {
|
||||
cb(null, usable as LookupAddress[]);
|
||||
return;
|
||||
}
|
||||
const chosen = usable[index % usable.length];
|
||||
index += 1;
|
||||
cb(null, chosen.address, chosen.family);
|
||||
}) as typeof dnsLookupCb;
|
||||
}
|
||||
|
||||
export type PinnedHostname = {
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
lookup: typeof dnsLookupCb;
|
||||
};
|
||||
|
||||
export async function resolvePinnedHostname(
|
||||
hostname: string,
|
||||
lookupFn: LookupFn = dnsLookup,
|
||||
): Promise<void> {
|
||||
): Promise<PinnedHostname> {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid hostname");
|
||||
@@ -128,4 +197,46 @@ export async function assertPublicHostname(
|
||||
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
|
||||
}
|
||||
}
|
||||
|
||||
const addresses = Array.from(new Set(results.map((entry) => entry.address)));
|
||||
if (addresses.length === 0) {
|
||||
throw new Error(`Unable to resolve hostname: ${hostname}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: normalized,
|
||||
addresses,
|
||||
lookup: createPinnedLookup({ hostname: normalized, addresses }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher {
|
||||
return new Agent({
|
||||
connect: {
|
||||
lookup: pinned.lookup,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise<void> {
|
||||
if (!dispatcher) return;
|
||||
const candidate = dispatcher as { close?: () => Promise<void> | void; destroy?: () => void };
|
||||
try {
|
||||
if (typeof candidate.close === "function") {
|
||||
await candidate.close();
|
||||
return;
|
||||
}
|
||||
if (typeof candidate.destroy === "function") {
|
||||
candidate.destroy();
|
||||
}
|
||||
} catch {
|
||||
// ignore dispatcher cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPublicHostname(
|
||||
hostname: string,
|
||||
lookupFn: LookupFn = dnsLookup,
|
||||
): Promise<void> {
|
||||
await resolvePinnedHostname(hostname, lookupFn);
|
||||
}
|
||||
+49
-37
@@ -1,5 +1,10 @@
|
||||
import { logWarn } from "../logger.js";
|
||||
import { assertPublicHostname } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolvePinnedHostname,
|
||||
} from "../infra/net/ssrf.js";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
type CanvasModule = typeof import("@napi-rs/canvas");
|
||||
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
@@ -154,50 +159,57 @@ export async function fetchWithGuard(params: {
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`);
|
||||
}
|
||||
await assertPublicHostname(parsedUrl.hostname);
|
||||
const pinned = await resolvePinnedHostname(parsedUrl.hostname);
|
||||
const dispatcher = createPinnedDispatcher(pinned);
|
||||
|
||||
const response = await fetch(parsedUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "Clawdbot-Gateway/1.0" },
|
||||
redirect: "manual",
|
||||
});
|
||||
try {
|
||||
const response = await fetch(parsedUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "Clawdbot-Gateway/1.0" },
|
||||
redirect: "manual",
|
||||
dispatcher,
|
||||
} as RequestInit & { dispatcher: Dispatcher });
|
||||
|
||||
if (isRedirectStatus(response.status)) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`Redirect missing location header (${response.status})`);
|
||||
if (isRedirectStatus(response.status)) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`Redirect missing location header (${response.status})`);
|
||||
}
|
||||
redirectCount += 1;
|
||||
if (redirectCount > params.maxRedirects) {
|
||||
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
||||
}
|
||||
void response.body?.cancel();
|
||||
currentUrl = new URL(location, parsedUrl).toString();
|
||||
continue;
|
||||
}
|
||||
redirectCount += 1;
|
||||
if (redirectCount > params.maxRedirects) {
|
||||
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
currentUrl = new URL(location, parsedUrl).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
const size = parseInt(contentLength, 10);
|
||||
if (size > params.maxBytes) {
|
||||
throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`);
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
const size = parseInt(contentLength, 10);
|
||||
if (size > params.maxBytes) {
|
||||
throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || undefined;
|
||||
const parsed = parseContentType(contentType);
|
||||
const mimeType = parsed.mimeType ?? "application/octet-stream";
|
||||
return { buffer, mimeType, contentType };
|
||||
const contentType = response.headers.get("content-type") || undefined;
|
||||
const parsed = parseContentType(contentType);
|
||||
const mimeType = parsed.mimeType ?? "application/octet-stream";
|
||||
return { buffer, mimeType, contentType };
|
||||
} finally {
|
||||
await closeDispatcher(dispatcher);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -18,6 +18,9 @@ vi.doMock("node:os", () => ({
|
||||
vi.doMock("node:https", () => ({
|
||||
request: (...args: unknown[]) => mockRequest(...args),
|
||||
}));
|
||||
vi.doMock("node:dns/promises", () => ({
|
||||
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||||
}));
|
||||
|
||||
const loadStore = async () => await import("./store.js");
|
||||
|
||||
|
||||
+63
-45
@@ -1,10 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { request } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolveConfigDir } from "../utils.js";
|
||||
import { resolvePinnedHostname } from "../infra/net/ssrf.js";
|
||||
import { detectMime, extensionForMime } from "./mime.js";
|
||||
|
||||
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
|
||||
@@ -88,51 +90,67 @@ async function downloadToFile(
|
||||
maxRedirects = 5,
|
||||
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = request(url, { headers }, (res) => {
|
||||
// Follow redirects
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
||||
const location = res.headers.location;
|
||||
if (!location || maxRedirects <= 0) {
|
||||
reject(new Error(`Redirect loop or missing Location header`));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = new URL(location, url).href;
|
||||
resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1));
|
||||
return;
|
||||
}
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
const sniffChunks: Buffer[] = [];
|
||||
let sniffLen = 0;
|
||||
const out = createWriteStream(dest);
|
||||
res.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (sniffLen < 16384) {
|
||||
sniffChunks.push(chunk);
|
||||
sniffLen += chunk.length;
|
||||
}
|
||||
if (total > MAX_BYTES) {
|
||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||
}
|
||||
});
|
||||
pipeline(res, out)
|
||||
.then(() => {
|
||||
const sniffBuffer = Buffer.concat(sniffChunks, Math.min(sniffLen, 16384));
|
||||
const rawHeader = res.headers["content-type"];
|
||||
const headerMime = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
||||
resolve({
|
||||
headerMime,
|
||||
sniffBuffer,
|
||||
size: total,
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
reject(new Error("Invalid URL"));
|
||||
return;
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
reject(new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`));
|
||||
return;
|
||||
}
|
||||
const requestImpl = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
resolvePinnedHostname(parsedUrl.hostname)
|
||||
.then((pinned) => {
|
||||
const req = requestImpl(parsedUrl, { headers, lookup: pinned.lookup }, (res) => {
|
||||
// Follow redirects
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
||||
const location = res.headers.location;
|
||||
if (!location || maxRedirects <= 0) {
|
||||
reject(new Error(`Redirect loop or missing Location header`));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = new URL(location, url).href;
|
||||
resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1));
|
||||
return;
|
||||
}
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
const sniffChunks: Buffer[] = [];
|
||||
let sniffLen = 0;
|
||||
const out = createWriteStream(dest);
|
||||
res.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (sniffLen < 16384) {
|
||||
sniffChunks.push(chunk);
|
||||
sniffLen += chunk.length;
|
||||
}
|
||||
if (total > MAX_BYTES) {
|
||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
pipeline(res, out)
|
||||
.then(() => {
|
||||
const sniffBuffer = Buffer.concat(sniffChunks, Math.min(sniffLen, 16384));
|
||||
const rawHeader = res.headers["content-type"];
|
||||
const headerMime = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
||||
resolve({
|
||||
headerMime,
|
||||
sniffBuffer,
|
||||
size: total,
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ export type {
|
||||
ClawdbotPluginService,
|
||||
ClawdbotPluginServiceContext,
|
||||
} from "../plugins/types.js";
|
||||
export type {
|
||||
GatewayRequestHandler,
|
||||
GatewayRequestHandlerOptions,
|
||||
RespondFn,
|
||||
} from "../gateway/server-methods/types.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export { normalizePluginHttpPath } from "../plugins/http-path.js";
|
||||
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||
|
||||
+120
-48
@@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
formatOctal,
|
||||
isGroupReadable,
|
||||
isGroupWritable,
|
||||
isWorldReadable,
|
||||
isWorldWritable,
|
||||
modeBits,
|
||||
formatPermissionDetail,
|
||||
formatPermissionRemediation,
|
||||
inspectPathPermissions,
|
||||
safeStat,
|
||||
} from "./audit-fs.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
|
||||
|
||||
export async function collectIncludeFilePermFindings(params: {
|
||||
configSnapshot: ConfigFileSnapshot;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (!params.configSnapshot.exists) return findings;
|
||||
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
|
||||
|
||||
for (const p of includePaths) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const st = await safeStat(p);
|
||||
if (!st.ok) continue;
|
||||
const bits = modeBits(st.mode);
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
const perms = await inspectPathPermissions(p, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (!perms.ok) continue;
|
||||
if (perms.worldWritable || perms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Config include file is writable by others",
|
||||
detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
|
||||
remediation: `chmod 600 ${p}`,
|
||||
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isWorldReadable(bits)) {
|
||||
} else if (perms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_world_readable",
|
||||
severity: "critical",
|
||||
title: "Config include file is world-readable",
|
||||
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${p}`,
|
||||
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits)) {
|
||||
} else if (perms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_group_readable",
|
||||
severity: "warn",
|
||||
title: "Config include file is group-readable",
|
||||
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${p}`,
|
||||
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
|
||||
|
||||
const oauthStat = await safeStat(oauthDir);
|
||||
if (oauthStat.ok && oauthStat.isDir) {
|
||||
const bits = modeBits(oauthStat.mode);
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
const oauthPerms = await inspectPathPermissions(oauthDir, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (oauthPerms.ok && oauthPerms.isDir) {
|
||||
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.credentials_dir.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Credentials dir is writable by others",
|
||||
detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
|
||||
remediation: `chmod 700 ${oauthDir}`,
|
||||
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: oauthDir,
|
||||
perms: oauthPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
|
||||
} else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.credentials_dir.perms_readable",
|
||||
severity: "warn",
|
||||
title: "Credentials dir is readable by others",
|
||||
detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
|
||||
remediation: `chmod 700 ${oauthDir}`,
|
||||
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: oauthDir,
|
||||
perms: oauthPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: {
|
||||
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const authStat = await safeStat(authPath);
|
||||
if (authStat.ok) {
|
||||
const bits = modeBits(authStat.mode);
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
const authPerms = await inspectPathPermissions(authPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (authPerms.ok) {
|
||||
if (authPerms.worldWritable || authPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.auth_profiles.perms_writable",
|
||||
severity: "critical",
|
||||
title: "auth-profiles.json is writable by others",
|
||||
detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
|
||||
remediation: `chmod 600 ${authPath}`,
|
||||
detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: authPath,
|
||||
perms: authPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
||||
} else if (authPerms.worldReadable || authPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.auth_profiles.perms_readable",
|
||||
severity: "warn",
|
||||
title: "auth-profiles.json is readable by others",
|
||||
detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
||||
remediation: `chmod 600 ${authPath}`,
|
||||
detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: authPath,
|
||||
perms: authPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const storeStat = await safeStat(storePath);
|
||||
if (storeStat.ok) {
|
||||
const bits = modeBits(storeStat.mode);
|
||||
if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
||||
const storePerms = await inspectPathPermissions(storePath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (storePerms.ok) {
|
||||
if (storePerms.worldReadable || storePerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.sessions_store.perms_readable",
|
||||
severity: "warn",
|
||||
title: "sessions.json is readable by others",
|
||||
detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
|
||||
remediation: `chmod 600 ${storePath}`,
|
||||
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: storePath,
|
||||
perms: storePerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: {
|
||||
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
|
||||
if (expanded) {
|
||||
const logPath = path.resolve(expanded);
|
||||
const st = await safeStat(logPath);
|
||||
if (st.ok) {
|
||||
const bits = modeBits(st.mode);
|
||||
if (isWorldReadable(bits) || isGroupReadable(bits)) {
|
||||
const logPerms = await inspectPathPermissions(logPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (logPerms.ok) {
|
||||
if (logPerms.worldReadable || logPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.log_file.perms_readable",
|
||||
severity: "warn",
|
||||
title: "Log file is readable by others",
|
||||
detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
|
||||
remediation: `chmod 600 ${logPath}`,
|
||||
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: logPath,
|
||||
perms: logPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
formatIcaclsResetCommand,
|
||||
formatWindowsAclSummary,
|
||||
inspectWindowsAcl,
|
||||
type ExecFn,
|
||||
} from "./windows-acl.js";
|
||||
|
||||
export type PermissionCheck = {
|
||||
ok: boolean;
|
||||
isSymlink: boolean;
|
||||
isDir: boolean;
|
||||
mode: number | null;
|
||||
bits: number | null;
|
||||
source: "posix" | "windows-acl" | "unknown";
|
||||
worldWritable: boolean;
|
||||
groupWritable: boolean;
|
||||
worldReadable: boolean;
|
||||
groupReadable: boolean;
|
||||
aclSummary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type PermissionCheckOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
exec?: ExecFn;
|
||||
};
|
||||
|
||||
export async function safeStat(targetPath: string): Promise<{
|
||||
ok: boolean;
|
||||
isSymlink: boolean;
|
||||
@@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectPathPermissions(
|
||||
targetPath: string,
|
||||
opts?: PermissionCheckOptions,
|
||||
): Promise<PermissionCheck> {
|
||||
const st = await safeStat(targetPath);
|
||||
if (!st.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
isSymlink: false,
|
||||
isDir: false,
|
||||
mode: null,
|
||||
bits: null,
|
||||
source: "unknown",
|
||||
worldWritable: false,
|
||||
groupWritable: false,
|
||||
worldReadable: false,
|
||||
groupReadable: false,
|
||||
error: st.error,
|
||||
};
|
||||
}
|
||||
|
||||
const bits = modeBits(st.mode);
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
|
||||
if (!acl.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
isSymlink: st.isSymlink,
|
||||
isDir: st.isDir,
|
||||
mode: st.mode,
|
||||
bits,
|
||||
source: "unknown",
|
||||
worldWritable: false,
|
||||
groupWritable: false,
|
||||
worldReadable: false,
|
||||
groupReadable: false,
|
||||
error: acl.error,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
isSymlink: st.isSymlink,
|
||||
isDir: st.isDir,
|
||||
mode: st.mode,
|
||||
bits,
|
||||
source: "windows-acl",
|
||||
worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
|
||||
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
|
||||
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
|
||||
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
|
||||
aclSummary: formatWindowsAclSummary(acl),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
isSymlink: st.isSymlink,
|
||||
isDir: st.isDir,
|
||||
mode: st.mode,
|
||||
bits,
|
||||
source: "posix",
|
||||
worldWritable: isWorldWritable(bits),
|
||||
groupWritable: isGroupWritable(bits),
|
||||
worldReadable: isWorldReadable(bits),
|
||||
groupReadable: isGroupReadable(bits),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
|
||||
if (perms.source === "windows-acl") {
|
||||
const summary = perms.aclSummary ?? "unknown";
|
||||
return `${targetPath} acl=${summary}`;
|
||||
}
|
||||
return `${targetPath} mode=${formatOctal(perms.bits)}`;
|
||||
}
|
||||
|
||||
export function formatPermissionRemediation(params: {
|
||||
targetPath: string;
|
||||
perms: PermissionCheck;
|
||||
isDir: boolean;
|
||||
posixMode: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
if (params.perms.source === "windows-acl") {
|
||||
return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
|
||||
}
|
||||
const mode = params.posixMode.toString(8).padStart(3, "0");
|
||||
return `chmod ${mode} ${params.targetPath}`;
|
||||
}
|
||||
|
||||
export function modeBits(mode: number | null): number | null {
|
||||
if (mode == null) return null;
|
||||
return mode & 0o777;
|
||||
|
||||
+102
-2
@@ -82,7 +82,7 @@ describe("security audit", () => {
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
controlUi: { enabled: true },
|
||||
auth: { mode: "none" as any },
|
||||
auth: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,6 +120,83 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats Windows ACL-only perms as secure", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-"));
|
||||
const stateDir = path.join(tmp, "state");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
const configPath = path.join(stateDir, "clawdbot.json");
|
||||
await fs.writeFile(configPath, "{}\n", "utf-8");
|
||||
|
||||
const user = "DESKTOP-TEST\\Tester";
|
||||
const execIcacls = async (_cmd: string, args: string[]) => ({
|
||||
stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: {},
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath,
|
||||
platform: "win32",
|
||||
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
|
||||
execIcacls,
|
||||
});
|
||||
|
||||
const forbidden = new Set([
|
||||
"fs.state_dir.perms_world_writable",
|
||||
"fs.state_dir.perms_group_writable",
|
||||
"fs.state_dir.perms_readable",
|
||||
"fs.config.perms_writable",
|
||||
"fs.config.perms_world_readable",
|
||||
"fs.config.perms_group_readable",
|
||||
]);
|
||||
for (const id of forbidden) {
|
||||
expect(res.findings.some((f) => f.checkId === id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("flags Windows ACLs when Users can read the state dir", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-open-"));
|
||||
const stateDir = path.join(tmp, "state");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
const configPath = path.join(stateDir, "clawdbot.json");
|
||||
await fs.writeFile(configPath, "{}\n", "utf-8");
|
||||
|
||||
const user = "DESKTOP-TEST\\Tester";
|
||||
const execIcacls = async (_cmd: string, args: string[]) => {
|
||||
const target = args[0];
|
||||
if (target === stateDir) {
|
||||
return {
|
||||
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`,
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
|
||||
stderr: "",
|
||||
};
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: {},
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath,
|
||||
platform: "win32",
|
||||
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
|
||||
execIcacls,
|
||||
});
|
||||
|
||||
expect(
|
||||
res.findings.some(
|
||||
(f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when small models are paired with web/browser tools", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
|
||||
@@ -293,7 +370,30 @@ describe("security audit", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.control_ui.insecure_auth",
|
||||
severity: "warn",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when control UI device auth is disabled", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
controlUi: { dangerouslyDisableDeviceAuth: true },
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.control_ui.device_auth_disabled",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
+109
-38
@@ -24,14 +24,11 @@ import {
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import {
|
||||
formatOctal,
|
||||
isGroupReadable,
|
||||
isGroupWritable,
|
||||
isWorldReadable,
|
||||
isWorldWritable,
|
||||
modeBits,
|
||||
safeStat,
|
||||
formatPermissionDetail,
|
||||
formatPermissionRemediation,
|
||||
inspectPathPermissions,
|
||||
} from "./audit-fs.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityAuditSeverity = "info" | "warn" | "critical";
|
||||
|
||||
@@ -66,6 +63,8 @@ export type SecurityAuditReport = {
|
||||
|
||||
export type SecurityAuditOptions = {
|
||||
config: ClawdbotConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
deep?: boolean;
|
||||
includeFilesystem?: boolean;
|
||||
includeChannelSecurity?: boolean;
|
||||
@@ -79,6 +78,8 @@ export type SecurityAuditOptions = {
|
||||
plugins?: ReturnType<typeof listChannelPlugins>;
|
||||
/** Dependency injection for tests. */
|
||||
probeGatewayFn?: typeof probeGateway;
|
||||
/** Dependency injection for tests (Windows ACL checks). */
|
||||
execIcacls?: ExecFn;
|
||||
};
|
||||
|
||||
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
|
||||
@@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
|
||||
async function collectFilesystemFindings(params: {
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
|
||||
const stateDirStat = await safeStat(params.stateDir);
|
||||
if (stateDirStat.ok) {
|
||||
const bits = modeBits(stateDirStat.mode);
|
||||
if (stateDirStat.isSymlink) {
|
||||
const stateDirPerms = await inspectPathPermissions(params.stateDir, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (stateDirPerms.ok) {
|
||||
if (stateDirPerms.isSymlink) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.symlink",
|
||||
severity: "warn",
|
||||
@@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: {
|
||||
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
|
||||
});
|
||||
}
|
||||
if (isWorldWritable(bits)) {
|
||||
if (stateDirPerms.worldWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.perms_world_writable",
|
||||
severity: "critical",
|
||||
title: "State dir is world-writable",
|
||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`,
|
||||
remediation: `chmod 700 ${params.stateDir}`,
|
||||
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.stateDir,
|
||||
perms: stateDirPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupWritable(bits)) {
|
||||
} else if (stateDirPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.perms_group_writable",
|
||||
severity: "warn",
|
||||
title: "State dir is group-writable",
|
||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`,
|
||||
remediation: `chmod 700 ${params.stateDir}`,
|
||||
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.stateDir,
|
||||
perms: stateDirPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
|
||||
} else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.state_dir.perms_readable",
|
||||
severity: "warn",
|
||||
title: "State dir is readable by others",
|
||||
detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`,
|
||||
remediation: `chmod 700 ${params.stateDir}`,
|
||||
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.stateDir,
|
||||
perms: stateDirPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configStat = await safeStat(params.configPath);
|
||||
if (configStat.ok) {
|
||||
const bits = modeBits(configStat.mode);
|
||||
if (configStat.isSymlink) {
|
||||
const configPerms = await inspectPathPermissions(params.configPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (configPerms.ok) {
|
||||
if (configPerms.isSymlink) {
|
||||
findings.push({
|
||||
checkId: "fs.config.symlink",
|
||||
severity: "warn",
|
||||
@@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: {
|
||||
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
|
||||
});
|
||||
}
|
||||
if (isWorldWritable(bits) || isGroupWritable(bits)) {
|
||||
if (configPerms.worldWritable || configPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.config.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Config file is writable by others",
|
||||
detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`,
|
||||
remediation: `chmod 600 ${params.configPath}`,
|
||||
detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.configPath,
|
||||
perms: configPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isWorldReadable(bits)) {
|
||||
} else if (configPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config.perms_world_readable",
|
||||
severity: "critical",
|
||||
title: "Config file is world-readable",
|
||||
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${params.configPath}`,
|
||||
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.configPath,
|
||||
perms: configPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (isGroupReadable(bits)) {
|
||||
} else if (configPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config.perms_group_readable",
|
||||
severity: "warn",
|
||||
title: "Config file is group-readable",
|
||||
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
|
||||
remediation: `chmod 600 ${params.configPath}`,
|
||||
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: params.configPath,
|
||||
perms: configPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -274,7 +320,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
||||
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
|
||||
findings.push({
|
||||
checkId: "gateway.control_ui.insecure_auth",
|
||||
severity: "warn",
|
||||
severity: "critical",
|
||||
title: "Control UI allows insecure HTTP auth",
|
||||
detail:
|
||||
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
|
||||
@@ -282,6 +328,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
|
||||
findings.push({
|
||||
checkId: "gateway.control_ui.device_auth_disabled",
|
||||
severity: "critical",
|
||||
title: "DANGEROUS: Control UI device auth disabled",
|
||||
detail:
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
|
||||
remediation: "Disable it unless you are in a short-lived break-glass scenario.",
|
||||
});
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||
if (auth.mode === "token" && token && token.length < 24) {
|
||||
@@ -839,7 +896,9 @@ async function maybeProbeGateway(params: {
|
||||
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const cfg = opts.config;
|
||||
const env = process.env;
|
||||
const env = opts.env ?? process.env;
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const execIcacls = opts.execIcacls;
|
||||
const stateDir = opts.stateDir ?? resolveStateDir(env);
|
||||
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
|
||||
|
||||
@@ -862,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
: null;
|
||||
|
||||
if (opts.includeFilesystem !== false) {
|
||||
findings.push(...(await collectFilesystemFindings({ stateDir, configPath })));
|
||||
findings.push(
|
||||
...(await collectFilesystemFindings({
|
||||
stateDir,
|
||||
configPath,
|
||||
env,
|
||||
platform,
|
||||
execIcacls,
|
||||
})),
|
||||
);
|
||||
if (configSnapshot) {
|
||||
findings.push(...(await collectIncludeFilePermFindings({ configSnapshot })));
|
||||
findings.push(
|
||||
...(await collectIncludeFilePermFindings({ configSnapshot, env, platform, execIcacls })),
|
||||
);
|
||||
}
|
||||
findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir })));
|
||||
findings.push(
|
||||
...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })),
|
||||
);
|
||||
findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
|
||||
}
|
||||
|
||||
|
||||
+125
-15
@@ -10,6 +10,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityFixChmodAction = {
|
||||
kind: "chmod";
|
||||
@@ -20,13 +22,24 @@ export type SecurityFixChmodAction = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SecurityFixIcaclsAction = {
|
||||
kind: "icacls";
|
||||
path: string;
|
||||
command: string;
|
||||
ok: boolean;
|
||||
skipped?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
|
||||
|
||||
export type SecurityFixResult = {
|
||||
ok: boolean;
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
configWritten: boolean;
|
||||
changes: string[];
|
||||
actions: SecurityFixChmodAction[];
|
||||
actions: SecurityFixAction[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
@@ -97,6 +110,82 @@ async function safeChmod(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function safeAclReset(params: {
|
||||
path: string;
|
||||
require: "dir" | "file";
|
||||
env: NodeJS.ProcessEnv;
|
||||
exec?: ExecFn;
|
||||
}): Promise<SecurityFixIcaclsAction> {
|
||||
const display = formatIcaclsResetCommand(params.path, {
|
||||
isDir: params.require === "dir",
|
||||
env: params.env,
|
||||
});
|
||||
try {
|
||||
const st = await fs.lstat(params.path);
|
||||
if (st.isSymbolicLink()) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "symlink",
|
||||
};
|
||||
}
|
||||
if (params.require === "dir" && !st.isDirectory()) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "not-a-directory",
|
||||
};
|
||||
}
|
||||
if (params.require === "file" && !st.isFile()) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "not-a-file",
|
||||
};
|
||||
}
|
||||
const cmd = createIcaclsResetCommand(params.path, {
|
||||
isDir: st.isDirectory(),
|
||||
env: params.env,
|
||||
});
|
||||
if (!cmd) {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "missing-user",
|
||||
};
|
||||
}
|
||||
const exec = params.exec ?? runExec;
|
||||
await exec(cmd.command, cmd.args);
|
||||
return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
skipped: "missing",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "icacls",
|
||||
path: params.path,
|
||||
command: display,
|
||||
ok: false,
|
||||
error: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function setGroupPolicyAllowlist(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: string;
|
||||
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
cfg: ClawdbotConfig;
|
||||
actions: SecurityFixChmodAction[];
|
||||
actions: SecurityFixAction[];
|
||||
applyPerms: (params: {
|
||||
path: string;
|
||||
mode: number;
|
||||
require: "dir" | "file";
|
||||
}) => Promise<SecurityFixAction>;
|
||||
}): Promise<void> {
|
||||
const credsDir = resolveOAuthDir(params.env, params.stateDir);
|
||||
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
|
||||
@@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" }));
|
||||
params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" }));
|
||||
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" }));
|
||||
params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" }));
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" }));
|
||||
params.actions.push(
|
||||
await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }),
|
||||
);
|
||||
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" }));
|
||||
params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
configPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
exec?: ExecFn;
|
||||
}): Promise<SecurityFixResult> {
|
||||
const env = opts?.env ?? process.env;
|
||||
const platform = opts?.platform ?? process.platform;
|
||||
const exec = opts?.exec ?? runExec;
|
||||
const isWindows = platform === "win32";
|
||||
const stateDir = opts?.stateDir ?? resolveStateDir(env);
|
||||
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
|
||||
const actions: SecurityFixChmodAction[] = [];
|
||||
const actions: SecurityFixAction[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const io = createConfigIO({ env, configPath });
|
||||
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
|
||||
}
|
||||
}
|
||||
|
||||
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
|
||||
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
|
||||
const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
|
||||
isWindows
|
||||
? safeAclReset({ path: params.path, require: params.require, env, exec })
|
||||
: safeChmod({ path: params.path, mode: params.mode, require: params.require });
|
||||
|
||||
actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" }));
|
||||
actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" }));
|
||||
|
||||
if (snap.exists) {
|
||||
const includePaths = await collectIncludePathsRecursive({
|
||||
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
|
||||
}).catch(() => []);
|
||||
for (const p of includePaths) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
|
||||
actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" }));
|
||||
}
|
||||
}
|
||||
|
||||
await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch(
|
||||
(err) => {
|
||||
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
|
||||
},
|
||||
);
|
||||
await chmodCredentialsAndAgentState({
|
||||
env,
|
||||
stateDir,
|
||||
cfg: snap.config ?? {},
|
||||
actions,
|
||||
applyPerms,
|
||||
}).catch((err) => {
|
||||
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import os from "node:os";
|
||||
|
||||
import { runExec } from "../process/exec.js";
|
||||
|
||||
export type ExecFn = typeof runExec;
|
||||
|
||||
export type WindowsAclEntry = {
|
||||
principal: string;
|
||||
rights: string[];
|
||||
rawRights: string;
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export type WindowsAclSummary = {
|
||||
ok: boolean;
|
||||
entries: WindowsAclEntry[];
|
||||
untrustedWorld: WindowsAclEntry[];
|
||||
untrustedGroup: WindowsAclEntry[];
|
||||
trusted: WindowsAclEntry[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
|
||||
const WORLD_PRINCIPALS = new Set([
|
||||
"everyone",
|
||||
"users",
|
||||
"builtin\\users",
|
||||
"authenticated users",
|
||||
"nt authority\\authenticated users",
|
||||
]);
|
||||
const TRUSTED_BASE = new Set([
|
||||
"nt authority\\system",
|
||||
"system",
|
||||
"builtin\\administrators",
|
||||
"creator owner",
|
||||
]);
|
||||
const WORLD_SUFFIXES = ["\\users", "\\authenticated users"];
|
||||
const TRUSTED_SUFFIXES = ["\\administrators", "\\system"];
|
||||
|
||||
const normalize = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null {
|
||||
const username = env?.USERNAME?.trim() || os.userInfo().username?.trim();
|
||||
if (!username) return null;
|
||||
const domain = env?.USERDOMAIN?.trim();
|
||||
return domain ? `${domain}\\${username}` : username;
|
||||
}
|
||||
|
||||
function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set<string> {
|
||||
const trusted = new Set<string>(TRUSTED_BASE);
|
||||
const principal = resolveWindowsUserPrincipal(env);
|
||||
if (principal) {
|
||||
trusted.add(normalize(principal));
|
||||
const parts = principal.split("\\");
|
||||
const userOnly = parts.at(-1);
|
||||
if (userOnly) trusted.add(normalize(userOnly));
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
function classifyPrincipal(
|
||||
principal: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): "trusted" | "world" | "group" {
|
||||
const normalized = normalize(principal);
|
||||
const trusted = buildTrustedPrincipals(env);
|
||||
if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s)))
|
||||
return "trusted";
|
||||
if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s)))
|
||||
return "world";
|
||||
return "group";
|
||||
}
|
||||
|
||||
function rightsFromTokens(tokens: string[]): { canRead: boolean; canWrite: boolean } {
|
||||
const upper = tokens.join("").toUpperCase();
|
||||
const canWrite =
|
||||
upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D");
|
||||
const canRead = upper.includes("F") || upper.includes("M") || upper.includes("R");
|
||||
return { canRead, canWrite };
|
||||
}
|
||||
|
||||
export function parseIcaclsOutput(output: string, targetPath: string): WindowsAclEntry[] {
|
||||
const entries: WindowsAclEntry[] = [];
|
||||
const normalizedTarget = targetPath.trim();
|
||||
const lowerTarget = normalizedTarget.toLowerCase();
|
||||
const quotedTarget = `"${normalizedTarget}"`;
|
||||
const quotedLower = quotedTarget.toLowerCase();
|
||||
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.trim()) continue;
|
||||
const trimmed = line.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (
|
||||
lower.startsWith("successfully processed") ||
|
||||
lower.startsWith("processed") ||
|
||||
lower.startsWith("failed processing") ||
|
||||
lower.startsWith("no mapping between account names")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = trimmed;
|
||||
if (lower.startsWith(lowerTarget)) {
|
||||
entry = trimmed.slice(normalizedTarget.length).trim();
|
||||
} else if (lower.startsWith(quotedLower)) {
|
||||
entry = trimmed.slice(quotedTarget.length).trim();
|
||||
}
|
||||
if (!entry) continue;
|
||||
|
||||
const idx = entry.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
|
||||
const principal = entry.slice(0, idx).trim();
|
||||
const rawRights = entry.slice(idx + 1).trim();
|
||||
const tokens =
|
||||
rawRights
|
||||
.match(/\(([^)]+)\)/g)
|
||||
?.map((token) => token.slice(1, -1).trim())
|
||||
.filter(Boolean) ?? [];
|
||||
if (tokens.some((token) => token.toUpperCase() === "DENY")) continue;
|
||||
const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
|
||||
if (rights.length === 0) continue;
|
||||
const { canRead, canWrite } = rightsFromTokens(rights);
|
||||
entries.push({ principal, rights, rawRights, canRead, canWrite });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function summarizeWindowsAcl(
|
||||
entries: WindowsAclEntry[],
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Pick<WindowsAclSummary, "trusted" | "untrustedWorld" | "untrustedGroup"> {
|
||||
const trusted: WindowsAclEntry[] = [];
|
||||
const untrustedWorld: WindowsAclEntry[] = [];
|
||||
const untrustedGroup: WindowsAclEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const classification = classifyPrincipal(entry.principal, env);
|
||||
if (classification === "trusted") trusted.push(entry);
|
||||
else if (classification === "world") untrustedWorld.push(entry);
|
||||
else untrustedGroup.push(entry);
|
||||
}
|
||||
return { trusted, untrustedWorld, untrustedGroup };
|
||||
}
|
||||
|
||||
export async function inspectWindowsAcl(
|
||||
targetPath: string,
|
||||
opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn },
|
||||
): Promise<WindowsAclSummary> {
|
||||
const exec = opts?.exec ?? runExec;
|
||||
try {
|
||||
const { stdout, stderr } = await exec("icacls", [targetPath]);
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
const entries = parseIcaclsOutput(output, targetPath);
|
||||
const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env);
|
||||
return { ok: true, entries, trusted, untrustedWorld, untrustedGroup };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
entries: [],
|
||||
trusted: [],
|
||||
untrustedWorld: [],
|
||||
untrustedGroup: [],
|
||||
error: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWindowsAclSummary(summary: WindowsAclSummary): string {
|
||||
if (!summary.ok) return "unknown";
|
||||
const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup];
|
||||
if (untrusted.length === 0) return "trusted-only";
|
||||
return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", ");
|
||||
}
|
||||
|
||||
export function formatIcaclsResetCommand(
|
||||
targetPath: string,
|
||||
opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
|
||||
): string {
|
||||
const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%";
|
||||
const grant = opts.isDir ? "(OI)(CI)F" : "F";
|
||||
return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`;
|
||||
}
|
||||
|
||||
export function createIcaclsResetCommand(
|
||||
targetPath: string,
|
||||
opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
|
||||
): { command: string; args: string[]; display: string } | null {
|
||||
const user = resolveWindowsUserPrincipal(opts.env);
|
||||
if (!user) return null;
|
||||
const grant = opts.isDir ? "(OI)(CI)F" : "F";
|
||||
const args = [
|
||||
targetPath,
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
`${user}:${grant}`,
|
||||
"/grant:r",
|
||||
`SYSTEM:${grant}`,
|
||||
];
|
||||
return { command: "icacls", args, display: formatIcaclsResetCommand(targetPath, opts) };
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Store original fetch
|
||||
const originalFetch = globalThis.fetch;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("fetchWithSlackAuth", () => {
|
||||
beforeEach(() => {
|
||||
// Create a new mock for each test
|
||||
mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original fetch
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("sends Authorization header on initial request with manual redirect", async () => {
|
||||
// Import after mocking fetch
|
||||
const { fetchWithSlackAuth } = await import("./media.js");
|
||||
|
||||
// Simulate direct 200 response (no redirect)
|
||||
const mockResponse = new Response(Buffer.from("image data"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
|
||||
|
||||
expect(result).toBe(mockResponse);
|
||||
|
||||
// Verify fetch was called with correct params
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://files.slack.com/test.jpg", {
|
||||
headers: { Authorization: "Bearer xoxb-test-token" },
|
||||
redirect: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("follows redirects without Authorization header", async () => {
|
||||
const { fetchWithSlackAuth } = await import("./media.js");
|
||||
|
||||
// First call: redirect response from Slack
|
||||
const redirectResponse = new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.slack-edge.com/presigned-url?sig=abc123" },
|
||||
});
|
||||
|
||||
// Second call: actual file content from CDN
|
||||
const fileResponse = new Response(Buffer.from("actual image data"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
|
||||
|
||||
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
|
||||
|
||||
expect(result).toBe(fileResponse);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should have Authorization header and manual redirect
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(1, "https://files.slack.com/test.jpg", {
|
||||
headers: { Authorization: "Bearer xoxb-test-token" },
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
// Second call should follow the redirect without Authorization
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://cdn.slack-edge.com/presigned-url?sig=abc123",
|
||||
{ redirect: "follow" },
|
||||
);
|
||||
});
|
||||
|
||||
it("handles relative redirect URLs", async () => {
|
||||
const { fetchWithSlackAuth } = await import("./media.js");
|
||||
|
||||
// Redirect with relative URL
|
||||
const redirectResponse = new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "/files/redirect-target" },
|
||||
});
|
||||
|
||||
const fileResponse = new Response(Buffer.from("image data"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
|
||||
|
||||
await fetchWithSlackAuth("https://files.slack.com/original.jpg", "xoxb-test-token");
|
||||
|
||||
// Second call should resolve the relative URL against the original
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(2, "https://files.slack.com/files/redirect-target", {
|
||||
redirect: "follow",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns redirect response when no location header is provided", async () => {
|
||||
const { fetchWithSlackAuth } = await import("./media.js");
|
||||
|
||||
// Redirect without location header
|
||||
const redirectResponse = new Response(null, {
|
||||
status: 302,
|
||||
// No location header
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(redirectResponse);
|
||||
|
||||
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
|
||||
|
||||
// Should return the redirect response directly
|
||||
expect(result).toBe(redirectResponse);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns 4xx/5xx responses directly without following", async () => {
|
||||
const { fetchWithSlackAuth } = await import("./media.js");
|
||||
|
||||
const errorResponse = new Response("Not Found", {
|
||||
status: 404,
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(errorResponse);
|
||||
|
||||
const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
|
||||
|
||||
expect(result).toBe(errorResponse);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles 301 permanent redirects", async () => {
|
||||
const { fetchWithSlackAuth } = await import("./media.js");
|
||||
|
||||
const redirectResponse = new Response(null, {
|
||||
status: 301,
|
||||
headers: { location: "https://cdn.slack.com/new-url" },
|
||||
});
|
||||
|
||||
const fileResponse = new Response(Buffer.from("image data"), {
|
||||
status: 200,
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
|
||||
|
||||
await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(2, "https://cdn.slack.com/new-url", {
|
||||
redirect: "follow",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSlackMedia", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("prefers url_private_download over url_private", async () => {
|
||||
// Mock the store module
|
||||
vi.doMock("../../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
path: "/tmp/test.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveSlackMedia } = await import("./media.js");
|
||||
|
||||
const mockResponse = new Response(Buffer.from("image data"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await resolveSlackMedia({
|
||||
files: [
|
||||
{
|
||||
url_private: "https://files.slack.com/private.jpg",
|
||||
url_private_download: "https://files.slack.com/download.jpg",
|
||||
name: "test.jpg",
|
||||
},
|
||||
],
|
||||
token: "xoxb-test-token",
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://files.slack.com/download.jpg",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when download fails", async () => {
|
||||
const { resolveSlackMedia } = await import("./media.js");
|
||||
|
||||
// Simulate a network error
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const result = await resolveSlackMedia({
|
||||
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
|
||||
token: "xoxb-test-token",
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no files are provided", async () => {
|
||||
const { resolveSlackMedia } = await import("./media.js");
|
||||
|
||||
const result = await resolveSlackMedia({
|
||||
files: [],
|
||||
token: "xoxb-test-token",
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("skips files without url_private", async () => {
|
||||
const { resolveSlackMedia } = await import("./media.js");
|
||||
|
||||
const result = await resolveSlackMedia({
|
||||
files: [{ name: "test.jpg" }], // No url_private
|
||||
token: "xoxb-test-token",
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to next file when first file returns error", async () => {
|
||||
// Mock the store module
|
||||
vi.doMock("../../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
path: "/tmp/test.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveSlackMedia } = await import("./media.js");
|
||||
|
||||
// First file: 404
|
||||
const errorResponse = new Response("Not Found", { status: 404 });
|
||||
// Second file: success
|
||||
const successResponse = new Response(Buffer.from("image data"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse);
|
||||
|
||||
const result = await resolveSlackMedia({
|
||||
files: [
|
||||
{ url_private: "https://files.slack.com/first.jpg", name: "first.jpg" },
|
||||
{ url_private: "https://files.slack.com/second.jpg", name: "second.jpg" },
|
||||
],
|
||||
token: "xoxb-test-token",
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,38 @@ import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import type { SlackFile } from "../types.js";
|
||||
|
||||
/**
|
||||
* Fetches a URL with Authorization header, handling cross-origin redirects.
|
||||
* Node.js fetch strips Authorization headers on cross-origin redirects for security.
|
||||
* Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that
|
||||
* don't need the Authorization header, so we handle the initial auth request manually.
|
||||
*/
|
||||
export async function fetchWithSlackAuth(url: string, token: string): Promise<Response> {
|
||||
// Initial request with auth and manual redirect handling
|
||||
const initialRes = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
// If not a redirect, return the response directly
|
||||
if (initialRes.status < 300 || initialRes.status >= 400) {
|
||||
return initialRes;
|
||||
}
|
||||
|
||||
// Handle redirect - the redirected URL should be pre-signed and not need auth
|
||||
const redirectUrl = initialRes.headers.get("location");
|
||||
if (!redirectUrl) {
|
||||
return initialRes;
|
||||
}
|
||||
|
||||
// Resolve relative URLs against the original
|
||||
const resolvedUrl = new URL(redirectUrl, url).toString();
|
||||
|
||||
// Follow the redirect without the Authorization header
|
||||
// (Slack's CDN URLs are pre-signed and don't need it)
|
||||
return fetch(resolvedUrl, { redirect: "follow" });
|
||||
}
|
||||
|
||||
export async function resolveSlackMedia(params: {
|
||||
files?: SlackFile[];
|
||||
token: string;
|
||||
@@ -19,10 +51,12 @@ export async function resolveSlackMedia(params: {
|
||||
const url = file.url_private_download ?? file.url_private;
|
||||
if (!url) continue;
|
||||
try {
|
||||
const fetchImpl: FetchLike = (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${params.token}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
// Note: We ignore init options because fetchWithSlackAuth handles
|
||||
// redirect behavior specially. fetchRemoteMedia only passes the URL.
|
||||
const fetchImpl: FetchLike = (input) => {
|
||||
const inputUrl =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return fetchWithSlackAuth(inputUrl, params.token);
|
||||
};
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||
|
||||
const getPluginCommandSpecs = vi.hoisted(() => vi.fn());
|
||||
const matchPluginCommand = vi.hoisted(() => vi.fn());
|
||||
const executePluginCommand = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../plugins/commands.js", () => ({
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
executePluginCommand,
|
||||
}));
|
||||
|
||||
const deliverReplies = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock("./bot/delivery.js", () => ({ deliverReplies }));
|
||||
|
||||
vi.mock("./pairing-store.js", () => ({
|
||||
readTelegramAllowFromStore: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("registerTelegramNativeCommands (plugin auth)", () => {
|
||||
it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
|
||||
const command = {
|
||||
name: "plugin",
|
||||
description: "Plugin command",
|
||||
requireAuth: false,
|
||||
handler: vi.fn(),
|
||||
} as const;
|
||||
|
||||
getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]);
|
||||
matchPluginCommand.mockReturnValue({ command, args: undefined });
|
||||
executePluginCommand.mockResolvedValue({ text: "ok" });
|
||||
|
||||
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
|
||||
const bot = {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
|
||||
handlers[name] = handler;
|
||||
},
|
||||
} as const;
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const telegramCfg = {} as TelegramAccountConfig;
|
||||
const resolveGroupPolicy = () =>
|
||||
({
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
}) as ChannelGroupPolicy;
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
cfg,
|
||||
runtime: {} as RuntimeEnv,
|
||||
accountId: "default",
|
||||
telegramCfg,
|
||||
allowFrom: ["999"],
|
||||
groupAllowFrom: [],
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
useAccessGroups: false,
|
||||
nativeEnabled: false,
|
||||
nativeSkillsEnabled: false,
|
||||
nativeDisabledExplicit: false,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: undefined,
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
shouldSkipUpdate: () => false,
|
||||
opts: { token: "token" },
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 111, username: "nope" },
|
||||
message_id: 10,
|
||||
date: 123456,
|
||||
},
|
||||
match: "",
|
||||
};
|
||||
|
||||
await handlers.plugin?.(ctx);
|
||||
|
||||
expect(matchPluginCommand).toHaveBeenCalled();
|
||||
expect(executePluginCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isAuthorizedSender: false,
|
||||
}),
|
||||
);
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [{ text: "ok" }],
|
||||
}),
|
||||
);
|
||||
expect(bot.api.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,18 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import {
|
||||
normalizeTelegramCommandName,
|
||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||
} from "../config/telegram-custom-commands.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
||||
import {
|
||||
executePluginCommand,
|
||||
getPluginCommandSpecs,
|
||||
matchPluginCommand,
|
||||
} from "../plugins/commands.js";
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type {
|
||||
ReplyToMode,
|
||||
@@ -42,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js";
|
||||
|
||||
type TelegramNativeCommandContext = Context & { match?: string };
|
||||
|
||||
type TelegramCommandAuthResult = {
|
||||
chatId: number;
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
resolvedThreadId?: number;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
commandAuthorized: boolean;
|
||||
};
|
||||
|
||||
type RegisterTelegramNativeCommandsParams = {
|
||||
bot: Bot;
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -65,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = {
|
||||
opts: { token: string };
|
||||
};
|
||||
|
||||
async function resolveTelegramCommandAuth(params: {
|
||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||
bot: Bot;
|
||||
cfg: ClawdbotConfig;
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
useAccessGroups: boolean;
|
||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
requireAuth: boolean;
|
||||
}): Promise<TelegramCommandAuthResult | null> {
|
||||
const {
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth,
|
||||
} = params;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
const effectiveGroupAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: groupAllowOverride ?? groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
const senderIdRaw = msg.from?.id;
|
||||
const senderId = senderIdRaw ? String(senderIdRaw) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
|
||||
if (isGroup && groupConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This group is disabled.");
|
||||
return null;
|
||||
}
|
||||
if (isGroup && topicConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This topic is disabled.");
|
||||
return null;
|
||||
}
|
||||
if (requireAuth && isGroup && hasGroupAllowOverride) {
|
||||
if (
|
||||
senderIdRaw == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderIdRaw),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && useAccessGroups) {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
|
||||
return null;
|
||||
}
|
||||
if (groupPolicy === "allowlist" && requireAuth) {
|
||||
if (
|
||||
senderIdRaw == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderIdRaw),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
await bot.api.sendMessage(chatId, "This group is not allowed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dmAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (requireAuth && !commandAuthorized) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
};
|
||||
}
|
||||
|
||||
export const registerTelegramNativeCommands = ({
|
||||
bot,
|
||||
cfg,
|
||||
@@ -103,11 +252,50 @@ export const registerTelegramNativeCommands = ({
|
||||
runtime.error?.(danger(issue.message));
|
||||
}
|
||||
const customCommands = customResolution.commands;
|
||||
const pluginCommandSpecs = getPluginCommandSpecs();
|
||||
const pluginCommands: Array<{ command: string; description: string }> = [];
|
||||
const existingCommands = new Set(
|
||||
[
|
||||
...nativeCommands.map((command) => command.name),
|
||||
...customCommands.map((command) => command.command),
|
||||
].map((command) => command.toLowerCase()),
|
||||
);
|
||||
const pluginCommandNames = new Set<string>();
|
||||
for (const spec of pluginCommandSpecs) {
|
||||
const normalized = normalizeTelegramCommandName(spec.name);
|
||||
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
||||
runtime.error?.(
|
||||
danger(
|
||||
`Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const description = spec.description.trim();
|
||||
if (!description) {
|
||||
runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`));
|
||||
continue;
|
||||
}
|
||||
if (existingCommands.has(normalized)) {
|
||||
runtime.error?.(
|
||||
danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pluginCommandNames.has(normalized)) {
|
||||
runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`));
|
||||
continue;
|
||||
}
|
||||
pluginCommandNames.add(normalized);
|
||||
existingCommands.add(normalized);
|
||||
pluginCommands.push({ command: normalized, description });
|
||||
}
|
||||
const allCommands: Array<{ command: string; description: string }> = [
|
||||
...nativeCommands.map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
...pluginCommands,
|
||||
...customCommands,
|
||||
];
|
||||
|
||||
@@ -124,99 +312,30 @@ export const registerTelegramNativeCommands = ({
|
||||
const msg = ctx.message;
|
||||
if (!msg) return;
|
||||
if (shouldSkipUpdate(ctx)) return;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: true,
|
||||
});
|
||||
if (!auth) return;
|
||||
const {
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
const effectiveGroupAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: groupAllowOverride ?? groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
|
||||
if (isGroup && groupConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This group is disabled.");
|
||||
return;
|
||||
}
|
||||
if (isGroup && topicConfig?.enabled === false) {
|
||||
await bot.api.sendMessage(chatId, "This topic is disabled.");
|
||||
return;
|
||||
}
|
||||
if (isGroup && hasGroupAllowOverride) {
|
||||
const senderId = msg.from?.id;
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
if (
|
||||
senderId == null ||
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderId),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && useAccessGroups) {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
const senderId = msg.from?.id;
|
||||
if (senderId == null) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
if (
|
||||
!isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId: String(senderId),
|
||||
senderUsername,
|
||||
})
|
||||
) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
await bot.api.sendMessage(chatId, "This group is not allowed.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const dmAllow = normalizeAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
storeAllowFrom,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
resolvedThreadId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
if (!commandAuthorized) {
|
||||
await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
|
||||
return;
|
||||
}
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
} = auth;
|
||||
|
||||
const commandDefinition = findCommandByNativeName(command.name, "telegram");
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
@@ -362,6 +481,66 @@ export const registerTelegramNativeCommands = ({
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const pluginCommand of pluginCommands) {
|
||||
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
|
||||
const msg = ctx.message;
|
||||
if (!msg) return;
|
||||
if (shouldSkipUpdate(ctx)) return;
|
||||
const chatId = msg.chat.id;
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
|
||||
const match = matchPluginCommand(commandBody);
|
||||
if (!match) {
|
||||
await bot.api.sendMessage(chatId, "Command not found.");
|
||||
return;
|
||||
}
|
||||
const auth = await resolveTelegramCommandAuth({
|
||||
msg,
|
||||
bot,
|
||||
cfg,
|
||||
telegramCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
resolveGroupPolicy,
|
||||
resolveTelegramGroupConfig,
|
||||
requireAuth: match.command.requireAuth !== false,
|
||||
});
|
||||
if (!auth) return;
|
||||
const { resolvedThreadId, senderId, commandAuthorized } = auth;
|
||||
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId,
|
||||
channel: "telegram",
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
commandBody,
|
||||
config: cfg,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "telegram", accountId);
|
||||
|
||||
await deliverReplies({
|
||||
replies: [result],
|
||||
chatId: String(chatId),
|
||||
token: opts.token,
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
messageThreadId: resolvedThreadId,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (nativeDisabledExplicit) {
|
||||
bot.api.setMyCommands([]).catch((err) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isGifMedia } from "../../media/mime.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
||||
import type { TelegramContext } from "./types.js";
|
||||
@@ -80,9 +81,17 @@ export async function deliverReplies(params: {
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: Array<Array<{ text: string; callback_data: string }>> }
|
||||
| undefined;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
if (mediaList.length === 0) {
|
||||
const chunks = chunkText(reply.text || "");
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
if (!chunk) continue;
|
||||
// Only attach buttons to the first chunk.
|
||||
const shouldAttachButtons = i === 0 && replyMarkup;
|
||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||
replyToMessageId:
|
||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
|
||||
@@ -90,6 +99,7 @@ export async function deliverReplies(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -125,10 +135,12 @@ export async function deliverReplies(params: {
|
||||
first = false;
|
||||
const replyToMessageId =
|
||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
|
||||
const mediaParams: Record<string, unknown> = {
|
||||
caption: htmlCaption,
|
||||
reply_to_message_id: replyToMessageId,
|
||||
...(htmlCaption ? { parse_mode: "HTML" } : {}),
|
||||
...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
|
||||
};
|
||||
if (threadParams) {
|
||||
mediaParams.message_thread_id = threadParams.message_thread_id;
|
||||
@@ -183,6 +195,7 @@ export async function deliverReplies(params: {
|
||||
hasReplied,
|
||||
messageThreadId,
|
||||
linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
// Skip this media item; continue with next.
|
||||
continue;
|
||||
@@ -207,7 +220,8 @@ export async function deliverReplies(params: {
|
||||
// Chunk it in case it's extremely long (same logic as text-only replies).
|
||||
if (pendingFollowUpText && isFirstMedia) {
|
||||
const chunks = chunkText(pendingFollowUpText);
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
const replyToMessageIdFollowup =
|
||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||
@@ -216,6 +230,7 @@ export async function deliverReplies(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
replyMarkup: i === 0 ? replyMarkup : undefined,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -277,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
hasReplied: boolean;
|
||||
messageThreadId?: number;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
}): Promise<boolean> {
|
||||
const chunks = opts.chunkText(opts.text);
|
||||
let hasReplied = opts.hasReplied;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId:
|
||||
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
|
||||
@@ -288,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (opts.replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -322,6 +340,7 @@ async function sendTelegramText(
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number | undefined> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
@@ -337,6 +356,7 @@ async function sendTelegramText(
|
||||
const res = await bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...baseParams,
|
||||
});
|
||||
return res.message_id;
|
||||
@@ -347,6 +367,7 @@ async function sendTelegramText(
|
||||
const fallbackText = opts?.plainText ?? text;
|
||||
const res = await bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...baseParams,
|
||||
});
|
||||
return res.message_id;
|
||||
|
||||
@@ -93,11 +93,6 @@ export async function configureGatewayForOnboarding(
|
||||
: ((await prompter.select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Not recommended unless you fully trust local processes",
|
||||
},
|
||||
{
|
||||
value: "token",
|
||||
label: "Token",
|
||||
@@ -165,7 +160,6 @@ export async function configureGatewayForOnboarding(
|
||||
|
||||
// Safety + constraints:
|
||||
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
|
||||
// - Auth off only allowed for bind=loopback.
|
||||
// - Funnel requires password auth.
|
||||
if (tailscaleMode !== "off" && bind !== "loopback") {
|
||||
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
|
||||
@@ -173,11 +167,6 @@ export async function configureGatewayForOnboarding(
|
||||
customBindHost = undefined;
|
||||
}
|
||||
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
await prompter.note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
||||
authMode = "token";
|
||||
}
|
||||
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
await prompter.note("Tailscale funnel requires password auth.", "Note");
|
||||
authMode = "password";
|
||||
|
||||
@@ -51,12 +51,26 @@ async function requireRiskAcknowledgement(params: {
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Please read: https://docs.clawd.bot/security",
|
||||
"Security warning — please read.",
|
||||
"",
|
||||
"Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).",
|
||||
"Clawdbot is a hobby project and still in beta. Expect sharp edges.",
|
||||
"This bot can read files and run actions if tools are enabled.",
|
||||
"A bad prompt can trick it into doing unsafe things.",
|
||||
"",
|
||||
"If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.",
|
||||
"Learn more: https://docs.clawd.bot/sandboxing",
|
||||
"If you’re not comfortable with basic security and access control, don’t run Clawdbot.",
|
||||
"Ask someone experienced to help before enabling tools or exposing it to the internet.",
|
||||
"",
|
||||
"Recommended baseline:",
|
||||
"- Pairing/allowlists + mention gating.",
|
||||
"- Sandbox + least-privilege tools.",
|
||||
"- Keep secrets out of the agent’s reachable filesystem.",
|
||||
"- Use the strongest available model for any bot with tools or untrusted inboxes.",
|
||||
"",
|
||||
"Run regularly:",
|
||||
"clawdbot security audit --deep",
|
||||
"clawdbot security audit --fix",
|
||||
"",
|
||||
"Must read: https://docs.clawd.bot/gateway/security",
|
||||
].join("\n"),
|
||||
"Security",
|
||||
);
|
||||
@@ -230,7 +244,6 @@ export async function runOnboardingWizard(
|
||||
return "Auto";
|
||||
};
|
||||
const formatAuth = (value: GatewayAuthChoice) => {
|
||||
if (value === "off") return "Off (loopback only)";
|
||||
if (value === "token") return "Token (default)";
|
||||
return "Password";
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user