Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-09 11:04:23 -05:00
committed by GitHub
359 changed files with 18384 additions and 4739 deletions

View File

@@ -11,10 +11,8 @@ describe("resolveAgentConfig", () => {
it("should return undefined when agent id does not exist", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
const result = resolveAgentConfig(cfg, "nonexistent");
@@ -23,15 +21,16 @@ describe("resolveAgentConfig", () => {
it("should return basic agent config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main Agent",
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
},
},
],
},
};
const result = resolveAgentConfig(cfg, "main");
@@ -40,6 +39,9 @@ describe("resolveAgentConfig", () => {
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
identity: undefined,
groupChat: undefined,
subagents: undefined,
sandbox: undefined,
tools: undefined,
});
@@ -47,9 +49,10 @@ describe("resolveAgentConfig", () => {
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -57,13 +60,9 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "work");
@@ -73,25 +72,22 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
});
});
it("should return agent-specific tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "restricted");
@@ -103,9 +99,10 @@ describe("resolveAgentConfig", () => {
it("should return both sandbox and tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
family: {
agents: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all",
@@ -116,7 +113,7 @@ describe("resolveAgentConfig", () => {
deny: ["bash"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "family");
@@ -126,10 +123,8 @@ describe("resolveAgentConfig", () => {
it("should normalize agent id", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
// Should normalize to "main" (default)

View File

@@ -3,61 +3,75 @@ import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
export function resolveAgentIdFromSessionKey(
sessionKey?: string | null,
): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
sandbox?: AgentEntry["sandbox"];
tools?: AgentEntry["tools"];
};
let defaultAgentWarned = false;
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID;
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
console.warn(
"Multiple agents marked default=true; using the first entry as default.",
);
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
export function resolveAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
):
| {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
subagents?: {
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
scope?: "session" | "agent" | "shared";
perSession?: boolean;
workspaceRoot?: string;
tools?: {
allow?: string[];
deny?: string[];
};
};
tools?: {
allow?: string[];
deny?: string[];
};
}
| undefined {
): ResolvedAgentConfig | undefined {
const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents;
if (!agents || typeof agents !== "object") return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
const entry = resolveAgentEntry(cfg, id);
if (!entry) return undefined;
return {
name: typeof entry.name === "string" ? entry.name : undefined,
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
identity: entry.identity,
groupChat: entry.groupChat,
subagents:
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
@@ -71,9 +85,10 @@ export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) return resolveUserPath(configured);
if (id === DEFAULT_AGENT_ID) {
const legacy = cfg.agent?.workspace?.trim();
if (legacy) return resolveUserPath(legacy);
const defaultAgentId = resolveDefaultAgentId(cfg);
if (id === defaultAgentId) {
const fallback = cfg.agents?.defaults?.workspace?.trim();
if (fallback) return resolveUserPath(fallback);
return DEFAULT_AGENT_WORKSPACE_DIR;
}
return path.join(os.homedir(), `clawd-${id}`);

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
@@ -13,40 +14,6 @@ import {
resolveAuthProfileOrder,
} from "./auth-profiles.js";
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
type HomeEnvSnapshot = Record<
(typeof HOME_ENV_KEYS)[number],
string | undefined
>;
const snapshotHomeEnv = (): HomeEnvSnapshot => ({
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
});
const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => {
for (const key of HOME_ENV_KEYS) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
const setTempHome = (tempHome: string) => {
process.env.HOME = tempHome;
if (process.platform === "win32") {
process.env.USERPROFILE = tempHome;
const root = path.parse(tempHome).root;
process.env.HOMEDRIVE = root.replace(/\\$/, "");
process.env.HOMEPATH = tempHome.slice(root.length - 1);
}
};
describe("resolveAuthProfileOrder", () => {
const store: AuthProfileStore = {
version: 1,
@@ -130,6 +97,60 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("prefers store order over config order", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
order: { anthropic: ["anthropic:work", "anthropic:default"] },
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes cooldown profiles to the end even with store order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
store: {
...store,
order: { anthropic: ["anthropic:default", "anthropic:work"] },
usageStats: {
"anthropic:default": { cooldownUntil: now + 60_000 },
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes cooldown profiles to the end even with configured order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
usageStats: {
"anthropic:default": { cooldownUntil: now + 60_000 },
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("normalizes z.ai aliases in auth.order", () => {
const order = resolveAuthProfileOrder({
cfg: {
@@ -377,259 +398,259 @@ describe("auth profile cooldowns", () => {
});
describe("external CLI credential sync", () => {
it("syncs Claude CLI credentials into anthropic:claude-cli", () => {
it("syncs Claude CLI credentials into anthropic:claude-cli", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
);
const originalHome = snapshotHomeEnv();
try {
// Create a temp home with Claude CLI credentials
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
await withTempHome(
async (tempHome) => {
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
},
}),
);
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Load the store - should sync from CLI
const store = ensureAuthProfileStore(agentDir);
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
"sk-default",
// Load the store - should sync from CLI
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect(
(store.profiles["anthropic:default"] as { key: string }).key,
).toBe("sk-default");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(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,
).toBeGreaterThan(Date.now());
},
{ prefix: "clawdbot-home-" },
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(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,
).toBeGreaterThan(Date.now());
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("syncs Codex CLI credentials into openai-codex:codex-cli", () => {
it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-sync-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
await withTempHome(
async (tempHome) => {
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
).toBe("codex-access-token");
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
{ prefix: "clawdbot-home-" },
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
).toBe("codex-access-token");
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not overwrite API keys when syncing external CLI creds", () => {
it("does not overwrite API keys when syncing external CLI creds", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-no-overwrite-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
await withTempHome(
async (tempHome) => {
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
},
}),
);
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
const store = ensureAuthProfileStore(agentDir);
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
},
},
}),
);
// Should keep the store's API key and still add the CLI profile.
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
"sk-store",
const store = ensureAuthProfileStore(agentDir);
// Should keep the store's API key and still add the CLI profile.
expect(
(store.profiles["anthropic:default"] as { key: string }).key,
).toBe("sk-store");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not overwrite fresher store token with older Claude CLI credentials", () => {
it("does not overwrite fresher store token with older Claude CLI credentials", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
await withTempHome(
async (tempHome) => {
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
);
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "store-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access");
},
{ prefix: "clawdbot-home-" },
);
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "store-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access");
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("updates codex-cli profile when Codex CLI refresh token changes", () => {
it("updates codex-cli profile when Codex CLI refresh token changes", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "same-access",
refresh_token: "new-refresh",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: { access_token: "same-access", refresh_token: "new-refresh" },
}),
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string })
.refresh,
).toBe("new-refresh");
},
{ prefix: "clawdbot-home-" },
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh,
).toBe("new-refresh");
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});

View File

@@ -82,6 +82,12 @@ export type ProfileUsageStats = {
export type AuthProfileStore = {
version: number;
profiles: Record<string, AuthProfileCredential>;
/**
* Optional per-agent preferred profile order overrides.
* This lets you lock/override auth rotation for a specific agent without
* changing the global config.
*/
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
/** Usage statistics per profile for round-robin rotation */
usageStats?: Record<string, ProfileUsageStats>;
@@ -133,6 +139,7 @@ function syncAuthProfileStore(
): void {
target.version = source.version;
target.profiles = source.profiles;
target.order = source.order;
target.lastGood = source.lastGood;
target.usageStats = source.usageStats;
}
@@ -270,9 +277,25 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
const order =
record.order && typeof record.order === "object"
? Object.entries(record.order as Record<string, unknown>).reduce(
(acc, [provider, value]) => {
if (!Array.isArray(value)) return acc;
const list = value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (list.length === 0) return acc;
acc[provider] = list;
return acc;
},
{} as Record<string, string[]>,
)
: undefined;
return {
version: Number(record.version ?? AUTH_STORE_VERSION),
profiles: normalized,
order,
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
@@ -680,12 +703,47 @@ export function saveAuthProfileStore(
const payload = {
version: AUTH_STORE_VERSION,
profiles: store.profiles,
order: store.order ?? undefined,
lastGood: store.lastGood ?? undefined,
usageStats: store.usageStats ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
}
export async function setAuthProfileOrder(params: {
agentDir?: string;
provider: string;
order?: string[] | null;
}): Promise<AuthProfileStore | null> {
const providerKey = normalizeProviderId(params.provider);
const sanitized =
params.order && Array.isArray(params.order)
? params.order.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const deduped: string[] = [];
for (const entry of sanitized) {
if (!deduped.includes(entry)) deduped.push(entry);
}
return await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
updater: (store) => {
store.order = store.order ?? {};
if (deduped.length === 0) {
if (!store.order[providerKey]) return false;
delete store.order[providerKey];
if (Object.keys(store.order).length === 0) {
store.order = undefined;
}
return true;
}
store.order[providerKey] = deduped;
return true;
},
});
}
export function upsertAuthProfile(params: {
profileId: string;
credential: AuthProfileCredential;
@@ -863,6 +921,14 @@ export function resolveAuthProfileOrder(params: {
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const storedOrder = (() => {
const order = store.order;
if (!order) return undefined;
for (const [key, value] of Object.entries(order)) {
if (normalizeProviderId(key) === providerKey) return value;
}
return undefined;
})();
const configuredOrder = (() => {
const order = cfg?.auth?.order;
if (!order) return undefined;
@@ -871,6 +937,7 @@ export function resolveAuthProfileOrder(params: {
}
return undefined;
})();
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(
@@ -880,7 +947,7 @@ export function resolveAuthProfileOrder(params: {
.map(([profileId]) => profileId)
: [];
const baseOrder =
configuredOrder ??
explicitOrder ??
(explicitProfiles.length > 0
? explicitProfiles
: listProfilesForProvider(store, providerKey));
@@ -895,16 +962,44 @@ export function resolveAuthProfileOrder(params: {
if (!deduped.includes(entry)) deduped.push(entry);
}
// If user specified explicit order in config, respect it exactly
if (configuredOrder && configuredOrder.length > 0) {
// If user specified explicit order (store override or config), respect it
// exactly, but still apply cooldown sorting to avoid repeatedly selecting
// known-bad/rate-limited keys as the first candidate.
if (explicitOrder && explicitOrder.length > 0) {
// ...but still respect cooldown tracking to avoid repeatedly selecting a
// known-bad/rate-limited key as the first candidate.
const now = Date.now();
const available: string[] = [];
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
for (const profileId of deduped) {
const cooldownUntil = store.usageStats?.[profileId]?.cooldownUntil;
if (
typeof cooldownUntil === "number" &&
Number.isFinite(cooldownUntil) &&
cooldownUntil > 0 &&
now < cooldownUntil
) {
inCooldown.push({ profileId, cooldownUntil });
} else {
available.push(profileId);
}
}
const cooldownSorted = inCooldown
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
.map((entry) => entry.profileId);
const ordered = [...available, ...cooldownSorted];
// Still put preferredProfile first if specified
if (preferredProfile && deduped.includes(preferredProfile)) {
if (preferredProfile && ordered.includes(preferredProfile)) {
return [
preferredProfile,
...deduped.filter((e) => e !== preferredProfile),
...ordered.filter((e) => e !== preferredProfile),
];
}
return deduped;
return ordered;
}
// Otherwise, use round-robin: sort by lastUsed (oldest first)
@@ -1092,8 +1187,8 @@ export async function markAuthProfileGood(params: {
saveAuthProfileStore(store, agentDir);
}
export function resolveAuthStorePathForDisplay(): string {
const pathname = resolveAuthStorePath();
export function resolveAuthStorePathForDisplay(agentDir?: string): string {
const pathname = resolveAuthStorePath(agentDir);
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}

View File

@@ -50,35 +50,40 @@ beforeEach(() => {
});
describe("bash tool backgrounding", () => {
it("backgrounds after yield and can be polled", async () => {
const result = await bashTool.execute("call1", {
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let status = "running";
let output = "";
const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call2", {
action: "poll",
sessionId,
it(
"backgrounds after yield and can be polled",
async () => {
const result = await bashTool.execute("call1", {
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
status = (poll.details as { status: string }).status;
const textBlock = poll.content.find((c) => c.type === "text");
output = textBlock?.text ?? "";
if (status === "running") {
await sleep(20);
}
}
expect(status).toBe("completed");
expect(output).toContain("done");
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let status = "running";
let output = "";
const deadline =
Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call2", {
action: "poll",
sessionId,
});
status = (poll.details as { status: string }).status;
const textBlock = poll.content.find((c) => c.type === "text");
output = textBlock?.text ?? "";
if (status === "running") {
await sleep(20);
}
}
expect(status).toBe("completed");
expect(output).toContain("done");
},
isWin ? 15_000 : 5_000,
);
it("supports explicit background", async () => {
const result = await bashTool.execute("call1", {

View File

@@ -8,6 +8,7 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { logInfo } from "../logger.js";
import { sliceUtf16Safe } from "../utils.js";
import {
addSession,
appendOutput,
@@ -1041,7 +1042,7 @@ function chunkString(input: string, limit = CHUNK_LIMIT) {
function truncateMiddle(str: string, max: number) {
if (str.length <= max) return str;
const half = Math.floor((max - 3) / 2);
return `${str.slice(0, half)}...${str.slice(str.length - half)}`;
return `${sliceUtf16Safe(str, 0, half)}...${sliceUtf16Safe(str, -half)}`;
}
function sliceLogLines(

View File

@@ -108,7 +108,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -134,7 +134,9 @@ function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone);
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
@@ -143,7 +145,7 @@ function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo: {
host: "clawdbot",

View File

@@ -46,7 +46,7 @@ describe("gateway tool", () => {
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
const raw = '{\n agent: { workspace: "~/clawd" }\n}\n';
const raw = '{\n agents: { defaults: { workspace: "~/clawd" } }\n}\n';
await tool.execute("call2", {
action: "config.apply",
raw,

View File

@@ -52,18 +52,20 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main",
subagents: {
allowAgents: ["research"],
},
},
research: {
{
id: "research",
name: "Research",
},
},
],
},
};
@@ -87,20 +89,23 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
research: {
{
id: "research",
name: "Research",
},
coder: {
{
id: "coder",
name: "Coder",
},
},
],
},
};
@@ -131,14 +136,15 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
},
],
},
};

View File

@@ -314,14 +314,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["beta"],
},
},
},
],
},
};
@@ -365,14 +366,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
},
],
},
};
@@ -416,14 +418,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["Research"],
},
},
},
],
},
};
@@ -467,14 +470,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["alpha"],
},
},
},
],
},
};

69
src/agents/identity.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { ClawdbotConfig, IdentityConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
const DEFAULT_ACK_REACTION = "👀";
export function resolveAgentIdentity(
cfg: ClawdbotConfig,
agentId: string,
): IdentityConfig | undefined {
return resolveAgentConfig(cfg, agentId)?.identity;
}
export function resolveAckReaction(
cfg: ClawdbotConfig,
agentId: string,
): string {
const configured = cfg.messages?.ackReaction;
if (configured !== undefined) return configured.trim();
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || DEFAULT_ACK_REACTION;
}
export function resolveIdentityNamePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const name = resolveAgentIdentity(cfg, agentId)?.name?.trim();
if (!name) return undefined;
return `[${name}]`;
}
export function resolveMessagePrefix(
cfg: ClawdbotConfig,
agentId: string,
opts?: { hasAllowFrom?: boolean; fallback?: string },
): string {
const configured = cfg.messages?.messagePrefix;
if (configured !== undefined) return configured;
const hasAllowFrom = opts?.hasAllowFrom === true;
if (hasAllowFrom) return "";
return (
resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]"
);
}
export function resolveResponsePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const configured = cfg.messages?.responsePrefix;
if (configured !== undefined) return configured;
return resolveIdentityNamePrefix(cfg, agentId);
}
export function resolveEffectiveMessagesConfig(
cfg: ClawdbotConfig,
agentId: string,
opts?: { hasAllowFrom?: boolean; fallbackMessagePrefix?: string },
): { messagePrefix: string; responsePrefix?: string } {
return {
messagePrefix: resolveMessagePrefix(cfg, agentId, {
hasAllowFrom: opts?.hasAllowFrom,
fallback: opts?.fallbackMessagePrefix,
}),
responsePrefix: resolveResponsePrefix(cfg, agentId),
};
}

View File

@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;

View File

@@ -136,6 +136,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
minimax: "MINIMAX_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
};

View File

@@ -34,7 +34,7 @@ function buildAllowedModelKeys(
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {};
const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
@@ -85,7 +85,7 @@ function resolveImageFallbackCandidates(params: {
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
} else {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { primary?: string }
| string
| undefined;
@@ -95,7 +95,7 @@ function resolveImageFallbackCandidates(params: {
}
const imageFallbacks = (() => {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { fallbacks?: string[] }
| string
| undefined;
@@ -142,7 +142,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
const model = params.cfg?.agent?.model as
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
| undefined;
@@ -253,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
});
if (candidates.length === 0) {
throw new Error(
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
"No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.",
);
}

View File

@@ -18,9 +18,11 @@ const catalog = [
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agent: {
models: {
"openai/gpt-4": { alias: "gpt4" },
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
},
} as ClawdbotConfig;
@@ -41,7 +43,7 @@ describe("buildAllowedModelSet", () => {
it("includes the default model when no allowlist is set", () => {
const cfg = {
agent: {},
agents: { defaults: {} },
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({

View File

@@ -65,7 +65,7 @@ export function buildModelAliasIndex(params: {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
const rawModels = params.cfg.agent?.models ?? {};
const rawModels = params.cfg.agents?.defaults?.models ?? {};
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
if (!parsed) continue;
@@ -109,7 +109,7 @@ export function resolveConfiguredModelRef(params: {
defaultModel: string;
}): ModelRef {
const rawModel = (() => {
const raw = params.cfg.agent?.model as
const raw = params.cfg.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
@@ -128,7 +128,7 @@ export function resolveConfiguredModelRef(params: {
aliasIndex,
});
if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
return { provider: "anthropic", model: trimmed };
}
return { provider: params.defaultProvider, model: params.defaultModel };
@@ -145,7 +145,7 @@ export function buildAllowedModelSet(params: {
allowedKeys: Set<string>;
} {
const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {};
const modelMap = params.cfg.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0;
@@ -203,7 +203,7 @@ export function resolveThinkingDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
const configured = params.cfg.agent?.thinkingDefault;
const configured = params.cfg.agents?.defaults?.thinkingDefault;
if (configured) return configured;
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,

View File

@@ -1,20 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-models-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
}
const MODELS_CONFIG: ClawdbotConfig = {

View File

@@ -110,12 +110,14 @@ describe("resolveExtraParams", () => {
it("respects explicit thinking config from user (disable thinking)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
},
},
},
},
@@ -136,12 +138,14 @@ describe("resolveExtraParams", () => {
it("preserves other params while adding thinking config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
},
},
},
},
@@ -164,13 +168,15 @@ describe("resolveExtraParams", () => {
it("does not override explicit thinking config even if partial", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
},
},
},
},
@@ -214,12 +220,14 @@ describe("resolveExtraParams", () => {
it("passes through params for non-GLM models without modification", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
},
},
},
},
@@ -264,7 +272,7 @@ describe("resolveExtraParams", () => {
it("handles config with empty models gracefully", () => {
const result = resolveExtraParams({
cfg: { agent: { models: {} } },
cfg: { agents: { defaults: { models: {} } } },
provider: "zai",
modelId: "glm-4.7",
});
@@ -280,12 +288,14 @@ describe("resolveExtraParams", () => {
it("model alias lookup uses exact provider/model key", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
},
},
},
},
@@ -307,11 +317,13 @@ describe("resolveExtraParams", () => {
it("treats thinking: null as explicit config (no auto-enable)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
},
},
},
},
@@ -374,11 +386,13 @@ describe("resolveExtraParams", () => {
it("thinkLevel: 'off' still passes through explicit config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
},
},
},
},

View File

@@ -105,7 +105,7 @@ import { loadWorkspaceBootstrapFiles } from "./workspace.js";
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
*
* Users can override via config:
* agent.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
* agents.defaults.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
*
* Or disable via runtime flag: --thinking off
*
@@ -119,7 +119,7 @@ export function resolveExtraParams(params: {
thinkLevel?: string;
}): Record<string, unknown> | undefined {
const modelKey = `${params.provider}/${params.modelId}`;
const modelConfig = params.cfg?.agent?.models?.[modelKey];
const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined;
// Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured
@@ -200,10 +200,10 @@ function resolveContextWindowTokens(params: {
if (fromModelsConfig) return fromModelsConfig;
const fromAgentConfig =
typeof params.cfg?.agent?.contextTokens === "number" &&
Number.isFinite(params.cfg.agent.contextTokens) &&
params.cfg.agent.contextTokens > 0
? Math.floor(params.cfg.agent.contextTokens)
typeof params.cfg?.agents?.defaults?.contextTokens === "number" &&
Number.isFinite(params.cfg.agents.defaults.contextTokens) &&
params.cfg.agents.defaults.contextTokens > 0
? Math.floor(params.cfg.agents.defaults.contextTokens)
: undefined;
if (fromAgentConfig) return fromAgentConfig;
@@ -217,7 +217,7 @@ function buildContextPruningExtension(params: {
modelId: string;
model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } {
const raw = params.cfg?.agent?.contextPruning;
const raw = params.cfg?.agents?.defaults?.contextPruning;
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
const settings = computeEffectiveSettings(raw);
@@ -254,7 +254,7 @@ export type EmbeddedPiRunMeta = {
};
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -844,7 +844,7 @@ export async function compactEmbeddedPiSession(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -865,7 +865,7 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -875,7 +875,7 @@ export async function compactEmbeddedPiSession(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1157,7 +1157,7 @@ export async function runEmbeddedPiAgent(params: {
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -1178,7 +1178,7 @@ export async function runEmbeddedPiAgent(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -1188,7 +1188,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1444,7 +1444,8 @@ export async function runEmbeddedPiAgent(params: {
}
const fallbackConfigured =
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) >
0;
const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);

View File

@@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { truncateUtf16Safe } from "../utils.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
import { isMessagingToolDuplicate } from "./pi-embedded-helpers.js";
@@ -64,7 +65,7 @@ type MessagingToolSend = {
function truncateToolText(text: string): string {
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
}
function sanitizeToolResult(result: unknown): unknown {

View File

@@ -6,18 +6,17 @@ import type { SandboxDockerConfig } from "./sandbox.js";
describe("Agent-specific tool filtering", () => {
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
},
},
],
},
};
@@ -36,22 +35,21 @@ describe("Agent-specific tool filtering", () => {
it("should apply agent-specific tool policy", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"], // Agent override: only read
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
@@ -71,20 +69,22 @@ describe("Agent-specific tool filtering", () => {
it("should allow different tool policies for different agents", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
// No tools restriction - all tools available
},
family: {
{
id: "family",
workspace: "~/clawd-family",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit", "process"],
},
},
},
],
},
};
@@ -116,20 +116,19 @@ describe("Agent-specific tool filtering", () => {
it("should prefer agent-specific tool policy over global", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["browser"], // Global deny
},
tools: {
deny: ["browser"], // Global deny
},
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
tools: {
deny: ["bash", "process"], // Agent deny (override)
},
},
},
],
},
};
@@ -149,19 +148,16 @@ describe("Agent-specific tool filtering", () => {
it("should work with sandbox tools filtering", () => {
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
@@ -172,6 +168,14 @@ describe("Agent-specific tool filtering", () => {
deny: ["bash", "write"],
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
},
},
},
};
@@ -216,10 +220,8 @@ describe("Agent-specific tool filtering", () => {
it("should run bash synchronously when process is denied", async () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["process"],
},
tools: {
deny: ["process"],
},
};

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { createClawdbotCodingTools } from "./pi-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
describe("createClawdbotCodingTools", () => {
@@ -64,6 +64,28 @@ describe("createClawdbotCodingTools", () => {
expect(format?.enum).toEqual(["aria", "ai"]);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = [
@@ -171,7 +193,7 @@ describe("createClawdbotCodingTools", () => {
sessionKey: "agent:main:subagent:test",
// Intentionally partial config; only fields used by pi-tools are provided.
config: {
agent: {
tools: {
subagents: {
tools: {
// Policy matching is case-insensitive
@@ -325,10 +347,58 @@ describe("createClawdbotCodingTools", () => {
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
config: { tools: { deny: ["browser"] } },
});
// 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);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
]);
const findUnsupportedKeywords = (
schema: unknown,
path: string,
): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
for (const [key, value] of Object.entries(
schema as Record<string, unknown>,
)) {
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of tools) {
const violations = findUnsupportedKeywords(
tool.parameters,
`${tool.name}.parameters`,
);
expect(violations).toEqual([]);
}
});
});

View File

@@ -24,6 +24,7 @@ import {
import { createClawdbotTools } from "./clawdbot-tools.js";
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
@@ -154,128 +155,6 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
return existing;
}
// Check if an anyOf array contains only literal values that can be flattened
// TypeBox Type.Literal generates { const: "value", type: "string" }
// Some schemas may use { enum: ["value"], type: "string" }
// Both patterns are flattened to { type: "string", enum: ["a", "b", ...] }
function tryFlattenLiteralAnyOf(
anyOf: unknown[],
): { type: string; enum: unknown[] } | null {
if (anyOf.length === 0) return null;
const allValues: unknown[] = [];
let commonType: string | null = null;
for (const variant of anyOf) {
if (!variant || typeof variant !== "object") return null;
const v = variant as Record<string, unknown>;
// Extract the literal value - either from const or single-element enum
let literalValue: unknown;
if ("const" in v) {
literalValue = v.const;
} else if (Array.isArray(v.enum) && v.enum.length === 1) {
literalValue = v.enum[0];
} else {
return null; // Not a literal pattern
}
// Must have consistent type (usually "string")
const variantType = typeof v.type === "string" ? v.type : null;
if (!variantType) return null;
if (commonType === null) commonType = variantType;
else if (commonType !== variantType) return null;
allValues.push(literalValue);
}
if (commonType && allValues.length > 0) {
return { type: commonType, enum: allValues };
}
return null;
}
function cleanSchemaForGemini(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const obj = schema as Record<string, unknown>;
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
// Try to flatten anyOf of literals to a single enum BEFORE processing
// This handles Type.Union([Type.Literal("a"), Type.Literal("b")]) patterns
if (hasAnyOf) {
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
if (flattened) {
// Return flattened enum, preserving metadata (description, title, default, examples)
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) {
result[key] = obj[key];
}
}
return result;
}
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
// Skip unsupported schema features for Gemini:
// - patternProperties: not in OpenAPI 3.0 subset
// - const: convert to enum with single value instead
if (key === "patternProperties") {
// Gemini doesn't support patternProperties - skip it
continue;
}
// Convert const to enum (Gemini doesn't support const)
if (key === "const") {
cleaned.enum = [value];
continue;
}
// Skip 'type' if we have 'anyOf' — Gemini doesn't allow both
if (key === "type" && hasAnyOf) {
continue;
}
if (key === "properties" && value && typeof value === "object") {
// Recursively clean nested properties
const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]),
);
} else if (key === "items" && value && typeof value === "object") {
// Recursively clean array items schema
cleaned[key] = cleanSchemaForGemini(value);
} else if (key === "anyOf" && Array.isArray(value)) {
// Clean each anyOf variant
cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (key === "oneOf" && Array.isArray(value)) {
// Clean each oneOf variant
cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (key === "allOf" && Array.isArray(value)) {
// Clean each allOf variant
cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (
key === "additionalProperties" &&
value &&
typeof value === "object"
) {
// Recursively clean additionalProperties schema
cleaned[key] = cleanSchemaForGemini(value);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
@@ -394,6 +273,10 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
};
}
function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
@@ -429,7 +312,7 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
];
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
const configured = cfg?.agent?.subagents?.tools;
const configured = cfg?.tools?.subagents?.tools;
const deny = [
...DEFAULT_SUBAGENT_TOOL_DENY,
...(Array.isArray(configured?.deny) ? configured.deny : []),
@@ -466,7 +349,7 @@ function resolveEffectiveToolPolicy(params: {
? resolveAgentConfig(params.config, agentId)
: undefined;
const hasAgentTools = agentConfig?.tools !== undefined;
const globalTools = params.config?.agent?.tools;
const globalTools = params.config?.tools;
return {
agentId,
policy: hasAgentTools ? agentConfig?.tools : globalTools,
@@ -613,6 +496,10 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
export const __testing = {
cleanToolSchemaForGemini,
} as const;
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;

View File

@@ -52,51 +52,57 @@ describe("Agent-specific sandbox config", () => {
spawnCalls.length = 0;
});
it("should use global sandbox config when no agent-specific config exists", async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
it(
"should use global sandbox config when no agent-specific config exists",
{ timeout: 15_000 },
async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
},
},
routing: {
const cfg: ClawdbotConfig = {
agents: {
main: {
workspace: "~/clawd",
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
list: [
{
id: "main",
workspace: "~/clawd",
},
],
},
},
};
};
const context = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test",
});
const context = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test",
});
expect(context).toBeDefined();
expect(context?.enabled).toBe(true);
});
expect(context).toBeDefined();
expect(context?.enabled).toBe(true);
},
);
it("should allow agent-specific docker setupCommand overrides", async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -106,7 +112,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -133,18 +139,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -154,7 +161,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -182,19 +189,20 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -205,7 +213,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -224,21 +232,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all", // Global default
scope: "agent",
agents: {
defaults: {
sandbox: {
mode: "all", // Global default
scope: "agent",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // Agent override
},
},
},
],
},
};
@@ -256,21 +265,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "off", // Global default
agents: {
defaults: {
sandbox: {
mode: "off", // Global default
},
},
},
routing: {
agents: {
family: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // Agent override
scope: "agent",
},
},
},
],
},
};
@@ -288,22 +298,23 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "session", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "session", // Global default
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
scope: "agent", // Agent override
},
},
},
],
},
};
@@ -322,16 +333,17 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
},
},
},
routing: {
agents: {
isolated: {
list: [
{
id: "isolated",
workspace: "~/clawd-isolated",
sandbox: {
mode: "all",
@@ -339,7 +351,7 @@ describe("Agent-specific sandbox config", () => {
workspaceRoot: "/tmp/isolated-sandboxes", // Agent override
},
},
},
],
},
};
@@ -359,28 +371,30 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "non-main",
scope: "session",
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // main: no sandbox
},
},
family: {
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // family: always sandbox
scope: "agent",
},
},
},
],
},
};
@@ -406,29 +420,38 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read"],
deny: ["bash"],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
},
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
};

View File

@@ -1,16 +1,20 @@
import { describe, expect, it } from "vitest";
describe("sandbox config merges", () => {
it("resolves sandbox scope deterministically", async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
it(
"resolves sandbox scope deterministically",
{ timeout: 15_000 },
async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
expect(resolveSandboxScope({})).toBe("agent");
expect(resolveSandboxScope({ perSession: true })).toBe("session");
expect(resolveSandboxScope({ perSession: false })).toBe("shared");
expect(resolveSandboxScope({ perSession: true, scope: "agent" })).toBe(
"agent",
);
});
expect(resolveSandboxScope({})).toBe("agent");
expect(resolveSandboxScope({ perSession: true })).toBe("session");
expect(resolveSandboxScope({ perSession: false })).toBe("shared");
expect(resolveSandboxScope({ perSession: true, scope: "agent" })).toBe(
"agent",
);
},
);
it("merges sandbox docker env and ulimits (agent wins)", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");

View File

@@ -14,11 +14,18 @@ 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 {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
@@ -329,19 +336,26 @@ 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,
): SandboxConfig {
const agent = cfg?.agent?.sandbox;
const agent = cfg?.agents?.defaults?.sandbox;
// Agent-specific sandbox config overrides global
let agentSandbox: typeof agent | undefined;
if (agentId && cfg?.routing?.agents) {
const agentConfig = cfg.routing.agents[agentId];
if (agentConfig && typeof agentConfig === "object") {
agentSandbox = agentConfig.sandbox;
}
const agentConfig =
cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agentConfig?.sandbox) {
agentSandbox = agentConfig.sandbox;
}
const scope = resolveSandboxScope({
@@ -370,9 +384,13 @@ export function resolveSandboxConfigForAgent(
}),
tools: {
allow:
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
agentConfig?.tools?.sandbox?.tools?.allow ??
cfg?.tools?.sandbox?.tools?.allow ??
DEFAULT_TOOL_ALLOW,
deny:
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
agentConfig?.tools?.sandbox?.tools?.deny ??
cfg?.tools?.sandbox?.tools?.deny ??
DEFAULT_TOOL_DENY,
},
prune: resolveSandboxPruneConfig({
scope,
@@ -1047,7 +1065,7 @@ export async function resolveSandboxContext(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
@@ -1121,7 +1139,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
@@ -1145,3 +1163,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);
}
}
}

View File

@@ -0,0 +1,229 @@
// Cloud Code Assist API rejects a subset of JSON Schema keywords.
// This module scrubs/normalizes tool schemas to keep Gemini happy.
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
]);
// Check if an anyOf/oneOf array contains only literal values that can be flattened.
// TypeBox Type.Literal generates { const: "value", type: "string" }.
// Some schemas may use { enum: ["value"], type: "string" }.
// Both patterns are flattened to { type: "string", enum: ["a", "b", ...] }.
function tryFlattenLiteralAnyOf(
variants: unknown[],
): { type: string; enum: unknown[] } | null {
if (variants.length === 0) return null;
const allValues: unknown[] = [];
let commonType: string | null = null;
for (const variant of variants) {
if (!variant || typeof variant !== "object") return null;
const v = variant as Record<string, unknown>;
let literalValue: unknown;
if ("const" in v) {
literalValue = v.const;
} else if (Array.isArray(v.enum) && v.enum.length === 1) {
literalValue = v.enum[0];
} else {
return null;
}
const variantType = typeof v.type === "string" ? v.type : null;
if (!variantType) return null;
if (commonType === null) commonType = variantType;
else if (commonType !== variantType) return null;
allValues.push(literalValue);
}
if (commonType && allValues.length > 0)
return { type: commonType, enum: allValues };
return null;
}
type SchemaDefs = Map<string, unknown>;
function extendSchemaDefs(
defs: SchemaDefs | undefined,
schema: Record<string, unknown>,
): SchemaDefs | undefined {
const defsEntry =
schema.$defs &&
typeof schema.$defs === "object" &&
!Array.isArray(schema.$defs)
? (schema.$defs as Record<string, unknown>)
: undefined;
const legacyDefsEntry =
schema.definitions &&
typeof schema.definitions === "object" &&
!Array.isArray(schema.definitions)
? (schema.definitions as Record<string, unknown>)
: undefined;
if (!defsEntry && !legacyDefsEntry) return defs;
const next = defs ? new Map(defs) : new Map<string, unknown>();
if (defsEntry) {
for (const [key, value] of Object.entries(defsEntry)) next.set(key, value);
}
if (legacyDefsEntry) {
for (const [key, value] of Object.entries(legacyDefsEntry))
next.set(key, value);
}
return next;
}
function decodeJsonPointerSegment(segment: string): string {
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
}
function tryResolveLocalRef(
ref: string,
defs: SchemaDefs | undefined,
): unknown {
if (!defs) return undefined;
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) return undefined;
const name = decodeJsonPointerSegment(match[1] ?? "");
if (!name) return undefined;
return defs.get(name);
}
function cleanSchemaForGeminiWithDefs(
schema: unknown,
defs: SchemaDefs | undefined,
refStack: Set<string> | undefined,
): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) {
return schema.map((item) =>
cleanSchemaForGeminiWithDefs(item, defs, refStack),
);
}
const obj = schema as Record<string, unknown>;
const nextDefs = extendSchemaDefs(defs, obj);
const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined;
if (refValue) {
if (refStack?.has(refValue)) return {};
const resolved = tryResolveLocalRef(refValue, nextDefs);
if (resolved) {
const nextRefStack = refStack ? new Set(refStack) : new Set<string>();
nextRefStack.add(refValue);
const cleaned = cleanSchemaForGeminiWithDefs(
resolved,
nextDefs,
nextRefStack,
);
if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) {
return cleaned;
}
const result: Record<string, unknown> = {
...(cleaned as Record<string, unknown>),
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
const result: Record<string, unknown> = {};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
if (hasAnyOf) {
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
}
if (hasOneOf) {
const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
if (key === "const") {
cleaned.enum = [value];
continue;
}
if (key === "type" && (hasAnyOf || hasOneOf)) continue;
if (key === "properties" && value && typeof value === "object") {
const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [
k,
cleanSchemaForGeminiWithDefs(v, nextDefs, refStack),
]),
);
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
} else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "oneOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "allOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
export function cleanSchemaForGemini(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const defs = extendSchemaDefs(undefined, schema as Record<string, unknown>);
return cleanSchemaForGeminiWithDefs(schema, defs, undefined);
}

View File

@@ -196,6 +196,7 @@ export async function runSubagentAnnounceFlow(params: {
waitForCompletion?: boolean;
startedAt?: number;
endedAt?: number;
label?: string;
}) {
try {
let reply = params.roundOneReply;
@@ -273,6 +274,18 @@ export async function runSubagentAnnounceFlow(params: {
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
} finally {
// Patch label after all writes complete
if (params.label) {
try {
await callGateway({
method: "sessions.patch",
params: { key: params.childSessionKey, label: params.label },
timeoutMs: 10_000,
});
} catch {
// Best-effort
}
}
if (params.cleanup === "delete") {
try {
await callGateway({

View File

@@ -11,6 +11,7 @@ export type SubagentRunRecord = {
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
createdAt: number;
startedAt?: number;
endedAt?: number;
@@ -24,7 +25,7 @@ let listenerStarted = false;
function resolveArchiveAfterMs() {
const cfg = loadConfig();
const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60;
const minutes = cfg.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60;
if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
return Math.max(1, Math.floor(minutes)) * 60_000;
}
@@ -83,6 +84,7 @@ function ensureListener() {
? (evt.data.endedAt as number)
: Date.now();
entry.endedAt = endedAt;
if (!beginSubagentAnnounce(evt.runId)) {
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
@@ -101,6 +103,7 @@ function ensureListener() {
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
@@ -124,6 +127,7 @@ export function registerSubagentRun(params: {
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
}) {
const now = Date.now();
const archiveAfterMs = resolveArchiveAfterMs();
@@ -136,6 +140,7 @@ export function registerSubagentRun(params: {
requesterDisplayKey: params.requesterDisplayKey,
task: params.task,
cleanup: params.cleanup,
label: params.label,
createdAt: now,
startedAt: now,
archiveAtMs,
@@ -175,6 +180,7 @@ async function probeImmediateCompletion(runId: string) {
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(runId);

View File

@@ -8,7 +8,7 @@ const normalizeNumber = (value: unknown): number | undefined =>
: undefined;
export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
const raw = normalizeNumber(cfg?.agent?.timeoutSeconds);
const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds);
const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
return Math.max(seconds, 1);
}

View File

@@ -55,19 +55,17 @@ export function createAgentsListTool(opts?: {
.map((value) => normalizeAgentId(value)),
);
const configuredAgents = cfg.routing?.agents ?? {};
const configuredIds = Object.keys(configuredAgents).map((key) =>
normalizeAgentId(key),
const configuredAgents = Array.isArray(cfg.agents?.list)
? cfg.agents?.list
: [];
const configuredIds = configuredAgents.map((entry) =>
normalizeAgentId(entry.id),
);
const configuredNameMap = new Map<string, string>();
for (const [key, value] of Object.entries(configuredAgents)) {
if (!value || typeof value !== "object") continue;
const name =
typeof (value as { name?: unknown }).name === "string"
? ((value as { name?: string }).name?.trim() ?? "")
: "";
for (const entry of configuredAgents) {
const name = entry?.name?.trim() ?? "";
if (!name) continue;
configuredNameMap.set(normalizeAgentId(key), name);
configuredNameMap.set(normalizeAgentId(entry.id), name);
}
const allowed = new Set<string>();

View File

@@ -23,7 +23,7 @@ import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const imageModel = cfg?.agent?.imageModel as
const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
@@ -45,7 +45,7 @@ function pickMaxBytes(
) {
return Math.floor(maxBytesMb * 1024 * 1024);
}
const configured = cfg?.agent?.mediaMaxMb;
const configured = cfg?.agents?.defaults?.mediaMaxMb;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
@@ -141,7 +141,7 @@ export function createImageTool(options?: {
label: "Image",
name: "image",
description:
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.",
"Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL.",
parameters: Type.Object({
prompt: Type.Optional(Type.String()),
image: Type.String(),

View File

@@ -130,7 +130,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
label: "Message",
name: "message",
description:
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage).",
"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>;

View File

@@ -25,7 +25,7 @@ const SessionsHistoryToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
async function isSpawnedSessionAllowed(params: {
@@ -97,7 +97,7 @@ export function createSessionsHistoryTool(opts?: {
}
}
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -126,14 +126,13 @@ export function createSessionsHistoryTool(opts?: {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.",
"Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history denied by routing.agentToAgent.allow.",
error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
});
}
}

View File

@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -32,7 +32,7 @@ describe("sessions_list gating", () => {
});
});
it("filters out other agents when routing.agentToAgent.enabled is false", async () => {
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({

View File

@@ -25,6 +25,7 @@ type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
label?: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
@@ -53,7 +54,7 @@ const SessionsListToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
export function createSessionsListTool(opts?: {
@@ -126,7 +127,7 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -205,6 +206,7 @@ export function createSessionsListTool(opts?: {
key: displayKey,
kind,
provider: derivedProvider,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName:
typeof entry.displayName === "string"
? entry.displayName

View File

@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -25,7 +25,7 @@ describe("sessions_send gating", () => {
callGatewayMock.mockReset();
});
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => {
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",

View File

@@ -9,6 +9,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
@@ -29,11 +30,25 @@ import {
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
const SessionsSendToolSchema = Type.Union([
Type.Object(
{
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
Type.Object(
{
label: Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }),
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
]);
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
@@ -43,18 +58,16 @@ export function createSessionsSendTool(opts?: {
return {
label: "Session Send",
name: "sessions_send",
description: "Send a message into another session.",
description:
"Send a message into another session. Use sessionKey or label to identify the target.",
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
@@ -63,42 +76,172 @@ export function createSessionsSendTool(opts?: {
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label")?.trim() || undefined;
const labelAgentIdParam =
readStringParam(params, "agentId")?.trim() || undefined;
if (sessionKeyParam && labelParam) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Provide either sessionKey or label (not both).",
});
}
const listSessions = async (listParams: Record<string, unknown>) => {
const result = (await callGateway({
method: "sessions.list",
params: listParams,
timeoutMs: 10_000,
})) as { sessions?: Array<Record<string, unknown>> };
return Array.isArray(result?.sessions) ? result.sessions : [];
};
let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) {
const requesterAgentId = requesterInternalKey
? normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
)
: undefined;
const requestedAgentId = labelAgentIdParam
? normalizeAgentId(labelAgentIdParam)
: undefined;
if (
restrictToSpawned &&
requestedAgentId &&
requesterAgentId &&
requestedAgentId !== requesterAgentId
) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Sandboxed sessions_send label lookup is limited to this agent",
});
}
if (
requesterAgentId &&
requestedAgentId &&
requestedAgentId !== requesterAgentId
) {
if (!a2aEnabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
error:
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
});
}
} catch {
if (
!matchesAllow(requesterAgentId) ||
!matchesAllow(requestedAgentId)
) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
});
}
}
const resolveParams: Record<string, unknown> = {
label: labelParam,
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}),
};
let resolvedKey = "";
try {
const resolved = (await callGateway({
method: "sessions.resolve",
params: resolveParams,
timeoutMs: 10_000,
})) as { key?: unknown };
resolvedKey =
typeof resolved?.key === "string" ? resolved.key.trim() : "";
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: msg || `No session found with label: ${labelParam}`,
});
}
if (!resolvedKey) {
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `No session found with label: ${labelParam}`,
});
}
sessionKey = resolvedKey;
}
if (!sessionKey) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Either sessionKey or label is required",
});
}
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
if (restrictToSpawned) {
const sessions = await listSessions({
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
});
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
@@ -125,24 +268,6 @@ export function createSessionsSendTool(opts?: {
alias,
mainKey,
});
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
@@ -156,7 +281,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.",
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey,
});
}
@@ -165,7 +290,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.",
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
sessionKey: displayKey,
});
}

View File

@@ -126,17 +126,7 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) {
try {
await callGateway({
method: "sessions.patch",
params: { key: childSessionKey, spawnedBy: requesterInternalKey },
timeoutMs: 10_000,
});
} catch {
// best-effort; scoping relies on this metadata but spawning still works without it
}
}
const shouldPatchSpawnedBy = opts?.sandboxed === true;
if (model) {
try {
await callGateway({
@@ -185,6 +175,8 @@ export function createSessionsSpawnTool(opts?: {
lane: "subagent",
extraSystemPrompt: childSystemPrompt,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
},
timeoutMs: 10_000,
})) as { runId?: string };
@@ -214,6 +206,7 @@ export function createSessionsSpawnTool(opts?: {
requesterDisplayKey,
task,
cleanup,
label: label || undefined,
});
return jsonResult({

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) {

View File

@@ -27,6 +27,7 @@ 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("/debug set foo=bar")).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);

View File

@@ -33,6 +33,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "debug",
nativeName: "debug",
description: "Set runtime debug overrides.",
textAliases: ["/debug"],
acceptsArgs: true,
},
{
key: "cost",
nativeName: "cost",

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
@@ -22,15 +21,7 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-stream-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(fn, { prefix: "clawdbot-stream-" });
}
describe("block streaming", () => {
@@ -85,9 +76,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -140,9 +133,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -185,9 +180,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -239,9 +236,11 @@ describe("block streaming", () => {
blockReplyTimeoutMs: 10,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },

View File

@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import {
@@ -28,28 +28,18 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-"));
const previousHome = process.env.HOME;
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.HOME = base;
process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot");
process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined)
delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(
async (home) => {
return await fn(home);
},
{
env: {
CLAWDBOT_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
PI_CODING_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
},
prefix: "clawdbot-reply-",
},
);
}
describe("directive behavior", () => {
@@ -78,11 +68,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
},
},
whatsapp: { allowFrom: ["*"] },
@@ -108,9 +100,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -138,11 +132,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
routing: {
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
@@ -174,10 +170,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -198,9 +196,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -232,9 +232,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -270,9 +272,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -303,9 +307,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -330,9 +336,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -352,10 +360,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -376,9 +386,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -399,10 +411,12 @@ describe("directive behavior", () => {
{ Body: "/verbose", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -423,9 +437,11 @@ describe("directive behavior", () => {
{ Body: "/reasoning", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -452,10 +468,14 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -486,13 +506,17 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
sandbox: { mode: "off" },
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
sandbox: { mode: "off" },
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
@@ -520,9 +544,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -552,9 +580,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -585,9 +617,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -613,9 +649,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -644,9 +682,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -677,9 +717,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -690,9 +732,11 @@ describe("directive behavior", () => {
{ Body: "/queue reset", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -749,9 +793,11 @@ describe("directive behavior", () => {
ctx,
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -810,9 +856,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -825,9 +873,11 @@ describe("directive behavior", () => {
ctx,
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -853,12 +903,14 @@ describe("directive behavior", () => {
{ 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": {},
agents: {
defaults: {
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 },
@@ -883,12 +935,14 @@ describe("directive behavior", () => {
{ Body: "/model status", 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": {},
agents: {
defaults: {
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 },
@@ -913,12 +967,14 @@ describe("directive behavior", () => {
{ Body: "/model list", 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": {},
agents: {
defaults: {
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 },
@@ -943,12 +999,14 @@ describe("directive behavior", () => {
{ 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": {},
agents: {
defaults: {
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 },
@@ -972,11 +1030,13 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
},
},
},
session: { store: storePath },
@@ -999,12 +1059,14 @@ describe("directive behavior", () => {
{ Body: "/model openai/gpt-4.1-mini", 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": {},
agents: {
defaults: {
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 },
@@ -1030,12 +1092,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1057,7 +1121,7 @@ describe("directive behavior", () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const authDir = path.join(home, ".clawdbot", "agent");
const authDir = path.join(home, ".clawdbot", "agents", "main", "agent");
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
await fs.writeFile(
path.join(authDir, "auth-profiles.json"),
@@ -1081,12 +1145,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1112,12 +1178,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1151,12 +1219,14 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
whatsapp: {
@@ -1204,9 +1274,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1242,9 +1314,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1004"] },
},

View File

@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../agents/model-fallback.js", () => ({
@@ -43,23 +43,22 @@ vi.mock("../web/session.js", () => webMocks);
import { getReplyFromConfig } from "./reply.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-typing-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
runEmbeddedPiAgentMock.mockClear();
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(
async (home) => {
runEmbeddedPiAgentMock.mockClear();
return await fn(home);
},
{ prefix: "clawdbot-typing-" },
);
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
@@ -28,27 +27,28 @@ 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;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(base);
} finally {
process.env.HOME = previousHome;
try {
await fs.rm(base, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
}
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(home);
},
{
env: {
CLAWDBOT_BUNDLED_SKILLS_DIR: (home) =>
path.join(home, "bundled-skills"),
},
prefix: "clawdbot-media-note-",
},
);
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import {
isEmbeddedPiRunActive,
isEmbeddedPiRunStreaming,
@@ -32,31 +31,26 @@ 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-queue-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(base);
} finally {
process.env.HOME = previousHome;
try {
await fs.rm(base, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
}
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(home);
},
{ prefix: "clawdbot-queue-" },
);
}
function makeCfg(home: string, queue?: Record<string, unknown>) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
routing: queue ? { queue } : undefined,
messages: queue ? { queue } : undefined,
};
}

View File

@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -25,13 +27,18 @@ const usageMocks = vi.hoisted(() => ({
vi.mock("../infra/provider-usage.js", () => usageMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionKey,
} from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -46,24 +53,23 @@ const webMocks = vi.hoisted(() => ({
vi.mock("../web/session.js", () => webMocks);
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-triggers-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(home);
},
{ prefix: "clawdbot-triggers-" },
);
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -94,7 +100,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("📊 Usage: Claude 80% left");
expect(normalizeTestText(text ?? "")).toContain("Usage: Claude 80% left");
expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["anthropic"] }),
);
@@ -293,7 +299,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("api-key");
expect(text).toContain("…");
expect(text).toMatch(/…|\.{3}/);
expect(text).toContain("(anthropic:work)");
expect(text).not.toContain("mixed");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -345,9 +351,11 @@ describe("trigger handling", () => {
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
@@ -381,9 +389,13 @@ describe("trigger handling", () => {
it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -420,9 +432,13 @@ describe("trigger handling", () => {
it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+1000"] },
@@ -467,9 +483,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -510,9 +530,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -545,9 +569,13 @@ describe("trigger handling", () => {
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"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -589,9 +617,13 @@ describe("trigger handling", () => {
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"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -635,9 +667,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -668,9 +704,11 @@ describe("trigger handling", () => {
it("falls back to discord dm allowFrom for elevated approval", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
discord: {
dm: {
@@ -708,9 +746,13 @@ describe("trigger handling", () => {
it("treats explicit discord elevated allowlist as override", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { discord: [] },
},
@@ -799,9 +841,12 @@ describe("trigger handling", () => {
});
const cfg = makeCfg(home);
cfg.agent = {
...cfg.agent,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
},
};
await getReplyFromConfig(
@@ -941,15 +986,17 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
routing: {
messages: {
groupChat: {},
},
session: { store: join(home, "sessions.json") },
@@ -985,9 +1032,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1024,9 +1073,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1056,9 +1107,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
@@ -1083,9 +1136,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
@@ -1124,9 +1179,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1229,12 +1286,14 @@ describe("trigger handling", () => {
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
whatsapp: {
@@ -1272,10 +1331,11 @@ describe("trigger handling", () => {
ctx,
cfg.session?.mainKey,
);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: cfg.agent.workspace,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
expect(sandbox).not.toBeNull();
if (!sandbox) {

View File

@@ -212,7 +212,7 @@ export async function getReplyFromConfig(
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig();
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const agentCfg = cfg.agent;
const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
cfg,
@@ -239,7 +239,7 @@ export async function getReplyFromConfig(
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId);
@@ -257,7 +257,7 @@ export async function getReplyFromConfig(
opts?.onTypingController?.(typing);
let transcribedText: string | undefined;
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
if (cfg.audio?.transcription && isAudio(ctx.MediaType)) {
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
if (transcribed?.text) {
transcribedText = transcribed.text;
@@ -329,7 +329,7 @@ export async function getReplyFromConfig(
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
),
);
const configuredAliases = Object.values(cfg.agent?.models ?? {})
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
@@ -391,7 +391,7 @@ export async function getReplyFromConfig(
sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ??
"";
const elevatedConfig = agentCfg?.elevated;
const elevatedConfig = cfg.tools?.elevated;
const discordElevatedFallback =
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false;
@@ -582,6 +582,7 @@ export async function getReplyFromConfig(
directives,
effectiveModelDirective,
cfg,
agentDir,
sessionEntry,
sessionStore,
sessionKey,

View File

@@ -50,7 +50,7 @@ import {
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js";
import {
createReplyToModeFilter,
createReplyToModeFilterForChannel,
resolveReplyToMode,
} from "./reply-threading.js";
import { incrementCompactionCount } from "./session-updates.js";
@@ -260,7 +260,10 @@ export async function runReplyAgent(params: {
followupRun.run.config,
replyToChannel,
);
const applyReplyToMode = createReplyToModeFilter(replyToMode);
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
replyToChannel,
);
const cfg = followupRun.run.config;
if (shouldSteer && isStreaming) {
@@ -716,7 +719,8 @@ export async function runReplyAgent(params: {
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
payloads: sanitizedPayloads,
applyReplyToMode,
replyToMode,
replyToChannel,
currentMessageId: sessionCtx.MessageSid,
})
.map((payload) => {

View File

@@ -34,7 +34,7 @@ export function resolveBlockStreamingChunking(
} {
const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk;
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
const maxRequested = Math.max(
1,
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),

View File

@@ -1,3 +1,7 @@
import {
resolveAgentDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
@@ -16,6 +20,13 @@ import {
} from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
getConfigOverrides,
resetConfigOverrides,
setConfigOverride,
unsetConfigOverride,
} from "../../config/runtime-overrides.js";
import {
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
type SessionEntry,
type SessionScope,
@@ -61,6 +72,7 @@ import type {
} from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import { parseDebugCommand } from "./debug-commands.js";
import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
@@ -135,6 +147,10 @@ export async function buildStatusReply(params: {
);
return undefined;
}
const statusAgentId = sessionKey
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
let usageLine: string | null = null;
try {
const usageProvider = resolveUsageProviderId(provider);
@@ -142,8 +158,18 @@ export async function buildStatusReply(params: {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: [usageProvider],
agentDir: statusAgentDir,
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
if (
!usageLine &&
(resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")
) {
const entry = usageSummary.providers[0];
if (entry?.error) {
usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`;
}
}
}
} catch {
usageLine = null;
@@ -164,18 +190,19 @@ export async function buildStatusReply(params: {
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({
config: cfg,
agent: {
...cfg.agent,
...agentDefaults,
model: {
...cfg.agent?.model,
...agentDefaults.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
thinkingDefault: agentDefaults.thinkingDefault,
verboseDefault: agentDefaults.verboseDefault,
elevatedDefault: agentDefaults.elevatedDefault,
},
sessionEntry,
sessionKey,
@@ -185,7 +212,12 @@ export async function buildStatusReply(params: {
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
modelAuth: resolveModelAuthLabel(
provider,
cfg,
sessionEntry,
statusAgentDir,
),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
@@ -213,12 +245,15 @@ function resolveModelAuthLabel(
provider?: string,
cfg?: ClawdbotConfig,
sessionEntry?: SessionEntry,
agentDir?: string,
): string | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
const providerKey = normalizeProviderId(resolved);
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const profileOverride = sessionEntry?.authProfileOverride?.trim();
const order = resolveAuthProfileOrder({
cfg,
@@ -593,6 +628,88 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply };
}
const debugCommand = allowTextCommands
? parseDebugCommand(command.commandBodyNormalized)
: null;
if (debugCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /debug from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (debugCommand.action === "error") {
return {
shouldContinue: false,
reply: { text: `⚠️ ${debugCommand.message}` },
};
}
if (debugCommand.action === "show") {
const overrides = getConfigOverrides();
const hasOverrides = Object.keys(overrides).length > 0;
if (!hasOverrides) {
return {
shouldContinue: false,
reply: { text: "⚙️ Debug overrides: (none)" },
};
}
const json = JSON.stringify(overrides, null, 2);
return {
shouldContinue: false,
reply: {
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
},
};
}
if (debugCommand.action === "reset") {
resetConfigOverrides();
return {
shouldContinue: false,
reply: { text: "⚙️ Debug overrides cleared; using config on disk." },
};
}
if (debugCommand.action === "unset") {
const result = unsetConfigOverride(debugCommand.path);
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error ?? "Invalid path."}` },
};
}
if (!result.removed) {
return {
shouldContinue: false,
reply: {
text: `⚙️ No debug override found for ${debugCommand.path}.`,
},
};
}
return {
shouldContinue: false,
reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` },
};
}
if (debugCommand.action === "set") {
const result = setConfigOverride(debugCommand.path, debugCommand.value);
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
};
}
const valueLabel =
typeof debugCommand.value === "string"
? `"${debugCommand.value}"`
: JSON.stringify(debugCommand.value);
return {
shouldContinue: false,
reply: {
text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`,
},
};
}
}
const stopRequested = command.commandBodyNormalized === "/stop";
if (allowTextCommands && stopRequested) {
if (!command.isAuthorizedSender) {

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { parseDebugCommand } from "./debug-commands.js";
describe("parseDebugCommand", () => {
it("parses show/reset", () => {
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
});
it("parses set with JSON", () => {
const cmd = parseDebugCommand('/debug set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
it("parses unset", () => {
const cmd = parseDebugCommand("/debug unset foo.bar");
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
});
});

View File

@@ -0,0 +1,99 @@
export type DebugCommand =
| { action: "show" }
| { action: "reset" }
| { action: "set"; path: string; value: unknown }
| { action: "unset"; path: string }
| { action: "error"; message: string };
function parseDebugValue(raw: string): { value?: unknown; error?: string } {
const trimmed = raw.trim();
if (!trimmed) return { error: "Missing value." };
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return { value: JSON.parse(trimmed) };
} catch (err) {
return { error: `Invalid JSON: ${String(err)}` };
}
}
if (trimmed === "true") return { value: true };
if (trimmed === "false") return { value: false };
if (trimmed === "null") return { value: null };
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) return { value: num };
}
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
try {
return { value: JSON.parse(trimmed) };
} catch {
const unquoted = trimmed.slice(1, -1);
return { value: unquoted };
}
}
return { value: trimmed };
}
export function parseDebugCommand(raw: string): DebugCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
const rest = trimmed.slice("/debug".length).trim();
if (!rest) return { action: "show" };
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
if (!match) return { action: "error", message: "Invalid /debug syntax." };
const action = match[1].toLowerCase();
const args = (match[2] ?? "").trim();
switch (action) {
case "show":
return { action: "show" };
case "reset":
return { action: "reset" };
case "unset": {
if (!args)
return { action: "error", message: "Usage: /debug unset path" };
return { action: "unset", path: args };
}
case "set": {
if (!args) {
return {
action: "error",
message: "Usage: /debug set path=value",
};
}
const eqIndex = args.indexOf("=");
if (eqIndex <= 0) {
return {
action: "error",
message: "Usage: /debug set path=value",
};
}
const path = args.slice(0, eqIndex).trim();
const rawValue = args.slice(eqIndex + 1);
if (!path) {
return {
action: "error",
message: "Usage: /debug set path=value",
};
}
const parsed = parseDebugValue(rawValue);
if (parsed.error) {
return { action: "error", message: parsed.error };
}
return { action: "set", path, value: parsed.value };
}
default:
return {
action: "error",
message: "Usage: /debug show|set|unset|reset",
};
}
}

View File

@@ -1,6 +1,10 @@
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import {
resolveAgentConfig,
resolveAgentDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import {
isProfileInCooldown,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
} from "../../agents/auth-profiles.js";
@@ -20,9 +24,11 @@ import {
buildModelAliasIndex,
type ModelAliasIndex,
modelKey,
normalizeProviderId,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveAgentIdFromSessionKey,
@@ -72,18 +78,111 @@ const maskApiKey = (value: string): string => {
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
};
type ModelAuthDetailMode = "compact" | "verbose";
const resolveAuthLabel = async (
provider: string,
cfg: ClawdbotConfig,
modelsPath: string,
agentDir?: string,
mode: ModelAuthDetailMode = "compact",
): Promise<{ label: string; source: string }> => {
const formatPath = (value: string) => shortenHomePath(value);
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const order = resolveAuthProfileOrder({ cfg, store, provider });
const providerKey = normalizeProviderId(provider);
const lastGood = (() => {
const map = store.lastGood;
if (!map) return undefined;
for (const [key, value] of Object.entries(map)) {
if (normalizeProviderId(key) === providerKey) return value;
}
return undefined;
})();
const nextProfileId = order[0];
const now = Date.now();
const formatUntil = (timestampMs: number) => {
const remainingMs = Math.max(0, timestampMs - now);
const minutes = Math.round(remainingMs / 60_000);
if (minutes < 1) return "soon";
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`;
};
if (order.length > 0) {
if (mode === "compact") {
const profileId = nextProfileId;
if (!profileId) return { label: "missing", source: "missing" };
const profile = store.profiles[profileId];
const configProfile = cfg.auth?.profiles?.[profileId];
const missing =
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"));
const more = order.length > 1 ? ` (+${order.length - 1})` : "";
if (missing) return { label: `${profileId} missing${more}`, source: "" };
if (profile.type === "api_key") {
return {
label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`,
source: "",
};
}
if (profile.type === "token") {
const exp =
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
? profile.expires <= now
? " expired"
: ` exp ${formatUntil(profile.expires)}`
: "";
return {
label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`,
source: "",
};
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const label = display === profileId ? profileId : display;
const exp =
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
? profile.expires <= now
? " expired"
: ` exp ${formatUntil(profile.expires)}`
: "";
return { label: `${label} oauth${exp}${more}`, source: "" };
}
const labels = order.map((profileId) => {
const profile = store.profiles[profileId];
const configProfile = cfg.auth?.profiles?.[profileId];
const flags: string[] = [];
if (profileId === nextProfileId) flags.push("next");
if (lastGood && profileId === lastGood) flags.push("lastGood");
if (isProfileInCooldown(store, profileId)) {
const until = store.usageStats?.[profileId]?.cooldownUntil;
if (
typeof until === "number" &&
Number.isFinite(until) &&
until > now
) {
flags.push(`cooldown ${formatUntil(until)}`);
} else {
flags.push("cooldown");
}
}
if (
!profile ||
(configProfile?.provider &&
@@ -92,13 +191,27 @@ const resolveAuthLabel = async (
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"))
) {
return `${profileId}=missing`;
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=missing${suffix}`;
}
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=${maskApiKey(profile.key)}${suffix}`;
}
if (profile.type === "token") {
return `${profileId}=token:${maskApiKey(profile.token)}`;
if (
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
) {
flags.push(
profile.expires <= now
? "expired"
: `exp ${formatUntil(profile.expires)}`,
);
}
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
}
const display = resolveAuthProfileDisplayLabel({
cfg,
@@ -111,13 +224,24 @@ const resolveAuthLabel = async (
: display.startsWith(profileId)
? display.slice(profileId.length).trim()
: `(${display})`;
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
if (
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
) {
flags.push(
profile.expires <= now
? "expired"
: `exp ${formatUntil(profile.expires)}`,
);
}
const suffixLabel = suffix ? ` ${suffix}` : "";
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=OAuth${suffixLabel}${suffixFlags}`;
});
return {
label: labels.join(", "),
source: `auth-profiles.json: ${formatPath(
resolveAuthStorePathForDisplay(),
)}`,
source: `auth-profiles.json: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
};
}
@@ -127,13 +251,14 @@ const resolveAuthLabel = async (
envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth");
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
return { label, source: envKey.source };
return { label, source: mode === "verbose" ? envKey.source : "" };
}
const customKey = getCustomProviderApiKey(cfg, provider);
if (customKey) {
return {
label: maskApiKey(customKey),
source: `models.json: ${formatPath(modelsPath)}`,
source:
mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
};
}
return { label: "missing", source: "missing" };
@@ -150,10 +275,13 @@ const resolveProfileOverride = (params: {
rawProfile?: string;
provider: string;
cfg: ClawdbotConfig;
agentDir?: string;
}): { profileId?: string; error?: string } => {
const raw = params.rawProfile?.trim();
if (!raw) return {};
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profile = store.profiles[raw];
if (!profile) {
return { error: `Auth profile "${raw}" not found.` };
@@ -362,17 +490,21 @@ export async function handleDirectiveOnly(params: {
currentReasoningLevel,
currentElevatedLevel,
} = params;
const activeAgentId = params.sessionKey
? resolveAgentIdFromSessionKey(params.sessionKey)
: resolveDefaultAgentId(params.cfg);
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
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 sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
if (sandboxCfg.mode === "off") return false;
const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
if (sandboxMode === "all") return true;
if (sandboxCfg.mode === "all") return true;
return sessionKey !== mainKey;
})();
const shouldHintDirectRuntime =
@@ -383,6 +515,10 @@ export async function handleDirectiveOnly(params: {
const isModelListAlias =
modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) {
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authMode: ModelAuthDetailMode =
modelDirective === "status" ? "verbose" : "compact";
if (allowedModelCatalog.length === 0) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
@@ -394,7 +530,9 @@ export async function handleDirectiveOnly(params: {
provider: string;
id: string;
}> = [];
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) {
for (const raw of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
@@ -420,9 +558,6 @@ export async function handleDirectiveOnly(params: {
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;
@@ -430,6 +565,8 @@ export async function handleDirectiveOnly(params: {
entry.provider,
params.cfg,
modelsPath,
agentDir,
authMode,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
@@ -438,7 +575,8 @@ export async function handleDirectiveOnly(params: {
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
`Agent: ${activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
`⚠️ Model catalog unavailable; showing configured models only.`,
];
const byProvider = new Map<string, typeof fallbackCatalog>();
@@ -466,9 +604,6 @@ export async function handleDirectiveOnly(params: {
}
return { text: lines.join("\n") };
}
const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue;
@@ -476,6 +611,8 @@ export async function handleDirectiveOnly(params: {
entry.provider,
params.cfg,
modelsPath,
agentDir,
authMode,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
@@ -484,7 +621,8 @@ export async function handleDirectiveOnly(params: {
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
`Agent: ${activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
];
if (resetModelOverride) {
lines.push(`(previous selection reset to default)`);
@@ -686,6 +824,7 @@ export async function handleDirectiveOnly(params: {
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
@@ -837,6 +976,7 @@ export async function persistInlineDirectives(params: {
directives: InlineDirectives;
effectiveModelDirective?: string;
cfg: ClawdbotConfig;
agentDir?: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -851,7 +991,7 @@ export async function persistInlineDirectives(params: {
model: string;
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg: NonNullable<ClawdbotConfig["agents"]>["defaults"] | undefined;
}): Promise<{ provider: string; model: string; contextTokens: number }> {
const {
directives,
@@ -871,6 +1011,10 @@ export async function persistInlineDirectives(params: {
agentCfg,
} = params;
let { provider, model } = params;
const activeAgentId = sessionKey
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, activeAgentId);
if (sessionEntry && sessionStore && sessionKey) {
let updated = false;
@@ -930,6 +1074,7 @@ export async function persistInlineDirectives(params: {
rawProfile: directives.rawModelProfile,
provider: resolved.ref.provider,
cfg,
agentDir,
});
if (profileResolved.error) {
throw new Error(profileResolved.error);
@@ -1007,13 +1152,16 @@ export function resolveDefaultModel(params: {
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agent: {
...params.cfg.agent,
model: {
...(typeof params.cfg.agent?.model === "object"
? params.cfg.agent.model
: undefined),
primary: agentModelOverride,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model: {
...(typeof params.cfg.agents?.defaults?.model === "object"
? params.cfg.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}

View File

@@ -53,6 +53,7 @@ export async function dispatchReplyFromConfig(params: {
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
@@ -106,6 +107,7 @@ export async function dispatchReplyFromConfig(params: {
payload: reply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,

View File

@@ -19,10 +19,7 @@ import {
filterMessagingToolDuplicates,
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js";
import {
createReplyToModeFilter,
resolveReplyToMode,
} from "./reply-threading.js";
import { resolveReplyToMode } from "./reply-threading.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js";
@@ -97,6 +94,7 @@ export function createFollowupRunner(params: {
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: queued.run.sessionKey,
accountId: queued.originatingAccountId,
threadId: queued.originatingThreadId,
cfg: queued.run.config,
@@ -194,13 +192,12 @@ export function createFollowupRunner(params: {
(queued.run.messageProvider?.toLowerCase() as
| OriginatingChannelType
| undefined);
const applyReplyToMode = createReplyToModeFilter(
resolveReplyToMode(queued.run.config, replyToChannel),
);
const replyToMode = resolveReplyToMode(queued.run.config, replyToChannel);
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
payloads: sanitizedPayloads,
applyReplyToMode,
replyToMode,
replyToChannel,
});
const dedupedPayloads = filterMessagingToolDuplicates({

View File

@@ -9,7 +9,7 @@ import {
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
routing: {
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
@@ -23,7 +23,7 @@ describe("mention helpers", () => {
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
@@ -31,11 +31,16 @@ describe("mention helpers", () => {
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
routing: {
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: {
work: { mentionPatterns: ["\\bworkbot\\b"] },
},
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",

View File

@@ -1,23 +1,62 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
const patterns: string[] = [];
const name = identity?.name?.trim();
if (name) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join(String.raw`\s+`) : escapeRegExp(name);
patterns.push(String.raw`\b@?${re}\b`);
}
const emoji = identity?.emoji?.trim();
if (emoji) {
patterns.push(escapeRegExp(emoji));
}
return patterns;
}
const BACKSPACE_CHAR = "\u0008";
function normalizeMentionPattern(pattern: string): string {
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
return pattern.split(BACKSPACE_CHAR).join("\\b");
}
function normalizeMentionPatterns(patterns: string[]): string[] {
return patterns.map(normalizeMentionPattern);
}
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 ?? [];
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentGroupChat = agentConfig?.groupChat;
if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
return agentGroupChat.mentionPatterns ?? [];
}
return cfg.routing?.groupChat?.mentionPatterns ?? [];
const globalGroupChat = cfg.messages?.groupChat;
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
return globalGroupChat.mentionPatterns ?? [];
}
const derived = deriveMentionPatterns(agentConfig?.identity);
return derived.length > 0 ? derived : [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
return patterns
.map((pattern) => {
try {
@@ -66,7 +105,9 @@ export function stripMentions(
agentId?: string,
): string {
let result = text;
const patterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");

View File

@@ -33,7 +33,9 @@ type ModelSelectionState = {
export async function createModelSelectionState(params: {
cfg: ClawdbotConfig;
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -201,7 +203,9 @@ export function resolveModelDirectiveSelection(params: {
}
export function resolveContextTokens(params: {
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
model: string;
}): number {
return (

View File

@@ -0,0 +1,49 @@
import { stripHeartbeatToken } from "../heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
export type NormalizeReplyOptions = {
responsePrefix?: string;
onHeartbeatStrip?: () => void;
stripHeartbeat?: boolean;
silentToken?: string;
};
export function normalizeReplyPayload(
payload: ReplyPayload,
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
if (trimmed === silentToken && !hasMedia) return null;
let text = payload.text ?? undefined;
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
const shouldStripHeartbeat = opts.stripHeartbeat ?? true;
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia) return null;
text = stripped.text;
}
if (
opts.responsePrefix &&
text &&
text.trim() !== HEARTBEAT_TOKEN &&
!text.startsWith(opts.responsePrefix)
) {
text = `${opts.responsePrefix} ${text}`;
}
return { ...payload, text };
}

View File

@@ -553,7 +553,7 @@ export function resolveQueueSettings(params: {
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
const providerKey = params.provider?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue;
const queueCfg = params.cfg.messages?.queue;
const providerModeRaw =
providerKey && queueCfg?.byProvider
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]

View File

@@ -1,6 +1,5 @@
import { stripHeartbeatToken } from "../heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import type { TypingController } from "./typing.js";
export type ReplyDispatchKind = "tool" | "block" | "final";
@@ -45,41 +44,14 @@ export type ReplyDispatcher = {
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
};
function normalizeReplyPayload(
function normalizeReplyPayloadInternal(
payload: ReplyPayload,
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
): ReplyPayload | null {
const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;
// Avoid sending the explicit silent token when no media is attached.
if (trimmed === SILENT_REPLY_TOKEN && !hasMedia) return null;
let text = payload.text ?? undefined;
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
if (text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia) return null;
text = stripped.text;
}
if (
opts.responsePrefix &&
text &&
text.trim() !== HEARTBEAT_TOKEN &&
!text.startsWith(opts.responsePrefix)
) {
text = `${opts.responsePrefix} ${text}`;
}
return { ...payload, text };
return normalizeReplyPayload(payload, {
responsePrefix: opts.responsePrefix,
onHeartbeatStrip: opts.onHeartbeatStrip,
});
}
export function createReplyDispatcher(
@@ -96,7 +68,7 @@ export function createReplyDispatcher(
};
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const normalized = normalizeReplyPayload(payload, options);
const normalized = normalizeReplyPayloadInternal(payload, options);
if (!normalized) return false;
queuedCounts[kind] += 1;
pending += 1;

View File

@@ -1,16 +1,17 @@
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
export type ReplyToModeFilter = (payload: ReplyPayload) => ReplyPayload;
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
export function applyReplyTagsToPayload(
payload: ReplyPayload,
currentMessageId?: string,
): ReplyPayload {
if (typeof payload.text !== "string") return payload;
const { cleaned, replyToId } = extractReplyToTag(
const { cleaned, replyToId, hasTag } = extractReplyToTag(
payload.text,
currentMessageId,
);
@@ -18,6 +19,7 @@ export function applyReplyTagsToPayload(
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
replyToTag: hasTag || payload.replyToTag,
};
}
@@ -31,10 +33,15 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
export function applyReplyThreading(params: {
payloads: ReplyPayload[];
applyReplyToMode: ReplyToModeFilter;
replyToMode: ReplyToMode;
replyToChannel?: OriginatingChannelType;
currentMessageId?: string;
}): ReplyPayload[] {
const { payloads, applyReplyToMode, currentMessageId } = params;
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
replyToChannel,
);
return payloads
.map((payload) => applyReplyTagsToPayload(payload, currentMessageId))
.filter(isRenderablePayload)

View File

@@ -40,6 +40,13 @@ describe("createReplyToModeFilter", () => {
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
});
it("keeps replyToId when mode is off and reply tags are allowed", () => {
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
expect(
filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId,
).toBe("1");
});
it("keeps replyToId when mode is all", () => {
const filter = createReplyToModeFilter("all");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");

View File

@@ -19,11 +19,15 @@ export function resolveReplyToMode(
}
}
export function createReplyToModeFilter(mode: ReplyToMode) {
export function createReplyToModeFilter(
mode: ReplyToMode,
opts: { allowTagsWhenOff?: boolean } = {},
) {
let hasThreaded = false;
return (payload: ReplyPayload): ReplyPayload => {
if (!payload.replyToId) return payload;
if (mode === "off") {
if (opts.allowTagsWhenOff && payload.replyToTag) return payload;
return { ...payload, replyToId: undefined };
}
if (mode === "all") return payload;
@@ -34,3 +38,12 @@ export function createReplyToModeFilter(mode: ReplyToMode) {
return payload;
};
}
export function createReplyToModeFilterForChannel(
mode: ReplyToMode,
channel?: OriginatingChannelType,
) {
return createReplyToModeFilter(mode, {
allowTagsWhenOff: channel === "slack",
});
}

View File

@@ -1,8 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { SILENT_REPLY_TOKEN } from "../tokens.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 +22,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,
}));
@@ -59,6 +69,63 @@ describe("routeReply", () => {
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("drops silent token payloads", async () => {
mocks.sendMessageSlack.mockClear();
const res = await routeReply({
payload: { text: SILENT_REPLY_TOKEN },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("applies responsePrefix when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
messages: { responsePrefix: "[clawdbot]" },
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[clawdbot] hi",
expect.any(Object),
);
});
it("derives responsePrefix from agent identity when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
agents: {
list: [
{
id: "rich",
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
},
],
},
messages: {},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:rich:main",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[Richbot] hi",
expect.any(Object),
);
});
it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({
@@ -143,4 +210,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,
});
});
});

View File

@@ -7,15 +7,19 @@
* across multiple providers.
*/
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
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 { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { sendMessageWhatsApp } from "../../web/outbound.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
export type RouteReplyParams = {
/** The reply payload to send. */
@@ -24,6 +28,8 @@ export type RouteReplyParams = {
channel: OriginatingChannelType;
/** The destination chat/channel/user ID. */
to: string;
/** Session key for deriving agent identity defaults (multi-agent). */
sessionKey?: string;
/** Provider account id (multi-account). */
accountId?: string;
/** Telegram message thread id (forum topics). */
@@ -54,16 +60,28 @@ 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 ?? "";
const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length
? (payload.mediaUrls?.filter(Boolean) as string[])
: payload.mediaUrl
? [payload.mediaUrl]
const responsePrefix = params.sessionKey
? resolveEffectiveMessagesConfig(
cfg,
resolveAgentIdFromSessionKey(params.sessionKey),
).responsePrefix
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
});
if (!normalized) return { ok: true };
const text = normalized.text ?? "";
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]
: [];
const replyToId = payload.replyToId;
const replyToId = normalized.replyToId;
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0) {
@@ -145,6 +163,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 +223,8 @@ export function isRoutableChannel(
| "discord"
| "signal"
| "imessage"
| "whatsapp" {
| "whatsapp"
| "msteams" {
if (!channel) return false;
return [
"telegram",
@@ -204,5 +233,6 @@ export function isRoutableChannel(
"signal",
"imessage",
"whatsapp",
"msteams",
].includes(channel);
}

View File

@@ -91,7 +91,7 @@ export async function ensureSkillSnapshot(params: {
systemSent: true,
skillsSnapshot: skillSnapshot,
};
sessionStore[sessionKey] = nextEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -123,7 +123,7 @@ export async function ensureSkillSnapshot(params: {
updatedAt: Date.now(),
skillsSnapshot,
};
sessionStore[sessionKey] = nextEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}

View File

@@ -264,7 +264,7 @@ export async function initSessionState(params: {
ctx.MessageThreadId,
);
}
sessionStore[sessionKey] = sessionEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
await saveSessionStore(storePath, sessionStore);
const sessionCtx: TemplateContext = {

View File

@@ -1,44 +1,11 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
type HomeEnvSnapshot = Record<
(typeof HOME_ENV_KEYS)[number],
string | undefined
>;
const snapshotHomeEnv = (): HomeEnvSnapshot => ({
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
});
const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => {
for (const key of HOME_ENV_KEYS) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
const setTempHome = (tempHome: string) => {
process.env.HOME = tempHome;
if (process.platform === "win32") {
process.env.USERPROFILE = tempHome;
const root = path.parse(tempHome).root;
process.env.HOMEDRIVE = root.replace(/\\$/, "");
process.env.HOMEPATH = tempHome.slice(root.length - 1);
}
};
afterEach(() => {
vi.restoreAllMocks();
});
@@ -89,19 +56,22 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
now: 10 * 60_000, // 10 minutes later
});
const normalized = normalizeTestText(text);
expect(text).toContain("🦞 ClawdBot");
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");
expect(normalized).toContain("ClawdBot");
expect(normalized).toContain("Model: anthropic/pi:opus");
expect(normalized).toContain("api-key");
expect(normalized).toContain("Tokens: 1.2k in / 800 out");
expect(normalized).toContain("Cost: $0.0020");
expect(normalized).toContain("Context: 16k/32k (50%)");
expect(normalized).toContain("Compactions: 2");
expect(normalized).toContain("Session: agent:main:main");
expect(normalized).toContain("updated 10m ago");
expect(normalized).toContain("Runtime: direct");
expect(normalized).toContain("Think: medium");
expect(normalized).toContain("Verbose: off");
expect(normalized).toContain("Elevated: on");
expect(normalized).toContain("Queue: collect");
});
it("shows verbose/elevated labels only when enabled", () => {
@@ -141,7 +111,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
expect(normalizeTestText(text)).toContain("Model: openai/gpt-4.1-mini");
});
it("keeps provider prefix from configured model", () => {
@@ -154,7 +124,9 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
expect(normalizeTestText(text)).toContain(
"Model: google-antigravity/claude-sonnet-4-5",
);
});
it("handles missing agent config gracefully", () => {
@@ -165,9 +137,10 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model:");
expect(text).toContain("Context:");
expect(text).toContain("Queue: collect");
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model:");
expect(normalized).toContain("Context:");
expect(normalized).toContain("Queue: collect");
});
it("includes group activation for group sessions", () => {
@@ -221,10 +194,10 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
const lines = text.split("\n");
const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
const lines = normalizeTestText(text).split("\n");
const contextIndex = lines.findIndex((line) => line.includes("Context:"));
expect(contextIndex).toBeGreaterThan(-1);
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
expect(lines[contextIndex + 1]).toContain("Usage: Claude 80% left (5h)");
});
it("hides cost when not using an API key", () => {
@@ -260,70 +233,67 @@ describe("buildStatusMessage", () => {
});
it("prefers cached prompt tokens from the session log", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-"));
const previousHome = snapshotHomeEnv();
setTempHome(dir);
try {
vi.resetModules();
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
"./status.js"
);
await withTempHome(
async (dir) => {
vi.resetModules();
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
"./status.js"
);
const sessionId = "sess-1";
const logPath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const sessionId = "sess-1";
const logPath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
},
},
},
}),
].join("\n"),
"utf-8",
);
}),
].join("\n"),
"utf-8",
);
const text = buildStatusMessageDynamic({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
const text = buildStatusMessageDynamic({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
expect(text).toContain("Context: 1.0k/32k");
} finally {
restoreHomeEnv(previousHome);
fs.rmSync(dir, { recursive: true, force: true });
}
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
},
{ prefix: "clawdbot-status-" },
);
});
});

View File

@@ -36,7 +36,9 @@ import type {
VerboseLevel,
} from "./thinking.js";
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
type AgentConfig = Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>;
export const formatTokenCount = formatTokenCountShared;
@@ -189,7 +191,11 @@ export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} },
cfg: {
agents: {
defaults: args.agent ?? {},
},
} as ClawdbotConfig,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
@@ -352,7 +358,7 @@ export function buildHelpMessage(): string {
return [
" Help",
"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",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
"More: /commands for all slash commands",
].join("\n");
}

View File

@@ -6,7 +6,8 @@ export type OriginatingChannelType =
| "signal"
| "imessage"
| "whatsapp"
| "webchat";
| "webchat"
| "msteams";
export type MsgContext = {
Body?: string;

View File

@@ -37,8 +37,8 @@ describe("transcribeInboundAudio", () => {
vi.stubGlobal("fetch", fetchMock);
const cfg = {
routing: {
transcribeAudio: {
audio: {
transcription: {
command: ["echo", "{{MediaPath}}"],
timeoutSeconds: 5,
},
@@ -64,7 +64,7 @@ describe("transcribeInboundAudio", () => {
it("returns undefined when no transcription command", async () => {
const { transcribeInboundAudio } = await import("./transcription.js");
const res = await transcribeInboundAudio(
{ routing: {} } as never,
{ audio: {} } as never,
{} as never,
runtime as never,
);

View File

@@ -18,7 +18,7 @@ export async function transcribeInboundAudio(
ctx: MsgContext,
runtime: RuntimeEnv,
): Promise<{ text: string } | undefined> {
const transcriber = cfg.routing?.transcribeAudio;
const transcriber = cfg.audio?.transcription;
if (!transcriber?.command?.length) return undefined;
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);

View File

@@ -28,6 +28,7 @@ export type ReplyPayload = {
mediaUrl?: string;
mediaUrls?: string[];
replyToId?: string;
replyToTag?: boolean;
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
isError?: boolean;

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-"));

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([\\/]|$)/,

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,
};
}

View File

@@ -168,6 +168,42 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain("ws://");
});
it("registers gateway discover and prints human output with details on new lines", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (Clawdbot)",
displayName: "Studio",
domain: "clawdbot.internal.",
host: "studio.clawdbot.internal",
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", "--timeout", "1"], {
from: "user",
});
const out = runtimeLogs.join("\n");
expect(out).toContain("Gateway Discovery");
expect(out).toContain("Found 1 gateway(s)");
expect(out).toContain("- Studio clawdbot.internal.");
expect(out).toContain(" tailnet: studio.tailnet.ts.net");
expect(out).toContain(" host: studio.clawdbot.internal");
expect(out).toContain(" ws: ws://studio.tailnet.ts.net:18789");
});
it("validates gateway discover timeout", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;

View File

@@ -1,13 +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 { handleReset } from "../commands/onboard-helpers.js";
import {
CONFIG_PATH_CLAWDBOT,
type GatewayAuthMode,
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
@@ -34,6 +39,7 @@ import {
} 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";
@@ -62,6 +68,8 @@ type GatewayRunOpts = {
compact?: boolean;
rawStream?: boolean;
rawStreamPath?: unknown;
dev?: boolean;
reset?: boolean;
};
type GatewayRunParams = {
@@ -69,6 +77,32 @@ 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_TEMPLATE_DIR = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
"../../docs/reference/templates",
);
async function loadDevTemplate(
name: string,
fallback: string,
): Promise<string> {
try {
const raw = await fs.promises.readFile(
path.join(DEV_TEMPLATE_DIR, name),
"utf-8",
);
if (!raw.startsWith("---")) return raw;
const endIndex = raw.indexOf("\n---", 3);
if (endIndex === -1) return raw;
return raw.slice(endIndex + "\n---".length).replace(/^\s+/, "");
} catch {
return fallback;
}
}
type GatewayRunSignalAction = "stop" | "restart";
@@ -93,6 +127,99 @@ const toOptionString = (value: unknown): string | undefined => {
return undefined;
};
const resolveDevWorkspaceDir = (
env: NodeJS.ProcessEnv = process.env,
): string => {
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
const profile = env.CLAWDBOT_PROFILE?.trim().toLowerCase();
if (profile === "dev") return baseDir;
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 });
const [agents, soul, tools, identity, user] = await Promise.all([
loadDevTemplate(
"AGENTS.dev.md",
`# AGENTS.md - Clawdbot Dev Workspace\n\nDefault dev workspace for clawdbot gateway --dev.\n`,
),
loadDevTemplate(
"SOUL.dev.md",
`# SOUL.md - Dev Persona\n\nProtocol droid for debugging and operations.\n`,
),
loadDevTemplate(
"TOOLS.dev.md",
`# TOOLS.md - User Tool Notes (editable)\n\nAdd your local tool notes here.\n`,
),
loadDevTemplate(
"IDENTITY.dev.md",
`# IDENTITY.md - Agent Identity\n\n- Name: ${DEV_IDENTITY_NAME}\n- Creature: protocol droid\n- Vibe: ${DEV_IDENTITY_THEME}\n- Emoji: ${DEV_IDENTITY_EMOJI}\n`,
),
loadDevTemplate(
"USER.dev.md",
`# USER.md - User Profile\n\n- Name:\n- Preferred address:\n- Notes:\n`,
),
]);
await writeFileIfMissing(path.join(resolvedDir, "AGENTS.md"), agents);
await writeFileIfMissing(path.join(resolvedDir, "SOUL.md"), soul);
await writeFileIfMissing(path.join(resolvedDir, "TOOLS.md"), tools);
await writeFileIfMissing(path.join(resolvedDir, "IDENTITY.md"), identity);
await writeFileIfMissing(path.join(resolvedDir, "USER.md"), user);
}
async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
const workspace = resolveDevWorkspaceDir();
if (opts.reset) {
await handleReset("full", workspace, defaultRuntime);
}
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
if (!opts.reset && configExists) return;
await writeConfigFile({
gateway: {
mode: "local",
bind: "loopback",
},
agents: {
defaults: {
workspace,
skipBootstrap: true,
},
list: [
{
id: "dev",
default: true,
workspace,
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;
@@ -164,26 +291,24 @@ function renderBeaconLines(
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 = [`- ${title} ${domain}`];
if (beacon.tailnetDns) {
lines.push(
` ${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`,
);
}
if (beacon.lanHost) {
lines.push(` ${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`);
}
if (beacon.host) {
lines.push(` ${colorize(rich, theme.info, "host")}: ${beacon.host}`);
}
const lines = [`- ${firstLine}`];
if (wsUrl) {
lines.push(
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
@@ -403,6 +528,14 @@ async function runGatewayCommand(
opts: GatewayRunOpts,
params: GatewayRunParams = {},
) {
const isDevProfile =
process.env.CLAWDBOT_PROFILE?.trim().toLowerCase() === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
if (opts.reset && !devMode) {
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) {
@@ -439,6 +572,10 @@ async function runGatewayCommand(
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
}
if (devMode) {
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
@@ -692,6 +829,16 @@ 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",
"Reset dev config + credentials + sessions + workspace (requires --dev)",
false,
)
.option(
"--force",
"Kill any existing listener on the target port before starting",
@@ -825,6 +972,16 @@ export function registerGatewayCli(program: Command) {
"--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")
@@ -853,6 +1010,7 @@ export function registerGatewayCli(program: Command) {
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
delayMs: 0,
},
async () => await discoverGatewayBeacons({ timeoutMs }),
);

View File

@@ -5,6 +5,9 @@ import {
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
modelsAuthAddCommand,
modelsAuthOrderClearCommand,
modelsAuthOrderGetCommand,
modelsAuthOrderSetCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
modelsFallbacksAddCommand,
@@ -267,10 +270,14 @@ export function registerModelsCli(program: Command) {
.option("--no-probe", "Skip live probes; list free candidates only")
.option("--yes", "Accept defaults without prompting", false)
.option("--no-input", "Disable prompts (use defaults)")
.option("--set-default", "Set agent.model to the first selection", false)
.option(
"--set-default",
"Set agents.defaults.model to the first selection",
false,
)
.option(
"--set-image",
"Set agent.imageModel to the first image selection",
"Set agents.defaults.imageModel to the first image selection",
false,
)
.option("--json", "Output JSON", false)
@@ -356,4 +363,76 @@ export function registerModelsCli(program: Command) {
defaultRuntime.exit(1);
}
});
const order = auth
.command("order")
.description("Manage per-agent auth profile order overrides");
order
.command("get")
.description("Show per-agent auth order override (from auth-profiles.json)")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await modelsAuthOrderGetCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
order
.command("set")
.description(
"Set per-agent auth order override (locks rotation to this list)",
)
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:claude-cli)")
.action(async (profileIds: string[], opts) => {
try {
await modelsAuthOrderSetCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
order: profileIds,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
order
.command("clear")
.description(
"Clear per-agent auth order override (fall back to config/round-robin)",
)
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.action(async (opts) => {
try {
await modelsAuthOrderClearCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}

View File

@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
@@ -21,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [
"discord",
"slack",
"whatsapp",
"msteams",
];
function parseProvider(raw: unknown): PairingProvider {
@@ -65,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.
}

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
describe("parseCliProfileArgs", () => {
it("strips --dev anywhere in argv", () => {
it("leaves gateway --dev for subcommands", () => {
const res = parseCliProfileArgs([
"node",
"clawdbot",
@@ -12,15 +12,23 @@ describe("parseCliProfileArgs", () => {
"--allow-unconfigured",
]);
if (!res.ok) throw new Error(res.error);
expect(res.profile).toBe("dev");
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"clawdbot",
"gateway",
"--dev",
"--allow-unconfigured",
]);
});
it("still accepts global --dev before subcommand", () => {
const res = parseCliProfileArgs(["node", "clawdbot", "--dev", "gateway"]);
if (!res.ok) throw new Error(res.error);
expect(res.profile).toBe("dev");
expect(res.argv).toEqual(["node", "clawdbot", "gateway"]);
});
it("parses --profile value and strips it", () => {
const res = parseCliProfileArgs([
"node",

View File

@@ -33,12 +33,18 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
const out: string[] = argv.slice(0, 2);
let profile: string | null = null;
let sawDev = false;
let sawCommand = false;
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === undefined) continue;
if (sawCommand) {
out.push(arg);
continue;
}
if (arg === "--dev") {
if (profile && profile !== "dev") {
return { ok: false, error: "Cannot combine --dev with --profile" };
@@ -66,6 +72,12 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
continue;
}
if (!arg.startsWith("-")) {
sawCommand = true;
out.push(arg);
continue;
}
out.push(arg);
}

View File

@@ -46,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";
@@ -190,7 +191,7 @@ export function buildProgram() {
.description("Initialize ~/.clawdbot/clawdbot.json and the agent workspace")
.option(
"--workspace <dir>",
"Agent workspace directory (default: ~/clawd; stored as agent.workspace)",
"Agent workspace directory (default: ~/clawd; stored as agents.defaults.workspace)",
)
.option("--wizard", "Run the interactive onboarding wizard", false)
.option("--non-interactive", "Run the wizard without prompts", false)
@@ -239,11 +240,12 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|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")
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
@@ -276,12 +278,14 @@ export function buildProgram() {
| "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"
? Number.parseInt(opts.gatewayPort, 10)
@@ -1038,6 +1042,7 @@ Examples:
registerLogsCli(program);
registerModelsCli(program);
registerNodesCli(program);
registerSandboxCli(program);
registerTuiCli(program);
registerCronCli(program);
registerDnsCli(program);
@@ -1158,7 +1163,7 @@ Examples:
clawdbot sessions --json # machine-readable output
clawdbot sessions --store ./tmp/sessions.json
Shows token usage per session when the agent reports it; set agent.contextTokens to see % of your model window.`,
Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));

132
src/cli/sandbox-cli.ts Normal file
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,
),
),
);
}

View File

@@ -1,5 +1,9 @@
import chalk from "chalk";
import type { Command } from "commander";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import {
buildWorkspaceSkillStatus,
type SkillStatusEntry,
@@ -363,7 +367,10 @@ export function registerSkillsCli(program: Command) {
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsList(report, opts));
} catch (err) {
@@ -380,7 +387,10 @@ export function registerSkillsCli(program: Command) {
.action(async (name, opts) => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillInfo(report, name, opts));
} catch (err) {
@@ -396,7 +406,10 @@ export function registerSkillsCli(program: Command) {
.action(async (opts) => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsCheck(report, opts));
} catch (err) {
@@ -409,7 +422,10 @@ export function registerSkillsCli(program: Command) {
skills.action(async () => {
try {
const config = loadConfig();
const workspaceDir = config.agent?.workspace ?? process.cwd();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsList(report, {}));
} catch (err) {

View File

@@ -18,6 +18,7 @@ export function registerTuiCli(program: Command) {
)
.option("--deliver", "Deliver assistant replies", false)
.option("--thinking <level>", "Thinking level override")
.option("--message <text>", "Send an initial message after connecting")
.option("--timeout-ms <ms>", "Agent timeout in ms", "30000")
.option("--history-limit <n>", "History entries to load", "200")
.action(async (opts) => {
@@ -37,6 +38,7 @@ export function registerTuiCli(program: Command) {
session: opts.session as string | undefined,
deliver: Boolean(opts.deliver),
thinking: opts.thinking as string | undefined,
message: opts.message as string | undefined,
timeoutMs: Number.isNaN(timeoutMs) ? undefined : timeoutMs,
historyLimit: Number.isNaN(historyLimit) ? undefined : historyLimit,
});

View File

@@ -29,9 +29,11 @@ const configSpy = vi.spyOn(configModule, "loadConfig");
function mockConfig(storePath: string, overrides?: Partial<ClawdbotConfig>) {
configSpy.mockReturnValue({
agent: {
timeoutSeconds: 600,
...overrides?.agent,
agents: {
defaults: {
timeoutSeconds: 600,
...overrides?.agents?.defaults,
},
},
session: {
store: storePath,

View File

@@ -80,7 +80,7 @@ function parseTimeoutSeconds(opts: {
const raw =
opts.timeout !== undefined
? Number.parseInt(String(opts.timeout), 10)
: (opts.cfg.agent?.timeoutSeconds ?? 600);
: (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
if (Number.isNaN(raw) || raw <= 0) {
throw new Error("--timeout must be a positive integer (seconds)");
}

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
@@ -11,6 +10,8 @@ import {
vi,
} from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
@@ -39,33 +40,27 @@ const runtime: RuntimeEnv = {
const configSpy = vi.spyOn(configModule, "loadConfig");
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
fs.rmSync(base, { recursive: true, force: true });
}
return withTempHomeBase(fn, { prefix: "clawdbot-agent-" });
}
function mockConfig(
home: string,
storePath: string,
routingOverrides?: Partial<NonNullable<ClawdbotConfig["routing"]>>,
agentOverrides?: Partial<NonNullable<ClawdbotConfig["agent"]>>,
agentOverrides?: Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>,
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
) {
configSpy.mockReturnValue({
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
models: { "anthropic/claude-opus-4-5": {} },
workspace: path.join(home, "clawd"),
...agentOverrides,
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: { "anthropic/claude-opus-4-5": {} },
workspace: path.join(home, "clawd"),
...agentOverrides,
},
},
session: { store: storePath, mainKey: "main" },
routing: routingOverrides ? { ...routingOverrides } : undefined,
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
});
}
@@ -153,11 +148,15 @@ describe("agentCommand", () => {
});
});
it("uses provider/model from agent.model", async () => {
it("uses provider/model from agents.defaults.model.primary", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, {
model: "openai/gpt-4.1-mini",
mockConfig(home, store, {
model: { primary: "openai/gpt-4.1-mini" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
});
await agentCommand({ message: "hi", to: "+1555" }, runtime);
@@ -269,7 +268,7 @@ describe("agentCommand", () => {
it("passes through telegram accountId when delivering", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
mockConfig(home, store, undefined, { botToken: "t-1" });
const deps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi

View File

@@ -181,13 +181,13 @@ export async function agentCommand(
}
const cfg = loadConfig();
const agentCfg = cfg.agent;
const agentCfg = cfg.agents?.defaults;
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;

View File

@@ -1,9 +1,9 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
applyAgentBindings,
applyAgentConfig,
@@ -12,27 +12,32 @@ import {
} from "./agents.js";
describe("agents helpers", () => {
it("buildAgentSummaries includes default + routing agents", () => {
it("buildAgentSummaries includes default + configured agents", () => {
const cfg: ClawdbotConfig = {
agent: { workspace: "/main-ws", model: { primary: "anthropic/claude" } },
routing: {
defaultAgentId: "work",
agents: {
work: {
agents: {
defaults: {
workspace: "/main-ws",
model: { primary: "anthropic/claude" },
},
list: [
{ id: "main" },
{
id: "work",
default: true,
name: "Work",
workspace: "/work-ws",
agentDir: "/state/agents/work/agent",
model: "openai/gpt-4.1",
},
},
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", accountId: "biz" },
},
{ agentId: "main", match: { provider: "telegram" } },
],
},
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", accountId: "biz" },
},
{ agentId: "main", match: { provider: "telegram" } },
],
};
const summaries = buildAgentSummaries(cfg);
@@ -40,7 +45,7 @@ describe("agents helpers", () => {
const work = summaries.find((summary) => summary.id === "work");
expect(main).toBeTruthy();
expect(main?.workspace).toBe(path.resolve("/main-ws"));
expect(main?.workspace).toBe(path.join(os.homedir(), "clawd-main"));
expect(main?.bindings).toBe(1);
expect(main?.model).toBe("anthropic/claude");
expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe(
@@ -57,10 +62,8 @@ describe("agents helpers", () => {
it("applyAgentConfig merges updates", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: { workspace: "/old-ws", model: "anthropic/claude" },
},
agents: {
list: [{ id: "work", workspace: "/old-ws", model: "anthropic/claude" }],
},
};
@@ -71,7 +74,7 @@ describe("agents helpers", () => {
agentDir: "/state/work/agent",
});
const work = next.routing?.agents?.work;
const work = next.agents?.list?.find((agent) => agent.id === "work");
expect(work?.name).toBe("Work");
expect(work?.workspace).toBe("/new-ws");
expect(work?.agentDir).toBe("/state/work/agent");
@@ -80,14 +83,12 @@ describe("agents helpers", () => {
it("applyAgentBindings skips duplicates and reports conflicts", () => {
const cfg: ClawdbotConfig = {
routing: {
bindings: [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
},
],
},
bindings: [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
},
],
};
const result = applyAgentBindings(cfg, [
@@ -108,32 +109,36 @@ describe("agents helpers", () => {
expect(result.added).toHaveLength(1);
expect(result.skipped).toHaveLength(1);
expect(result.conflicts).toHaveLength(1);
expect(result.config.routing?.bindings).toHaveLength(2);
expect(result.config.bindings).toHaveLength(2);
});
it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
const cfg: ClawdbotConfig = {
routing: {
defaultAgentId: "work",
agents: {
work: { workspace: "/work-ws" },
home: { workspace: "/home-ws" },
},
bindings: [
{ agentId: "work", match: { provider: "whatsapp" } },
{ agentId: "home", match: { provider: "telegram" } },
agents: {
list: [
{ id: "work", default: true, workspace: "/work-ws" },
{ id: "home", workspace: "/home-ws" },
],
},
bindings: [
{ agentId: "work", match: { provider: "whatsapp" } },
{ agentId: "home", match: { provider: "telegram" } },
],
tools: {
agentToAgent: { enabled: true, allow: ["work", "home"] },
},
};
const result = pruneAgentConfig(cfg, "work");
expect(result.config.routing?.agents?.work).toBeUndefined();
expect(result.config.routing?.agents?.home).toBeTruthy();
expect(result.config.routing?.bindings).toHaveLength(1);
expect(result.config.routing?.bindings?.[0]?.agentId).toBe("home");
expect(result.config.routing?.agentToAgent?.allow).toEqual(["home"]);
expect(result.config.routing?.defaultAgentId).toBe(DEFAULT_AGENT_ID);
expect(
result.config.agents?.list?.some((agent) => agent.id === "work"),
).toBe(false);
expect(
result.config.agents?.list?.some((agent) => agent.id === "home"),
).toBe(true);
expect(result.config.bindings).toHaveLength(1);
expect(result.config.bindings?.[0]?.agentId).toBe("home");
expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]);
expect(result.removedBindings).toBe(1);
expect(result.removedAllow).toBe(1);
});

View File

@@ -1,9 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
@@ -114,6 +114,10 @@ type AgentBinding = {
};
};
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type AgentIdentity = {
name?: string;
emoji?: string;
@@ -140,15 +144,32 @@ function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv {
return { ...runtime, log: () => {} };
}
function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
function findAgentEntryIndex(list: AgentEntry[], agentId: string): number {
const id = normalizeAgentId(agentId);
return list.findIndex((entry) => normalizeAgentId(entry.id) === id);
}
function resolveAgentName(cfg: ClawdbotConfig, agentId: string) {
return cfg.routing?.agents?.[agentId]?.name?.trim() || undefined;
const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
);
return entry?.name?.trim() || undefined;
}
function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
if (agentId !== DEFAULT_AGENT_ID) {
return cfg.routing?.agents?.[agentId]?.model?.trim() || undefined;
}
const raw = cfg.agent?.model;
const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
);
if (entry?.model?.trim()) return entry.model.trim();
const raw = cfg.agents?.defaults?.model;
if (typeof raw === "string") return raw;
return raw?.primary?.trim() || undefined;
}
@@ -183,37 +204,33 @@ function loadAgentIdentity(workspace: string): AgentIdentity | null {
}
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
const defaultAgentId = normalizeAgentId(
cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const agentIds = new Set<string>([
DEFAULT_AGENT_ID,
defaultAgentId,
...Object.keys(cfg.routing?.agents ?? {}),
]);
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
const configuredAgents = listAgentEntries(cfg);
const orderedIds =
configuredAgents.length > 0
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
: [defaultAgentId];
const bindingCounts = new Map<string, number>();
for (const binding of cfg.routing?.bindings ?? []) {
for (const binding of cfg.bindings ?? []) {
const agentId = normalizeAgentId(binding.agentId);
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}
const ordered = [
DEFAULT_AGENT_ID,
...[...agentIds]
.filter((id) => id !== DEFAULT_AGENT_ID)
.sort((a, b) => a.localeCompare(b)),
];
const ordered = orderedIds.filter(
(id, index) => orderedIds.indexOf(id) === index,
);
return ordered.map((id) => {
const workspace = resolveAgentWorkspaceDir(cfg, id);
const identity = loadAgentIdentity(workspace);
const fallbackIdentity = id === defaultAgentId ? cfg.identity : undefined;
const identityName = identity?.name ?? fallbackIdentity?.name?.trim();
const identityEmoji = identity?.emoji ?? fallbackIdentity?.emoji?.trim();
const configIdentity = configuredAgents.find(
(agent) => normalizeAgentId(agent.id) === id,
)?.identity;
const identityName = identity?.name ?? configIdentity?.name?.trim();
const identityEmoji = identity?.emoji ?? configIdentity?.emoji?.trim();
const identitySource = identity
? "identity"
: fallbackIdentity && (identityName || identityEmoji)
: configIdentity && (identityName || identityEmoji)
? "config"
: undefined;
return {
@@ -242,22 +259,34 @@ export function applyAgentConfig(
},
): ClawdbotConfig {
const agentId = normalizeAgentId(params.agentId);
const existing = cfg.routing?.agents?.[agentId] ?? {};
const name = params.name?.trim();
const list = listAgentEntries(cfg);
const index = findAgentEntryIndex(list, agentId);
const base = index >= 0 ? list[index] : { id: agentId };
const nextEntry: AgentEntry = {
...base,
...(name ? { name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
};
const nextList = [...list];
if (index >= 0) {
nextList[index] = nextEntry;
} else {
if (
nextList.length === 0 &&
agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))
) {
nextList.push({ id: resolveDefaultAgentId(cfg) });
}
nextList.push(nextEntry);
}
return {
...cfg,
routing: {
...cfg.routing,
agents: {
...cfg.routing?.agents,
[agentId]: {
...existing,
...(name ? { name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
},
},
agents: {
...cfg.agents,
list: nextList,
},
};
}
@@ -283,7 +312,7 @@ export function applyAgentBindings(
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.routing?.bindings ?? [];
const existing = cfg.bindings ?? [];
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
const key = bindingMatchKey(binding.match);
@@ -320,10 +349,7 @@ export function applyAgentBindings(
return {
config: {
...cfg,
routing: {
...cfg.routing,
bindings: [...existing, ...added],
},
bindings: [...existing, ...added],
},
added,
skipped,
@@ -340,39 +366,41 @@ export function pruneAgentConfig(
removedAllow: number;
} {
const id = normalizeAgentId(agentId);
const agents = { ...cfg.routing?.agents };
delete agents[id];
const nextAgents = Object.keys(agents).length > 0 ? agents : undefined;
const agents = listAgentEntries(cfg);
const nextAgentsList = agents.filter(
(entry) => normalizeAgentId(entry.id) !== id,
);
const nextAgents = nextAgentsList.length > 0 ? nextAgentsList : undefined;
const bindings = cfg.routing?.bindings ?? [];
const bindings = cfg.bindings ?? [];
const filteredBindings = bindings.filter(
(binding) => normalizeAgentId(binding.agentId) !== id,
);
const allow = cfg.routing?.agentToAgent?.allow ?? [];
const allow = cfg.tools?.agentToAgent?.allow ?? [];
const filteredAllow = allow.filter((entry) => entry !== id);
const nextRouting = {
...cfg.routing,
...(nextAgents ? { agents: nextAgents } : {}),
...(nextAgents ? {} : { agents: undefined }),
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
agentToAgent: cfg.routing?.agentToAgent
? {
...cfg.routing.agentToAgent,
const nextAgentsConfig = cfg.agents
? { ...cfg.agents, list: nextAgents }
: nextAgents
? { list: nextAgents }
: undefined;
const nextTools = cfg.tools?.agentToAgent
? {
...cfg.tools,
agentToAgent: {
...cfg.tools.agentToAgent,
allow: filteredAllow.length > 0 ? filteredAllow : undefined,
}
: undefined,
defaultAgentId:
normalizeAgentId(cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID) === id
? DEFAULT_AGENT_ID
: cfg.routing?.defaultAgentId,
};
},
}
: cfg.tools;
return {
config: {
...cfg,
routing: nextRouting,
agents: nextAgentsConfig,
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
tools: nextTools,
},
removedBindings: bindings.length - filteredBindings.length,
removedAllow: allow.length - filteredAllow.length,
@@ -632,7 +660,7 @@ export async function agentsListCommand(
const summaries = buildAgentSummaries(cfg);
const bindingMap = new Map<string, AgentBinding[]>();
for (const binding of cfg.routing?.bindings ?? []) {
for (const binding of cfg.bindings ?? []) {
const agentId = normalizeAgentId(binding.agentId);
const list = bindingMap.get(agentId) ?? [];
list.push(binding as AgentBinding);
@@ -818,7 +846,7 @@ export async function agentsAddCommand(
if (agentId !== nameInput) {
runtime.log(`Normalized agent id to "${agentId}".`);
}
if (cfg.routing?.agents?.[agentId]) {
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) {
runtime.error(`Agent "${agentId}" already exists.`);
runtime.exit(1);
return;
@@ -856,7 +884,9 @@ export async function agentsAddCommand(
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
skipBootstrap: Boolean(bindingResult.config.agent?.skipBootstrap),
skipBootstrap: Boolean(
bindingResult.config.agents?.defaults?.skipBootstrap,
),
agentId,
});
@@ -920,7 +950,9 @@ export async function agentsAddCommand(
await prompter.note(`Normalized id to "${agentId}".`, "Agent id");
}
const existingAgent = cfg.routing?.agents?.[agentId];
const existingAgent = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === agentId,
);
if (existingAgent) {
const shouldUpdate = await prompter.confirm({
message: `Agent "${agentId}" already exists. Update it?`,
@@ -1005,8 +1037,7 @@ export async function agentsAddCommand(
if (selection.length > 0) {
const wantsBindings = await prompter.confirm({
message:
"Route selected providers to this agent now? (routing.bindings)",
message: "Route selected providers to this agent now? (bindings)",
initialValue: false,
});
if (wantsBindings) {
@@ -1033,7 +1064,7 @@ export async function agentsAddCommand(
} else {
await prompter.note(
[
"Routing unchanged. Add routing.bindings when you're ready.",
"Routing unchanged. Add bindings when you're ready.",
"Docs: https://docs.clawd.bot/concepts/multi-agent",
].join("\n"),
"Routing",
@@ -1044,7 +1075,7 @@ export async function agentsAddCommand(
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
agentId,
});
@@ -1091,7 +1122,7 @@ export async function agentsDeleteCommand(
return;
}
if (!cfg.routing?.agents?.[agentId]) {
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
runtime.error(`Agent "${agentId}" not found.`);
runtime.exit(1);
return;

View File

@@ -93,6 +93,7 @@ export function buildAuthChoiceOptions(params: {
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) {
options.push({ value: "skip", label: "Skip for now" });

View File

@@ -37,9 +37,13 @@ import {
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
applyMinimaxHostedProviderConfig,
applyMinimaxProviderConfig,
MINIMAX_HOSTED_MODEL_REF,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
@@ -61,13 +65,16 @@ export async function warnIfModelConfigLooksOff(
agentModelOverride && agentModelOverride.length > 0
? {
...config,
agent: {
...config.agent,
model: {
...(typeof config.agent?.model === "object"
? config.agent.model
: undefined),
primary: agentModelOverride,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
model: {
...(typeof config.agents?.defaults?.model === "object"
? config.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}
@@ -88,7 +95,7 @@ export async function warnIfModelConfigLooksOff(
);
if (!known) {
warnings.push(
`Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`,
`Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`,
);
}
}
@@ -107,7 +114,7 @@ export async function warnIfModelConfigLooksOff(
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
warnings.push(
`Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
`Detected OpenAI Codex OAuth. Consider setting agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
);
}
}
@@ -450,30 +457,36 @@ export async function applyAuthChoice(params: {
const modelKey = "google-antigravity/claude-opus-4-5-thinking";
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
models: {
...nextConfig.agent?.models,
[modelKey]: nextConfig.agent?.models?.[modelKey] ?? {},
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
models: {
...nextConfig.agents?.defaults?.models,
[modelKey]:
nextConfig.agents?.defaults?.models?.[modelKey] ?? {},
},
},
},
};
if (params.setDefaultModel) {
const existingModel = nextConfig.agents?.defaults?.model;
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: {
...(nextConfig.agent?.model &&
"fallbacks" in
(nextConfig.agent.model as Record<string, unknown>)
? {
fallbacks: (
nextConfig.agent.model as { fallbacks?: string[] }
).fallbacks,
}
: undefined),
primary: modelKey,
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: modelKey,
},
},
},
};
@@ -529,6 +542,24 @@ export async function applyAuthChoice(params: {
provider: "anthropic",
mode: "api_key",
});
} else if (params.authChoice === "minimax-cloud") {
const key = await params.prompter.text({
message: "Enter MiniMax API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setMinimaxApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applyMinimaxHostedConfig(nextConfig);
} else {
nextConfig = applyMinimaxHostedProviderConfig(nextConfig);
agentModelOverride = MINIMAX_HOSTED_MODEL_REF;
await noteAgentModel(MINIMAX_HOSTED_MODEL_REF);
}
} else if (params.authChoice === "minimax") {
if (params.setDefaultModel) {
nextConfig = applyMinimaxConfig(nextConfig);

View File

@@ -37,6 +37,7 @@ 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 { listChatProviders } from "../providers/registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
@@ -69,16 +70,16 @@ import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
detectBrowserOpenSupport,
ensureWorkspaceAndSessions,
formatControlUiSshHint,
guardCancel,
openUrl,
printWizardHeader,
@@ -105,6 +106,8 @@ type WizardSection =
| "skills"
| "health";
type ProvidersWizardMode = "configure" | "remove";
type ConfigureWizardParams = {
command: "configure" | "update";
sections?: WizardSection[];
@@ -357,6 +360,7 @@ async function promptAuthConfig(
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax-cloud"
| "minimax"
| "skip";
@@ -622,26 +626,32 @@ async function promptAuthConfig(
mode: "oauth",
});
// Set default model to Claude Opus 4.5 via Antigravity
const existingDefaults = next.agents?.defaults;
const existingModel = existingDefaults?.model;
const existingModels = existingDefaults?.models;
next = {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...next.agent?.models,
"google-antigravity/claude-opus-4-5-thinking":
next.agent?.models?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
agents: {
...next.agents,
defaults: {
...existingDefaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...existingModels,
"google-antigravity/claude-opus-4-5-thinking":
existingModels?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
},
},
},
};
@@ -691,14 +701,29 @@ async function promptAuthConfig(
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "minimax-cloud") {
const key = guardCancel(
await text({
message: "Enter MiniMax API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setMinimaxApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
next = applyMinimaxHostedConfig(next);
} else if (authChoice === "minimax") {
next = applyMinimaxConfig(next);
}
const currentModel =
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? "");
typeof next.agents?.defaults?.model === "string"
? next.agents?.defaults?.model
: (next.agents?.defaults?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "token" ||
@@ -718,23 +743,29 @@ async function promptAuthConfig(
);
const model = String(modelInput ?? "").trim();
if (model) {
const existingDefaults = next.agents?.defaults;
const existingModel = existingDefaults?.model;
const existingModels = existingDefaults?.models;
next = {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: model,
},
models: {
...next.agent?.models,
[model]: next.agent?.models?.[model] ?? {},
agents: {
...next.agents,
defaults: {
...existingDefaults,
model: {
...(existingModel &&
"fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: model,
},
models: {
...existingModels,
[model]: existingModels?.[model] ?? {},
},
},
},
};
@@ -834,6 +865,74 @@ async function maybeInstallDaemon(params: {
}
}
async function removeProviderConfigWizard(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
): Promise<ClawdbotConfig> {
let next = { ...cfg };
const listConfiguredProviders = () =>
listChatProviders().filter((meta) => {
const value = (next as Record<string, unknown>)[meta.id];
return value !== undefined;
});
while (true) {
const configured = listConfiguredProviders();
if (configured.length === 0) {
note(
[
"No provider config found in clawdbot.json.",
"Tip: `clawdbot providers status` shows what is configured and enabled.",
].join("\n"),
"Remove provider",
);
return next;
}
const provider = guardCancel(
await select({
message: "Remove which provider config?",
options: [
...configured.map((meta) => ({
value: meta.id,
label: meta.label,
hint: "Deletes tokens + settings from config (credentials stay on disk)",
})),
{ value: "done", label: "Done" },
],
}),
runtime,
) as string;
if (provider === "done") return next;
const label =
listChatProviders().find((meta) => meta.id === provider)?.label ??
provider;
const confirmed = guardCancel(
await confirm({
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
initialValue: false,
}),
runtime,
);
if (!confirmed) continue;
const clone = { ...next } as Record<string, unknown>;
delete clone[provider];
next = clone as ClawdbotConfig;
note(
[
`${label} removed from config.`,
"Note: credentials/sessions on disk are unchanged.",
].join("\n"),
"Provider removed",
);
}
}
export async function runConfigureWizard(
opts: ConfigureWizardParams,
runtime: RuntimeEnv = defaultRuntime,
@@ -937,7 +1036,7 @@ export async function runConfigureWizard(
{
value: "workspace",
label: "Workspace",
hint: "Set agent workspace + ensure sessions",
hint: "Set default workspace + ensure sessions",
},
{
value: "model",
@@ -981,8 +1080,8 @@ export async function runConfigureWizard(
let nextConfig = { ...baseConfig };
let workspaceDir =
nextConfig.agent?.workspace ??
baseConfig.agent?.workspace ??
nextConfig.agents?.defaults?.workspace ??
baseConfig.agents?.defaults?.workspace ??
DEFAULT_WORKSPACE;
let gatewayPort = resolveGatewayPort(baseConfig);
let gatewayToken: string | undefined;
@@ -1000,9 +1099,12 @@ export async function runConfigureWizard(
);
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
workspace: workspaceDir,
agents: {
...nextConfig.agents,
defaults: {
...nextConfig.agents?.defaults,
workspace: workspaceDir,
},
},
};
await ensureWorkspaceAndSessions(workspaceDir, runtime);
@@ -1020,10 +1122,34 @@ export async function runConfigureWizard(
}
if (selected.includes("providers")) {
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowDisable: true,
allowSignalInstall: true,
});
const providerMode = guardCancel(
await select({
message: "Providers",
options: [
{
value: "configure",
label: "Configure/link",
hint: "Add/update providers; disable unselected accounts",
},
{
value: "remove",
label: "Remove provider config",
hint: "Delete provider tokens/settings from clawdbot.json",
},
],
initialValue: "configure",
}),
runtime,
) as ProvidersWizardMode;
if (providerMode === "configure") {
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowDisable: true,
allowSignalInstall: true,
});
} else {
nextConfig = await removeProviderConfigWizard(nextConfig, runtime);
}
}
if (selected.includes("skills")) {
@@ -1109,41 +1235,6 @@ export async function runConfigureWizard(
"Control UI",
);
const browserSupport = await detectBrowserOpenSupport();
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",
);
}
}
}
}
outro("Configure complete.");
}

View File

@@ -71,75 +71,184 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
const changes: string[] = [];
let next: ClawdbotConfig = cfg;
const workspace = cfg.agent?.workspace;
const updatedWorkspace = normalizeDefaultWorkspacePath(workspace);
if (updatedWorkspace && updatedWorkspace !== workspace) {
next = {
...next,
agent: {
...next.agent,
workspace: updatedWorkspace,
},
};
changes.push(`Updated agent.workspace → ${updatedWorkspace}`);
}
const defaults = cfg.agents?.defaults;
if (defaults) {
let updatedDefaults = defaults;
let defaultsChanged = false;
const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot);
if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) {
next = {
...next,
agent: {
...next.agent,
sandbox: {
...next.agent?.sandbox,
const updatedWorkspace = normalizeDefaultWorkspacePath(defaults.workspace);
if (updatedWorkspace && updatedWorkspace !== defaults.workspace) {
updatedDefaults = { ...updatedDefaults, workspace: updatedWorkspace };
defaultsChanged = true;
changes.push(`Updated agents.defaults.workspace → ${updatedWorkspace}`);
}
const sandbox = defaults.sandbox;
if (sandbox) {
let updatedSandbox = sandbox;
let sandboxChanged = false;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(
sandbox.workspaceRoot,
);
if (
updatedWorkspaceRoot &&
updatedWorkspaceRoot !== sandbox.workspaceRoot
) {
updatedSandbox = {
...updatedSandbox,
workspaceRoot: updatedWorkspaceRoot,
},
},
};
changes.push(
`Updated agent.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
}
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
}
const dockerImage = cfg.agent?.sandbox?.docker?.image;
const updatedDockerImage = replaceLegacyName(dockerImage);
if (updatedDockerImage && updatedDockerImage !== dockerImage) {
next = {
...next,
agent: {
...next.agent,
sandbox: {
...next.agent?.sandbox,
const dockerImage = sandbox.docker?.image;
const updatedDockerImage = replaceLegacyName(dockerImage);
if (updatedDockerImage && updatedDockerImage !== dockerImage) {
updatedSandbox = {
...updatedSandbox,
docker: {
...next.agent?.sandbox?.docker,
...updatedSandbox.docker,
image: updatedDockerImage,
},
},
},
};
changes.push(`Updated agent.sandbox.docker.image → ${updatedDockerImage}`);
}
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.docker.image → ${updatedDockerImage}`,
);
}
const containerPrefix = cfg.agent?.sandbox?.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (updatedContainerPrefix && updatedContainerPrefix !== containerPrefix) {
next = {
...next,
agent: {
...next.agent,
sandbox: {
...next.agent?.sandbox,
const containerPrefix = sandbox.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (
updatedContainerPrefix &&
updatedContainerPrefix !== containerPrefix
) {
updatedSandbox = {
...updatedSandbox,
docker: {
...next.agent?.sandbox?.docker,
...updatedSandbox.docker,
containerPrefix: updatedContainerPrefix,
},
};
sandboxChanged = true;
changes.push(
`Updated agents.defaults.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`,
);
}
if (sandboxChanged) {
updatedDefaults = { ...updatedDefaults, sandbox: updatedSandbox };
defaultsChanged = true;
}
}
if (defaultsChanged) {
next = {
...next,
agents: {
...next.agents,
defaults: updatedDefaults,
},
},
};
changes.push(
`Updated agent.sandbox.docker.containerPrefix → ${updatedContainerPrefix}`,
);
};
}
}
const list = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
if (list.length > 0) {
let listChanged = false;
const nextList = list.map((agent) => {
let updatedAgent = agent;
let agentChanged = false;
const updatedWorkspace = normalizeDefaultWorkspacePath(agent.workspace);
if (updatedWorkspace && updatedWorkspace !== agent.workspace) {
updatedAgent = { ...updatedAgent, workspace: updatedWorkspace };
agentChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") workspace → ${updatedWorkspace}`,
);
}
const sandbox = agent.sandbox;
if (sandbox) {
let updatedSandbox = sandbox;
let sandboxChanged = false;
const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(
sandbox.workspaceRoot,
);
if (
updatedWorkspaceRoot &&
updatedWorkspaceRoot !== sandbox.workspaceRoot
) {
updatedSandbox = {
...updatedSandbox,
workspaceRoot: updatedWorkspaceRoot,
};
sandboxChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") sandbox.workspaceRoot → ${updatedWorkspaceRoot}`,
);
}
const dockerImage = sandbox.docker?.image;
const updatedDockerImage = replaceLegacyName(dockerImage);
if (updatedDockerImage && updatedDockerImage !== dockerImage) {
updatedSandbox = {
...updatedSandbox,
docker: {
...updatedSandbox.docker,
image: updatedDockerImage,
},
};
sandboxChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") sandbox.docker.image → ${updatedDockerImage}`,
);
}
const containerPrefix = sandbox.docker?.containerPrefix;
const updatedContainerPrefix = replaceLegacyName(containerPrefix);
if (
updatedContainerPrefix &&
updatedContainerPrefix !== containerPrefix
) {
updatedSandbox = {
...updatedSandbox,
docker: {
...updatedSandbox.docker,
containerPrefix: updatedContainerPrefix,
},
};
sandboxChanged = true;
changes.push(
`Updated agents.list (id "${agent.id}") sandbox.docker.containerPrefix → ${updatedContainerPrefix}`,
);
}
if (sandboxChanged) {
updatedAgent = { ...updatedAgent, sandbox: updatedSandbox };
agentChanged = true;
}
}
if (agentChanged) listChanged = true;
return agentChanged ? updatedAgent : agent;
});
if (listChanged) {
next = {
...next,
agents: {
...next.agents,
list: nextList,
},
};
}
}
return { config: next, changes };
@@ -170,18 +279,40 @@ export async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
typeof (legacySnapshot.parsed as ClawdbotConfig)?.gateway?.bind === "string"
? (legacySnapshot.parsed as ClawdbotConfig).gateway?.bind
: undefined;
const agentWorkspace =
typeof (legacySnapshot.parsed as ClawdbotConfig)?.agent?.workspace ===
"string"
? (legacySnapshot.parsed as ClawdbotConfig).agent?.workspace
const parsed = legacySnapshot.parsed as Record<string, unknown>;
const parsedAgents =
parsed.agents && typeof parsed.agents === "object"
? (parsed.agents as Record<string, unknown>)
: undefined;
const parsedDefaults =
parsedAgents?.defaults && typeof parsedAgents.defaults === "object"
? (parsedAgents.defaults as Record<string, unknown>)
: undefined;
const parsedLegacyAgent =
parsed.agent && typeof parsed.agent === "object"
? (parsed.agent as Record<string, unknown>)
: undefined;
const defaultWorkspace =
typeof parsedDefaults?.workspace === "string"
? parsedDefaults.workspace
: undefined;
const legacyWorkspace =
typeof parsedLegacyAgent?.workspace === "string"
? parsedLegacyAgent.workspace
: undefined;
const agentWorkspace = defaultWorkspace ?? legacyWorkspace;
const workspaceLabel = defaultWorkspace
? "agents.defaults.workspace"
: legacyWorkspace
? "agent.workspace"
: "agents.defaults.workspace";
note(
[
`- File exists at ${legacyConfigPath}`,
gatewayMode ? `- gateway.mode: ${gatewayMode}` : undefined,
gatewayBind ? `- gateway.bind: ${gatewayBind}` : undefined,
agentWorkspace ? `- agent.workspace: ${agentWorkspace}` : undefined,
agentWorkspace ? `- ${workspaceLabel}: ${agentWorkspace}` : undefined,
]
.filter(Boolean)
.join("\n"),

View File

@@ -96,12 +96,12 @@ async function dockerImageExists(image: string): Promise<boolean> {
}
function resolveSandboxDockerImage(cfg: ClawdbotConfig): string {
const image = cfg.agent?.sandbox?.docker?.image?.trim();
const image = cfg.agents?.defaults?.sandbox?.docker?.image?.trim();
return image ? image : DEFAULT_SANDBOX_IMAGE;
}
function resolveSandboxBrowserImage(cfg: ClawdbotConfig): string {
const image = cfg.agent?.sandbox?.browser?.image?.trim();
const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim();
return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE;
}
@@ -111,13 +111,16 @@ function updateSandboxDockerImage(
): ClawdbotConfig {
return {
...cfg,
agent: {
...cfg.agent,
sandbox: {
...cfg.agent?.sandbox,
docker: {
...cfg.agent?.sandbox?.docker,
image,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
sandbox: {
...cfg.agents?.defaults?.sandbox,
docker: {
...cfg.agents?.defaults?.sandbox?.docker,
image,
},
},
},
},
@@ -130,13 +133,16 @@ function updateSandboxBrowserImage(
): ClawdbotConfig {
return {
...cfg,
agent: {
...cfg.agent,
sandbox: {
...cfg.agent?.sandbox,
browser: {
...cfg.agent?.sandbox?.browser,
image,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
sandbox: {
...cfg.agents?.defaults?.sandbox,
browser: {
...cfg.agents?.defaults?.sandbox?.browser,
image,
},
},
},
},
@@ -198,7 +204,7 @@ export async function maybeRepairSandboxImages(
runtime: RuntimeEnv,
prompter: DoctorPrompter,
): Promise<ClawdbotConfig> {
const sandbox = cfg.agent?.sandbox;
const sandbox = cfg.agents?.defaults?.sandbox;
const mode = sandbox?.mode ?? "off";
if (!sandbox || mode === "off") return cfg;
@@ -224,7 +230,7 @@ export async function maybeRepairSandboxImages(
: undefined,
updateConfig: (image) => {
next = updateSandboxDockerImage(next, image);
changes.push(`Updated agent.sandbox.docker.image → ${image}`);
changes.push(`Updated agents.defaults.sandbox.docker.image → ${image}`);
},
},
runtime,
@@ -239,7 +245,9 @@ export async function maybeRepairSandboxImages(
buildScript: "scripts/sandbox-browser-setup.sh",
updateConfig: (image) => {
next = updateSandboxBrowserImage(next, image);
changes.push(`Updated agent.sandbox.browser.image → ${image}`);
changes.push(
`Updated agents.defaults.sandbox.browser.image → ${image}`,
);
},
},
runtime,
@@ -255,11 +263,12 @@ export async function maybeRepairSandboxImages(
}
export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) {
const globalSandbox = cfg.agent?.sandbox;
const agents = cfg.routing?.agents ?? {};
const globalSandbox = cfg.agents?.defaults?.sandbox;
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const warnings: string[] = [];
for (const [agentId, agent] of Object.entries(agents)) {
for (const agent of agents) {
const agentId = agent.id;
const agentSandbox = agent.sandbox;
if (!agentSandbox) continue;
@@ -284,7 +293,7 @@ export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) {
if (overrides.length === 0) continue;
warnings.push(
`- routing.agents.${agentId}.sandbox: ${overrides.join(
`- agents.list (id "${agentId}") sandbox ${overrides.join(
"/",
)} overrides ignored (scope resolves to "shared").`,
);

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