Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3c163c71b5 fix: export SECTION_META for config form (#1418) (thanks @MaudeBot) 2026-01-22 04:33:37 +00:00
Maude Bot 277881b52f fix(ui): export SECTION_META from config-form module
Export the SECTION_META constant from config-form.render.ts and
re-export it through config-form.ts so it can be imported by config.ts.

This fixes a runtime error where SECTION_META was being referenced
but not properly exported from its source module.
2026-01-22 04:16:51 +00:00
Peter Steinberger 5424b4173c fix: localize system event timestamps 2026-01-22 04:15:39 +00:00
Peter Steinberger 30a8478e1a fix: default envelope timestamps to local 2026-01-22 04:10:06 +00:00
Peter Steinberger 2fc926ab1c Merge pull request #1329 from dlauer/feature/agent-avatar-support
feat: add avatar support for agent identity
2026-01-22 04:09:00 +00:00
Peter Steinberger 1ac1e72a47 Merge pull request #1204 from cpojer/reminders
Improve `cron` reminder tool description.
2026-01-22 04:06:50 +00:00
Peter Steinberger 9450873c1b fix: align exec approvals default agent 2026-01-22 04:05:54 +00:00
Peter Steinberger 5fb6a0fd32 fix: map OpenCode Zen models to correct APIs 2026-01-22 04:02:53 +00:00
Peter Steinberger 3b2aff0d6f Merge pull request #1417 from czekaj/fix/exec-allowlist-agentid-derivation
fix(exec): derive agentId from sessionKey for allowlist lookup
2026-01-22 04:01:01 +00:00
Peter Steinberger 2d583e877b fix: default exec approvals to main agent (#1417) (thanks @czekaj) 2026-01-22 03:58:53 +00:00
Lucas Czekaj 0c55b1e9ce fix(exec): derive agentId from sessionKey for allowlist lookup
When creating exec tools via chat/Discord, agentId was not passed,
causing allowlist lookup to use 'default' key instead of 'main'.
User's allowlist entries in agents.main were never matched.

Now derives agentId from sessionKey if not explicitly provided,
ensuring correct allowlist lookup for all exec paths.
2026-01-22 03:58:53 +00:00
Peter Steinberger 51cd9c7ff4 fix: make lobster tool tests windows-safe 2026-01-22 03:58:05 +00:00
Dave Lauer 2f0dd9c4ee chore: fix swift formatting 2026-01-20 16:38:37 -05:00
Dave Lauer 2af497495f chore: regenerate protocol files 2026-01-20 16:21:15 -05:00
Dave Lauer 056b3e40d6 chore: fix formatting 2026-01-20 16:21:14 -05:00
Dave Lauer 6402a48482 feat: add avatar support for agent identity
- Add avatar field to IdentityConfig type
- Add avatar parsing in AgentIdentity from IDENTITY.md
- Add renderAvatar support for image avatars in webchat
- Add CSS styling for image avatars

Users can now configure a custom avatar for the assistant in the webchat
by setting 'identity.avatar' in the agent config or adding 'Avatar: path'
to IDENTITY.md. The avatar can be served from the assets folder.

Closes #TBD
2026-01-20 16:21:14 -05:00
cpojer ed909d6013 Improve cron reminder tool description. 2026-01-19 10:42:21 +09:00
28 changed files with 477 additions and 216 deletions

No files matched your search

+6 -1
View File
@@ -13,13 +13,18 @@ Docs: https://docs.clawd.bot
- Signal: add typing indicators and DM read receipts via signal-cli. - Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes ### Fixes
- Config: avoid stack traces for invalid configs and log the config path. - Config: avoid stack traces for invalid configs and log the config path.
- Doctor: warn when gateway.mode is unset with configure/config guidance. - Doctor: warn when gateway.mode is unset with configure/config guidance.
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Cron: clarify reminder systemEvent wording guidance. (#1204) Thanks @cpojer. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
## 2026.1.21 ## 2026.1.21
@@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults {
enum ExecApprovalsStore { enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals") private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultAgentId = "main"
private static let defaultSecurity: ExecSecurity = .deny private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny private static let defaultAskFallback: ExecSecurity = .deny
@@ -165,13 +166,22 @@ enum ExecApprovalsStore {
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var agents = file.agents ?? [:]
if let legacyDefault = agents["default"] {
if let main = agents[self.defaultAgentId] {
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
} else {
agents[self.defaultAgentId] = legacyDefault
}
agents.removeValue(forKey: "default")
}
return ExecApprovalsFile( return ExecApprovalsFile(
version: 1, version: 1,
socket: ExecApprovalsSocketConfig( socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath, path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token), token: token.isEmpty ? nil : token),
defaults: file.defaults, defaults: file.defaults,
agents: file.agents) agents: agents)
} }
static func readSnapshot() -> ExecApprovalsSnapshot { static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -272,9 +282,7 @@ enum ExecApprovalsStore {
ask: defaults.ask ?? self.defaultAsk, ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback, askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) let key = self.agentKey(agentId)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults( let resolvedAgent = ExecApprovalsResolvedDefaults(
@@ -457,7 +465,36 @@ enum ExecApprovalsStore {
private static func agentKey(_ agentId: String?) -> String { private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "default" : trimmed return trimmed.isEmpty ? self.defaultAgentId : trimmed
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent
) -> ExecApprovalsAgent {
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
return
}
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] { append(entry) }
for entry in legacy.allowlist ?? [] { append(entry) }
return ExecApprovalsAgent(
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.isEmpty ? nil : allowlist)
} }
} }
+7 -6
View File
@@ -9,15 +9,15 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**. Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (UTC by default) ## Message envelopes (local by default)
Inbound messages are wrapped in an envelope like: Inbound messages are wrapped in an envelope like:
``` ```
[Provider ... 2026-01-05T21:26Z] message text [Provider ... 2026-01-05 16:26 PST] message text
``` ```
The timestamp in the envelope is **UTC by default**, with minutes precision. The timestamp in the envelope is **host-local by default**, with minutes precision.
You can override this with: You can override this with:
@@ -25,7 +25,7 @@ You can override this with:
{ {
agents: { agents: {
defaults: { defaults: {
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off" envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off" envelopeElapsed: "on" // "on" | "off"
} }
@@ -33,6 +33,7 @@ You can override this with:
} }
``` ```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). - `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset. - Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. - `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
@@ -40,10 +41,10 @@ You can override this with:
### Examples ### Examples
**UTC (default):** **Local (default):**
``` ```
[Signal Alice +1555 2026-01-18T05:19Z] hello [Signal Alice +1555 2026-01-18 00:19 PST] hello
``` ```
**Fixed timezone:** **Fixed timezone:**
+13 -11
View File
@@ -7,18 +7,18 @@ read_when:
# Date & Time # Date & Time
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**. Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics. Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (UTC by default) ## Message envelopes (local by default)
Inbound messages are wrapped with a UTC timestamp (minute precision): Inbound messages are wrapped with a timestamp (minute precision):
``` ```
[Provider ... 2026-01-05T21:26Z] message text [Provider ... 2026-01-05 16:26 PST] message text
``` ```
This envelope timestamp is **UTC by default**, regardless of the host timezone. This envelope timestamp is **host-local by default**, regardless of the provider timezone.
You can override this behavior: You can override this behavior:
@@ -26,7 +26,7 @@ You can override this behavior:
{ {
agents: { agents: {
defaults: { defaults: {
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off" envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off" envelopeElapsed: "on" // "on" | "off"
} }
@@ -34,6 +34,7 @@ You can override this behavior:
} }
``` ```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "local"` uses the host timezone. - `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). - `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone. - Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
@@ -42,10 +43,10 @@ You can override this behavior:
### Examples ### Examples
**UTC (default):** **Local (default):**
``` ```
[WhatsApp +1555 2026-01-18T05:19Z] hello [WhatsApp +1555 2026-01-18 00:19 PST] hello
``` ```
**User timezone:** **User timezone:**
@@ -73,12 +74,13 @@ Time format: 12-hour
If only the timezone is known, we still include the section and instruct the model If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references. to assume UTC for unknown time references.
## System event lines (UTC) ## System event lines (local by default)
Queued system events inserted into agent context are prefixed with a UTC timestamp: Queued system events inserted into agent context are prefixed with a timestamp using the
same timezone selection as message envelopes (default: host-local).
``` ```
System: [2026-01-12T20:19:17Z] Model switched. System: [2026-01-12 12:19:17 PST] Model switched.
``` ```
### Configure user timezone + format ### Configure user timezone + format
+1
View File
@@ -88,6 +88,7 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**. editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored). Patterns should resolve to **binary paths** (basename-only entries are ignored).
Legacy `agents.default` entries are migrated to `agents.main` on load.
Examples: Examples:
- `~/Projects/**/bin/bird` - `~/Projects/**/bin/bird`
+25 -14
View File
@@ -7,19 +7,32 @@ import { describe, expect, it } from "vitest";
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js"; import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
import { createLobsterTool } from "./lobster-tool.js"; import { createLobsterTool } from "./lobster-tool.js";
async function writeFakeLobster(params: { async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") {
payload: unknown; const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
}) { const isWindows = process.platform === "win32";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
if (isWindows) {
const scriptPath = path.join(dir, "lobster.js");
const cmdPath = path.join(dir, "lobster.cmd");
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
return { dir, binPath: cmdPath };
}
const binPath = path.join(dir, "lobster"); const binPath = path.join(dir, "lobster");
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
const file = `#!/usr/bin/env node\n` +
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
return { dir, binPath }; return { dir, binPath };
} }
async function writeFakeLobster(params: { payload: unknown }) {
const scriptBody =
`const payload = ${JSON.stringify(params.payload)};\n` +
`process.stdout.write(JSON.stringify(payload));\n`;
return await writeFakeLobsterScript(scriptBody);
}
function fakeApi(): ClawdbotPluginApi { function fakeApi(): ClawdbotPluginApi {
return { return {
id: "lobster", id: "lobster",
@@ -82,12 +95,10 @@ describe("lobster plugin tool", () => {
}); });
it("rejects invalid JSON from lobster", async () => { it("rejects invalid JSON from lobster", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-")); const { binPath } = await writeFakeLobsterScript(
const binPath = path.join(dir, "lobster"); `process.stdout.write("nope");\n`,
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, { "clawdbot-lobster-plugin-bad-",
encoding: "utf8", );
mode: 0o755,
});
const tool = createLobsterTool(fakeApi()); const tool = createLobsterTool(fakeApi());
await expect( await expect(
+18 -12
View File
@@ -54,6 +54,7 @@ import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const DEFAULT_MAX_OUTPUT = clampNumber( const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@@ -659,6 +660,11 @@ export function createExecTool(
const notifyOnExit = defaults?.notifyOnExit !== false; const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined; const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs); 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 { return {
name: "exec", name: "exec",
@@ -799,7 +805,7 @@ export function createExecTool(
if (host === "node") { if (host === "node") {
const approvals = resolveExecApprovals( const approvals = resolveExecApprovals(
defaults?.agentId, agentId,
host === "node" ? { security: "allowlist" } : undefined, host === "node" ? { security: "allowlist" } : undefined,
); );
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
@@ -865,7 +871,7 @@ export function createExecTool(
cwd: workdir, cwd: workdir,
env: nodeEnv, env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId, agentId,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey,
approved: approvedByAsk, approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined, approvalDecision: approvalDecision ?? undefined,
@@ -895,9 +901,9 @@ export function createExecTool(
host: "node", host: "node",
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: defaults?.agentId, agentId,
resolvedPath: undefined, resolvedPath: null,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@@ -1026,7 +1032,7 @@ export function createExecTool(
} }
if (host === "gateway") { if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" }); const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback; const askFallback = approvals.agent.askFallback;
@@ -1060,7 +1066,7 @@ export function createExecTool(
const approvalSlug = createApprovalSlug(approvalId); const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`; 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 noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command; const commandText = params.command;
const effectiveTimeout = const effectiveTimeout =
@@ -1080,9 +1086,9 @@ export function createExecTool(
host: "gateway", host: "gateway",
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: defaults?.agentId, agentId,
resolvedPath, resolvedPath,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@@ -1123,7 +1129,7 @@ export function createExecTool(
for (const segment of analysis.segments) { for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? ""; const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) { if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern); addAllowlistEntry(approvals.file, agentId, pattern);
} }
} }
} }
@@ -1152,7 +1158,7 @@ export function createExecTool(
seen.add(match.pattern); seen.add(match.pattern);
recordAllowlistUse( recordAllowlistUse(
approvals.file, approvals.file,
defaults?.agentId, agentId,
match, match,
commandText, commandText,
resolvedPath ?? undefined, resolvedPath ?? undefined,
@@ -1242,7 +1248,7 @@ export function createExecTool(
seen.add(match.pattern); seen.add(match.pattern);
recordAllowlistUse( recordAllowlistUse(
approvals.file, approvals.file,
defaults?.agentId, agentId,
match, match,
params.command, params.command,
analysis.segments[0]?.resolution?.resolvedPath, analysis.segments[0]?.resolution?.resolvedPath,
+2
View File
@@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => {
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages"); expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai"); expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses"); 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("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions"); expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
}); });
+5 -5
View File
@@ -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 { export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase(); 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"; return "anthropic-messages";
} }
if (lower.startsWith("gemini-")) { if (lower.startsWith("gemini-")) {
return "google-generative-ai"; return "google-generative-ai";
} }
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions"; return "openai-completions";
} }
+2 -2
View File
@@ -200,7 +200,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser", browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas", canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes", nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; 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", message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running Clawdbot process", gateway: "Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn", agents_list: "List agent ids allowed for sessions_spawn",
@@ -351,7 +351,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser", "- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas", "- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes", "- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; 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_list: list sessions",
"- sessions_history: fetch session history", "- sessions_history: fetch session history",
"- sessions_send: send to another session", "- sessions_send: send to another session",
+6 -5
View File
@@ -18,6 +18,7 @@ describe("formatAgentEnvelope", () => {
host: "mac-mini", host: "mac-mini",
ip: "10.0.0.5", ip: "10.0.0.5",
timestamp: ts, timestamp: ts,
envelope: { timezone: "utc" },
body: "hello", body: "hello",
}); });
@@ -26,7 +27,7 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello"); 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; const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles"; process.env.TZ = "America/Los_Angeles";
@@ -39,10 +40,10 @@ describe("formatAgentEnvelope", () => {
process.env.TZ = originalTz; 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; const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles"; process.env.TZ = "America/Los_Angeles";
@@ -50,13 +51,13 @@ describe("formatAgentEnvelope", () => {
const body = formatAgentEnvelope({ const body = formatAgentEnvelope({
channel: "WebChat", channel: "WebChat",
timestamp: ts, timestamp: ts,
envelope: { timezone: "local" }, envelope: { timezone: "utc" },
body: "hello", body: "hello",
}); });
process.env.TZ = originalTz; 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", () => { it("formats timestamps in user timezone when configured", () => {
+3 -3
View File
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
export type EnvelopeFormatOptions = { 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; timezone?: string;
/** /**
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
const includeTimestamp = options?.includeTimestamp !== false; const includeTimestamp = options?.includeTimestamp !== false;
const includeElapsed = options?.includeElapsed !== false; const includeElapsed = options?.includeElapsed !== false;
return { return {
timezone: options?.timezone?.trim() || "utc", timezone: options?.timezone?.trim() || "local",
includeTimestamp, includeTimestamp,
includeElapsed, includeElapsed,
userTimezone: options?.userTimezone, userTimezone: options?.userTimezone,
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone { function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim(); const trimmed = options.timezone?.trim();
if (!trimmed) return { mode: "utc" }; if (!trimmed) return { mode: "local" };
const lowered = trimmed.toLowerCase(); const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" }; if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" }; if (lowered === "local" || lowered === "host") return { mode: "local" };
+5 -4
View File
@@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js"; import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => { 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(); vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z"); const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp); vi.setSystemTime(timestamp);
@@ -20,11 +22,10 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi", prefixedBodyBase: "User: hi",
}); });
const expectedTimestamp = "2026-01-12T20:19:17Z"; expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
resetSystemEventsForTest(); resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
+64 -4
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { resolveUserTimezone } from "../../agents/date-time.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
@@ -27,9 +28,32 @@ export async function prependSystemEvents(params: {
return trimmed; return trimmed;
}; };
const formatSystemEventTimestamp = (ts: number) => { const resolveExplicitTimezone = (value: string): string | undefined => {
const date = new Date(ts); try {
if (Number.isNaN(date.getTime())) return "unknown-time"; 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 yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).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`; 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 systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey); const queued = drainSystemEventEntries(params.sessionKey);
systemLines.push( systemLines.push(
@@ -46,7 +106,7 @@ export async function prependSystemEvents(params: {
.map((event) => { .map((event) => {
const compacted = compactSystemEvent(event.text); const compacted = compactSystemEvent(event.text);
if (!compacted) return null; if (!compacted) return null;
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`; return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
}) })
.filter((v): v is string => Boolean(v)), .filter((v): v is string => Boolean(v)),
); );
+10 -1
View File
@@ -34,6 +34,7 @@ export type AgentIdentity = {
creature?: string; creature?: string;
vibe?: string; vibe?: string;
theme?: string; theme?: string;
avatar?: string;
}; };
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
@@ -90,6 +91,7 @@ export function parseIdentityMarkdown(content: string): AgentIdentity {
if (label === "creature") identity.creature = value; if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value; if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value; if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
} }
return identity; return identity;
} }
@@ -99,7 +101,14 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null {
try { try {
const content = fs.readFileSync(identityPath, "utf-8"); const content = fs.readFileSync(identityPath, "utf-8");
const parsed = parseIdentityMarkdown(content); const parsed = parseIdentityMarkdown(content);
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) { if (
!parsed.name &&
!parsed.emoji &&
!parsed.theme &&
!parsed.creature &&
!parsed.vibe &&
!parsed.avatar
) {
return null; return null;
} }
return parsed; return parsed;
+2
View File
@@ -154,4 +154,6 @@ export type IdentityConfig = {
name?: string; name?: string;
theme?: string; theme?: string;
emoji?: string; emoji?: string;
/** Path to a custom avatar image (relative to workspace or absolute). */
avatar?: string;
}; };
+33
View File
@@ -14,6 +14,7 @@ import {
normalizeSafeBins, normalizeSafeBins,
resolveCommandResolution, resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
resolveExecApprovalsFromFile,
type ExecAllowlistEntry, type ExecAllowlistEntry,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -227,3 +228,35 @@ 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();
});
});
+42 -4
View File
@@ -4,6 +4,8 @@ import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always"; export type ExecAsk = "off" | "on-miss" | "always";
@@ -84,6 +86,35 @@ export function resolveExecApprovalsSocketPath(): string {
return expandHome(DEFAULT_SOCKET); 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) { function ensureDir(filePath: string) {
const dir = path.dirname(filePath); const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -92,6 +123,13 @@ function ensureDir(filePath: string) {
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim(); const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.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 = { const normalized: ExecApprovalsFile = {
version: 1, version: 1,
socket: { socket: {
@@ -104,7 +142,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
askFallback: file.defaults?.askFallback, askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills, autoAllowSkills: file.defaults?.autoAllowSkills,
}, },
agents: file.agents ?? {}, agents,
}; };
return normalized; return normalized;
} }
@@ -231,7 +269,7 @@ export function resolveExecApprovalsFromFile(params: {
}): ExecApprovalsResolved { }): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file); const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {}; const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? "default"; const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agent = file.agents?.[agentKey] ?? {}; const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {}; const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY; const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
@@ -696,7 +734,7 @@ export function recordAllowlistUse(
command: string, command: string,
resolvedPath?: string, resolvedPath?: string,
) { ) {
const target = agentId ?? "default"; const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {}; const agents = approvals.agents ?? {};
const existing = agents[target] ?? {}; const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@@ -720,7 +758,7 @@ export function addAllowlistEntry(
agentId: string | undefined, agentId: string | undefined,
pattern: string, pattern: string,
) { ) {
const target = agentId ?? "default"; const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {}; const agents = approvals.agents ?? {};
const existing = agents[target] ?? {}; const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@@ -141,83 +141,101 @@ describe("createTelegramBot", () => {
// groupPolicy tests // groupPolicy tests
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
onSpy.mockReset(); const originalTz = process.env.TZ;
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; process.env.TZ = "UTC";
replySpy.mockReset();
loadConfig.mockReturnValue({ try {
identity: { name: "Bert" }, onSpy.mockReset();
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
channels: { replySpy.mockReset();
telegram: {
groupPolicy: "open", loadConfig.mockReturnValue({
groups: { "*": { requireMention: true } }, identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 7, type: "group", title: "Test Group" }, chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself", text: "bert: introduce yourself",
date: 1736380800, date: 1736380800,
message_id: 1, message_id: 1,
from: { id: 9, first_name: "Ada" }, from: { id: 9, first_name: "Ada" },
}, },
me: { username: "clawdbot_bot" }, me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }), getFile: async () => ({ download: async () => new Uint8Array() }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true); expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada"); expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9"); expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
}); });
it("keeps group envelope headers stable (sender identity is separate)", async () => { it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset(); const originalTz = process.env.TZ;
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; process.env.TZ = "UTC";
replySpy.mockReset();
loadConfig.mockReturnValue({ try {
channels: { onSpy.mockReset();
telegram: { const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
groupPolicy: "open", replySpy.mockReset();
groups: { "*": { requireMention: false } },
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 42, type: "group", title: "Ops" }, chat: { id: 42, type: "group", title: "Ops" },
text: "hello", text: "hello",
date: 1736380800, date: 1736380800,
message_id: 2, message_id: 2,
from: { from: {
id: 99, id: 99,
first_name: "Ada", first_name: "Ada",
last_name: "Lovelace", last_name: "Lovelace",
username: "ada", username: "ada",
},
}, },
}, me: { username: "clawdbot_bot" },
me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: async () => ({ download: async () => new Uint8Array() }), });
});
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99"); expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada"); expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.Body).toMatch(
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
}); });
it("reacts to mention-gated group messages when ackReaction is enabled", async () => { it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset(); onSpy.mockReset();
@@ -329,7 +329,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch( expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/, /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
); );
expect(payload.Body).toContain("hello world"); expect(payload.Body).toContain("hello world");
} finally { } finally {
+85 -67
View File
@@ -451,7 +451,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch( expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/, /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
); );
expect(payload.Body).toContain("hello world"); expect(payload.Body).toContain("hello world");
} finally { } finally {
@@ -551,86 +551,104 @@ describe("createTelegramBot", () => {
}); });
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
onSpy.mockReset(); const originalTz = process.env.TZ;
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; process.env.TZ = "UTC";
replySpy.mockReset();
loadConfig.mockReturnValue({ try {
identity: { name: "Bert" }, onSpy.mockReset();
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
channels: { replySpy.mockReset();
telegram: {
groupPolicy: "open", loadConfig.mockReturnValue({
groups: { "*": { requireMention: true } }, identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 7, type: "group", title: "Test Group" }, chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself", text: "bert: introduce yourself",
date: 1736380800, date: 1736380800,
message_id: 1, message_id: 1,
from: { id: 9, first_name: "Ada" }, from: { id: 9, first_name: "Ada" },
}, },
me: { username: "clawdbot_bot" }, me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }), getFile: async () => ({ download: async () => new Uint8Array() }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload); expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true); expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.Body).toMatch(
expect(payload.SenderName).toBe("Ada"); /^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
expect(payload.SenderId).toBe("9"); );
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
} finally {
process.env.TZ = originalTz;
}
}); });
it("includes sender identity in group envelope headers", async () => { it("includes sender identity in group envelope headers", async () => {
onSpy.mockReset(); const originalTz = process.env.TZ;
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; process.env.TZ = "UTC";
replySpy.mockReset();
loadConfig.mockReturnValue({ try {
channels: { onSpy.mockReset();
telegram: { const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
groupPolicy: "open", replySpy.mockReset();
groups: { "*": { requireMention: false } },
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 42, type: "group", title: "Ops" }, chat: { id: 42, type: "group", title: "Ops" },
text: "hello", text: "hello",
date: 1736380800, date: 1736380800,
message_id: 2, message_id: 2,
from: { from: {
id: 99, id: 99,
first_name: "Ada", first_name: "Ada",
last_name: "Lovelace", last_name: "Lovelace",
username: "ada", username: "ada",
},
}, },
}, me: { username: "clawdbot_bot" },
me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: async () => ({ download: async () => new Uint8Array() }), });
});
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload); expectInboundContextContract(payload);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.Body).toMatch(
expect(payload.SenderName).toBe("Ada Lovelace"); /^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
expect(payload.SenderId).toBe("99"); );
expect(payload.SenderUsername).toBe("ada"); 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 () => { it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
@@ -329,11 +329,11 @@ describe("web auto-reply", () => {
const firstArgs = resolver.mock.calls[0][0]; const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][0]; const secondArgs = resolver.mock.calls[1][0];
expect(firstArgs.Body).toMatch( expect(firstArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/, /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/,
); );
expect(firstArgs.Body).not.toContain("second"); expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toMatch( expect(secondArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/, /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/,
); );
expect(secondArgs.Body).not.toContain("first"); expect(secondArgs.Body).not.toContain("first");
+6
View File
@@ -89,6 +89,12 @@
color: rgba(134, 142, 150, 1); color: rgba(134, 142, 150, 1);
} }
/* Image avatar support */
img.chat-avatar {
object-fit: cover;
object-position: center;
}
/* Minimal Bubble Design - dynamic width based on content */ /* Minimal Bubble Design - dynamic width based on content */
.chat-bubble { .chat-bubble {
position: relative; position: relative;
+7 -1
View File
@@ -105,7 +105,7 @@ export function renderMessageGroup(
`; `;
} }
function renderAvatar(role: string) { function renderAvatar(role: string, avatarUrl?: string) {
const normalized = normalizeRoleForGrouping(role); const normalized = normalizeRoleForGrouping(role);
const initial = const initial =
normalized === "user" normalized === "user"
@@ -123,6 +123,12 @@ function renderAvatar(role: string) {
: normalized === "tool" : normalized === "tool"
? "tool" ? "tool"
: "other"; : "other";
// If avatar URL is provided for assistant, show image
if (avatarUrl && normalized === "assistant") {
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`; return html`<div class="chat-avatar ${className}">${initial}</div>`;
} }
+5 -1
View File
@@ -1,7 +1,7 @@
import { render } from "lit"; import { render } from "lit";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./views/config-form";
const rootSchema = { const rootSchema = {
type: "object", type: "object",
@@ -40,6 +40,10 @@ const rootSchema = {
}; };
describe("config form renderer", () => { describe("config form renderer", () => {
it("exposes section metadata", () => {
expect(SECTION_META.env.label).toBe("Environment Variables");
});
it("renders inputs and patches values", () => { it("renders inputs and patches values", () => {
const onPatch = vi.fn(); const onPatch = vi.fn();
const container = document.createElement("div"); const container = document.createElement("div");
+1 -1
View File
@@ -54,7 +54,7 @@ const sectionIcons = {
}; };
// Section metadata // Section metadata
const SECTION_META: Record<string, { label: string; description: string }> = { export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" }, env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
update: { label: "Updates", description: "Auto-update settings and release channel" }, update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" }, agents: { label: "Agents", description: "Agent configurations, models, and identities" },
+1 -1
View File
@@ -1,4 +1,4 @@
export { renderConfigForm, type ConfigFormProps } from "./config-form.render"; export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export { export {
analyzeConfigSchema, analyzeConfigSchema,
type ConfigSchemaAnalysis, type ConfigSchemaAnalysis,
+1 -1
View File
@@ -1,6 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { import {
hintForPath, hintForPath,
humanize, humanize,