feat: add /exec session overrides

This commit is contained in:
Peter Steinberger
2026-01-18 06:11:38 +00:00
parent 1d8614c7c2
commit 8f7f7ee7dc
28 changed files with 615 additions and 8 deletions

View File

@@ -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.

View File

@@ -12,7 +12,7 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` 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=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;

View File

@@ -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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;
runId: string;

View File

@@ -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",

View File

@@ -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=<id>.",
);
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();

View File

@@ -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);

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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=<id>",
),
};
}
}
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}).`,

View File

@@ -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
)

View File

@@ -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

View File

@@ -153,3 +153,4 @@ export function extractStatusDirective(body?: string): {
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js";

View File

@@ -0,0 +1 @@
export { extractExecDirective } from "./exec/directive.js";

View File

@@ -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<ExecDirectiveParse, "cleaned" | "hasDirective"> & {
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,
};
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
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,

View File

@@ -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<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
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,

View File

@@ -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,

View File

@@ -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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: {
enabled: boolean;
allowed: boolean;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<string, SessionEntry>;
@@ -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) {