fix: clarify exec node routing guidance

This commit is contained in:
Peter Steinberger
2026-04-05 20:54:55 +01:00
parent 466e17436d
commit 97e1437803
18 changed files with 315 additions and 75 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
- Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120) Thanks @eleqtrizit and @pgondhi987.
- Auto-reply: unify reply lifecycle ownership across preflight compaction, session rotation, CLI-backed runs, and gateway restart handling so `/stop` and same-session overlap checks target the right active turn and restart-interrupted turns return the restart notice instead of being silently dropped. (#61267) Thanks @dutifulbob.
- Exec/remote skills: stop advertising `exec host=node` when the current exec policy cannot route to a node, and clarify blocked exec-host override errors with both the requested host and allowed config path.
- Agents/Claude CLI/security: clear inherited Claude Code config-root and plugin-root env overrides like `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_PLUGIN_*`, so OpenClaw-launched Claude CLI runs cannot be silently pointed at an alternate Claude config/plugin tree with different hooks, plugins, or auth context. Thanks @vincentkoc.
- Agents/Claude CLI/security: clear inherited Claude Code provider-routing and managed-auth env overrides, and mark OpenClaw-launched Claude CLI runs as host-managed, so Claude CLI backdoor sessions cannot be silently redirected to proxy, Bedrock, Vertex, Foundry, or parent-managed token contexts. Thanks @vincentkoc.
- Agents/Claude CLI/security: force host-managed Claude CLI backdoor runs to `--setting-sources user`, even under custom backend arg overrides, so repo-local `.claude` project/local settings, hooks, and plugin discovery do not silently execute inside non-interactive OpenClaw sessions. Thanks @vincentkoc.

View File

@@ -65,6 +65,7 @@ import { updateSessionStoreAfterAgentRun } from "./command/session-store.js";
import { resolveSession } from "./command/session.js";
import type { AgentCommandIngressOpts, AgentCommandOpts } from "./command/types.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { canExecRequestNode } from "./exec-defaults.js";
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
import { loadModelCatalog } from "./model-catalog.js";
@@ -508,7 +509,16 @@ async function agentCommandInternal(
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg,
sessionEntry,
sessionKey,
agentId: sessionAgentId,
}),
}),
},
snapshotVersion: skillsSnapshotVersion,
skillFilter,
agentId: sessionAgentId,

View File

@@ -126,7 +126,9 @@ describe("resolveExecTarget", () => {
elevatedRequested: false,
sandboxAvailable: true,
}),
).toThrow("exec host not allowed");
).toThrow(
"exec host not allowed (requested gateway; configured host is auto; set tools.exec.host=gateway or auto to allow this override).",
);
});
it("allows per-call host=sandbox override when configured host is auto", () => {
@@ -153,7 +155,9 @@ describe("resolveExecTarget", () => {
elevatedRequested: false,
sandboxAvailable: false,
}),
).toThrow("exec host not allowed");
).toThrow(
"exec host not allowed (requested gateway; configured host is node; set tools.exec.host=gateway or auto to allow this override).",
);
});
it("allows explicit auto request when configured host is auto", () => {
@@ -180,7 +184,9 @@ describe("resolveExecTarget", () => {
elevatedRequested: false,
sandboxAvailable: true,
}),
).toThrow("exec host not allowed");
).toThrow(
"exec host not allowed (requested auto; configured host is gateway; set tools.exec.host=auto to allow this override).",
);
});
it("allows exact node matches", () => {

View File

@@ -259,9 +259,17 @@ export function resolveExecTarget(params: {
sandboxAvailable: params.sandboxAvailable,
})
) {
const allowedConfig = Array.from(
new Set(
requestedTarget === "gateway" && !params.sandboxAvailable
? ["gateway", "auto"]
: [renderExecTargetLabel(requestedTarget), "auto"],
),
).join(" or ");
throw new Error(
`exec host not allowed (requested ${renderExecTargetLabel(requestedTarget)}; ` +
`configure tools.exec.host=${renderExecTargetLabel(requestedTarget)} to allow).`,
`configured host is ${renderExecTargetLabel(configuredTarget)}; ` +
`set tools.exec.host=${allowedConfig} to allow this override).`,
);
}
const selectedTarget = requestedTarget ?? configuredTarget;

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import { resolveExecDefaults } from "./exec-defaults.js";
describe("resolveExecDefaults", () => {
it("does not advertise node routing when exec host is pinned to gateway", () => {
expect(
resolveExecDefaults({
cfg: {
tools: {
exec: {
host: "gateway",
},
},
},
sandboxAvailable: false,
}).canRequestNode,
).toBe(false);
});
it("keeps node routing available when exec host is auto", () => {
expect(
resolveExecDefaults({
cfg: {
tools: {
exec: {
host: "auto",
},
},
},
sandboxAvailable: true,
}),
).toMatchObject({
host: "auto",
effectiveHost: "sandbox",
canRequestNode: true,
});
});
it("honors session-level exec host overrides", () => {
const sessionEntry = {
execHost: "node",
} as SessionEntry;
expect(
resolveExecDefaults({
cfg: {
tools: {
exec: {
host: "gateway",
},
},
},
sessionEntry,
sandboxAvailable: false,
}).canRequestNode,
).toBe(true);
});
});

110
src/agents/exec-defaults.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../infra/exec-approvals.js";
import { resolveAgentConfig, resolveSessionAgentId } from "./agent-scope.js";
import { isRequestedExecTargetAllowed, resolveExecTarget } from "./bash-tools.exec-runtime.js";
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
type ResolvedExecConfig = {
host?: ExecTarget;
security?: ExecSecurity;
ask?: ExecAsk;
node?: string;
};
function resolveExecConfigState(params: {
cfg?: OpenClawConfig;
sessionEntry?: SessionEntry;
agentId?: string;
sessionKey?: string;
}): {
cfg: OpenClawConfig;
host: ExecTarget;
agentExec?: ResolvedExecConfig;
globalExec?: ResolvedExecConfig;
} {
const cfg = params.cfg ?? {};
const resolvedAgentId =
params.agentId ??
resolveSessionAgentId({
sessionKey: params.sessionKey,
config: cfg,
});
const globalExec = cfg.tools?.exec;
const agentExec = resolvedAgentId
? resolveAgentConfig(cfg, resolvedAgentId)?.tools?.exec
: undefined;
const host =
(params.sessionEntry?.execHost as ExecTarget | undefined) ??
(agentExec?.host as ExecTarget | undefined) ??
(globalExec?.host as ExecTarget | undefined) ??
"auto";
return {
cfg,
host,
agentExec,
globalExec,
};
}
export function canExecRequestNode(params: {
cfg?: OpenClawConfig;
sessionEntry?: SessionEntry;
agentId?: string;
sessionKey?: string;
}): boolean {
const { host } = resolveExecConfigState(params);
return isRequestedExecTargetAllowed({
configuredTarget: host,
requestedTarget: "node",
});
}
export function resolveExecDefaults(params: {
cfg?: OpenClawConfig;
sessionEntry?: SessionEntry;
agentId?: string;
sessionKey?: string;
sandboxAvailable?: boolean;
}): {
host: ExecTarget;
effectiveHost: ExecHost;
security: ExecSecurity;
ask: ExecAsk;
node?: string;
canRequestNode: boolean;
} {
const { cfg, host, agentExec, globalExec } = resolveExecConfigState(params);
const sandboxAvailable =
params.sandboxAvailable ??
(params.sessionKey
? resolveSandboxRuntimeStatus({
cfg,
sessionKey: params.sessionKey,
}).sandboxed
: false);
const resolved = resolveExecTarget({
configuredTarget: host,
elevatedRequested: false,
sandboxAvailable,
});
return {
host,
effectiveHost: resolved.effectiveHost,
security:
(params.sessionEntry?.execSecurity as ExecSecurity | undefined) ??
agentExec?.security ??
globalExec?.security ??
"deny",
ask:
(params.sessionEntry?.execAsk as ExecAsk | undefined) ??
agentExec?.ask ??
globalExec?.ask ??
"on-miss",
node: params.sessionEntry?.execNode ?? agentExec?.node ?? globalExec?.node,
canRequestNode: isRequestedExecTargetAllowed({
configuredTarget: host,
requestedTarget: "node",
}),
};
}

View File

@@ -9,6 +9,7 @@ import {
import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../plugin-sdk/browser-profiles.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath } from "../../utils.js";
import { canExecRequestNode } from "../exec-defaults.js";
import { syncSkillsToWorkspace } from "../skills.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
import { requireSandboxBackendFactory } from "./backend.js";
@@ -58,7 +59,15 @@ async function ensureSandboxWorkspaceLayout(params: {
targetWorkspaceDir: sandboxWorkspaceDir,
config: params.config,
agentId: params.agentId,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg: params.config,
sessionKey: rawSessionKey,
agentId: params.agentId,
}),
}),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : JSON.stringify(error);

View File

@@ -25,6 +25,7 @@ vi.mock("../../agents/skills/refresh.js", () => ({
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => undefined),
resolveSessionAgentIds: vi.fn(() => ({ sessionAgentId: "main" })),
}));

View File

@@ -1,6 +1,7 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
import { canExecRequestNode } from "../../agents/exec-defaults.js";
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
import type { EmbeddedContextFile } from "../../agents/pi-embedded-helpers.js";
import { createOpenClawCodingTools } from "../../agents/pi-tools.js";
@@ -38,12 +39,25 @@ export async function resolveCommandsSystemPromptBundle(
sessionKey: params.sessionKey,
sessionId: params.sessionEntry?.sessionId,
});
const sandboxRuntime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.ctx.SessionKey ?? params.sessionKey,
});
const skillsSnapshot = (() => {
try {
return buildWorkspaceSkillSnapshot(workspaceDir, {
config: params.cfg,
agentId: sessionAgentId,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg: params.cfg,
sessionEntry: params.sessionEntry,
sessionKey: params.sessionKey,
agentId: sessionAgentId,
}),
}),
},
snapshotVersion: getSkillsSnapshotVersion(workspaceDir),
});
} catch {
@@ -51,10 +65,6 @@ export async function resolveCommandsSystemPromptBundle(
}
})();
const skillsPrompt = skillsSnapshot.prompt ?? "";
const sandboxRuntime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.ctx.SessionKey ?? params.sessionKey,
});
const tools = (() => {
try {
return createOpenClawCodingTools({

View File

@@ -1,14 +1,9 @@
import {
resolveAgentConfig,
resolveAgentDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { renderExecTargetLabel, resolveExecTarget } from "../../agents/bash-tools.exec-runtime.js";
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { renderExecTargetLabel } from "../../agents/bash-tools.exec-runtime.js";
import { resolveExecDefaults } from "../../agents/exec-defaults.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { OpenClawConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../../infra/exec-approvals.js";
import { updateSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
@@ -33,49 +28,6 @@ import {
import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js";
import { refreshQueuedFollowupSession } from "./queue.js";
function resolveExecDefaults(params: {
cfg: OpenClawConfig;
sessionEntry?: SessionEntry;
agentId?: string;
sandboxAvailable: boolean;
}): {
host: ExecTarget;
effectiveHost: 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;
const host =
(params.sessionEntry?.execHost as ExecTarget | undefined) ??
(agentExec?.host as ExecTarget | undefined) ??
(globalExec?.host as ExecTarget | undefined) ??
"auto";
const resolved = resolveExecTarget({
configuredTarget: host,
elevatedRequested: false,
sandboxAvailable: params.sandboxAvailable,
});
return {
host,
effectiveHost: resolved.effectiveHost,
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 ?? agentExec?.node ?? globalExec?.node,
};
}
export async function handleDirectiveOnly(
params: HandleDirectiveOnlyParams,
): Promise<ReplyPayload | undefined> {

View File

@@ -6,6 +6,7 @@ const {
getSkillsSnapshotVersionMock,
shouldRefreshSnapshotForVersionMock,
getRemoteSkillEligibilityMock,
resolveAgentConfigMock,
resolveSessionAgentIdMock,
resolveAgentIdFromSessionKeyMock,
} = vi.hoisted(() => ({
@@ -18,11 +19,13 @@ const {
hasBin: () => false,
hasAnyBin: () => false,
})),
resolveAgentConfigMock: vi.fn(() => undefined),
resolveSessionAgentIdMock: vi.fn(() => "writer"),
resolveAgentIdFromSessionKeyMock: vi.fn(() => "main"),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: resolveAgentConfigMock,
resolveSessionAgentId: resolveSessionAgentIdMock,
}));
@@ -63,6 +66,7 @@ describe("ensureSkillSnapshot", () => {
hasBin: () => false,
hasAnyBin: () => false,
});
resolveAgentConfigMock.mockReturnValue(undefined);
resolveSessionAgentIdMock.mockReturnValue("writer");
resolveAgentIdFromSessionKeyMock.mockReturnValue("main");
});

View File

@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { canExecRequestNode } from "../../agents/exec-defaults.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { matchesSkillFilter } from "../../agents/skills/filter.js";
import {
@@ -131,9 +132,16 @@ export async function ensureSkillSnapshot(params: {
let nextEntry = sessionEntry;
let systemSent = sessionEntry?.systemSent ?? false;
const remoteEligibility = getRemoteSkillEligibility();
const snapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
const remoteEligibility = getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg,
sessionEntry,
sessionKey,
agentId: sessionAgentId,
}),
});
const snapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const existingSnapshot = nextEntry?.skillsSnapshot;
ensureSkillsWatcher({ workspaceDir, config: cfg });
const shouldRefreshSnapshot =

View File

@@ -4,6 +4,7 @@ import {
resolveAgentSkillsFilter,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { canExecRequestNode } from "../agents/exec-defaults.js";
import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.js";
import type { OpenClawConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
@@ -24,7 +25,14 @@ export function listSkillCommandsForWorkspace(params: {
config: params.cfg,
agentId: params.agentId,
skillFilter: params.skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg: params.cfg,
agentId: params.agentId,
}),
}),
},
reservedNames: listReservedChatSlashCommandNames(),
});
}
@@ -100,7 +108,13 @@ export function listSkillCommandsForAgents(params: {
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
config: params.cfg,
skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg: params.cfg,
}),
}),
},
reservedNames: used,
});
for (const command of commands) {

View File

@@ -1,3 +1,4 @@
import { canExecRequestNode } from "../agents/exec-defaults.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
@@ -247,7 +248,14 @@ export async function statusAllCommand(
try {
return buildWorkspaceSkillStatus(defaultWorkspace, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg,
agentId: agentStatus.defaultId,
}),
}),
},
});
} catch {
return null;

View File

@@ -1,3 +1,4 @@
import { canExecRequestNode } from "../../agents/exec-defaults.js";
import type { SkillSnapshot } from "../../agents/skills.js";
import { matchesSkillFilter } from "../../agents/skills/filter.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -35,7 +36,14 @@ export function resolveCronSkillsSnapshot(params: {
config: params.config,
agentId: params.agentId,
skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg: params.config,
agentId: params.agentId,
}),
}),
},
snapshotVersion,
});
}

View File

@@ -3,6 +3,7 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { canExecRequestNode } from "../../agents/exec-defaults.js";
import {
installSkillFromClawHub,
searchSkillsFromClawHub,
@@ -92,7 +93,14 @@ export const skillsHandlers: GatewayRequestHandlers = {
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
eligibility: {
remote: getRemoteSkillEligibility({
advertiseExecNode: canExecRequestNode({
cfg,
agentId,
}),
}),
},
});
respond(true, report, undefined);
},

View File

@@ -113,4 +113,25 @@ describe("skills-remote", () => {
removeRemoteNodeInfo(nodeB);
}
});
it("suppresses the exec host=node note when routing is not allowed", () => {
const nodeId = `node-${randomUUID()}`;
const bin = `bin-${randomUUID()}`;
try {
recordRemoteNodeInfo({
nodeId,
displayName: "Mac Studio",
platform: "darwin",
commands: ["system.run"],
});
recordRemoteNodeBins(nodeId, [bin]);
const eligibility = getRemoteSkillEligibility({ advertiseExecNode: false });
expect(eligibility?.hasBin(bin)).toBe(true);
expect(eligibility?.note).toBeUndefined();
} finally {
removeRemoteNodeInfo(nodeId);
}
});
});

View File

@@ -316,7 +316,9 @@ export async function refreshRemoteNodeBins(params: {
}
}
export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] | undefined {
export function getRemoteSkillEligibility(options?: {
advertiseExecNode?: boolean;
}): SkillEligibilityContext["remote"] | undefined {
const macNodes = [...remoteNodes.values()].filter(
(node) => isMacPlatform(node.platform, node.deviceFamily) && supportsSystemRun(node.commands),
);
@@ -331,14 +333,16 @@ export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] |
}
const labels = macNodes.map((node) => node.displayName ?? node.nodeId).filter(Boolean);
const note =
labels.length > 0
? `Remote macOS node available (${labels.join(", ")}). Run macOS-only skills via exec host=node on that node.`
: "Remote macOS node available. Run macOS-only skills via exec host=node on that node.";
options?.advertiseExecNode === false
? undefined
: labels.length > 0
? `Remote macOS node available (${labels.join(", ")}). Run macOS-only skills via exec host=node on that node.`
: "Remote macOS node available. Run macOS-only skills via exec host=node on that node.";
return {
platforms: ["darwin"],
hasBin: (bin) => bins.has(bin),
hasAnyBin: (required) => required.some((bin) => bins.has(bin)),
note,
...(note ? { note } : {}),
};
}