mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-30 21:01:11 +03:00
Merge branch 'main' into fix/imessage-groupish-threads
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)
|
||||
|
||||
+58
-43
@@ -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}`);
|
||||
|
||||
@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
|
||||
export type AuthProfileHealth = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
type: "oauth" | "api_key";
|
||||
type: "oauth" | "token" | "api_key";
|
||||
status: AuthProfileHealthStatus;
|
||||
expiresAt?: number;
|
||||
remainingMs?: number;
|
||||
@@ -109,6 +109,39 @@ function buildProfileHealth(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (credential.type === "token") {
|
||||
const expiresAt =
|
||||
typeof credential.expires === "number" &&
|
||||
Number.isFinite(credential.expires)
|
||||
? credential.expires
|
||||
: undefined;
|
||||
if (!expiresAt || expiresAt <= 0) {
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
type: "token",
|
||||
status: "static",
|
||||
source,
|
||||
label,
|
||||
};
|
||||
}
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
expiresAt,
|
||||
now,
|
||||
warnAfterMs,
|
||||
);
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
type: "token",
|
||||
status,
|
||||
expiresAt,
|
||||
remainingMs,
|
||||
source,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
credential.expires,
|
||||
now,
|
||||
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
|
||||
}
|
||||
|
||||
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
|
||||
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
|
||||
const apiKeyProfiles = provider.profiles.filter(
|
||||
(p) => p.type === "api_key",
|
||||
);
|
||||
|
||||
if (oauthProfiles.length === 0) {
|
||||
const expirable = [...oauthProfiles, ...tokenProfiles];
|
||||
if (expirable.length === 0) {
|
||||
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
|
||||
continue;
|
||||
}
|
||||
|
||||
const expiryCandidates = oauthProfiles
|
||||
const expiryCandidates = expirable
|
||||
.map((p) => p.expiresAt)
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||
if (expiryCandidates.length > 0) {
|
||||
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
|
||||
provider.remainingMs = provider.expiresAt - now;
|
||||
}
|
||||
|
||||
const statuses = oauthProfiles.map((p) => p.status);
|
||||
const statuses = expirable.map((p) => p.status);
|
||||
if (statuses.includes("expired") || statuses.includes("missing")) {
|
||||
provider.status = "expired";
|
||||
} else if (statuses.includes("expiring")) {
|
||||
|
||||
+251
-231
@@ -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,260 +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 { access: string }).access,
|
||||
).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 OAuth 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: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "store-access",
|
||||
refresh: "store-refresh",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
|
||||
).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 });
|
||||
}
|
||||
});
|
||||
|
||||
+206
-35
@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type TokenCredential = {
|
||||
/**
|
||||
* Static bearer-style token (often OAuth access token / PAT).
|
||||
* Not refreshable by clawdbot (unlike `type: "oauth"`).
|
||||
*/
|
||||
type: "token";
|
||||
provider: string;
|
||||
token: string;
|
||||
/** Optional expiry timestamp (ms since epoch). */
|
||||
expires?: number;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type OAuthCredential = OAuthCredentials & {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
|
||||
export type AuthProfileCredential =
|
||||
| ApiKeyCredential
|
||||
| TokenCredential
|
||||
| OAuthCredential;
|
||||
|
||||
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
||||
export type ProfileUsageStats = {
|
||||
@@ -66,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>;
|
||||
@@ -117,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;
|
||||
}
|
||||
@@ -220,7 +243,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: typed.provider ?? (key as OAuthProvider),
|
||||
@@ -238,13 +267,35 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!typed.provider) continue;
|
||||
normalized[key] = typed as AuthProfileCredential;
|
||||
}
|
||||
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>)
|
||||
@@ -285,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
*/
|
||||
function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
}): OAuthCredential | null {
|
||||
}): TokenCredential | null {
|
||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) {
|
||||
@@ -306,18 +357,15 @@ function readClaudeCliCredentials(options?: {
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -326,7 +374,7 @@ function readClaudeCliCredentials(options?: {
|
||||
* Read Claude Code credentials from macOS keychain.
|
||||
* Uses the `security` CLI to access keychain without native dependencies.
|
||||
*/
|
||||
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
||||
function readClaudeCliKeychainCredentials(): TokenCredential | null {
|
||||
try {
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w',
|
||||
@@ -338,18 +386,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
@@ -416,6 +461,20 @@ function shallowEqualOAuthCredentials(
|
||||
);
|
||||
}
|
||||
|
||||
function shallowEqualTokenCredentials(
|
||||
a: TokenCredential | undefined,
|
||||
b: TokenCredential,
|
||||
): boolean {
|
||||
if (!a) return false;
|
||||
if (a.type !== "token") return false;
|
||||
return (
|
||||
a.provider === b.provider &&
|
||||
a.token === b.token &&
|
||||
a.expires === b.expires &&
|
||||
a.email === b.email
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
|
||||
* This allows clawdbot to use the same credentials as these tools without requiring
|
||||
@@ -434,25 +493,28 @@ function syncExternalCliCredentials(
|
||||
const claudeCreds = readClaudeCliCredentials(options);
|
||||
if (claudeCreds) {
|
||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
const existingToken = existing?.type === "token" ? existing : undefined;
|
||||
|
||||
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== "anthropic" ||
|
||||
existingOAuth.expires <= now ||
|
||||
(claudeCreds.expires > now &&
|
||||
claudeCreds.expires > existingOAuth.expires);
|
||||
!existingToken ||
|
||||
existingToken.provider !== "anthropic" ||
|
||||
(existingToken.expires ?? 0) <= now ||
|
||||
((claudeCreds.expires ?? 0) > now &&
|
||||
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
|
||||
|
||||
if (
|
||||
shouldUpdate &&
|
||||
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
|
||||
!shallowEqualTokenCredentials(existingToken, claudeCreds)
|
||||
) {
|
||||
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
||||
mutated = true;
|
||||
log.info("synced anthropic credentials from claude cli", {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
expires: new Date(claudeCreds.expires).toISOString(),
|
||||
expires:
|
||||
typeof claudeCreds.expires === "number"
|
||||
? new Date(claudeCreds.expires).toISOString()
|
||||
: "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -515,6 +577,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
@@ -570,6 +642,16 @@ export function ensureAuthProfileStore(
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
@@ -621,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;
|
||||
@@ -804,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;
|
||||
@@ -812,6 +937,7 @@ export function resolveAuthProfileOrder(params: {
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const explicitOrder = storedOrder ?? configuredOrder;
|
||||
const explicitProfiles = cfg?.auth?.profiles
|
||||
? Object.entries(cfg.auth.profiles)
|
||||
.filter(
|
||||
@@ -821,7 +947,7 @@ export function resolveAuthProfileOrder(params: {
|
||||
.map(([profileId]) => profileId)
|
||||
: [];
|
||||
const baseOrder =
|
||||
configuredOrder ??
|
||||
explicitOrder ??
|
||||
(explicitProfiles.length > 0
|
||||
? explicitProfiles
|
||||
: listProfilesForProvider(store, providerKey));
|
||||
@@ -836,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)
|
||||
@@ -882,16 +1036,17 @@ function orderProfilesByMode(
|
||||
// Then by lastUsed (oldest first = round-robin within type)
|
||||
const scored = available.map((profileId) => {
|
||||
const type = store.profiles[profileId]?.type;
|
||||
const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
|
||||
const typeScore =
|
||||
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
|
||||
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
|
||||
return { profileId, typeScore, lastUsed };
|
||||
});
|
||||
|
||||
// Primary sort: type preference (oauth > api_key).
|
||||
// Primary sort: type preference (oauth > token > api_key).
|
||||
// Secondary sort: lastUsed (oldest first for round-robin within type).
|
||||
const sorted = scored
|
||||
.sort((a, b) => {
|
||||
// First by type (oauth > api_key)
|
||||
// First by type (oauth > token > api_key)
|
||||
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
|
||||
// Then by lastUsed (oldest first)
|
||||
return a.lastUsed - b.lastUsed;
|
||||
@@ -921,11 +1076,27 @@ export async function resolveApiKeyForProfile(params: {
|
||||
if (!cred) return null;
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) {
|
||||
// Compatibility: treat "oauth" config as compatible with stored token profiles.
|
||||
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
|
||||
}
|
||||
|
||||
if (cred.type === "api_key") {
|
||||
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
|
||||
}
|
||||
if (cred.type === "token") {
|
||||
const token = cred.token?.trim();
|
||||
if (!token) return null;
|
||||
if (
|
||||
typeof cred.expires === "number" &&
|
||||
Number.isFinite(cred.expires) &&
|
||||
cred.expires > 0 &&
|
||||
Date.now() >= cred.expires
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey: token, provider: cred.provider, email: cred.email };
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
@@ -1016,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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createBrowserTool } from "./tools/browser-tool.js";
|
||||
import { createCanvasTool } from "./tools/canvas-tool.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { createCronTool } from "./tools/cron-tool.js";
|
||||
import { createDiscordTool } from "./tools/discord-tool.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createImageTool } from "./tools/image-tool.js";
|
||||
import { createMessageTool } from "./tools/message-tool.js";
|
||||
@@ -13,9 +12,6 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
import { createSlackTool } from "./tools/slack-tool.js";
|
||||
import { createTelegramTool } from "./tools/telegram-tool.js";
|
||||
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
|
||||
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
@@ -35,14 +31,10 @@ export function createClawdbotTools(options?: {
|
||||
createCanvasTool(),
|
||||
createNodesTool(),
|
||||
createCronTool(),
|
||||
createDiscordTool(),
|
||||
createMessageTool(),
|
||||
createSlackTool({
|
||||
createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
config: options?.config,
|
||||
}),
|
||||
createTelegramTool(),
|
||||
createWhatsAppTool(),
|
||||
createGatewayTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
|
||||
}
|
||||
|
||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
||||
export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
|
||||
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
|
||||
|
||||
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
@@ -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",
|
||||
};
|
||||
@@ -158,10 +159,14 @@ export function resolveModelAuthMode(
|
||||
const modes = new Set(
|
||||
profiles
|
||||
.map((id) => authStore.profiles[id]?.type)
|
||||
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
|
||||
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
|
||||
);
|
||||
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
|
||||
const distinct = ["oauth", "token", "api_key"].filter((k) =>
|
||||
modes.has(k as "oauth" | "token" | "api_key"),
|
||||
);
|
||||
if (distinct.length >= 2) return "mixed";
|
||||
if (modes.has("oauth")) return "oauth";
|
||||
if (modes.has("token")) return "token";
|
||||
if (modes.has("api_key")) return "api-key";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -167,6 +167,117 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("promotes <think> tags to thinking blocks at write-time", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onBlockReply = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run",
|
||||
onBlockReply,
|
||||
blockReplyBreak: "message_end",
|
||||
reasoningMode: "on",
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
|
||||
},
|
||||
],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
expect(onBlockReply.mock.calls[0][0].text).toBe(
|
||||
"_Reasoning:_\n_Because it helps_\n\nFinal answer",
|
||||
);
|
||||
|
||||
expect(assistantMessage.content).toEqual([
|
||||
{ type: "thinking", thinking: "Because it helps" },
|
||||
{ type: "text", text: "Final answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("streams <think> reasoning via onReasoningStream without leaking into final text", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onReasoningStream = vi.fn();
|
||||
const onBlockReply = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run",
|
||||
onReasoningStream,
|
||||
onBlockReply,
|
||||
blockReplyBreak: "message_end",
|
||||
reasoningMode: "stream",
|
||||
});
|
||||
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta: "<think>\nBecause",
|
||||
},
|
||||
});
|
||||
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta: " it helps\n</think>\n\nFinal answer",
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
|
||||
},
|
||||
],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer");
|
||||
|
||||
const streamTexts = onReasoningStream.mock.calls
|
||||
.map((call) => call[0]?.text)
|
||||
.filter((value): value is string => typeof value === "string");
|
||||
expect(streamTexts.at(-1)).toBe("Reasoning:\nBecause it helps");
|
||||
|
||||
expect(assistantMessage.content).toEqual([
|
||||
{ type: "thinking", thinking: "Because it helps" },
|
||||
{ type: "text", text: "Final answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits block replies on text_end and does not duplicate on message_end", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -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";
|
||||
@@ -24,6 +25,7 @@ const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||
const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi;
|
||||
const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi;
|
||||
const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
|
||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||
const log = createSubsystemLogger("agent/embedded");
|
||||
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
|
||||
@@ -63,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 {
|
||||
@@ -121,6 +123,96 @@ function stripUnpairedThinkingTags(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
type ThinkTaggedSplitBlock =
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "text"; text: string };
|
||||
|
||||
function splitThinkingTaggedText(text: string): ThinkTaggedSplitBlock[] | null {
|
||||
const trimmedStart = text.trimStart();
|
||||
// Avoid false positives: only treat it as structured thinking when it begins
|
||||
// with a think tag (common for local/OpenAI-compat providers that emulate
|
||||
// reasoning blocks via tags).
|
||||
if (!trimmedStart.startsWith("<")) return null;
|
||||
if (!THINKING_OPEN_RE.test(trimmedStart)) return null;
|
||||
if (!THINKING_CLOSE_RE.test(text)) return null;
|
||||
|
||||
THINKING_TAG_SCAN_RE.lastIndex = 0;
|
||||
let inThinking = false;
|
||||
let cursor = 0;
|
||||
let thinkingStart = 0;
|
||||
const blocks: ThinkTaggedSplitBlock[] = [];
|
||||
|
||||
const pushText = (value: string) => {
|
||||
if (!value) return;
|
||||
blocks.push({ type: "text", text: value });
|
||||
};
|
||||
const pushThinking = (value: string) => {
|
||||
const cleaned = value.trim();
|
||||
if (!cleaned) return;
|
||||
blocks.push({ type: "thinking", thinking: cleaned });
|
||||
};
|
||||
|
||||
for (const match of text.matchAll(THINKING_TAG_SCAN_RE)) {
|
||||
const index = match.index ?? 0;
|
||||
const isClose = Boolean(match[1]?.includes("/"));
|
||||
|
||||
if (!inThinking && !isClose) {
|
||||
pushText(text.slice(cursor, index));
|
||||
thinkingStart = index + match[0].length;
|
||||
inThinking = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inThinking && isClose) {
|
||||
pushThinking(text.slice(thinkingStart, index));
|
||||
cursor = index + match[0].length;
|
||||
inThinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inThinking) return null;
|
||||
pushText(text.slice(cursor));
|
||||
|
||||
const hasThinking = blocks.some((b) => b.type === "thinking");
|
||||
if (!hasThinking) return null;
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function promoteThinkingTagsToBlocks(message: AssistantMessage): void {
|
||||
if (!Array.isArray(message.content)) return;
|
||||
const hasThinkingBlock = message.content.some(
|
||||
(block) => block.type === "thinking",
|
||||
);
|
||||
if (hasThinkingBlock) return;
|
||||
|
||||
const next: AssistantMessage["content"] = [];
|
||||
let changed = false;
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "text") {
|
||||
next.push(block);
|
||||
continue;
|
||||
}
|
||||
const split = splitThinkingTaggedText(block.text);
|
||||
if (!split) {
|
||||
next.push(block);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
for (const part of split) {
|
||||
if (part.type === "thinking") {
|
||||
next.push({ type: "thinking", thinking: part.thinking });
|
||||
} else if (part.type === "text") {
|
||||
const cleaned = part.text.trimStart();
|
||||
if (cleaned) next.push({ type: "text", text: cleaned });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
message.content = next;
|
||||
}
|
||||
|
||||
function normalizeSlackTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
@@ -792,6 +884,7 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
|
||||
if (msg?.role === "assistant") {
|
||||
const assistantMessage = msg as AssistantMessage;
|
||||
promoteThinkingTagsToBlocks(assistantMessage);
|
||||
const rawText = extractAssistantText(assistantMessage);
|
||||
appendRawStream({
|
||||
ts: Date.now(),
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+88
-34
@@ -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,9 +64,38 @@ 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 = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
||||
const toolNames = [
|
||||
"browser",
|
||||
"canvas",
|
||||
"nodes",
|
||||
"cron",
|
||||
"gateway",
|
||||
"message",
|
||||
];
|
||||
|
||||
const collectActionValues = (
|
||||
schema: unknown,
|
||||
@@ -134,36 +163,13 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("scopes discord tool to discord provider", () => {
|
||||
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "discord")).toBe(false);
|
||||
|
||||
const discord = createClawdbotCodingTools({ messageProvider: "discord" });
|
||||
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes slack tool to slack provider", () => {
|
||||
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "slack")).toBe(false);
|
||||
|
||||
const slack = createClawdbotCodingTools({ messageProvider: "slack" });
|
||||
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes telegram tool to telegram provider", () => {
|
||||
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "telegram")).toBe(false);
|
||||
|
||||
const telegram = createClawdbotCodingTools({ messageProvider: "telegram" });
|
||||
expect(telegram.some((tool) => tool.name === "telegram")).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes whatsapp tool to whatsapp provider", () => {
|
||||
const other = createClawdbotCodingTools({ messageProvider: "slack" });
|
||||
expect(other.some((tool) => tool.name === "whatsapp")).toBe(false);
|
||||
|
||||
const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||
expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true);
|
||||
it("does not expose provider-specific message tools", () => {
|
||||
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("discord")).toBe(false);
|
||||
expect(names.has("slack")).toBe(false);
|
||||
expect(names.has("telegram")).toBe(false);
|
||||
expect(names.has("whatsapp")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters session tools for sub-agent sessions by default", () => {
|
||||
@@ -187,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
|
||||
@@ -341,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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+12
-167
@@ -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,36 +496,9 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessageProvider(
|
||||
messageProvider?: string,
|
||||
): string | undefined {
|
||||
const trimmed = messageProvider?.trim().toLowerCase();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
|
||||
const normalized = normalizeMessageProvider(messageProvider);
|
||||
if (!normalized) return false;
|
||||
return normalized === "discord" || normalized.startsWith("discord:");
|
||||
}
|
||||
|
||||
function shouldIncludeSlackTool(messageProvider?: string): boolean {
|
||||
const normalized = normalizeMessageProvider(messageProvider);
|
||||
if (!normalized) return false;
|
||||
return normalized === "slack" || normalized.startsWith("slack:");
|
||||
}
|
||||
|
||||
function shouldIncludeTelegramTool(messageProvider?: string): boolean {
|
||||
const normalized = normalizeMessageProvider(messageProvider);
|
||||
if (!normalized) return false;
|
||||
return normalized === "telegram" || normalized.startsWith("telegram:");
|
||||
}
|
||||
|
||||
function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
|
||||
const normalized = normalizeMessageProvider(messageProvider);
|
||||
if (!normalized) return false;
|
||||
return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
|
||||
}
|
||||
export const __testing = {
|
||||
cleanToolSchemaForGemini,
|
||||
} as const;
|
||||
|
||||
export function createClawdbotCodingTools(options?: {
|
||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||
@@ -724,20 +580,9 @@ export function createClawdbotCodingTools(options?: {
|
||||
config: options?.config,
|
||||
}),
|
||||
];
|
||||
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
|
||||
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
|
||||
const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider);
|
||||
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
|
||||
const filtered = tools.filter((tool) => {
|
||||
if (tool.name === "discord") return allowDiscord;
|
||||
if (tool.name === "slack") return allowSlack;
|
||||
if (tool.name === "telegram") return allowTelegram;
|
||||
if (tool.name === "whatsapp") return allowWhatsApp;
|
||||
return true;
|
||||
});
|
||||
const toolsFiltered = effectiveToolsPolicy
|
||||
? filterToolsByPolicy(filtered, effectiveToolsPolicy)
|
||||
: filtered;
|
||||
? filterToolsByPolicy(tools, effectiveToolsPolicy)
|
||||
: tools;
|
||||
const sandboxed = sandbox
|
||||
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
|
||||
: toolsFiltered;
|
||||
|
||||
@@ -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");
|
||||
|
||||
+146
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -222,6 +222,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
||||
"- [[reply_to_current]] replies to the triggering message.",
|
||||
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
|
||||
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
|
||||
"Tags are stripped before sending; support depends on the current provider config.",
|
||||
"",
|
||||
"## Messaging",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -154,8 +154,37 @@
|
||||
"emoji": "✉️",
|
||||
"title": "Message",
|
||||
"actions": {
|
||||
"send": { "label": "send", "detailKeys": ["to", "provider", "mediaUrl"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["to", "provider", "question"] }
|
||||
"send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] },
|
||||
"react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] },
|
||||
"read": { "label": "read", "detailKeys": ["provider", "to", "limit"] },
|
||||
"edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] },
|
||||
"delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] },
|
||||
"pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] },
|
||||
"unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] },
|
||||
"list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] },
|
||||
"permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] },
|
||||
"thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] },
|
||||
"thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] },
|
||||
"thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] },
|
||||
"search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] },
|
||||
"sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] },
|
||||
"member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] },
|
||||
"role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] },
|
||||
"emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] },
|
||||
"emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] },
|
||||
"sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] },
|
||||
"role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
|
||||
"role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
|
||||
"channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] },
|
||||
"channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] },
|
||||
"voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] },
|
||||
"event-list": { "label": "events", "detailKeys": ["provider", "guildId"] },
|
||||
"event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] },
|
||||
"timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] },
|
||||
"kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] },
|
||||
"ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] }
|
||||
}
|
||||
},
|
||||
"agents_list": {
|
||||
@@ -190,77 +219,6 @@
|
||||
"start": { "label": "start" },
|
||||
"wait": { "label": "wait" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"emoji": "💬",
|
||||
"title": "Discord",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
|
||||
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
|
||||
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
|
||||
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
||||
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
||||
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
|
||||
"emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
|
||||
"stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
|
||||
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
|
||||
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
|
||||
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
|
||||
"eventList": { "label": "events", "detailKeys": ["guildId"] },
|
||||
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
|
||||
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
|
||||
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
|
||||
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"emoji": "💬",
|
||||
"title": "Slack",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
|
||||
"emojiList": { "label": "emoji list" }
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"emoji": "✈️",
|
||||
"title": "Telegram",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] }
|
||||
}
|
||||
},
|
||||
"whatsapp": {
|
||||
"emoji": "💬",
|
||||
"title": "WhatsApp",
|
||||
"actions": {
|
||||
"react": {
|
||||
"label": "react",
|
||||
"detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
createScheduledEventDiscord,
|
||||
deleteChannelDiscord,
|
||||
editChannelDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchMemberInfoDiscord,
|
||||
fetchRoleInfoDiscord,
|
||||
@@ -10,17 +13,28 @@ import {
|
||||
listGuildChannelsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listScheduledEventsDiscord,
|
||||
moveChannelDiscord,
|
||||
removeChannelPermissionDiscord,
|
||||
removeRoleDiscord,
|
||||
setChannelPermissionDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} from "../../discord/send.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
|
||||
function readParentIdParam(
|
||||
params: Record<string, unknown>,
|
||||
): string | null | undefined {
|
||||
if (params.parentId === null) return null;
|
||||
return readStringParam(params, "parentId");
|
||||
}
|
||||
|
||||
export async function handleDiscordGuildAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
@@ -207,6 +221,157 @@ export async function handleDiscordGuildAction(
|
||||
const event = await createScheduledEventDiscord(guildId, payload);
|
||||
return jsonResult({ ok: true, event });
|
||||
}
|
||||
case "channelCreate": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const type = readNumberParam(params, "type", { integer: true });
|
||||
const parentId = readParentIdParam(params);
|
||||
const topic = readStringParam(params, "topic");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const nsfw = params.nsfw as boolean | undefined;
|
||||
const channel = await createChannelDiscord({
|
||||
guildId,
|
||||
name,
|
||||
type: type ?? undefined,
|
||||
parentId: parentId ?? undefined,
|
||||
topic: topic ?? undefined,
|
||||
position: position ?? undefined,
|
||||
nsfw,
|
||||
});
|
||||
return jsonResult({ ok: true, channel });
|
||||
}
|
||||
case "channelEdit": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name");
|
||||
const topic = readStringParam(params, "topic");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const parentId = readParentIdParam(params);
|
||||
const nsfw = params.nsfw as boolean | undefined;
|
||||
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
||||
integer: true,
|
||||
});
|
||||
const channel = await editChannelDiscord({
|
||||
channelId,
|
||||
name: name ?? undefined,
|
||||
topic: topic ?? undefined,
|
||||
position: position ?? undefined,
|
||||
parentId: parentId === undefined ? undefined : parentId,
|
||||
nsfw,
|
||||
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, channel });
|
||||
}
|
||||
case "channelDelete": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const result = await deleteChannelDiscord(channelId);
|
||||
return jsonResult(result);
|
||||
}
|
||||
case "channelMove": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const parentId = readParentIdParam(params);
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
await moveChannelDiscord({
|
||||
guildId,
|
||||
channelId,
|
||||
parentId: parentId === undefined ? undefined : parentId,
|
||||
position: position ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "categoryCreate": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const channel = await createChannelDiscord({
|
||||
guildId,
|
||||
name,
|
||||
type: 4,
|
||||
position: position ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, category: channel });
|
||||
}
|
||||
case "categoryEdit": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const categoryId = readStringParam(params, "categoryId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const channel = await editChannelDiscord({
|
||||
channelId: categoryId,
|
||||
name: name ?? undefined,
|
||||
position: position ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, category: channel });
|
||||
}
|
||||
case "categoryDelete": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const categoryId = readStringParam(params, "categoryId", {
|
||||
required: true,
|
||||
});
|
||||
const result = await deleteChannelDiscord(categoryId);
|
||||
return jsonResult(result);
|
||||
}
|
||||
case "channelPermissionSet": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const targetId = readStringParam(params, "targetId", { required: true });
|
||||
const targetTypeRaw = readStringParam(params, "targetType", {
|
||||
required: true,
|
||||
});
|
||||
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
||||
const allow = readStringParam(params, "allow");
|
||||
const deny = readStringParam(params, "deny");
|
||||
await setChannelPermissionDiscord({
|
||||
channelId,
|
||||
targetId,
|
||||
targetType,
|
||||
allow: allow ?? undefined,
|
||||
deny: deny ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "channelPermissionRemove": {
|
||||
if (!isActionEnabled("channels", false)) {
|
||||
throw new Error("Discord channel management is disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const targetId = readStringParam(params, "targetId", { required: true });
|
||||
await removeChannelPermissionDiscord(channelId, targetId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||
|
||||
const createChannelDiscord = vi.fn(async () => ({
|
||||
id: "new-channel",
|
||||
name: "test",
|
||||
type: 0,
|
||||
}));
|
||||
const createThreadDiscord = vi.fn(async () => ({}));
|
||||
const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" }));
|
||||
const deleteMessageDiscord = vi.fn(async () => ({}));
|
||||
const editChannelDiscord = vi.fn(async () => ({
|
||||
id: "C1",
|
||||
name: "edited",
|
||||
}));
|
||||
const editMessageDiscord = vi.fn(async () => ({}));
|
||||
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
|
||||
const fetchReactionsDiscord = vi.fn(async () => ({}));
|
||||
const listPinsDiscord = vi.fn(async () => ({}));
|
||||
const listThreadsDiscord = vi.fn(async () => ({}));
|
||||
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
|
||||
const pinMessageDiscord = vi.fn(async () => ({}));
|
||||
const reactMessageDiscord = vi.fn(async () => ({}));
|
||||
const readMessagesDiscord = vi.fn(async () => []);
|
||||
const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
|
||||
const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
|
||||
const removeReactionDiscord = vi.fn(async () => ({}));
|
||||
const searchMessagesDiscord = vi.fn(async () => ({}));
|
||||
const sendMessageDiscord = vi.fn(async () => ({}));
|
||||
const sendPollDiscord = vi.fn(async () => ({}));
|
||||
const sendStickerDiscord = vi.fn(async () => ({}));
|
||||
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
|
||||
const unpinMessageDiscord = vi.fn(async () => ({}));
|
||||
|
||||
vi.mock("../../discord/send.js", () => ({
|
||||
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
|
||||
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
|
||||
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
|
||||
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
|
||||
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
|
||||
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
|
||||
fetchChannelPermissionsDiscord: (...args: unknown[]) =>
|
||||
fetchChannelPermissionsDiscord(...args),
|
||||
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
|
||||
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
|
||||
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
|
||||
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
|
||||
pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args),
|
||||
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
|
||||
readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args),
|
||||
removeChannelPermissionDiscord: (...args: unknown[]) =>
|
||||
removeChannelPermissionDiscord(...args),
|
||||
removeOwnReactionsDiscord: (...args: unknown[]) =>
|
||||
removeOwnReactionsDiscord(...args),
|
||||
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
|
||||
@@ -40,6 +60,8 @@ vi.mock("../../discord/send.js", () => ({
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
|
||||
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
|
||||
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
|
||||
setChannelPermissionDiscord: (...args: unknown[]) =>
|
||||
setChannelPermissionDiscord(...args),
|
||||
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
|
||||
}));
|
||||
|
||||
@@ -117,3 +139,214 @@ describe("handleDiscordMessagingAction", () => {
|
||||
).rejects.toThrow(/Discord reactions are disabled/);
|
||||
});
|
||||
});
|
||||
|
||||
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
|
||||
const channelsDisabled = () => false;
|
||||
|
||||
describe("handleDiscordGuildAction - channel management", () => {
|
||||
it("creates a channel", async () => {
|
||||
const result = await handleDiscordGuildAction(
|
||||
"channelCreate",
|
||||
{
|
||||
guildId: "G1",
|
||||
name: "test-channel",
|
||||
type: 0,
|
||||
topic: "Test topic",
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(createChannelDiscord).toHaveBeenCalledWith({
|
||||
guildId: "G1",
|
||||
name: "test-channel",
|
||||
type: 0,
|
||||
parentId: undefined,
|
||||
topic: "Test topic",
|
||||
position: undefined,
|
||||
nsfw: undefined,
|
||||
});
|
||||
expect(result.details).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("respects channel gating for channelCreate", async () => {
|
||||
await expect(
|
||||
handleDiscordGuildAction(
|
||||
"channelCreate",
|
||||
{ guildId: "G1", name: "test" },
|
||||
channelsDisabled,
|
||||
),
|
||||
).rejects.toThrow(/Discord channel management is disabled/);
|
||||
});
|
||||
|
||||
it("edits a channel", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelEdit",
|
||||
{
|
||||
channelId: "C1",
|
||||
name: "new-name",
|
||||
topic: "new topic",
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(editChannelDiscord).toHaveBeenCalledWith({
|
||||
channelId: "C1",
|
||||
name: "new-name",
|
||||
topic: "new topic",
|
||||
position: undefined,
|
||||
parentId: undefined,
|
||||
nsfw: undefined,
|
||||
rateLimitPerUser: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the channel parent when parentId is null", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelEdit",
|
||||
{
|
||||
channelId: "C1",
|
||||
parentId: null,
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(editChannelDiscord).toHaveBeenCalledWith({
|
||||
channelId: "C1",
|
||||
name: undefined,
|
||||
topic: undefined,
|
||||
position: undefined,
|
||||
parentId: null,
|
||||
nsfw: undefined,
|
||||
rateLimitPerUser: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a channel", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelDelete",
|
||||
{ channelId: "C1" },
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1");
|
||||
});
|
||||
|
||||
it("moves a channel", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelMove",
|
||||
{
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
parentId: "P1",
|
||||
position: 5,
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(moveChannelDiscord).toHaveBeenCalledWith({
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
parentId: "P1",
|
||||
position: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the channel parent on move when parentId is null", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelMove",
|
||||
{
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
parentId: null,
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(moveChannelDiscord).toHaveBeenCalledWith({
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
parentId: null,
|
||||
position: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a category with type=4", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"categoryCreate",
|
||||
{ guildId: "G1", name: "My Category" },
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(createChannelDiscord).toHaveBeenCalledWith({
|
||||
guildId: "G1",
|
||||
name: "My Category",
|
||||
type: 4,
|
||||
position: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("edits a category", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"categoryEdit",
|
||||
{ categoryId: "CAT1", name: "Renamed Category" },
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(editChannelDiscord).toHaveBeenCalledWith({
|
||||
channelId: "CAT1",
|
||||
name: "Renamed Category",
|
||||
position: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a category", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"categoryDelete",
|
||||
{ categoryId: "CAT1" },
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1");
|
||||
});
|
||||
|
||||
it("sets channel permissions for role", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelPermissionSet",
|
||||
{
|
||||
channelId: "C1",
|
||||
targetId: "R1",
|
||||
targetType: "role",
|
||||
allow: "1024",
|
||||
deny: "2048",
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
|
||||
channelId: "C1",
|
||||
targetId: "R1",
|
||||
targetType: 0,
|
||||
allow: "1024",
|
||||
deny: "2048",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets channel permissions for member", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelPermissionSet",
|
||||
{
|
||||
channelId: "C1",
|
||||
targetId: "U1",
|
||||
targetType: "member",
|
||||
allow: "1024",
|
||||
},
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
|
||||
channelId: "C1",
|
||||
targetId: "U1",
|
||||
targetType: 1,
|
||||
allow: "1024",
|
||||
deny: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes channel permissions", async () => {
|
||||
await handleDiscordGuildAction(
|
||||
"channelPermissionRemove",
|
||||
{ channelId: "C1", targetId: "R1" },
|
||||
channelsEnabled,
|
||||
);
|
||||
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,15 @@ const guildActions = new Set([
|
||||
"voiceStatus",
|
||||
"eventList",
|
||||
"eventCreate",
|
||||
"channelCreate",
|
||||
"channelEdit",
|
||||
"channelDelete",
|
||||
"channelMove",
|
||||
"categoryCreate",
|
||||
"categoryEdit",
|
||||
"categoryDelete",
|
||||
"channelPermissionSet",
|
||||
"channelPermissionRemove",
|
||||
]);
|
||||
|
||||
const moderationActions = new Set(["timeout", "kick", "ban"]);
|
||||
|
||||
@@ -202,4 +202,67 @@ export const DiscordToolSchema = Type.Union([
|
||||
reason: Type.Optional(Type.String()),
|
||||
deleteMessageDays: Type.Optional(Type.Number()),
|
||||
}),
|
||||
// Channel management actions
|
||||
Type.Object({
|
||||
action: Type.Literal("channelCreate"),
|
||||
guildId: Type.String(),
|
||||
name: Type.String(),
|
||||
type: Type.Optional(Type.Number()),
|
||||
parentId: Type.Optional(Type.String()),
|
||||
topic: Type.Optional(Type.String()),
|
||||
position: Type.Optional(Type.Number()),
|
||||
nsfw: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelEdit"),
|
||||
channelId: Type.String(),
|
||||
name: Type.Optional(Type.String()),
|
||||
topic: Type.Optional(Type.String()),
|
||||
position: Type.Optional(Type.Number()),
|
||||
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
nsfw: Type.Optional(Type.Boolean()),
|
||||
rateLimitPerUser: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelDelete"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelMove"),
|
||||
guildId: Type.String(),
|
||||
channelId: Type.String(),
|
||||
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
position: Type.Optional(Type.Number()),
|
||||
}),
|
||||
// Category management actions (convenience aliases)
|
||||
Type.Object({
|
||||
action: Type.Literal("categoryCreate"),
|
||||
guildId: Type.String(),
|
||||
name: Type.String(),
|
||||
position: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("categoryEdit"),
|
||||
categoryId: Type.String(),
|
||||
name: Type.Optional(Type.String()),
|
||||
position: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("categoryDelete"),
|
||||
categoryId: Type.String(),
|
||||
}),
|
||||
// Permission overwrite actions
|
||||
Type.Object({
|
||||
action: Type.Literal("channelPermissionSet"),
|
||||
channelId: Type.String(),
|
||||
targetId: Type.String(),
|
||||
targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]),
|
||||
allow: Type.Optional(Type.String()),
|
||||
deny: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelPermissionRemove"),
|
||||
channelId: Type.String(),
|
||||
targetId: Type.String(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -71,6 +71,9 @@ export function createGatewayTool(opts?: {
|
||||
typeof params.reason === "string" && params.reason.trim()
|
||||
? params.reason.trim().slice(0, 200)
|
||||
: undefined;
|
||||
console.info(
|
||||
`gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`,
|
||||
);
|
||||
const scheduled = scheduleGatewaySigusr1Restart({
|
||||
delayMs,
|
||||
reason,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
sendMessage,
|
||||
sendPoll,
|
||||
type MessagePollResult,
|
||||
type MessageSendResult,
|
||||
sendMessage,
|
||||
sendPoll,
|
||||
} from "../../infra/outbound/message.js";
|
||||
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import {
|
||||
jsonResult,
|
||||
@@ -13,36 +17,131 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
import { handleDiscordAction } from "./discord-actions.js";
|
||||
import { handleSlackAction } from "./slack-actions.js";
|
||||
import { handleTelegramAction } from "./telegram-actions.js";
|
||||
import { handleWhatsAppAction } from "./whatsapp-actions.js";
|
||||
|
||||
const MessageActionSchema = Type.Union([
|
||||
Type.Literal("send"),
|
||||
Type.Literal("poll"),
|
||||
Type.Literal("react"),
|
||||
Type.Literal("reactions"),
|
||||
Type.Literal("read"),
|
||||
Type.Literal("edit"),
|
||||
Type.Literal("delete"),
|
||||
Type.Literal("pin"),
|
||||
Type.Literal("unpin"),
|
||||
Type.Literal("list-pins"),
|
||||
Type.Literal("permissions"),
|
||||
Type.Literal("thread-create"),
|
||||
Type.Literal("thread-list"),
|
||||
Type.Literal("thread-reply"),
|
||||
Type.Literal("search"),
|
||||
Type.Literal("sticker"),
|
||||
Type.Literal("member-info"),
|
||||
Type.Literal("role-info"),
|
||||
Type.Literal("emoji-list"),
|
||||
Type.Literal("emoji-upload"),
|
||||
Type.Literal("sticker-upload"),
|
||||
Type.Literal("role-add"),
|
||||
Type.Literal("role-remove"),
|
||||
Type.Literal("channel-info"),
|
||||
Type.Literal("channel-list"),
|
||||
Type.Literal("voice-status"),
|
||||
Type.Literal("event-list"),
|
||||
Type.Literal("event-create"),
|
||||
Type.Literal("timeout"),
|
||||
Type.Literal("kick"),
|
||||
Type.Literal("ban"),
|
||||
]);
|
||||
|
||||
const MessageToolSchema = Type.Object({
|
||||
action: Type.Union([Type.Literal("send"), Type.Literal("poll")]),
|
||||
to: Type.Optional(Type.String()),
|
||||
content: Type.Optional(Type.String()),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
gifPlayback: Type.Optional(Type.Boolean()),
|
||||
action: MessageActionSchema,
|
||||
provider: Type.Optional(Type.String()),
|
||||
to: Type.Optional(Type.String()),
|
||||
message: Type.Optional(Type.String()),
|
||||
media: Type.Optional(Type.String()),
|
||||
messageId: Type.Optional(Type.String()),
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
threadId: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
dryRun: Type.Optional(Type.Boolean()),
|
||||
bestEffort: Type.Optional(Type.Boolean()),
|
||||
question: Type.Optional(Type.String()),
|
||||
options: Type.Optional(Type.Array(Type.String())),
|
||||
maxSelections: Type.Optional(Type.Number()),
|
||||
durationHours: Type.Optional(Type.Number()),
|
||||
gifPlayback: Type.Optional(Type.Boolean()),
|
||||
emoji: Type.Optional(Type.String()),
|
||||
remove: Type.Optional(Type.Boolean()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
before: Type.Optional(Type.String()),
|
||||
after: Type.Optional(Type.String()),
|
||||
around: Type.Optional(Type.String()),
|
||||
pollQuestion: Type.Optional(Type.String()),
|
||||
pollOption: Type.Optional(Type.Array(Type.String())),
|
||||
pollDurationHours: Type.Optional(Type.Number()),
|
||||
pollMulti: Type.Optional(Type.Boolean()),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
channelIds: Type.Optional(Type.Array(Type.String())),
|
||||
guildId: Type.Optional(Type.String()),
|
||||
userId: Type.Optional(Type.String()),
|
||||
authorId: Type.Optional(Type.String()),
|
||||
authorIds: Type.Optional(Type.Array(Type.String())),
|
||||
roleId: Type.Optional(Type.String()),
|
||||
roleIds: Type.Optional(Type.Array(Type.String())),
|
||||
emojiName: Type.Optional(Type.String()),
|
||||
stickerId: Type.Optional(Type.Array(Type.String())),
|
||||
stickerName: Type.Optional(Type.String()),
|
||||
stickerDesc: Type.Optional(Type.String()),
|
||||
stickerTags: Type.Optional(Type.String()),
|
||||
threadName: Type.Optional(Type.String()),
|
||||
autoArchiveMin: Type.Optional(Type.Number()),
|
||||
query: Type.Optional(Type.String()),
|
||||
eventName: Type.Optional(Type.String()),
|
||||
eventType: Type.Optional(Type.String()),
|
||||
startTime: Type.Optional(Type.String()),
|
||||
endTime: Type.Optional(Type.String()),
|
||||
desc: Type.Optional(Type.String()),
|
||||
location: Type.Optional(Type.String()),
|
||||
durationMin: Type.Optional(Type.Number()),
|
||||
until: Type.Optional(Type.String()),
|
||||
reason: Type.Optional(Type.String()),
|
||||
deleteDays: Type.Optional(Type.Number()),
|
||||
includeArchived: Type.Optional(Type.Boolean()),
|
||||
participant: Type.Optional(Type.String()),
|
||||
fromMe: Type.Optional(Type.Boolean()),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
export function createMessageTool(): AnyAgentTool {
|
||||
type MessageToolOptions = {
|
||||
agentAccountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
};
|
||||
|
||||
function resolveAgentAccountId(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return normalizeAccountId(trimmed);
|
||||
}
|
||||
|
||||
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
|
||||
return {
|
||||
label: "Message",
|
||||
name: "message",
|
||||
description:
|
||||
"Send messages and polls across providers (send/poll). Prefer this for general outbound messaging.",
|
||||
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).",
|
||||
parameters: MessageToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const cfg = options?.config ?? loadConfig();
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const providerSelection = await resolveMessageProviderSelection({
|
||||
cfg,
|
||||
provider: readStringParam(params, "provider"),
|
||||
});
|
||||
const provider = providerSelection.provider;
|
||||
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
||||
const gateway = {
|
||||
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||
token: readStringParam(params, "gatewayToken", { trim: false }),
|
||||
@@ -54,24 +153,80 @@ export function createMessageTool(): AnyAgentTool {
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content", {
|
||||
const message = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl", { trim: false });
|
||||
const provider = readStringParam(params, "provider");
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const gifPlayback =
|
||||
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
|
||||
const bestEffort =
|
||||
typeof params.bestEffort === "boolean" ? params.bestEffort : undefined;
|
||||
typeof params.bestEffort === "boolean"
|
||||
? params.bestEffort
|
||||
: undefined;
|
||||
|
||||
if (dryRun) {
|
||||
const result: MessageSendResult = await sendMessage({
|
||||
to,
|
||||
content: message,
|
||||
mediaUrl: mediaUrl || undefined,
|
||||
provider: provider || undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
gifPlayback,
|
||||
dryRun,
|
||||
bestEffort,
|
||||
gateway,
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content: message,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyTo: replyTo ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content: message,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
threadTs: threadId ?? replyTo ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content: message,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToMessageId: replyTo ?? undefined,
|
||||
messageThreadId: threadId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
const result: MessageSendResult = await sendMessage({
|
||||
to,
|
||||
content,
|
||||
content: message,
|
||||
mediaUrl: mediaUrl || undefined,
|
||||
provider: provider || undefined,
|
||||
accountId: accountId || undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
gifPlayback,
|
||||
dryRun,
|
||||
bestEffort,
|
||||
@@ -82,30 +237,679 @@ export function createMessageTool(): AnyAgentTool {
|
||||
|
||||
if (action === "poll") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "question", { required: true });
|
||||
const question = readStringParam(params, "pollQuestion", {
|
||||
required: true,
|
||||
});
|
||||
const options =
|
||||
readStringArrayParam(params, "options", { required: true }) ?? [];
|
||||
const maxSelections = readNumberParam(params, "maxSelections", {
|
||||
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
||||
const allowMultiselect =
|
||||
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
});
|
||||
const durationHours = readNumberParam(params, "durationHours", {
|
||||
integer: true,
|
||||
});
|
||||
const provider = readStringParam(params, "provider");
|
||||
|
||||
if (dryRun) {
|
||||
const maxSelections = allowMultiselect
|
||||
? Math.max(2, options.length)
|
||||
: 1;
|
||||
const result: MessagePollResult = await sendPoll({
|
||||
to,
|
||||
question,
|
||||
options,
|
||||
maxSelections,
|
||||
durationHours: durationHours ?? undefined,
|
||||
provider,
|
||||
dryRun,
|
||||
gateway,
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "poll",
|
||||
to,
|
||||
question,
|
||||
answers: options,
|
||||
allowMultiselect,
|
||||
durationHours: durationHours ?? undefined,
|
||||
content: readStringParam(params, "message"),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
const maxSelections = allowMultiselect
|
||||
? Math.max(2, options.length)
|
||||
: 1;
|
||||
const result: MessagePollResult = await sendPoll({
|
||||
to,
|
||||
question,
|
||||
options,
|
||||
maxSelections,
|
||||
durationHours,
|
||||
provider: provider || undefined,
|
||||
durationHours: durationHours ?? undefined,
|
||||
provider,
|
||||
dryRun,
|
||||
gateway,
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
|
||||
const resolveChannelId = (label: string) =>
|
||||
readStringParam(params, label) ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
|
||||
const resolveChatId = (label: string) =>
|
||||
readStringParam(params, label) ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove =
|
||||
typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "react",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "react",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: resolveChatId("chatId"),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "whatsapp") {
|
||||
return await handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid: resolveChatId("chatJid"),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
participant: readStringParam(params, "participant"),
|
||||
accountId: accountId ?? undefined,
|
||||
fromMe:
|
||||
typeof params.fromMe === "boolean" ? params.fromMe : undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(`React is not supported for provider ${provider}.`);
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "reactions",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
limit,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "reactions",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
limit,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Reactions are not supported for provider ${provider}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
const before = readStringParam(params, "before");
|
||||
const after = readStringParam(params, "after");
|
||||
const around = readStringParam(params, "around");
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(`Read is not supported for provider ${provider}.`);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const message = readStringParam(params, "message", { required: true });
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
content: message,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
content: message,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(`Edit is not supported for provider ${provider}.`);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
if (provider === "discord") {
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(`Delete is not supported for provider ${provider}.`);
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
const messageId =
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
const channelId = resolveChannelId("channelId");
|
||||
if (provider === "discord") {
|
||||
const discordAction =
|
||||
action === "pin"
|
||||
? "pinMessage"
|
||||
: action === "unpin"
|
||||
? "unpinMessage"
|
||||
: "listPins";
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: discordAction,
|
||||
channelId,
|
||||
messageId,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
const slackAction =
|
||||
action === "pin"
|
||||
? "pinMessage"
|
||||
: action === "unpin"
|
||||
? "unpinMessage"
|
||||
: "listPins";
|
||||
return await handleSlackAction(
|
||||
{
|
||||
action: slackAction,
|
||||
channelId,
|
||||
messageId,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(`Pins are not supported for provider ${provider}.`);
|
||||
}
|
||||
|
||||
if (action === "permissions") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Permissions are only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "permissions",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "thread-create") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Thread create is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const name = readStringParam(params, "threadName", { required: true });
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
|
||||
integer: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "threadCreate",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
name,
|
||||
messageId,
|
||||
autoArchiveMinutes,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "thread-list") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Thread list is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const includeArchived =
|
||||
typeof params.includeArchived === "boolean"
|
||||
? params.includeArchived
|
||||
: undefined;
|
||||
const before = readStringParam(params, "before");
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "threadList",
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "thread-reply") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Thread reply is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "threadReply",
|
||||
channelId: resolveChannelId("channelId"),
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyTo: replyTo ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "search") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Search is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const channelIds = readStringArrayParam(params, "channelIds");
|
||||
const authorId = readStringParam(params, "authorId");
|
||||
const authorIds = readStringArrayParam(params, "authorIds");
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "searchMessages",
|
||||
guildId,
|
||||
content: query,
|
||||
channelId,
|
||||
channelIds,
|
||||
authorId,
|
||||
authorIds,
|
||||
limit,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Sticker send is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const stickerIds =
|
||||
readStringArrayParam(params, "stickerId", {
|
||||
required: true,
|
||||
label: "sticker-id",
|
||||
}) ?? [];
|
||||
const content = readStringParam(params, "message");
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "sticker",
|
||||
to: readStringParam(params, "to", { required: true }),
|
||||
stickerIds,
|
||||
content,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
if (provider === "discord") {
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{ action: "memberInfo", guildId, userId },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Member info is not supported for provider ${provider}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "role-info") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Role info is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
|
||||
}
|
||||
|
||||
if (action === "emoji-list") {
|
||||
if (provider === "discord") {
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{ action: "emojiList", guildId },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return await handleSlackAction(
|
||||
{ action: "emojiList", accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Emoji list is not supported for provider ${provider}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "emoji-upload") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Emoji upload is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "emojiName", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", {
|
||||
required: true,
|
||||
trim: false,
|
||||
});
|
||||
const roleIds = readStringArrayParam(params, "roleIds");
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "emojiUpload",
|
||||
guildId,
|
||||
name,
|
||||
mediaUrl,
|
||||
roleIds,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker-upload") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Sticker upload is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "stickerName", { required: true });
|
||||
const description = readStringParam(params, "stickerDesc", {
|
||||
required: true,
|
||||
});
|
||||
const tags = readStringParam(params, "stickerTags", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", {
|
||||
required: true,
|
||||
trim: false,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "stickerUpload",
|
||||
guildId,
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
mediaUrl,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "role-add" || action === "role-remove") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Role changes are only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
const roleId = readStringParam(params, "roleId", { required: true });
|
||||
const discordAction = action === "role-add" ? "roleAdd" : "roleRemove";
|
||||
return await handleDiscordAction(
|
||||
{ action: discordAction, guildId, userId, roleId },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "channel-info") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Channel info is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{ action: "channelInfo", channelId },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "channel-list") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Channel list is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
return await handleDiscordAction(
|
||||
{ action: "channelList", guildId },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "voice-status") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Voice status is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleDiscordAction(
|
||||
{ action: "voiceStatus", guildId, userId },
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "event-list") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Event list is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
|
||||
}
|
||||
|
||||
if (action === "event-create") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Event create is only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "eventName", { required: true });
|
||||
const startTime = readStringParam(params, "startTime", {
|
||||
required: true,
|
||||
});
|
||||
const endTime = readStringParam(params, "endTime");
|
||||
const description = readStringParam(params, "desc");
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const location = readStringParam(params, "location");
|
||||
const entityType = readStringParam(params, "eventType");
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "eventCreate",
|
||||
guildId,
|
||||
name,
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
channelId,
|
||||
location,
|
||||
entityType,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "timeout" || action === "kick" || action === "ban") {
|
||||
if (provider !== "discord") {
|
||||
throw new Error(
|
||||
`Moderation actions are only supported for Discord (provider=${provider}).`,
|
||||
);
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
const durationMinutes = readNumberParam(params, "durationMin", {
|
||||
integer: true,
|
||||
});
|
||||
const until = readStringParam(params, "until");
|
||||
const reason = readStringParam(params, "reason");
|
||||
const deleteMessageDays = readNumberParam(params, "deleteDays", {
|
||||
integer: true,
|
||||
});
|
||||
const discordAction = action as "timeout" | "kick" | "ban";
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: discordAction,
|
||||
guildId,
|
||||
userId,
|
||||
durationMinutes,
|
||||
until,
|
||||
reason,
|
||||
deleteMessageDays,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -91,9 +91,11 @@ export async function handleSlackAction(
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content", { required: true });
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const threadTs = readStringParam(params, "threadTs");
|
||||
const result = await sendSlackMessage(to, content, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
threadTs: threadTs ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([
|
||||
to: Type.String(),
|
||||
content: Type.String(),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
threadTs: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
|
||||
@@ -37,14 +38,23 @@ describe("control command parsing", () => {
|
||||
});
|
||||
|
||||
it("treats bare commands as non-control", () => {
|
||||
expect(hasControlCommand("/send")).toBe(true);
|
||||
expect(hasControlCommand("send")).toBe(false);
|
||||
expect(hasControlCommand("/help")).toBe(true);
|
||||
expect(hasControlCommand("/help:")).toBe(true);
|
||||
expect(hasControlCommand("help")).toBe(false);
|
||||
expect(hasControlCommand("/status")).toBe(true);
|
||||
expect(hasControlCommand("/status:")).toBe(true);
|
||||
expect(hasControlCommand("/commands")).toBe(true);
|
||||
expect(hasControlCommand("/commands:")).toBe(true);
|
||||
expect(hasControlCommand("commands")).toBe(false);
|
||||
expect(hasControlCommand("/compact")).toBe(true);
|
||||
expect(hasControlCommand("/compact:")).toBe(true);
|
||||
expect(hasControlCommand("compact")).toBe(false);
|
||||
expect(hasControlCommand("status")).toBe(false);
|
||||
expect(hasControlCommand("usage")).toBe(false);
|
||||
|
||||
for (const command of listChatCommands()) {
|
||||
for (const alias of command.textAliases) {
|
||||
expect(hasControlCommand(alias)).toBe(true);
|
||||
expect(hasControlCommand(`${alias}:`)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("requires commands to be the full message", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCommandText,
|
||||
getCommandDetection,
|
||||
listChatCommands,
|
||||
listNativeCommandSpecs,
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
@@ -17,17 +18,28 @@ describe("commands registry", () => {
|
||||
const specs = listNativeCommandSpecs();
|
||||
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
||||
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
|
||||
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("detects known text commands", () => {
|
||||
const detection = getCommandDetection();
|
||||
expect(detection.exact.has("/help")).toBe(true);
|
||||
expect(detection.regex.test("/status")).toBe(true);
|
||||
expect(detection.regex.test("/status:")).toBe(true);
|
||||
expect(detection.regex.test("/stop")).toBe(true);
|
||||
expect(detection.regex.test("/send:")).toBe(true);
|
||||
expect(detection.regex.test("/models")).toBe(true);
|
||||
expect(detection.regex.test("/models list")).toBe(true);
|
||||
expect(detection.exact.has("/commands")).toBe(true);
|
||||
expect(detection.exact.has("/compact")).toBe(true);
|
||||
for (const command of listChatCommands()) {
|
||||
for (const alias of command.textAliases) {
|
||||
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
|
||||
expect(detection.regex.test(alias)).toBe(true);
|
||||
expect(detection.regex.test(`${alias}:`)).toBe(true);
|
||||
|
||||
if (command.acceptsArgs) {
|
||||
expect(detection.regex.test(`${alias} list`)).toBe(true);
|
||||
expect(detection.regex.test(`${alias}: list`)).toBe(true);
|
||||
} else {
|
||||
expect(detection.regex.test(`${alias} list`)).toBe(false);
|
||||
expect(detection.regex.test(`${alias}: list`)).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(detection.regex.test("try /status")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+284
-110
@@ -1,11 +1,14 @@
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
|
||||
export type CommandScope = "text" | "native" | "both";
|
||||
|
||||
export type ChatCommandDefinition = {
|
||||
key: string;
|
||||
nativeName: string;
|
||||
nativeName?: string;
|
||||
description: string;
|
||||
textAliases: string[];
|
||||
acceptsArgs?: boolean;
|
||||
scope: CommandScope;
|
||||
};
|
||||
|
||||
export type NativeCommandSpec = {
|
||||
@@ -14,110 +17,257 @@ export type NativeCommandSpec = {
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
||||
{
|
||||
key: "help",
|
||||
nativeName: "help",
|
||||
description: "Show available commands.",
|
||||
textAliases: ["/help"],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
nativeName: "status",
|
||||
description: "Show current status.",
|
||||
textAliases: ["/status"],
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
description: "Toggle per-response usage line.",
|
||||
textAliases: ["/cost"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
description: "Stop the current run.",
|
||||
textAliases: ["/stop"],
|
||||
},
|
||||
{
|
||||
key: "restart",
|
||||
nativeName: "restart",
|
||||
description: "Restart Clawdbot.",
|
||||
textAliases: ["/restart"],
|
||||
},
|
||||
{
|
||||
key: "activation",
|
||||
nativeName: "activation",
|
||||
description: "Set group activation mode.",
|
||||
textAliases: ["/activation"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "send",
|
||||
nativeName: "send",
|
||||
description: "Set send policy.",
|
||||
textAliases: ["/send"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
nativeName: "reset",
|
||||
description: "Reset the current session.",
|
||||
textAliases: ["/reset"],
|
||||
},
|
||||
{
|
||||
key: "new",
|
||||
nativeName: "new",
|
||||
description: "Start a new session.",
|
||||
textAliases: ["/new"],
|
||||
},
|
||||
{
|
||||
key: "think",
|
||||
nativeName: "think",
|
||||
description: "Set thinking level.",
|
||||
textAliases: ["/thinking", "/think", "/t"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "verbose",
|
||||
nativeName: "verbose",
|
||||
description: "Toggle verbose mode.",
|
||||
textAliases: ["/verbose", "/v"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning",
|
||||
nativeName: "reasoning",
|
||||
description: "Toggle reasoning visibility.",
|
||||
textAliases: ["/reasoning", "/reason"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "elevated",
|
||||
nativeName: "elevated",
|
||||
description: "Toggle elevated mode.",
|
||||
textAliases: ["/elevated", "/elev"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Show or set the model.",
|
||||
textAliases: ["/model", "/models"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
description: "Adjust queue settings.",
|
||||
textAliases: ["/queue"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
];
|
||||
type TextAliasSpec = {
|
||||
canonical: string;
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
function defineChatCommand(command: {
|
||||
key: string;
|
||||
nativeName?: string;
|
||||
description: string;
|
||||
acceptsArgs?: boolean;
|
||||
textAlias?: string;
|
||||
textAliases?: string[];
|
||||
scope?: CommandScope;
|
||||
}): ChatCommandDefinition {
|
||||
const aliases = (
|
||||
command.textAliases ?? (command.textAlias ? [command.textAlias] : [])
|
||||
)
|
||||
.map((alias) => alias.trim())
|
||||
.filter(Boolean);
|
||||
const scope =
|
||||
command.scope ??
|
||||
(command.nativeName ? (aliases.length ? "both" : "native") : "text");
|
||||
return {
|
||||
key: command.key,
|
||||
nativeName: command.nativeName,
|
||||
description: command.description,
|
||||
acceptsArgs: command.acceptsArgs,
|
||||
textAliases: aliases,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
function registerAlias(
|
||||
commands: ChatCommandDefinition[],
|
||||
key: string,
|
||||
...aliases: string[]
|
||||
): void {
|
||||
const command = commands.find((entry) => entry.key === key);
|
||||
if (!command) {
|
||||
throw new Error(`registerAlias: unknown command key: ${key}`);
|
||||
}
|
||||
const existing = new Set(
|
||||
command.textAliases.map((alias) => alias.trim().toLowerCase()),
|
||||
);
|
||||
for (const alias of aliases) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed) continue;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (existing.has(lowered)) continue;
|
||||
existing.add(lowered);
|
||||
command.textAliases.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
|
||||
const keys = new Set<string>();
|
||||
const nativeNames = new Set<string>();
|
||||
const textAliases = new Set<string>();
|
||||
for (const command of commands) {
|
||||
if (keys.has(command.key)) {
|
||||
throw new Error(`Duplicate command key: ${command.key}`);
|
||||
}
|
||||
keys.add(command.key);
|
||||
|
||||
const nativeName = command.nativeName?.trim();
|
||||
if (command.scope === "text") {
|
||||
if (nativeName) {
|
||||
throw new Error(`Text-only command has native name: ${command.key}`);
|
||||
}
|
||||
if (command.textAliases.length === 0) {
|
||||
throw new Error(`Text-only command missing text alias: ${command.key}`);
|
||||
}
|
||||
} else if (!nativeName) {
|
||||
throw new Error(`Native command missing native name: ${command.key}`);
|
||||
} else {
|
||||
const nativeKey = nativeName.toLowerCase();
|
||||
if (nativeNames.has(nativeKey)) {
|
||||
throw new Error(`Duplicate native command: ${nativeName}`);
|
||||
}
|
||||
nativeNames.add(nativeKey);
|
||||
}
|
||||
|
||||
if (command.scope === "native" && command.textAliases.length > 0) {
|
||||
throw new Error(`Native-only command has text aliases: ${command.key}`);
|
||||
}
|
||||
|
||||
for (const alias of command.textAliases) {
|
||||
if (!alias.startsWith("/")) {
|
||||
throw new Error(`Command alias missing leading '/': ${alias}`);
|
||||
}
|
||||
const aliasKey = alias.toLowerCase();
|
||||
if (textAliases.has(aliasKey)) {
|
||||
throw new Error(`Duplicate command alias: ${alias}`);
|
||||
}
|
||||
textAliases.add(aliasKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
const commands: ChatCommandDefinition[] = [
|
||||
defineChatCommand({
|
||||
key: "help",
|
||||
nativeName: "help",
|
||||
description: "Show available commands.",
|
||||
textAlias: "/help",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "commands",
|
||||
nativeName: "commands",
|
||||
description: "List all slash commands.",
|
||||
textAlias: "/commands",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "status",
|
||||
nativeName: "status",
|
||||
description: "Show current status.",
|
||||
textAlias: "/status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
description: "Set runtime debug overrides.",
|
||||
textAlias: "/debug",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
description: "Toggle per-response usage line.",
|
||||
textAlias: "/cost",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
description: "Stop the current run.",
|
||||
textAlias: "/stop",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "restart",
|
||||
nativeName: "restart",
|
||||
description: "Restart Clawdbot.",
|
||||
textAlias: "/restart",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "activation",
|
||||
nativeName: "activation",
|
||||
description: "Set group activation mode.",
|
||||
textAlias: "/activation",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "send",
|
||||
nativeName: "send",
|
||||
description: "Set send policy.",
|
||||
textAlias: "/send",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "reset",
|
||||
nativeName: "reset",
|
||||
description: "Reset the current session.",
|
||||
textAlias: "/reset",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "new",
|
||||
nativeName: "new",
|
||||
description: "Start a new session.",
|
||||
textAlias: "/new",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "compact",
|
||||
description: "Compact the session context.",
|
||||
textAlias: "/compact",
|
||||
scope: "text",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "think",
|
||||
nativeName: "think",
|
||||
description: "Set thinking level.",
|
||||
textAlias: "/think",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "verbose",
|
||||
nativeName: "verbose",
|
||||
description: "Toggle verbose mode.",
|
||||
textAlias: "/verbose",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "reasoning",
|
||||
nativeName: "reasoning",
|
||||
description: "Toggle reasoning visibility.",
|
||||
textAlias: "/reasoning",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "elevated",
|
||||
nativeName: "elevated",
|
||||
description: "Toggle elevated mode.",
|
||||
textAlias: "/elevated",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Show or set the model.",
|
||||
textAlias: "/model",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
description: "Adjust queue settings.",
|
||||
textAlias: "/queue",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
];
|
||||
|
||||
registerAlias(commands, "status", "/usage");
|
||||
registerAlias(commands, "think", "/thinking", "/t");
|
||||
registerAlias(commands, "verbose", "/v");
|
||||
registerAlias(commands, "reasoning", "/reason");
|
||||
registerAlias(commands, "elevated", "/elev");
|
||||
registerAlias(commands, "model", "/models");
|
||||
|
||||
assertCommandRegistry(commands);
|
||||
return commands;
|
||||
})();
|
||||
|
||||
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
|
||||
|
||||
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
|
||||
const map = new Map<string, TextAliasSpec>();
|
||||
for (const command of CHAT_COMMANDS) {
|
||||
const canonical = `/${command.key}`;
|
||||
const acceptsArgs = Boolean(command.acceptsArgs);
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (!map.has(normalized)) {
|
||||
map.set(normalized, { canonical, acceptsArgs });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
let cachedDetection:
|
||||
| {
|
||||
exact: Set<string>;
|
||||
@@ -134,8 +284,10 @@ export function listChatCommands(): ChatCommandDefinition[] {
|
||||
}
|
||||
|
||||
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
||||
return CHAT_COMMANDS.map((command) => ({
|
||||
name: command.nativeName,
|
||||
return CHAT_COMMANDS.filter(
|
||||
(command) => command.scope !== "text" && command.nativeName,
|
||||
).map((command) => ({
|
||||
name: command.nativeName ?? command.key,
|
||||
description: command.description,
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
}));
|
||||
@@ -146,7 +298,9 @@ export function findCommandByNativeName(
|
||||
): ChatCommandDefinition | undefined {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return CHAT_COMMANDS.find(
|
||||
(command) => command.nativeName.toLowerCase() === normalized,
|
||||
(command) =>
|
||||
command.scope !== "text" &&
|
||||
command.nativeName?.toLowerCase() === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,11 +312,31 @@ export function buildCommandText(commandName: string, args?: string): string {
|
||||
export function normalizeCommandBody(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) return trimmed;
|
||||
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||
if (!match) return trimmed;
|
||||
const [, command, rest] = match;
|
||||
const normalizedRest = rest.trimStart();
|
||||
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
||||
|
||||
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||
const normalized = colonMatch
|
||||
? (() => {
|
||||
const [, command, rest] = colonMatch;
|
||||
const normalizedRest = rest.trimStart();
|
||||
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
||||
})()
|
||||
: trimmed;
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
const exact = TEXT_ALIAS_MAP.get(lowered);
|
||||
if (exact) return exact.canonical;
|
||||
|
||||
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
||||
if (!tokenMatch) return normalized;
|
||||
const [, token, rest] = tokenMatch;
|
||||
const tokenKey = `/${token.toLowerCase()}`;
|
||||
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
|
||||
if (!tokenSpec) return normalized;
|
||||
if (rest && !tokenSpec.acceptsArgs) return normalized;
|
||||
const normalizedRest = rest?.trimStart();
|
||||
return normalizedRest
|
||||
? `${tokenSpec.canonical} ${normalizedRest}`
|
||||
: tokenSpec.canonical;
|
||||
}
|
||||
|
||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -144,6 +144,12 @@ describe("directive parsing", () => {
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("preserves spacing when stripping usage directives before paths", () => {
|
||||
const res = extractStatusDirective("thats not /usage:/tmp/hello");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.cleaned).toBe("thats not /tmp/hello");
|
||||
});
|
||||
|
||||
it("parses queue options and modes", () => {
|
||||
const res = extractQueueDirective(
|
||||
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
|
||||
@@ -162,12 +168,24 @@ describe("directive parsing", () => {
|
||||
expect(res.cleaned).toBe("ok");
|
||||
});
|
||||
|
||||
it("extracts reply_to_current tag with whitespace", () => {
|
||||
const res = extractReplyToTag("ok [[ reply_to_current ]]", "msg-1");
|
||||
expect(res.replyToId).toBe("msg-1");
|
||||
expect(res.cleaned).toBe("ok");
|
||||
});
|
||||
|
||||
it("extracts reply_to id tag", () => {
|
||||
const res = extractReplyToTag("see [[reply_to:12345]] now", "msg-1");
|
||||
expect(res.replyToId).toBe("12345");
|
||||
expect(res.cleaned).toBe("see now");
|
||||
});
|
||||
|
||||
it("extracts reply_to id tag with whitespace", () => {
|
||||
const res = extractReplyToTag("see [[ reply_to : 12345 ]] now", "msg-1");
|
||||
expect(res.replyToId).toBe("12345");
|
||||
expect(res.cleaned).toBe("see now");
|
||||
});
|
||||
|
||||
it("preserves newlines when stripping reply tags", () => {
|
||||
const res = extractReplyToTag(
|
||||
"line 1\nline 2 [[reply_to_current]]\n\nline 3",
|
||||
|
||||
@@ -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,47 @@ 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") },
|
||||
},
|
||||
);
|
||||
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload?.text).toBe("hello");
|
||||
expect(payload?.replyToId).toBe("msg-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("strips reply tags with whitespace and maps reply_to_current to MessageSid", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello [[ reply_to_current ]]" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "ping",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
MessageSid: "msg-123",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
@@ -270,9 +308,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 +343,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 +372,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 +396,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 +422,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 +447,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 +473,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 +504,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 +542,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 +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"] },
|
||||
},
|
||||
@@ -552,9 +616,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 +653,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 +685,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 +718,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 +753,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 +768,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 +829,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 +892,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 +909,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 +939,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 +971,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 +1003,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 +1035,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 +1066,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 +1095,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 +1128,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 +1157,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 +1181,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 +1214,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 +1255,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 +1310,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 +1350,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"] }),
|
||||
);
|
||||
@@ -236,6 +242,23 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports status via /usage without invoking the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("ClawdBot");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports active auth profile and key snippet in status", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
@@ -293,7 +316,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 +368,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 +406,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 +449,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 +500,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 +547,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 +586,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 +634,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 +684,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 +721,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 +763,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 +858,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 +1003,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 +1049,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 +1090,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 +1124,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 +1153,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 +1196,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: ["*"],
|
||||
@@ -1183,6 +1257,7 @@ describe("trigger handling", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||
expect(prompt).toContain("Give me the status");
|
||||
expect(prompt).not.toContain("/thinking high");
|
||||
expect(prompt).not.toContain("/think high");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1229,12 +1304,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 +1349,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,
|
||||
@@ -46,6 +57,7 @@ import {
|
||||
} from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import {
|
||||
buildCommandsMessage,
|
||||
buildHelpMessage,
|
||||
buildStatusMessage,
|
||||
formatContextUsageShort,
|
||||
@@ -60,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";
|
||||
@@ -134,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);
|
||||
@@ -141,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;
|
||||
@@ -163,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,
|
||||
@@ -184,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,
|
||||
@@ -212,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,
|
||||
@@ -236,6 +272,10 @@ function resolveModelAuthLabel(
|
||||
if (profile.type === "oauth") {
|
||||
return `oauth${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
const snippet = formatApiKeySnippet(profile.token);
|
||||
return `token ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
const snippet = formatApiKeySnippet(profile.key);
|
||||
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
@@ -553,6 +593,17 @@ export async function handleCommands(params: {
|
||||
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
|
||||
}
|
||||
|
||||
const commandsRequested = command.commandBodyNormalized === "/commands";
|
||||
if (allowTextCommands && commandsRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /commands from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
|
||||
}
|
||||
|
||||
const statusRequested =
|
||||
directives.hasStatusDirective ||
|
||||
command.commandBodyNormalized === "/status";
|
||||
@@ -577,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) {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,28 +78,140 @@ 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 &&
|
||||
configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode && configProfile.mode !== profile.type)
|
||||
(configProfile?.mode &&
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"))
|
||||
) {
|
||||
return `${profileId}=missing`;
|
||||
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") {
|
||||
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,
|
||||
@@ -106,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))}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,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" };
|
||||
@@ -145,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.` };
|
||||
@@ -357,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 =
|
||||
@@ -378,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,
|
||||
@@ -389,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,
|
||||
@@ -415,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;
|
||||
@@ -425,6 +565,8 @@ export async function handleDirectiveOnly(params: {
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
agentDir,
|
||||
authMode,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -433,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>();
|
||||
@@ -461,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;
|
||||
@@ -471,6 +611,8 @@ export async function handleDirectiveOnly(params: {
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
agentDir,
|
||||
authMode,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -479,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)`);
|
||||
@@ -681,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 };
|
||||
@@ -832,6 +976,7 @@ export async function persistInlineDirectives(params: {
|
||||
directives: InlineDirectives;
|
||||
effectiveModelDirective?: string;
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -846,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,
|
||||
@@ -866,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;
|
||||
@@ -925,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);
|
||||
@@ -1002,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): {
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
return extractSimpleDirective(body, ["status"]);
|
||||
return extractSimpleDirective(body, ["status", "usage"]);
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
const REPLY_TAG_RE =
|
||||
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
|
||||
|
||||
function normalizeReplyText(text: string) {
|
||||
return text
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function extractReplyToTag(
|
||||
text?: string,
|
||||
currentMessageId?: string,
|
||||
@@ -7,29 +17,28 @@ export function extractReplyToTag(
|
||||
hasTag: boolean;
|
||||
} {
|
||||
if (!text) return { cleaned: "", hasTag: false };
|
||||
let cleaned = text;
|
||||
let replyToId: string | undefined;
|
||||
|
||||
let sawCurrent = false;
|
||||
let lastExplicitId: string | undefined;
|
||||
let hasTag = false;
|
||||
|
||||
const currentMatch = cleaned.match(/\[\[reply_to_current\]\]/i);
|
||||
if (currentMatch) {
|
||||
cleaned = cleaned.replace(/\[\[reply_to_current\]\]/gi, " ");
|
||||
hasTag = true;
|
||||
if (currentMessageId?.trim()) {
|
||||
replyToId = currentMessageId.trim();
|
||||
}
|
||||
}
|
||||
const cleaned = normalizeReplyText(
|
||||
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
|
||||
hasTag = true;
|
||||
if (idRaw === undefined) {
|
||||
sawCurrent = true;
|
||||
return " ";
|
||||
}
|
||||
|
||||
const idMatch = cleaned.match(/\[\[reply_to:([^\]\n]+)\]\]/i);
|
||||
if (idMatch?.[1]) {
|
||||
cleaned = cleaned.replace(/\[\[reply_to:[^\]\n]+\]\]/gi, " ");
|
||||
replyToId = idMatch[1].trim();
|
||||
hasTag = true;
|
||||
}
|
||||
const id = idRaw.trim();
|
||||
if (id) lastExplicitId = id;
|
||||
return " ";
|
||||
}),
|
||||
);
|
||||
|
||||
const replyToId =
|
||||
lastExplicitId ??
|
||||
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
|
||||
|
||||
cleaned = cleaned
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
return { cleaned, replyToId, hasTag };
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+98
-115
@@ -1,43 +1,10 @@
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
|
||||
|
||||
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,69 +233,79 @@ 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-" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommandsMessage", () => {
|
||||
it("lists commands with aliases and text-only hints", () => {
|
||||
const text = buildCommandsMessage();
|
||||
expect(text).toContain("/commands - List all slash commands.");
|
||||
expect(text).toContain(
|
||||
"/think (aliases: /thinking, /t) - Set thinking level.",
|
||||
);
|
||||
expect(text).toContain(
|
||||
"/compact (text-only) - Compact the session context.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveModelCostConfig,
|
||||
} from "../utils/usage-format.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
@@ -35,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;
|
||||
|
||||
@@ -188,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,
|
||||
});
|
||||
@@ -351,6 +358,33 @@ 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");
|
||||
}
|
||||
|
||||
export function buildCommandsMessage(): string {
|
||||
const lines = ["ℹ️ Slash commands"];
|
||||
for (const command of listChatCommands()) {
|
||||
const primary = command.nativeName
|
||||
? `/${command.nativeName}`
|
||||
: command.textAliases[0]?.trim() || `/${command.key}`;
|
||||
const seen = new Set<string>();
|
||||
const aliases = command.textAliases
|
||||
.map((alias) => alias.trim())
|
||||
.filter(Boolean)
|
||||
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
|
||||
.filter((alias) => {
|
||||
const key = alias.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
const aliasLabel = aliases.length
|
||||
? ` (aliases: ${aliases.join(", ")})`
|
||||
: "";
|
||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
||||
}
|
||||
return lines.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([\\/]|$)/,
|
||||
|
||||
@@ -10,6 +10,20 @@ type BannerOptions = TaglineOptions & {
|
||||
|
||||
let bannerEmitted = false;
|
||||
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: null;
|
||||
|
||||
function splitGraphemes(value: string): string[] {
|
||||
if (!graphemeSegmenter) return Array.from(value);
|
||||
try {
|
||||
return Array.from(graphemeSegmenter.segment(value), (seg) => seg.segment);
|
||||
} catch {
|
||||
return Array.from(value);
|
||||
}
|
||||
}
|
||||
|
||||
const hasJsonFlag = (argv: string[]) =>
|
||||
argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
|
||||
|
||||
@@ -33,6 +47,41 @@ export function formatCliBannerLine(
|
||||
return `${title} ${version} (${commitLabel}) — ${tagline}`;
|
||||
}
|
||||
|
||||
const LOBSTER_ASCII = [
|
||||
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
|
||||
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
|
||||
" 🦞 FRESH DAILY 🦞",
|
||||
];
|
||||
|
||||
export function formatCliBannerArt(options: BannerOptions = {}): string {
|
||||
const rich = options.richTty ?? isRich();
|
||||
if (!rich) return LOBSTER_ASCII.join("\n");
|
||||
|
||||
const colorChar = (ch: string) => {
|
||||
if (ch === "█") return theme.accentBright(ch);
|
||||
if (ch === "░") return theme.accentDim(ch);
|
||||
if (ch === "▀") return theme.accent(ch);
|
||||
return theme.muted(ch);
|
||||
};
|
||||
|
||||
const colored = LOBSTER_ASCII.map((line) => {
|
||||
if (line.includes("FRESH DAILY")) {
|
||||
return (
|
||||
theme.muted(" ") +
|
||||
theme.accent("🦞") +
|
||||
theme.info(" FRESH DAILY ") +
|
||||
theme.accent("🦞")
|
||||
);
|
||||
}
|
||||
return splitGraphemes(line).map(colorChar).join("");
|
||||
});
|
||||
|
||||
return colored.join("\n");
|
||||
}
|
||||
|
||||
export function emitCliBanner(version: string, options: BannerOptions = {}) {
|
||||
if (bannerEmitted) return;
|
||||
const argv = options.argv ?? process.argv;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ const forceFreePortAndWait = vi.fn(async () => ({
|
||||
escalatedToSigkill: false,
|
||||
}));
|
||||
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
||||
const discoverGatewayBeacons = vi.fn(async () => []);
|
||||
const gatewayStatusCommand = vi.fn(async () => {});
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
@@ -90,8 +92,16 @@ vi.mock("../daemon/program-args.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/bonjour-discovery.js", () => ({
|
||||
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/gateway-status.js", () => ({
|
||||
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
|
||||
}));
|
||||
|
||||
describe("gateway-cli coverage", () => {
|
||||
it("registers call/health/status commands and routes to callGateway", async () => {
|
||||
it("registers call/health commands and routes to callGateway", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
@@ -110,6 +120,110 @@ describe("gateway-cli coverage", () => {
|
||||
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
||||
});
|
||||
|
||||
it("registers gateway status and routes to gatewayStatusCommand", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
gatewayStatusCommand.mockClear();
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await program.parseAsync(["gateway", "status", "--json"], { from: "user" });
|
||||
|
||||
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers gateway discover and prints JSON", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
discoverGatewayBeacons.mockReset();
|
||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
||||
{
|
||||
instanceName: "Studio (Clawdbot)",
|
||||
displayName: "Studio",
|
||||
domain: "local.",
|
||||
host: "studio.local",
|
||||
lanHost: "studio.local",
|
||||
tailnetDns: "studio.tailnet.ts.net",
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
sshPort: 22,
|
||||
},
|
||||
]);
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await program.parseAsync(["gateway", "discover", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeLogs.join("\n")).toContain('"beacons"');
|
||||
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
|
||||
expect(runtimeLogs.join("\n")).toContain("ws://");
|
||||
});
|
||||
|
||||
it("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;
|
||||
discoverGatewayBeacons.mockReset();
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
|
||||
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails gateway call on invalid params JSON", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
|
||||
+428
-20
@@ -1,12 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Command } from "commander";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||
import { gatewayStatusCommand } from "../commands/gateway-status.js";
|
||||
import { 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,
|
||||
@@ -22,13 +28,18 @@ import {
|
||||
setGatewayWsLogStyle,
|
||||
} from "../gateway/ws-logging.js";
|
||||
import { setVerbose } from "../globals.js";
|
||||
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
setConsoleSubsystemFilter,
|
||||
} from "../logging.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { forceFreePortAndWait } from "./ports.js";
|
||||
import { withProgress } from "./progress.js";
|
||||
|
||||
@@ -38,6 +49,7 @@ type GatewayRpcOpts = {
|
||||
password?: string;
|
||||
timeout?: string;
|
||||
expectFinal?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type GatewayRunOpts = {
|
||||
@@ -56,6 +68,8 @@ type GatewayRunOpts = {
|
||||
compact?: boolean;
|
||||
rawStream?: boolean;
|
||||
rawStreamPath?: unknown;
|
||||
dev?: boolean;
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
type GatewayRunParams = {
|
||||
@@ -63,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";
|
||||
|
||||
@@ -87,6 +127,202 @@ 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;
|
||||
};
|
||||
|
||||
function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number {
|
||||
if (raw === undefined || raw === null) return fallbackMs;
|
||||
const value =
|
||||
typeof raw === "string"
|
||||
? raw.trim()
|
||||
: typeof raw === "number" || typeof raw === "bigint"
|
||||
? String(raw)
|
||||
: null;
|
||||
if (value === null) {
|
||||
throw new Error("invalid --timeout");
|
||||
}
|
||||
if (!value) return fallbackMs;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`invalid --timeout: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
|
||||
const host = beacon.tailnetDns || beacon.lanHost || beacon.host;
|
||||
return host?.trim() ? host.trim() : null;
|
||||
}
|
||||
|
||||
function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
|
||||
const port = beacon.gatewayPort ?? 18789;
|
||||
return port > 0 ? port : 18789;
|
||||
}
|
||||
|
||||
function dedupeBeacons(
|
||||
beacons: GatewayBonjourBeacon[],
|
||||
): GatewayBonjourBeacon[] {
|
||||
const out: GatewayBonjourBeacon[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const b of beacons) {
|
||||
const host = pickBeaconHost(b) ?? "";
|
||||
const key = [
|
||||
b.domain ?? "",
|
||||
b.instanceName ?? "",
|
||||
b.displayName ?? "",
|
||||
host,
|
||||
String(b.port ?? ""),
|
||||
String(b.bridgePort ?? ""),
|
||||
String(b.gatewayPort ?? ""),
|
||||
].join("|");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderBeaconLines(
|
||||
beacon: GatewayBonjourBeacon,
|
||||
rich: boolean,
|
||||
): string[] {
|
||||
const nameRaw = (
|
||||
beacon.displayName ||
|
||||
beacon.instanceName ||
|
||||
"Gateway"
|
||||
).trim();
|
||||
const domainRaw = (beacon.domain || "local.").trim();
|
||||
|
||||
const title = colorize(rich, theme.accentBright, nameRaw);
|
||||
const domain = colorize(rich, theme.muted, domainRaw);
|
||||
|
||||
const host = pickBeaconHost(beacon);
|
||||
const gatewayPort = pickGatewayPort(beacon);
|
||||
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
if (wsUrl) {
|
||||
lines.push(
|
||||
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
|
||||
);
|
||||
}
|
||||
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
|
||||
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
|
||||
lines.push(
|
||||
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function describeUnknownError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
@@ -219,9 +455,18 @@ async function runGatewayLoop(params: {
|
||||
})();
|
||||
};
|
||||
|
||||
const onSigterm = () => request("stop", "SIGTERM");
|
||||
const onSigint = () => request("stop", "SIGINT");
|
||||
const onSigusr1 = () => request("restart", "SIGUSR1");
|
||||
const onSigterm = () => {
|
||||
gatewayLog.info("signal SIGTERM received");
|
||||
request("stop", "SIGTERM");
|
||||
};
|
||||
const onSigint = () => {
|
||||
gatewayLog.info("signal SIGINT received");
|
||||
request("stop", "SIGINT");
|
||||
};
|
||||
const onSigusr1 = () => {
|
||||
gatewayLog.info("signal SIGUSR1 received");
|
||||
request("restart", "SIGUSR1");
|
||||
};
|
||||
|
||||
process.on("SIGTERM", onSigterm);
|
||||
process.on("SIGINT", onSigint);
|
||||
@@ -251,7 +496,8 @@ const gatewayCallOpts = (cmd: Command) =>
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (password auth)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--expect-final", "Wait for final response (agent)", false);
|
||||
.option("--expect-final", "Wait for final response (agent)", false)
|
||||
.option("--json", "Output JSON", false);
|
||||
|
||||
const callGatewayCli = async (
|
||||
method: string,
|
||||
@@ -262,7 +508,7 @@ const callGatewayCli = async (
|
||||
{
|
||||
label: `Gateway ${method}`,
|
||||
indeterminate: true,
|
||||
enabled: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
@@ -282,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) {
|
||||
@@ -318,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) {
|
||||
@@ -571,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",
|
||||
@@ -611,7 +879,7 @@ export function registerGatewayCli(program: Command) {
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("call")
|
||||
.description("Call a Gateway method and print JSON")
|
||||
.description("Call a Gateway method")
|
||||
.argument(
|
||||
"<method>",
|
||||
"Method name (health/status/system-presence/cron.*)",
|
||||
@@ -621,6 +889,18 @@ export function registerGatewayCli(program: Command) {
|
||||
try {
|
||||
const params = JSON.parse(String(opts.params ?? "{}"));
|
||||
const result = await callGatewayCli(method, opts, params);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
defaultRuntime.log(
|
||||
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
String(method),
|
||||
)}`,
|
||||
);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
|
||||
@@ -636,7 +916,46 @@ export function registerGatewayCli(program: Command) {
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("health", opts);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
const obj =
|
||||
result && typeof result === "object"
|
||||
? (result as Record<string, unknown>)
|
||||
: {};
|
||||
const durationMs =
|
||||
typeof obj.durationMs === "number" ? obj.durationMs : null;
|
||||
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
|
||||
defaultRuntime.log(
|
||||
`${colorize(rich, theme.success, "OK")}${
|
||||
durationMs != null ? ` (${durationMs}ms)` : ""
|
||||
}`,
|
||||
);
|
||||
if (obj.web && typeof obj.web === "object") {
|
||||
const web = obj.web as Record<string, unknown>;
|
||||
const linked = web.linked === true;
|
||||
defaultRuntime.log(
|
||||
`Web: ${linked ? "linked" : "not linked"}${
|
||||
typeof web.authAgeMs === "number" && linked
|
||||
? ` (${Math.round(web.authAgeMs / 60_000)}m)`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (obj.telegram && typeof obj.telegram === "object") {
|
||||
const tg = obj.telegram as Record<string, unknown>;
|
||||
defaultRuntime.log(
|
||||
`Telegram: ${tg.configured === true ? "configured" : "not configured"}`,
|
||||
);
|
||||
}
|
||||
if (obj.discord && typeof obj.discord === "object") {
|
||||
const dc = obj.discord as Record<string, unknown>;
|
||||
defaultRuntime.log(
|
||||
`Discord: ${dc.configured === true ? "configured" : "not configured"}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -644,18 +963,107 @@ export function registerGatewayCli(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("status")
|
||||
.description("Fetch Gateway status")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("status", opts);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
gateway
|
||||
.command("status")
|
||||
.description(
|
||||
"Show gateway reachability + discovery + health + status summary (local + remote)",
|
||||
)
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Explicit Gateway WebSocket URL (still probes localhost)",
|
||||
)
|
||||
.option(
|
||||
"--ssh <target>",
|
||||
"SSH target for remote gateway tunnel (user@host or user@host:port)",
|
||||
)
|
||||
.option("--ssh-identity <path>", "SSH identity file path")
|
||||
.option(
|
||||
"--ssh-auto",
|
||||
"Try to derive an SSH target from Bonjour discovery",
|
||||
false,
|
||||
)
|
||||
.option("--token <token>", "Gateway token (applies to all probes)")
|
||||
.option("--password <password>", "Gateway password (applies to all probes)")
|
||||
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await gatewayStatusCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
gateway
|
||||
.command("discover")
|
||||
.description(
|
||||
`Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
|
||||
)
|
||||
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayDiscoverOpts) => {
|
||||
try {
|
||||
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
|
||||
const beacons = await withProgress(
|
||||
{
|
||||
label: "Scanning for gateways…",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
delayMs: 0,
|
||||
},
|
||||
async () => await discoverGatewayBeacons({ timeoutMs }),
|
||||
);
|
||||
|
||||
const deduped = dedupeBeacons(beacons).sort((a, b) =>
|
||||
String(a.displayName || a.instanceName).localeCompare(
|
||||
String(b.displayName || b.instanceName),
|
||||
),
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
const enriched = deduped.map((b) => {
|
||||
const host = pickBeaconHost(b);
|
||||
const port = pickGatewayPort(b);
|
||||
return {
|
||||
...b,
|
||||
wsUrl: host ? `ws://${host}:${port}` : null,
|
||||
};
|
||||
});
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
timeoutMs,
|
||||
domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
|
||||
count: enriched.length,
|
||||
beacons: enriched,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const rich = isRich();
|
||||
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
|
||||
defaultRuntime.log(
|
||||
colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
`Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
|
||||
),
|
||||
);
|
||||
if (deduped.length === 0) return;
|
||||
|
||||
for (const beacon of deduped) {
|
||||
for (const line of renderBeaconLines(beacon, rich)) {
|
||||
defaultRuntime.log(line);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`gateway discover failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+143
-2
@@ -4,6 +4,12 @@ import {
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
modelsAliasesRemoveCommand,
|
||||
modelsAuthAddCommand,
|
||||
modelsAuthOrderClearCommand,
|
||||
modelsAuthOrderGetCommand,
|
||||
modelsAuthOrderSetCommand,
|
||||
modelsAuthPasteTokenCommand,
|
||||
modelsAuthSetupTokenCommand,
|
||||
modelsFallbacksAddCommand,
|
||||
modelsFallbacksClearCommand,
|
||||
modelsFallbacksListCommand,
|
||||
@@ -264,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)
|
||||
@@ -294,4 +304,135 @@ export function registerModelsCli(program: Command) {
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const auth = models.command("auth").description("Manage model auth profiles");
|
||||
|
||||
auth
|
||||
.command("add")
|
||||
.description("Interactive auth helper (setup-token or paste token)")
|
||||
.action(async () => {
|
||||
try {
|
||||
await modelsAuthAddCommand({}, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
auth
|
||||
.command("setup-token")
|
||||
.description("Run a provider CLI to create/sync a token (TTY required)")
|
||||
.option("--provider <name>", "Provider id (default: anthropic)")
|
||||
.option("--yes", "Skip confirmation", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsAuthSetupTokenCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
yes: Boolean(opts.yes),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
auth
|
||||
.command("paste-token")
|
||||
.description("Paste a token into auth-profiles.json and update config")
|
||||
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
|
||||
.option("--profile-id <id>", "Auth profile id (default: <provider>:manual)")
|
||||
.option(
|
||||
"--expires-in <duration>",
|
||||
"Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsAuthPasteTokenCommand(
|
||||
{
|
||||
provider: opts.provider as string | undefined,
|
||||
profileId: opts.profileId as string | undefined,
|
||||
expiresIn: opts.expiresIn as string | undefined,
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user