From 8f7f7ee7dc0498749b56883387038a254bd61c33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 06:11:38 +0000 Subject: [PATCH] feat: add /exec session overrides --- docs/tools/exec.md | 10 + docs/tools/slash-commands.md | 3 +- src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 4 +- src/agents/pi-embedded-runner/run/params.ts | 3 +- src/agents/pi-embedded-runner/run/types.ts | 3 +- src/auto-reply/commands-registry.data.ts | 14 ++ ...rrent-verbose-level-verbose-has-no.test.ts | 41 ++++ src/auto-reply/reply.directive.parse.test.ts | 21 ++ src/auto-reply/reply.ts | 1 + .../reply/agent-runner-execution.ts | 1 + src/auto-reply/reply/agent-runner-memory.ts | 1 + .../reply/directive-handling.impl.ts | 95 ++++++++- .../reply/directive-handling.parse.ts | 52 ++++- .../reply/directive-handling.persist.ts | 18 ++ src/auto-reply/reply/directives.ts | 1 + src/auto-reply/reply/exec.ts | 1 + src/auto-reply/reply/exec/directive.ts | 198 ++++++++++++++++++ src/auto-reply/reply/followup-runner.ts | 1 + .../reply/get-reply-directives-apply.ts | 15 ++ .../reply/get-reply-directives-utils.ts | 14 ++ src/auto-reply/reply/get-reply-directives.ts | 40 ++++ src/auto-reply/reply/get-reply-run.ts | 5 + src/auto-reply/reply/get-reply.ts | 2 + src/auto-reply/reply/queue/types.ts | 2 + src/config/sessions/types.ts | 4 + src/gateway/protocol/schema/sessions.ts | 4 + src/gateway/sessions-patch.ts | 68 ++++++ 28 files changed, 615 insertions(+), 8 deletions(-) create mode 100644 src/auto-reply/reply/exec.ts create mode 100644 src/auto-reply/reply/exec/directive.ts diff --git a/docs/tools/exec.md b/docs/tools/exec.md index de4e4ac46..a021d3c21 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -41,6 +41,16 @@ Notes: - `tools.exec.ask` (default: `on-miss`) - `tools.exec.node` (default: unset) +## Session overrides (`/exec`) + +Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`. +Send `/exec` with no arguments to show the current values. + +Example: +``` +/exec host=gateway security=allowlist ask=on-miss node=mac-1 +``` + ## Exec approvals (macOS app) Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 76f838feb..0678c32c6 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -12,7 +12,7 @@ The host-only bash chat command uses `! ` (with `/bash ` as an alias). There are two related systems: - **Commands**: standalone `/...` messages. -- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`. +- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. - Directives are stripped from the message before the model sees it. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. @@ -77,6 +77,7 @@ Text + native (when enabled): - `/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`) +- `/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) - `/bash ` (host-only; alias for `! `; requires `commands.bash: true` + `tools.elevated` allowlists) diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index cf7c65734..a5fe9ddc3 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -218,6 +218,7 @@ export async function runEmbeddedPiAgent( verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, toolResultFormat: resolvedToolResultFormat, + execOverrides: params.execOverrides, bashElevated: params.bashElevated, timeoutMs: params.timeoutMs, runId: params.runId, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b5e31a262..bf7590e70 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -64,7 +64,7 @@ import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; import { splitSdkTools } from "../tool-split.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js"; -import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js"; +import { mapThinkingLevel } from "../utils.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; @@ -133,7 +133,7 @@ export async function runEmbeddedAttempt( const toolsRaw = createClawdbotCodingTools({ exec: { - ...resolveExecToolDefaults(params.config), + ...(params.execOverrides ?? {}), elevated: params.bashElevated, }, sandbox, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 3d4623e66..9f280affe 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -2,7 +2,7 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import type { ClawdbotConfig } from "../../../config/config.js"; import type { enqueueCommand } from "../../../process/command-queue.js"; -import type { ExecElevatedDefaults } from "../../bash-tools.js"; +import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js"; import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; @@ -34,6 +34,7 @@ export type RunEmbeddedPiAgentParams = { verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; toolResultFormat?: ToolResultFormat; + execOverrides?: Pick; bashElevated?: ExecElevatedDefaults; timeoutMs: number; runId: string; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 1609a4d87..f6606f3c7 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -4,7 +4,7 @@ import type { discoverAuthStorage, discoverModels } from "@mariozechner/pi-codin import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import type { ClawdbotConfig } from "../../../config/config.js"; -import type { ExecElevatedDefaults } from "../../bash-tools.js"; +import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js"; import type { MessagingToolSend } from "../../pi-embedded-messaging.js"; import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; @@ -39,6 +39,7 @@ export type EmbeddedRunAttemptParams = { verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; toolResultFormat?: ToolResultFormat; + execOverrides?: Pick; bashElevated?: ExecElevatedDefaults; timeoutMs: number; runId: string; diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 887a519fc..e7b9a62c4 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -367,6 +367,20 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { ], argsMenu: "auto", }), + defineChatCommand({ + key: "exec", + nativeName: "exec", + description: "Set exec defaults for this session.", + textAlias: "/exec", + args: [ + { + name: "options", + description: "host=... security=... ask=... node=...", + type: "string", + }, + ], + argsParsing: "none", + }), defineChatCommand({ key: "model", nativeName: "model", 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 51e9fd5f1..3ff09e217 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 @@ -147,6 +147,47 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("shows current exec defaults when /exec has no argument", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/exec", + From: "+1222", + To: "+1222", + CommandAuthorized: true, + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + tools: { + exec: { + host: "gateway", + security: "allowlist", + ask: "always", + node: "mac-1", + }, + }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain( + "Current exec defaults: host=gateway, security=allowlist, ask=always, node=mac-1.", + ); + expect(text).toContain( + "Options: host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=.", + ); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("persists elevated off and reflects it in /status (even when default is on)", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index a85718a38..72f22262f 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { extractStatusDirective } from "./reply/directives.js"; import { extractElevatedDirective, + extractExecDirective, extractQueueDirective, extractReasoningDirective, extractReplyToTag, @@ -112,6 +113,26 @@ describe("directive parsing", () => { expect(res.cleaned).toBe(""); }); + it("matches exec directive with options", () => { + const res = extractExecDirective( + "please /exec host=gateway security=allowlist ask=on-miss node=mac-mini now", + ); + expect(res.hasDirective).toBe(true); + expect(res.execHost).toBe("gateway"); + expect(res.execSecurity).toBe("allowlist"); + expect(res.execAsk).toBe("on-miss"); + expect(res.execNode).toBe("mac-mini"); + expect(res.cleaned).toBe("please now"); + }); + + it("captures invalid exec host values", () => { + const res = extractExecDirective("/exec host=spaceship"); + expect(res.hasDirective).toBe(true); + expect(res.execHost).toBeUndefined(); + expect(res.rawExecHost).toBe("spaceship"); + expect(res.invalidHost).toBe(true); + }); + it("matches queue directive", () => { const res = extractQueueDirective("please /queue interrupt now"); expect(res.hasDirective).toBe(true); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index dc52cae4c..56d13ff7d 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -5,6 +5,7 @@ export { extractVerboseDirective, } from "./reply/directives.js"; export { getReplyFromConfig } from "./reply/get-reply.js"; +export { extractExecDirective } from "./reply/exec.js"; export { extractQueueDirective } from "./reply/queue.js"; export { extractReplyToTag } from "./reply/reply-tags.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index e756aac9a..744e9f46f 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -226,6 +226,7 @@ export async function runAgentTurnWithFallback(params: { thinkLevel: params.followupRun.run.thinkLevel, verboseLevel: params.followupRun.run.verboseLevel, reasoningLevel: params.followupRun.run.reasoningLevel, + execOverrides: params.followupRun.run.execOverrides, toolResultFormat: (() => { const channel = resolveMessageChannel( params.sessionCtx.Surface, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 96d7fc88a..3f32c2982 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -123,6 +123,7 @@ export async function runMemoryFlushIfNeeded(params: { thinkLevel: params.followupRun.run.thinkLevel, verboseLevel: params.followupRun.run.verboseLevel, reasoningLevel: params.followupRun.run.reasoningLevel, + execOverrides: params.followupRun.run.execOverrides, bashElevated: params.followupRun.run.bashElevated, timeoutMs: params.followupRun.run.timeoutMs, runId: flushRunId, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 872ab2617..4d9321114 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -1,8 +1,9 @@ -import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentConfig, resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; +import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js"; @@ -23,6 +24,38 @@ import { } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js"; +function resolveExecDefaults(params: { + cfg: ClawdbotConfig; + sessionEntry?: SessionEntry; + agentId?: string; +}): { host: ExecHost; security: ExecSecurity; ask: ExecAsk; node?: string } { + const globalExec = params.cfg.tools?.exec; + const agentExec = params.agentId + ? resolveAgentConfig(params.cfg, params.agentId)?.tools?.exec + : undefined; + return { + host: + (params.sessionEntry?.execHost as ExecHost | undefined) ?? + (agentExec?.host as ExecHost | undefined) ?? + (globalExec?.host as ExecHost | undefined) ?? + "sandbox", + security: + (params.sessionEntry?.execSecurity as ExecSecurity | undefined) ?? + (agentExec?.security as ExecSecurity | undefined) ?? + (globalExec?.security as ExecSecurity | undefined) ?? + "deny", + ask: + (params.sessionEntry?.execAsk as ExecAsk | undefined) ?? + (agentExec?.ask as ExecAsk | undefined) ?? + (globalExec?.ask as ExecAsk | undefined) ?? + "on-miss", + node: + (params.sessionEntry?.execNode as string | undefined) ?? + agentExec?.node ?? + globalExec?.node, + }; +} + export async function handleDirectiveOnly(params: { cfg: ClawdbotConfig; directives: InlineDirectives; @@ -189,6 +222,42 @@ export async function handleDirectiveOnly(params: { }), }; } + if (directives.hasExecDirective) { + if (directives.invalidExecHost) { + return { + text: `Unrecognized exec host "${directives.rawExecHost ?? ""}". Valid hosts: sandbox, gateway, node.`, + }; + } + if (directives.invalidExecSecurity) { + return { + text: `Unrecognized exec security "${directives.rawExecSecurity ?? ""}". Valid: deny, allowlist, full.`, + }; + } + if (directives.invalidExecAsk) { + return { + text: `Unrecognized exec ask "${directives.rawExecAsk ?? ""}". Valid: off, on-miss, always.`, + }; + } + if (directives.invalidExecNode) { + return { + text: "Exec node requires a value.", + }; + } + if (!directives.hasExecOptions) { + const execDefaults = resolveExecDefaults({ + cfg: params.cfg, + sessionEntry, + agentId: activeAgentId, + }); + const nodeLabel = execDefaults.node ? `node=${execDefaults.node}` : "node=(unset)"; + return { + text: withOptions( + `Current exec defaults: host=${execDefaults.host}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`, + "host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=", + ), + }; + } + } const queueAck = maybeHandleQueueDirective({ directives, @@ -254,6 +323,20 @@ export async function handleDirectiveOnly(params: { elevatedChanged || (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined); } + if (directives.hasExecDirective && directives.hasExecOptions) { + if (directives.execHost) { + sessionEntry.execHost = directives.execHost; + } + if (directives.execSecurity) { + sessionEntry.execSecurity = directives.execSecurity; + } + if (directives.execAsk) { + sessionEntry.execAsk = directives.execAsk; + } + if (directives.execNode) { + sessionEntry.execNode = directives.execNode; + } + } if (modelSelection) { if (modelSelection.isDefault) { delete sessionEntry.providerOverride; @@ -355,6 +438,16 @@ export async function handleDirectiveOnly(params: { ); if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); } + if (directives.hasExecDirective && directives.hasExecOptions) { + const execParts: string[] = []; + if (directives.execHost) execParts.push(`host=${directives.execHost}`); + if (directives.execSecurity) execParts.push(`security=${directives.execSecurity}`); + if (directives.execAsk) execParts.push(`ask=${directives.execAsk}`); + if (directives.execNode) execParts.push(`node=${directives.execNode}`); + if (execParts.length > 0) { + parts.push(formatDirectiveAck(`Exec defaults set (${execParts.join(", ")}).`)); + } + } if (shouldDowngradeXHigh) { parts.push( `Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`, diff --git a/src/auto-reply/reply/directive-handling.parse.ts b/src/auto-reply/reply/directive-handling.parse.ts index ef2deb32f..f6cdc9f0c 100644 --- a/src/auto-reply/reply/directive-handling.parse.ts +++ b/src/auto-reply/reply/directive-handling.parse.ts @@ -1,9 +1,11 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js"; import { extractModelDirective } from "../model.js"; import type { MsgContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js"; import { extractElevatedDirective, + extractExecDirective, extractReasoningDirective, extractStatusDirective, extractThinkDirective, @@ -27,6 +29,20 @@ export type InlineDirectives = { hasElevatedDirective: boolean; elevatedLevel?: ElevatedLevel; rawElevatedLevel?: string; + hasExecDirective: boolean; + execHost?: ExecHost; + execSecurity?: ExecSecurity; + execAsk?: ExecAsk; + execNode?: string; + rawExecHost?: string; + rawExecSecurity?: string; + rawExecAsk?: string; + rawExecNode?: string; + hasExecOptions: boolean; + invalidExecHost: boolean; + invalidExecSecurity: boolean; + invalidExecAsk: boolean; + invalidExecNode: boolean; hasStatusDirective: boolean; hasModelDirective: boolean; rawModelDirective?: string; @@ -83,10 +99,27 @@ export function parseInlineDirectives( hasDirective: false, } : extractElevatedDirective(reasoningCleaned); + const { + cleaned: execCleaned, + execHost, + execSecurity, + execAsk, + execNode, + rawExecHost, + rawExecSecurity, + rawExecAsk, + rawExecNode, + hasExecOptions, + invalidHost: invalidExecHost, + invalidSecurity: invalidExecSecurity, + invalidAsk: invalidExecAsk, + invalidNode: invalidExecNode, + hasDirective: hasExecDirective, + } = extractExecDirective(elevatedCleaned); const allowStatusDirective = options?.allowStatusDirective !== false; const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = allowStatusDirective - ? extractStatusDirective(elevatedCleaned) - : { cleaned: elevatedCleaned, hasDirective: false }; + ? extractStatusDirective(execCleaned) + : { cleaned: execCleaned, hasDirective: false }; const { cleaned: modelCleaned, rawModel, @@ -124,6 +157,20 @@ export function parseInlineDirectives( hasElevatedDirective, elevatedLevel, rawElevatedLevel, + hasExecDirective, + execHost, + execSecurity, + execAsk, + execNode, + rawExecHost, + rawExecSecurity, + rawExecAsk, + rawExecNode, + hasExecOptions, + invalidExecHost, + invalidExecSecurity, + invalidExecAsk, + invalidExecNode, hasStatusDirective, hasModelDirective, rawModelDirective: rawModel, @@ -156,6 +203,7 @@ export function isDirectiveOnly(params: { !directives.hasVerboseDirective && !directives.hasReasoningDirective && !directives.hasElevatedDirective && + !directives.hasExecDirective && !directives.hasModelDirective && !directives.hasQueueDirective ) diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 8fc4ebc0a..3cb4606a3 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -118,6 +118,24 @@ export async function persistInlineDirectives(params: { (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined); updated = true; } + if (directives.hasExecDirective && directives.hasExecOptions) { + if (directives.execHost) { + sessionEntry.execHost = directives.execHost; + updated = true; + } + if (directives.execSecurity) { + sessionEntry.execSecurity = directives.execSecurity; + updated = true; + } + if (directives.execAsk) { + sessionEntry.execAsk = directives.execAsk; + updated = true; + } + if (directives.execNode) { + sessionEntry.execNode = directives.execNode; + updated = true; + } + } const modelDirective = directives.hasModelDirective && params.effectiveModelDirective diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index 642beb0e8..15b0dcb1a 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -153,3 +153,4 @@ export function extractStatusDirective(body?: string): { } export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; +export { extractExecDirective } from "./exec/directive.js"; diff --git a/src/auto-reply/reply/exec.ts b/src/auto-reply/reply/exec.ts new file mode 100644 index 000000000..4907e5fb4 --- /dev/null +++ b/src/auto-reply/reply/exec.ts @@ -0,0 +1 @@ +export { extractExecDirective } from "./exec/directive.js"; diff --git a/src/auto-reply/reply/exec/directive.ts b/src/auto-reply/reply/exec/directive.ts new file mode 100644 index 000000000..1e177ab7d --- /dev/null +++ b/src/auto-reply/reply/exec/directive.ts @@ -0,0 +1,198 @@ +import type { ExecAsk, ExecHost, ExecSecurity } from "../../../infra/exec-approvals.js"; + +type ExecDirectiveParse = { + cleaned: string; + hasDirective: boolean; + execHost?: ExecHost; + execSecurity?: ExecSecurity; + execAsk?: ExecAsk; + execNode?: string; + rawExecHost?: string; + rawExecSecurity?: string; + rawExecAsk?: string; + rawExecNode?: string; + hasExecOptions: boolean; + invalidHost: boolean; + invalidSecurity: boolean; + invalidAsk: boolean; + invalidNode: boolean; +}; + +function normalizeExecHost(value?: string): ExecHost | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") return normalized; + return undefined; +} + +function normalizeExecSecurity(value?: string): ExecSecurity | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") return normalized; + return undefined; +} + +function normalizeExecAsk(value?: string): ExecAsk | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized as ExecAsk; + } + return undefined; +} + +function parseExecDirectiveArgs(raw: string): Omit & { + consumed: number; +} { + let i = 0; + const len = raw.length; + while (i < len && /\s/.test(raw[i])) i += 1; + if (raw[i] === ":") { + i += 1; + while (i < len && /\s/.test(raw[i])) i += 1; + } + let consumed = i; + let execHost: ExecHost | undefined; + let execSecurity: ExecSecurity | undefined; + let execAsk: ExecAsk | undefined; + let execNode: string | undefined; + let rawExecHost: string | undefined; + let rawExecSecurity: string | undefined; + let rawExecAsk: string | undefined; + let rawExecNode: string | undefined; + let hasExecOptions = false; + let invalidHost = false; + let invalidSecurity = false; + let invalidAsk = false; + let invalidNode = false; + + const takeToken = (): string | null => { + if (i >= len) return null; + const start = i; + while (i < len && !/\s/.test(raw[i])) i += 1; + if (start === i) return null; + const token = raw.slice(start, i); + while (i < len && /\s/.test(raw[i])) i += 1; + return token; + }; + + const splitToken = (token: string): { key: string; value: string } | null => { + const eq = token.indexOf("="); + const colon = token.indexOf(":"); + const idx = + eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon); + if (idx === -1) return null; + const key = token.slice(0, idx).trim().toLowerCase(); + const value = token.slice(idx + 1).trim(); + if (!key) return null; + return { key, value }; + }; + + while (i < len) { + const token = takeToken(); + if (!token) break; + const parsed = splitToken(token); + if (!parsed) break; + const { key, value } = parsed; + if (key === "host") { + rawExecHost = value; + execHost = normalizeExecHost(value); + if (!execHost) invalidHost = true; + hasExecOptions = true; + consumed = i; + continue; + } + if (key === "security") { + rawExecSecurity = value; + execSecurity = normalizeExecSecurity(value); + if (!execSecurity) invalidSecurity = true; + hasExecOptions = true; + consumed = i; + continue; + } + if (key === "ask") { + rawExecAsk = value; + execAsk = normalizeExecAsk(value); + if (!execAsk) invalidAsk = true; + hasExecOptions = true; + consumed = i; + continue; + } + if (key === "node") { + rawExecNode = value; + const trimmed = value.trim(); + if (!trimmed) { + invalidNode = true; + } else { + execNode = trimmed; + } + hasExecOptions = true; + consumed = i; + continue; + } + break; + } + + return { + consumed, + execHost, + execSecurity, + execAsk, + execNode, + rawExecHost, + rawExecSecurity, + rawExecAsk, + rawExecNode, + hasExecOptions, + invalidHost, + invalidSecurity, + invalidAsk, + invalidNode, + }; +} + +export function extractExecDirective(body?: string): ExecDirectiveParse { + if (!body) { + return { + cleaned: "", + hasDirective: false, + hasExecOptions: false, + invalidHost: false, + invalidSecurity: false, + invalidAsk: false, + invalidNode: false, + }; + } + const re = /(?:^|\s)\/exec(?=$|\s|:)/i; + const match = re.exec(body); + if (!match) { + return { + cleaned: body.trim(), + hasDirective: false, + hasExecOptions: false, + invalidHost: false, + invalidSecurity: false, + invalidAsk: false, + invalidNode: false, + }; + } + const start = match.index + match[0].indexOf("/exec"); + const argsStart = start + "/exec".length; + const parsed = parseExecDirectiveArgs(body.slice(argsStart)); + const cleanedRaw = `${body.slice(0, start)} ${body.slice(argsStart + parsed.consumed)}`; + const cleaned = cleanedRaw.replace(/\s+/g, " ").trim(); + return { + cleaned, + hasDirective: true, + execHost: parsed.execHost, + execSecurity: parsed.execSecurity, + execAsk: parsed.execAsk, + execNode: parsed.execNode, + rawExecHost: parsed.rawExecHost, + rawExecSecurity: parsed.rawExecSecurity, + rawExecAsk: parsed.rawExecAsk, + rawExecNode: parsed.rawExecNode, + hasExecOptions: parsed.hasExecOptions, + invalidHost: parsed.invalidHost, + invalidSecurity: parsed.invalidSecurity, + invalidAsk: parsed.invalidAsk, + invalidNode: parsed.invalidNode, + }; +} diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 64e8ac91f..4c42df4aa 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -158,6 +158,7 @@ export function createFollowupRunner(params: { thinkLevel: queued.run.thinkLevel, verboseLevel: queued.run.verboseLevel, reasoningLevel: queued.run.reasoningLevel, + execOverrides: queued.run.execOverrides, bashElevated: queued.run.bashElevated, timeoutMs: queued.run.timeoutMs, runId, diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 0201ca287..a03ade77d 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -110,6 +110,20 @@ export async function applyInlineDirectiveOverrides(params: { hasVerboseDirective: false, hasReasoningDirective: false, hasElevatedDirective: false, + hasExecDirective: false, + execHost: undefined, + execSecurity: undefined, + execAsk: undefined, + execNode: undefined, + rawExecHost: undefined, + rawExecSecurity: undefined, + rawExecAsk: undefined, + rawExecNode: undefined, + hasExecOptions: false, + invalidExecHost: false, + invalidExecSecurity: false, + invalidExecAsk: false, + invalidExecNode: false, hasStatusDirective: false, hasModelDirective: false, hasQueueDirective: false, @@ -206,6 +220,7 @@ export async function applyInlineDirectiveOverrides(params: { directives.hasVerboseDirective || directives.hasReasoningDirective || directives.hasElevatedDirective || + directives.hasExecDirective || directives.hasModelDirective || directives.hasQueueDirective || directives.hasStatusDirective; diff --git a/src/auto-reply/reply/get-reply-directives-utils.ts b/src/auto-reply/reply/get-reply-directives-utils.ts index 574c66909..c6b926ee6 100644 --- a/src/auto-reply/reply/get-reply-directives-utils.ts +++ b/src/auto-reply/reply/get-reply-directives-utils.ts @@ -15,6 +15,20 @@ export function clearInlineDirectives(cleaned: string): InlineDirectives { hasElevatedDirective: false, elevatedLevel: undefined, rawElevatedLevel: undefined, + hasExecDirective: false, + execHost: undefined, + execSecurity: undefined, + execAsk: undefined, + execNode: undefined, + rawExecHost: undefined, + rawExecSecurity: undefined, + rawExecAsk: undefined, + rawExecNode: undefined, + hasExecOptions: false, + invalidExecHost: false, + invalidExecSecurity: false, + invalidExecAsk: false, + invalidExecNode: false, hasStatusDirective: false, hasModelDirective: false, rawModelDirective: undefined, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 249de1e9e..13a1a6a73 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -1,3 +1,4 @@ +import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; @@ -21,6 +22,7 @@ import { stripInlineStatus } from "./reply-inline.js"; import type { TypingController } from "./typing.js"; type AgentDefaults = NonNullable["defaults"]; +type ExecOverrides = Pick; export type ReplyDirectiveContinuation = { commandSource: string; @@ -38,6 +40,7 @@ export type ReplyDirectiveContinuation = { resolvedVerboseLevel: VerboseLevel | undefined; resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel: ElevatedLevel; + execOverrides?: ExecOverrides; blockStreamingEnabled: boolean; blockReplyChunking?: { minChars: number; @@ -59,6 +62,19 @@ export type ReplyDirectiveContinuation = { }; }; +function resolveExecOverrides(params: { + directives: InlineDirectives; + sessionEntry?: SessionEntry; +}): ExecOverrides | undefined { + const host = params.directives.execHost ?? (params.sessionEntry?.execHost as ExecOverrides["host"]); + const security = + params.directives.execSecurity ?? (params.sessionEntry?.execSecurity as ExecOverrides["security"]); + const ask = params.directives.execAsk ?? (params.sessionEntry?.execAsk as ExecOverrides["ask"]); + const node = params.directives.execNode ?? params.sessionEntry?.execNode; + if (!host && !security && !ask && !node) return undefined; + return { host, security, ask, node }; +} + export type ReplyDirectiveResult = | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } | { kind: "continue"; result: ReplyDirectiveContinuation }; @@ -190,11 +206,33 @@ export async function resolveReplyDirectives(params: { }; } } + if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasExecDirective) { + if (parsedDirectives.execSecurity !== "deny") { + parsedDirectives = { + ...parsedDirectives, + hasExecDirective: false, + execHost: undefined, + execSecurity: undefined, + execAsk: undefined, + execNode: undefined, + rawExecHost: undefined, + rawExecSecurity: undefined, + rawExecAsk: undefined, + rawExecNode: undefined, + hasExecOptions: false, + invalidExecHost: false, + invalidExecSecurity: false, + invalidExecAsk: false, + invalidExecNode: false, + }; + } + } const hasInlineDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || parsedDirectives.hasReasoningDirective || parsedDirectives.hasElevatedDirective || + parsedDirectives.hasExecDirective || parsedDirectives.hasModelDirective || parsedDirectives.hasQueueDirective; if (hasInlineDirective) { @@ -405,6 +443,7 @@ export async function resolveReplyDirectives(params: { model = applyResult.model; contextTokens = applyResult.contextTokens; const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult; + const execOverrides = resolveExecOverrides({ directives, sessionEntry }); return { kind: "continue", @@ -424,6 +463,7 @@ export async function resolveReplyDirectives(params: { resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, + execOverrides, blockStreamingEnabled, blockReplyChunking, resolvedBlockStreamingBreak, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index b554107af..f53a41040 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -10,6 +10,7 @@ import { isProfileInCooldown, resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; +import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveSessionFilePath, @@ -47,6 +48,7 @@ import type { TypingController } from "./typing.js"; import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; type AgentDefaults = NonNullable["defaults"]; +type ExecOverrides = Pick; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; @@ -69,6 +71,7 @@ type RunPreparedReplyParams = { resolvedVerboseLevel: VerboseLevel | undefined; resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel: ElevatedLevel; + execOverrides?: ExecOverrides; elevatedEnabled: boolean; elevatedAllowed: boolean; blockStreamingEnabled: boolean; @@ -227,6 +230,7 @@ export async function runPreparedReply( resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, + execOverrides, abortedLastRun, } = params; let currentSystemSent = systemSent; @@ -430,6 +434,7 @@ export async function runPreparedReply( verboseLevel: resolvedVerboseLevel, reasoningLevel: resolvedReasoningLevel, elevatedLevel: resolvedElevatedLevel, + execOverrides, bashElevated: { enabled: elevatedEnabled, allowed: elevatedAllowed, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 86e8438de..bef565a1e 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -157,6 +157,7 @@ export async function getReplyFromConfig( resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, + execOverrides, blockStreamingEnabled, blockReplyChunking, resolvedBlockStreamingBreak, @@ -241,6 +242,7 @@ export async function getReplyFromConfig( resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, + execOverrides, elevatedEnabled, elevatedAllowed, blockStreamingEnabled, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index c64143d75..007cc8a3d 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../../config/config.js"; import type { SessionEntry } from "../../../config/sessions.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; +import type { ExecToolDefaults } from "../../../agents/bash-tools.js"; export type QueueMode = "steer" | "followup" | "collect" | "steer-backlog" | "interrupt" | "queue"; @@ -56,6 +57,7 @@ export type FollowupRun = { verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; elevatedLevel?: ElevatedLevel; + execOverrides?: Pick; bashElevated?: { enabled: boolean; allowed: boolean; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 94c6177e3..7578b0b1a 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -42,6 +42,10 @@ export type SessionEntry = { verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; + execHost?: string; + execSecurity?: string; + execAsk?: string; + execNode?: string; responseUsage?: "on" | "off" | "tokens" | "full"; providerOverride?: string; modelOverride?: string; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 01831f429..502af9230 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -45,6 +45,10 @@ export const SessionsPatchParamsSchema = Type.Object( ]), ), 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( diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 2949fe34e..e7d7780cd 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -30,6 +30,30 @@ function invalid(message: string): { ok: false; error: ErrorShape } { return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; } +function normalizeExecHost(raw: string): "sandbox" | "gateway" | "node" | undefined { + const normalized = raw.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { + return normalized; + } + return undefined; +} + +function normalizeExecSecurity(raw: string): "deny" | "allowlist" | "full" | undefined { + const normalized = raw.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { + return normalized; + } + return undefined; +} + +function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined { + const normalized = raw.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized; + } + return undefined; +} + export async function applySessionsPatchToStore(params: { cfg: ClawdbotConfig; store: Record; @@ -150,6 +174,50 @@ export async function applySessionsPatchToStore(params: { } } + if ("execHost" in patch) { + const raw = patch.execHost; + if (raw === null) { + delete next.execHost; + } else if (raw !== undefined) { + const normalized = normalizeExecHost(String(raw)); + if (!normalized) return invalid('invalid execHost (use "sandbox"|"gateway"|"node")'); + next.execHost = normalized; + } + } + + if ("execSecurity" in patch) { + const raw = patch.execSecurity; + if (raw === null) { + delete next.execSecurity; + } else if (raw !== undefined) { + const normalized = normalizeExecSecurity(String(raw)); + if (!normalized) return invalid('invalid execSecurity (use "deny"|"allowlist"|"full")'); + next.execSecurity = normalized; + } + } + + if ("execAsk" in patch) { + const raw = patch.execAsk; + if (raw === null) { + delete next.execAsk; + } else if (raw !== undefined) { + const normalized = normalizeExecAsk(String(raw)); + if (!normalized) return invalid('invalid execAsk (use "off"|"on-miss"|"always")'); + next.execAsk = normalized; + } + } + + if ("execNode" in patch) { + const raw = patch.execNode; + if (raw === null) { + delete next.execNode; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) return invalid("invalid execNode: empty"); + next.execNode = trimmed; + } + } + if ("model" in patch) { const raw = patch.model; if (raw === null) {