diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 8e0b945dad6..e834457f146 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -2,6 +2,7 @@ import type { ExecApprovalDecision } from "../infra/exec-approvals.js"; import type { ExecAsk, ExecHost, ExecSecurity, ExecTarget } from "../infra/exec-approvals.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; +import type { EmbeddedFullAccessBlockedReason } from "./pi-embedded-runner/types.js"; export type ExecToolDefaults = { hasCronTool?: boolean; @@ -37,6 +38,8 @@ export type ExecElevatedDefaults = { enabled: boolean; allowed: boolean; defaultLevel: "on" | "off" | "ask" | "full"; + fullAccessAvailable?: boolean; + fullAccessBlockedReason?: EmbeddedFullAccessBlockedReason; }; export type ExecToolDetails = diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts index e71cdb94e30..0cf6ca6edb2 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts @@ -77,7 +77,40 @@ describe("buildEmbeddedSandboxInfo", () => { workspaceAccess: "none", agentWorkspaceMount: undefined, hostBrowserAllowed: false, - elevated: { allowed: true, defaultLevel: "on" }, + elevated: { + allowed: true, + defaultLevel: "on", + fullAccessAvailable: true, + }, + }); + }); + + it("keeps full-access unavailability truth when provided", () => { + const sandbox = createSandboxContext(); + + expect( + buildEmbeddedSandboxInfo(sandbox, { + enabled: true, + allowed: true, + defaultLevel: "full", + fullAccessAvailable: false, + fullAccessBlockedReason: "runtime", + }), + ).toEqual({ + enabled: true, + workspaceDir: "/tmp/openclaw-sandbox", + containerWorkspaceDir: "/workspace", + workspaceAccess: "none", + agentWorkspaceMount: undefined, + browserBridgeUrl: "http://localhost:9222", + browserNoVncUrl: "http://localhost:6080", + hostBrowserAllowed: true, + elevated: { + allowed: true, + defaultLevel: "full", + fullAccessAvailable: false, + fullAccessBlockedReason: "runtime", + }, }); }); }); diff --git a/src/agents/pi-embedded-runner/sandbox-info.ts b/src/agents/pi-embedded-runner/sandbox-info.ts index 35f6b5d284e..331dd9e0071 100644 --- a/src/agents/pi-embedded-runner/sandbox-info.ts +++ b/src/agents/pi-embedded-runner/sandbox-info.ts @@ -1,6 +1,34 @@ import type { ExecElevatedDefaults } from "../bash-tools.js"; import type { resolveSandboxContext } from "../sandbox.js"; -import type { EmbeddedSandboxInfo } from "./types.js"; +import type { EmbeddedFullAccessBlockedReason, EmbeddedSandboxInfo } from "./types.js"; + +export function resolveEmbeddedFullAccessState(params: { + sandboxEnabled: boolean; + execElevated?: ExecElevatedDefaults; +}): { available: boolean; blockedReason?: EmbeddedFullAccessBlockedReason } { + if (!params.sandboxEnabled) { + return { + available: false, + blockedReason: "runtime", + }; + } + if (params.execElevated?.fullAccessAvailable === true) { + return { available: true }; + } + if (params.execElevated?.fullAccessAvailable === false) { + return { + available: false, + blockedReason: params.execElevated.fullAccessBlockedReason ?? "host-policy", + }; + } + if (!params.execElevated?.enabled || !params.execElevated.allowed) { + return { + available: false, + blockedReason: "host-policy", + }; + } + return { available: true }; +} export function buildEmbeddedSandboxInfo( sandbox?: Awaited>, @@ -9,7 +37,12 @@ export function buildEmbeddedSandboxInfo( if (!sandbox?.enabled) { return undefined; } + const elevatedConfigured = execElevated?.enabled === true; const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed); + const fullAccess = resolveEmbeddedFullAccessState({ + sandboxEnabled: true, + execElevated, + }); return { enabled: true, workspaceDir: sandbox.workspaceDir, @@ -18,11 +51,15 @@ export function buildEmbeddedSandboxInfo( agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined, browserBridgeUrl: sandbox.browser?.bridgeUrl, hostBrowserAllowed: sandbox.browserAllowHostControl, - ...(elevatedAllowed + ...(elevatedConfigured ? { elevated: { - allowed: true, + allowed: elevatedAllowed, defaultLevel: execElevated?.defaultLevel ?? "off", + fullAccessAvailable: fullAccess.available, + ...(fullAccess.blockedReason + ? { fullAccessBlockedReason: fullAccess.blockedReason } + : {}), }, } : {}), diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 96ac367b33e..1c01c40230f 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -93,6 +93,8 @@ export type EmbeddedPiCompactResult = { }; }; +export type EmbeddedFullAccessBlockedReason = "sandbox" | "host-policy" | "channel" | "runtime"; + export type EmbeddedSandboxInfo = { enabled: boolean; workspaceDir?: string; @@ -104,5 +106,7 @@ export type EmbeddedSandboxInfo = { elevated?: { allowed: boolean; defaultLevel: "on" | "off" | "ask" | "full"; + fullAccessAvailable: boolean; + fullAccessBlockedReason?: EmbeddedFullAccessBlockedReason; }; }; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 38f05f051c1..9e1bd76304e 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -939,7 +939,7 @@ describe("buildAgentSystemPrompt", () => { containerWorkspaceDir: "/workspace", workspaceAccess: "ro", agentWorkspaceMount: "/agent", - elevated: { allowed: true, defaultLevel: "on" }, + elevated: { allowed: true, defaultLevel: "on", fullAccessAvailable: true }, }, }); @@ -957,6 +957,35 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Current elevated level: on"); }); + it("does not advertise /elevated full when auto-approved full access is unavailable", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + sandboxInfo: { + enabled: true, + workspaceDir: "/tmp/sandbox", + containerWorkspaceDir: "/workspace", + workspaceAccess: "ro", + agentWorkspaceMount: "/agent", + elevated: { + allowed: true, + defaultLevel: "full", + fullAccessAvailable: false, + fullAccessBlockedReason: "runtime", + }, + }, + }); + + expect(prompt).toContain("Elevated exec is available for this session."); + expect(prompt).toContain("User can toggle with /elevated on|off|ask."); + expect(prompt).not.toContain("User can toggle with /elevated on|off|ask|full."); + expect(prompt).toContain( + "Auto-approved /elevated full is unavailable here (runtime constraints).", + ); + expect(prompt).toContain( + "Current elevated level: full (full auto-approval unavailable here; use ask/on instead).", + ); + }); + it("includes reaction guidance when provided", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 0e016f573a4..6acaeb55275 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -12,7 +12,10 @@ import { import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; -import type { EmbeddedSandboxInfo } from "./pi-embedded-runner/types.js"; +import type { + EmbeddedFullAccessBlockedReason, + EmbeddedSandboxInfo, +} from "./pi-embedded-runner/types.js"; import { normalizePromptCapabilityIds, normalizeStructuredPromptSection, @@ -336,6 +339,19 @@ function buildExecApprovalPromptGuidance(params: { return "When exec returns approval-pending, include the concrete /approve command from tool output as plain chat text for the user, and do not ask for a different or rotated code."; } +function formatFullAccessBlockedReason(reason?: EmbeddedFullAccessBlockedReason): string { + if (reason === "host-policy") { + return "host policy"; + } + if (reason === "channel") { + return "channel constraints"; + } + if (reason === "sandbox") { + return "sandbox constraints"; + } + return "runtime constraints"; +} + export function buildAgentSystemPrompt(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; @@ -470,6 +486,11 @@ export function buildAgentSystemPrompt(params: { const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace ? sanitizeForPromptLiteral(sandboxContainerWorkspace) : ""; + const elevated = params.sandboxInfo?.elevated; + const fullAccessBlockedReasonLabel = + elevated?.fullAccessAvailable === false + ? formatFullAccessBlockedReason(elevated.fullAccessBlockedReason) + : undefined; const displayWorkspaceDir = params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace ? sanitizedSandboxContainerWorkspace @@ -652,17 +673,35 @@ export function buildAgentSystemPrompt(params: { : params.sandboxInfo.hostBrowserAllowed === false ? "Host browser control: blocked." : "", - params.sandboxInfo.elevated?.allowed + elevated?.allowed ? "Elevated exec is available for this session." - : "", - params.sandboxInfo.elevated?.allowed + : elevated + ? "Elevated exec is unavailable for this session." + : "", + elevated?.allowed && elevated.fullAccessAvailable ? "User can toggle with /elevated on|off|ask|full." : "", - params.sandboxInfo.elevated?.allowed + elevated?.allowed && !elevated.fullAccessAvailable + ? "User can toggle with /elevated on|off|ask." + : "", + elevated?.allowed && elevated.fullAccessAvailable ? "You may also send /elevated on|off|ask|full when needed." : "", - params.sandboxInfo.elevated?.allowed - ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).` + elevated?.allowed && !elevated.fullAccessAvailable + ? "You may also send /elevated on|off|ask when needed." + : "", + elevated?.fullAccessAvailable === false + ? `Auto-approved /elevated full is unavailable here (${fullAccessBlockedReasonLabel}).` + : "", + elevated?.allowed && elevated.fullAccessAvailable + ? `Current elevated level: ${elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).` + : elevated?.allowed + ? `Current elevated level: ${elevated.defaultLevel} (full auto-approval unavailable here; use ask/on instead).` + : elevated + ? "Current elevated level: off (elevated exec unavailable)." + : "", + elevated && !elevated.allowed + ? "Do not tell the user to switch to /elevated full in this session." : "", ] .filter(Boolean) diff --git a/src/auto-reply/reply/get-reply-run.exec-hint.test.ts b/src/auto-reply/reply/get-reply-run.exec-hint.test.ts index b8149ca61cc..356c52f6350 100644 --- a/src/auto-reply/reply/get-reply-run.exec-hint.test.ts +++ b/src/auto-reply/reply/get-reply-run.exec-hint.test.ts @@ -38,4 +38,17 @@ describe("buildExecOverridePromptHint", () => { ); expect(result).toContain("Current elevated level: full."); }); + + it("warns when auto-approved full access is unavailable", () => { + const result = buildExecOverridePromptHint({ + elevatedLevel: "full", + fullAccessAvailable: false, + fullAccessBlockedReason: "runtime", + }); + + expect(result).toContain("Current elevated level: full."); + expect(result).toContain( + "Auto-approved /elevated full is unavailable here (runtime). Use ask/on instead and do not ask the user to switch to /elevated full.", + ); + }); }); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 8ec7f23f841..fba2b155a8c 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -2,6 +2,9 @@ import crypto from "node:crypto"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; +import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js"; +import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; import { @@ -53,6 +56,8 @@ type ExecOverrides = Pick | null = @@ -213,6 +225,17 @@ export async function runPreparedReply( cfg, isFastTestEnv: process.env.OPENCLAW_TEST_FAST === "1", }); + const fullAccessState = resolveEmbeddedFullAccessState({ + sandboxEnabled: resolveSandboxRuntimeStatus({ + cfg, + sessionKey: ctx.SessionKey, + }).sandboxed, + execElevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + defaultLevel: resolvedElevatedLevel ?? "off", + }, + }); let currentSystemSent = systemSent; const isFirstTurnInSession = isNewSession || !currentSystemSent; @@ -261,6 +284,8 @@ export async function runPreparedReply( buildExecOverridePromptHint({ execOverrides, elevatedLevel: resolvedElevatedLevel, + fullAccessAvailable: fullAccessState.available, + fullAccessBlockedReason: fullAccessState.blockedReason, }), ].filter(Boolean); const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; @@ -609,6 +634,10 @@ export async function runPreparedReply( enabled: elevatedEnabled, allowed: elevatedAllowed, defaultLevel: resolvedElevatedLevel ?? "off", + fullAccessAvailable: fullAccessState.available, + ...(fullAccessState.blockedReason + ? { fullAccessBlockedReason: fullAccessState.blockedReason } + : {}), }, timeoutMs, blockReplyBreak: resolvedBlockStreamingBreak,