Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support
This commit is contained in:
@@ -54,6 +54,7 @@ import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||
@@ -139,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
export type ExecElevatedDefaults = {
|
||||
enabled: boolean;
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off";
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
};
|
||||
|
||||
const execSchema = Type.Object({
|
||||
@@ -659,6 +660,11 @@ export function createExecTool(
|
||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
|
||||
// Derive agentId only when sessionKey is an agent session key.
|
||||
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
|
||||
const agentId =
|
||||
defaults?.agentId ??
|
||||
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
|
||||
|
||||
return {
|
||||
name: "exec",
|
||||
@@ -700,12 +706,23 @@ export function createExecTool(
|
||||
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
||||
: null;
|
||||
const elevatedDefaults = defaults?.elevated;
|
||||
const elevatedDefaultOn =
|
||||
elevatedDefaults?.defaultLevel === "on" &&
|
||||
elevatedDefaults.enabled &&
|
||||
elevatedDefaults.allowed;
|
||||
const elevatedRequested =
|
||||
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
|
||||
const elevatedDefaultMode =
|
||||
elevatedDefaults?.defaultLevel === "full"
|
||||
? "full"
|
||||
: elevatedDefaults?.defaultLevel === "ask"
|
||||
? "ask"
|
||||
: elevatedDefaults?.defaultLevel === "on"
|
||||
? "ask"
|
||||
: "off";
|
||||
const elevatedMode =
|
||||
typeof params.elevated === "boolean"
|
||||
? params.elevated
|
||||
? elevatedDefaultMode === "full"
|
||||
? "full"
|
||||
: "ask"
|
||||
: "off"
|
||||
: elevatedDefaultMode;
|
||||
const elevatedRequested = elevatedMode !== "off";
|
||||
if (elevatedRequested) {
|
||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
|
||||
@@ -761,6 +778,10 @@ export function createExecTool(
|
||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||
const requestedAsk = normalizeExecAsk(params.ask);
|
||||
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||
const bypassApprovals = elevatedRequested && elevatedMode === "full";
|
||||
if (bypassApprovals) {
|
||||
ask = "off";
|
||||
}
|
||||
|
||||
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
|
||||
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
|
||||
@@ -799,7 +820,7 @@ export function createExecTool(
|
||||
|
||||
if (host === "node") {
|
||||
const approvals = resolveExecApprovals(
|
||||
defaults?.agentId,
|
||||
agentId,
|
||||
host === "node" ? { security: "allowlist" } : undefined,
|
||||
);
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
@@ -865,7 +886,7 @@ export function createExecTool(
|
||||
cwd: workdir,
|
||||
env: nodeEnv,
|
||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||
agentId: defaults?.agentId,
|
||||
agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
@@ -895,9 +916,9 @@ export function createExecTool(
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath: undefined,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
agentId,
|
||||
resolvedPath: null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
@@ -1025,8 +1046,8 @@ export function createExecTool(
|
||||
};
|
||||
}
|
||||
|
||||
if (host === "gateway") {
|
||||
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
|
||||
if (host === "gateway" && !bypassApprovals) {
|
||||
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
@@ -1060,7 +1081,7 @@ export function createExecTool(
|
||||
const approvalSlug = createApprovalSlug(approvalId);
|
||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
const contextKey = `exec:${approvalId}`;
|
||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
|
||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
|
||||
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
||||
const commandText = params.command;
|
||||
const effectiveTimeout =
|
||||
@@ -1080,9 +1101,9 @@ export function createExecTool(
|
||||
host: "gateway",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
agentId,
|
||||
resolvedPath,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
@@ -1123,7 +1144,7 @@ export function createExecTool(
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1152,7 +1173,7 @@ export function createExecTool(
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
agentId,
|
||||
match,
|
||||
commandText,
|
||||
resolvedPath ?? undefined,
|
||||
@@ -1242,7 +1263,7 @@ export function createExecTool(
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
agentId,
|
||||
match,
|
||||
params.command,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
|
||||
@@ -183,6 +183,8 @@ export function buildSystemPrompt(params: {
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
|
||||
@@ -68,6 +68,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
||||
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||
}
|
||||
|
||||
function isInstructionsRequiredError(raw: string): boolean {
|
||||
return /instructions are required/i.test(raw);
|
||||
}
|
||||
|
||||
function toInt(value: string | undefined, fallback: number): number {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return fallback;
|
||||
@@ -443,6 +447,15 @@ describeLive("live models (profile keys)", () => {
|
||||
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
allowNotFoundSkip &&
|
||||
model.provider === "openai-codex" &&
|
||||
isInstructionsRequiredError(message)
|
||||
) {
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (instructions required)`);
|
||||
break;
|
||||
}
|
||||
logProgress(`${progressLabel}: failed`);
|
||||
failures.push({ model: id, error: message });
|
||||
break;
|
||||
|
||||
@@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => {
|
||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||
});
|
||||
|
||||
@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode Zen routes models to different APIs based on model family.
|
||||
* OpenCode Zen routes models to specific API shapes by family.
|
||||
*/
|
||||
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
|
||||
if (lower.startsWith("gpt-")) {
|
||||
return "openai-responses";
|
||||
}
|
||||
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
|
||||
return "anthropic-messages";
|
||||
}
|
||||
if (lower.startsWith("gemini-")) {
|
||||
return "google-generative-ai";
|
||||
}
|
||||
if (lower.startsWith("gpt-")) {
|
||||
return "openai-responses";
|
||||
}
|
||||
return "openai-completions";
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,8 @@ export async function runEmbeddedAttempt(
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
|
||||
@@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off";
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
};
|
||||
};
|
||||
|
||||
106
src/agents/system-prompt-params.test.ts
Normal file
106
src/agents/system-prompt-params.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { buildSystemPromptParams } from "./system-prompt-params.js";
|
||||
|
||||
async function makeTempDir(label: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`));
|
||||
}
|
||||
|
||||
async function makeRepoRoot(root: string): Promise<void> {
|
||||
await fs.mkdir(path.join(root, ".git"), { recursive: true });
|
||||
}
|
||||
|
||||
function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) {
|
||||
return buildSystemPromptParams({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: params.cwd,
|
||||
runtime: {
|
||||
host: "host",
|
||||
os: "os",
|
||||
arch: "arch",
|
||||
node: "node",
|
||||
model: "model",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildSystemPromptParams repo root", () => {
|
||||
it("detects repo root from workspaceDir", async () => {
|
||||
const temp = await makeTempDir("workspace");
|
||||
const repoRoot = path.join(temp, "repo");
|
||||
const workspaceDir = path.join(repoRoot, "nested", "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await makeRepoRoot(repoRoot);
|
||||
|
||||
const { runtimeInfo } = buildParams({ workspaceDir });
|
||||
|
||||
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it("falls back to cwd when workspaceDir has no repo", async () => {
|
||||
const temp = await makeTempDir("cwd");
|
||||
const repoRoot = path.join(temp, "repo");
|
||||
const workspaceDir = path.join(temp, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await makeRepoRoot(repoRoot);
|
||||
|
||||
const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot });
|
||||
|
||||
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it("uses configured repoRoot when valid", async () => {
|
||||
const temp = await makeTempDir("config");
|
||||
const repoRoot = path.join(temp, "config-root");
|
||||
const workspaceDir = path.join(temp, "workspace");
|
||||
await fs.mkdir(repoRoot, { recursive: true });
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await makeRepoRoot(workspaceDir);
|
||||
|
||||
const config: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
repoRoot,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { runtimeInfo } = buildParams({ config, workspaceDir });
|
||||
|
||||
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it("ignores invalid repoRoot config and auto-detects", async () => {
|
||||
const temp = await makeTempDir("invalid");
|
||||
const repoRoot = path.join(temp, "repo");
|
||||
const workspaceDir = path.join(repoRoot, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await makeRepoRoot(repoRoot);
|
||||
|
||||
const config: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
repoRoot: path.join(temp, "missing"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { runtimeInfo } = buildParams({ config, workspaceDir });
|
||||
|
||||
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it("returns undefined when no repo is found", async () => {
|
||||
const workspaceDir = await makeTempDir("norepo");
|
||||
|
||||
const { runtimeInfo } = buildParams({ workspaceDir });
|
||||
|
||||
expect(runtimeInfo.repoRoot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
formatUserTime,
|
||||
@@ -18,6 +21,7 @@ export type RuntimeInfoInput = {
|
||||
capabilities?: string[];
|
||||
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||
channelActions?: string[];
|
||||
repoRoot?: string;
|
||||
};
|
||||
|
||||
export type SystemPromptRuntimeParams = {
|
||||
@@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: {
|
||||
config?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
runtime: Omit<RuntimeInfoInput, "agentId">;
|
||||
workspaceDir?: string;
|
||||
cwd?: string;
|
||||
}): SystemPromptRuntimeParams {
|
||||
const repoRoot = resolveRepoRoot({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
@@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: {
|
||||
runtimeInfo: {
|
||||
agentId: params.agentId,
|
||||
...params.runtime,
|
||||
repoRoot,
|
||||
},
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRepoRoot(params: {
|
||||
config?: ClawdbotConfig;
|
||||
workspaceDir?: string;
|
||||
cwd?: string;
|
||||
}): string | undefined {
|
||||
const configured = params.config?.agents?.defaults?.repoRoot?.trim();
|
||||
if (configured) {
|
||||
try {
|
||||
const resolved = path.resolve(configured);
|
||||
const stat = fs.statSync(resolved);
|
||||
if (stat.isDirectory()) return resolved;
|
||||
} catch {
|
||||
// ignore invalid config path
|
||||
}
|
||||
}
|
||||
const candidates = [params.workspaceDir, params.cwd]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean) as string[];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.resolve(candidate);
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
const root = findGitRoot(resolved);
|
||||
if (root) return root;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findGitRoot(startDir: string): string | null {
|
||||
let current = path.resolve(startDir);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const gitPath = path.join(current, ".git");
|
||||
try {
|
||||
const stat = fs.statSync(gitPath);
|
||||
if (stat.isDirectory() || stat.isFile()) return current;
|
||||
} catch {
|
||||
// ignore missing .git at this level
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
{
|
||||
agentId: "work",
|
||||
host: "host",
|
||||
repoRoot: "/repo",
|
||||
os: "macOS",
|
||||
arch: "arm64",
|
||||
node: "v20",
|
||||
@@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(line).toContain("agent=work");
|
||||
expect(line).toContain("host=host");
|
||||
expect(line).toContain("repo=/repo");
|
||||
expect(line).toContain("os=macOS (arm64)");
|
||||
expect(line).toContain("node=v20");
|
||||
expect(line).toContain("model=anthropic/claude");
|
||||
@@ -320,7 +322,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("You are running in a sandboxed runtime");
|
||||
expect(prompt).toContain("Sub-agents stay sandboxed");
|
||||
expect(prompt).toContain("User can toggle with /elevated on|off.");
|
||||
expect(prompt).toContain("User can toggle with /elevated on|off|ask|full.");
|
||||
expect(prompt).toContain("Current elevated level: on");
|
||||
});
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
defaultModel?: string;
|
||||
channel?: string;
|
||||
capabilities?: string[];
|
||||
repoRoot?: string;
|
||||
};
|
||||
messageToolHints?: string[];
|
||||
sandboxInfo?: {
|
||||
@@ -175,7 +176,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
allowedControlPorts?: number[];
|
||||
elevated?: {
|
||||
allowed: boolean;
|
||||
defaultLevel: "on" | "off";
|
||||
defaultLevel: "on" | "off" | "ask" | "full";
|
||||
};
|
||||
};
|
||||
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
|
||||
@@ -200,7 +201,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
browser: "Control web browser",
|
||||
canvas: "Present/eval/snapshot the Canvas",
|
||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
|
||||
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
||||
message: "Send messages and channel actions",
|
||||
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
||||
agents_list: "List agent ids allowed for sessions_spawn",
|
||||
@@ -351,7 +352,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
"- browser: control clawd's dedicated browser",
|
||||
"- canvas: present/eval/snapshot the Canvas",
|
||||
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
||||
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
|
||||
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
||||
"- sessions_list: list sessions",
|
||||
"- sessions_history: fetch session history",
|
||||
"- sessions_send: send to another session",
|
||||
@@ -443,12 +444,14 @@ export function buildAgentSystemPrompt(params: {
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? "Elevated exec is available for this session."
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? "You may also send /elevated on|off when needed."
|
||||
? "User can toggle with /elevated on|off|ask|full."
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
|
||||
? "You may also send /elevated on|off|ask|full when needed."
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -570,6 +573,7 @@ export function buildRuntimeLine(
|
||||
node?: string;
|
||||
model?: string;
|
||||
defaultModel?: string;
|
||||
repoRoot?: string;
|
||||
},
|
||||
runtimeChannel?: string,
|
||||
runtimeCapabilities: string[] = [],
|
||||
@@ -578,6 +582,7 @@ export function buildRuntimeLine(
|
||||
return `Runtime: ${[
|
||||
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
|
||||
runtimeInfo?.os
|
||||
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
||||
: runtimeInfo?.arch
|
||||
|
||||
@@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
description: "on, off, ask, or full",
|
||||
type: "string",
|
||||
choices: ["on", "off"],
|
||||
choices: ["on", "off", "ask", "full"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("formatAgentEnvelope", () => {
|
||||
host: "mac-mini",
|
||||
ip: "10.0.0.5",
|
||||
timestamp: ts,
|
||||
envelope: { timezone: "utc" },
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
@@ -26,7 +27,7 @@ describe("formatAgentEnvelope", () => {
|
||||
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
|
||||
});
|
||||
|
||||
it("formats timestamps in UTC regardless of local timezone", () => {
|
||||
it("formats timestamps in local timezone by default", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
|
||||
@@ -39,10 +40,10 @@ describe("formatAgentEnvelope", () => {
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||
});
|
||||
|
||||
it("formats timestamps in local timezone when configured", () => {
|
||||
it("formats timestamps in UTC when configured", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
|
||||
@@ -50,13 +51,13 @@ describe("formatAgentEnvelope", () => {
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "WebChat",
|
||||
timestamp: ts,
|
||||
envelope: { timezone: "local" },
|
||||
envelope: { timezone: "utc" },
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||
});
|
||||
|
||||
it("formats timestamps in user timezone when configured", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
|
||||
|
||||
export type EnvelopeFormatOptions = {
|
||||
/**
|
||||
* "utc" (default), "local", "user", or an explicit IANA timezone string.
|
||||
* "local" (default), "utc", "user", or an explicit IANA timezone string.
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
|
||||
const includeTimestamp = options?.includeTimestamp !== false;
|
||||
const includeElapsed = options?.includeElapsed !== false;
|
||||
return {
|
||||
timezone: options?.timezone?.trim() || "utc",
|
||||
timezone: options?.timezone?.trim() || "local",
|
||||
includeTimestamp,
|
||||
includeElapsed,
|
||||
userTimezone: options?.userTimezone,
|
||||
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
|
||||
|
||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||
const trimmed = options.timezone?.trim();
|
||||
if (!trimmed) return { mode: "utc" };
|
||||
if (!trimmed) return { mode: "local" };
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||
|
||||
@@ -219,7 +219,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const events = drainSystemEvents(MAIN_SESSION_KEY);
|
||||
expect(events.some((e) => e.includes("Elevated ON"))).toBe(true);
|
||||
expect(events.some((e) => e.includes("Elevated ASK"))).toBe(true);
|
||||
});
|
||||
});
|
||||
it("queues a system event when toggling reasoning", async () => {
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
expect(text).toContain("Elevated mode set to ask");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current elevated level: on");
|
||||
expect(text).toContain("Options: on, off.");
|
||||
expect(text).toContain("Options: on, off, ask, full.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,16 @@ describe("directive parsing", () => {
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.elevatedLevel).toBe("on");
|
||||
});
|
||||
it("matches elevated ask", () => {
|
||||
const res = extractElevatedDirective("/elevated ask please");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.elevatedLevel).toBe("ask");
|
||||
});
|
||||
it("matches elevated full", () => {
|
||||
const res = extractElevatedDirective("/elevated full please");
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.elevatedLevel).toBe("full");
|
||||
});
|
||||
|
||||
it("matches think at start of line", () => {
|
||||
const res = extractThinkDirective("/think:high run slow");
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
expect(text).toContain("Elevated mode set to ask");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
@@ -223,7 +223,7 @@ describe("trigger handling", () => {
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("ok");
|
||||
expect(text).not.toContain("Elevated mode enabled");
|
||||
expect(text).not.toContain("Elevated mode set to ask");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
expect(text).toContain("Elevated mode set to ask");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
@@ -226,7 +226,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
expect(text).toContain("Elevated mode set to ask");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
|
||||
@@ -167,7 +167,7 @@ describe("trigger handling", () => {
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode enabled");
|
||||
expect(text).toContain("Elevated mode set to ask");
|
||||
|
||||
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
|
||||
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
|
||||
|
||||
@@ -102,6 +102,8 @@ async function resolveContextReport(
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
workspaceDir,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: "unknown",
|
||||
os: "unknown",
|
||||
@@ -118,7 +120,7 @@ async function resolveContextReport(
|
||||
workspaceAccess: "rw" as const,
|
||||
elevated: {
|
||||
allowed: params.elevated.allowed,
|
||||
defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const),
|
||||
defaultLevel: (params.resolvedElevatedLevel ?? "off") as "on" | "off" | "ask" | "full",
|
||||
},
|
||||
}
|
||||
: { enabled: false };
|
||||
|
||||
@@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
|
||||
}
|
||||
|
||||
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
|
||||
const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
|
||||
const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim();
|
||||
if (!raw) return undefined;
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
return resolveInternalSessionKey({ key: raw, alias, mainKey });
|
||||
|
||||
@@ -215,6 +215,33 @@ describe("handleCommands subagents", () => {
|
||||
expect(result.reply?.text).toContain("Subagents: none");
|
||||
});
|
||||
|
||||
it("lists subagents for the current command session over the target session", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
addSubagentRunForTests({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:abc",
|
||||
requesterSessionKey: "agent:main:slack:slash:U1",
|
||||
requesterDisplayKey: "agent:main:slack:slash:U1",
|
||||
task: "do thing",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
startedAt: 1000,
|
||||
});
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/subagents list", cfg, {
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: "agent:main:main",
|
||||
});
|
||||
params.sessionKey = "agent:main:slack:slash:U1";
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Subagents (current session)");
|
||||
expect(result.reply?.text).toContain("agent:main:subagent:abc");
|
||||
});
|
||||
|
||||
it("omits subagent status line when none exist", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const cfg = {
|
||||
|
||||
@@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
|
||||
const level = currentElevatedLevel ?? "off";
|
||||
return {
|
||||
text: [
|
||||
withOptions(`Current elevated level: ${level}.`, "on, off"),
|
||||
withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"),
|
||||
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
|
||||
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`,
|
||||
};
|
||||
}
|
||||
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
|
||||
@@ -426,7 +426,9 @@ export async function handleDirectiveOnly(params: {
|
||||
parts.push(
|
||||
directives.elevatedLevel === "off"
|
||||
? formatDirectiveAck("Elevated mode disabled.")
|
||||
: formatDirectiveAck("Elevated mode enabled."),
|
||||
: directives.elevatedLevel === "full"
|
||||
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
|
||||
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
|
||||
);
|
||||
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
|
||||
}
|
||||
|
||||
@@ -16,10 +16,15 @@ export const withOptions = (line: string, options: string) =>
|
||||
export const formatElevatedRuntimeHint = () =>
|
||||
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
|
||||
|
||||
export const formatElevatedEvent = (level: ElevatedLevel) =>
|
||||
level === "on"
|
||||
? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed."
|
||||
: "Elevated OFF — exec stays in sandbox.";
|
||||
export const formatElevatedEvent = (level: ElevatedLevel) => {
|
||||
if (level === "full") {
|
||||
return "Elevated FULL — exec runs on host with auto-approval.";
|
||||
}
|
||||
if (level === "ask" || level === "on") {
|
||||
return "Elevated ASK — exec runs on host; approvals may still apply.";
|
||||
}
|
||||
return "Elevated OFF — exec stays in sandbox.";
|
||||
};
|
||||
|
||||
export const formatReasoningEvent = (level: ReasoningLevel) => {
|
||||
if (level === "stream") return "Reasoning STREAM — emit live <think>.";
|
||||
|
||||
@@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
|
||||
import { prependSystemEvents } from "./session-updates.js";
|
||||
|
||||
describe("prependSystemEvents", () => {
|
||||
it("adds a UTC timestamp to queued system events", async () => {
|
||||
it("adds a local timestamp to queued system events by default", async () => {
|
||||
vi.useFakeTimers();
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||
vi.setSystemTime(timestamp);
|
||||
|
||||
@@ -20,11 +22,10 @@ describe("prependSystemEvents", () => {
|
||||
prefixedBodyBase: "User: hi",
|
||||
});
|
||||
|
||||
const expectedTimestamp = "2026-01-12T20:19:17Z";
|
||||
|
||||
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
|
||||
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
||||
|
||||
resetSystemEventsForTest();
|
||||
process.env.TZ = originalTz;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
@@ -27,9 +28,32 @@ export async function prependSystemEvents(params: {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const formatSystemEventTimestamp = (ts: number) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||
const resolveExplicitTimezone = (value: string): string | undefined => {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
|
||||
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
||||
if (!raw) return { mode: "local" as const };
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
|
||||
if (lowered === "user") {
|
||||
return {
|
||||
mode: "iana" as const,
|
||||
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||
};
|
||||
}
|
||||
const explicit = resolveExplicitTimezone(raw);
|
||||
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
|
||||
};
|
||||
|
||||
const formatUtcTimestamp = (date: Date): string => {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
@@ -39,6 +63,42 @@ export async function prependSystemEvents(params: {
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||
};
|
||||
|
||||
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const sec = pick("second");
|
||||
const tz = [...parts]
|
||||
.reverse()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||
};
|
||||
|
||||
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||
const zone = resolveSystemEventTimezone(cfg);
|
||||
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
|
||||
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
|
||||
};
|
||||
|
||||
const systemLines: string[] = [];
|
||||
const queued = drainSystemEventEntries(params.sessionKey);
|
||||
systemLines.push(
|
||||
@@ -46,7 +106,7 @@ export async function prependSystemEvents(params: {
|
||||
.map((event) => {
|
||||
const compacted = compactSystemEvent(event.text);
|
||||
if (!compacted) return null;
|
||||
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
|
||||
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
|
||||
})
|
||||
.filter((v): v is string => Boolean(v)),
|
||||
);
|
||||
|
||||
@@ -324,7 +324,12 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const queueDetails = formatQueueDetails(args.queue);
|
||||
const verboseLabel =
|
||||
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
|
||||
const elevatedLabel = elevatedLevel === "on" ? "elevated" : null;
|
||||
const elevatedLabel =
|
||||
elevatedLevel && elevatedLevel !== "off"
|
||||
? elevatedLevel === "on"
|
||||
? "elevated"
|
||||
: `elevated:${elevatedLevel}`
|
||||
: null;
|
||||
const optionParts = [
|
||||
`Runtime: ${runtime.label}`,
|
||||
`Think: ${thinkLevel}`,
|
||||
@@ -395,7 +400,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
|
||||
"/think <level>",
|
||||
"/verbose on|full|off",
|
||||
"/reasoning on|off",
|
||||
"/elevated on|off",
|
||||
"/elevated on|off|ask|full",
|
||||
"/model <id>",
|
||||
"/usage off|tokens|full",
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
export type VerboseLevel = "off" | "on" | "full";
|
||||
export type ElevatedLevel = "off" | "on";
|
||||
export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
||||
export type ElevatedMode = "off" | "ask" | "full";
|
||||
export type ReasoningLevel = "off" | "on" | "stream";
|
||||
export type UsageDisplayLevel = "off" | "tokens" | "full";
|
||||
|
||||
@@ -112,10 +113,18 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und
|
||||
if (!raw) return undefined;
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
|
||||
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
|
||||
if (["on", "true", "yes", "1"].includes(key)) return "on";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
|
||||
if (!level || level === "off") return "off";
|
||||
if (level === "full") return "full";
|
||||
return "ask";
|
||||
}
|
||||
|
||||
// Normalize reasoning visibility flags used to toggle reasoning exposure.
|
||||
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
|
||||
@@ -180,3 +180,11 @@ export function decorateClawdProfile(
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureProfileCleanExit(userDataDir: string) {
|
||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
||||
const prefs = safeReadJson(preferencesPath) ?? {};
|
||||
setDeep(prefs, ["exit_type"], "Normal");
|
||||
setDeep(prefs, ["exited_cleanly"], true);
|
||||
safeWriteJson(preferencesPath, prefs);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
decorateClawdProfile,
|
||||
ensureProfileCleanExit,
|
||||
findChromeExecutableMac,
|
||||
findChromeExecutableWindows,
|
||||
isChromeReachable,
|
||||
@@ -103,6 +104,18 @@ describe("browser chrome profile decoration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("writes clean exit prefs to avoid restore prompts", async () => {
|
||||
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
||||
try {
|
||||
ensureProfileCleanExit(userDataDir);
|
||||
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
|
||||
expect(prefs.exit_type).toBe("Normal");
|
||||
expect(prefs.exited_cleanly).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent when rerun on an existing profile", async () => {
|
||||
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
type BrowserExecutable,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
||||
import {
|
||||
decorateClawdProfile,
|
||||
ensureProfileCleanExit,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
|
||||
|
||||
@@ -26,7 +30,11 @@ export {
|
||||
findChromeExecutableWindows,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
||||
export {
|
||||
decorateClawdProfile,
|
||||
ensureProfileCleanExit,
|
||||
isProfileDecorated,
|
||||
} from "./chrome.profile-decoration.js";
|
||||
|
||||
function exists(filePath: string) {
|
||||
try {
|
||||
@@ -178,6 +186,8 @@ export async function launchClawdChrome(
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,MediaRouter",
|
||||
"--disable-session-crashed-bubble",
|
||||
"--hide-crash-restore-bubble",
|
||||
"--password-store=basic",
|
||||
];
|
||||
|
||||
@@ -246,6 +256,12 @@ export async function launchClawdChrome(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ensureProfileCleanExit(userDataDir);
|
||||
} catch (err) {
|
||||
log.warn(`clawd browser clean-exit prefs failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const proc = spawnOnce();
|
||||
// Wait for CDP to come up.
|
||||
const readyDeadline = Date.now() + 15_000;
|
||||
|
||||
@@ -1 +1 @@
|
||||
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
|
||||
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("canvas host", () => {
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("ws open timeout")), 2000);
|
||||
const timer = setTimeout(() => reject(new Error("ws open timeout")), 5000);
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
@@ -183,13 +183,14 @@ describe("canvas host", () => {
|
||||
});
|
||||
|
||||
const msg = new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("reload timeout")), 4000);
|
||||
const timer = setTimeout(() => reject(new Error("reload timeout")), 10_000);
|
||||
ws.on("message", (data) => {
|
||||
clearTimeout(timer);
|
||||
resolve(rawDataToString(data));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
|
||||
expect(await msg).toBe("reload");
|
||||
ws.close();
|
||||
@@ -197,7 +198,7 @@ describe("canvas host", () => {
|
||||
await server.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
}, 20_000);
|
||||
|
||||
it("serves the gateway-hosted A2UI scaffold", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));
|
||||
|
||||
@@ -44,6 +44,14 @@ const entries: SubCliEntry[] = [
|
||||
mod.registerGatewayCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "daemon",
|
||||
description: "Gateway service (legacy alias)",
|
||||
register: async (program) => {
|
||||
const mod = await import("../daemon-cli.js");
|
||||
mod.registerDaemonCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "logs",
|
||||
description: "Gateway logs",
|
||||
|
||||
@@ -68,8 +68,12 @@ const STEP_LABELS: Record<string, string> = {
|
||||
"clean check": "Working directory is clean",
|
||||
"upstream check": "Upstream branch exists",
|
||||
"git fetch": "Fetching latest changes",
|
||||
"git rebase": "Rebasing onto upstream",
|
||||
"git rebase": "Rebasing onto target commit",
|
||||
"git rev-parse @{upstream}": "Resolving upstream commit",
|
||||
"git rev-list": "Enumerating candidate commits",
|
||||
"git clone": "Cloning git checkout",
|
||||
"preflight worktree": "Preparing preflight worktree",
|
||||
"preflight cleanup": "Cleaning preflight worktree",
|
||||
"deps install": "Installing dependencies",
|
||||
build: "Building",
|
||||
"ui:build": "Building UI",
|
||||
|
||||
@@ -23,21 +23,33 @@ describe("legacy config detection", () => {
|
||||
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
||||
}
|
||||
});
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
|
||||
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => {
|
||||
it("drops routing.allowFrom when whatsapp missing", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
|
||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
@@ -53,6 +65,26 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).not.toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
|
||||
@@ -156,11 +156,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||
if (allowFrom === undefined) return;
|
||||
|
||||
const channels = ensureRecord(raw, "channels");
|
||||
const whatsapp =
|
||||
channels.whatsapp && typeof channels.whatsapp === "object"
|
||||
? (channels.whatsapp as Record<string, unknown>)
|
||||
: {};
|
||||
const channels = getRecord(raw.channels);
|
||||
const whatsapp = channels ? getRecord(channels.whatsapp) : null;
|
||||
if (!whatsapp) {
|
||||
delete (routing as Record<string, unknown>).allowFrom;
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (whatsapp.allowFrom === undefined) {
|
||||
whatsapp.allowFrom = allowFrom;
|
||||
@@ -173,8 +178,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
channels.whatsapp = whatsapp;
|
||||
raw.channels = channels;
|
||||
channels!.whatsapp = whatsapp;
|
||||
raw.channels = channels!;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -193,7 +198,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const channels = ensureRecord(raw, "channels");
|
||||
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
|
||||
const applyTo = (
|
||||
key: "whatsapp" | "telegram" | "imessage",
|
||||
options?: { requireExisting?: boolean },
|
||||
) => {
|
||||
if (options?.requireExisting && !isRecord(channels[key])) return;
|
||||
const section =
|
||||
channels[key] && typeof channels[key] === "object"
|
||||
? (channels[key] as Record<string, unknown>)
|
||||
@@ -222,7 +231,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
}
|
||||
};
|
||||
|
||||
applyTo("whatsapp");
|
||||
applyTo("whatsapp", { requireExisting: true });
|
||||
applyTo("telegram");
|
||||
applyTo("imessage");
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"skills.load.watch": "Watch Skills",
|
||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"agents.defaults.repoRoot": "Repo Root",
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||
@@ -436,6 +437,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||
"agents.defaults.repoRoot":
|
||||
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
"agents.defaults.envelopeTimezone":
|
||||
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
||||
"agents.defaults.envelopeTimestamp":
|
||||
|
||||
@@ -99,6 +99,8 @@ export type AgentDefaultsConfig = {
|
||||
models?: Record<string, AgentModelEntryConfig>;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
|
||||
repoRoot?: string;
|
||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||
skipBootstrap?: boolean;
|
||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||
@@ -134,7 +136,7 @@ export type AgentDefaultsConfig = {
|
||||
/** Default verbose level when no /verbose directive is present. */
|
||||
verboseDefault?: "off" | "on" | "full";
|
||||
/** Default elevated level when no /elevated directive is present. */
|
||||
elevatedDefault?: "off" | "on";
|
||||
elevatedDefault?: "off" | "on" | "ask" | "full";
|
||||
/** Default block streaming level when no override is present. */
|
||||
blockStreamingDefault?: "off" | "on";
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z
|
||||
)
|
||||
.optional(),
|
||||
workspace: z.string().optional(),
|
||||
repoRoot: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
@@ -112,7 +113,9 @@ export const AgentDefaultsSchema = z
|
||||
])
|
||||
.optional(),
|
||||
verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(),
|
||||
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
elevatedDefault: z
|
||||
.union([z.literal("off"), z.literal("on"), z.literal("ask"), z.literal("full")])
|
||||
.optional(),
|
||||
blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
|
||||
blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(),
|
||||
blockStreamingChunk: BlockStreamingChunkSchema.optional(),
|
||||
|
||||
@@ -113,6 +113,30 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
||||
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||
}
|
||||
|
||||
function isInstructionsRequiredError(error: string): boolean {
|
||||
return /instructions are required/i.test(error);
|
||||
}
|
||||
|
||||
function isOpenAIReasoningSequenceError(error: string): boolean {
|
||||
const msg = error.toLowerCase();
|
||||
return msg.includes("required following item") && msg.includes("reasoning");
|
||||
}
|
||||
|
||||
function isToolNonceRefusal(error: string): boolean {
|
||||
const msg = error.toLowerCase();
|
||||
if (!msg.includes("nonce")) return false;
|
||||
return (
|
||||
msg.includes("token") ||
|
||||
msg.includes("secret") ||
|
||||
msg.includes("local file") ||
|
||||
msg.includes("disclose") ||
|
||||
msg.includes("can't help") ||
|
||||
msg.includes("can’t help") ||
|
||||
msg.includes("can't comply") ||
|
||||
msg.includes("can’t comply")
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingProfileError(error: string): boolean {
|
||||
return /no credentials found for profile/i.test(error);
|
||||
}
|
||||
@@ -856,6 +880,27 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||
break;
|
||||
}
|
||||
if (model.provider === "openai-codex" && isInstructionsRequiredError(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (instructions required)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(model.provider === "openai" || model.provider === "openai-codex") &&
|
||||
isOpenAIReasoningSequenceError(message)
|
||||
) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (openai reasoning sequence error)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(model.provider === "openai" || model.provider === "openai-codex") &&
|
||||
isToolNonceRefusal(message)
|
||||
) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (tool probe refusal)`);
|
||||
break;
|
||||
}
|
||||
if (isMissingProfileError(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (missing auth profile)`);
|
||||
|
||||
@@ -169,7 +169,7 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.elevatedLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeElevatedLevel(String(raw));
|
||||
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")');
|
||||
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
|
||||
// Persist "off" explicitly so patches can override defaults.
|
||||
next.elevatedLevel = normalized;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizeSafeBins,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
type ExecAllowlistEntry,
|
||||
} from "./exec-approvals.js";
|
||||
|
||||
@@ -227,3 +228,32 @@ describe("exec approvals wildcard agent", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals default agent migration", () => {
|
||||
it("migrates legacy default agent entries to main", () => {
|
||||
const file = {
|
||||
version: 1,
|
||||
agents: {
|
||||
default: { allowlist: [{ pattern: "/bin/legacy" }] },
|
||||
},
|
||||
};
|
||||
const resolved = resolveExecApprovalsFromFile({ file });
|
||||
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
|
||||
expect(resolved.file.agents?.default).toBeUndefined();
|
||||
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
|
||||
});
|
||||
|
||||
it("prefers main agent settings when both main and default exist", () => {
|
||||
const file = {
|
||||
version: 1,
|
||||
agents: {
|
||||
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
|
||||
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
|
||||
},
|
||||
};
|
||||
const resolved = resolveExecApprovalsFromFile({ file });
|
||||
expect(resolved.agent.ask).toBe("always");
|
||||
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]);
|
||||
expect(resolved.file.agents?.default).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
|
||||
export type ExecHost = "sandbox" | "gateway" | "node";
|
||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
export type ExecAsk = "off" | "on-miss" | "always";
|
||||
@@ -84,6 +86,35 @@ export function resolveExecApprovalsSocketPath(): string {
|
||||
return expandHome(DEFAULT_SOCKET);
|
||||
}
|
||||
|
||||
function normalizeAllowlistPattern(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function mergeLegacyAgent(
|
||||
current: ExecApprovalsAgent,
|
||||
legacy: ExecApprovalsAgent,
|
||||
): ExecApprovalsAgent {
|
||||
const allowlist: ExecAllowlistEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushEntry = (entry: ExecAllowlistEntry) => {
|
||||
const key = normalizeAllowlistPattern(entry.pattern);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
allowlist.push(entry);
|
||||
};
|
||||
for (const entry of current.allowlist ?? []) pushEntry(entry);
|
||||
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
|
||||
|
||||
return {
|
||||
security: current.security ?? legacy.security,
|
||||
ask: current.ask ?? legacy.ask,
|
||||
askFallback: current.askFallback ?? legacy.askFallback,
|
||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
||||
allowlist: allowlist.length > 0 ? allowlist : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -92,6 +123,13 @@ function ensureDir(filePath: string) {
|
||||
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||
const socketPath = file.socket?.path?.trim();
|
||||
const token = file.socket?.token?.trim();
|
||||
const agents = { ...file.agents };
|
||||
const legacyDefault = agents.default;
|
||||
if (legacyDefault) {
|
||||
const main = agents[DEFAULT_AGENT_ID];
|
||||
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
|
||||
delete agents.default;
|
||||
}
|
||||
const normalized: ExecApprovalsFile = {
|
||||
version: 1,
|
||||
socket: {
|
||||
@@ -104,7 +142,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
|
||||
askFallback: file.defaults?.askFallback,
|
||||
autoAllowSkills: file.defaults?.autoAllowSkills,
|
||||
},
|
||||
agents: file.agents ?? {},
|
||||
agents,
|
||||
};
|
||||
return normalized;
|
||||
}
|
||||
@@ -231,7 +269,7 @@ export function resolveExecApprovalsFromFile(params: {
|
||||
}): ExecApprovalsResolved {
|
||||
const file = normalizeExecApprovals(params.file);
|
||||
const defaults = file.defaults ?? {};
|
||||
const agentKey = params.agentId ?? "default";
|
||||
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
|
||||
const agent = file.agents?.[agentKey] ?? {};
|
||||
const wildcard = file.agents?.["*"] ?? {};
|
||||
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
|
||||
@@ -696,7 +734,7 @@ export function recordAllowlistUse(
|
||||
command: string,
|
||||
resolvedPath?: string,
|
||||
) {
|
||||
const target = agentId ?? "default";
|
||||
const target = agentId ?? DEFAULT_AGENT_ID;
|
||||
const agents = approvals.agents ?? {};
|
||||
const existing = agents[target] ?? {};
|
||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||
@@ -720,7 +758,7 @@ export function addAllowlistEntry(
|
||||
agentId: string | undefined,
|
||||
pattern: string,
|
||||
) {
|
||||
const target = agentId ?? "default";
|
||||
const target = agentId ?? DEFAULT_AGENT_ID;
|
||||
const agents = approvals.agents ?? {};
|
||||
const existing = agents[target] ?? {};
|
||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||
|
||||
@@ -74,7 +74,9 @@ describe("runGatewayUpdate", () => {
|
||||
stdout: "origin/main",
|
||||
},
|
||||
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
|
||||
[`git -C ${tempDir} rev-parse @{upstream}`]: { stdout: "upstream123" },
|
||||
[`git -C ${tempDir} rev-list --max-count=10 upstream123`]: { stdout: "upstream123\n" },
|
||||
[`git -C ${tempDir} rebase upstream123`]: { code: 1, stderr: "conflict" },
|
||||
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||
@@ -63,6 +64,7 @@ type UpdateRunnerOptions = {
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
||||
const MAX_LOG_CHARS = 8000;
|
||||
const PREFLIGHT_MAX_COMMITS = 10;
|
||||
const START_DIRS = ["cwd", "argv1", "process"];
|
||||
|
||||
function normalizeDir(value?: string | null) {
|
||||
@@ -420,8 +422,152 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
);
|
||||
steps.push(fetchStep);
|
||||
|
||||
const upstreamShaStep = await runStep(
|
||||
step(
|
||||
"git rev-parse @{upstream}",
|
||||
["git", "-C", gitRoot, "rev-parse", "@{upstream}"],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
steps.push(upstreamShaStep);
|
||||
const upstreamSha = upstreamShaStep.stdoutTail?.trim();
|
||||
if (!upstreamShaStep.stdoutTail || !upstreamSha) {
|
||||
return {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "no-upstream-sha",
|
||||
before: { sha: beforeSha, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const revListStep = await runStep(
|
||||
step(
|
||||
"git rev-list",
|
||||
["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
steps.push(revListStep);
|
||||
if (revListStep.exitCode !== 0) {
|
||||
return {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "preflight-revlist-failed",
|
||||
before: { sha: beforeSha, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = (revListStep.stdoutTail ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "preflight-no-candidates",
|
||||
before: { sha: beforeSha, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = await detectPackageManager(gitRoot);
|
||||
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-preflight-"));
|
||||
const worktreeDir = path.join(preflightRoot, "worktree");
|
||||
const worktreeStep = await runStep(
|
||||
step(
|
||||
"preflight worktree",
|
||||
["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, upstreamSha],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
steps.push(worktreeStep);
|
||||
if (worktreeStep.exitCode !== 0) {
|
||||
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
|
||||
return {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "preflight-worktree-failed",
|
||||
before: { sha: beforeSha, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let selectedSha: string | null = null;
|
||||
try {
|
||||
for (const sha of candidates) {
|
||||
const shortSha = sha.slice(0, 8);
|
||||
const checkoutStep = await runStep(
|
||||
step(
|
||||
`preflight checkout (${shortSha})`,
|
||||
["git", "-C", worktreeDir, "checkout", "--detach", sha],
|
||||
worktreeDir,
|
||||
),
|
||||
);
|
||||
steps.push(checkoutStep);
|
||||
if (checkoutStep.exitCode !== 0) continue;
|
||||
|
||||
const depsStep = await runStep(
|
||||
step(`preflight deps install (${shortSha})`, managerInstallArgs(manager), worktreeDir),
|
||||
);
|
||||
steps.push(depsStep);
|
||||
if (depsStep.exitCode !== 0) continue;
|
||||
|
||||
const lintStep = await runStep(
|
||||
step(`preflight lint (${shortSha})`, managerScriptArgs(manager, "lint"), worktreeDir),
|
||||
);
|
||||
steps.push(lintStep);
|
||||
if (lintStep.exitCode !== 0) continue;
|
||||
|
||||
const buildStep = await runStep(
|
||||
step(`preflight build (${shortSha})`, managerScriptArgs(manager, "build"), worktreeDir),
|
||||
);
|
||||
steps.push(buildStep);
|
||||
if (buildStep.exitCode !== 0) continue;
|
||||
|
||||
selectedSha = sha;
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
const removeStep = await runStep(
|
||||
step(
|
||||
"preflight cleanup",
|
||||
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
steps.push(removeStep);
|
||||
await runCommand(["git", "-C", gitRoot, "worktree", "prune"], {
|
||||
cwd: gitRoot,
|
||||
timeoutMs,
|
||||
}).catch(() => null);
|
||||
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!selectedSha) {
|
||||
return {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "preflight-no-good-commit",
|
||||
before: { sha: beforeSha, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const rebaseStep = await runStep(
|
||||
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
|
||||
step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot),
|
||||
);
|
||||
steps.push(rebaseStep);
|
||||
if (rebaseStep.exitCode !== 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { createTelegramBot } from "./bot.js";
|
||||
@@ -119,8 +119,11 @@ const getOnHandler = (event: string) => {
|
||||
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
const ORIGINAL_TZ = process.env.TZ;
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TZ = "UTC";
|
||||
resetInboundDedupe();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
@@ -138,6 +141,9 @@ describe("createTelegramBot", () => {
|
||||
botCtorSpy.mockReset();
|
||||
_sequentializeKey = undefined;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.TZ = ORIGINAL_TZ;
|
||||
});
|
||||
|
||||
// groupPolicy tests
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||
@@ -122,8 +122,11 @@ const getOnHandler = (event: string) => {
|
||||
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
const ORIGINAL_TZ = process.env.TZ;
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TZ = "UTC";
|
||||
resetInboundDedupe();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
@@ -141,6 +144,9 @@ describe("createTelegramBot", () => {
|
||||
botCtorSpy.mockReset();
|
||||
sequentializeKey = undefined;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.TZ = ORIGINAL_TZ;
|
||||
});
|
||||
|
||||
// groupPolicy tests
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
@@ -148,8 +148,11 @@ const getOnHandler = (event: string) => {
|
||||
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
const ORIGINAL_TZ = process.env.TZ;
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TZ = "UTC";
|
||||
resetInboundDedupe();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
@@ -168,6 +171,9 @@ describe("createTelegramBot", () => {
|
||||
botCtorSpy.mockReset();
|
||||
sequentializeKey = undefined;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.TZ = ORIGINAL_TZ;
|
||||
});
|
||||
|
||||
it("installs grammY throttler", () => {
|
||||
createTelegramBot({ token: "tok" });
|
||||
@@ -556,94 +562,106 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
|
||||
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
identity: { name: "Bert" },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
try {
|
||||
loadConfig.mockReturnValue({
|
||||
identity: { name: "Bert" },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "group", title: "Test Group" },
|
||||
text: "bert: introduce yourself",
|
||||
date: 1736380800,
|
||||
message_id: 1,
|
||||
from: { id: 9, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "group", title: "Test Group" },
|
||||
text: "bert: introduce yourself",
|
||||
date: 1736380800,
|
||||
message_id: 1,
|
||||
from: { id: 9, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
||||
const timestampPattern = escapeRegExp(expectedTimestamp);
|
||||
expect(payload.Body).toMatch(
|
||||
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
||||
);
|
||||
expect(payload.SenderName).toBe("Ada");
|
||||
expect(payload.SenderId).toBe("9");
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
||||
const timestampPattern = escapeRegExp(expectedTimestamp);
|
||||
expect(payload.Body).toMatch(
|
||||
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
||||
);
|
||||
expect(payload.SenderName).toBe("Ada");
|
||||
expect(payload.SenderId).toBe("9");
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
|
||||
it("includes sender identity in group envelope headers", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
try {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
},
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
||||
const timestampPattern = escapeRegExp(expectedTimestamp);
|
||||
expect(payload.Body).toMatch(
|
||||
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
||||
);
|
||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||
expect(payload.SenderId).toBe("99");
|
||||
expect(payload.SenderUsername).toBe("ada");
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
||||
const timestampPattern = escapeRegExp(expectedTimestamp);
|
||||
expect(payload.Body).toMatch(
|
||||
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
||||
);
|
||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||
expect(payload.SenderId).toBe("99");
|
||||
expect(payload.SenderUsername).toBe("ada");
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
|
||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thi
|
||||
|
||||
const VERBOSE_LEVELS = ["on", "off"];
|
||||
const REASONING_LEVELS = ["on", "off"];
|
||||
const ELEVATED_LEVELS = ["on", "off"];
|
||||
const ELEVATED_LEVELS = ["on", "off", "ask", "full"];
|
||||
const ACTIVATION_LEVELS = ["mention", "always"];
|
||||
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"];
|
||||
|
||||
@@ -83,7 +83,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
|
||||
},
|
||||
{
|
||||
name: "elevated",
|
||||
description: "Set elevated on/off",
|
||||
description: "Set elevated on/off/ask/full",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
|
||||
value,
|
||||
@@ -130,8 +130,8 @@ export function helpText(options: SlashCommandOptions = {}): string {
|
||||
"/verbose <on|off>",
|
||||
"/reasoning <on|off>",
|
||||
"/usage <off|tokens|full>",
|
||||
"/elevated <on|off>",
|
||||
"/elev <on|off>",
|
||||
"/elevated <on|off|ask|full>",
|
||||
"/elev <on|off|ask|full>",
|
||||
"/activation <mention|always>",
|
||||
"/new or /reset",
|
||||
"/abort",
|
||||
|
||||
@@ -371,7 +371,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
}
|
||||
case "elevated":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /elevated <on|off>");
|
||||
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
|
||||
break;
|
||||
}
|
||||
if (!["on", "off", "ask", "full"].includes(args)) {
|
||||
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user