mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 16:21:26 +00:00
Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
69
src/agents/identity.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
229
src/agents/schema/clean-for-gemini.ts
Normal file
229
src/agents/schema/clean-for-gemini.ts
Normal 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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
|
||||
@@ -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: ["*"],
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
21
src/auto-reply/reply/debug-commands.test.ts
Normal file
21
src/auto-reply/reply/debug-commands.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
99
src/auto-reply/reply/debug-commands.ts
Normal file
99
src/auto-reply/reply/debug-commands.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
49
src/auto-reply/reply/normalize-reply.ts
Normal file
49
src/auto-reply/reply/normalize-reply.ts
Normal 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 };
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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-" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export type OriginatingChannelType =
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "whatsapp"
|
||||
| "webchat";
|
||||
| "webchat"
|
||||
| "msteams";
|
||||
|
||||
export type MsgContext = {
|
||||
Body?: string;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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([\\/]|$)/,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
132
src/cli/sandbox-cli.ts
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user