From ed909d6013b5c6c6e1af86f9c545708fde67317f Mon Sep 17 00:00:00 2001 From: cpojer Date: Mon, 19 Jan 2026 10:42:21 +0900 Subject: [PATCH 01/34] Improve `cron` reminder tool description. --- src/agents/system-prompt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 22fd92f83..951749dcc 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -195,7 +195,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", @@ -346,7 +346,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", From 6402a48482b70ff1d4d7f5b659c8f876d1754118 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 14:45:58 -0500 Subject: [PATCH 02/34] 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 --- src/commands/agents.config.ts | 4 +++- src/config/types.base.ts | 2 ++ ui/src/styles/chat/grouped.css | 6 ++++++ ui/src/ui/chat/grouped-render.ts | 8 +++++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 26c70932f..3aca27d70 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -34,6 +34,7 @@ export type AgentIdentity = { creature?: string; vibe?: string; theme?: string; + avatar?: string; }; export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { @@ -90,6 +91,7 @@ export function parseIdentityMarkdown(content: string): AgentIdentity { if (label === "creature") identity.creature = value; if (label === "vibe") identity.vibe = value; if (label === "theme") identity.theme = value; + if (label === "avatar") identity.avatar = value; } return identity; } @@ -99,7 +101,7 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null { try { const content = fs.readFileSync(identityPath, "utf-8"); 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 parsed; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 827ec5abb..65b9cf68c 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -144,4 +144,6 @@ export type IdentityConfig = { name?: string; theme?: string; emoji?: string; + /** Path to a custom avatar image (relative to workspace or absolute). */ + avatar?: string; }; diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index d0e05e508..158ad2e0a 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -89,6 +89,12 @@ 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 */ .chat-bubble { display: inline-block; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index e84d2b7f1..b9f1ef234 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -104,7 +104,7 @@ export function renderMessageGroup( `; } -function renderAvatar(role: string) { +function renderAvatar(role: string, avatarUrl?: string) { const normalized = normalizeRoleForGrouping(role); const initial = normalized === "user" @@ -122,6 +122,12 @@ function renderAvatar(role: string) { : normalized === "tool" ? "tool" : "other"; + + // If avatar URL is provided for assistant, show image + if (avatarUrl && normalized === "assistant") { + return html`Assistant`; + } + return html`
${initial}
`; } From 056b3e40d6d1896d0dcc0f5a00ee7db1b2e69439 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 16:02:55 -0500 Subject: [PATCH 03/34] chore: fix formatting --- src/commands/agents.config.ts | 9 +- src/gateway/protocol/schema/sessions.ts | 146 ++++++++++++------------ src/tui/components/fuzzy-filter.ts | 138 +++++++++++----------- src/tui/components/selectors.ts | 5 +- src/utils/time-format.ts | 24 ++-- 5 files changed, 163 insertions(+), 159 deletions(-) diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 3aca27d70..cd778740e 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -101,7 +101,14 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null { try { const content = fs.readFileSync(identityPath, "utf-8"); const parsed = parseIdentityMarkdown(content); - if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe && !parsed.avatar) { + if ( + !parsed.name && + !parsed.emoji && + !parsed.theme && + !parsed.creature && + !parsed.vibe && + !parsed.avatar + ) { return null; } return parsed; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 217981bb2..42fa83ff6 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; export const SessionsListParamsSchema = Type.Object( - { - limit: Type.Optional(Type.Integer({ minimum: 1 })), - activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - /** - * Read first 8KB of each session transcript to derive title from first user message. - * Performs a file read per session - use `limit` to bound result set on large stores. - */ - includeDerivedTitles: Type.Optional(Type.Boolean()), - /** - * Read last 16KB of each session transcript to extract most recent message preview. - * Performs a file read per session - use `limit` to bound result set on large stores. - */ - includeLastMessage: Type.Optional(Type.Boolean()), - label: Type.Optional(SessionLabelString), - spawnedBy: Type.Optional(NonEmptyString), - agentId: Type.Optional(NonEmptyString), - search: Type.Optional(Type.String()), - }, - { additionalProperties: false }, + { + limit: Type.Optional(Type.Integer({ minimum: 1 })), + activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + /** + * Read first 8KB of each session transcript to derive title from first user message. + * Performs a file read per session - use `limit` to bound result set on large stores. + */ + includeDerivedTitles: Type.Optional(Type.Boolean()), + /** + * Read last 16KB of each session transcript to extract most recent message preview. + * Performs a file read per session - use `limit` to bound result set on large stores. + */ + includeLastMessage: Type.Optional(Type.Boolean()), + label: Type.Optional(SessionLabelString), + spawnedBy: Type.Optional(NonEmptyString), + agentId: Type.Optional(NonEmptyString), + search: Type.Optional(Type.String()), + }, + { additionalProperties: false }, ); export const SessionsResolveParamsSchema = Type.Object( - { - key: Type.Optional(NonEmptyString), - label: Type.Optional(SessionLabelString), - agentId: Type.Optional(NonEmptyString), - spawnedBy: Type.Optional(NonEmptyString), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, + { + key: Type.Optional(NonEmptyString), + label: Type.Optional(SessionLabelString), + agentId: Type.Optional(NonEmptyString), + spawnedBy: Type.Optional(NonEmptyString), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, ); export const SessionsPatchParamsSchema = Type.Object( - { - key: NonEmptyString, - label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), - thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - responseUsage: Type.Optional( - Type.Union([ - Type.Literal("off"), - Type.Literal("tokens"), - Type.Literal("full"), - // Backward compat with older clients/stores. - Type.Literal("on"), - Type.Null(), - ]), - ), - elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - sendPolicy: Type.Optional( - Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), - ), - groupActivation: Type.Optional( - Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), - ), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), + thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + responseUsage: Type.Optional( + Type.Union([ + Type.Literal("off"), + Type.Literal("tokens"), + Type.Literal("full"), + // Backward compat with older clients/stores. + Type.Literal("on"), + Type.Null(), + ]), + ), + elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + sendPolicy: Type.Optional( + Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), + ), + groupActivation: Type.Optional( + Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), + ), + }, + { additionalProperties: false }, ); export const SessionsResetParamsSchema = Type.Object( - { key: NonEmptyString }, - { additionalProperties: false }, + { key: NonEmptyString }, + { additionalProperties: false }, ); export const SessionsDeleteParamsSchema = Type.Object( - { - key: NonEmptyString, - deleteTranscript: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + deleteTranscript: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, ); export const SessionsCompactParamsSchema = Type.Object( - { - key: NonEmptyString, - maxLines: Type.Optional(Type.Integer({ minimum: 1 })), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + maxLines: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, ); diff --git a/src/tui/components/fuzzy-filter.ts b/src/tui/components/fuzzy-filter.ts index 76a688d3b..fb6e2acf2 100644 --- a/src/tui/components/fuzzy-filter.ts +++ b/src/tui/components/fuzzy-filter.ts @@ -11,7 +11,7 @@ const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/; * Check if position is at a word boundary. */ export function isWordBoundary(text: string, index: number): boolean { - return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); + return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); } /** @@ -19,17 +19,17 @@ export function isWordBoundary(text: string, index: number): boolean { * Returns null if no match. */ export function findWordBoundaryIndex(text: string, query: string): number | null { - if (!query) return null; - const textLower = text.toLowerCase(); - const queryLower = query.toLowerCase(); - const maxIndex = textLower.length - queryLower.length; - if (maxIndex < 0) return null; - for (let i = 0; i <= maxIndex; i++) { - if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { - return i; - } - } - return null; + if (!query) return null; + const textLower = text.toLowerCase(); + const queryLower = query.toLowerCase(); + const maxIndex = textLower.length - queryLower.length; + if (maxIndex < 0) return null; + for (let i = 0; i <= maxIndex; i++) { + if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { + return i; + } + } + return null; } /** @@ -37,31 +37,31 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul * Returns score (lower = better) or null if no match. */ export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { - if (queryLower.length === 0) return 0; - if (queryLower.length > textLower.length) return null; + if (queryLower.length === 0) return 0; + if (queryLower.length > textLower.length) return null; - let queryIndex = 0; - let score = 0; - let lastMatchIndex = -1; - let consecutiveMatches = 0; + let queryIndex = 0; + let score = 0; + let lastMatchIndex = -1; + let consecutiveMatches = 0; - for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { - if (textLower[i] === queryLower[queryIndex]) { - const isAtWordBoundary = isWordBoundary(textLower, i); - if (lastMatchIndex === i - 1) { - consecutiveMatches++; - score -= consecutiveMatches * 5; // Reward consecutive matches - } else { - consecutiveMatches = 0; - if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps - } - if (isAtWordBoundary) score -= 10; // Reward word boundary matches - score += i * 0.1; // Slight penalty for later matches - lastMatchIndex = i; - queryIndex++; - } - } - return queryIndex < queryLower.length ? null : score; + for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { + if (textLower[i] === queryLower[queryIndex]) { + const isAtWordBoundary = isWordBoundary(textLower, i); + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; // Reward consecutive matches + } else { + consecutiveMatches = 0; + if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps + } + if (isAtWordBoundary) score -= 10; // Reward word boundary matches + score += i * 0.1; // Slight penalty for later matches + lastMatchIndex = i; + queryIndex++; + } + } + return queryIndex < queryLower.length ? null : score; } /** @@ -69,46 +69,46 @@ export function fuzzyMatchLower(queryLower: string, textLower: string): number | * Supports space-separated tokens (all must match). */ export function fuzzyFilterLower( - items: T[], - queryLower: string, + items: T[], + queryLower: string, ): T[] { - const trimmed = queryLower.trim(); - if (!trimmed) return items; + const trimmed = queryLower.trim(); + if (!trimmed) return items; - const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); - if (tokens.length === 0) return items; + const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0) return items; - const results: { item: T; score: number }[] = []; - for (const item of items) { - const text = item.searchTextLower ?? ""; - let totalScore = 0; - let allMatch = true; - for (const token of tokens) { - const score = fuzzyMatchLower(token, text); - if (score !== null) { - totalScore += score; - } else { - allMatch = false; - break; - } - } - if (allMatch) results.push({ item, score: totalScore }); - } - results.sort((a, b) => a.score - b.score); - return results.map((r) => r.item); + const results: { item: T; score: number }[] = []; + for (const item of items) { + const text = item.searchTextLower ?? ""; + let totalScore = 0; + let allMatch = true; + for (const token of tokens) { + const score = fuzzyMatchLower(token, text); + if (score !== null) { + totalScore += score; + } else { + allMatch = false; + break; + } + } + if (allMatch) results.push({ item, score: totalScore }); + } + results.sort((a, b) => a.score - b.score); + return results.map((r) => r.item); } /** * Prepare items for fuzzy filtering by pre-computing lowercase search text. */ -export function prepareSearchItems( - items: T[], -): (T & { searchTextLower: string })[] { - return items.map((item) => { - const parts: string[] = []; - if (item.label) parts.push(item.label); - if (item.description) parts.push(item.description); - if (item.searchText) parts.push(item.searchText); - return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; - }); +export function prepareSearchItems< + T extends { label?: string; description?: string; searchText?: string }, +>(items: T[]): (T & { searchTextLower: string })[] { + return items.map((item) => { + const parts: string[] = []; + if (item.label) parts.push(item.label); + if (item.description) parts.push(item.description); + if (item.searchText) parts.push(item.searchText); + return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; + }); } diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index ba37ff7c9..46073fbca 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -5,10 +5,7 @@ import { selectListTheme, settingsListTheme, } from "../theme/theme.js"; -import { - FilterableSelectList, - type FilterableSelectItem, -} from "./filterable-select-list.js"; +import { FilterableSelectList, type FilterableSelectItem } from "./filterable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js"; export function createSelectList(items: SelectItem[], maxVisible = 7) { diff --git a/src/utils/time-format.ts b/src/utils/time-format.ts index f5d4ee81b..bd473e4f6 100644 --- a/src/utils/time-format.ts +++ b/src/utils/time-format.ts @@ -1,15 +1,15 @@ export function formatRelativeTime(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); - if (seconds < 60) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days === 1) return "Yesterday"; - if (days < 7) return `${days}d ago`; - return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + if (seconds < 60) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } From 2af497495f102bccbc25958f48e44aaeb14fc425 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 16:14:29 -0500 Subject: [PATCH 04/34] chore: regenerate protocol files --- .../ClawdbotProtocol/GatewayModels.swift | 18 ++++++++++- .../ClawdbotProtocol/GatewayModels.swift | 30 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 85696eb6a..04c3bab09 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable { public let replychannel: String? public let accountid: String? public let replyaccountid: String? + public let threadid: String? public let timeout: Int? public let lane: String? public let extrasystemprompt: String? @@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable { replychannel: String?, accountid: String?, replyaccountid: String?, + threadid: String?, timeout: Int?, lane: String?, extrasystemprompt: String?, @@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable { self.replychannel = replychannel self.accountid = accountid self.replyaccountid = replyaccountid + self.threadid = threadid self.timeout = timeout self.lane = lane self.extrasystemprompt = extrasystemprompt @@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable { case replychannel = "replyChannel" case accountid = "accountId" case replyaccountid = "replyAccountId" + case threadid = "threadId" case timeout case lane case extrasystemprompt = "extraSystemPrompt" @@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let includederivedtitles: Bool? + public let includelastmessage: Bool? public let label: String? public let spawnedby: String? public let agentid: String? + public let search: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, + includederivedtitles: Bool?, + includelastmessage: Bool?, label: String?, spawnedby: String?, - agentid: String? + agentid: String?, + search: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.includederivedtitles = includederivedtitles + self.includelastmessage = includelastmessage self.label = label self.spawnedby = spawnedby self.agentid = agentid + self.search = search } private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case includederivedtitles = "includeDerivedTitles" + case includelastmessage = "includeLastMessage" case label case spawnedby = "spawnedBy" case agentid = "agentId" + case search } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift index dd01ffe70..04c3bab09 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift @@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable { public let replychannel: String? public let accountid: String? public let replyaccountid: String? + public let threadid: String? public let timeout: Int? public let lane: String? public let extrasystemprompt: String? @@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable { replychannel: String?, accountid: String?, replyaccountid: String?, + threadid: String?, timeout: Int?, lane: String?, extrasystemprompt: String?, @@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable { self.replychannel = replychannel self.accountid = accountid self.replyaccountid = replyaccountid + self.threadid = threadid self.timeout = timeout self.lane = lane self.extrasystemprompt = extrasystemprompt @@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable { case replychannel = "replyChannel" case accountid = "accountId" case replyaccountid = "replyAccountId" + case threadid = "threadId" case timeout case lane case extrasystemprompt = "extraSystemPrompt" @@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable { public let activeminutes: Int? public let includeglobal: Bool? public let includeunknown: Bool? + public let includederivedtitles: Bool? + public let includelastmessage: Bool? public let label: String? public let spawnedby: String? public let agentid: String? + public let search: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, + includederivedtitles: Bool?, + includelastmessage: Bool?, label: String?, spawnedby: String?, - agentid: String? + agentid: String?, + search: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown + self.includederivedtitles = includederivedtitles + self.includelastmessage = includelastmessage self.label = label self.spawnedby = spawnedby self.agentid = agentid + self.search = search } private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case includederivedtitles = "includeDerivedTitles" + case includelastmessage = "includeLastMessage" case label case spawnedby = "spawnedBy" case agentid = "agentId" + case search } } @@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable { public let ts: Int public let channelorder: [String] public let channellabels: [String: AnyCodable] + public let channeldetaillabels: [String: AnyCodable]? + public let channelsystemimages: [String: AnyCodable]? + public let channelmeta: [[String: AnyCodable]]? public let channels: [String: AnyCodable] public let channelaccounts: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable] @@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable { ts: Int, channelorder: [String], channellabels: [String: AnyCodable], + channeldetaillabels: [String: AnyCodable]?, + channelsystemimages: [String: AnyCodable]?, + channelmeta: [[String: AnyCodable]]?, channels: [String: AnyCodable], channelaccounts: [String: AnyCodable], channeldefaultaccountid: [String: AnyCodable] @@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable { self.ts = ts self.channelorder = channelorder self.channellabels = channellabels + self.channeldetaillabels = channeldetaillabels + self.channelsystemimages = channelsystemimages + self.channelmeta = channelmeta self.channels = channels self.channelaccounts = channelaccounts self.channeldefaultaccountid = channeldefaultaccountid @@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable { case ts case channelorder = "channelOrder" case channellabels = "channelLabels" + case channeldetaillabels = "channelDetailLabels" + case channelsystemimages = "channelSystemImages" + case channelmeta = "channelMeta" case channels case channelaccounts = "channelAccounts" case channeldefaultaccountid = "channelDefaultAccountId" From 2f0dd9c4ee5ef51e8aca9f8f23d977361aa2be64 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Tue, 20 Jan 2026 16:38:37 -0500 Subject: [PATCH 05/34] chore: fix swift formatting --- .../Sources/Clawdbot/ExecApprovalsSocket.swift | 2 +- .../Sources/ClawdbotMacCLI/ConnectCommand.swift | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift index bf2ffc149..268d155a0 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift @@ -319,7 +319,7 @@ private enum ExecHostExecutor { security: context.security, allowlistMatch: context.allowlistMatch, skillAllow: context.skillAllow), - approvalDecision == nil + approvalDecision == nil { let decision = ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( diff --git a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift index 08e8cdde6..ac4938fb8 100644 --- a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift @@ -7,7 +7,7 @@ struct ConnectOptions { var token: String? var password: String? var mode: String? - var timeoutMs: Int = 15_000 + var timeoutMs: Int = 15000 var json: Bool = false var probe: Bool = false var clientId: String = "clawdbot-macos" @@ -254,8 +254,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) if resolvedMode == "remote" { guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) + !raw.isEmpty + else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) } guard let url = URL(string: raw) else { throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) @@ -270,7 +274,10 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) let port = config.port ?? 18789 let host = "127.0.0.1" guard let url = URL(string: "ws://\(host):\(port)") else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) } return GatewayEndpoint( url: url, @@ -280,7 +287,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) } private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { - return try? resolveGatewayEndpoint(opts: opts, config: config) + try? resolveGatewayEndpoint(opts: opts, config: config) } private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { From 51cd9c7ff4f35dbfc60399953a3259e3c3c0469f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 03:57:56 +0000 Subject: [PATCH 06/34] fix: make lobster tool tests windows-safe --- extensions/lobster/src/lobster-tool.test.ts | 39 +++++++++++++-------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 1c69b3280..5c887cc76 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -7,19 +7,32 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js"; import { createLobsterTool } from "./lobster-tool.js"; -async function writeFakeLobster(params: { - payload: unknown; -}) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-")); +async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const isWindows = process.platform === "win32"; + + 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 file = `#!/usr/bin/env node\n` + - `process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`; - + const file = `#!/usr/bin/env node\n${scriptBody}\n`; await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); 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 { return { id: "lobster", @@ -82,12 +95,10 @@ describe("lobster plugin tool", () => { }); it("rejects invalid JSON from lobster", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-")); - const binPath = path.join(dir, "lobster"); - await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, { - encoding: "utf8", - mode: 0o755, - }); + const { binPath } = await writeFakeLobsterScript( + `process.stdout.write("nope");\n`, + "clawdbot-lobster-plugin-bad-", + ); const tool = createLobsterTool(fakeApi()); await expect( From 0c55b1e9ce0ac486a23ec7f9afaad7e00c8152ef Mon Sep 17 00:00:00 2001 From: Lucas Czekaj Date: Wed, 21 Jan 2026 18:55:32 -0800 Subject: [PATCH 07/34] 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. --- src/agents/bash-tools.exec.ts | 30 +++++++++++++---------- src/infra/exec-approvals.test.ts | 33 +++++++++++++++++++++++++ src/infra/exec-approvals.ts | 41 ++++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 3d58bc705..91b38dc3b 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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"), @@ -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", @@ -799,7 +805,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 +871,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 +901,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; @@ -1026,7 +1032,7 @@ export function createExecTool( } if (host === "gateway") { - const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" }); + 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 +1066,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 +1086,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 +1129,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 +1158,7 @@ export function createExecTool( seen.add(match.pattern); recordAllowlistUse( approvals.file, - defaults?.agentId, + agentId, match, commandText, resolvedPath ?? undefined, @@ -1242,7 +1248,7 @@ export function createExecTool( seen.add(match.pattern); recordAllowlistUse( approvals.file, - defaults?.agentId, + agentId, match, params.command, analysis.segments[0]?.resolution?.resolvedPath, diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index d6474a39c..0bb12c192 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -14,6 +14,7 @@ import { normalizeSafeBins, resolveCommandResolution, resolveExecApprovals, + resolveExecApprovalsFromFile, type ExecAllowlistEntry, } 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(); + }); +}); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index ee8b1c541..40e3d9c7d 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -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,32 @@ 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(); + 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 +120,15 @@ 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 +141,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi askFallback: file.defaults?.askFallback, autoAllowSkills: file.defaults?.autoAllowSkills, }, - agents: file.agents ?? {}, + agents, }; return normalized; } @@ -231,7 +268,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; From 2d583e877b60c76c231f57b67e4a880159f48c30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 03:57:17 +0000 Subject: [PATCH 08/34] fix: default exec approvals to main agent (#1417) (thanks @czekaj) --- CHANGELOG.md | 1 + src/infra/exec-approvals.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aa73616..51124675b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.clawd.bot - Doctor: warn when gateway.mode is unset with configure/config guidance. - 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. +- 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. ## 2026.1.21 diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 40e3d9c7d..616124765 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -91,7 +91,10 @@ function normalizeAllowlistPattern(value: string | undefined): string | null { return trimmed ? trimmed.toLowerCase() : null; } -function mergeLegacyAgent(current: ExecApprovalsAgent, legacy: ExecApprovalsAgent): ExecApprovalsAgent { +function mergeLegacyAgent( + current: ExecApprovalsAgent, + legacy: ExecApprovalsAgent, +): ExecApprovalsAgent { const allowlist: ExecAllowlistEntry[] = []; const seen = new Set(); const pushEntry = (entry: ExecAllowlistEntry) => { @@ -120,13 +123,11 @@ 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 agents = { ...file.agents }; const legacyDefault = agents.default; if (legacyDefault) { const main = agents[DEFAULT_AGENT_ID]; - agents[DEFAULT_AGENT_ID] = main - ? mergeLegacyAgent(main, legacyDefault) - : legacyDefault; + agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault; delete agents.default; } const normalized: ExecApprovalsFile = { From 5fb6a0fd32fd6e6ed150fced44a76f1b9f7c7221 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:02:42 +0000 Subject: [PATCH 09/34] fix: map OpenCode Zen models to correct APIs --- CHANGELOG.md | 1 + src/agents/opencode-zen-models.test.ts | 2 ++ src/agents/opencode-zen-models.ts | 10 +++++----- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51124675b..558f67cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.clawd.bot ### Fixes - Config: avoid stack traces for invalid configs and log the config path. - 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) - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. diff --git a/src/agents/opencode-zen-models.test.ts b/src/agents/opencode-zen-models.test.ts index e732bae44..d46b0ac6b 100644 --- a/src/agents/opencode-zen-models.test.ts +++ b/src/agents/opencode-zen-models.test.ts @@ -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"); }); diff --git a/src/agents/opencode-zen-models.ts b/src/agents/opencode-zen-models.ts index 6eb87b855..bf1734d5e 100644 --- a/src/agents/opencode-zen-models.ts +++ b/src/agents/opencode-zen-models.ts @@ -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"; } From f40f16608c8390529dd5bb6a51b7ca24ea25e268 Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Wed, 21 Jan 2026 23:03:08 -0500 Subject: [PATCH 10/34] 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. --- ui/src/ui/views/config-form.render.ts | 2 +- ui/src/ui/views/config-form.ts | 2 +- ui/src/ui/views/config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 60f2feb75..149b40801 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -54,7 +54,7 @@ const sectionIcons = { }; // Section metadata -const SECTION_META: Record = { +export const SECTION_META: Record = { env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" }, update: { label: "Updates", description: "Auto-update settings and release channel" }, agents: { label: "Agents", description: "Agent configurations, models, and identities" }, diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index 6675bef26..0bcfe0a9c 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -1,4 +1,4 @@ -export { renderConfigForm, type ConfigFormProps } from "./config-form.render"; +export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render"; export { analyzeConfigSchema, type ConfigSchemaAnalysis, diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 9af992024..53b550efe 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,6 +1,6 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; -import { analyzeConfigSchema, renderConfigForm } from "./config-form"; +import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form"; import { hintForPath, humanize, From 9450873c1b6a8e7ea535693f4563f76eb33657ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:05:54 +0000 Subject: [PATCH 11/34] fix: align exec approvals default agent --- .../Sources/Clawdbot/ExecApprovals.swift | 47 +++++++++++++++++-- docs/tools/exec-approvals.md | 1 + src/infra/exec-approvals.ts | 4 +- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index ad1a18300..53e0b10a8 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults { enum ExecApprovalsStore { 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 defaultAsk: ExecAsk = .onMiss private static let defaultAskFallback: ExecSecurity = .deny @@ -165,13 +166,22 @@ enum ExecApprovalsStore { static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { let socketPath = file.socket?.path?.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( version: 1, socket: ExecApprovalsSocketConfig( path: socketPath.isEmpty ? nil : socketPath, token: token.isEmpty ? nil : token), defaults: file.defaults, - agents: file.agents) + agents: agents) } static func readSnapshot() -> ExecApprovalsSnapshot { @@ -272,9 +282,7 @@ enum ExecApprovalsStore { ask: defaults.ask ?? self.defaultAsk, askFallback: defaults.askFallback ?? self.defaultAskFallback, autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) - let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? agentId!.trimmingCharacters(in: .whitespacesAndNewlines) - : "default" + let key = self.agentKey(agentId) let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() let resolvedAgent = ExecApprovalsResolvedDefaults( @@ -457,7 +465,36 @@ enum ExecApprovalsStore { private static func agentKey(_ agentId: String?) -> String { 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() + 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) } } diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 517b73fbe..4bef999ae 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -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 editing in the macOS app. Patterns are **case-insensitive glob matches**. Patterns should resolve to **binary paths** (basename-only entries are ignored). +Legacy `agents.default` entries are migrated to `agents.main` on load. Examples: - `~/Projects/**/bin/bird` diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 616124765..b6d3549f4 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -734,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 : []; @@ -758,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 : []; From 30a8478e1ad68df3118935e98608d983fc22af0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:09:57 +0000 Subject: [PATCH 12/34] fix: default envelope timestamps to local --- CHANGELOG.md | 3 +++ docs/concepts/timezone.md | 13 +++++++------ docs/date-time.md | 17 +++++++++-------- src/auto-reply/envelope.test.ts | 11 ++++++----- src/auto-reply/envelope.ts | 6 +++--- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558f67cb3..6c755959c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Docs: https://docs.clawd.bot - Signal: add typing indicators and DM read receipts via signal-cli. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. +### Breaking +- **BREAKING:** Envelope timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. + ### Fixes - Config: avoid stack traces for invalid configs and log the config path. - Doctor: warn when gateway.mode is unset with configure/config guidance. diff --git a/docs/concepts/timezone.md b/docs/concepts/timezone.md index 3a6d3a4dd..b8b168cc9 100644 --- a/docs/concepts/timezone.md +++ b/docs/concepts/timezone.md @@ -9,15 +9,15 @@ read_when: 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: ``` -[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: @@ -25,7 +25,7 @@ You can override this with: { agents: { defaults: { - envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone + envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone envelopeTimestamp: "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). - Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset. - `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. @@ -40,10 +41,10 @@ You can override this with: ### 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:** diff --git a/docs/date-time.md b/docs/date-time.md index 99da67630..97383ef38 100644 --- a/docs/date-time.md +++ b/docs/date-time.md @@ -7,18 +7,18 @@ read_when: # 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. -## 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: @@ -26,7 +26,7 @@ You can override this behavior: { agents: { defaults: { - envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone + envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone envelopeTimestamp: "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: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). - Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone. @@ -42,10 +43,10 @@ You can override this behavior: ### Examples -**UTC (default):** +**Local (default):** ``` -[WhatsApp +1555 2026-01-18T05:19Z] hello +[WhatsApp +1555 2026-01-18 00:19 PST] hello ``` **User timezone:** diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index d811fbd2f..7860ecb49 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -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", () => { diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 513be2aa4..53622d5e5 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -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" }; From 5424b4173c54ce65b90f5af1f0fa4e66e85d6736 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:15:39 +0000 Subject: [PATCH 13/34] fix: localize system event timestamps --- CHANGELOG.md | 2 +- docs/date-time.md | 7 +- src/auto-reply/reply/session-updates.test.ts | 9 +-- src/auto-reply/reply/session-updates.ts | 68 ++++++++++++++++++-- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c755959c..d5e457d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Docs: https://docs.clawd.bot - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. ### Breaking -- **BREAKING:** Envelope timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. +- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. ### Fixes - Config: avoid stack traces for invalid configs and log the config path. diff --git a/docs/date-time.md b/docs/date-time.md index 97383ef38..8b711350d 100644 --- a/docs/date-time.md +++ b/docs/date-time.md @@ -74,12 +74,13 @@ Time format: 12-hour If only the timezone is known, we still include the section and instruct the model 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 diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts index 05def80da..d673e2b4f 100644 --- a/src/auto-reply/reply/session-updates.test.ts +++ b/src/auto-reply/reply/session-updates.test.ts @@ -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(); }); }); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 227e61cd5..e5ad81d8e 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -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)), ); From 9ae03b92bb4958e5d97d512ee90762c2e6203c8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:11:46 +0000 Subject: [PATCH 14/34] docs: clarify prompt injection guidance --- docs/gateway/security.md | 15 +++++++++++++++ docs/start/faq.md | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index e429205ef..d969ce3e6 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -178,6 +178,20 @@ Even with strong system prompts, **prompt injection is not solved**. What helps - Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem. - **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)). +### Prompt injection does not require public DMs + +Even if **only you** can message the bot, prompt injection can still happen via +any **untrusted content** the bot reads (web search/fetch results, browser pages, +emails, docs, attachments, pasted logs/code). In other words: the sender is not +the only threat surface; the **content itself** can carry adversarial instructions. + +When tools are enabled, the typical risk is exfiltrating context or triggering +tool calls. Reduce the blast radius by: +- Using a read-only or tool-disabled **reader agent** to summarize untrusted content, + then pass the summary to your main agent. +- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed. +- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input. + ### Model strength (security note) Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts. @@ -187,6 +201,7 @@ Recommendations: - **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes. - If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists). - When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled. + - For chat-only personal assistants with trusted input and no tools, smaller models are usually fine. ## Reasoning & verbose output in groups diff --git a/docs/start/faq.md b/docs/start/faq.md index ff45c89b0..c292303ab 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -117,6 +117,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent) - [Security and access control](#security-and-access-control) - [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms) + - [Is prompt injection only a concern for public bots?](#is-prompt-injection-only-a-concern-for-public-bots) + - [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks) - [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code) - [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work) - [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop) @@ -1539,6 +1541,28 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk: Run `clawdbot doctor` to surface risky DM policies. +### Is prompt injection only a concern for public bots? + +No. Prompt injection is about **untrusted content**, not just who can DM the bot. +If your assistant reads external content (web search/fetch, browser pages, emails, +docs, attachments, pasted logs), that content can include instructions that try +to hijack the model. This can happen even if **you are the only sender**. + +The biggest risk is when tools are enabled: the model can be tricked into +exfiltrating context or calling tools on your behalf. Reduce the blast radius by: +- using a read-only or tool-disabled "reader" agent to summarize untrusted content +- keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents +- sandboxing and strict tool allowlists + +Details: [Security](/gateway/security). + +### Can I use cheaper models for personal assistant tasks? + +Yes, **if** the agent is chat-only and the input is trusted. Smaller tiers are +more susceptible to instruction hijacking, so avoid them for tool-enabled agents +or when reading untrusted content. If you must use a smaller model, lock down +tools and run inside a sandbox. See [Security](/gateway/security). + ### I ran `/start` in Telegram but didn’t get a pairing code Pairing codes are sent **only** when an unknown sender messages the bot and From ff3d8cab2bc98b5b8a2c098dfc442b56ab328640 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:19:29 +0000 Subject: [PATCH 15/34] feat: preflight update runner before rebase --- docs/cli/update.md | 12 +- scripts/e2e/Dockerfile | 1 + scripts/e2e/onboard-docker.sh | 122 ++++++++++----- src/agents/models.profiles.live.test.ts | 13 ++ src/cli/update-cli.ts | 6 +- .../gateway-models.profiles.live.test.ts | 45 ++++++ src/infra/update-runner.test.ts | 4 +- src/infra/update-runner.ts | 148 +++++++++++++++++- 8 files changed, 306 insertions(+), 45 deletions(-) diff --git a/docs/cli/update.md b/docs/cli/update.md index acac61b20..9ebe509b0 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -69,11 +69,13 @@ High-level: 1. Requires a clean worktree (no uncommitted changes). 2. Switches to the selected channel (tag or branch). -3. Fetches and rebases against `@{upstream}` (dev only). -4. Installs deps (pnpm preferred; npm fallback). -5. Builds + builds the Control UI. -6. Runs `clawdbot doctor` as the final “safe update” check. -7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins. +3. Fetches upstream (dev only). +4. Dev only: preflight lint + TypeScript build in a temp worktree; if the tip fails, walks back up to 10 commits to find the newest clean build. +5. Rebases onto the selected commit (dev only). +6. Installs deps (pnpm preferred; npm fallback). +7. Builds + builds the Control UI. +8. Runs `clawdbot doctor` as the final “safe update” check. +9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins. ## `--update` shorthand diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 5092e38d1..b5a7c5500 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -11,6 +11,7 @@ COPY src ./src COPY scripts ./scripts COPY docs ./docs COPY skills ./skills +COPY extensions/memory-core ./extensions/memory-core RUN pnpm install --frozen-lockfile RUN pnpm build diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 560c2d9a5..42de5434d 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -51,14 +51,27 @@ TRASH start_s="$(date +%s)" while true; do if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then - if NEEDLE="$needle_compact" node --input-type=module -e " + if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then + return 0 + fi + if NEEDLE=\"$needle_compact\" node --input-type=module -e " import fs from \"node:fs\"; const file = process.env.WIZARD_LOG_PATH; const needle = process.env.NEEDLE ?? \"\"; let text = \"\"; try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } - text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\"); - process.exit(text.includes(needle) ? 0 : 1); + if (text.length > 20000) text = text.slice(-20000); + const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\"); + const haystack = sanitize(text); + const safeNeedle = sanitize(needle); + const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]); + let escaped = \"\"; + for (const ch of safeNeedle) { + escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch; + } + const pattern = escaped.split(\"\").join(\".*\"); + const re = new RegExp(pattern, \"i\"); + process.exit(re.test(haystack) ? 0 : 1); "; then return 0 fi @@ -80,13 +93,35 @@ TRASH } wait_for_gateway() { - for _ in $(seq 1 10); do - if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then + for _ in $(seq 1 20); do + if node --input-type=module -e " + import net from 'node:net'; + const socket = net.createConnection({ host: '127.0.0.1', port: 18789 }); + const timeout = setTimeout(() => { + socket.destroy(); + process.exit(1); + }, 500); + socket.on('connect', () => { + clearTimeout(timeout); + socket.end(); + process.exit(0); + }); + socket.on('error', () => { + clearTimeout(timeout); + process.exit(1); + }); + " >/dev/null 2>&1; then return 0 fi + if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then + if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then + return 0 + fi + fi sleep 1 done - cat /tmp/gateway-e2e.log + echo "Gateway failed to start" + cat /tmp/gateway-e2e.log || true return 1 } @@ -116,7 +151,7 @@ TRASH WIZARD_LOG_PATH="$log_path" export WIZARD_LOG_PATH # Run under script to keep an interactive TTY for clack prompts. - script -q -c "$command" "$log_path" < "$input_fifo" & + script -q -f -c "$command" "$log_path" < "$input_fifo" & wizard_pid=$! exec 3> "$input_fifo" @@ -129,8 +164,18 @@ TRASH "$send_fn" + if ! wait "$wizard_pid"; then + wizard_status=$? + exec 3>&- + rm -f "$input_fifo" + stop_gateway "$gw_pid" + echo "Wizard exited with status $wizard_status" + if [ -f "$log_path" ]; then + tail -n 160 "$log_path" || true + fi + exit "$wizard_status" + fi exec 3>&- - wait "$wizard_pid" rm -f "$input_fifo" stop_gateway "$gw_pid" if [ -n "$validate_fn" ]; then @@ -176,14 +221,18 @@ TRASH send_local_basic() { # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 60 send $'"'"'y\r'"'"' 0.6 # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI. - send $'"'"'\r'"'"' 0.5 + if wait_for_log "Where will the Gateway run?" 20; then + send $'"'"'\r'"'"' 0.5 + fi select_skip_hooks } send_reset_config_only() { # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 40 || true send $'"'"'y\r'"'"' 0.8 # Select reset flow for existing config. wait_for_log "Config handling" 40 || true @@ -211,19 +260,27 @@ TRASH send_skills_flow() { # Select skills section and skip optional installs. - wait_for_log "Where will the Gateway run?" 40 || true - send $'"'"'\r'"'"' 0.8 + send $'"'"'\r'"'"' 1.2 # Configure skills now? -> No - wait_for_log "Configure skills now?" 40 || true - send $'"'"'n\r'"'"' 0.8 - wait_for_log "Configure complete." 40 || true - send "" 0.8 + send $'"'"'n\r'"'"' 1.5 + send "" 1.0 } run_case_local_basic() { local home_dir home_dir="$(make_home local-basic)" - run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log + export HOME="$home_dir" + mkdir -p "$HOME" + node dist/index.js onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --mode local \ + --skip-channels \ + --skip-skills \ + --skip-daemon \ + --skip-ui \ + --skip-health # Assert config + workspace scaffolding. workspace_dir="$HOME/clawd" @@ -283,25 +340,6 @@ if (errors.length > 0) { } NODE - node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 & - GW_PID=$! - # Gate on gateway readiness, then run health. - for _ in $(seq 1 10); do - if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then - break - fi - sleep 1 - done - - if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then - cat /tmp/gateway.log - exit 1 - fi - - node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1) - - kill "$GW_PID" - wait "$GW_PID" || true } run_case_remote_non_interactive() { @@ -355,7 +393,7 @@ NODE # Seed a remote config to exercise reset path. cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"' { - "agent": { "workspace": "/root/old" }, + "agents": { "defaults": { "workspace": "/root/old" } }, "gateway": { "mode": "remote", "remote": { "url": "ws://old.example:18789", "token": "old-token" } @@ -363,7 +401,17 @@ NODE } JSON - run_wizard reset-config "$home_dir" send_reset_config_only + node dist/index.js onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --mode local \ + --reset \ + --skip-channels \ + --skip-skills \ + --skip-daemon \ + --skip-ui \ + --skip-health config_path="$HOME/.clawdbot/clawdbot.json" assert_file "$config_path" diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 032c82992..6d0122d1e 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -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; diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 088a021bf..37ac4fc1c 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -68,8 +68,12 @@ const STEP_LABELS: Record = { "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", diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 5ca96efc9..8b95e6eb8 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -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)`); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index f01a03d67..e33159326 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -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: "" }, }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 994788ee2..0a5196fd7 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -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) { From fd597a796b1f1371ec19b511d99294fde9d0d672 Mon Sep 17 00:00:00 2001 From: James Groat Date: Wed, 21 Jan 2026 21:27:34 -0700 Subject: [PATCH 16/34] Browser: suppress Chrome restore prompt --- src/browser/chrome.profile-decoration.ts | 8 ++++++++ src/browser/chrome.test.ts | 13 +++++++++++++ src/browser/chrome.ts | 20 ++++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/browser/chrome.profile-decoration.ts b/src/browser/chrome.profile-decoration.ts index 7c5d96d6f..49192466c 100644 --- a/src/browser/chrome.profile-decoration.ts +++ b/src/browser/chrome.profile-decoration.ts @@ -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); +} diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index a8a42ae95..da8e384da 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -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 { diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index eebf399bc..6f610bcc4 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -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; From 55ead9636c7c6ae7902a4f3a43a49faa633ef727 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:28:50 +0000 Subject: [PATCH 17/34] docs: add /model allowlist troubleshooting note --- CHANGELOG.md | 1 + docs/help/troubleshooting.md | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e457d2d..355cee7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting +- Docs: add /model allowlist troubleshooting note. (#1405) - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Signal: add typing indicators and DM read receipts via signal-cli. diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 94e769764..1cef34b11 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -53,6 +53,15 @@ Almost always a Node/npm PATH issue. Start here: - [Models](/cli/models) - [OAuth / auth concepts](/concepts/oauth) +### `/model` says `model not allowed` + +This usually means `agents.defaults.models` is configured as an allowlist. When it’s non-empty, +only those provider/model keys can be selected. + +- Check the allowlist: `clawdbot config get agents.defaults.models` +- Add the model you want (or clear the allowlist) and retry `/model` +- Use `/models` to browse the allowed providers/models + ### When filing an issue Paste a safe report: From 351c73be01686800bd2a71969eea35b462dee7d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:26:18 +0000 Subject: [PATCH 18/34] docs: fix npm prefix guidance --- docs/install/index.md | 9 ++++++--- docs/install/node.md | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/install/index.md b/docs/install/index.md index 467ea6bf0..57b09c4cf 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -155,18 +155,21 @@ Quick diagnosis: ```bash node -v npm -v -npm bin -g +npm prefix -g echo "$PATH" ``` -If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). +If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`): ```bash -export PATH="/path/from/npm/bin/-g:$PATH" +# macOS / Linux +export PATH="$(npm prefix -g)/bin:$PATH" ``` +On Windows, add the output of `npm prefix -g` to your PATH. + Then open a new terminal (or `rehash` in zsh / `hash -r` in bash). ## Update / uninstall diff --git a/docs/install/node.md b/docs/install/node.md index 8987a859b..6a622e198 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -19,33 +19,36 @@ Run: ```bash node -v npm -v -npm bin -g +npm prefix -g echo "$PATH" ``` -If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). +If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). ## Fix: put npm’s global bin dir on PATH -1) Find your global bin directory: +1) Find your global npm prefix: ```bash -npm bin -g +npm prefix -g ``` -2) Add it to your shell startup file: +2) Add the global npm bin directory to your shell startup file: - zsh: `~/.zshrc` - bash: `~/.bashrc` -Example (replace the path with your `npm bin -g` output): +Example (replace the path with your `npm prefix -g` output): ```bash -export PATH="/path/from/npm/bin/-g:$PATH" +# macOS / Linux +export PATH="/path/from/npm/prefix/bin:$PATH" ``` Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash). +On Windows, add the output of `npm prefix -g` to your PATH. + ## Fix: avoid `sudo npm install -g` / permission errors (Linux) If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory: @@ -63,7 +66,7 @@ Persist the `export PATH=...` line in your shell startup file. You’ll have the fewest surprises if Node/npm are installed in a way that: - keeps Node updated (22+) -- makes `npm bin -g` stable and on PATH in new shells +- makes the global npm bin dir stable and on PATH in new shells Common choices: From 13dab38a2683291dd1e1e4961f06904ffcad2c95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:31:11 +0000 Subject: [PATCH 19/34] fix: retry lobster spawn on windows --- extensions/lobster/src/lobster-tool.ts | 42 +++++++++++++++++++++----- src/infra/exec-approvals.test.ts | 5 +-- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 71dade859..60c0a2429 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -29,13 +29,22 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) { return lobsterPath; } -async function runLobsterSubprocess(params: { - execPath: string; - argv: string[]; - cwd: string; - timeoutMs: number; - maxStdoutBytes: number; -}) { +function isWindowsSpawnEINVAL(err: unknown) { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + return code === "EINVAL"; +} + +async function runLobsterSubprocessOnce( + params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; + }, + useShell: boolean, +) { const { execPath, argv, cwd } = params; const timeoutMs = Math.max(200, params.timeoutMs); const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); @@ -51,6 +60,8 @@ async function runLobsterSubprocess(params: { cwd, stdio: ["ignore", "pipe", "pipe"], env, + shell: useShell, + windowsHide: useShell ? true : undefined, }); let stdout = ""; @@ -102,6 +113,23 @@ async function runLobsterSubprocess(params: { }); } +async function runLobsterSubprocess(params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; +}) { + try { + return await runLobsterSubprocessOnce(params, false); + } catch (err) { + if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) { + return await runLobsterSubprocessOnce(params, true); + } + throw err; + } +} + function parseEnvelope(stdout: string): LobsterEnvelope { let parsed: unknown; try { diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 0bb12c192..f6d77b2f1 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -253,10 +253,7 @@ describe("exec approvals default agent migration", () => { }; const resolved = resolveExecApprovalsFromFile({ file }); expect(resolved.agent.ask).toBe("always"); - expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([ - "/bin/main", - "/bin/legacy", - ]); + expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]); expect(resolved.file.agents?.default).toBeUndefined(); }); }); From b60db040e26d3ea6bdb0bc0e13757edaff9b710c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:49:34 +0000 Subject: [PATCH 20/34] test: align envelope timestamps with local tz --- ...patterns-match-without-botusername.test.ts | 15 +- ...gram-bot.installs-grammy-throttler.test.ts | 2 +- src/telegram/bot.test.ts | 138 ++++++++++-------- ....reconnects-after-connection-close.test.ts | 4 +- 4 files changed, 92 insertions(+), 67 deletions(-) diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 7134d7d3b..eae2de919 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot } from "./bot.js"; @@ -89,6 +89,7 @@ vi.mock("grammy", () => ({ const sequentializeMiddleware = vi.fn(); const sequentializeSpy = vi.fn(() => sequentializeMiddleware); let _sequentializeKey: ((ctx: unknown) => string) | undefined; +let originalTz: string | undefined; vi.mock("@grammyjs/runner", () => ({ sequentialize: (keyFn: (ctx: unknown) => string) => { _sequentializeKey = keyFn; @@ -120,6 +121,8 @@ const getOnHandler = (event: string) => { describe("createTelegramBot", () => { beforeEach(() => { + originalTz = process.env.TZ; + process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { @@ -138,6 +141,10 @@ describe("createTelegramBot", () => { _sequentializeKey = undefined; }); + afterEach(() => { + process.env.TZ = originalTz; + }); + // groupPolicy tests it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { @@ -176,7 +183,9 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); expect(payload.SenderName).toBe("Ada"); 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 [^\]]+\]/, + ); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { onSpy.mockReset(); @@ -217,7 +226,7 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); 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 [^\]]+\]/); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { onSpy.mockReset(); diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index de7f6b62b..cabdfeae7 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -329,7 +329,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; 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"); } finally { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 77f50b41f..2e7c86cf8 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -451,7 +451,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; 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"); } finally { @@ -551,86 +551,102 @@ 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; 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) => Promise; + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - 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); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); - 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); + expect(payload.Body).toMatch( + /^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + ); + 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; 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) => Promise; + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - 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); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); - 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); + expect(payload.Body).toMatch( + /^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + ); + 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 () => { diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index 411875e21..b9a15e869 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -329,11 +329,11 @@ describe("web auto-reply", () => { const firstArgs = resolver.mock.calls[0][0]; const secondArgs = resolver.mock.calls[1][0]; 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(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"); From f02960df26774e07ef48a203907831be8f9b3cd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:49:49 +0000 Subject: [PATCH 21/34] fix: avoid whatsapp config resurrection --- CHANGELOG.md | 1 + ...etection.rejects-routing-allowfrom.test.ts | 36 +++++++++++++++++-- src/config/legacy.migrations.part-1.ts | 27 +++++++++----- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 355cee7ad..86c881fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.clawd.bot ### Fixes - Config: avoid stack traces for invalid configs and log the config path. +- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900) - 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) diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index fd0f2f296..dbc08339d 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -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"); diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index d1d0a57e7..f537c3ce8 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -156,11 +156,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ const allowFrom = (routing as Record).allowFrom; if (allowFrom === undefined) return; - const channels = ensureRecord(raw, "channels"); - const whatsapp = - channels.whatsapp && typeof channels.whatsapp === "object" - ? (channels.whatsapp as Record) - : {}; + const channels = getRecord(raw.channels); + const whatsapp = channels ? getRecord(channels.whatsapp) : null; + if (!whatsapp) { + delete (routing as Record).allowFrom; + if (Object.keys(routing as Record).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).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) @@ -222,7 +231,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ } }; - applyTo("whatsapp"); + applyTo("whatsapp", { requireExisting: true }); applyTo("telegram"); applyTo("imessage"); From 9ead31211856eb50ab6ddbf04731c7e7df067d12 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:45:46 +0000 Subject: [PATCH 22/34] feat(macos): move location access to permissions tab --- .../Sources/Clawdbot/GeneralSettings.swift | 60 ---------------- .../Clawdbot/PermissionsSettings.swift | 70 +++++++++++++++++++ 2 files changed, 70 insertions(+), 60 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 5c64f0d63..daa07466d 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -2,15 +2,12 @@ import AppKit import ClawdbotDiscovery import ClawdbotIPC import ClawdbotKit -import CoreLocation import Observation import SwiftUI struct GeneralSettings: View { @Bindable var state: AppState @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false - @AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue - @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true private let healthStore = HealthStore.shared private let gatewayManager = GatewayProcessManager.shared @State private var gatewayDiscovery = GatewayDiscoveryModel( @@ -20,7 +17,6 @@ struct GeneralSettings: View { @State private var showRemoteAdvanced = false private let isPreview = ProcessInfo.processInfo.isPreview private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } - @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue var body: some View { ScrollView(.vertical) { @@ -60,27 +56,6 @@ struct GeneralSettings: View { subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", binding: self.$cameraEnabled) - VStack(alignment: .leading, spacing: 6) { - Text("Location Access") - .font(.body) - - Picker("", selection: self.$locationModeRaw) { - Text("Off").tag(ClawdbotLocationMode.off.rawValue) - Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue) - Text("Always").tag(ClawdbotLocationMode.always.rawValue) - } - .labelsHidden() - .pickerStyle(.menu) - - Toggle("Precise Location", isOn: self.$locationPreciseEnabled) - .disabled(self.locationMode == .off) - - Text("Always may require System Settings to approve background location.") - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - SettingsToggleRow( title: "Enable Peekaboo Bridge", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", @@ -106,27 +81,12 @@ struct GeneralSettings: View { .onAppear { guard !self.isPreview else { return } self.refreshGatewayStatus() - self.lastLocationModeRaw = self.locationModeRaw } .onChange(of: self.state.canvasEnabled) { _, enabled in if !enabled { CanvasManager.shared.hideAll() } } - .onChange(of: self.locationModeRaw) { _, newValue in - let previous = self.lastLocationModeRaw - self.lastLocationModeRaw = newValue - guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return } - Task { - let granted = await self.requestLocationAuthorization(mode: mode) - if !granted { - await MainActor.run { - self.locationModeRaw = previous - self.lastLocationModeRaw = previous - } - } - } - } } private var activeBinding: Binding { @@ -135,26 +95,6 @@ struct GeneralSettings: View { set: { self.state.isPaused = !$0 }) } - private var locationMode: ClawdbotLocationMode { - ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off - } - - private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool { - guard mode != .off else { return true } - guard CLLocationManager.locationServicesEnabled() else { - await MainActor.run { LocationPermissionHelper.openSettings() } - return false - } - - let status = CLLocationManager().authorizationStatus - let requireAlways = mode == .always - if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { - return true - } - let updated = await LocationPermissionRequester.shared.request(always: requireAlways) - return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) - } - private var connectionSection: some View { VStack(alignment: .leading, spacing: 10) { Text("Clawdbot runs") diff --git a/apps/macos/Sources/Clawdbot/PermissionsSettings.swift b/apps/macos/Sources/Clawdbot/PermissionsSettings.swift index edce7f41b..f5a926032 100644 --- a/apps/macos/Sources/Clawdbot/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdbot/PermissionsSettings.swift @@ -1,4 +1,6 @@ import ClawdbotIPC +import ClawdbotKit +import CoreLocation import SwiftUI struct PermissionsSettings: View { @@ -17,6 +19,8 @@ struct PermissionsSettings: View { .padding(.horizontal, 2) .padding(.vertical, 6) + LocationAccessSettings() + Button("Restart onboarding") { self.showOnboarding() } .buttonStyle(.bordered) Spacer() @@ -26,6 +30,72 @@ struct PermissionsSettings: View { } } +private struct LocationAccessSettings: View { + @AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue + @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true + @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Location Access") + .font(.body) + + Picker("", selection: self.$locationModeRaw) { + Text("Off").tag(ClawdbotLocationMode.off.rawValue) + Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue) + Text("Always").tag(ClawdbotLocationMode.always.rawValue) + } + .labelsHidden() + .pickerStyle(.menu) + + Toggle("Precise Location", isOn: self.$locationPreciseEnabled) + .disabled(self.locationMode == .off) + + Text("Always may require System Settings to approve background location.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .onAppear { + self.lastLocationModeRaw = self.locationModeRaw + } + .onChange(of: self.locationModeRaw) { _, newValue in + let previous = self.lastLocationModeRaw + self.lastLocationModeRaw = newValue + guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return } + Task { + let granted = await self.requestLocationAuthorization(mode: mode) + if !granted { + await MainActor.run { + self.locationModeRaw = previous + self.lastLocationModeRaw = previous + } + } + } + } + } + + private var locationMode: ClawdbotLocationMode { + ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off + } + + private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool { + guard mode != .off else { return true } + guard CLLocationManager.locationServicesEnabled() else { + await MainActor.run { LocationPermissionHelper.openSettings() } + return false + } + + let status = CLLocationManager().authorizationStatus + let requireAlways = mode == .always + if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { + return true + } + let updated = await LocationPermissionRequester.shared.request(always: requireAlways) + return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) + } +} + struct PermissionStatusList: View { let status: [Capability: Bool] let refresh: () async -> Void From 50049fd220e981a1165195802e396f4b5b8ea80e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:49:16 +0000 Subject: [PATCH 23/34] chore(macos): drop time-sensitive notification entitlement toggle --- scripts/codesign-mac-app.sh | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 318fc8d60..d8eab87c6 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -7,7 +7,6 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX) -ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX) ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX) ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX) @@ -21,7 +20,6 @@ Env: CODESIGN_TIMESTAMP=auto|on|off DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit - ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 HELP exit 0 fi @@ -182,43 +180,13 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST' PLIST -cat > "$ENT_TMP_APP" <<'PLIST' - - - - - com.apple.developer.usernotifications.time-sensitive - - com.apple.security.automation.apple-events - - com.apple.security.device.audio-input - - com.apple.security.device.camera - - com.apple.security.personal-information.location - - - -PLIST - if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \ /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE" - /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \ - /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP" echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)." fi -# The time-sensitive entitlement is restricted and requires explicit enablement -# (and typically a matching provisioning profile). It is *not* safe to enable -# unconditionally for local debug packaging since AMFI will refuse to launch. APP_ENTITLEMENTS="$ENT_TMP_APP_BASE" -if [[ "${ENABLE_TIME_SENSITIVE_NOTIFICATIONS:-}" == "1" ]]; then - APP_ENTITLEMENTS="$ENT_TMP_APP" -else - echo "Note: Time Sensitive Notifications entitlement disabled." - echo " To force it: ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 scripts/codesign-mac-app.sh " -fi # clear extended attributes to avoid stale signatures xattr -cr "$APP_BUNDLE" 2>/dev/null || true From 9063b9e61d68f5a3a1c90becece596ad8253e426 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:51:36 +0000 Subject: [PATCH 24/34] chore(pnpm): update lockfile --- pnpm-lock.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2827b8511..f66d3c25f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,8 @@ importers: extensions/imessage: {} + extensions/lobster: {} + extensions/matrix: dependencies: '@matrix-org/matrix-sdk-crypto-nodejs': From 4dca662a5d6ab342eb68b430d9f242103e127b3c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 04:51:39 +0000 Subject: [PATCH 25/34] chore(canvas): update a2ui bundle hash --- src/canvas-host/a2ui/.bundle.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 6e2a91754..91cb22241 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce +0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4 From d912b02a4368e77e097aa8a094e6c82298e9b591 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:05:30 +0000 Subject: [PATCH 26/34] docs: add control ui dev gatewayUrl note --- docs/web/control-ui.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 0d473c858..bcead1b7a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -134,3 +134,29 @@ pnpm ui:dev # auto-installs UI deps on first run ``` Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). + +## Debugging/testing: dev server + remote Gateway + +The Control UI is static files; the WebSocket target is configurable and can be +different from the HTTP origin. This is handy when you want the Vite dev server +locally but the Gateway runs elsewhere. + +1) Start the UI dev server: `pnpm ui:dev` +2) Open a URL like: + +```text +http://localhost:5173/?gatewayUrl=ws://:18789 +``` + +Optional one-time auth (if needed): + +```text +http://localhost:5173/?gatewayUrl=wss://:18789&token= +``` + +Notes: +- `gatewayUrl` is stored in localStorage after load and removed from the URL. +- `token` is stored in localStorage; `password` is kept in memory only. +- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). + +Remote access setup details: [Remote access](/gateway/remote). From 8d73c16488ecc61fcf3e3b4a9a70ab998020ca41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:16:38 +0000 Subject: [PATCH 27/34] fix: add changelog for Chrome restore prompt (#1419) (thanks @jamesgroat) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c881fd1..2366a0c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot - Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900) - 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) +- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat. - 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. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. From e0896de2bf18c8494186c32c0fba81d85e16dd8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:07:40 +0000 Subject: [PATCH 28/34] feat: surface repo root in runtime prompt --- docs/concepts/system-prompt.md | 2 +- docs/gateway/configuration.md | 12 ++ src/agents/cli-runner/helpers.ts | 2 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/system-prompt-params.test.ts | 106 ++++++++++++++++++ src/agents/system-prompt-params.ts | 58 ++++++++++ src/agents/system-prompt.test.ts | 2 + src/agents/system-prompt.ts | 3 + .../reply/commands-context-report.ts | 2 + src/config/schema.ts | 3 + src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + ...patterns-match-without-botusername.test.ts | 4 +- 13 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/agents/system-prompt-params.test.ts diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index b46f11578..1a52fc501 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections: - **Current Date & Time**: user-local time, timezone, and time format. - **Reply Tags**: optional reply tag syntax for supported providers. - **Heartbeats**: heartbeat prompt and ack behavior. -- **Runtime**: host, OS, node, model, thinking level (one line). +- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line). - **Reasoning**: current visibility level + /reasoning toggle hint. ## Prompt modes diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 6cdc39394..1665fdcc8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1266,6 +1266,18 @@ Default: `~/clawd`. If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`. +### `agents.defaults.repoRoot` + +Optional repository root to show in the system prompt’s Runtime line. If unset, Clawdbot +tries to detect a `.git` directory by walking upward from the workspace (and current +working directory). The path must exist to be used. + +```json5 +{ + agents: { defaults: { repoRoot: "~/Projects/clawdbot" } } +} +``` + ### `agents.defaults.skipBootstrap` Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index c1d96ea71..26ee43495 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -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()}`, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 19450226c..f16a71759 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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()}`, diff --git a/src/agents/system-prompt-params.test.ts b/src/agents/system-prompt-params.test.ts new file mode 100644 index 000000000..fd108a3c7 --- /dev/null +++ b/src/agents/system-prompt-params.test.ts @@ -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 { + return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`)); +} + +async function makeRepoRoot(root: string): Promise { + 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(); + }); +}); diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 21a97831a..9de8f481a 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -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; + 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(); + 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; +} diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index fce27677a..2f0e936e4 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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"); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index dbf30414f..772f154e4 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: { defaultModel?: string; channel?: string; capabilities?: string[]; + repoRoot?: string; }; messageToolHints?: string[]; sandboxInfo?: { @@ -570,6 +571,7 @@ export function buildRuntimeLine( node?: string; model?: string; defaultModel?: string; + repoRoot?: string; }, runtimeChannel?: string, runtimeCapabilities: string[] = [], @@ -578,6 +580,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 diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 5ba3aedc9..ac93e5fed 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -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", diff --git a/src/config/schema.ts b/src/config/schema.ts index cd22e94d9..953205cd6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -197,6 +197,7 @@ const FIELD_LABELS: Record = { "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", @@ -432,6 +433,8 @@ const FIELD_HELP: Record = { "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": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 11f7cf10d..46bd25d64 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -99,6 +99,8 @@ export type AgentDefaultsConfig = { models?: Record; /** 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). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index c6c0ab3b2..fd624bfe3 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -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(), diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index eae2de919..1d40a6ac5 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -226,7 +226,9 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/); + expect(payload.Body).toMatch( + /^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + ); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { onSpy.mockReset(); From 5567bceb6645b9eea6f7eb00ea5b65e888598884 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:33:35 +0000 Subject: [PATCH 30/34] fix: restore daemon subcommand alias --- src/cli/program/register.subclis.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index bc2496b70..26beb81e2 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -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", From a2981c5a2cfba4db52d19f20da53604442f33514 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:32:13 +0000 Subject: [PATCH 31/34] feat: add elevated ask/full modes --- docs/gateway/configuration.md | 2 +- .../sandbox-vs-tool-policy-vs-elevated.md | 3 +- docs/refactor/exec-host.md | 2 +- docs/tools/elevated.md | 19 +++++++----- docs/tools/exec-approvals.md | 2 +- docs/tools/slash-commands.md | 2 +- docs/tui.md | 2 +- src/agents/bash-tools.exec.ts | 31 ++++++++++++++----- src/agents/pi-embedded-runner/types.ts | 2 +- src/agents/system-prompt.test.ts | 2 +- src/agents/system-prompt.ts | 10 +++--- src/auto-reply/commands-registry.data.ts | 4 +-- ...tches-fuzzy-selection-is-ambiguous.test.ts | 2 +- ...er-agent-allowlist-addition-global.test.ts | 2 +- ...rrent-verbose-level-verbose-has-no.test.ts | 2 +- src/auto-reply/reply.directive.parse.test.ts | 10 ++++++ ...proved-sender-toggle-elevated-mode.test.ts | 4 +-- ...levated-off-groups-without-mention.test.ts | 4 +-- ...evated-directive-unapproved-sender.test.ts | 2 +- .../reply/commands-context-report.ts | 2 +- .../reply/directive-handling.impl.ts | 8 +++-- .../reply/directive-handling.shared.ts | 13 +++++--- src/auto-reply/status.ts | 9 ++++-- src/auto-reply/thinking.ts | 11 ++++++- src/config/types.agent-defaults.ts | 2 +- src/config/zod-schema.agent-defaults.ts | 4 ++- src/gateway/sessions-patch.ts | 2 +- src/tui/commands.ts | 8 ++--- src/tui/tui-command-handlers.ts | 6 +++- 29 files changed, 115 insertions(+), 57 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1665fdcc8..01a64361f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1981,7 +1981,7 @@ Per-agent override (further restrict): Notes: - `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow). -- `/elevated on|off` stores state per session key; inline directives apply to a single message. +- `/elevated on|off|ask|full` stores state per session key; inline directives apply to a single message. - Elevated `exec` runs on the host and bypasses sandboxing. - Tool policy still applies; if `exec` is denied, elevated cannot be used. diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 8c5fd19e8..d28481ebb 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -91,7 +91,8 @@ Available groups: ## Elevated: exec-only “run on host” Elevated does **not** grant extra tools; it only affects `exec`. -- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host. +- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host (approvals may still apply). +- Use `/elevated full` to skip exec approvals for the session. - If you’re already running direct, elevated is effectively a no-op (still gated). - Elevated is **not** skill-scoped and does **not** override tool allow/deny. diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md index c71a456ef..3b4e1a15c 100644 --- a/docs/refactor/exec-host.md +++ b/docs/refactor/exec-host.md @@ -216,7 +216,7 @@ Option B: ## Slash commands - `/exec host= security= ask= node=` - Per-agent, per-session overrides; non-persistent unless saved via config. -- `/elevated on|off` remains a shortcut for `host=gateway security=full`. +- `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals). ## Cross-platform story - The runner service is the portable execution target. diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 2e74162c5..8b561b473 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -6,17 +6,20 @@ read_when: # Elevated Mode (/elevated directives) ## What it does -- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`. +- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply). +- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals). +- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`). - Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). -- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. -- Only `on|off` are accepted; anything else returns a hint and does not change state. +- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. +- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. ## What it controls (and what it doesn’t) - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). -- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. -- **Inline directive**: `/elevated on` inside a message applies to that message only. +- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. +- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. - **Host execution**: elevated forces `exec` onto the gateway host with full security. +- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them. - **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. - **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. @@ -26,8 +29,8 @@ read_when: 3. Global default (`agents.defaults.elevatedDefault` in config). ## Setting a session default -- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`. -- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`). +- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`. +- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`). - If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. @@ -41,4 +44,4 @@ read_when: ## Logging + status - Elevated exec calls are logged at info level. -- Session status includes elevated mode (e.g. `elevated=on`). +- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`). diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 4bef999ae..59ac7d119 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -11,7 +11,7 @@ read_when: Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run commands on a real host (`gateway` or `node`). Think of it like a safety interlock: commands are allowed only when policy + allowlist + (optional) user approval all agree. -Exec approvals are **in addition** to tool policy and elevated gating. +Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals). If the companion app UI is **not available**, any request that requires a prompt is resolved by the **ask fallback** (default: deny). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index a0dfbf8c7..a96e1760f 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -78,7 +78,7 @@ Text + native (when enabled): - `/think ` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/verbose on|full|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) -- `/elevated on|off` (alias: `/elev`) +- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals) - `/exec host= security= ask= node=` (send `/exec` to show current) - `/model ` (alias: `/models`; or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) diff --git a/docs/tui.md b/docs/tui.md index 57fffa493..1c94aee1d 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -78,7 +78,7 @@ Session controls: - `/verbose ` - `/reasoning ` - `/usage ` -- `/elevated ` (alias: `/elev`) +- `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 91b38dc3b..d7aaa218f 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -140,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({ @@ -706,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"; @@ -767,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(); @@ -1031,7 +1046,7 @@ export function createExecTool( }; } - if (host === "gateway") { + if (host === "gateway" && !bypassApprovals) { const approvals = resolveExecApprovals(agentId, { security: "allowlist" }); const hostSecurity = minSecurity(security, approvals.agent.security); const hostAsk = maxAsk(ask, approvals.agent.ask); diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index a8aa3c48c..56380cd1d 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = { allowedControlPorts?: number[]; elevated?: { allowed: boolean; - defaultLevel: "on" | "off"; + defaultLevel: "on" | "off" | "ask" | "full"; }; }; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 2f0e936e4..b5fe28556 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -322,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"); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 772f154e4..6a20391c0 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -176,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). */ @@ -444,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) diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 4ce176b1d..7e6d76399 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index b636b85d6..1997ebe3b 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts index bf0ac3df2..abf7eb0c1 100644 --- a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts @@ -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(); }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 3ff09e217..4fb307f0d 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -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(); }); }); diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index 72f22262f..545c5e169 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -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"); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts index 193172535..43cc0e5b2 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts @@ -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; @@ -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"); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts index 8ace7a4bc..fe4479df1 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts @@ -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; @@ -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; diff --git a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts index 0d6a0b303..183c4a67e 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts @@ -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; diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index ac93e5fed..5cdc9f3d7 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -120,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 }; diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 33f19ee3f..7f056e6cd 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -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()); } diff --git a/src/auto-reply/reply/directive-handling.shared.ts b/src/auto-reply/reply/directive-handling.shared.ts index 961fe50a7..2fa4fd1ed 100644 --- a/src/auto-reply/reply/directive-handling.shared.ts +++ b/src/auto-reply/reply/directive-handling.shared.ts @@ -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 ."; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 2184c5f9a..eaf2d20a8 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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 ", "/verbose on|full|off", "/reasoning on|off", - "/elevated on|off", + "/elevated on|off|ask|full", "/model ", "/usage off|tokens|full", ]; diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 6f9637dbd..aabb2cf17 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -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; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 46bd25d64..d4bac779c 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -136,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"; /** diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index fd624bfe3..c4b8a8f2c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -113,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(), diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 1a3736971..1d34217b7 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -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; } diff --git a/src/tui/commands.ts b/src/tui/commands.ts index b85049472..59806cfbd 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -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 ", "/reasoning ", "/usage ", - "/elevated ", - "/elev ", + "/elevated ", + "/elev ", "/activation ", "/new or /reset", "/abort", diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 40584da0e..79765b5fc 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -371,7 +371,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { } case "elevated": if (!args) { - chatLog.addSystem("usage: /elevated "); + chatLog.addSystem("usage: /elevated "); + break; + } + if (!["on", "off", "ask", "full"].includes(args)) { + chatLog.addSystem("usage: /elevated "); break; } try { From 5ff4ac7fb725efeece24bdf49792b933e214fd58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:42:58 +0000 Subject: [PATCH 32/34] fix: use gateway subcommand for launchd --- apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 6031677ea..154932c64 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager { quiet: Bool) async -> CommandResult { let command = CommandResolver.clawdbotCommand( - subcommand: "daemon", + subcommand: "gateway", extraArgs: self.withJsonFlag(args), // Launchd management must always run locally, even if remote mode is configured. configRoot: ["gateway": ["mode": "local"]]) From 8580b85f0be8f5e35872f15fb4865b9ff165d582 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:32:28 +0000 Subject: [PATCH 33/34] fix: subagents list uses command session --- src/auto-reply/reply/commands-subagents.ts | 2 +- src/auto-reply/reply/commands.test.ts | 27 +++++++++++++++++++ ...patterns-match-without-botusername.test.ts | 7 +++-- ...gram-bot.installs-grammy-throttler.test.ts | 8 +++++- src/telegram/bot.test.ts | 8 +++++- 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index f57c34f95..a1e33c642 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) { } function resolveRequesterSessionKey(params: Parameters[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 }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index fa104de03..cf383ed86 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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 = { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 1d40a6ac5..3d27526fb 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -89,7 +89,6 @@ vi.mock("grammy", () => ({ const sequentializeMiddleware = vi.fn(); const sequentializeSpy = vi.fn(() => sequentializeMiddleware); let _sequentializeKey: ((ctx: unknown) => string) | undefined; -let originalTz: string | undefined; vi.mock("@grammyjs/runner", () => ({ sequentialize: (keyFn: (ctx: unknown) => string) => { _sequentializeKey = keyFn; @@ -119,9 +118,10 @@ const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const ORIGINAL_TZ = process.env.TZ; + describe("createTelegramBot", () => { beforeEach(() => { - originalTz = process.env.TZ; process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ @@ -140,9 +140,8 @@ describe("createTelegramBot", () => { botCtorSpy.mockReset(); _sequentializeKey = undefined; }); - afterEach(() => { - process.env.TZ = originalTz; + process.env.TZ = ORIGINAL_TZ; }); // groupPolicy tests diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index cabdfeae7..b11ecb058 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -121,8 +121,11 @@ const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const ORIGINAL_TZ = process.env.TZ; + describe("createTelegramBot", () => { beforeEach(() => { + process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { @@ -140,6 +143,9 @@ describe("createTelegramBot", () => { botCtorSpy.mockReset(); sequentializeKey = undefined; }); + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); // groupPolicy tests diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 2e7c86cf8..6c965932d 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -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, @@ -147,8 +147,11 @@ const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const ORIGINAL_TZ = process.env.TZ; + describe("createTelegramBot", () => { beforeEach(() => { + process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { @@ -167,6 +170,9 @@ describe("createTelegramBot", () => { botCtorSpy.mockReset(); sequentializeKey = undefined; }); + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); it("installs grammY throttler", () => { createTelegramBot({ token: "tok" }); From 36cfe75a0bffce7e54ef481c827ad46e7a5d3ba0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:54:00 +0000 Subject: [PATCH 34/34] test: relax canvas host reload timing --- src/canvas-host/server.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 8f3d8e839..f51e2f5e0 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -171,7 +171,7 @@ describe("canvas host", () => { const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); await new Promise((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((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, "v2", "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-"));