Merge branch 'main' into tobias-sync

This commit is contained in:
Peter Steinberger
2026-01-09 13:42:34 +01:00
436 changed files with 27171 additions and 5489 deletions
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
} from "./auth-health.js";
describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000;
afterEach(() => {
vi.restoreAllMocks();
});
it("classifies OAuth and API key profiles", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const store = {
version: 1,
profiles: {
"anthropic:ok": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now + DEFAULT_OAUTH_WARN_MS + 60_000,
},
"anthropic:expiring": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now + 10_000,
},
"anthropic:expired": {
type: "oauth" as const,
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: now - 10_000,
},
"anthropic:api": {
type: "api_key" as const,
provider: "anthropic",
key: "sk-ant-api",
},
},
};
const summary = buildAuthHealthSummary({
store,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const statuses = Object.fromEntries(
summary.profiles.map((profile) => [profile.profileId, profile.status]),
);
expect(statuses["anthropic:ok"]).toBe("ok");
expect(statuses["anthropic:expiring"]).toBe("expiring");
expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find(
(entry) => entry.provider === "anthropic",
);
expect(provider?.status).toBe("expired");
});
});
+262
View File
@@ -0,0 +1,262 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
resolveAuthProfileDisplayLabel,
} from "./auth-profiles.js";
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProfileHealth = {
profileId: string;
provider: string;
type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
expiresAt?: number;
remainingMs?: number;
source: AuthProfileSource;
label: string;
};
export type AuthProviderHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProviderHealth = {
provider: string;
status: AuthProviderHealthStatus;
expiresAt?: number;
remainingMs?: number;
profiles: AuthProfileHealth[];
};
export type AuthHealthSummary = {
now: number;
warnAfterMs: number;
profiles: AuthProfileHealth[];
providers: AuthProviderHealth[];
};
export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
export function resolveAuthProfileSource(profileId: string): AuthProfileSource {
if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli";
if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli";
return "store";
}
export function formatRemainingShort(remainingMs?: number): string {
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown";
if (remainingMs <= 0) return "0m";
const minutes = Math.max(1, Math.round(remainingMs / 60_000));
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
function resolveOAuthStatus(
expiresAt: number | undefined,
now: number,
warnAfterMs: number,
): { status: AuthProfileHealthStatus; remainingMs?: number } {
if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) {
return { status: "missing" };
}
const remainingMs = expiresAt - now;
if (remainingMs <= 0) {
return { status: "expired", remainingMs };
}
if (remainingMs <= warnAfterMs) {
return { status: "expiring", remainingMs };
}
return { status: "ok", remainingMs };
}
function buildProfileHealth(params: {
profileId: string;
credential: AuthProfileCredential;
store: AuthProfileStore;
cfg?: ClawdbotConfig;
now: number;
warnAfterMs: number;
}): AuthProfileHealth {
const { profileId, credential, store, cfg, now, warnAfterMs } = params;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const source = resolveAuthProfileSource(profileId);
if (credential.type === "api_key") {
return {
profileId,
provider: credential.provider,
type: "api_key",
status: "static",
source,
label,
};
}
if (credential.type === "token") {
const expiresAt =
typeof credential.expires === "number" &&
Number.isFinite(credential.expires)
? credential.expires
: undefined;
if (!expiresAt || expiresAt <= 0) {
return {
profileId,
provider: credential.provider,
type: "token",
status: "static",
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
expiresAt,
now,
warnAfterMs,
);
return {
profileId,
provider: credential.provider,
type: "token",
status,
expiresAt,
remainingMs,
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
warnAfterMs,
);
return {
profileId,
provider: credential.provider,
type: "oauth",
status,
expiresAt: credential.expires,
remainingMs,
source,
label,
};
}
export function buildAuthHealthSummary(params: {
store: AuthProfileStore;
cfg?: ClawdbotConfig;
warnAfterMs?: number;
providers?: string[];
}): AuthHealthSummary {
const now = Date.now();
const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
const providerFilter = params.providers
? new Set(params.providers.map((p) => p.trim()).filter(Boolean))
: null;
const profiles = Object.entries(params.store.profiles)
.filter(([_, cred]) =>
providerFilter ? providerFilter.has(cred.provider) : true,
)
.map(([profileId, credential]) =>
buildProfileHealth({
profileId,
credential,
store: params.store,
cfg: params.cfg,
now,
warnAfterMs,
}),
)
.sort((a, b) => {
if (a.provider !== b.provider) {
return a.provider.localeCompare(b.provider);
}
return a.profileId.localeCompare(b.profileId);
});
const providersMap = new Map<string, AuthProviderHealth>();
for (const profile of profiles) {
const existing = providersMap.get(profile.provider);
if (!existing) {
providersMap.set(profile.provider, {
provider: profile.provider,
status: "missing",
profiles: [profile],
});
} else {
existing.profiles.push(profile);
}
}
if (providerFilter) {
for (const provider of providerFilter) {
if (!providersMap.has(provider)) {
providersMap.set(provider, {
provider,
status: "missing",
profiles: [],
});
}
}
}
for (const provider of providersMap.values()) {
if (provider.profiles.length === 0) {
provider.status = "missing";
continue;
}
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
const expirable = [...oauthProfiles, ...tokenProfiles];
if (expirable.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue;
}
const expiryCandidates = expirable
.map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) {
provider.expiresAt = Math.min(...expiryCandidates);
provider.remainingMs = provider.expiresAt - now;
}
const statuses = expirable.map((p) => p.status);
if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired";
} else if (statuses.includes("expiring")) {
provider.status = "expiring";
} else {
provider.status = "ok";
}
}
const providers = Array.from(providersMap.values()).sort((a, b) =>
a.provider.localeCompare(b.provider),
);
return { now, warnAfterMs, profiles, providers };
}
+5 -6
View File
@@ -428,7 +428,7 @@ describe("external CLI credential sync", () => {
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("fresh-access-token");
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
@@ -537,7 +537,7 @@ describe("external CLI credential sync", () => {
}
});
it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
it("does not overwrite fresher store token with older Claude CLI credentials", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
);
@@ -567,10 +567,9 @@ describe("external CLI credential sync", () => {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
type: "token",
provider: "anthropic",
access: "store-access",
refresh: "store-refresh",
token: "store-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
@@ -579,7 +578,7 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access");
} finally {
restoreHomeEnv(originalHome);
+159 -28
View File
@@ -1,3 +1,4 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@@ -47,13 +48,29 @@ export type ApiKeyCredential = {
email?: string;
};
export type TokenCredential = {
/**
* Static bearer-style token (often OAuth access token / PAT).
* Not refreshable by clawdbot (unlike `type: "oauth"`).
*/
type: "token";
provider: string;
token: string;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
email?: string;
};
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: OAuthProvider;
email?: string;
};
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
export type AuthProfileCredential =
| ApiKeyCredential
| TokenCredential
| OAuthCredential;
/** Per-profile usage statistics for round-robin and cooldown tracking */
export type ProfileUsageStats = {
@@ -219,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
entries[key] = {
...typed,
provider: typed.provider ?? (key as OAuthProvider),
@@ -237,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
@@ -276,10 +305,23 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
}
/**
* Read Anthropic OAuth credentials from Claude CLI's credential file.
* Claude CLI stores credentials at ~/.claude/.credentials.json
* Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS)
* or credential file (Linux/Windows).
*
* On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
* On Linux/Windows, it uses ~/.claude/.credentials.json
*/
function readClaudeCliCredentials(): OAuthCredential | null {
function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
}): TokenCredential | null {
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) {
log.info("read anthropic credentials from claude cli keychain");
return keychainCreds;
}
}
const credPath = path.join(
resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
@@ -292,22 +334,51 @@ function readClaudeCliCredentials(): OAuthCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
type: "token",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
token: accessToken,
expires: expiresAt,
};
}
/**
* Read Claude Code credentials from macOS keychain.
* Uses the `security` CLI to access keychain without native dependencies.
*/
function readClaudeCliKeychainCredentials(): TokenCredential | null {
try {
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w',
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
const data = JSON.parse(result.trim());
const claudeOauth = data?.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
};
} catch {
return null;
}
}
/**
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
* Codex CLI stores credentials at ~/.codex/auth.json
@@ -367,6 +438,20 @@ function shallowEqualOAuthCredentials(
);
}
function shallowEqualTokenCredentials(
a: TokenCredential | undefined,
b: TokenCredential,
): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
a.provider === b.provider &&
a.token === b.token &&
a.expires === b.expires &&
a.email === b.email
);
}
/**
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
@@ -374,33 +459,39 @@ function shallowEqualOAuthCredentials(
*
* Returns true if any credentials were updated.
*/
function syncExternalCliCredentials(store: AuthProfileStore): boolean {
function syncExternalCliCredentials(
store: AuthProfileStore,
options?: { allowKeychainPrompt?: boolean },
): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude CLI
const claudeCreds = readClaudeCliCredentials();
const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const existingToken = existing?.type === "token" ? existing : undefined;
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCreds.expires > now &&
claudeCreds.expires > existingOAuth.expires);
!existingToken ||
existingToken.provider !== "anthropic" ||
(existingToken.expires ?? 0) <= now ||
((claudeCreds.expires ?? 0) > now &&
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
if (
shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
!shallowEqualTokenCredentials(existingToken, claudeCreds)
) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
expires: new Date(claudeCreds.expires).toISOString(),
expires:
typeof claudeCreds.expires === "number"
? new Date(claudeCreds.expires).toISOString()
: "unknown",
});
}
}
@@ -463,6 +554,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -486,13 +587,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
return store;
}
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore);
const synced = syncExternalCliCredentials(asStore, options);
if (synced) {
saveJsonFile(authPath, asStore);
}
@@ -515,6 +619,16 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -532,7 +646,7 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store);
const syncedCli = syncExternalCliCredentials(store, options);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
@@ -827,16 +941,17 @@ function orderProfilesByMode(
// Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
const typeScore =
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed };
});
// Primary sort: type preference (oauth > api_key).
// Primary sort: type preference (oauth > token > api_key).
// Secondary sort: lastUsed (oldest first for round-robin within type).
const sorted = scored
.sort((a, b) => {
// First by type (oauth > api_key)
// First by type (oauth > token > api_key)
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
// Then by lastUsed (oldest first)
return a.lastUsed - b.lastUsed;
@@ -866,11 +981,27 @@ export async function resolveApiKeyForProfile(params: {
if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (profileConfig && profileConfig.mode !== cred.type) {
// Compatibility: treat "oauth" config as compatible with stored token profiles.
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
}
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
if (cred.type === "token") {
const token = cred.token?.trim();
if (!token) return null;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
Date.now() >= cred.expires
) {
return null;
}
return { apiKey: token, provider: cred.provider, email: cred.email };
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
+2 -1
View File
@@ -10,6 +10,7 @@ import { sanitizeBinaryOutput } from "./shell-utils.js";
const isWin = process.platform === "win32";
const shortDelayCmd = isWin ? "ping -n 2 127.0.0.1 > nul" : "sleep 0.05";
const yieldDelayCmd = isWin ? "ping -n 3 127.0.0.1 > nul" : "sleep 0.2";
const longDelayCmd = isWin ? "ping -n 4 127.0.0.1 > nul" : "sleep 2";
const joinCommands = (commands: string[]) =>
commands.join(isWin ? " & " : "; ");
@@ -51,7 +52,7 @@ beforeEach(() => {
describe("bash tool backgrounding", () => {
it("backgrounds after yield and can be polled", async () => {
const result = await bashTool.execute("call1", {
command: echoAfterDelay("done"),
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
+157
View File
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runClaudeCliAgent } from "./claude-cli-runner.js";
const runCommandWithTimeoutMock = vi.fn();
function createDeferred<T>() {
let resolve: (value: T) => void;
let reject: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: resolve as (value: T) => void,
reject: reject as (error: unknown) => void,
};
}
async function waitForCalls(
mockFn: { mock: { calls: unknown[][] } },
count: number,
) {
for (let i = 0; i < 50; i += 1) {
if (mockFn.mock.calls.length >= count) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error(`Expected ${count} calls, got ${mockFn.mock.calls.length}`);
}
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) =>
runCommandWithTimeoutMock(...args),
}));
describe("runClaudeCliAgent", () => {
beforeEach(() => {
runCommandWithTimeoutMock.mockReset();
});
it("starts a new session with --session-id when none is provided", async () => {
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }),
stderr: "",
code: 0,
signal: null,
killed: false,
});
await runClaudeCliAgent({
sessionId: "clawdbot-session",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
model: "opus",
timeoutMs: 1_000,
runId: "run-1",
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
expect(argv).toContain("claude");
expect(argv).toContain("--session-id");
expect(argv).toContain("hi");
});
it("uses provided --session-id when a claude session id is provided", async () => {
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
stderr: "",
code: 0,
signal: null,
killed: false,
});
await runClaudeCliAgent({
sessionId: "clawdbot-session",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
model: "opus",
timeoutMs: 1_000,
runId: "run-2",
claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b",
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
expect(argv).toContain("--session-id");
expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
expect(argv).toContain("hi");
});
it("serializes concurrent claude-cli runs", async () => {
const firstDeferred = createDeferred<{
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
killed: boolean;
}>();
const secondDeferred = createDeferred<{
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
killed: boolean;
}>();
runCommandWithTimeoutMock
.mockImplementationOnce(() => firstDeferred.promise)
.mockImplementationOnce(() => secondDeferred.promise);
const firstRun = runClaudeCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "first",
model: "opus",
timeoutMs: 1_000,
runId: "run-1",
});
const secondRun = runClaudeCliAgent({
sessionId: "s2",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "second",
model: "opus",
timeoutMs: 1_000,
runId: "run-2",
});
await waitForCalls(runCommandWithTimeoutMock, 1);
firstDeferred.resolve({
stdout: JSON.stringify({ message: "ok", session_id: "sid-1" }),
stderr: "",
code: 0,
signal: null,
killed: false,
});
await waitForCalls(runCommandWithTimeoutMock, 2);
secondDeferred.resolve({
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
stderr: "",
code: 0,
signal: null,
killed: false,
});
await Promise.all([firstRun, secondRun]);
});
});
+394
View File
@@ -0,0 +1,394 @@
import crypto from "node:crypto";
import os from "node:os";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import {
buildBootstrapContextFiles,
type EmbeddedContextFile,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
const log = createSubsystemLogger("agent/claude-cli");
const CLAUDE_CLI_QUEUE_KEY = "global";
const CLAUDE_CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function enqueueClaudeCliRun<T>(
key: string,
task: () => Promise<T>,
): Promise<T> {
const prior = CLAUDE_CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
const tracked = chained.finally(() => {
if (CLAUDE_CLI_RUN_QUEUE.get(key) === tracked) {
CLAUDE_CLI_RUN_QUEUE.delete(key);
}
});
CLAUDE_CLI_RUN_QUEUE.set(key, tracked);
return chained;
}
type ClaudeCliUsage = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
type ClaudeCliOutput = {
text: string;
sessionId?: string;
usage?: ClaudeCliUsage;
};
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeClaudeSessionId(raw?: string): string {
const trimmed = raw?.trim();
if (trimmed && UUID_RE.test(trimmed)) return trimmed;
return crypto.randomUUID();
}
function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(
new Date(),
);
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (
!map.weekday ||
!map.year ||
!map.month ||
!map.day ||
!map.hour ||
!map.minute
) {
return undefined;
}
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
if (!model) continue;
const alias = String(
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
).trim();
if (!alias) continue;
entries.push({ alias, model });
}
return entries
.sort((a, b) => a.alias.localeCompare(b.alias))
.map((entry) => `- ${entry.alias}: ${entry.model}`);
}
function buildSystemPrompt(params: {
workspaceDir: string;
config?: ClawdbotConfig;
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
ownerNumbers?: string[];
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
),
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: params.modelDisplay,
},
toolNames: params.tools.map((tool) => tool.name),
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
contextFiles: params.contextFiles,
});
}
function normalizeClaudeCliModel(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed) return "opus";
const lower = trimmed.toLowerCase();
if (lower.startsWith("opus")) return "opus";
if (lower.startsWith("sonnet")) return "sonnet";
if (lower.startsWith("haiku")) return "haiku";
return trimmed;
}
function toUsage(raw: Record<string, unknown>): ClaudeCliUsage | undefined {
const pick = (key: string) =>
typeof raw[key] === "number" && raw[key] > 0
? (raw[key] as number)
: undefined;
const input = pick("input_tokens") ?? pick("inputTokens");
const output = pick("output_tokens") ?? pick("outputTokens");
const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead");
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
const total = pick("total_tokens") ?? pick("total");
if (!input && !output && !cacheRead && !cacheWrite && !total)
return undefined;
return { input, output, cacheRead, cacheWrite, total };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function collectText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.map((entry) => collectText(entry)).join("");
}
if (!isRecord(value)) return "";
if (typeof value.text === "string") return value.text;
if (typeof value.content === "string") return value.content;
if (Array.isArray(value.content)) {
return value.content.map((entry) => collectText(entry)).join("");
}
if (isRecord(value.message)) return collectText(value.message);
return "";
}
function parseClaudeCliJson(raw: string): ClaudeCliOutput | null {
const trimmed = raw.trim();
if (!trimmed) return null;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
const sessionId =
(typeof parsed.session_id === "string" && parsed.session_id) ||
(typeof parsed.sessionId === "string" && parsed.sessionId) ||
(typeof parsed.conversation_id === "string" && parsed.conversation_id) ||
undefined;
const usage = isRecord(parsed.usage) ? toUsage(parsed.usage) : undefined;
const text =
collectText(parsed.message) ||
collectText(parsed.content) ||
collectText(parsed.result) ||
collectText(parsed);
return { text: text.trim(), sessionId, usage };
}
async function runClaudeCliOnce(params: {
prompt: string;
workspaceDir: string;
modelId: string;
systemPrompt: string;
timeoutMs: number;
sessionId: string;
}): Promise<ClaudeCliOutput> {
const args = [
"-p",
"--output-format",
"json",
"--model",
normalizeClaudeCliModel(params.modelId),
"--append-system-prompt",
params.systemPrompt,
"--dangerously-skip-permissions",
"--session-id",
params.sessionId,
];
args.push(params.prompt);
log.info(
`claude-cli exec: model=${normalizeClaudeCliModel(params.modelId)} promptChars=${params.prompt.length} systemPromptChars=${params.systemPrompt.length}`,
);
if (process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1") {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--append-system-prompt") {
logArgs.push(arg, `<systemPrompt:${params.systemPrompt.length} chars>`);
i += 1;
continue;
}
if (arg === "--session-id") {
logArgs.push(arg, args[i + 1] ?? "");
i += 1;
continue;
}
logArgs.push(arg);
}
const promptIndex = logArgs.indexOf(params.prompt);
if (promptIndex >= 0) {
logArgs[promptIndex] = `<prompt:${params.prompt.length} chars>`;
}
log.info(`claude-cli argv: claude ${logArgs.join(" ")}`);
}
const result = await runCommandWithTimeout(["claude", ...args], {
timeoutMs: params.timeoutMs,
cwd: params.workspaceDir,
env: (() => {
const next = { ...process.env };
delete next.ANTHROPIC_API_KEY;
return next;
})(),
});
if (process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1") {
const stdoutDump = result.stdout.trim();
const stderrDump = result.stderr.trim();
if (stdoutDump) {
log.info(`claude-cli stdout:\n${stdoutDump}`);
}
if (stderrDump) {
log.info(`claude-cli stderr:\n${stderrDump}`);
}
}
const stdout = result.stdout.trim();
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
if (shouldLogVerbose()) {
if (stdout) {
log.debug(`claude-cli stdout:\n${stdout}`);
}
if (result.stderr.trim()) {
log.debug(`claude-cli stderr:\n${result.stderr.trim()}`);
}
}
if (result.code !== 0) {
const err = result.stderr.trim() || stdout || "Claude CLI failed.";
throw new Error(err);
}
const parsed = parseClaudeCliJson(stdout);
const output = parsed ?? { text: stdout };
if (logOutputText) {
const text = output.text?.trim();
if (text) {
log.info(`claude-cli output:\n${text}`);
}
}
return output;
}
export async function runClaudeCliAgent(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config?: ClawdbotConfig;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
claudeSessionId?: string;
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
const modelId = (params.model ?? "opus").trim() || "opus";
const modelDisplay = `${params.provider ?? "claude-cli"}/${modelId}`;
const extraSystemPrompt = [
params.extraSystemPrompt?.trim(),
"Tools are disabled in this session. Do not call tools.",
]
.filter(Boolean)
.join("\n");
const bootstrapFiles = await loadWorkspaceBootstrapFiles(workspaceDir);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
tools: [],
contextFiles,
modelDisplay,
});
const claudeSessionId = normalizeClaudeSessionId(params.claudeSessionId);
const output = await enqueueClaudeCliRun(CLAUDE_CLI_QUEUE_KEY, () =>
runClaudeCliOnce({
prompt: params.prompt,
workspaceDir,
modelId,
systemPrompt,
timeoutMs: params.timeoutMs,
sessionId: claudeSessionId,
}),
);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: output.sessionId ?? claudeSessionId,
provider: params.provider ?? "claude-cli",
model: modelId,
usage: output.usage,
},
},
};
}
+3 -3
View File
@@ -12,9 +12,9 @@ describe("gateway tool", () => {
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "gateway",
);
const tool = createClawdbotTools({
config: { commands: { restart: true } },
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
+6 -9
View File
@@ -4,17 +4,14 @@ import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createDiscordTool } from "./tools/discord-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSlackTool } from "./tools/slack-tool.js";
import { createTelegramTool } from "./tools/telegram-tool.js";
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -34,14 +31,14 @@ export function createClawdbotTools(options?: {
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createSlackTool({
createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,
+36
View File
@@ -100,6 +100,7 @@ export async function resolveApiKeyForProvider(params: {
}
export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys());
@@ -144,6 +145,41 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return pick(envVar);
}
export function resolveModelAuthMode(
provider?: string,
cfg?: ClawdbotConfig,
store?: AuthProfileStore,
): ModelAuthMode | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
const authStore = store ?? ensureAuthProfileStore();
const profiles = listProfilesForProvider(authStore, resolved);
if (profiles.length > 0) {
const modes = new Set(
profiles
.map((id) => authStore.profiles[id]?.type)
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
);
const distinct = ["oauth", "token", "api_key"].filter((k) =>
modes.has(k as "oauth" | "token" | "api_key"),
);
if (distinct.length >= 2) return "mixed";
if (modes.has("oauth")) return "oauth";
if (modes.has("token")) return "token";
if (modes.has("api_key")) return "api-key";
}
const envKey = resolveEnvApiKey(resolved);
if (envKey?.apiKey) {
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
}
if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
return "unknown";
}
export async function getApiKeyForModel(params: {
model: Model<Api>;
cfg?: ClawdbotConfig;
+49 -125
View File
@@ -1,146 +1,70 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
normalizeProviderId,
resolveConfiguredModelRef,
buildAllowedModelSet,
modelKey,
parseModelRef,
} from "./model-selection.js";
describe("resolveConfiguredModelRef", () => {
it("parses provider/model from agent.model.primary", () => {
const cfg = {
agent: { model: { primary: "openai/gpt-4.1-mini" } },
} satisfies ClawdbotConfig;
const catalog = [
{
provider: "openai",
id: "gpt-4",
name: "GPT-4",
},
];
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
});
it("falls back to anthropic when agent.model.primary omits provider", () => {
const cfg = {
agent: { model: { primary: "claude-opus-4-5" } },
} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
});
});
it("falls back to defaults when agent.model is missing", () => {
const cfg = {} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({
provider: DEFAULT_PROVIDER,
model: DEFAULT_MODEL,
});
});
it("resolves agent.model aliases when configured", () => {
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agent: {
model: { primary: "Opus" },
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
"openai/gpt-4": { alias: "gpt4" },
},
},
} satisfies ClawdbotConfig;
} as ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
const allowed = buildAllowedModelSet({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
expect(resolved).toEqual({
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
});
it("includes the default model when no allowlist is set", () => {
const cfg = {
agent: {},
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
});
});
describe("parseModelRef", () => {
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => {
const ref = parseModelRef("anthropic/opus-4.5", "anthropic");
expect(ref).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
});
});
it("normalizes z.ai provider in agent.model", () => {
const cfg = {
agent: { model: "z.ai/glm-4.7" },
} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" });
});
it("normalizes z-ai provider in agent.model", () => {
const cfg = {
agent: { model: "z-ai/glm-4.7" },
} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" });
});
it("normalizes provider casing in agent.model", () => {
const cfg = {
agent: { model: "OpenAI/gpt-4.1-mini" },
} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
});
it("normalizes z.ai casing in agent.model", () => {
const cfg = {
agent: { model: "Z.AI/glm-4.7" },
} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" });
});
});
describe("normalizeProviderId", () => {
it("normalizes z.ai aliases to canonical zai", () => {
expect(normalizeProviderId("z.ai")).toBe("zai");
expect(normalizeProviderId("z-ai")).toBe("zai");
});
it("normalizes provider casing", () => {
expect(normalizeProviderId("OpenAI")).toBe("openai");
expect(normalizeProviderId("Z.AI")).toBe("zai");
});
});
+28 -2
View File
@@ -27,6 +27,15 @@ export function normalizeProviderId(provider: string): string {
return normalized;
}
function normalizeAnthropicModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) return trimmed;
const lower = trimmed.toLowerCase();
if (lower === "opus-4.5") return "claude-opus-4-5";
if (lower === "sonnet-4.5") return "claude-sonnet-4-5";
return trimmed;
}
export function parseModelRef(
raw: string,
defaultProvider: string,
@@ -35,13 +44,18 @@ export function parseModelRef(
if (!trimmed) return null;
const slash = trimmed.indexOf("/");
if (slash === -1) {
return { provider: normalizeProviderId(defaultProvider), model: trimmed };
const provider = normalizeProviderId(defaultProvider);
const model =
provider === "anthropic" ? normalizeAnthropicModelId(trimmed) : trimmed;
return { provider, model };
}
const providerRaw = trimmed.slice(0, slash).trim();
const provider = normalizeProviderId(providerRaw);
const model = trimmed.slice(slash + 1).trim();
if (!provider || !model) return null;
return { provider, model };
const normalizedModel =
provider === "anthropic" ? normalizeAnthropicModelId(model) : model;
return { provider, model: normalizedModel };
}
export function buildModelAliasIndex(params: {
@@ -124,6 +138,7 @@ export function buildAllowedModelSet(params: {
cfg: ClawdbotConfig;
catalog: ModelCatalogEntry[];
defaultProvider: string;
defaultModel?: string;
}): {
allowAny: boolean;
allowedCatalog: ModelCatalogEntry[];
@@ -134,11 +149,17 @@ export function buildAllowedModelSet(params: {
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0;
const defaultModel = params.defaultModel?.trim();
const defaultKey =
defaultModel && params.defaultProvider
? modelKey(params.defaultProvider, defaultModel)
: undefined;
const catalogKeys = new Set(
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),
);
if (allowAny) {
if (defaultKey) catalogKeys.add(defaultKey);
return {
allowAny: true,
allowedCatalog: params.catalog,
@@ -156,11 +177,16 @@ export function buildAllowedModelSet(params: {
}
}
if (defaultKey) {
allowedKeys.add(defaultKey);
}
const allowedCatalog = params.catalog.filter((entry) =>
allowedKeys.has(modelKey(entry.provider, entry.id)),
);
if (allowedCatalog.length === 0) {
if (defaultKey) catalogKeys.add(defaultKey);
return {
allowAny: true,
allowedCatalog: params.catalog,
+72
View File
@@ -8,6 +8,7 @@ import {
isMessagingToolDuplicate,
normalizeTextForComparison,
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
validateGeminiTurns,
} from "./pi-embedded-helpers.js";
import {
@@ -250,6 +251,77 @@ describe("sanitizeGoogleTurnOrdering", () => {
});
});
describe("sanitizeSessionMessagesImages", () => {
it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
});
it("filters whitespace-only assistant text blocks", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: " " },
{ type: "text", text: "ok" },
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok");
});
it("drops assistant messages that only contain empty text", async () => {
const input = [
{ role: "user", content: "hello" },
{ role: "assistant", content: [{ type: "text", text: "" }] },
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
expect(out[0]?.role).toBe("user");
});
it("leaves non-assistant messages unchanged", async () => {
const input = [
{ role: "user", content: "hello" },
{
role: "toolResult",
toolUseId: "tool-1",
content: [{ type: "text", text: "result" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(2);
expect(out[0]?.role).toBe("user");
expect(out[1]?.role).toBe("toolResult");
});
});
describe("normalizeTextForComparison", () => {
it("lowercases text", () => {
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
+22
View File
@@ -99,6 +99,28 @@ export async function sanitizeSessionMessagesImages(
}
}
if (role === "assistant") {
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
const content = assistantMsg.content;
if (Array.isArray(content)) {
const filteredContent = content.filter((block) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type !== "text" || typeof rec.text !== "string") return true;
return rec.text.trim().length > 0;
});
const sanitizedContent = (await sanitizeContentBlocksImages(
filteredContent as unknown as ContentBlock[],
label,
)) as unknown as typeof assistantMsg.content;
if (sanitizedContent.length === 0) {
continue;
}
out.push({ ...assistantMsg, content: sanitizedContent });
continue;
}
}
out.push(msg);
}
return out;
+20 -16
View File
@@ -68,41 +68,45 @@ function createStubTool(name: string): AgentTool {
}
describe("splitSdkTools", () => {
// Tool names are now capitalized (Bash, Read, etc.) to bypass Anthropic OAuth blocking
const tools = [
createStubTool("read"),
createStubTool("bash"),
createStubTool("edit"),
createStubTool("write"),
createStubTool("Read"),
createStubTool("Bash"),
createStubTool("Edit"),
createStubTool("Write"),
createStubTool("browser"),
];
it("routes built-ins to custom tools when sandboxed", () => {
it("routes all tools to customTools when sandboxed", () => {
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: true,
});
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"read",
"bash",
"edit",
"write",
"Read",
"Bash",
"Edit",
"Write",
"browser",
]);
});
it("keeps built-ins as SDK tools when not sandboxed", () => {
it("routes all tools to customTools even when not sandboxed (for OAuth compatibility)", () => {
// All tools are now passed as customTools to bypass pi-coding-agent's
// built-in tool filtering, which expects lowercase names.
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: false,
});
expect(builtInTools.map((tool) => tool.name)).toEqual([
"read",
"bash",
"edit",
"write",
expect(builtInTools).toEqual([]);
expect(customTools.map((tool) => tool.name)).toEqual([
"Read",
"Bash",
"Edit",
"Write",
"browser",
]);
expect(customTools.map((tool) => tool.name)).toEqual(["browser"]);
});
});
+10 -13
View File
@@ -612,7 +612,10 @@ export function createSystemPromptOverride(
return () => trimmed;
}
const BUILT_IN_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
// Tool names are now capitalized (Bash, Read, Write, Edit) to bypass Anthropic's
// OAuth token blocking of lowercase names. However, pi-coding-agent's SDK has
// hardcoded lowercase names in its built-in tool registry, so we must pass ALL
// tools as customTools to bypass the SDK's filtering.
type AnyAgentTool = AgentTool;
@@ -623,19 +626,13 @@ export function splitSdkTools(options: {
builtInTools: AnyAgentTool[];
customTools: ReturnType<typeof toToolDefinitions>;
} {
// SDK rebuilds built-ins from cwd; route sandboxed versions as custom tools.
const { tools, sandboxEnabled } = options;
if (sandboxEnabled) {
return {
builtInTools: [],
customTools: toToolDefinitions(tools),
};
}
// Always pass all tools as customTools to bypass pi-coding-agent's built-in
// tool filtering, which expects lowercase names (bash, read, write, edit).
// Our tools are now capitalized (Bash, Read, Write, Edit) for OAuth compatibility.
const { tools } = options;
return {
builtInTools: tools.filter((tool) => BUILT_IN_TOOL_NAMES.has(tool.name)),
customTools: toToolDefinitions(
tools.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name)),
),
builtInTools: [],
customTools: toToolDefinitions(tools),
};
}
+181
View File
@@ -167,6 +167,117 @@ describe("subscribeEmbeddedPiSession", () => {
);
});
it("promotes <think> tags to thinking blocks at write-time", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
reasoningMode: "on",
});
const assistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
},
],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply.mock.calls[0][0].text).toBe(
"_Reasoning:_\n_Because it helps_\n\nFinal answer",
);
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "Because it helps" },
{ type: "text", text: "Final answer" },
]);
});
it("streams <think> reasoning via onReasoningStream without leaking into final text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onReasoningStream = vi.fn();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
onReasoningStream,
onBlockReply,
blockReplyBreak: "message_end",
reasoningMode: "stream",
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: "<think>\nBecause",
},
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: " it helps\n</think>\n\nFinal answer",
},
});
const assistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
},
],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer");
const streamTexts = onReasoningStream.mock.calls
.map((call) => call[0]?.text)
.filter((value): value is string => typeof value === "string");
expect(streamTexts.at(-1)).toBe("Reasoning:\nBecause it helps");
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "Because it helps" },
{ type: "text", text: "Final answer" },
]);
});
it("emits block replies on text_end and does not duplicate on message_end", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
@@ -1180,6 +1291,76 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1);
});
it("includes browser action metadata in tool summaries", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-browser-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "browser",
toolCallId: "tool-browser-1",
args: { action: "snapshot", targetUrl: "https://example.com" },
});
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🌐");
expect(payload.text).toContain("browser");
expect(payload.text).toContain("snapshot");
expect(payload.text).toContain("https://example.com");
});
it("includes canvas action metadata in tool summaries", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-canvas-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
type: "tool_execution_start",
toolName: "canvas",
toolCallId: "tool-canvas-1",
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
});
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🖼️");
expect(payload.text).toContain("canvas");
expect(payload.text).toContain("A2UI push");
expect(payload.text).toContain("/tmp/a2ui.jsonl");
});
it("skips tool summaries when shouldEmitToolResult is false", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
+137
View File
@@ -1,8 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { resolveStateDir } from "../config/paths.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
@@ -21,8 +24,34 @@ const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi;
const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi;
const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
const TOOL_RESULT_MAX_CHARS = 8000;
const log = createSubsystemLogger("agent/embedded");
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
const RAW_STREAM_PATH =
process.env.CLAWDBOT_RAW_STREAM_PATH?.trim() ||
path.join(resolveStateDir(), "logs", "raw-stream.jsonl");
let rawStreamReady = false;
const appendRawStream = (payload: Record<string, unknown>) => {
if (!RAW_STREAM_ENABLED) return;
if (!rawStreamReady) {
rawStreamReady = true;
try {
fs.mkdirSync(path.dirname(RAW_STREAM_PATH), { recursive: true });
} catch {
// ignore raw stream mkdir failures
}
}
try {
void fs.promises.appendFile(
RAW_STREAM_PATH,
`${JSON.stringify(payload)}\n`,
);
} catch {
// ignore raw stream write failures
}
};
export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
@@ -93,6 +122,96 @@ function stripUnpairedThinkingTags(text: string): string {
return text;
}
type ThinkTaggedSplitBlock =
| { type: "thinking"; thinking: string }
| { type: "text"; text: string };
function splitThinkingTaggedText(text: string): ThinkTaggedSplitBlock[] | null {
const trimmedStart = text.trimStart();
// Avoid false positives: only treat it as structured thinking when it begins
// with a think tag (common for local/OpenAI-compat providers that emulate
// reasoning blocks via tags).
if (!trimmedStart.startsWith("<")) return null;
if (!THINKING_OPEN_RE.test(trimmedStart)) return null;
if (!THINKING_CLOSE_RE.test(text)) return null;
THINKING_TAG_SCAN_RE.lastIndex = 0;
let inThinking = false;
let cursor = 0;
let thinkingStart = 0;
const blocks: ThinkTaggedSplitBlock[] = [];
const pushText = (value: string) => {
if (!value) return;
blocks.push({ type: "text", text: value });
};
const pushThinking = (value: string) => {
const cleaned = value.trim();
if (!cleaned) return;
blocks.push({ type: "thinking", thinking: cleaned });
};
for (const match of text.matchAll(THINKING_TAG_SCAN_RE)) {
const index = match.index ?? 0;
const isClose = Boolean(match[1]?.includes("/"));
if (!inThinking && !isClose) {
pushText(text.slice(cursor, index));
thinkingStart = index + match[0].length;
inThinking = true;
continue;
}
if (inThinking && isClose) {
pushThinking(text.slice(thinkingStart, index));
cursor = index + match[0].length;
inThinking = false;
}
}
if (inThinking) return null;
pushText(text.slice(cursor));
const hasThinking = blocks.some((b) => b.type === "thinking");
if (!hasThinking) return null;
return blocks;
}
function promoteThinkingTagsToBlocks(message: AssistantMessage): void {
if (!Array.isArray(message.content)) return;
const hasThinkingBlock = message.content.some(
(block) => block.type === "thinking",
);
if (hasThinkingBlock) return;
const next: AssistantMessage["content"] = [];
let changed = false;
for (const block of message.content) {
if (block.type !== "text") {
next.push(block);
continue;
}
const split = splitThinkingTaggedText(block.text);
if (!split) {
next.push(block);
continue;
}
changed = true;
for (const part of split) {
if (part.type === "thinking") {
next.push({ type: "thinking", thinking: part.thinking });
} else if (part.type === "text") {
const cleaned = part.text.trimStart();
if (cleaned) next.push({ type: "text", text: cleaned });
}
}
}
if (!changed) return;
message.content = next;
}
function normalizeSlackTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
@@ -664,6 +783,15 @@ export function subscribeEmbeddedPiSession(params: {
typeof assistantRecord?.content === "string"
? assistantRecord.content
: "";
appendRawStream({
ts: Date.now(),
event: "assistant_text_stream",
runId: params.runId,
sessionId: (params.session as { id?: string }).id,
evtType,
delta,
content,
});
let chunk = "";
if (evtType === "text_delta") {
chunk = delta;
@@ -755,7 +883,16 @@ export function subscribeEmbeddedPiSession(params: {
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
if (msg?.role === "assistant") {
const assistantMessage = msg as AssistantMessage;
promoteThinkingTagsToBlocks(assistantMessage);
const rawText = extractAssistantText(assistantMessage);
appendRawStream({
ts: Date.now(),
event: "assistant_message_end",
runId: params.runId,
sessionId: (params.session as { id?: string }).id,
rawText,
rawThinking: extractAssistantThinking(assistantMessage),
});
const cleaned = params.enforceFinalTag
? stripThinkingSegments(stripUnpairedThinkingTags(rawText))
: stripThinkingSegments(rawText);
@@ -313,12 +313,12 @@ describe("context-pruning", () => {
makeUser("u1"),
makeToolResult({
toolCallId: "t1",
toolName: "bash",
toolName: "Bash",
text: "x".repeat(20_000),
}),
makeToolResult({
toolCallId: "t2",
toolName: "browser",
toolName: "Browser",
text: "y".repeat(20_000),
}),
];
@@ -2,7 +2,13 @@ import type { ContextPruningToolMatch } from "./settings.js";
function normalizePatterns(patterns?: string[]): string[] {
if (!Array.isArray(patterns)) return [];
return patterns.map((p) => String(p ?? "").trim()).filter(Boolean);
return patterns
.map((p) =>
String(p ?? "")
.trim()
.toLowerCase(),
)
.filter(Boolean);
}
type CompiledPattern =
@@ -39,8 +45,9 @@ export function makeToolPrunablePredicate(
const allow = compilePatterns(match.allow);
return (toolName: string) => {
if (matchesAny(toolName, deny)) return false;
const normalized = toolName.trim().toLowerCase();
if (matchesAny(normalized, deny)) return false;
if (allow.length === 0) return true;
return matchesAny(toolName, allow);
return matchesAny(normalized, allow);
};
}
+19 -19
View File
@@ -29,9 +29,9 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).toContain("write");
expect(toolNames).not.toContain("bash");
expect(toolNames).toContain("Read");
expect(toolNames).toContain("Write");
expect(toolNames).not.toContain("Bash");
});
it("should apply agent-specific tool policy", () => {
@@ -63,10 +63,10 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("edit");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("Write");
expect(toolNames).not.toContain("Edit");
});
it("should allow different tool policies for different agents", () => {
@@ -96,9 +96,9 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-main",
});
const mainToolNames = mainTools.map((t) => t.name);
expect(mainToolNames).toContain("bash");
expect(mainToolNames).toContain("write");
expect(mainToolNames).toContain("edit");
expect(mainToolNames).toContain("Bash");
expect(mainToolNames).toContain("Write");
expect(mainToolNames).toContain("Edit");
// family agent: restricted
const familyTools = createClawdbotCodingTools({
@@ -108,10 +108,10 @@ describe("Agent-specific tool filtering", () => {
agentDir: "/tmp/agent-family",
});
const familyToolNames = familyTools.map((t) => t.name);
expect(familyToolNames).toContain("read");
expect(familyToolNames).not.toContain("bash");
expect(familyToolNames).not.toContain("write");
expect(familyToolNames).not.toContain("edit");
expect(familyToolNames).toContain("Read");
expect(familyToolNames).not.toContain("Bash");
expect(familyToolNames).not.toContain("Write");
expect(familyToolNames).not.toContain("Edit");
});
it("should prefer agent-specific tool policy over global", () => {
@@ -143,7 +143,7 @@ describe("Agent-specific tool filtering", () => {
const toolNames = tools.map((t) => t.name);
// Agent policy overrides global: browser is allowed again
expect(toolNames).toContain("browser");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("process");
});
@@ -209,9 +209,9 @@ describe("Agent-specific tool filtering", () => {
// Agent policy should be applied first, then sandbox
// Agent allows only "read", sandbox allows ["read", "write", "bash"]
// Result: only "read" (most restrictive wins)
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("write");
expect(toolNames).toContain("Read");
expect(toolNames).not.toContain("Bash");
expect(toolNames).not.toContain("Write");
});
it("should run bash synchronously when process is denied", async () => {
@@ -229,7 +229,7 @@ describe("Agent-specific tool filtering", () => {
workspaceDir: "/tmp/test-main",
agentDir: "/tmp/agent-main",
});
const bash = tools.find((tool) => tool.name === "bash");
const bash = tools.find((tool) => tool.name === "Bash");
expect(bash).toBeDefined();
const result = await bash?.execute("call1", {
+37 -43
View File
@@ -66,7 +66,14 @@ describe("createClawdbotCodingTools", () => {
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway"];
const toolNames = [
"browser",
"canvas",
"nodes",
"cron",
"gateway",
"message",
];
const collectActionValues = (
schema: unknown,
@@ -110,7 +117,8 @@ describe("createClawdbotCodingTools", () => {
it("includes bash and process tools", () => {
const tools = createClawdbotCodingTools();
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "process")).toBe(true);
});
@@ -133,36 +141,13 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
it("scopes discord tool to discord provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false);
const discord = createClawdbotCodingTools({ messageProvider: "discord" });
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
});
it("scopes slack tool to slack provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "slack")).toBe(false);
const slack = createClawdbotCodingTools({ messageProvider: "slack" });
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
});
it("scopes telegram tool to telegram provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "telegram")).toBe(false);
const telegram = createClawdbotCodingTools({ messageProvider: "telegram" });
expect(telegram.some((tool) => tool.name === "telegram")).toBe(true);
});
it("scopes whatsapp tool to whatsapp provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "slack" });
expect(other.some((tool) => tool.name === "whatsapp")).toBe(false);
const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true);
it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("discord")).toBe(false);
expect(names.has("slack")).toBe(false);
expect(names.has("telegram")).toBe(false);
expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {
@@ -175,8 +160,9 @@ describe("createClawdbotCodingTools", () => {
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("read")).toBe(true);
expect(names.has("bash")).toBe(true);
// NOTE: bash/read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(names.has("Read")).toBe(true);
expect(names.has("Bash")).toBe(true);
expect(names.has("process")).toBe(true);
});
@@ -188,18 +174,21 @@ describe("createClawdbotCodingTools", () => {
agent: {
subagents: {
tools: {
// Policy matching is case-insensitive
allow: ["read"],
},
},
},
},
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
// Tool names are capitalized for OAuth compatibility
expect(tools.map((tool) => tool.name)).toEqual(["Read"]);
});
it("keeps read tool image metadata intact", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
const readTool = tools.find((tool) => tool.name === "Read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -239,7 +228,8 @@ describe("createClawdbotCodingTools", () => {
it("returns text content without image blocks for text files", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
// NOTE: read is capitalized to bypass Anthropic OAuth blocking
const readTool = tools.find((tool) => tool.name === "Read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -294,8 +284,10 @@ describe("createClawdbotCodingTools", () => {
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
expect(tools.some((tool) => tool.name === "read")).toBe(false);
// NOTE: bash/read are capitalized to bypass Anthropic OAuth blocking
// Policy matching is case-insensitive, so allow: ["bash"] matches tool named "Bash"
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "Read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
@@ -325,16 +317,18 @@ describe("createClawdbotCodingTools", () => {
},
};
const tools = createClawdbotCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(false);
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
// NOTE: read/write/edit are capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Read")).toBe(true);
expect(tools.some((tool) => tool.name === "Write")).toBe(false);
expect(tools.some((tool) => tool.name === "Edit")).toBe(false);
});
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
});
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
});
+29 -45
View File
@@ -399,6 +399,28 @@ function normalizeToolNames(list?: string[]) {
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
/**
* Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
* Renaming to capitalized versions bypasses the block while maintaining compatibility
* with regular API keys.
*/
const OAUTH_BLOCKED_TOOL_NAMES: Record<string, string> = {
bash: "Bash",
read: "Read",
write: "Write",
edit: "Edit",
};
function renameBlockedToolsForOAuth(tools: AnyAgentTool[]): AnyAgentTool[] {
return tools.map((tool) => {
const newName = OAUTH_BLOCKED_TOOL_NAMES[tool.name];
if (newName) {
return { ...tool, name: newName };
}
return tool;
});
}
const DEFAULT_SUBAGENT_TOOL_DENY = [
"sessions_list",
"sessions_history",
@@ -591,37 +613,6 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
function normalizeMessageProvider(
messageProvider?: string,
): string | undefined {
const trimmed = messageProvider?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
}
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:");
}
function shouldIncludeSlackTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "slack" || normalized.startsWith("slack:");
}
function shouldIncludeTelegramTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "telegram" || normalized.startsWith("telegram:");
}
function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
}
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
@@ -702,20 +693,9 @@ export function createClawdbotCodingTools(options?: {
config: options?.config,
}),
];
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider);
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
const filtered = tools.filter((tool) => {
if (tool.name === "discord") return allowDiscord;
if (tool.name === "slack") return allowSlack;
if (tool.name === "telegram") return allowTelegram;
if (tool.name === "whatsapp") return allowWhatsApp;
return true;
});
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(filtered, effectiveToolsPolicy)
: filtered;
? filterToolsByPolicy(tools, effectiveToolsPolicy)
: tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: toolsFiltered;
@@ -724,5 +704,9 @@ export function createClawdbotCodingTools(options?: {
: sandboxed;
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
return subagentFiltered.map(normalizeToolParameters);
const normalized = subagentFiltered.map(normalizeToolParameters);
// Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
// Always use capitalized versions for compatibility with both OAuth and regular API keys.
return renameBlockedToolsForOAuth(normalized);
}
+156 -2
View File
@@ -14,11 +14,16 @@ import {
resolveProfile,
} from "../browser/config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
import type { ClawdbotConfig } from "../config/config.js";
import { STATE_DIR_CLAWDBOT } from "../config/config.js";
import {
type ClawdbotConfig,
loadConfig,
STATE_DIR_CLAWDBOT,
} from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -328,6 +333,14 @@ function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
return `agent:${agentId}`;
}
function resolveSandboxAgentId(scopeKey: string): string | undefined {
const trimmed = scopeKey.trim();
if (!trimmed || trimmed === "shared") return undefined;
const parts = trimmed.split(":").filter(Boolean);
if (parts[0] === "agent" && parts[1]) return normalizeAgentId(parts[1]);
return resolveAgentIdFromSessionKey(trimmed);
}
export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig,
agentId?: string,
@@ -1048,6 +1061,19 @@ export async function resolveSandboxContext(params: {
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
await syncSkillsToWorkspace({
sourceWorkspaceDir: agentWorkspaceDir,
targetWorkspaceDir: sandboxWorkspaceDir,
config: params.config,
});
} catch (error) {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
}
}
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
@@ -1109,6 +1135,19 @@ export async function ensureSandboxWorkspaceForSession(params: {
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
await syncSkillsToWorkspace({
sourceWorkspaceDir: agentWorkspaceDir,
targetWorkspaceDir: sandboxWorkspaceDir,
config: params.config,
});
} catch (error) {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
}
}
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
@@ -1118,3 +1157,118 @@ export async function ensureSandboxWorkspaceForSession(params: {
containerWorkdir: cfg.docker.workdir,
};
}
// --- Public API for sandbox management ---
export type SandboxContainerInfo = SandboxRegistryEntry & {
running: boolean;
imageMatch: boolean;
};
export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & {
running: boolean;
imageMatch: boolean;
};
export async function listSandboxContainers(): Promise<SandboxContainerInfo[]> {
const config = loadConfig();
const registry = await readRegistry();
const results: SandboxContainerInfo[] = [];
for (const entry of registry.entries) {
const state = await dockerContainerState(entry.containerName);
// Get actual image from container
let actualImage = entry.image;
if (state.exists) {
try {
const result = await execDocker(
["inspect", "-f", "{{.Config.Image}}", entry.containerName],
{ allowFailure: true },
);
if (result.code === 0) {
actualImage = result.stdout.trim();
}
} catch {
// ignore
}
}
const agentId = resolveSandboxAgentId(entry.sessionKey);
const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker
.image;
results.push({
...entry,
image: actualImage,
running: state.running,
imageMatch: actualImage === configuredImage,
});
}
return results;
}
export async function listSandboxBrowsers(): Promise<SandboxBrowserInfo[]> {
const config = loadConfig();
const registry = await readBrowserRegistry();
const results: SandboxBrowserInfo[] = [];
for (const entry of registry.entries) {
const state = await dockerContainerState(entry.containerName);
let actualImage = entry.image;
if (state.exists) {
try {
const result = await execDocker(
["inspect", "-f", "{{.Config.Image}}", entry.containerName],
{ allowFailure: true },
);
if (result.code === 0) {
actualImage = result.stdout.trim();
}
} catch {
// ignore
}
}
const agentId = resolveSandboxAgentId(entry.sessionKey);
const configuredImage = resolveSandboxConfigForAgent(config, agentId)
.browser.image;
results.push({
...entry,
image: actualImage,
running: state.running,
imageMatch: actualImage === configuredImage,
});
}
return results;
}
export async function removeSandboxContainer(
containerName: string,
): Promise<void> {
try {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
} catch {
// ignore removal failures
}
await removeRegistryEntry(containerName);
}
export async function removeSandboxBrowserContainer(
containerName: string,
): Promise<void> {
try {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
} catch {
// ignore removal failures
}
await removeBrowserRegistryEntry(containerName);
// Stop browser bridge if active
for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) {
if (bridge.containerName === containerName) {
await stopBrowserBridgeServer(bridge.bridge.server).catch(
() => undefined,
);
BROWSER_BRIDGES.delete(sessionKey);
}
}
}
+55
View File
@@ -10,6 +10,7 @@ import {
buildWorkspaceSkillSnapshot,
buildWorkspaceSkillsPrompt,
loadWorkspaceSkillEntries,
syncSkillsToWorkspace,
} from "./skills.js";
import { buildWorkspaceSkillStatus } from "./skills-status.js";
@@ -130,6 +131,60 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
});
it("syncs merged skills into a target workspace", async () => {
const sourceWorkspace = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-"),
);
const targetWorkspace = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-"),
);
const extraDir = path.join(sourceWorkspace, ".extra");
const bundledDir = path.join(sourceWorkspace, ".bundled");
const managedDir = path.join(sourceWorkspace, ".managed");
await writeSkill({
dir: path.join(extraDir, "demo-skill"),
name: "demo-skill",
description: "Extra version",
});
await writeSkill({
dir: path.join(bundledDir, "demo-skill"),
name: "demo-skill",
description: "Bundled version",
});
await writeSkill({
dir: path.join(managedDir, "demo-skill"),
name: "demo-skill",
description: "Managed version",
});
await writeSkill({
dir: path.join(sourceWorkspace, "skills", "demo-skill"),
name: "demo-skill",
description: "Workspace version",
});
await syncSkillsToWorkspace({
sourceWorkspaceDir: sourceWorkspace,
targetWorkspaceDir: targetWorkspace,
config: { skills: { load: { extraDirs: [extraDir] } } },
bundledSkillsDir: bundledDir,
managedSkillsDir: managedDir,
});
const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, {
bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
managedSkillsDir: path.join(targetWorkspace, ".managed"),
});
expect(prompt).toContain("Workspace version");
expect(prompt).not.toContain("Managed version");
expect(prompt).not.toContain("Bundled version");
expect(prompt).not.toContain("Extra version");
expect(prompt).toContain(
path.join(targetWorkspace, "skills", "demo-skill", "SKILL.md"),
);
});
it("filters skills based on env/config gates", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
+37
View File
@@ -11,6 +11,8 @@ import {
import type { ClawdbotConfig, SkillConfig } from "../config/config.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
const fsp = fs.promises;
export type SkillInstallSpec = {
id?: string;
kind: "brew" | "node" | "go" | "uv";
@@ -619,6 +621,41 @@ export function loadWorkspaceSkillEntries(
return loadSkillEntries(workspaceDir, opts);
}
export async function syncSkillsToWorkspace(params: {
sourceWorkspaceDir: string;
targetWorkspaceDir: string;
config?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
}) {
const sourceDir = resolveUserPath(params.sourceWorkspaceDir);
const targetDir = resolveUserPath(params.targetWorkspaceDir);
if (sourceDir === targetDir) return;
const targetSkillsDir = path.join(targetDir, "skills");
const entries = loadSkillEntries(sourceDir, {
config: params.config,
managedSkillsDir: params.managedSkillsDir,
bundledSkillsDir: params.bundledSkillsDir,
});
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
await fsp.mkdir(targetSkillsDir, { recursive: true });
for (const entry of entries) {
const dest = path.join(targetSkillsDir, entry.skill.name);
try {
await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true });
} catch (error) {
const message =
error instanceof Error ? error.message : JSON.stringify(error);
console.warn(
`[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`,
);
}
}
}
export function filterWorkspaceSkillEntries(
entries: SkillEntry[],
config?: ClawdbotConfig,
+5
View File
@@ -224,6 +224,11 @@ export function buildAgentSystemPrompt(params: {
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Tags are stripped before sending; support depends on the current provider config.",
"",
"## Messaging",
"- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.",
"",
];
if (extraSystemPrompt) {
+37 -71
View File
@@ -150,6 +150,43 @@
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"message": {
"emoji": "✉️",
"title": "Message",
"actions": {
"send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] },
"poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] },
"react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] },
"read": { "label": "read", "detailKeys": ["provider", "to", "limit"] },
"edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] },
"delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] },
"pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] },
"unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] },
"list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] },
"thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] },
"thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] },
"thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] },
"search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] },
"sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] },
"member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] },
"role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] },
"emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] },
"emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] },
"sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] },
"role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] },
"channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] },
"voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] },
"event-list": { "label": "events", "detailKeys": ["provider", "guildId"] },
"event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] },
"timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] }
}
},
"agents_list": {
"emoji": "🧭",
"title": "Agents",
@@ -182,77 +219,6 @@
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
"stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
},
"slack": {
"emoji": "💬",
"title": "Slack",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
"emojiList": { "label": "emoji list" }
}
},
"telegram": {
"emoji": "✈️",
"title": "Telegram",
"actions": {
"react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] }
}
},
"whatsapp": {
"emoji": "💬",
"title": "WhatsApp",
"actions": {
"react": {
"label": "react",
"detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"]
}
}
}
}
}
+10
View File
@@ -1,5 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js";
@@ -45,6 +46,7 @@ const GatewayToolSchema = Type.Union([
export function createGatewayTool(opts?: {
agentSessionKey?: string;
config?: ClawdbotConfig;
}): AnyAgentTool {
return {
label: "Gateway",
@@ -56,6 +58,11 @@ export function createGatewayTool(opts?: {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action === "restart") {
if (opts?.config?.commands?.restart !== true) {
throw new Error(
"Gateway restart is disabled. Set commands.restart=true to enable.",
);
}
const delayMs =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? Math.floor(params.delayMs)
@@ -64,6 +71,9 @@ export function createGatewayTool(opts?: {
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
console.info(
`gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`,
);
const scheduled = scheduleGatewaySigusr1Restart({
delayMs,
reason,
+916
View File
@@ -0,0 +1,916 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
type MessagePollResult,
type MessageSendResult,
sendMessage,
sendPoll,
} from "../../infra/outbound/message.js";
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
import { handleDiscordAction } from "./discord-actions.js";
import { handleSlackAction } from "./slack-actions.js";
import { handleTelegramAction } from "./telegram-actions.js";
import { handleWhatsAppAction } from "./whatsapp-actions.js";
const MessageActionSchema = Type.Union([
Type.Literal("send"),
Type.Literal("poll"),
Type.Literal("react"),
Type.Literal("reactions"),
Type.Literal("read"),
Type.Literal("edit"),
Type.Literal("delete"),
Type.Literal("pin"),
Type.Literal("unpin"),
Type.Literal("list-pins"),
Type.Literal("permissions"),
Type.Literal("thread-create"),
Type.Literal("thread-list"),
Type.Literal("thread-reply"),
Type.Literal("search"),
Type.Literal("sticker"),
Type.Literal("member-info"),
Type.Literal("role-info"),
Type.Literal("emoji-list"),
Type.Literal("emoji-upload"),
Type.Literal("sticker-upload"),
Type.Literal("role-add"),
Type.Literal("role-remove"),
Type.Literal("channel-info"),
Type.Literal("channel-list"),
Type.Literal("voice-status"),
Type.Literal("event-list"),
Type.Literal("event-create"),
Type.Literal("timeout"),
Type.Literal("kick"),
Type.Literal("ban"),
]);
const MessageToolSchema = Type.Object({
action: MessageActionSchema,
provider: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
includeArchived: Type.Optional(Type.Boolean()),
participant: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
type MessageToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
};
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Message",
name: "message",
description:
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).",
parameters: MessageToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { required: true });
const providerSelection = await resolveMessageProviderSelection({
cfg,
provider: readStringParam(params, "provider"),
});
const provider = providerSelection.provider;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs: readNumberParam(params, "timeoutMs"),
clientName: "agent" as const,
mode: "agent" as const,
};
const dryRun = Boolean(params.dryRun);
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const message = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
typeof params.bestEffort === "boolean"
? params.bestEffort
: undefined;
if (dryRun) {
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
},
cfg,
);
}
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect =
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
if (dryRun) {
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "poll",
to,
question,
answers: options,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
);
}
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
const resolveChannelId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
const resolveChatId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove =
typeof params.remove === "boolean" ? params.remove : undefined;
if (provider === "discord") {
return await handleDiscordAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "react",
chatId: resolveChatId("chatId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "whatsapp") {
return await handleWhatsAppAction(
{
action: "react",
chatJid: resolveChatId("chatJid"),
messageId,
emoji,
remove,
participant: readStringParam(params, "participant"),
accountId: accountId ?? undefined,
fromMe:
typeof params.fromMe === "boolean" ? params.fromMe : undefined,
},
cfg,
);
}
throw new Error(`React is not supported for provider ${provider}.`);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(
`Reactions are not supported for provider ${provider}.`,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const around = readStringParam(params, "around");
if (provider === "discord") {
return await handleDiscordAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
around,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Read is not supported for provider ${provider}.`);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Edit is not supported for provider ${provider}.`);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (provider === "discord") {
return await handleDiscordAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Delete is not supported for provider ${provider}.`);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
const channelId = resolveChannelId("channelId");
if (provider === "discord") {
const discordAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleDiscordAction(
{
action: discordAction,
channelId,
messageId,
},
cfg,
);
}
if (provider === "slack") {
const slackAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleSlackAction(
{
action: slackAction,
channelId,
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Pins are not supported for provider ${provider}.`);
}
if (action === "permissions") {
if (provider !== "discord") {
throw new Error(
`Permissions are only supported for Discord (provider=${provider}).`,
);
}
return await handleDiscordAction(
{
action: "permissions",
channelId: resolveChannelId("channelId"),
},
cfg,
);
}
if (action === "thread-create") {
if (provider !== "discord") {
throw new Error(
`Thread create is only supported for Discord (provider=${provider}).`,
);
}
const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
integer: true,
});
return await handleDiscordAction(
{
action: "threadCreate",
channelId: resolveChannelId("channelId"),
name,
messageId,
autoArchiveMinutes,
},
cfg,
);
}
if (action === "thread-list") {
if (provider !== "discord") {
throw new Error(
`Thread list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
const before = readStringParam(params, "before");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "threadList",
guildId,
channelId,
includeArchived,
before,
limit,
},
cfg,
);
}
if (action === "thread-reply") {
if (provider !== "discord") {
throw new Error(
`Thread reply is only supported for Discord (provider=${provider}).`,
);
}
const content = readStringParam(params, "message", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
return await handleDiscordAction(
{
action: "threadReply",
channelId: resolveChannelId("channelId"),
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "search") {
if (provider !== "discord") {
throw new Error(
`Search is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const query = readStringParam(params, "query", { required: true });
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "searchMessages",
guildId,
content: query,
channelId,
channelIds,
authorId,
authorIds,
limit,
},
cfg,
);
}
if (action === "sticker") {
if (provider !== "discord") {
throw new Error(
`Sticker send is only supported for Discord (provider=${provider}).`,
);
}
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
const content = readStringParam(params, "message");
return await handleDiscordAction(
{
action: "sticker",
to: readStringParam(params, "to", { required: true }),
stickerIds,
content,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", guildId, userId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Member info is not supported for provider ${provider}.`,
);
}
if (action === "role-info") {
if (provider !== "discord") {
throw new Error(
`Role info is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
}
if (action === "emoji-list") {
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "emojiList", guildId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Emoji list is not supported for provider ${provider}.`,
);
}
if (action === "emoji-upload") {
if (provider !== "discord") {
throw new Error(
`Emoji upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "emojiName", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
const roleIds = readStringArrayParam(params, "roleIds");
return await handleDiscordAction(
{
action: "emojiUpload",
guildId,
name,
mediaUrl,
roleIds,
},
cfg,
);
}
if (action === "sticker-upload") {
if (provider !== "discord") {
throw new Error(
`Sticker upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "stickerName", { required: true });
const description = readStringParam(params, "stickerDesc", {
required: true,
});
const tags = readStringParam(params, "stickerTags", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
return await handleDiscordAction(
{
action: "stickerUpload",
guildId,
name,
description,
tags,
mediaUrl,
},
cfg,
);
}
if (action === "role-add" || action === "role-remove") {
if (provider !== "discord") {
throw new Error(
`Role changes are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const roleId = readStringParam(params, "roleId", { required: true });
const discordAction = action === "role-add" ? "roleAdd" : "roleRemove";
return await handleDiscordAction(
{ action: discordAction, guildId, userId, roleId },
cfg,
);
}
if (action === "channel-info") {
if (provider !== "discord") {
throw new Error(
`Channel info is only supported for Discord (provider=${provider}).`,
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", channelId },
cfg,
);
}
if (action === "channel-list") {
if (provider !== "discord") {
throw new Error(
`Channel list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction(
{ action: "channelList", guildId },
cfg,
);
}
if (action === "voice-status") {
if (provider !== "discord") {
throw new Error(
`Voice status is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
return await handleDiscordAction(
{ action: "voiceStatus", guildId, userId },
cfg,
);
}
if (action === "event-list") {
if (provider !== "discord") {
throw new Error(
`Event list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
}
if (action === "event-create") {
if (provider !== "discord") {
throw new Error(
`Event create is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "eventName", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "desc");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityType = readStringParam(params, "eventType");
return await handleDiscordAction(
{
action: "eventCreate",
guildId,
name,
startTime,
endTime,
description,
channelId,
location,
entityType,
},
cfg,
);
}
if (action === "timeout" || action === "kick" || action === "ban") {
if (provider !== "discord") {
throw new Error(
`Moderation actions are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const durationMinutes = readNumberParam(params, "durationMin", {
integer: true,
});
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const deleteMessageDays = readNumberParam(params, "deleteDays", {
integer: true,
});
const discordAction = action as "timeout" | "kick" | "ban";
return await handleDiscordAction(
{
action: discordAction,
guildId,
userId,
durationMinutes,
until,
reason,
deleteMessageDays,
},
cfg,
);
}
throw new Error(`Unknown action: ${action}`);
},
};
}
+2
View File
@@ -91,9 +91,11 @@ export async function handleSlackAction(
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const threadTs = readStringParam(params, "threadTs");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
threadTs: threadTs ?? undefined,
});
return jsonResult({ ok: true, result });
}
+1
View File
@@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
threadTs: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
+41 -22
View File
@@ -25,6 +25,45 @@ function expectFencesBalanced(chunks: string[]) {
}
}
type ChunkCase = {
name: string;
text: string;
limit: number;
expected: string[];
};
function runChunkCases(
chunker: (text: string, limit: number) => string[],
cases: ChunkCase[],
) {
for (const { name, text, limit, expected } of cases) {
it(name, () => {
expect(chunker(text, limit)).toEqual(expected);
});
}
}
const parentheticalCases: ChunkCase[] = [
{
name: "keeps parenthetical phrases together",
text: "Heads up now (Though now I'm curious)ok",
limit: 35,
expected: ["Heads up now", "(Though now I'm curious)ok"],
},
{
name: "handles nested parentheses",
text: "Hello (outer (inner) end) world",
limit: 26,
expected: ["Hello (outer (inner) end)", "world"],
},
{
name: "ignores unmatched closing parentheses",
text: "Hello) world (ok)",
limit: 12,
expected: ["Hello)", "world (ok)"],
},
];
describe("chunkText", () => {
it("keeps multi-line text in one chunk when under limit", () => {
const text = "Line one\n\nLine two\n\nLine three";
@@ -68,11 +107,7 @@ describe("chunkText", () => {
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
});
it("keeps parenthetical phrases together", () => {
const text = "Heads up now (Though now I'm curious)ok";
const chunks = chunkText(text, 35);
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
});
runChunkCases(chunkText, [parentheticalCases[0]]);
});
describe("resolveTextChunkLimit", () => {
@@ -191,17 +226,7 @@ describe("chunkMarkdownText", () => {
}
});
it("keeps parenthetical phrases together", () => {
const text = "Heads up now (Though now I'm curious)ok";
const chunks = chunkMarkdownText(text, 35);
expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
});
it("handles nested parentheses", () => {
const text = "Hello (outer (inner) end) world";
const chunks = chunkMarkdownText(text, 26);
expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]);
});
runChunkCases(chunkMarkdownText, parentheticalCases);
it("hard-breaks when a parenthetical exceeds the limit", () => {
const text = `(${"a".repeat(80)})`;
@@ -209,10 +234,4 @@ describe("chunkMarkdownText", () => {
expect(chunks[0]?.length).toBe(20);
expect(chunks.join("")).toBe(text);
});
it("ignores unmatched closing parentheses", () => {
const text = "Hello) world (ok)";
const chunks = chunkMarkdownText(text, 12);
expect(chunks).toEqual(["Hello)", "world (ok)"]);
});
});
+23 -22
View File
@@ -17,7 +17,8 @@ export type TextChunkProvider =
| "slack"
| "signal"
| "imessage"
| "webchat";
| "webchat"
| "msteams";
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
whatsapp: 4000,
@@ -27,6 +28,7 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
signal: 4000,
imessage: 4000,
webchat: 4000,
msteams: 4000,
};
export function resolveTextChunkLimit(
@@ -70,6 +72,9 @@ export function resolveTextChunkLimit(
cfg?.imessage?.textChunkLimit
);
}
if (provider === "msteams") {
return cfg?.msteams?.textChunkLimit;
}
return undefined;
})();
if (typeof providerOverride === "number" && providerOverride > 0) {
@@ -91,23 +96,7 @@ export function chunkText(text: string, limit: number): string[] {
const window = remaining.slice(0, limit);
// 1) Prefer a newline break inside the window (outside parentheses).
let lastNewline = -1;
let lastWhitespace = -1;
let depth = 0;
for (let i = 0; i < window.length; i++) {
const char = window[i];
if (char === "(") {
depth += 1;
continue;
}
if (char === ")" && depth > 0) {
depth -= 1;
continue;
}
if (depth !== 0) continue;
if (char === "\n") lastNewline = i;
else if (/\s/.test(char)) lastWhitespace = i;
}
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace;
@@ -243,12 +232,26 @@ function pickSafeBreakIndex(
window: string,
spans: ReturnType<typeof parseFenceSpans>,
): number {
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(
window,
(index) => isSafeFenceBreak(spans, index),
);
if (lastNewline > 0) return lastNewline;
if (lastWhitespace > 0) return lastWhitespace;
return -1;
}
function scanParenAwareBreakpoints(
window: string,
isAllowed: (index: number) => boolean = () => true,
): { lastNewline: number; lastWhitespace: number } {
let lastNewline = -1;
let lastWhitespace = -1;
let depth = 0;
for (let i = 0; i < window.length; i++) {
if (!isSafeFenceBreak(spans, i)) continue;
if (!isAllowed(i)) continue;
const char = window[i];
if (char === "(") {
depth += 1;
@@ -263,7 +266,5 @@ function pickSafeBreakIndex(
else if (/\s/.test(char)) lastWhitespace = i;
}
if (lastNewline > 0) return lastNewline;
if (lastWhitespace > 0) return lastWhitespace;
return -1;
return { lastNewline, lastWhitespace };
}
+2
View File
@@ -26,6 +26,8 @@ describe("commands registry", () => {
expect(detection.regex.test("/status:")).toBe(true);
expect(detection.regex.test("/stop")).toBe(true);
expect(detection.regex.test("/send:")).toBe(true);
expect(detection.regex.test("/models")).toBe(true);
expect(detection.regex.test("/models list")).toBe(true);
expect(detection.regex.test("try /status")).toBe(false);
});
+8 -1
View File
@@ -27,6 +27,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "cost",
nativeName: "cost",
description: "Toggle per-response usage line.",
textAliases: ["/cost"],
acceptsArgs: true,
},
{
key: "stop",
nativeName: "stop",
@@ -97,7 +104,7 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAliases: ["/model"],
textAliases: ["/model", "/models"],
acceptsArgs: true,
},
{
+13
View File
@@ -10,6 +10,13 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("extracts /models with argument", () => {
const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("");
});
it("extracts /model with provider/model format", () => {
const result = extractModelDirective("/model anthropic/claude-opus-4-5");
expect(result.hasDirective).toBe(true);
@@ -107,6 +114,12 @@ describe("extractModelDirective", () => {
});
describe("edge cases", () => {
it("preserves spacing when /model is followed by a path segment", () => {
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
expect(result.hasDirective).toBe(true);
expect(result.cleaned).toBe("thats not /hello");
});
it("handles alias with special regex characters", () => {
const result = extractModelDirective("/test.alias", {
aliases: ["test.alias"],
+2 -2
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
);
const aliases = (options?.aliases ?? [])
@@ -42,7 +42,7 @@ export function extractModelDirective(
}
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
return {
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { extractStatusDirective } from "./reply/directives.js";
import {
extractElevatedDirective,
extractQueueDirective,
@@ -119,6 +120,30 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
it("preserves spacing when stripping think directives before paths", () => {
const res = extractThinkDirective("thats not /think high/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping verbose directives before paths", () => {
const res = extractVerboseDirective("thats not /verbose on/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping reasoning directives before paths", () => {
const res = extractReasoningDirective("thats not /reasoning on/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping status directives before paths", () => {
const res = extractStatusDirective("thats not /status:/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("parses queue options and modes", () => {
const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
+140
View File
@@ -159,6 +159,9 @@ describe("directive behavior", () => {
expect(text).toContain(
"Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.",
);
expect(text).toContain(
"Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize.",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -182,6 +185,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -204,6 +208,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -358,6 +363,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -380,6 +386,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -403,6 +410,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current verbose level: on");
expect(text).toContain("Options: on, off.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -425,6 +433,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current reasoning level: off");
expect(text).toContain("Options: on, off, stream.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -458,6 +467,41 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("warns when elevated is used in direct runtime", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated off",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
sandbox: { mode: "off" },
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Runtime is direct; sandboxing does not apply.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -494,6 +538,72 @@ describe("directive behavior", () => {
});
});
it("handles multiple directives in a single message", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated off\n/verbose on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Verbose logging enabled.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("returns status alongside directive-only acks", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated off\n/status",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
expect(text).toContain("Session: agent:main:main");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("acks queue directive and persists override", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -823,6 +933,36 @@ describe("directive behavior", () => {
});
});
it("falls back to configured models when catalog is unavailable", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model catalog unavailable");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("does not repeat missing auth labels on /model list", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
+7
View File
@@ -30,12 +30,19 @@ function makeResult(text: string) {
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-note-"));
const previousHome = process.env.HOME;
const previousBundledSkills = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR;
process.env.HOME = base;
process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = path.join(base, "bundled-skills");
try {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(base);
} finally {
process.env.HOME = previousHome;
if (previousBundledSkills === undefined) {
delete process.env.CLAWDBOT_BUNDLED_SKILLS_DIR;
} else {
process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = previousBundledSkills;
}
try {
await fs.rm(base, { recursive: true, force: true });
} catch {
+286 -1
View File
@@ -14,6 +14,17 @@ vi.mock("../agents/pi-embedded.js", () => ({
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
const usageMocks = vi.hoisted(() => ({
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
vi.mock("../infra/provider-usage.js", () => usageMocks);
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
@@ -66,6 +77,30 @@ afterEach(() => {
});
describe("trigger handling", () => {
it("filters usage summary to the current model provider", async () => {
await withTempHome(async (home) => {
usageMocks.loadProviderUsageSummary.mockClear();
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("📊 Usage: Claude 80% left");
expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["anthropic"] }),
);
});
});
it("aborts even with timestamp prefix", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
@@ -146,7 +181,7 @@ describe("trigger handling", () => {
});
});
it("restarts even with prefix/whitespace", async () => {
it("rejects /restart by default", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
@@ -158,6 +193,24 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("/restart is disabled");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("restarts when enabled", async () => {
await withTempHome(async (home) => {
const cfg = { ...makeCfg(home), commands: { restart: true } };
const res = await getReplyFromConfig(
{
Body: "/restart",
From: "+1001",
To: "+2000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(
text?.startsWith("⚙️ Restarting") ||
text?.startsWith("⚠️ Restart failed"),
@@ -183,6 +236,70 @@ describe("trigger handling", () => {
});
});
it("reports active auth profile and key snippet in status", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const agentDir = join(home, ".clawdbot", "agents", "main", "agent");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-test-1234567890abcdef",
},
},
lastGood: { anthropic: "anthropic:work" },
},
null,
2,
),
);
const sessionKey = resolveSessionKey("per-sender", {
From: "+1002",
To: "+2000",
Provider: "whatsapp",
} as Parameters<typeof resolveSessionKey>[1]);
await fs.writeFile(
cfg.session.store,
JSON.stringify(
{
[sessionKey]: {
sessionId: "session-auth",
updatedAt: Date.now(),
authProfileOverride: "anthropic:work",
},
},
null,
2,
),
);
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+1002",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1002",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("api-key");
expect(text).toContain("…");
expect(text).toContain("(anthropic:work)");
expect(text).not.toContain("mixed");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("ignores inline /status and runs the agent", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -340,6 +457,174 @@ describe("trigger handling", () => {
});
});
it("ignores elevated directive in groups when not mentioned", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: false,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled");
});
});
it("allows elevated off in groups without mention", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated off",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: false,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode disabled.");
});
});
it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: true } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: true,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
"on",
);
});
});
it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
+74 -17
View File
@@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js";
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
import { applySessionHints } from "./reply/body.js";
import { buildCommandContext, handleCommands } from "./reply/commands.js";
import {
buildCommandContext,
buildStatusReply,
handleCommands,
} from "./reply/commands.js";
import {
handleDirectiveOnly,
type InlineDirectives,
@@ -332,6 +336,20 @@ export async function getReplyFromConfig(
let parsedDirectives = parseInlineDirectives(rawBody, {
modelAliases: configuredAliases,
});
if (
isGroup &&
ctx.WasMentioned !== true &&
parsedDirectives.hasElevatedDirective
) {
if (parsedDirectives.elevatedLevel !== "off") {
parsedDirectives = {
...parsedDirectives,
hasElevatedDirective: false,
elevatedLevel: undefined,
rawElevatedLevel: undefined,
};
}
}
const hasDirective =
parsedDirectives.hasThinkDirective ||
parsedDirectives.hasVerboseDirective ||
@@ -342,9 +360,16 @@ export async function getReplyFromConfig(
parsedDirectives.hasQueueDirective;
if (hasDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
const directiveOnlyCheck = parseInlineDirectives(noMentions, {
modelAliases: configuredAliases,
});
if (directiveOnlyCheck.cleaned.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
}
}
const directives = commandAuthorized
@@ -461,12 +486,28 @@ export async function getReplyFromConfig(
? undefined
: directives.rawModelDirective;
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
if (
isDirectiveOnly({
directives,
cleanedBody: directives.cleaned,
ctx,
cfg,
agentId,
isGroup,
})
) {
@@ -505,8 +546,36 @@ export async function getReplyFromConfig(
currentReasoningLevel,
currentElevatedLevel,
});
let statusReply: ReplyPayload | undefined;
if (directives.hasStatusDirective && allowTextCommands) {
statusReply = await buildStatusReply({
cfg,
command,
sessionEntry,
sessionKey,
sessionScope,
provider,
model,
contextTokens,
resolvedThinkLevel:
currentThinkLevel ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
resolvedReasoningLevel: (currentReasoningLevel ??
"off") as ReasoningLevel,
resolvedElevatedLevel: currentElevatedLevel,
resolveDefaultThinkingLevel: async () =>
currentThinkLevel ??
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
isGroup,
defaultGroupActivation: () => defaultActivation,
});
}
typing.cleanup();
return directiveReply;
if (statusReply?.text && directiveReply?.text) {
return { text: `${directiveReply.text}\n${statusReply.text}` };
}
return statusReply ?? directiveReply;
}
const persisted = await persistInlineDirectives({
@@ -546,19 +615,6 @@ export async function getReplyFromConfig(
}
: undefined;
const command = buildCommandContext({
ctx,
cfg,
sessionKey,
isGroup,
triggerBodyNormalized,
commandAuthorized,
});
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: command.surface,
commandSource: ctx.CommandSource,
});
const isEmptyConfig = Object.keys(cfg).length === 0;
if (
command.isWhatsAppProvider &&
@@ -579,6 +635,7 @@ export async function getReplyFromConfig(
ctx,
cfg,
command,
agentId,
directives,
sessionEntry,
sessionStore,
@@ -0,0 +1,134 @@
import crypto from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { onAgentEvent } from "../../infra/agent-events.js";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
const runClaudeCliAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("../../agents/claude-cli-runner.js", () => ({
runClaudeCliAgent: (params: unknown) => runClaudeCliAgentMock(params),
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
function createRun() {
const typing = createMockTypingController();
const sessionCtx = {
Provider: "webchat",
OriginatingTo: "session:1",
AccountId: "primary",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "webchat",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "claude-cli",
model: "opus-4.5",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
return runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
defaultModel: "claude-cli/opus-4.5",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
}
describe("runReplyAgent claude-cli routing", () => {
it("uses claude-cli runner for claude-cli provider", async () => {
const randomSpy = vi.spyOn(crypto, "randomUUID").mockReturnValue("run-1");
const lifecyclePhases: string[] = [];
const unsubscribe = onAgentEvent((evt) => {
if (evt.runId !== "run-1") return;
if (evt.stream !== "lifecycle") return;
const phase = evt.data?.phase;
if (typeof phase === "string") lifecyclePhases.push(phase);
});
runClaudeCliAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {
agentMeta: {
provider: "claude-cli",
model: "opus-4.5",
},
},
});
const result = await createRun();
unsubscribe();
randomSpy.mockRestore();
expect(runClaudeCliAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
expect(lifecyclePhases).toEqual(["start", "end"]);
expect(result).toMatchObject({ text: "ok" });
});
});
+178 -18
View File
@@ -1,13 +1,15 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { runClaudeCliAgent } from "../../agents/claude-cli-runner.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
queueEmbeddedPiMessage,
runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import {
loadSessionStore,
resolveSessionTranscriptPath,
@@ -16,8 +18,17 @@ import {
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import {
estimateUsageCost,
formatTokenCount,
formatUsd,
resolveModelCostConfig,
} from "../../utils/usage-format.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
@@ -62,6 +73,65 @@ const formatBunFetchSocketError = (message: string) => {
].join("\n");
};
const formatResponseUsageLine = (params: {
usage?: NormalizedUsage;
showCost: boolean;
costConfig?: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}): string | null => {
const usage = params.usage;
if (!usage) return null;
const input = usage.input;
const output = usage.output;
if (typeof input !== "number" && typeof output !== "number") return null;
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel =
typeof output === "number" ? formatTokenCount(output) : "?";
const cost =
params.showCost && typeof input === "number" && typeof output === "number"
? estimateUsageCost({
usage: {
input,
output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
},
cost: params.costConfig,
})
: undefined;
const costLabel = params.showCost ? formatUsd(cost) : undefined;
const suffix = costLabel ? ` · est ${costLabel}` : "";
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
};
const appendUsageLine = (
payloads: ReplyPayload[],
line: string,
): ReplyPayload[] => {
let index = -1;
for (let i = payloads.length - 1; i >= 0; i -= 1) {
if (payloads[i]?.text) {
index = i;
break;
}
}
if (index === -1) return [...payloads, { text: line }];
const existing = payloads[index];
const existingText = existing.text ?? "";
const separator = existingText.endsWith("\n") ? "" : "\n";
const next = {
...existing,
text: `${existingText}${separator}${line}`,
};
const updated = payloads.slice();
updated[index] = next;
return updated;
};
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
@@ -191,6 +261,7 @@ export async function runReplyAgent(params: {
replyToChannel,
);
const applyReplyToMode = createReplyToModeFilter(replyToMode);
const cfg = followupRun.run.config;
if (shouldSteer && isStreaming) {
const steered = queueEmbeddedPiMessage(
@@ -242,6 +313,7 @@ export async function runReplyAgent(params: {
let didLogHeartbeatStrip = false;
let autoCompactionCompleted = false;
let responseUsageLine: string | undefined;
try {
const runId = crypto.randomUUID();
if (sessionKey) {
@@ -258,8 +330,61 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config,
provider: followupRun.run.provider,
model: followupRun.run.model,
run: (provider, model) =>
runEmbeddedPiAgent({
run: (provider, model) => {
if (provider === "claude-cli") {
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt,
},
});
return runClaudeCliAgent({
sessionId: followupRun.run.sessionId,
sessionKey,
sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir,
config: followupRun.run.config,
prompt: commandBody,
provider,
model,
thinkLevel: followupRun.run.thinkLevel,
timeoutMs: followupRun.run.timeoutMs,
runId,
extraSystemPrompt: followupRun.run.extraSystemPrompt,
ownerNumbers: followupRun.run.ownerNumbers,
claudeSessionId:
sessionEntry?.claudeCliSessionId?.trim() || undefined,
})
.then((result) => {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
},
});
return result;
})
.catch((err) => {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
},
});
throw err;
});
}
return runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId,
sessionKey,
messageProvider:
@@ -486,7 +611,8 @@ export async function runReplyAgent(params: {
pendingToolTasks.add(task);
}
: undefined,
}),
});
},
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
@@ -641,20 +767,24 @@ export async function runReplyAgent(params: {
await typingSignals.signalRunStart();
}
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ??
fallbackProvider ??
followupRun.run.provider;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
const usage = runResult.meta.agentMeta?.usage;
const modelUsed =
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ??
fallbackProvider ??
followupRun.run.provider;
const cliSessionId =
providerUsed === "claude-cli"
? runResult.meta.agentMeta?.sessionId?.trim()
: undefined;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (sessionStore && sessionKey) {
if (hasNonzeroUsage(usage)) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
@@ -673,6 +803,9 @@ export async function runReplyAgent(params: {
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
};
if (cliSessionId) {
nextEntry.claudeCliSessionId = cliSessionId;
}
sessionStore[sessionKey] = nextEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
@@ -686,6 +819,7 @@ export async function runReplyAgent(params: {
modelProvider: providerUsed ?? entry.modelProvider,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
};
if (storePath) {
await saveSessionStore(storePath, sessionStore);
@@ -694,6 +828,29 @@ export async function runReplyAgent(params: {
}
}
const responseUsageEnabled =
(sessionEntry?.responseUsage ??
(sessionKey
? sessionStore?.[sessionKey]?.responseUsage
: undefined)) === "on";
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
const authMode = resolveModelAuthMode(providerUsed, cfg);
const showCost = authMode === "api-key";
const costConfig = showCost
? resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
})
: undefined;
const formatted = formatResponseUsageLine({
usage,
showCost,
costConfig,
});
if (formatted) responseUsageLine = formatted;
}
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = replyPayloads;
if (autoCompactionCompleted) {
@@ -717,6 +874,9 @@ export async function runReplyAgent(params: {
...finalPayloads,
];
}
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
+184 -77
View File
@@ -1,11 +1,13 @@
import {
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
@@ -23,6 +25,7 @@ import { logVerbose } from "../../globals.js";
import {
formatUsageSummaryLine,
loadProviderUsageSummary,
resolveUsageProviderId,
} from "../../infra/provider-usage.js";
import {
scheduleGatewaySigusr1Restart,
@@ -91,32 +94,168 @@ export type CommandContext = {
to?: string;
};
export async function buildStatusReply(params: {
cfg: ClawdbotConfig;
command: CommandContext;
sessionEntry?: SessionEntry;
sessionKey?: string;
sessionScope?: SessionScope;
provider: string;
model: string;
contextTokens: number;
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolvedReasoningLevel: ReasoningLevel;
resolvedElevatedLevel?: ElevatedLevel;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
}): Promise<ReplyPayload | undefined> {
const {
cfg,
command,
sessionEntry,
sessionKey,
sessionScope,
provider,
model,
contextTokens,
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
isGroup,
defaultGroupActivation,
} = params;
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return undefined;
}
let usageLine: string | null = null;
try {
const usageProvider = resolveUsageProviderId(provider);
if (usageProvider) {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: [usageProvider],
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
}
} catch {
usageLine = null;
}
const queueSettings = resolveQueueSettings({
cfg,
provider: command.provider,
sessionEntry,
});
const queueKey = sessionKey ?? sessionEntry?.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
sessionEntry?.queueDebounceMs ??
sessionEntry?.queueCap ??
sessionEntry?.queueDrop,
);
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const statusText = buildStatusMessage({
config: cfg,
agent: {
...cfg.agent,
model: {
...cfg.agent?.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
sessionEntry,
sessionKey,
sessionScope,
groupActivation,
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
});
return { text: statusText };
}
function formatApiKeySnippet(apiKey: string): string {
const compact = apiKey.replace(/\s+/g, "");
if (!compact) return "unknown";
const edge = compact.length >= 12 ? 6 : 4;
const head = compact.slice(0, edge);
const tail = compact.slice(-edge);
return `${head}${tail}`;
}
function resolveModelAuthLabel(
provider?: string,
cfg?: ClawdbotConfig,
sessionEntry?: SessionEntry,
): string | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
const providerKey = normalizeProviderId(resolved);
const store = ensureAuthProfileStore();
const profiles = listProfilesForProvider(store, resolved);
if (profiles.length > 0) {
const modes = new Set(
profiles
.map((id) => store.profiles[id]?.type)
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
);
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
if (modes.has("oauth")) return "oauth";
if (modes.has("api_key")) return "api-key";
const profileOverride = sessionEntry?.authProfileOverride?.trim();
const order = resolveAuthProfileOrder({
cfg,
store,
provider: providerKey,
preferredProfile: profileOverride,
});
const candidates = [profileOverride, ...order].filter(Boolean) as string[];
for (const profileId of candidates) {
const profile = store.profiles[profileId];
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
continue;
}
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`;
}
if (profile.type === "token") {
const snippet = formatApiKeySnippet(profile.token);
return `token ${snippet}${label ? ` (${label})` : ""}`;
}
const snippet = formatApiKeySnippet(profile.key);
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
}
const envKey = resolveEnvApiKey(resolved);
const envKey = resolveEnvApiKey(providerKey);
if (envKey?.apiKey) {
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
if (envKey.source.includes("OAUTH_TOKEN")) {
return `oauth (${envKey.source})`;
}
return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`;
}
if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
const customKey = getCustomProviderApiKey(cfg, providerKey);
if (customKey) {
return `api-key ${formatApiKeySnippet(customKey)} (models.json)`;
}
return "unknown";
}
@@ -125,11 +264,12 @@ function extractCompactInstructions(params: {
rawBody?: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}): string | undefined {
const raw = stripStructuralPrefixes(params.rawBody ?? "");
const stripped = params.isGroup
? stripMentions(raw, params.ctx, params.cfg)
? stripMentions(raw, params.ctx, params.cfg, params.agentId)
: raw;
const trimmed = stripped.trim();
if (!trimmed) return undefined;
@@ -144,12 +284,14 @@ function extractCompactInstructions(params: {
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
params;
const auth = resolveCommandAuthorization({
ctx,
cfg,
@@ -161,7 +303,9 @@ export function buildCommandContext(params: {
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
isGroup
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
: rawBodyNormalized,
);
return {
@@ -206,6 +350,7 @@ export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
command: CommandContext;
agentId?: string;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
@@ -363,6 +508,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.restart !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /restart is disabled. Set commands.restart=true to enable.",
},
};
}
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
if (hasSigusr1Listener) {
scheduleGatewaySigusr1Restart({ reason: "/restart" });
@@ -408,71 +561,24 @@ export async function handleCommands(params: {
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status";
if (allowTextCommands && statusRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
let usageLine: string | null = null;
try {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
} catch {
usageLine = null;
}
const queueSettings = resolveQueueSettings({
const reply = await buildStatusReply({
cfg,
provider: command.provider,
sessionEntry,
});
const queueKey = sessionKey ?? sessionEntry?.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
sessionEntry?.queueDebounceMs ??
sessionEntry?.queueCap ??
sessionEntry?.queueDrop,
);
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const statusText = buildStatusMessage({
agent: {
...cfg.agent,
model: {
...cfg.agent?.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
command,
sessionEntry,
sessionKey,
sessionScope,
groupActivation,
resolvedThink:
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
provider,
model,
contextTokens,
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
isGroup,
defaultGroupActivation,
});
return { shouldContinue: false, reply: { text: statusText } };
return { shouldContinue: false, reply };
}
const stopRequested = command.commandBodyNormalized === "/stop";
@@ -530,6 +636,7 @@ export async function handleCommands(params: {
rawBody: ctx.Body,
ctx,
cfg,
agentId: params.agentId,
isGroup,
});
const result = await compactEmbeddedPiSession({
+156 -12
View File
@@ -24,7 +24,12 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js";
@@ -54,6 +59,11 @@ import {
} from "./queue.js";
const SYSTEM_MARK = "⚙️";
const formatOptionsLine = (options: string) => `Options: ${options}.`;
const withOptions = (line: string, options: string) =>
`${line}\n${formatOptionsLine(options)}`;
const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
const maskApiKey = (value: string): string => {
const trimmed = value.trim();
@@ -78,13 +88,18 @@ const resolveAuthLabel = async (
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.mode && configProfile.mode !== profile.type)
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"))
) {
return `${profileId}=missing`;
}
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
}
const display = resolveAuthProfileDisplayLabel({
cfg,
store,
@@ -184,7 +199,7 @@ export type InlineDirectives = {
export function parseInlineDirectives(
body: string,
options?: { modelAliases?: string[] },
options?: { modelAliases?: string[]; disableElevated?: boolean },
): InlineDirectives {
const {
cleaned: thinkCleaned,
@@ -209,7 +224,14 @@ export function parseInlineDirectives(
elevatedLevel,
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
} = extractElevatedDirective(reasoningCleaned);
} = options?.disableElevated
? {
cleaned: reasoningCleaned,
elevatedLevel: undefined,
rawLevel: undefined,
hasDirective: false,
}
: extractElevatedDirective(reasoningCleaned);
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
extractStatusDirective(elevatedCleaned);
const {
@@ -272,9 +294,10 @@ export function isDirectiveOnly(params: {
cleanedBody: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}): boolean {
const { directives, cleanedBody, ctx, cfg, isGroup } = params;
const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params;
if (
!directives.hasThinkDirective &&
!directives.hasVerboseDirective &&
@@ -285,7 +308,9 @@ export function isDirectiveOnly(params: {
)
return false;
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
return noMentions.length === 0;
}
@@ -337,6 +362,21 @@ export async function handleDirectiveOnly(params: {
currentReasoningLevel,
currentElevatedLevel,
} = params;
const runtimeIsSandboxed = (() => {
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return false;
const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false;
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
if (sandboxMode === "all") return true;
return sessionKey !== mainKey;
})();
const shouldHintDirectRuntime =
directives.hasElevatedDirective && !runtimeIsSandboxed;
if (directives.hasModelDirective) {
const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
@@ -344,7 +384,87 @@ export async function handleDirectiveOnly(params: {
modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) {
if (allowedModelCatalog.length === 0) {
return { text: "No models available." };
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const fallbackKeys = new Set<string>();
const fallbackCatalog: Array<{
provider: string;
id: string;
}> = [];
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (fallbackKeys.has(key)) continue;
fallbackKeys.add(key);
fallbackCatalog.push({
provider: resolved.ref.provider,
id: resolved.ref.model,
});
}
if (fallbackCatalog.length === 0 && resolvedDefault.model) {
const key = modelKey(resolvedDefault.provider, resolvedDefault.model);
fallbackKeys.add(key);
fallbackCatalog.push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
});
}
if (fallbackCatalog.length === 0) {
return { text: "No models available." };
}
const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authByProvider = new Map<string, string>();
for (const entry of fallbackCatalog) {
if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel(
entry.provider,
params.cfg,
modelsPath,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
`⚠️ Model catalog unavailable; showing configured models only.`,
];
const byProvider = new Map<string, typeof fallbackCatalog>();
for (const entry of fallbackCatalog) {
const models = byProvider.get(entry.provider);
if (models) {
models.push(entry);
continue;
}
byProvider.set(entry.provider, [entry]);
}
for (const provider of byProvider.keys()) {
const models = byProvider.get(provider);
if (!models) continue;
const authLabel = authByProvider.get(provider) ?? "missing";
lines.push("");
lines.push(`[${provider}] auth: ${authLabel}`);
for (const entry of models) {
const label = `${entry.provider}/${entry.id}`;
const aliases = aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
lines.push(`${label}${aliasSuffix}`);
}
}
return { text: lines.join("\n") };
}
const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`;
@@ -407,7 +527,12 @@ export async function handleDirectiveOnly(params: {
// If no argument was provided, show the current level
if (!directives.rawThinkLevel) {
const level = currentThinkLevel ?? "off";
return { text: `Current thinking level: ${level}.` };
return {
text: withOptions(
`Current thinking level: ${level}.`,
"off, minimal, low, medium, high",
),
};
}
return {
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`,
@@ -416,7 +541,9 @@ export async function handleDirectiveOnly(params: {
if (directives.hasVerboseDirective && !directives.verboseLevel) {
if (!directives.rawVerboseLevel) {
const level = currentVerboseLevel ?? "off";
return { text: `Current verbose level: ${level}.` };
return {
text: withOptions(`Current verbose level: ${level}.`, "on, off"),
};
}
return {
text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`,
@@ -425,7 +552,12 @@ export async function handleDirectiveOnly(params: {
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
if (!directives.rawReasoningLevel) {
const level = currentReasoningLevel ?? "off";
return { text: `Current reasoning level: ${level}.` };
return {
text: withOptions(
`Current reasoning level: ${level}.`,
"on, off, stream",
),
};
}
return {
text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`,
@@ -437,7 +569,14 @@ export async function handleDirectiveOnly(params: {
return { text: "elevated is not available right now." };
}
const level = currentElevatedLevel ?? "off";
return { text: `Current elevated level: ${level}.` };
return {
text: [
withOptions(`Current elevated level: ${level}.`, "on, off"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
]
.filter(Boolean)
.join("\n"),
};
}
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
@@ -473,7 +612,10 @@ export async function handleDirectiveOnly(params: {
typeof settings.cap === "number" ? String(settings.cap) : "default";
const dropLabel = settings.dropPolicy ?? "default";
return {
text: `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`,
text: withOptions(
`Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`,
"modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize",
),
};
}
@@ -651,6 +793,7 @@ export async function handleDirectiveOnly(params: {
? `${SYSTEM_MARK} Elevated mode disabled.`
: `${SYSTEM_MARK} Elevated mode enabled.`,
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}
if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
@@ -686,6 +829,7 @@ export async function handleDirectiveOnly(params: {
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
}
const ack = parts.join(" ").trim();
if (!ack && directives.hasStatusDirective) return undefined;
return { text: ack || "OK." };
}
+2 -1
View File
@@ -56,6 +56,7 @@ const extractLevelDirective = <T>(
const level = normalize(rawLevel);
const cleaned = body
.slice(0, match.start)
.concat(" ")
.concat(body.slice(match.end))
.replace(/\s+/g, " ")
.trim();
@@ -76,7 +77,7 @@ const extractSimpleDirective = (
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
+16
View File
@@ -27,4 +27,20 @@ describe("mention helpers", () => {
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
routing: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: {
work: { mentionPatterns: ["\\bworkbot\\b"] },
},
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});
+19 -3
View File
@@ -1,8 +1,23 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string[] {
if (!cfg) return [];
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
return agentConfig.mentionPatterns ?? [];
}
return cfg.routing?.groupChat?.mentionPatterns ?? [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId);
return patterns
.map((pattern) => {
try {
@@ -48,9 +63,10 @@ export function stripMentions(
text: string,
ctx: MsgContext,
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string {
let result = text;
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
const patterns = resolveMentionPatterns(cfg, agentId);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
+2
View File
@@ -52,6 +52,7 @@ export async function createModelSelectionState(params: {
sessionKey,
storePath,
defaultProvider,
defaultModel,
} = params;
let provider = params.provider;
@@ -76,6 +77,7 @@ export async function createModelSelectionState(params: {
cfg,
catalog: modelCatalog,
defaultProvider,
defaultModel,
});
allowedModelCatalog = allowed.allowedCatalog;
allowedModelKeys = allowed.allowedKeys;
+3 -2
View File
@@ -271,8 +271,9 @@ export function extractQueueDirective(body?: string): {
const argsStart = start + "/queue".length;
const args = body.slice(argsStart);
const parsed = parseQueueDirectiveArgs(args);
const cleanedRaw =
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
const cleanedRaw = `${body.slice(0, start)} ${body.slice(
argsStart + parsed.consumed,
)}`;
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
return {
cleaned,
+30
View File
@@ -1,8 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
const mocks = vi.hoisted(() => ({
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
sendMessageMSTeams: vi.fn(async () => ({
messageId: "m1",
conversationId: "c1",
})),
sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
@@ -15,6 +21,9 @@ vi.mock("../../discord/send.js", () => ({
vi.mock("../../imessage/send.js", () => ({
sendMessageIMessage: mocks.sendMessageIMessage,
}));
vi.mock("../../msteams/send.js", () => ({
sendMessageMSTeams: mocks.sendMessageMSTeams,
}));
vi.mock("../../signal/send.js", () => ({
sendMessageSignal: mocks.sendMessageSignal,
}));
@@ -143,4 +152,25 @@ describe("routeReply", () => {
expect.objectContaining({ accountId: "acc-1", verbose: false }),
);
});
it("routes MS Teams via proactive sender", async () => {
mocks.sendMessageMSTeams.mockClear();
const cfg = {
msteams: {
enabled: true,
},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "msteams",
to: "conversation:19:abc@thread.tacv2",
cfg,
});
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
cfg,
to: "conversation:19:abc@thread.tacv2",
text: "hi",
mediaUrl: undefined,
});
});
});
+16 -2
View File
@@ -10,6 +10,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { sendMessageMSTeams } from "../../msteams/send.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
@@ -54,7 +55,8 @@ export type RouteReplyResult = {
export async function routeReply(
params: RouteReplyParams,
): Promise<RouteReplyResult> {
const { payload, channel, to, accountId, threadId, abortSignal } = params;
const { payload, channel, to, accountId, threadId, cfg, abortSignal } =
params;
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
const text = payload.text ?? "";
@@ -145,6 +147,16 @@ export async function routeReply(
};
}
case "msteams": {
const result = await sendMessageMSTeams({
cfg,
to,
text,
mediaUrl,
});
return { ok: true, messageId: result.messageId };
}
default: {
const _exhaustive: never = channel;
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
@@ -195,7 +207,8 @@ export function isRoutableChannel(
| "discord"
| "signal"
| "imessage"
| "whatsapp" {
| "whatsapp"
| "msteams" {
if (!channel) return false;
return [
"telegram",
@@ -204,5 +217,6 @@ export function isRoutableChannel(
"signal",
"imessage",
"whatsapp",
"msteams",
].includes(channel);
}
+2 -1
View File
@@ -136,7 +136,7 @@ export async function initSessionState(params: {
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg)
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;
@@ -194,6 +194,7 @@ export async function initSessionState(params: {
// Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
responseUsage: baseEntry?.responseUsage,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
sendPolicy: baseEntry?.sendPolicy,
+84 -4
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildStatusMessage } from "./status.js";
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
@@ -45,6 +46,26 @@ afterEach(() => {
describe("buildStatusMessage", () => {
it("summarizes agent readiness and context usage", () => {
const text = buildStatusMessage({
config: {
models: {
providers: {
anthropic: {
apiKey: "test-key",
models: [
{
id: "pi:opus",
cost: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
},
},
],
},
},
},
} as ClawdbotConfig,
agent: {
model: "anthropic/pi:opus",
contextTokens: 32_000,
@@ -52,6 +73,8 @@ describe("buildStatusMessage", () => {
sessionEntry: {
sessionId: "abc",
updatedAt: 0,
inputTokens: 1200,
outputTokens: 800,
totalTokens: 16_000,
contextTokens: 32_000,
thinkingLevel: "low",
@@ -63,22 +86,40 @@ describe("buildStatusMessage", () => {
resolvedThink: "medium",
resolvedVerbose: "off",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
now: 10 * 60_000, // 10 minutes later
});
expect(text).toContain("🦞 ClawdBot");
expect(text).toContain("🧠 Model:");
expect(text).toContain("Runtime: direct");
expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key");
expect(text).toContain("🧮 Tokens: 1.2k in / 800 out · 💵 Cost: $0.0020");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("🧹 Compactions: 2");
expect(text).toContain("Session: agent:main:main");
expect(text).toContain("updated 10m ago");
expect(text).toContain("Runtime: direct");
expect(text).toContain("Think: medium");
expect(text).toContain("Verbose: off");
expect(text).toContain("Elevated: on");
expect(text).toContain("Queue: collect");
});
it("shows verbose/elevated labels only when enabled", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "v1", updatedAt: 0 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
resolvedThink: "low",
resolvedVerbose: "on",
resolvedElevated: "on",
queue: { mode: "collect", depth: 0 },
});
expect(text).toContain("Verbose: on");
expect(text).toContain("Elevated: on");
});
it("prefers model overrides over last-run model", () => {
const text = buildStatusMessage({
agent: {
@@ -97,6 +138,7 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
@@ -109,6 +151,7 @@ describe("buildStatusMessage", () => {
},
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
@@ -118,12 +161,13 @@ describe("buildStatusMessage", () => {
const text = buildStatusMessage({
agent: {},
sessionScope: "per-sender",
webLinked: false,
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model:");
expect(text).toContain("Context:");
expect(text).toContain("Queue:");
expect(text).toContain("Queue: collect");
});
it("includes group activation for group sessions", () => {
@@ -138,6 +182,7 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "api-key",
});
expect(text).toContain("Activation: always");
@@ -157,6 +202,7 @@ describe("buildStatusMessage", () => {
dropPolicy: "old",
showDetails: true,
},
modelAuth: "api-key",
});
expect(text).toContain(
@@ -172,6 +218,7 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
usageLine: "📊 Usage: Claude 80% left (5h)",
modelAuth: "api-key",
});
const lines = text.split("\n");
@@ -180,6 +227,38 @@ describe("buildStatusMessage", () => {
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
});
it("hides cost when not using an API key", () => {
const text = buildStatusMessage({
config: {
models: {
providers: {
anthropic: {
models: [
{
id: "claude-opus-4-5",
cost: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
},
},
],
},
},
},
} as ClawdbotConfig,
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "c1", updatedAt: 0, inputTokens: 10 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "oauth",
});
expect(text).not.toContain("💵 Cost:");
});
it("prefers cached prompt tokens from the session log", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-"));
const previousHome = snapshotHomeEnv();
@@ -237,6 +316,7 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
expect(text).toContain("Context: 1.0k/32k");
+78 -28
View File
@@ -6,6 +6,7 @@ import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
import { resolveModelAuthMode } from "../agents/model-auth.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
derivePromptTokens,
@@ -20,6 +21,12 @@ import {
type SessionScope,
} from "../config/sessions.js";
import { resolveCommitHash } from "../infra/git-commit.js";
import {
estimateUsageCost,
formatTokenCount as formatTokenCountShared,
formatUsd,
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import type {
ElevatedLevel,
@@ -30,6 +37,8 @@ import type {
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
export const formatTokenCount = formatTokenCountShared;
type QueueStatus = {
mode?: string;
depth?: number;
@@ -40,6 +49,7 @@ type QueueStatus = {
};
type StatusArgs = {
config?: ClawdbotConfig;
agent: AgentConfig;
sessionEntry?: SessionEntry;
sessionKey?: string;
@@ -56,6 +66,26 @@ type StatusArgs = {
now?: number;
};
const formatTokens = (
total: number | null | undefined,
contextTokens: number | null,
) => {
const ctx = contextTokens ?? null;
if (total == null) {
const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
return `?/${ctxLabel}`;
}
const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null;
const totalLabel = formatTokenCount(total);
const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
};
export const formatContextUsageShort = (
total: number | null | undefined,
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatAge = (ms?: number | null) => {
if (!ms || ms < 0) return "unknown";
const minutes = Math.round(ms / 60_000);
@@ -67,31 +97,6 @@ const formatAge = (ms?: number | null) => {
return `${days}d ago`;
};
const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
export const formatTokenCount = (value: number) => formatKTokens(value);
const formatTokens = (
total: number | null | undefined,
contextTokens: number | null,
) => {
const ctx = contextTokens ?? null;
if (total == null) {
const ctxLabel = ctx ? formatKTokens(ctx) : "?";
return `unknown/${ctxLabel}`;
}
const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null;
const totalLabel = formatKTokens(total);
const ctxLabel = ctx ? formatKTokens(ctx) : "?";
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
};
export const formatContextUsageShort = (
total: number | null | undefined,
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) return "";
const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null;
@@ -171,6 +176,14 @@ const readUsageFromSessionLog = (
}
};
const formatUsagePair = (input?: number | null, output?: number | null) => {
if (input == null && output == null) return null;
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel =
typeof output === "number" ? formatTokenCount(output) : "?";
return `🧮 Tokens: ${inputLabel} in / ${outputLabel} out`;
};
export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
@@ -188,6 +201,8 @@ export function buildStatusMessage(args: StatusArgs): string {
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS;
let inputTokens = entry?.inputTokens;
let outputTokens = entry?.outputTokens;
let totalTokens =
entry?.totalTokens ??
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
@@ -205,6 +220,8 @@ export function buildStatusMessage(args: StatusArgs): string {
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input;
if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output;
}
}
@@ -278,15 +295,48 @@ export function buildStatusMessage(args: StatusArgs): string {
];
const activationLine = activationParts.filter(Boolean).join(" · ");
const authMode = resolveModelAuthMode(provider, args.config);
const authLabelValue =
args.modelAuth ??
(authMode && authMode !== "unknown" ? authMode : undefined);
const showCost = authLabelValue === "api-key" || authLabelValue === "mixed";
const costConfig = showCost
? resolveModelCostConfig({
provider,
model,
config: args.config,
})
: undefined;
const hasUsage =
typeof inputTokens === "number" || typeof outputTokens === "number";
const cost =
showCost && hasUsage
? estimateUsageCost({
usage: {
input: inputTokens ?? undefined,
output: outputTokens ?? undefined,
},
cost: costConfig,
})
: undefined;
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const modelLabel = model ? `${provider}/${model}` : "unknown";
const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : "";
const authLabel = authLabelValue ? ` · 🔑 ${authLabelValue}` : "";
const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
const commit = resolveCommitHash();
const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
const usagePair = formatUsagePair(inputTokens, outputTokens);
const costLine = costLabel ? `💵 Cost: ${costLabel}` : null;
const usageCostLine =
usagePair && costLine
? `${usagePair} · ${costLine}`
: (usagePair ?? costLine);
return [
versionLine,
modelLine,
usageCostLine,
`📚 ${contextLine}`,
args.usageLine,
`🧵 ${sessionLine}`,
@@ -300,7 +350,7 @@ export function buildStatusMessage(args: StatusArgs): string {
export function buildHelpMessage(): string {
return [
"️ Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id>",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
].join("\n");
}
+2 -1
View File
@@ -6,7 +6,8 @@ export type OriginatingChannelType =
| "signal"
| "imessage"
| "whatsapp"
| "webchat";
| "webchat"
| "msteams";
export type MsgContext = {
Body?: string;
+14
View File
@@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on";
// Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel(
@@ -46,6 +47,19 @@ export function normalizeVerboseLevel(
return undefined;
}
// Normalize response-usage display flags used to toggle cost/token lines.
export function normalizeUsageDisplay(
raw?: string | null,
): UsageDisplayLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0", "disable", "disabled"].includes(key))
return "off";
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key))
return "on";
return undefined;
}
// Normalize elevated flags used to toggle elevated bash permissions.
export function normalizeElevatedLevel(
raw?: string | null,
+1 -1
View File
@@ -231,7 +231,7 @@ describe("canvas host", () => {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
}, 10_000);
it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));
+1
View File
@@ -271,6 +271,7 @@ export async function createCanvasHostHandler(
? chokidar.watch(rootReal, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
usePolling: opts.allowInTests === true,
ignored: [
/(^|[\\/])\../, // dotfiles
/(^|[\\/])node_modules([\\/]|$)/,
+49
View File
@@ -10,6 +10,20 @@ type BannerOptions = TaglineOptions & {
let bannerEmitted = false;
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function splitGraphemes(value: string): string[] {
if (!graphemeSegmenter) return Array.from(value);
try {
return Array.from(graphemeSegmenter.segment(value), (seg) => seg.segment);
} catch {
return Array.from(value);
}
}
const hasJsonFlag = (argv: string[]) =>
argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
@@ -33,6 +47,41 @@ export function formatCliBannerLine(
return `${title} ${version} (${commitLabel}) — ${tagline}`;
}
const LOBSTER_ASCII = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
const rich = options.richTty ?? isRich();
if (!rich) return LOBSTER_ASCII.join("\n");
const colorChar = (ch: string) => {
if (ch === "█") return theme.accentBright(ch);
if (ch === "░") return theme.accentDim(ch);
if (ch === "▀") return theme.accent(ch);
return theme.muted(ch);
};
const colored = LOBSTER_ASCII.map((line) => {
if (line.includes("FRESH DAILY")) {
return (
theme.muted(" ") +
theme.accent("🦞") +
theme.info(" FRESH DAILY ") +
theme.accent("🦞")
);
}
return splitGraphemes(line).map(colorChar).join("");
});
return colored.join("\n");
}
export function emitCliBanner(version: string, options: BannerOptions = {}) {
if (bannerEmitted) return;
const argv = options.argv ?? process.argv;
+161 -58
View File
@@ -32,7 +32,11 @@ import {
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { callGateway } from "../gateway/call.js";
import { resolveGatewayBindHost } from "../gateway/net.js";
import {
@@ -44,6 +48,7 @@ import {
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { getResolvedLoggerSettings } from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { createDefaultDeps } from "./deps.js";
import { withProgress } from "./progress.js";
@@ -89,6 +94,7 @@ type DaemonStatus = {
cachedLabel?: boolean;
missingUnit?: boolean;
};
configAudit?: ServiceConfigAudit;
};
config?: {
cli: ConfigSummary;
@@ -343,6 +349,10 @@ async function gatherDaemonStatus(opts: {
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);
const configAudit = await auditGatewayServiceConfig({
env: process.env,
command,
});
const serviceEnv = command?.environment ?? undefined;
const mergedDaemonEnv = {
@@ -484,6 +494,7 @@ async function gatherDaemonStatus(opts: {
notLoadedText: service.notLoadedText,
command,
runtime,
configAudit,
},
config: {
cli: cliConfigSummary,
@@ -513,90 +524,146 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
return;
}
const rich = isRich();
const label = (value: string) => colorize(rich, theme.muted, value);
const accent = (value: string) => colorize(rich, theme.accent, value);
const infoText = (value: string) => colorize(rich, theme.info, value);
const okText = (value: string) => colorize(rich, theme.success, value);
const warnText = (value: string) => colorize(rich, theme.warn, value);
const errorText = (value: string) => colorize(rich, theme.error, value);
const spacer = () => defaultRuntime.log("");
const { service, rpc, legacyServices, extraServices } = status;
const serviceStatus = service.loaded
? okText(service.loadedText)
: warnText(service.notLoadedText);
defaultRuntime.log(
`Service: ${service.label} (${service.loaded ? service.loadedText : service.notLoadedText})`,
`${label("Service:")} ${accent(service.label)} (${serviceStatus})`,
);
try {
const logFile = getResolvedLoggerSettings().file;
defaultRuntime.log(`File logs: ${logFile}`);
defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
} catch {
// ignore missing config/log resolution
}
if (service.command?.programArguments?.length) {
defaultRuntime.log(
`Command: ${service.command.programArguments.join(" ")}`,
`${label("Command:")} ${infoText(service.command.programArguments.join(" "))}`,
);
}
if (service.command?.sourcePath) {
defaultRuntime.log(`Service file: ${service.command.sourcePath}`);
defaultRuntime.log(
`${label("Service file:")} ${infoText(service.command.sourcePath)}`,
);
}
if (service.command?.workingDirectory) {
defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`);
defaultRuntime.log(
`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`,
);
}
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
if (daemonEnvLines.length > 0) {
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`);
}
spacer();
if (service.configAudit?.issues.length) {
defaultRuntime.error(
warnText("Service config looks out of date or non-standard."),
);
for (const issue of service.configAudit.issues) {
const detail = issue.detail ? ` (${issue.detail})` : "";
defaultRuntime.error(
`${warnText("Service config issue:")} ${issue.message}${detail}`,
);
}
defaultRuntime.error(
warnText(
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
),
);
}
if (status.config) {
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`Config (cli): ${cliCfg}`);
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
if (!status.config.cli.valid && status.config.cli.issues?.length) {
for (const issue of status.config.cli.issues.slice(0, 5)) {
defaultRuntime.error(
`Config issue: ${issue.path || "<root>"}: ${issue.message}`,
`${errorText("Config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
);
}
}
if (status.config.daemon) {
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`Config (daemon): ${daemonCfg}`);
defaultRuntime.log(`${label("Config (daemon):")} ${infoText(daemonCfg)}`);
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
for (const issue of status.config.daemon.issues.slice(0, 5)) {
defaultRuntime.error(
`Daemon config issue: ${issue.path || "<root>"}: ${issue.message}`,
`${errorText("Daemon config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
);
}
}
}
if (status.config.mismatch) {
defaultRuntime.error(
"Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).",
errorText(
"Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).",
),
);
defaultRuntime.error(
"Fix: rerun `clawdbot daemon install --force` from the same --profile / CLAWDBOT_STATE_DIR you expect.",
errorText(
"Fix: rerun `clawdbot daemon install --force` from the same --profile / CLAWDBOT_STATE_DIR you expect.",
),
);
}
spacer();
}
if (status.gateway) {
const bindHost = status.gateway.bindHost ?? "n/a";
defaultRuntime.log(
`Gateway: bind=${status.gateway.bindMode} (${bindHost}), port=${status.gateway.port} (${status.gateway.portSource})`,
`${label("Gateway:")} bind=${infoText(status.gateway.bindMode)} (${infoText(bindHost)}), port=${infoText(String(status.gateway.port))} (${infoText(status.gateway.portSource)})`,
);
defaultRuntime.log(
`${label("Probe target:")} ${infoText(status.gateway.probeUrl)}`,
);
defaultRuntime.log(`Probe target: ${status.gateway.probeUrl}`);
const controlUiEnabled = status.config?.daemon?.controlUi?.enabled ?? true;
if (!controlUiEnabled) {
defaultRuntime.log("Dashboard: disabled");
defaultRuntime.log(`${label("Dashboard:")} ${warnText("disabled")}`);
} else {
const links = resolveControlUiLinks({
port: status.gateway.port,
bind: status.gateway.bindMode,
basePath: status.config?.daemon?.controlUi?.basePath,
});
defaultRuntime.log(`Dashboard: ${links.httpUrl}`);
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`);
}
if (status.gateway.probeNote) {
defaultRuntime.log(`Probe note: ${status.gateway.probeNote}`);
defaultRuntime.log(
`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`,
);
}
if (status.gateway.bindMode === "tailnet" && !status.gateway.bindHost) {
defaultRuntime.error(
"Root cause: gateway bind=tailnet but no tailnet interface was found.",
errorText(
"Root cause: gateway bind=tailnet but no tailnet interface was found.",
),
);
}
spacer();
}
const runtimeLine = formatRuntimeStatus(service.runtime);
if (runtimeLine) {
defaultRuntime.log(`Runtime: ${runtimeLine}`);
const runtimeStatus = service.runtime?.status ?? "unknown";
const runtimeColor =
runtimeStatus === "running"
? theme.success
: runtimeStatus === "stopped"
? theme.error
: runtimeStatus === "unknown"
? theme.muted
: theme.warn;
defaultRuntime.log(
`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`,
);
}
if (
rpc &&
@@ -605,44 +672,53 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
service.runtime?.status === "running"
) {
defaultRuntime.log(
"Warm-up: launch agents can take a few seconds. Try again shortly.",
warnText(
"Warm-up: launch agents can take a few seconds. Try again shortly.",
),
);
}
if (rpc) {
if (rpc.ok) {
defaultRuntime.log("RPC probe: ok");
defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`);
} else {
defaultRuntime.error("RPC probe: failed");
if (rpc.url) defaultRuntime.error(`RPC target: ${rpc.url}`);
defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`);
if (rpc.url) defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`);
const lines = String(rpc.error ?? "unknown")
.split(/\r?\n/)
.filter(Boolean);
for (const line of lines.slice(0, 12)) {
defaultRuntime.error(` ${line}`);
defaultRuntime.error(` ${errorText(line)}`);
}
}
spacer();
}
if (service.runtime?.missingUnit) {
defaultRuntime.error("Service unit not found.");
defaultRuntime.error(errorText("Service unit not found."));
for (const hint of renderRuntimeHints(service.runtime)) {
defaultRuntime.error(hint);
defaultRuntime.error(errorText(hint));
}
} else if (service.loaded && service.runtime?.status === "stopped") {
defaultRuntime.error(
"Service is loaded but not running (likely exited immediately).",
errorText(
"Service is loaded but not running (likely exited immediately).",
),
);
for (const hint of renderRuntimeHints(
service.runtime,
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
)) {
defaultRuntime.error(hint);
defaultRuntime.error(errorText(hint));
}
spacer();
}
if (service.runtime?.cachedLabel) {
defaultRuntime.error(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
errorText(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
),
);
defaultRuntime.error("Then reinstall: clawdbot daemon install");
defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install"));
spacer();
}
if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) {
for (const line of formatPortDiagnostics({
@@ -651,7 +727,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
listeners: status.port.listeners,
hints: status.port.hints,
})) {
defaultRuntime.error(line);
defaultRuntime.error(errorText(line));
}
}
if (status.port) {
@@ -663,12 +739,14 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
),
);
if (addrs.length > 0) {
defaultRuntime.log(`Listening: ${addrs.join(", ")}`);
defaultRuntime.log(
`${label("Listening:")} ${infoText(addrs.join(", "))}`,
);
}
}
if (status.portCli && status.portCli.port !== status.port?.port) {
defaultRuntime.log(
`Note: CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`,
`${label("Note:")} CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`,
);
}
if (
@@ -678,52 +756,72 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
status.port.status !== "busy"
) {
defaultRuntime.error(
`Gateway port ${status.port.port} is not listening (service appears running).`,
errorText(
`Gateway port ${status.port.port} is not listening (service appears running).`,
),
);
if (status.lastError) {
defaultRuntime.error(`Last gateway error: ${status.lastError}`);
defaultRuntime.error(
`${errorText("Last gateway error:")} ${status.lastError}`,
);
}
if (process.platform === "linux") {
defaultRuntime.error(
`Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`,
errorText(
`Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`,
),
);
} else if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
);
defaultRuntime.error(`Logs: ${logs.stdoutPath}`);
defaultRuntime.error(`Errors: ${logs.stderrPath}`);
defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
}
spacer();
}
if (legacyServices.length > 0) {
defaultRuntime.error("Legacy Clawdis services detected:");
defaultRuntime.error(errorText("Legacy Clawdis services detected:"));
for (const svc of legacyServices) {
defaultRuntime.error(`- ${svc.label} (${svc.detail})`);
defaultRuntime.error(`- ${errorText(svc.label)} (${svc.detail})`);
}
defaultRuntime.error("Cleanup: clawdbot doctor");
defaultRuntime.error(errorText("Cleanup: clawdbot doctor"));
spacer();
}
if (extraServices.length > 0) {
defaultRuntime.error("Other gateway-like services detected (best effort):");
defaultRuntime.error(
errorText("Other gateway-like services detected (best effort):"),
);
for (const svc of extraServices) {
defaultRuntime.error(`- ${svc.label} (${svc.scope}, ${svc.detail})`);
defaultRuntime.error(
`- ${errorText(svc.label)} (${svc.scope}, ${svc.detail})`,
);
}
for (const hint of renderGatewayServiceCleanupHints()) {
defaultRuntime.error(`Cleanup hint: ${hint}`);
defaultRuntime.error(`${errorText("Cleanup hint:")} ${hint}`);
}
spacer();
}
if (legacyServices.length > 0 || extraServices.length > 0) {
defaultRuntime.error(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
errorText(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
),
);
defaultRuntime.error(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
errorText(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
),
);
spacer();
}
defaultRuntime.log("Troubles: run clawdbot status");
defaultRuntime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting");
defaultRuntime.log(`${label("Troubles:")} run clawdbot status`);
defaultRuntime.log(
`${label("Troubleshooting:")} https://docs.clawd.bot/troubleshooting`,
);
}
export async function runDaemonStatus(opts: DaemonStatusOptions) {
@@ -735,7 +833,10 @@ export async function runDaemonStatus(opts: DaemonStatusOptions) {
});
printDaemonStatus(status, { json: Boolean(opts.json) });
} catch (err) {
defaultRuntime.error(`Daemon status failed: ${String(err)}`);
const rich = isRich();
defaultRuntime.error(
colorize(rich, theme.error, `Daemon status failed: ${String(err)}`),
);
defaultRuntime.exit(1);
}
}
@@ -789,25 +890,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: runtimeRaw,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeRaw,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
opts.token ||
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
try {
await service.install({
+3
View File
@@ -1,5 +1,6 @@
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
import { sendMessageSignal } from "../signal/send.js";
import { sendMessageSlack } from "../slack/send.js";
@@ -12,6 +13,7 @@ export type CliDeps = {
sendMessageSlack: typeof sendMessageSlack;
sendMessageSignal: typeof sendMessageSignal;
sendMessageIMessage: typeof sendMessageIMessage;
sendMessageMSTeams: typeof sendMessageMSTeams;
};
export function createDefaultDeps(): CliDeps {
@@ -22,6 +24,7 @@ export function createDefaultDeps(): CliDeps {
sendMessageSlack,
sendMessageSignal,
sendMessageIMessage,
sendMessageMSTeams,
};
}
+79 -1
View File
@@ -12,6 +12,8 @@ const forceFreePortAndWait = vi.fn(async () => ({
escalatedToSigkill: false,
}));
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
const discoverGatewayBeacons = vi.fn(async () => []);
const gatewayStatusCommand = vi.fn(async () => {});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -90,8 +92,16 @@ vi.mock("../daemon/program-args.js", () => ({
}),
}));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
vi.mock("../commands/gateway-status.js", () => ({
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
}));
describe("gateway-cli coverage", () => {
it("registers call/health/status commands and routes to callGateway", async () => {
it("registers call/health commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
@@ -110,6 +120,74 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
});
it("registers gateway status and routes to gatewayStatusCommand", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
gatewayStatusCommand.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "status", "--json"], { from: "user" });
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
});
it("registers gateway discover and prints JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (Clawdbot)",
displayName: "Studio",
domain: "local.",
host: "studio.local",
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--json"], {
from: "user",
});
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"beacons"');
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
expect(runtimeLogs.join("\n")).toContain("ws://");
});
it("validates gateway discover timeout", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
});
it("fails gateway call on invalid params JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
+441 -30
View File
@@ -1,12 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { gatewayStatusCommand } from "../commands/gateway-status.js";
import { moveToTrash } from "../commands/onboard-helpers.js";
import {
CONFIG_PATH_CLAWDBOT,
type GatewayAuthMode,
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
@@ -22,10 +28,18 @@ import {
setGatewayWsLogStyle,
} from "../gateway/ws-logging.js";
import { setVerbose } from "../globals.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
import {
createSubsystemLogger,
setConsoleSubsystemFilter,
} from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveUserPath } from "../utils.js";
import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
@@ -35,6 +49,7 @@ type GatewayRpcOpts = {
password?: string;
timeout?: string;
expectFinal?: boolean;
json?: boolean;
};
type GatewayRunOpts = {
@@ -48,8 +63,13 @@ type GatewayRunOpts = {
allowUnconfigured?: boolean;
force?: boolean;
verbose?: boolean;
claudeCliLogs?: boolean;
wsLog?: unknown;
compact?: boolean;
rawStream?: boolean;
rawStreamPath?: unknown;
dev?: boolean;
reset?: boolean;
};
type GatewayRunParams = {
@@ -57,6 +77,33 @@ type GatewayRunParams = {
};
const gatewayLog = createSubsystemLogger("gateway");
const DEV_IDENTITY_NAME = "C3-PO";
const DEV_IDENTITY_THEME = "protocol droid";
const DEV_IDENTITY_EMOJI = "🤖";
const DEV_AGENT_WORKSPACE_SUFFIX = "dev";
const DEV_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Dev Workspace
Default dev workspace for clawdbot gateway --dev.
- Keep replies concise and direct.
- Prefer observable debugging steps and logs.
- Avoid destructive actions unless asked.
`;
const DEV_SOUL_TEMPLATE = `# SOUL.md - Dev Persona
Protocol droid for debugging and operations.
- Concise, structured answers.
- Ask for missing context before guessing.
- Prefer reproducible steps and logs.
`;
const DEV_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity
- Name: ${DEV_IDENTITY_NAME}
- Creature: protocol droid
- Vibe: ${DEV_IDENTITY_THEME}
- Emoji: ${DEV_IDENTITY_EMOJI}
`;
type GatewayRunSignalAction = "stop" | "restart";
@@ -74,6 +121,184 @@ function parsePort(raw: unknown): number | null {
return parsed;
}
const toOptionString = (value: unknown): string | undefined => {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "bigint")
return value.toString();
return undefined;
};
const resolveDevWorkspaceDir = (
env: NodeJS.ProcessEnv = process.env,
): string => {
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
return `${baseDir}-${DEV_AGENT_WORKSPACE_SUFFIX}`;
};
async function writeFileIfMissing(filePath: string, content: string) {
try {
await fs.promises.writeFile(filePath, content, {
encoding: "utf-8",
flag: "wx",
});
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "EEXIST") throw err;
}
}
async function ensureDevWorkspace(dir: string) {
const resolvedDir = resolveUserPath(dir);
await fs.promises.mkdir(resolvedDir, { recursive: true });
await writeFileIfMissing(
path.join(resolvedDir, "AGENTS.md"),
DEV_AGENTS_TEMPLATE,
);
await writeFileIfMissing(
path.join(resolvedDir, "SOUL.md"),
DEV_SOUL_TEMPLATE,
);
await writeFileIfMissing(
path.join(resolvedDir, "IDENTITY.md"),
DEV_IDENTITY_TEMPLATE,
);
}
async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
if (opts.reset && configExists) {
await moveToTrash(CONFIG_PATH_CLAWDBOT, defaultRuntime);
}
const shouldWrite = opts.reset || !configExists;
if (!shouldWrite) return;
const workspace = resolveDevWorkspaceDir();
await writeConfigFile({
gateway: {
mode: "local",
bind: "loopback",
},
agent: {
workspace,
skipBootstrap: true,
},
identity: {
name: DEV_IDENTITY_NAME,
theme: DEV_IDENTITY_THEME,
emoji: DEV_IDENTITY_EMOJI,
},
});
await ensureDevWorkspace(workspace);
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
}
type GatewayDiscoverOpts = {
timeout?: string;
json?: boolean;
};
function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number {
if (raw === undefined || raw === null) return fallbackMs;
const value =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "bigint"
? String(raw)
: null;
if (value === null) {
throw new Error("invalid --timeout");
}
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`invalid --timeout: ${value}`);
}
return parsed;
}
function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
const host = beacon.tailnetDns || beacon.lanHost || beacon.host;
return host?.trim() ? host.trim() : null;
}
function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
const port = beacon.gatewayPort ?? 18789;
return port > 0 ? port : 18789;
}
function dedupeBeacons(
beacons: GatewayBonjourBeacon[],
): GatewayBonjourBeacon[] {
const out: GatewayBonjourBeacon[] = [];
const seen = new Set<string>();
for (const b of beacons) {
const host = pickBeaconHost(b) ?? "";
const key = [
b.domain ?? "",
b.instanceName ?? "",
b.displayName ?? "",
host,
String(b.port ?? ""),
String(b.bridgePort ?? ""),
String(b.gatewayPort ?? ""),
].join("|");
if (seen.has(key)) continue;
seen.add(key);
out.push(b);
}
return out;
}
function renderBeaconLines(
beacon: GatewayBonjourBeacon,
rich: boolean,
): string[] {
const nameRaw = (
beacon.displayName ||
beacon.instanceName ||
"Gateway"
).trim();
const domainRaw = (beacon.domain || "local.").trim();
const title = colorize(rich, theme.accentBright, nameRaw);
const domain = colorize(rich, theme.muted, domainRaw);
const parts: string[] = [];
if (beacon.tailnetDns)
parts.push(
`${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`,
);
if (beacon.lanHost)
parts.push(`${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`);
if (beacon.host)
parts.push(`${colorize(rich, theme.info, "host")}: ${beacon.host}`);
const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon);
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
const firstLine =
parts.length > 0
? `${title} ${domain} · ${parts.join(" · ")}`
: `${title} ${domain}`;
const lines = [`- ${firstLine}`];
if (wsUrl) {
lines.push(
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
lines.push(
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`,
);
}
return lines;
}
function describeUnknownError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
@@ -206,9 +431,18 @@ async function runGatewayLoop(params: {
})();
};
const onSigterm = () => request("stop", "SIGTERM");
const onSigint = () => request("stop", "SIGINT");
const onSigusr1 = () => request("restart", "SIGUSR1");
const onSigterm = () => {
gatewayLog.info("signal SIGTERM received");
request("stop", "SIGTERM");
};
const onSigint = () => {
gatewayLog.info("signal SIGINT received");
request("stop", "SIGINT");
};
const onSigusr1 = () => {
gatewayLog.info("signal SIGUSR1 received");
request("restart", "SIGUSR1");
};
process.on("SIGTERM", onSigterm);
process.on("SIGINT", onSigint);
@@ -238,7 +472,8 @@ const gatewayCallOpts = (cmd: Command) =>
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false);
.option("--expect-final", "Wait for final response (agent)", false)
.option("--json", "Output JSON", false);
const callGatewayCli = async (
method: string,
@@ -249,7 +484,7 @@ const callGatewayCli = async (
{
label: `Gateway ${method}`,
indeterminate: true,
enabled: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
@@ -269,6 +504,11 @@ async function runGatewayCommand(
opts: GatewayRunOpts,
params: GatewayRunParams = {},
) {
if (opts.reset && !opts.dev) {
defaultRuntime.error("Use --reset with --dev.");
defaultRuntime.exit(1);
return;
}
if (params.legacyTokenEnv) {
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
@@ -277,6 +517,10 @@ async function runGatewayCommand(
}
setVerbose(Boolean(opts.verbose));
if (opts.claudeCliLogs) {
setConsoleSubsystemFilter(["agent/claude-cli"]);
process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT = "1";
}
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
| string
| undefined;
@@ -293,6 +537,18 @@ async function runGatewayCommand(
}
setGatewayWsLogStyle(wsLogStyle);
if (opts.rawStream) {
process.env.CLAWDBOT_RAW_STREAM = "1";
}
const rawStreamPath = toOptionString(opts.rawStreamPath);
if (rawStreamPath) {
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
}
if (opts.dev) {
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
@@ -338,9 +594,10 @@ async function runGatewayCommand(
}
}
if (opts.token) {
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
const token = toOptionString(opts.token);
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
}
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
const authModeRaw = toOptionString(opts.auth);
const authMode: GatewayAuthMode | null =
authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null;
if (authModeRaw && !authMode) {
@@ -348,7 +605,7 @@ async function runGatewayCommand(
defaultRuntime.exit(1);
return;
}
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
const tailscaleRaw = toOptionString(opts.tailscale);
const tailscaleMode =
tailscaleRaw === "off" ||
tailscaleRaw === "serve" ||
@@ -362,6 +619,8 @@ async function runGatewayCommand(
defaultRuntime.exit(1);
return;
}
const passwordRaw = toOptionString(opts.password);
const tokenRaw = toOptionString(opts.token);
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") {
@@ -377,7 +636,7 @@ async function runGatewayCommand(
defaultRuntime.exit(1);
return;
}
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
const bind =
bindRaw === "loopback" ||
bindRaw === "tailnet" ||
@@ -398,8 +657,8 @@ async function runGatewayCommand(
const authConfig = {
...cfg.gateway?.auth,
...(authMode ? { mode: authMode } : {}),
...(opts.password ? { password: String(opts.password) } : {}),
...(opts.token ? { token: String(opts.token) } : {}),
...(passwordRaw ? { password: passwordRaw } : {}),
...(tokenRaw ? { token: tokenRaw } : {}),
};
const resolvedAuth = resolveGatewayAuth({
authConfig,
@@ -467,11 +726,11 @@ async function runGatewayCommand(
await startGatewayServer(port, {
bind,
auth:
authMode || opts.password || opts.token || authModeRaw
authMode || passwordRaw || tokenRaw || authModeRaw
? {
mode: authMode ?? undefined,
token: opts.token ? String(opts.token) : undefined,
password: opts.password ? String(opts.password) : undefined,
token: tokenRaw,
password: passwordRaw,
}
: undefined,
tailscale:
@@ -543,18 +802,31 @@ function addGatewayRunCommand(
"Allow gateway start without gateway.mode=local in config",
false,
)
.option(
"--dev",
"Create a dev config + workspace if missing (no BOOTSTRAP.md)",
false,
)
.option("--reset", "Recreate dev config (requires --dev)", false)
.option(
"--force",
"Kill any existing listener on the target port before starting",
false,
)
.option("--verbose", "Verbose logging to stdout/stderr", false)
.option(
"--claude-cli-logs",
"Only show claude-cli logs in the console (includes stdout/stderr)",
false,
)
.option(
"--ws-log <style>",
'WebSocket log style ("auto"|"full"|"compact")',
"auto",
)
.option("--compact", 'Alias for "--ws-log compact"', false)
.option("--raw-stream", "Log raw model stream events to jsonl", false)
.option("--raw-stream-path <path>", "Raw stream jsonl path")
.action(async (opts) => {
await runGatewayCommand(opts, params);
});
@@ -576,7 +848,7 @@ export function registerGatewayCli(program: Command) {
gatewayCallOpts(
gateway
.command("call")
.description("Call a Gateway method and print JSON")
.description("Call a Gateway method")
.argument(
"<method>",
"Method name (health/status/system-presence/cron.*)",
@@ -586,6 +858,18 @@ export function registerGatewayCli(program: Command) {
try {
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
defaultRuntime.log(
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(
rich,
theme.muted,
String(method),
)}`,
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
@@ -601,7 +885,46 @@ export function registerGatewayCli(program: Command) {
.action(async (opts) => {
try {
const result = await callGatewayCli("health", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
const obj =
result && typeof result === "object"
? (result as Record<string, unknown>)
: {};
const durationMs =
typeof obj.durationMs === "number" ? obj.durationMs : null;
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
defaultRuntime.log(
`${colorize(rich, theme.success, "OK")}${
durationMs != null ? ` (${durationMs}ms)` : ""
}`,
);
if (obj.web && typeof obj.web === "object") {
const web = obj.web as Record<string, unknown>;
const linked = web.linked === true;
defaultRuntime.log(
`Web: ${linked ? "linked" : "not linked"}${
typeof web.authAgeMs === "number" && linked
? ` (${Math.round(web.authAgeMs / 60_000)}m)`
: ""
}`,
);
}
if (obj.telegram && typeof obj.telegram === "object") {
const tg = obj.telegram as Record<string, unknown>;
defaultRuntime.log(
`Telegram: ${tg.configured === true ? "configured" : "not configured"}`,
);
}
if (obj.discord && typeof obj.discord === "object") {
const dc = obj.discord as Record<string, unknown>;
defaultRuntime.log(
`Discord: ${dc.configured === true ? "configured" : "not configured"}`,
);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
@@ -609,18 +932,106 @@ export function registerGatewayCli(program: Command) {
}),
);
gatewayCallOpts(
gateway
.command("status")
.description("Fetch Gateway status")
.action(async (opts) => {
try {
const result = await callGatewayCli("status", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
gateway
.command("status")
.description(
"Show gateway reachability + discovery + health + status summary (local + remote)",
)
.option(
"--url <url>",
"Explicit Gateway WebSocket URL (still probes localhost)",
)
.option(
"--ssh <target>",
"SSH target for remote gateway tunnel (user@host or user@host:port)",
)
.option("--ssh-identity <path>", "SSH identity file path")
.option(
"--ssh-auto",
"Try to derive an SSH target from Bonjour discovery",
false,
)
.option("--token <token>", "Gateway token (applies to all probes)")
.option("--password <password>", "Gateway password (applies to all probes)")
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await gatewayStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
gateway
.command("discover")
.description(
`Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
)
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayDiscoverOpts) => {
try {
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const beacons = await withProgress(
{
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
},
async () => await discoverGatewayBeacons({ timeoutMs }),
);
const deduped = dedupeBeacons(beacons).sort((a, b) =>
String(a.displayName || a.instanceName).localeCompare(
String(b.displayName || b.instanceName),
),
);
if (opts.json) {
const enriched = deduped.map((b) => {
const host = pickBeaconHost(b);
const port = pickGatewayPort(b);
return {
...b,
wsUrl: host ? `ws://${host}:${port}` : null,
};
});
defaultRuntime.log(
JSON.stringify(
{
timeoutMs,
domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
count: enriched.length,
beacons: enriched,
},
null,
2,
),
);
return;
}
}),
);
const rich = isRich();
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
defaultRuntime.log(
colorize(
rich,
theme.muted,
`Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
),
);
if (deduped.length === 0) return;
for (const beacon of deduped) {
for (const line of renderBeaconLines(beacon, rich)) {
defaultRuntime.log(line);
}
}
} catch (err) {
defaultRuntime.error(`gateway discover failed: ${String(err)}`);
defaultRuntime.exit(1);
}
});
}
+3 -1
View File
@@ -90,8 +90,10 @@ describe("gateway SIGTERM", () => {
const err: string[] = [];
child = spawn(
"bun",
process.execPath,
[
"--import",
"tsx",
"src/index.ts",
"gateway",
"--port",
+152 -8
View File
@@ -1,6 +1,9 @@
import { setTimeout as delay } from "node:timers/promises";
import type { Command } from "commander";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { parseLogLine } from "../logging/parse-log-line.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
type LogsTailPayload = {
@@ -18,6 +21,8 @@ type LogsCliOptions = {
follow?: boolean;
interval?: string;
json?: boolean;
plain?: boolean;
color?: boolean;
url?: string;
token?: string;
timeout?: string;
@@ -47,6 +52,98 @@ async function fetchLogs(
return payload as LogsTailPayload;
}
function formatLogTimestamp(
value?: string,
mode: "pretty" | "plain" = "plain",
) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
if (mode === "pretty") return parsed.toISOString().slice(11, 19);
return parsed.toISOString();
}
function formatLogLine(
raw: string,
opts: {
pretty: boolean;
rich: boolean;
},
): string {
const parsed = parseLogLine(raw);
if (!parsed) return raw;
const label = parsed.subsystem ?? parsed.module ?? "";
const time = formatLogTimestamp(
parsed.time,
opts.pretty ? "pretty" : "plain",
);
const level = parsed.level ?? "";
const levelLabel = level.padEnd(5).trim();
const message = parsed.message || parsed.raw;
if (!opts.pretty) {
return [time, level, label, message].filter(Boolean).join(" ").trim();
}
const timeLabel = colorize(opts.rich, theme.muted, time);
const labelValue = colorize(opts.rich, theme.accent, label);
const levelValue =
level === "error" || level === "fatal"
? colorize(opts.rich, theme.error, levelLabel)
: level === "warn"
? colorize(opts.rich, theme.warn, levelLabel)
: level === "debug" || level === "trace"
? colorize(opts.rich, theme.muted, levelLabel)
: colorize(opts.rich, theme.info, levelLabel);
const messageValue =
level === "error" || level === "fatal"
? colorize(opts.rich, theme.error, message)
: level === "warn"
? colorize(opts.rich, theme.warn, message)
: level === "debug" || level === "trace"
? colorize(opts.rich, theme.muted, message)
: colorize(opts.rich, theme.info, message);
const head = [timeLabel, levelValue, labelValue].filter(Boolean).join(" ");
return [head, messageValue].filter(Boolean).join(" ").trim();
}
function emitJsonLine(payload: Record<string, unknown>, toStdErr = false) {
const text = `${JSON.stringify(payload)}\n`;
if (toStdErr) process.stderr.write(text);
else process.stdout.write(text);
}
function emitGatewayError(
err: unknown,
opts: LogsCliOptions,
mode: "json" | "text",
rich: boolean,
) {
const details = buildGatewayConnectionDetails({ url: opts.url });
const message = "Gateway not reachable. Is it running and accessible?";
const hint = "Hint: run `clawdbot doctor`.";
const errorText = err instanceof Error ? err.message : String(err);
if (mode === "json") {
emitJsonLine(
{
type: "error",
message,
error: errorText,
details,
hint,
},
true,
);
return;
}
defaultRuntime.error(colorize(rich, theme.error, message));
defaultRuntime.error(details.message);
defaultRuntime.error(colorize(rich, theme.muted, hint));
}
export function registerLogsCli(program: Command) {
const logs = program
.command("logs")
@@ -55,7 +152,9 @@ export function registerLogsCli(program: Command) {
.option("--max-bytes <n>", "Max bytes to read", "250000")
.option("--follow", "Follow log output", false)
.option("--interval <ms>", "Polling interval in ms", "1000")
.option("--json", "Emit JSON payloads", false);
.option("--json", "Emit JSON log lines", false)
.option("--plain", "Plain text output (no ANSI styling)", false)
.option("--no-color", "Disable ANSI colors");
addGatewayClientOptions(logs);
@@ -63,18 +162,63 @@ export function registerLogsCli(program: Command) {
const interval = parsePositiveInt(opts.interval, 1000);
let cursor: number | undefined;
let first = true;
const jsonMode = Boolean(opts.json);
const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain;
const rich = isRich() && opts.color !== false;
while (true) {
const payload = await fetchLogs(opts, cursor);
let payload: LogsTailPayload;
try {
payload = await fetchLogs(opts, cursor);
} catch (err) {
emitGatewayError(err, opts, jsonMode ? "json" : "text", rich);
defaultRuntime.exit(1);
return;
}
const lines = Array.isArray(payload.lines) ? payload.lines : [];
if (opts.json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
} else {
if (first && payload.file) {
defaultRuntime.log(`Log file: ${payload.file}`);
if (jsonMode) {
if (first) {
emitJsonLine({
type: "meta",
file: payload.file,
cursor: payload.cursor,
size: payload.size,
});
}
for (const line of lines) {
defaultRuntime.log(line);
const parsed = parseLogLine(line);
if (parsed) {
emitJsonLine({ type: "log", ...parsed });
} else {
emitJsonLine({ type: "raw", raw: line });
}
}
if (payload.truncated) {
emitJsonLine({
type: "notice",
message: "Log tail truncated (increase --max-bytes).",
});
}
if (payload.reset) {
emitJsonLine({
type: "notice",
message: "Log cursor reset (file rotated).",
});
}
} else {
if (first && payload.file) {
const prefix = pretty
? colorize(rich, theme.muted, "Log file:")
: "Log file:";
defaultRuntime.log(`${prefix} ${payload.file}`);
}
for (const line of lines) {
defaultRuntime.log(
formatLogLine(line, {
pretty,
rich,
}),
);
}
if (payload.truncated) {
defaultRuntime.error("Log tail truncated (increase --max-bytes).");
+67
View File
@@ -4,6 +4,9 @@ import {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
modelsAuthAddCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
@@ -57,6 +60,11 @@ export function registerModelsCli(program: Command) {
.description("Show configured model state")
.option("--json", "Output JSON", false)
.option("--plain", "Plain output", false)
.option(
"--check",
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false,
)
.action(async (opts) => {
try {
await modelsStatusCommand(opts, defaultRuntime);
@@ -289,4 +297,63 @@ export function registerModelsCli(program: Command) {
defaultRuntime.exit(1);
}
});
const auth = models.command("auth").description("Manage model auth profiles");
auth
.command("add")
.description("Interactive auth helper (setup-token or paste token)")
.action(async () => {
try {
await modelsAuthAddCommand({}, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth
.command("setup-token")
.description("Run a provider CLI to create/sync a token (TTY required)")
.option("--provider <name>", "Provider id (default: anthropic)")
.option("--yes", "Skip confirmation", false)
.action(async (opts) => {
try {
await modelsAuthSetupTokenCommand(
{
provider: opts.provider as string | undefined,
yes: Boolean(opts.yes),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth
.command("paste-token")
.description("Paste a token into auth-profiles.json and update config")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--profile-id <id>", "Auth profile id (default: <provider>:manual)")
.option(
"--expires-in <duration>",
"Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.",
)
.action(async (opts) => {
try {
await modelsAuthPasteTokenCommand(
{
provider: opts.provider as string | undefined,
profileId: opts.profileId as string | undefined,
expiresIn: opts.expiresIn as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
+87
View File
@@ -0,0 +1,87 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const listProviderPairingRequests = vi.fn();
vi.mock("../pairing/pairing-store.js", () => ({
listProviderPairingRequests,
approveProviderPairingCode: vi.fn(),
}));
vi.mock("../telegram/send.js", () => ({
sendMessageTelegram: vi.fn(),
}));
vi.mock("../discord/send.js", () => ({
sendMessageDiscord: vi.fn(),
}));
vi.mock("../slack/send.js", () => ({
sendMessageSlack: vi.fn(),
}));
vi.mock("../signal/send.js", () => ({
sendMessageSignal: vi.fn(),
}));
vi.mock("../imessage/send.js", () => ({
sendMessageIMessage: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn().mockReturnValue({}),
}));
vi.mock("../telegram/token.js", () => ({
resolveTelegramToken: vi.fn().mockReturnValue({ token: "t" }),
}));
describe("pairing cli", () => {
it("labels Telegram ids as telegramUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([
{
id: "123",
code: "ABC123",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
meta: { username: "peter" },
},
]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--provider", "telegram"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(
expect.stringContaining("telegramUserId=123"),
);
});
it("labels Discord ids as discordUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([
{
id: "999",
code: "DEF456",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
meta: { tag: "Ada#0001" },
},
]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--provider", "discord"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(
expect.stringContaining("discordUserId=999"),
);
});
});
+10 -1
View File
@@ -3,6 +3,8 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
listProviderPairingRequests,
@@ -20,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [
"discord",
"slack",
"whatsapp",
"msteams",
];
function parseProvider(raw: unknown): PairingProvider {
@@ -64,6 +67,11 @@ async function notifyApproved(provider: PairingProvider, id: string) {
await sendMessageIMessage(id, message);
return;
}
if (provider === "msteams") {
const cfg = loadConfig();
await sendMessageMSTeams({ cfg, to: id, text: message });
return;
}
// WhatsApp: approval still works (store); notifying requires an active web session.
}
@@ -93,8 +101,9 @@ export function registerPairingCli(program: Command) {
}
for (const r of requests) {
const meta = r.meta ? JSON.stringify(r.meta) : "";
const idLabel = PROVIDER_ID_LABELS[provider];
console.log(
`${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
);
}
});
+4
View File
@@ -19,6 +19,10 @@ describe("parseDurationMs", () => {
expect(parseDurationMs("2h")).toBe(7_200_000);
});
it("parses days suffix", () => {
expect(parseDurationMs("2d")).toBe(172_800_000);
});
it("supports decimals", () => {
expect(parseDurationMs("0.5s")).toBe(500);
});
+17 -4
View File
@@ -1,5 +1,5 @@
export type DurationMsParseOptions = {
defaultUnit?: "ms" | "s" | "m" | "h";
defaultUnit?: "ms" | "s" | "m" | "h" | "d";
};
export function parseDurationMs(
@@ -11,7 +11,7 @@ export function parseDurationMs(
.toLowerCase();
if (!trimmed) throw new Error("invalid duration (empty)");
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(trimmed);
if (!m) throw new Error(`invalid duration: ${raw}`);
const value = Number(m[1]);
@@ -19,9 +19,22 @@ export function parseDurationMs(
throw new Error(`invalid duration: ${raw}`);
}
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m" | "h";
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as
| "ms"
| "s"
| "m"
| "h"
| "d";
const multiplier =
unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
unit === "ms"
? 1
: unit === "s"
? 1000
: unit === "m"
? 60_000
: unit === "h"
? 3_600_000
: 86_400_000;
const ms = Math.round(value * multiplier);
if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`);
return ms;
+12 -7
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCommand = vi.fn();
const messageCommand = vi.fn();
const statusCommand = vi.fn();
const configureCommand = vi.fn();
const setupCommand = vi.fn();
@@ -18,7 +18,9 @@ const runtime = {
}),
};
vi.mock("../commands/send.js", () => ({ sendCommand }));
vi.mock("../commands/message.js", () => ({
messageCommand,
}));
vi.mock("../commands/status.js", () => ({ statusCommand }));
vi.mock("../commands/configure.js", () => ({ configureCommand }));
vi.mock("../commands/setup.js", () => ({ setupCommand }));
@@ -43,12 +45,15 @@ describe("cli program", () => {
vi.clearAllMocks();
});
it("runs send with required options", async () => {
it("runs message with required options", async () => {
const program = buildProgram();
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
from: "user",
});
expect(sendCommand).toHaveBeenCalled();
await program.parseAsync(
["message", "send", "--to", "+1", "--message", "hi"],
{
from: "user",
},
);
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {
+486 -97
View File
@@ -8,9 +8,8 @@ import {
import { configureCommand } from "../commands/configure.js";
import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
import { messageCommand } from "../commands/message.js";
import { onboardCommand } from "../commands/onboard.js";
import { pollCommand } from "../commands/poll.js";
import { sendCommand } from "../commands/send.js";
import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js";
import { statusCommand } from "../commands/status.js";
@@ -26,7 +25,11 @@ import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { defaultRuntime } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { emitCliBanner, formatCliBannerLine } from "./banner.js";
import {
emitCliBanner,
formatCliBannerArt,
formatCliBannerLine,
} from "./banner.js";
import { registerBrowserCli } from "./browser-cli.js";
import { hasExplicitOptions } from "./command-options.js";
import { registerCronCli } from "./cron-cli.js";
@@ -43,6 +46,7 @@ import { registerPairingCli } from "./pairing-cli.js";
import { forceFreePort } from "./ports.js";
import { runProviderLogin, runProviderLogout } from "./provider-auth.js";
import { registerProvidersCli } from "./providers-cli.js";
import { registerSandboxCli } from "./sandbox-cli.js";
import { registerSkillsCli } from "./skills-cli.js";
import { registerTuiCli } from "./tui-cli.js";
@@ -69,6 +73,8 @@ export function buildProgram() {
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
);
program.option("--no-color", "Disable ANSI colors", false);
program.configureHelp({
optionTerm: (option) => theme.option(option.flags),
subcommandTerm: (cmd) => theme.command(cmd.name()),
@@ -96,8 +102,10 @@ export function buildProgram() {
}
program.addHelpText("beforeAll", () => {
const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: isRich() });
return `\n${line}\n`;
const rich = isRich();
const art = formatCliBannerArt({ richTty: rich });
const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: rich });
return `\n${art}\n${line}\n`;
});
program.hook("preAction", async (_thisCommand, actionCommand) => {
@@ -146,7 +154,7 @@ export function buildProgram() {
"Link personal WhatsApp Web and show QR + connection logs.",
],
[
'clawdbot send --to +15555550123 --message "Hi" --json',
'clawdbot message send --to +15555550123 --message "Hi" --json',
"Send via your web session and print JSON result.",
],
["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."],
@@ -164,7 +172,7 @@ export function buildProgram() {
"Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.",
],
[
'clawdbot send --provider telegram --to @mychat --message "Hi"',
'clawdbot message send --provider telegram --to @mychat --message "Hi"',
"Send via your Telegram bot.",
],
] as const;
@@ -232,9 +240,11 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax-cloud|minimax|skip",
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--openai-api-key <key>", "OpenAI API key")
.option("--gemini-api-key <key>", "Gemini API key")
.option("--minimax-api-key <key>", "MiniMax API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
@@ -261,15 +271,20 @@ export function buildProgram() {
authChoice: opts.authChoice as
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax-cloud"
| "minimax"
| "skip"
| undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined,
openaiApiKey: opts.openaiApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined,
gatewayPort:
typeof opts.gatewayPort === "string"
@@ -331,6 +346,12 @@ export function buildProgram() {
false,
)
.option("--yes", "Accept defaults without prompting", false)
.option("--repair", "Apply recommended repairs without prompting", false)
.option(
"--force",
"Apply aggressive repairs (overwrites custom service config)",
false,
)
.option(
"--non-interactive",
"Run without prompts (safe migrations only)",
@@ -342,6 +363,8 @@ export function buildProgram() {
await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes),
repair: Boolean(opts.repair),
force: Boolean(opts.force),
nonInteractive: Boolean(opts.nonInteractive),
deep: Boolean(opts.deep),
});
@@ -394,107 +417,472 @@ export function buildProgram() {
}
});
program
.command("send")
.description(
"Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
const message = program
.command("message")
.description("Send messages and provider actions")
.addHelpText(
"after",
`
Examples:
clawdbot message send --to +15555550123 --message "Hi"
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message react --provider discord --to 123 --message-id 456 --emoji "✅"`,
)
.requiredOption(
"-t, --to <number>",
"Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id",
.action(() => {
message.help({ error: true });
});
const withMessageBase = (command: Command) =>
command
.option(
"--provider <provider>",
"Provider: whatsapp|telegram|discord|slack|signal|imessage",
)
.option("--account <id>", "Provider account id")
.option("--json", "Output result as JSON", false)
.option("--dry-run", "Print payload and skip sending", false)
.option("--verbose", "Verbose logging", false);
const withMessageTarget = (command: Command) =>
command.option(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
);
const withRequiredMessageTarget = (command: Command) =>
command.requiredOption(
"-t, --to <dest>",
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
);
const runMessageAction = async (
action: string,
opts: Record<string, unknown>,
) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await messageCommand(
{
...opts,
action,
account: opts.account as string | undefined,
},
deps,
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
};
withMessageBase(
withRequiredMessageTarget(
message
.command("send")
.description("Send a message")
.requiredOption("-m, --message <text>", "Message body"),
)
.requiredOption("-m, --message <text>", "Message body")
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
),
).action(async (opts) => {
await runMessageAction("send", opts);
});
withMessageBase(
withRequiredMessageTarget(
message.command("poll").description("Send a poll"),
),
)
.requiredOption("--poll-question <text>", "Poll question")
.option(
"--poll-option <choice>",
"Poll option (repeat 2-12 times)",
collectOption,
[] as string[],
)
.option("--poll-multi", "Allow multiple selections", false)
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
.option("-m, --message <text>", "Optional message body")
.action(async (opts) => {
await runMessageAction("poll", opts);
});
withMessageBase(
withMessageTarget(
message.command("react").description("Add or remove a reaction"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--emoji <emoji>", "Emoji for reactions")
.option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("react", opts);
});
withMessageBase(
withMessageTarget(
message.command("reactions").description("List reactions on a message"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--limit <n>", "Result limit")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("reactions", opts);
});
withMessageBase(
withMessageTarget(
message.command("read").description("Read recent messages"),
),
)
.option("--limit <n>", "Result limit")
.option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id (Discord)")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("read", opts);
});
withMessageBase(
withMessageTarget(
message
.command("edit")
.description("Edit a message")
.requiredOption("-m, --message <text>", "Message body"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("edit", opts);
});
withMessageBase(
withMessageTarget(
message.command("delete").description("Delete a message"),
),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("delete", opts);
});
withMessageBase(
withMessageTarget(message.command("pin").description("Pin a message")),
)
.requiredOption("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("pin", opts);
});
withMessageBase(
withMessageTarget(message.command("unpin").description("Unpin a message")),
)
.option("--message-id <id>", "Message id")
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("unpin", opts);
});
withMessageBase(
withMessageTarget(
message.command("pins").description("List pinned messages"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("list-pins", opts);
});
withMessageBase(
withMessageTarget(
message.command("permissions").description("Fetch channel permissions"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.action(async (opts) => {
await runMessageAction("permissions", opts);
});
withMessageBase(
message.command("search").description("Search Discord messages"),
)
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--query <text>", "Search query")
.option("--channel-id <id>", "Channel id")
.option(
"--channel-ids <id>",
"Channel id (repeat)",
collectOption,
[] as string[],
)
.option("--author-id <id>", "Author id")
.option(
"--author-ids <id>",
"Author id (repeat)",
collectOption,
[] as string[],
)
.option("--limit <n>", "Result limit")
.action(async (opts) => {
await runMessageAction("search", opts);
});
const thread = message.command("thread").description("Thread actions");
withMessageBase(
withMessageTarget(
thread
.command("create")
.description("Create a thread")
.requiredOption("--thread-name <name>", "Thread name"),
),
)
.option("--channel-id <id>", "Channel id (defaults to --to)")
.option("--message-id <id>", "Message id (optional)")
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
.action(async (opts) => {
await runMessageAction("thread-create", opts);
});
withMessageBase(
thread
.command("list")
.description("List threads")
.requiredOption("--guild-id <id>", "Guild id"),
)
.option("--channel-id <id>", "Channel id")
.option("--include-archived", "Include archived threads", false)
.option("--before <id>", "Read/search before id")
.option("--limit <n>", "Result limit")
.action(async (opts) => {
await runMessageAction("thread-list", opts);
});
withMessageBase(
withRequiredMessageTarget(
thread
.command("reply")
.description("Reply in a thread")
.requiredOption("-m, --message <text>", "Message body"),
),
)
.option(
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
)
.option("--account <id>", "WhatsApp account id (accountId)")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdbot send --to +15555550123 --message "Hi"
clawdbot send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot send --to +15555550123 --message "Hi" --dry-run # print payload only
clawdbot send --to +15555550123 --message "Hi" --json # machine-readable result`,
)
.option("--reply-to <id>", "Reply-to message id")
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await sendCommand(
{
...opts,
account: opts.account as string | undefined,
},
deps,
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
await runMessageAction("thread-reply", opts);
});
program
.command("poll")
.description("Create a poll via WhatsApp or Discord")
.requiredOption(
"-t, --to <id>",
"Recipient: WhatsApp JID/number or Discord channel/user",
)
.requiredOption("-q, --question <text>", "Poll question")
.requiredOption(
"-o, --option <choice>",
"Poll option (use multiple times, 2-12 required)",
(value: string, previous: string[]) => previous.concat([value]),
const emoji = message.command("emoji").description("Emoji actions");
withMessageBase(emoji.command("list").description("List emojis"))
.option("--guild-id <id>", "Guild id (Discord)")
.action(async (opts) => {
await runMessageAction("emoji-list", opts);
});
withMessageBase(
emoji
.command("upload")
.description("Upload an emoji")
.requiredOption("--guild-id <id>", "Guild id"),
)
.requiredOption("--emoji-name <name>", "Emoji name")
.requiredOption("--media <path-or-url>", "Emoji media (path or URL)")
.option(
"--role-ids <id>",
"Role id (repeat)",
collectOption,
[] as string[],
)
.option(
"-s, --max-selections <n>",
"How many options can be selected (default: 1)",
)
.option(
"--duration-hours <n>",
"Poll duration in hours (Discord only, default: 24)",
)
.option(
"--provider <provider>",
"Delivery provider: whatsapp|discord (default: whatsapp)",
)
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await pollCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
await runMessageAction("emoji-upload", opts);
});
const sticker = message.command("sticker").description("Sticker actions");
withMessageBase(
withRequiredMessageTarget(
sticker.command("send").description("Send stickers"),
),
)
.requiredOption("--sticker-id <id>", "Sticker id (repeat)", collectOption)
.option("-m, --message <text>", "Optional message body")
.action(async (opts) => {
await runMessageAction("sticker", opts);
});
withMessageBase(
sticker
.command("upload")
.description("Upload a sticker")
.requiredOption("--guild-id <id>", "Guild id"),
)
.requiredOption("--sticker-name <name>", "Sticker name")
.requiredOption("--sticker-desc <text>", "Sticker description")
.requiredOption("--sticker-tags <tags>", "Sticker tags")
.requiredOption("--media <path-or-url>", "Sticker media (path or URL)")
.action(async (opts) => {
await runMessageAction("sticker-upload", opts);
});
const role = message.command("role").description("Role actions");
withMessageBase(
role
.command("info")
.description("List roles")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("role-info", opts);
});
withMessageBase(
role
.command("add")
.description("Add role to a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id")
.requiredOption("--role-id <id>", "Role id"),
).action(async (opts) => {
await runMessageAction("role-add", opts);
});
withMessageBase(
role
.command("remove")
.description("Remove role from a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id")
.requiredOption("--role-id <id>", "Role id"),
).action(async (opts) => {
await runMessageAction("role-remove", opts);
});
const channel = message.command("channel").description("Channel actions");
withMessageBase(
channel
.command("info")
.description("Fetch channel info")
.requiredOption("--channel-id <id>", "Channel id"),
).action(async (opts) => {
await runMessageAction("channel-info", opts);
});
withMessageBase(
channel
.command("list")
.description("List channels")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("channel-list", opts);
});
const member = message.command("member").description("Member actions");
withMessageBase(
member
.command("info")
.description("Fetch member info")
.requiredOption("--user-id <id>", "User id"),
)
.option("--guild-id <id>", "Guild id (Discord)")
.action(async (opts) => {
await runMessageAction("member-info", opts);
});
const voice = message.command("voice").description("Voice actions");
withMessageBase(
voice
.command("status")
.description("Fetch voice status")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
).action(async (opts) => {
await runMessageAction("voice-status", opts);
});
const event = message.command("event").description("Event actions");
withMessageBase(
event
.command("list")
.description("List scheduled events")
.requiredOption("--guild-id <id>", "Guild id"),
).action(async (opts) => {
await runMessageAction("event-list", opts);
});
withMessageBase(
event
.command("create")
.description("Create a scheduled event")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--event-name <name>", "Event name")
.requiredOption("--start-time <iso>", "Event start time"),
)
.option("--end-time <iso>", "Event end time")
.option("--desc <text>", "Event description")
.option("--channel-id <id>", "Channel id")
.option("--location <text>", "Event location")
.option("--event-type <stage|external|voice>", "Event type")
.action(async (opts) => {
await runMessageAction("event-create", opts);
});
withMessageBase(
message
.command("timeout")
.description("Timeout a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--duration-min <n>", "Timeout duration minutes")
.option("--until <iso>", "Timeout until")
.option("--reason <text>", "Moderation reason")
.action(async (opts) => {
await runMessageAction("timeout", opts);
});
withMessageBase(
message
.command("kick")
.description("Kick a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--reason <text>", "Moderation reason")
.action(async (opts) => {
await runMessageAction("kick", opts);
});
withMessageBase(
message
.command("ban")
.description("Ban a member")
.requiredOption("--guild-id <id>", "Guild id")
.requiredOption("--user-id <id>", "User id"),
)
.option("--reason <text>", "Moderation reason")
.option("--delete-days <n>", "Ban delete message days")
.action(async (opts) => {
await runMessageAction("ban", opts);
});
program
@@ -654,6 +1042,7 @@ Examples:
registerLogsCli(program);
registerModelsCli(program);
registerNodesCli(program);
registerSandboxCli(program);
registerTuiCli(program);
registerCronCli(program);
registerDnsCli(program);
+132
View File
@@ -0,0 +1,132 @@
import type { Command } from "commander";
import {
sandboxListCommand,
sandboxRecreateCommand,
} from "../commands/sandbox.js";
import { defaultRuntime } from "../runtime.js";
// --- Types ---
type CommandOptions = Record<string, unknown>;
// --- Helpers ---
const EXAMPLES = {
main: `
Examples:
clawdbot sandbox list # List all sandbox containers
clawdbot sandbox list --browser # List only browser containers
clawdbot sandbox recreate --all # Recreate all containers
clawdbot sandbox recreate --session main # Recreate specific session
clawdbot sandbox recreate --agent mybot # Recreate agent containers`,
list: `
Examples:
clawdbot sandbox list # List all sandbox containers
clawdbot sandbox list --browser # List only browser containers
clawdbot sandbox list --json # JSON output
Output includes:
Container name and status (running/stopped)
Docker image and whether it matches current config
Age (time since creation)
Idle time (time since last use)
Associated session/agent ID`,
recreate: `
Examples:
clawdbot sandbox recreate --all # Recreate all containers
clawdbot sandbox recreate --session main # Specific session
clawdbot sandbox recreate --agent mybot # Specific agent (includes sub-agents)
clawdbot sandbox recreate --browser --all # All browser containers only
clawdbot sandbox recreate --all --force # Skip confirmation
Why use this?
After updating Docker images or sandbox configuration, existing containers
continue running with old settings. This command removes them so they'll be
recreated automatically with current config when next needed.
Filter options:
--all Remove all sandbox containers
--session Remove container for specific session key
--agent Remove containers for agent (includes agent:id:* variants)
Modifiers:
--browser Only affect browser containers (not regular sandbox)
--force Skip confirmation prompt`,
};
function createRunner(
commandFn: (
opts: CommandOptions,
runtime: typeof defaultRuntime,
) => Promise<void>,
) {
return async (opts: CommandOptions) => {
try {
await commandFn(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
};
}
// --- Registration ---
export function registerSandboxCli(program: Command) {
const sandbox = program
.command("sandbox")
.description("Manage sandbox containers (Docker-based agent isolation)")
.addHelpText("after", EXAMPLES.main)
.action(() => {
sandbox.help({ error: true });
});
// --- List Command ---
sandbox
.command("list")
.description("List sandbox containers and their status")
.option("--json", "Output result as JSON", false)
.option("--browser", "List browser containers only", false)
.addHelpText("after", EXAMPLES.list)
.action(
createRunner((opts) =>
sandboxListCommand(
{
browser: Boolean(opts.browser),
json: Boolean(opts.json),
},
defaultRuntime,
),
),
);
// --- Recreate Command ---
sandbox
.command("recreate")
.description("Remove containers to force recreation with updated config")
.option("--all", "Recreate all sandbox containers", false)
.option("--session <key>", "Recreate container for specific session")
.option("--agent <id>", "Recreate containers for specific agent")
.option("--browser", "Only recreate browser containers", false)
.option("--force", "Skip confirmation prompt", false)
.addHelpText("after", EXAMPLES.recreate)
.action(
createRunner((opts) =>
sandboxRecreateCommand(
{
all: Boolean(opts.all),
session: opts.session as string | undefined,
agent: opts.agent as string | undefined,
browser: Boolean(opts.browser),
force: Boolean(opts.force),
},
defaultRuntime,
),
),
);
}
+38 -5
View File
@@ -4,6 +4,7 @@ import {
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { runClaudeCliAgent } from "../agents/claude-cli-runner.js";
import { lookupContextTokens } from "../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
@@ -336,6 +337,7 @@ export async function agentCommand(
cfg,
catalog: modelCatalog,
defaultProvider,
defaultModel,
});
allowedModelKeys = allowed.allowedKeys;
allowedModelCatalog = allowed.allowedCatalog;
@@ -347,7 +349,11 @@ export async function agentCommand(
const overrideModel = sessionEntry.modelOverride?.trim();
if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
if (
overrideProvider !== "claude-cli" &&
allowedModelKeys.size > 0 &&
!allowedModelKeys.has(key)
) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
sessionEntry.updatedAt = Date.now();
@@ -362,7 +368,11 @@ export async function agentCommand(
if (storedModelOverride) {
const candidateProvider = storedProviderOverride || defaultProvider;
const key = modelKey(candidateProvider, storedModelOverride);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
if (
candidateProvider === "claude-cli" ||
allowedModelKeys.size === 0 ||
allowedModelKeys.has(key)
) {
provider = candidateProvider;
model = storedModelOverride;
}
@@ -401,6 +411,7 @@ export async function agentCommand(
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
let fallbackModel = model;
const claudeSessionId = sessionEntry?.claudeCliSessionId?.trim();
try {
const messageProvider = resolveMessageProvider(
opts.messageProvider,
@@ -410,8 +421,25 @@ export async function agentCommand(
cfg,
provider,
model,
run: (providerOverride, modelOverride) =>
runEmbeddedPiAgent({
run: (providerOverride, modelOverride) => {
if (providerOverride === "claude-cli") {
return runClaudeCliAgent({
sessionId,
sessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: body,
provider: providerOverride,
model: modelOverride,
thinkLevel: resolvedThinkLevel,
timeoutMs,
runId,
extraSystemPrompt: opts.extraSystemPrompt,
claudeSessionId,
});
}
return runEmbeddedPiAgent({
sessionId,
sessionKey,
messageProvider,
@@ -445,7 +473,8 @@ export async function agentCommand(
data: evt.data,
});
},
}),
});
},
});
result = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
@@ -501,6 +530,10 @@ export async function agentCommand(
model: modelUsed,
contextTokens,
};
if (providerUsed === "claude-cli") {
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
if (cliSessionId) next.claudeCliSessionId = cliSessionId;
}
next.abortedLastRun = result.meta.aborted ?? false;
if (hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;
+4 -1
View File
@@ -955,12 +955,15 @@ export async function agentsAddCommand(
initialValue: false,
});
if (wantsAuth) {
const authStore = ensureAuthProfileStore(agentDir);
const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
})) as AuthChoice;
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
describe("buildAuthChoiceOptions", () => {
it("includes Claude CLI option on macOS even when missing", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli).toBeDefined();
expect(claudeCli?.hint).toBe("requires Keychain access");
});
it("skips missing Claude CLI option off macOS", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "linux",
});
expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined();
});
it("uses token hint when Claude CLI credentials exist", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "token",
expires: Date.now() + 60 * 60 * 1000,
},
},
};
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli?.hint).toContain("token ok");
});
});
+18 -2
View File
@@ -45,8 +45,11 @@ function formatOAuthHint(
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): AuthChoiceOption[] {
const options: AuthChoiceOption[] = [];
const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
@@ -58,25 +61,38 @@ export function buildAuthChoiceOptions(params: {
}
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth") {
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires),
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
hint: "requires Keychain access",
});
}
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "Run `claude setup-token`, then paste the token",
});
options.push({
value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)",
});
options.push({ value: "openai-api-key", label: "OpenAI API key" });
options.push({
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {
+179 -47
View File
@@ -1,5 +1,4 @@
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -10,6 +9,7 @@ import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
@@ -19,12 +19,21 @@ import {
import { loadModelCatalog } from "../agents/model-catalog.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { ClawdbotConfig } from "../config/config.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
@@ -33,6 +42,7 @@ import {
applyMinimaxProviderConfig,
MINIMAX_HOSTED_MODEL_REF,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
@@ -131,60 +141,158 @@ export async function applyAuthChoice(params: {
);
};
if (params.authChoice === "oauth") {
await params.prompter.note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = params.prompter.progress("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
params.runtime.log(`Open: ${url}`);
},
async () => {
const code = await params.prompter.text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note(
[
"macOS will show a Keychain prompt next.",
'Choose "Always Allow" so the launchd gateway can start without prompts.',
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
].join("\n"),
"Claude CLI Keychain",
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
const proceed = await params.prompter.confirm({
message: "Check Keychain for Claude CLI credentials now?",
initialValue: true,
});
if (!proceed) {
return { config: nextConfig, agentModelOverride };
}
} catch (err) {
spin.stop("OAuth failed");
params.runtime.error(String(err));
await params.prompter.note(
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
"OAuth help",
);
}
} else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
"No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
const storeWithKeychain = hasClaudeCli
? store
: ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
if (process.stdin.isTTY) {
const runNow = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} else {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Claude setup-token",
);
}
const refreshed = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
await params.prompter.note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = await params.prompter.text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenRaw).trim();
const profileNameRaw = await params.prompter.text({
message: "Token name (blank = default)",
placeholder: "default",
});
const namedProfileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
upsertAuthProfile({
profileId: namedProfileId,
agentDir: params.agentDir,
credential: {
type: "token",
provider,
token,
},
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: namedProfileId,
provider,
mode: "token",
});
} else if (params.authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
});
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
await params.prompter.note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
return { config: nextConfig, agentModelOverride };
}
}
const key = await params.prompter.text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
await params.prompter.note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
await params.prompter.note(
@@ -390,6 +498,30 @@ export async function applyAuthChoice(params: {
"OAuth help",
);
}
} else if (params.authChoice === "gemini-api-key") {
const key = await params.prompter.text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setGeminiApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
if (params.setDefaultModel) {
const applied = applyGoogleGeminiModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL;
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
}
} else if (params.authChoice === "apiKey") {
const key = await params.prompter.text({
message: "Enter Anthropic API key",
+37
View File
@@ -0,0 +1,37 @@
import { normalizeProviderId } from "../agents/model-selection.js";
export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
export const DEFAULT_TOKEN_PROFILE_NAME = "default";
export function normalizeTokenProfileName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return DEFAULT_TOKEN_PROFILE_NAME;
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || DEFAULT_TOKEN_PROFILE_NAME;
}
export function buildTokenProfileId(params: {
provider: string;
name: string;
}): string {
const provider = normalizeProviderId(params.provider);
const name = normalizeTokenProfileName(params.name);
return `${provider}:${name}`;
}
export function validateAnthropicSetupToken(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return "Required";
if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) {
return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`;
}
if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) {
return "Token looks too short; paste the full setup-token";
}
return undefined;
}
+332 -111
View File
@@ -1,17 +1,16 @@
import path from "node:path";
import {
confirm,
intro,
multiselect,
note,
outro,
select,
confirm as clackConfirm,
intro as clackIntro,
multiselect as clackMultiselect,
note as clackNote,
outro as clackOutro,
select as clackSelect,
text as clackText,
spinner,
text,
} from "@clack/prompts";
import {
loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -20,7 +19,9 @@ import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { createCliProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -31,10 +32,18 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, sleep } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
@@ -43,17 +52,26 @@ import {
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
@@ -94,6 +112,43 @@ type ConfigureWizardParams = {
sections?: WizardSection[];
};
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
const text = (params: Parameters<typeof clackText>[0]) =>
clackText({
...params,
message: stylePromptMessage(params.message),
});
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
...params,
message: stylePromptMessage(params.message),
});
const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
clackSelect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const multiselect = <T>(params: Parameters<typeof clackMultiselect<T>>[0]) =>
clackMultiselect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
const startOscSpinner = (label: string) => {
const spin = spinner();
spin.start(theme.accent(label));
@@ -286,17 +341,23 @@ async function promptAuthConfig(
await select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: ensureAuthProfileStore(),
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
}),
runtime,
) as
| "oauth"
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax-cloud"
| "minimax"
@@ -304,52 +365,138 @@ async function promptAuthConfig(
let next = cfg;
if (authChoice === "oauth") {
note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = startOscSpinner("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
runtime.log(`Open: ${url}`);
},
async () => {
const code = guardCancel(
await text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
return String(code);
},
if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID] && process.stdin.isTTY) {
note(
[
"No Claude CLI credentials found yet.",
"If you have a Claude Pro/Max subscription, run `claude setup-token`.",
].join("\n"),
"Claude CLI",
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds);
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
next = applyAuthProfileConfig(next, {
profileId,
provider: "anthropic",
mode: "oauth",
email: oauthCreds.email ?? undefined,
});
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} catch (err) {
spin.stop("OAuth failed");
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "claude-cli") {
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
mode: "token",
});
} else if (authChoice === "token" || authChoice === "oauth") {
const provider = guardCancel(
await select({
message: "Token provider",
options: [
{
value: "anthropic",
label: "Anthropic (only supported)",
},
],
}),
runtime,
) as "anthropic";
note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = guardCancel(
await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
}),
runtime,
);
const token = String(tokenRaw).trim();
const profileNameRaw = guardCancel(
await text({
message: "Token name (blank = default)",
placeholder: "default",
}),
runtime,
);
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
},
});
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
} else if (authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = guardCancel(
await confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
}),
runtime,
);
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
}
}
const key = guardCancel(
await text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
@@ -511,6 +658,28 @@ async function promptAuthConfig(
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "gemini-api-key") {
const key = guardCancel(
await text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setGeminiApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
const applied = applyGoogleGeminiModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({
@@ -544,13 +713,24 @@ async function promptAuthConfig(
next = applyMinimaxConfig(next);
}
const currentModel =
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "apiKey";
const modelInitialValue =
preferAnthropic && !currentModel.startsWith("anthropic/")
? "anthropic/claude-opus-4-5"
: currentModel;
const modelInput = guardCancel(
await text({
message: "Default model (blank to keep)",
initialValue:
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? ""),
initialValue: modelInitialValue,
}),
runtime,
);
@@ -629,18 +809,24 @@ async function maybeInstallDaemon(params: {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port: params.port,
token: params.gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
@@ -766,13 +952,41 @@ export async function runConfigureWizard(
await multiselect({
message: "Select sections to configure",
options: [
{ value: "workspace", label: "Workspace" },
{ value: "model", label: "Model/auth" },
{ value: "gateway", label: "Gateway config" },
{ value: "daemon", label: "Gateway daemon" },
{ value: "providers", label: "Providers" },
{ value: "skills", label: "Skills" },
{ value: "health", label: "Health check" },
{
value: "workspace",
label: "Workspace",
hint: "Set agent workspace + ensure sessions",
},
{
value: "model",
label: "Model/auth",
hint: "Pick model + auth profile sources",
},
{
value: "gateway",
label: "Gateway config",
hint: "Port/bind/auth/control UI settings",
},
{
value: "daemon",
label: "Gateway daemon",
hint: "Install/manage the background service",
},
{
value: "providers",
label: "Providers",
hint: "Link WhatsApp/Telegram/etc and defaults",
},
{
value: "skills",
label: "Skills",
hint: "Install/enable workspace skills",
},
{
value: "health",
label: "Health check",
hint: "Run gateway + provider checks",
},
],
}),
runtime,
@@ -885,58 +1099,65 @@ export async function runConfigureWizard(
runtime.error(controlUiAssets.message);
}
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token:
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
password:
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD,
});
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
note(
(() => {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
return [
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
"Docs: https://docs.clawd.bot/web/control-ui",
].join("\n");
})(),
[
`Web UI: ${links.httpUrl}`,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.clawd.bot/web/control-ui",
].join("\n"),
"Control UI",
);
const browserSupport = await detectBrowserOpenSupport();
if (!browserSupport.ok) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
} else {
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const bind = nextConfig.gateway?.bind ?? "loopback";
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const opened = await openUrl(links.httpUrl);
if (!opened) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
if (gatewayProbe.ok) {
if (!browserSupport.ok) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
} else {
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: false,
}),
runtime,
);
if (wantsOpen) {
const opened = await openUrl(links.httpUrl);
if (!opened) {
note(
formatControlUiSshHint({
port: gatewayPort,
basePath: nextConfig.gateway?.controlUi?.basePath,
token: gatewayToken,
}),
"Open Control UI",
);
}
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
{
value: "node",
label: "Node (recommended)",
hint: "Required for WhatsApp (Baileys WebSocket). Bun can corrupt memory on reconnect.",
hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
},
];
+126 -1
View File
@@ -1,12 +1,24 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
formatRemainingShort,
} from "../agents/auth-health.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
repairOAuthProfileIdMismatch,
resolveApiKeyForProfile,
} from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export async function maybeRepairAnthropicOAuthProfileId(
cfg: ClawdbotConfig,
prompter: DoctorPrompter,
@@ -28,3 +40,116 @@ export async function maybeRepairAnthropicOAuthProfileId(
if (!apply) return cfg;
return repair.config;
}
type AuthIssue = {
profileId: string;
provider: string;
status: string;
remainingMs?: number;
};
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (
issue.provider === "anthropic" &&
issue.profileId === CLAUDE_CLI_PROFILE_ID
) {
return "Run `claude setup-token` on the gateway host.";
}
if (
issue.provider === "openai-codex" &&
issue.profileId === CODEX_CLI_PROFILE_ID
) {
return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth).";
}
return "Re-auth via `clawdbot configure` or `clawdbot onboard`.";
}
function formatAuthIssueLine(issue: AuthIssue): string {
const remaining =
issue.remainingMs !== undefined
? ` (${formatRemainingShort(issue.remainingMs)})`
: "";
const hint = formatAuthIssueHint(issue);
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? `${hint}` : ""}`;
}
export async function noteAuthProfileHealth(params: {
cfg: ClawdbotConfig;
prompter: DoctorPrompter;
allowKeychainPrompt: boolean;
}): Promise<void> {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
let summary = buildAuthHealthSummary({
store,
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const findIssues = () =>
summary.profiles.filter(
(profile) =>
(profile.type === "oauth" || profile.type === "token") &&
(profile.status === "expired" ||
profile.status === "expiring" ||
profile.status === "missing"),
);
let issues = findIssues();
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)",
initialValue: true,
});
if (shouldRefresh) {
const refreshTargets = issues.filter(
(issue) =>
issue.type === "oauth" &&
["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {
try {
await resolveApiKeyForProfile({
cfg: params.cfg,
store,
profileId: profile.profileId,
});
} catch (err) {
errors.push(
`- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (errors.length > 0) {
note(errors.join("\n"), "OAuth refresh errors");
}
summary = buildAuthHealthSummary({
store: ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
}),
cfg: params.cfg,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
issues = findIssues();
}
if (issues.length > 0) {
note(
issues
.map((issue) =>
formatAuthIssueLine({
profileId: issue.profileId,
provider: issue.provider,
status: issue.status,
remainingMs: issue.remainingMs,
}),
)
.join("\n"),
"Model auth",
);
}
}
+147 -7
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
@@ -14,8 +14,18 @@ import {
uninstallLegacyGatewayServices,
} from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import {
resolvePreferredNodePath,
resolveSystemNodePath,
} from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import {
auditGatewayServiceConfig,
needsNodeRuntimeMigration,
} from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
@@ -23,6 +33,21 @@ import {
} from "./daemon-runtime.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function detectGatewayRuntime(
programArguments: string[] | undefined,
): GatewayDaemonRuntime {
const first = programArguments?.[0];
if (first) {
const base = path.basename(first).toLowerCase();
if (base === "bun" || base === "bun.exe") return "bun";
if (base === "node" || base === "node.exe") return "node";
}
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
}
export async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig,
mode: "local" | "remote",
@@ -90,19 +115,24 @@ export async function maybeMigrateLegacyGatewayService(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_GATEWAY_TOKEN:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
const environment = buildServiceEnvironment({
env: process.env,
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
@@ -112,6 +142,116 @@ export async function maybeMigrateLegacyGatewayService(
});
}
export async function maybeRepairGatewayServiceConfig(
cfg: ClawdbotConfig,
mode: "local" | "remote",
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
if (resolveIsNixMode(process.env)) {
note("Nix mode detected; skip service updates.", "Gateway");
return;
}
if (mode === "remote") {
note("Gateway mode is remote; skipped local service audit.", "Gateway");
return;
}
const service = resolveGatewayService();
let command: Awaited<ReturnType<typeof service.readCommand>> | null = null;
try {
command = await service.readCommand(process.env);
} catch {
command = null;
}
if (!command) return;
const audit = await auditGatewayServiceConfig({
env: process.env,
command,
});
if (audit.issues.length === 0) return;
note(
audit.issues
.map((issue) =>
issue.detail
? `- ${issue.message} (${issue.detail})`
: `- ${issue.message}`,
)
.join("\n"),
"Gateway service config",
);
const aggressiveIssues = audit.issues.filter(
(issue) => issue.level === "aggressive",
);
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
note(
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
"Gateway service config",
);
}
const repair = needsAggressive
? await prompter.confirmAggressive({
message: "Overwrite gateway service config with current defaults now?",
initialValue: Boolean(prompter.shouldForce),
})
: await prompter.confirmRepair({
message:
"Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) return;
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
});
try {
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
runtime.error(`Gateway service update failed: ${String(err)}`);
}
}
export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
const extraServices = await findExtraGatewayServices(process.env, {
deep: options.deep,
+5 -1
View File
@@ -1,7 +1,7 @@
import os from "node:os";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -12,8 +12,12 @@ import {
writeConfigFile,
} from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath } from "../utils.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string {
const override = env.CLAWDIS_CONFIG_PATH?.trim();
if (override) return override;
+62 -5
View File
@@ -1,6 +1,10 @@
import { confirm, select } from "@clack/prompts";
import type { RuntimeEnv } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
} from "../terminal/prompt-style.js";
import { guardCancel } from "./onboard-helpers.js";
export type DoctorOptions = {
@@ -8,14 +12,22 @@ export type DoctorOptions = {
yes?: boolean;
nonInteractive?: boolean;
deep?: boolean;
repair?: boolean;
force?: boolean;
};
export type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
confirmSkipInNonInteractive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
shouldRepair: boolean;
shouldForce: boolean;
};
export function createDoctorPrompter(params: {
@@ -24,24 +36,69 @@ export function createDoctorPrompter(params: {
}): DoctorPrompter {
const yes = params.options.yes === true;
const requestedNonInteractive = params.options.nonInteractive === true;
const shouldRepair = params.options.repair === true || yes;
const shouldForce = params.options.force === true;
const isTty = Boolean(process.stdin.isTTY);
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
return (
guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
) === true
);
};
return {
confirm: confirmDefault,
confirmSkipInNonInteractive: async (p) => {
confirmRepair: async (p) => {
if (nonInteractive) return false;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt) return fallback;
return guardCancel(await select(p), params.runtime) as T;
confirmAggressive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair && shouldForce) return true;
if (shouldRepair && !shouldForce) return false;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return (
guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
) === true
);
},
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt || shouldRepair) return fallback;
return guardCancel(
await select({
...p,
message: stylePromptMessage(p.message),
options: p.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
}),
params.runtime,
) as T;
},
shouldRepair,
shouldForce,
};
}
+5 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import {
DEFAULT_SANDBOX_BROWSER_IMAGE,
@@ -12,9 +12,13 @@ import {
import type { ClawdbotConfig } from "../config/config.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { replaceModernName } from "./doctor-legacy-config.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
type SandboxScriptInfo = {
scriptPath: string;
cwd: string;
+5 -1
View File
@@ -1,11 +1,15 @@
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { normalizeE164 } from "../utils.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = [];
+49 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { note } from "@clack/prompts";
import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
@@ -14,6 +14,10 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
type DoctorPrompterLike = {
confirmSkipInNonInteractive: (params: {
@@ -123,6 +127,7 @@ function findOtherStateDirs(stateDir: string): string[] {
export async function noteStateIntegrity(
cfg: ClawdbotConfig,
prompter: DoctorPrompterLike,
configPath?: string,
) {
const warnings: string[] = [];
const changes: string[] = [];
@@ -186,6 +191,49 @@ export async function noteStateIntegrity(
}
}
}
if (stateDirExists && process.platform !== "win32") {
try {
const stat = fs.statSync(stateDir);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${stateDir} to 700?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(stateDir, 0o700);
changes.push(`- Tightened permissions on ${stateDir} to 700`);
}
}
} catch (err) {
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
}
}
if (configPath && existsFile(configPath) && process.platform !== "win32") {
try {
const stat = fs.statSync(configPath);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${configPath} to 600?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(configPath, 0o600);
changes.push(`- Tightened permissions on ${configPath} to 600`);
}
}
} catch (err) {
warnings.push(
`- Failed to read config permissions (${configPath}): ${String(err)}`,
);
}
}
if (stateDirExists) {
const dirCandidates = new Map<string, string>();
+9
View File
@@ -94,6 +94,7 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const callGateway = vi.fn().mockRejectedValue(new Error("gateway closed"));
vi.mock("@clack/prompts", () => ({
confirm,
@@ -133,6 +134,14 @@ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments,
}));
vi.mock("../gateway/call.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/call.js")>();
return {
...actual,
callGateway,
};
});
vi.mock("../process/exec.js", () => ({
runExec,
runCommandWithTimeout,
+81 -17
View File
@@ -1,5 +1,9 @@
import path from "node:path";
import { intro, note, outro } from "@clack/prompts";
import {
intro as clackIntro,
note as clackNote,
outro as clackOutro,
} from "@clack/prompts";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -12,24 +16,32 @@ import {
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath, sleep } from "../utils.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import {
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import {
buildGatewayRuntimeHints,
formatGatewayRuntimeSummary,
} from "./doctor-format.js";
import {
maybeMigrateLegacyGatewayService,
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js";
import {
@@ -64,6 +76,13 @@ import {
} from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
const intro = (message: string) =>
clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) =>
clackOutro(stylePromptTitle(message) ?? message);
const note = (message: string, title?: string) =>
clackNote(message, stylePromptTitle(title));
function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}
@@ -120,6 +139,12 @@ export async function doctorCommand(
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
await noteAuthProfileHealth({
cfg,
prompter,
allowKeychainPrompt:
options.nonInteractive !== true && Boolean(process.stdin.isTTY),
});
const gatewayDetails = buildGatewayConnectionDetails({ config: cfg });
if (gatewayDetails.remoteFallbackNote) {
note(gatewayDetails.remoteFallbackNote, "Gateway");
@@ -128,10 +153,13 @@ export async function doctorCommand(
const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate = await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
const migrate =
options.nonInteractive === true
? true
: await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
@@ -145,7 +173,11 @@ export async function doctorCommand(
}
}
await noteStateIntegrity(cfg, prompter);
await noteStateIntegrity(
cfg,
prompter,
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
noteSandboxScopeWarnings(cfg);
@@ -157,6 +189,12 @@ export async function doctorCommand(
prompter,
);
await maybeScanExtraGatewayServices(options);
await maybeRepairGatewayServiceConfig(
cfg,
resolveMode(cfg),
runtime,
prompter,
);
await noteSecurityWarnings(cfg);
@@ -223,6 +261,30 @@ export async function doctorCommand(
}
}
if (healthOk) {
try {
const status = await callGateway<Record<string, unknown>>({
method: "providers.status",
params: { probe: true, timeoutMs: 5000 },
timeoutMs: 6000,
});
const issues = collectProvidersStatusIssues(status);
if (issues.length > 0) {
note(
issues
.map(
(issue) =>
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
)
.join("\n"),
"Provider warnings",
);
}
} catch {
// ignore: doctor already reported gateway health
}
}
if (!healthOk) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
@@ -266,25 +328,27 @@ export async function doctorCommand(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const nodePath = await resolvePreferredNodePath({
env: process.env,
runtime: daemonRuntime,
});
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
nodePath,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
const environment = buildServiceEnvironment({
env: process.env,
port,
token:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
};
});
await service.install({
env: process.env,
stdout: process.stdout,
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn(() => ({
gateway: {
mode: "remote",
remote: { url: "ws://remote.example:18789", token: "rtok" },
auth: { token: "ltok" },
},
}));
const resolveGatewayPort = vi.fn(() => 18789);
const discoverGatewayBeacons = vi.fn(async () => []);
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
const sshStop = vi.fn(async () => {});
const startSshPortForward = vi.fn(async () => ({
parsedTarget: { user: "me", host: "studio", port: 22 },
localPort: 18789,
remotePort: 18789,
pid: 123,
stderr: [],
stop: sshStop,
}));
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
if (url.includes("127.0.0.1")) {
return {
ok: true,
url,
connectLatencyMs: 12,
error: null,
close: null,
health: { ok: true },
status: { web: { linked: false }, sessions: { count: 0 } },
presence: [
{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" },
],
configSnapshot: {
path: "/tmp/cfg.json",
exists: true,
valid: true,
config: {
gateway: { mode: "local" },
bridge: { enabled: true, port: 18790 },
},
issues: [],
legacyIssues: [],
},
};
}
return {
ok: true,
url,
connectLatencyMs: 34,
error: null,
close: null,
health: { ok: true },
status: { web: { linked: true }, sessions: { count: 2 } },
presence: [
{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" },
],
configSnapshot: {
path: "/tmp/remote.json",
exists: true,
valid: true,
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
issues: [],
legacyIssues: [],
},
};
});
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfig(),
resolveGatewayPort: (cfg: unknown) => resolveGatewayPort(cfg),
}));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
}));
vi.mock("../infra/ssh-tunnel.js", () => ({
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
}));
vi.mock("../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
describe("gateway-status command", () => {
it("prints human output by default", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(runtimeErrors).toHaveLength(0);
expect(runtimeLogs.join("\n")).toContain("Gateway Status");
expect(runtimeLogs.join("\n")).toContain("Discovery (this machine)");
expect(runtimeLogs.join("\n")).toContain("Targets");
});
it("prints a structured JSON envelope when --json is set", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
expect(parsed.ok).toBe(true);
expect(parsed.targets).toBeTruthy();
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.length).toBeGreaterThanOrEqual(2);
expect(targets[0]?.health).toBeTruthy();
expect(targets[0]?.summary).toBeTruthy();
});
it("supports SSH tunnel targets", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
startSshPortForward.mockClear();
sshStop.mockClear();
probeGateway.mockClear();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true, ssh: "me@studio" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(startSshPortForward).toHaveBeenCalledTimes(1);
expect(probeGateway).toHaveBeenCalled();
expect(sshStop).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
});
});
+652
View File
@@ -0,0 +1,652 @@
import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { startSshPortForward } from "../infra/ssh-tunnel.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
type GatewayStatusTarget = {
id: string;
kind: TargetKind;
url: string;
active: boolean;
tunnel?: {
kind: "ssh";
target: string;
localPort: number;
remotePort: number;
pid: number | null;
};
};
type GatewayConfigSummary = {
path: string | null;
exists: boolean;
valid: boolean;
issues: Array<{ path: string; message: string }>;
legacyIssues: Array<{ path: string; message: string }>;
gateway: {
mode: string | null;
bind: string | null;
port: number | null;
controlUiEnabled: boolean | null;
controlUiBasePath: string | null;
authMode: string | null;
authTokenConfigured: boolean;
authPasswordConfigured: boolean;
remoteUrl: string | null;
remoteTokenConfigured: boolean;
remotePasswordConfigured: boolean;
tailscaleMode: string | null;
};
bridge: {
enabled: boolean | null;
bind: string | null;
port: number | null;
};
discovery: {
wideAreaEnabled: boolean | null;
};
};
function parseIntOrNull(value: unknown): number | null {
const s =
typeof value === "string"
? value.trim()
: typeof value === "number" || typeof value === "bigint"
? String(value)
: "";
if (!s) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
function parseTimeoutMs(raw: unknown, fallbackMs: number): number {
const value =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "bigint"
? String(raw)
: "";
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`invalid --timeout: ${value}`);
}
return parsed;
}
function normalizeWsUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://"))
return null;
return trimmed;
}
function resolveTargets(
cfg: ClawdbotConfig,
explicitUrl?: string,
): GatewayStatusTarget[] {
const targets: GatewayStatusTarget[] = [];
const add = (t: GatewayStatusTarget) => {
if (!targets.some((x) => x.url === t.url)) targets.push(t);
};
const explicit =
typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null;
if (explicit)
add({ id: "explicit", kind: "explicit", url: explicit, active: true });
const remoteUrl =
typeof cfg.gateway?.remote?.url === "string"
? normalizeWsUrl(cfg.gateway.remote.url)
: null;
if (remoteUrl) {
add({
id: "configRemote",
kind: "configRemote",
url: remoteUrl,
active: cfg.gateway?.mode === "remote",
});
}
const port = resolveGatewayPort(cfg);
add({
id: "localLoopback",
kind: "localLoopback",
url: `ws://127.0.0.1:${port}`,
active: cfg.gateway?.mode !== "remote",
});
return targets;
}
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
if (kind === "localLoopback") return Math.min(800, overallMs);
if (kind === "sshTunnel") return Math.min(2000, overallMs);
return Math.min(1500, overallMs);
}
function sanitizeSshTarget(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.replace(/^ssh\s+/, "");
}
function resolveAuthForTarget(
cfg: ClawdbotConfig,
target: GatewayStatusTarget,
overrides: { token?: string; password?: string },
): { token?: string; password?: string } {
const tokenOverride = overrides.token?.trim()
? overrides.token.trim()
: undefined;
const passwordOverride = overrides.password?.trim()
? overrides.password.trim()
: undefined;
if (tokenOverride || passwordOverride) {
return { token: tokenOverride, password: passwordOverride };
}
if (target.kind === "configRemote") {
const token =
typeof cfg.gateway?.remote?.token === "string"
? cfg.gateway.remote.token.trim()
: "";
const remotePassword = (
cfg.gateway?.remote as { password?: unknown } | undefined
)?.password;
const password =
typeof remotePassword === "string" ? remotePassword.trim() : "";
return {
token: token.length > 0 ? token : undefined,
password: password.length > 0 ? password : undefined,
};
}
const envToken = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || "";
const envPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || "";
const cfgToken =
typeof cfg.gateway?.auth?.token === "string"
? cfg.gateway.auth.token.trim()
: "";
const cfgPassword =
typeof cfg.gateway?.auth?.password === "string"
? cfg.gateway.auth.password.trim()
: "";
return {
token: envToken || cfgToken || undefined,
password: envPassword || cfgPassword || undefined,
};
}
function pickGatewaySelfPresence(
presence: unknown,
): { host?: string; ip?: string; version?: string; platform?: string } | null {
if (!Array.isArray(presence)) return null;
const entries = presence as Array<Record<string, unknown>>;
const self =
entries.find((e) => e.mode === "gateway" && e.reason === "self") ??
entries.find(
(e) =>
typeof e.text === "string" && String(e.text).startsWith("Gateway:"),
) ??
null;
if (!self) return null;
return {
host: typeof self.host === "string" ? self.host : undefined,
ip: typeof self.ip === "string" ? self.ip : undefined,
version: typeof self.version === "string" ? self.version : undefined,
platform: typeof self.platform === "string" ? self.platform : undefined,
};
}
function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSummary {
const snap = snapshotUnknown as Partial<ConfigFileSnapshot> | null;
const path = typeof snap?.path === "string" ? snap.path : null;
const exists = Boolean(snap?.exists);
const valid = Boolean(snap?.valid);
const issuesRaw = Array.isArray(snap?.issues) ? snap.issues : [];
const legacyRaw = Array.isArray(snap?.legacyIssues) ? snap.legacyIssues : [];
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
const remote = (gateway.remote ?? {}) as Record<string, unknown>;
const auth = (gateway.auth ?? {}) as Record<string, unknown>;
const controlUi = (gateway.controlUi ?? {}) as Record<string, unknown>;
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
const authMode = typeof auth.mode === "string" ? auth.mode : null;
const authTokenConfigured =
typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authPasswordConfigured =
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
const remoteUrl =
typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteTokenConfigured =
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
const remotePasswordConfigured =
typeof remote.password === "string"
? String(remote.password).trim().length > 0
: false;
const bridgeEnabled =
typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
const bridgePort = parseIntOrNull(bridge.port);
const wideAreaEnabled =
typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
return {
path,
exists,
valid,
issues: issuesRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
)
.map((i) => ({ path: i.path, message: i.message })),
legacyIssues: legacyRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
)
.map((i) => ({ path: i.path, message: i.message })),
gateway: {
mode: typeof gateway.mode === "string" ? gateway.mode : null,
bind: typeof gateway.bind === "string" ? gateway.bind : null,
port: parseIntOrNull(gateway.port),
controlUiEnabled:
typeof controlUi.enabled === "boolean" ? controlUi.enabled : null,
controlUiBasePath:
typeof controlUi.basePath === "string" ? controlUi.basePath : null,
authMode,
authTokenConfigured,
authPasswordConfigured,
remoteUrl,
remoteTokenConfigured,
remotePasswordConfigured,
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
},
bridge: {
enabled: bridgeEnabled,
bind: bridgeBind,
port: bridgePort,
},
discovery: { wideAreaEnabled },
};
}
function buildNetworkHints(cfg: ClawdbotConfig) {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const port = resolveGatewayPort(cfg);
return {
localLoopbackUrl: `ws://127.0.0.1:${port}`,
localTailnetUrl: tailnetIPv4 ? `ws://${tailnetIPv4}:${port}` : null,
tailnetIPv4: tailnetIPv4 ?? null,
};
}
function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
const kindLabel =
target.kind === "localLoopback"
? "Local loopback"
: target.kind === "sshTunnel"
? "Remote over SSH"
: target.kind === "configRemote"
? target.active
? "Remote (configured)"
: "Remote (configured, inactive)"
: "URL (explicit)";
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
}
function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
if (probe.ok) {
const latency =
typeof probe.connectLatencyMs === "number"
? `${probe.connectLatencyMs}ms`
: "unknown";
return `${colorize(rich, theme.success, "Connect: ok")} (${latency})`;
}
const detail = probe.error ? ` - ${probe.error}` : "";
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;
}
export async function gatewayStatusCommand(
opts: {
url?: string;
token?: string;
password?: string;
timeout?: unknown;
json?: boolean;
ssh?: string;
sshIdentity?: string;
sshAuto?: boolean;
},
runtime: RuntimeEnv,
) {
const startedAt = Date.now();
const cfg = loadConfig();
const rich = isRich() && opts.json !== true;
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
const baseTargets = resolveTargets(cfg, opts.url);
const network = buildNetworkHints(cfg);
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
const discoveryPromise = discoverGatewayBeacons({
timeoutMs: discoveryTimeoutMs,
});
let sshTarget =
sanitizeSshTarget(opts.ssh) ??
sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
const sshIdentity =
sanitizeSshTarget(opts.sshIdentity) ??
sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
const remotePort = resolveGatewayPort(cfg);
let sshTunnelError: string | null = null;
let sshTunnelStarted = false;
const { discovery, probed } = await withProgress(
{
label: "Inspecting gateways…",
indeterminate: true,
enabled: opts.json !== true,
},
async () => {
const tryStartTunnel = async () => {
if (!sshTarget) return null;
try {
const tunnel = await startSshPortForward({
target: sshTarget,
identity: sshIdentity ?? undefined,
localPortPreferred: remotePort,
remotePort,
timeoutMs: Math.min(1500, overallTimeoutMs),
});
sshTunnelStarted = true;
return tunnel;
} catch (err) {
sshTunnelError = err instanceof Error ? err.message : String(err);
return null;
}
};
const discoveryTask = discoveryPromise.catch(() => []);
const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
const [discovery, tunnelFirst] = await Promise.all([
discoveryTask,
tunnelTask,
]);
if (!sshTarget && opts.sshAuto) {
const user = process.env.USER?.trim() || "";
const candidates = discovery
.map((b) => {
const host = b.tailnetDns || b.lanHost || b.host;
if (!host?.trim()) return null;
const sshPort =
typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
const base = user ? `${user}@${host.trim()}` : host.trim();
return sshPort !== 22 ? `${base}:${sshPort}` : base;
})
.filter((x): x is string => Boolean(x));
if (candidates.length > 0) sshTarget = candidates[0] ?? null;
}
const tunnel =
tunnelFirst ||
(sshTarget && !sshTunnelStarted && !sshTunnelError
? await tryStartTunnel()
: null);
const tunnelTarget: GatewayStatusTarget | null = tunnel
? {
id: "sshTunnel",
kind: "sshTunnel",
url: `ws://127.0.0.1:${tunnel.localPort}`,
active: true,
tunnel: {
kind: "ssh",
target: sshTarget ?? "",
localPort: tunnel.localPort,
remotePort,
pid: tunnel.pid,
},
}
: null;
const targets: GatewayStatusTarget[] = tunnelTarget
? [
tunnelTarget,
...baseTargets.filter((t) => t.url !== tunnelTarget.url),
]
: baseTargets;
try {
const probed = await Promise.all(
targets.map(async (target) => {
const auth = resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password:
typeof opts.password === "string" ? opts.password : undefined,
});
const timeoutMs = resolveProbeBudgetMs(
overallTimeoutMs,
target.kind,
);
const probe = await probeGateway({
url: target.url,
auth,
timeoutMs,
});
const configSummary = probe.configSnapshot
? extractConfigSummary(probe.configSnapshot)
: null;
const self = pickGatewaySelfPresence(probe.presence);
return { target, probe, configSummary, self };
}),
);
return { discovery, probed };
} finally {
if (tunnel) {
try {
await tunnel.stop();
} catch {
// best-effort
}
}
}
},
);
const reachable = probed.filter((p) => p.probe.ok);
const ok = reachable.length > 0;
const multipleGateways = reachable.length > 1;
const primary =
reachable.find((p) => p.target.kind === "explicit") ??
reachable.find((p) => p.target.kind === "sshTunnel") ??
reachable.find((p) => p.target.kind === "configRemote") ??
reachable.find((p) => p.target.kind === "localLoopback") ??
null;
const warnings: Array<{
code: string;
message: string;
targetIds?: string[];
}> = [];
if (sshTarget && !sshTunnelStarted) {
warnings.push({
code: "ssh_tunnel_failed",
message: sshTunnelError
? `SSH tunnel failed: ${String(sshTunnelError)}`
: "SSH tunnel failed to start; falling back to direct probes.",
});
}
if (multipleGateways) {
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.",
targetIds: reachable.map((p) => p.target.id),
});
}
if (opts.json) {
runtime.log(
JSON.stringify(
{
ok,
ts: Date.now(),
durationMs: Date.now() - startedAt,
timeoutMs: overallTimeoutMs,
primaryTargetId: primary?.target.id ?? null,
warnings,
network,
discovery: {
timeoutMs: discoveryTimeoutMs,
count: discovery.length,
beacons: discovery.map((b) => ({
instanceName: b.instanceName,
displayName: b.displayName ?? null,
domain: b.domain ?? null,
host: b.host ?? null,
lanHost: b.lanHost ?? null,
tailnetDns: b.tailnetDns ?? null,
bridgePort: b.bridgePort ?? null,
gatewayPort: b.gatewayPort ?? null,
sshPort: b.sshPort ?? null,
wsUrl: (() => {
const host = b.tailnetDns || b.lanHost || b.host;
const port = b.gatewayPort ?? 18789;
return host ? `ws://${host}:${port}` : null;
})(),
})),
},
targets: probed.map((p) => ({
id: p.target.id,
kind: p.target.kind,
url: p.target.url,
active: p.target.active,
tunnel: p.target.tunnel ?? null,
connect: {
ok: p.probe.ok,
latencyMs: p.probe.connectLatencyMs,
error: p.probe.error,
close: p.probe.close,
},
self: p.self,
config: p.configSummary,
health: p.probe.health,
summary: p.probe.status,
presence: p.probe.presence,
})),
},
null,
2,
),
);
if (!ok) runtime.exit(1);
return;
}
runtime.log(colorize(rich, theme.heading, "Gateway Status"));
runtime.log(
ok
? `${colorize(rich, theme.success, "Reachable")}: yes`
: `${colorize(rich, theme.error, "Reachable")}: no`,
);
runtime.log(
colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`),
);
if (warnings.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.warn, "Warning:"));
for (const w of warnings) runtime.log(`- ${w.message}`);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Discovery (this machine)"));
runtime.log(
discovery.length > 0
? `Found ${discovery.length} gateway(s) via Bonjour (local. + clawdbot.internal.)`
: "Found 0 gateways via Bonjour (local. + clawdbot.internal.)",
);
if (discovery.length === 0) {
runtime.log(
colorize(
rich,
theme.muted,
"Tip: if the gateway is remote, mDNS wont cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.",
),
);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Targets"));
for (const p of probed) {
runtime.log(renderTargetHeader(p.target, rich));
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
if (p.target.tunnel?.kind === "ssh") {
runtime.log(
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`,
);
}
if (p.probe.ok && p.self) {
const host = p.self.host ?? "unknown";
const ip = p.self.ip ? ` (${p.self.ip})` : "";
const platform = p.self.platform ? ` · ${p.self.platform}` : "";
const version = p.self.version ? ` · app ${p.self.version}` : "";
runtime.log(
` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`,
);
}
if (p.configSummary) {
const c = p.configSummary;
const bridge =
c.bridge.enabled === false
? "disabled"
: c.bridge.enabled === true
? "enabled"
: "unknown";
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
: c.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
runtime.log(
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
);
runtime.log(
` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`,
);
}
runtime.log("");
}
if (!ok) runtime.exit(1);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
describe("applyGoogleGeminiModelDefault", () => {
it("sets gemini default when model is unset", () => {
const cfg: ClawdbotConfig = { agent: {} };
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("overrides existing model", () => {
const cfg: ClawdbotConfig = {
agent: { model: "anthropic/claude-opus-4-5" },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agent?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
});
it("no-ops when already gemini default", () => {
const cfg: ClawdbotConfig = {
agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
});
});
@@ -0,0 +1,38 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { AgentModelListConfig } from "../config/types.js";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview";
function resolvePrimaryModel(
model?: AgentModelListConfig | string,
): string | undefined {
if (typeof model === "string") return model;
if (model && typeof model === "object" && typeof model.primary === "string") {
return model.primary;
}
return undefined;
}
export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): {
next: ClawdbotConfig;
changed: boolean;
} {
const current = resolvePrimaryModel(cfg.agent?.model)?.trim();
if (current === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: {
...cfg,
agent: {
...cfg.agent,
model:
cfg.agent?.model && typeof cfg.agent.model === "object"
? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL }
: { primary: GOOGLE_GEMINI_DEFAULT_MODEL },
},
},
changed: true,
};
}
+153
View File
@@ -0,0 +1,153 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { messageCommand } from "./message.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
randomIdempotencyKey: () => "idem-1",
}));
const webAuthExists = vi.fn(async () => false);
vi.mock("../web/session.js", () => ({
webAuthExists: (...args: unknown[]) => webAuthExists(...args),
}));
const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/discord-actions.js", () => ({
handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args),
}));
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
const handleTelegramAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/telegram-actions.js", () => ({
handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
}));
const handleWhatsAppAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../agents/tools/whatsapp-actions.js", () => ({
handleWhatsAppAction: (...args: unknown[]) => handleWhatsAppAction(...args),
}));
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "";
process.env.DISCORD_BOT_TOKEN = "";
testConfig = {};
callGatewayMock.mockReset();
webAuthExists.mockReset().mockResolvedValue(false);
handleDiscordAction.mockReset();
handleSlackAction.mockReset();
handleTelegramAction.mockReset();
handleWhatsAppAction.mockReset();
});
afterAll(() => {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
...overrides,
});
describe("messageCommand", () => {
it("defaults provider when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
const deps = makeDeps();
await messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
);
expect(handleTelegramAction).toHaveBeenCalled();
});
it("requires provider when multiple configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
const deps = makeDeps();
await expect(
messageCommand(
{
to: "123",
message: "hi",
},
deps,
runtime,
),
).rejects.toThrow(/Provider is required/);
});
it("sends via gateway for WhatsApp", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
await messageCommand(
{
action: "send",
provider: "whatsapp",
to: "+1",
message: "hi",
},
deps,
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
});
it("routes discord polls through message action", async () => {
const deps = makeDeps();
await messageCommand(
{
action: "poll",
provider: "discord",
to: "channel:123",
pollQuestion: "Snack?",
pollOption: ["Pizza", "Sushi"],
},
deps,
runtime,
);
expect(handleDiscordAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
to: "channel:123",
}),
expect.any(Object),
);
});
});
File diff suppressed because it is too large Load Diff
+5
View File
@@ -3,6 +3,11 @@ export {
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
} from "./models/aliases.js";
export {
modelsAuthAddCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
} from "./models/auth.js";
export {
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,

Some files were not shown because too many files have changed in this diff Show More