feat: add /exec session overrides
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}).`,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,3 +153,4 @@ export function extractStatusDirective(body?: string): {
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
|
||||
1
src/auto-reply/reply/exec.ts
Normal file
1
src/auto-reply/reply/exec.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
198
src/auto-reply/reply/exec/directive.ts
Normal file
198
src/auto-reply/reply/exec/directive.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user