From b71d8e1c32e3903eed765164287c51cbce446e51 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Wed, 10 Jun 2026 15:35:34 +0100 Subject: [PATCH] fix(sandbox): use materialized skill paths in startup prompts (#91791) * fix(sandbox): use materialized skill paths in command prompts * fix(sandbox): resolve backend prompt workdirs * fix(sandbox): preserve custom backend prompt fallback --------- Co-authored-by: Vincent Koc --- extensions/openshell/index.ts | 1 + .../sandbox-skills.test.ts | 6 +- .../sandbox.resolveSandboxContext.test.ts | 79 ++++++-- src/agents/sandbox.ts | 2 + src/agents/sandbox/backend.test.ts | 15 ++ src/agents/sandbox/backend.ts | 16 +- src/agents/sandbox/backend.types.ts | 5 + src/agents/sandbox/context.ts | 48 ++++- src/agents/sandbox/ssh-backend.ts | 5 +- src/agents/sandbox/types.ts | 7 +- .../reply/commands-system-prompt.test.ts | 97 +++++++++- .../reply/commands-system-prompt.ts | 177 ++++++++++++++---- src/plugin-sdk/sandbox.ts | 2 + 13 files changed, 397 insertions(+), 63 deletions(-) diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts index ea839249590..46ad79bc522 100644 --- a/extensions/openshell/index.ts +++ b/extensions/openshell/index.ts @@ -24,6 +24,7 @@ export default definePluginEntry({ manager: createOpenShellSandboxBackendManager({ pluginConfig, }), + resolveWorkdir: () => pluginConfig.remoteWorkspaceDir, }); }, }); diff --git a/src/agents/embedded-agent-runner/sandbox-skills.test.ts b/src/agents/embedded-agent-runner/sandbox-skills.test.ts index a169a80e433..666eacfe8d6 100644 --- a/src/agents/embedded-agent-runner/sandbox-skills.test.ts +++ b/src/agents/embedded-agent-runner/sandbox-skills.test.ts @@ -96,7 +96,7 @@ describe("resolveSandboxSkillRuntimeInputs", () => { }); it("rebuilds sandbox prompts from materialized skill paths", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-")); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-")); try { const effectiveWorkspace = path.join(root, "workspace"); const materializedWorkspace = path.join(root, "state", "sandbox-skills"); @@ -160,7 +160,9 @@ describe("resolveSandboxSkillRuntimeInputs", () => { }); expect(prompt).toContain("/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md"); - expect(prompt.replaceAll("\\", "/")).not.toContain(materializedWorkspace.replaceAll("\\", "/")); + expect(prompt.replaceAll("\\", "/")).not.toContain( + materializedWorkspace.replaceAll("\\", "/"), + ); expect(prompt).not.toContain(hostSkillPath); expect(prompt).not.toContain("plugin-skills"); expect(prompt.replaceAll("\\", "/")).not.toContain("/skills/canvas/SKILL.md"); diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index b065a3fd056..2917d4d1efb 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -201,22 +201,25 @@ describe("resolveSandboxContext", () => { }, 15_000); it("resolves a registered non-docker backend", async () => { - const restore = registerSandboxBackend("test-backend", async () => ({ - id: "test-backend", - runtimeId: "test-runtime", - runtimeLabel: "Test Runtime", - workdir: "/workspace", - buildExecSpec: async () => ({ - argv: ["test-backend", "exec"], - env: process.env, - stdinMode: "pipe-closed", + const restore = registerSandboxBackend("test-backend", { + factory: async () => ({ + id: "test-backend", + runtimeId: "test-runtime", + runtimeLabel: "Test Runtime", + workdir: "/runtime/workspace", + buildExecSpec: async () => ({ + argv: ["test-backend", "exec"], + env: process.env, + stdinMode: "pipe-closed", + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), }), - runShellCommand: async () => ({ - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - code: 0, - }), - })); + resolveWorkdir: () => "/runtime/workspace", + }); try { const cfg: OpenClawConfig = { agents: { @@ -242,6 +245,13 @@ describe("resolveSandboxContext", () => { expect(result?.runtimeId).toBe("test-runtime"); expect(result?.containerName).toBe("test-runtime"); expect(result?.backend?.id).toBe("test-backend"); + + const workspace = await ensureSandboxWorkspaceForSession({ + config: cfg, + sessionKey: "agent:worker:task", + workspaceDir: "/tmp/openclaw-test", + }); + expect(workspace?.containerWorkdir).toBe("/runtime/workspace"); } finally { restore(); } @@ -409,11 +419,50 @@ describe("resolveSandboxContext", () => { expect(syncOptions?.config).toBe(cfg); expect(syncOptions?.agentId).toBe("main"); expect(syncOptions?.eligibility).toEqual({ remote: { note: "test-remote" } }); + expect(result?.skillsWorkspaceDir).toBe(syncOptions?.targetWorkspaceDir); + expect(result?.workspaceAccess).toBe("rw"); + expect(result?.skillsEligibility).toEqual({ remote: { note: "test-remote" } }); await expect( fs.readFile(path.join(userOwnedSandboxSkillsDir, "SKILL.md"), "utf8"), ).resolves.toBe("# User owned\n"); }, 15_000); + it("uses the SSH backend remote workspace for sandbox workspace info", async () => { + syncSkillsToWorkspaceMock.mockClear(); + const workspaceDir = await createSandboxFixtureDir("ssh-workspace"); + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "ssh.example.test", + workspaceRoot: "/remote/openclaw", + }, + }, + }, + }, + }; + + const result = await ensureSandboxWorkspaceForSession({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir, + }); + + expect(result?.workspaceDir).toBe(workspaceDir); + expect(result?.containerWorkdir).toMatch( + /^\/remote\/openclaw\/openclaw-ssh-agent-main-main-[a-f0-9]{8}\/workspace$/, + ); + expect(result?.containerWorkdir).not.toBe("/workspace"); + expect(result?.skillsWorkspaceDir).toContain( + path.join(".openclaw", "sandbox", "skills-workspaces"), + ); + }, 15_000); + it("materializes skills for shared writable sandboxes even when roots match", async () => { syncSkillsToWorkspaceMock.mockClear(); const workspaceDir = await createSandboxFixtureDir("shared-workspace"); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 5107aae5743..53799d0e8f0 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -20,6 +20,7 @@ export { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandb export { getSandboxBackendFactory, getSandboxBackendManager, + getSandboxBackendWorkdirResolver, registerSandboxBackend, requireSandboxBackendFactory, } from "./sandbox/backend.js"; @@ -69,6 +70,7 @@ export type { SandboxBackendManager, SandboxBackendRegistration, SandboxBackendRuntimeInfo, + SandboxBackendWorkdirResolver, } from "./sandbox/backend.js"; export type { RemoteShellSandboxHandle } from "./sandbox/remote-fs-bridge.js"; export type { diff --git a/src/agents/sandbox/backend.test.ts b/src/agents/sandbox/backend.test.ts index 279615e0944..ab513be8c2a 100644 --- a/src/agents/sandbox/backend.test.ts +++ b/src/agents/sandbox/backend.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { getSandboxBackendFactory, getSandboxBackendManager, + getSandboxBackendWorkdirResolver, registerSandboxBackend, } from "./backend.js"; @@ -40,4 +41,18 @@ describe("sandbox backend registry", () => { restore(); expect(getSandboxBackendManager("test-managed")).toBeNull(); }); + + it("registers backend workdir resolvers alongside factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const resolveWorkdir = () => "/runtime/workspace"; + const restore = registerSandboxBackend("test-workdir", { + factory, + resolveWorkdir, + }); + expect(getSandboxBackendWorkdirResolver("test-workdir")).toBe(resolveWorkdir); + restore(); + expect(getSandboxBackendWorkdirResolver("test-workdir")).toBeNull(); + }); }); diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index f826b98103e..c1742d08388 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -10,6 +10,7 @@ import type { SandboxBackendId, SandboxBackendManager, SandboxBackendRegistration, + SandboxBackendWorkdirResolver, } from "./backend.types.js"; export type { @@ -19,6 +20,7 @@ export type { SandboxBackendManager, SandboxBackendRegistration, SandboxBackendRuntimeInfo, + SandboxBackendWorkdirResolver, } from "./backend.types.js"; export type { SandboxBackendCommandParams, @@ -77,6 +79,11 @@ export function getSandboxBackendManager(id: string): SandboxBackendManager | nu return getSandboxBackendFactories().get(normalizeSandboxBackendId(id))?.manager ?? null; } +/** Look up optional backend workdir resolution that does not start the runtime. */ +export function getSandboxBackendWorkdirResolver(id: string): SandboxBackendWorkdirResolver | null { + return getSandboxBackendFactories().get(normalizeSandboxBackendId(id))?.resolveWorkdir ?? null; +} + /** Resolve a backend factory or throw the user-facing configuration error. */ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory { const factory = getSandboxBackendFactory(id); @@ -92,14 +99,21 @@ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory } import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; -import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; +import { + createSshSandboxBackend, + resolveSshRuntimePaths, + sshSandboxBackendManager, +} from "./ssh-backend.js"; registerSandboxBackend("docker", { factory: createDockerSandboxBackend, manager: dockerSandboxBackendManager, + resolveWorkdir: ({ cfg }) => cfg.docker.workdir, }); registerSandboxBackend("ssh", { factory: createSshSandboxBackend, manager: sshSandboxBackendManager, + resolveWorkdir: ({ cfg, scopeKey }) => + resolveSshRuntimePaths(cfg.ssh.workspaceRoot, scopeKey).remoteWorkspaceDir, }); diff --git a/src/agents/sandbox/backend.types.ts b/src/agents/sandbox/backend.types.ts index 7f4e57df5cc..e7f9acde67b 100644 --- a/src/agents/sandbox/backend.types.ts +++ b/src/agents/sandbox/backend.types.ts @@ -44,18 +44,23 @@ export type SandboxBackendFactory = ( params: CreateSandboxBackendParams, ) => Promise; +/** Resolve the runtime workdir without creating or starting the backend. */ +export type SandboxBackendWorkdirResolver = (params: CreateSandboxBackendParams) => string; + /** Registry input accepted for sandbox backend registration. */ export type SandboxBackendRegistration = | SandboxBackendFactory | { factory: SandboxBackendFactory; manager?: SandboxBackendManager; + resolveWorkdir?: SandboxBackendWorkdirResolver; }; /** Normalized backend registration stored in the sandbox backend registry. */ export type RegisteredSandboxBackend = { factory: SandboxBackendFactory; manager?: SandboxBackendManager; + resolveWorkdir?: SandboxBackendWorkdirResolver; }; export type { SandboxBackendHandle, SandboxBackendId } from "./backend-handle.types.js"; diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 678ab2e68f4..22f1e7640d5 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -18,7 +18,7 @@ import { defaultRuntime } from "../../runtime.js"; import type { SkillEligibilityContext } from "../../skills/types.js"; import { resolveUserPath } from "../../utils.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; -import { requireSandboxBackendFactory } from "./backend.js"; +import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from "./backend.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; import { SANDBOX_STATE_DIR } from "./constants.js"; @@ -178,6 +178,24 @@ function resolveSandboxSession(params: { config?: OpenClawConfig; sessionKey?: s return { rawSessionKey, runtime, cfg }; } +function resolveSandboxWorkspaceInfoWorkdir(params: { + cfg: ReturnType; + rawSessionKey: string; + scopeKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + skillsWorkspaceDir: string; +}): string | undefined { + return getSandboxBackendWorkdirResolver(params.cfg.backend)?.({ + sessionKey: params.rawSessionKey, + scopeKey: params.scopeKey, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + skillsWorkspaceDir: params.skillsWorkspaceDir, + cfg: params.cfg, + }); +} + export async function resolveSandboxContext(params: { config?: OpenClawConfig; sessionKey?: string; @@ -308,16 +326,28 @@ export async function ensureSandboxWorkspaceForSession(params: { } const { rawSessionKey, cfg, runtime } = resolved; - const { workspaceDir } = await ensureSandboxWorkspaceLayout({ - cfg, - agentId: runtime.agentId, - rawSessionKey, - config: params.config, - workspaceDir: params.workspaceDir, - }); + const { agentWorkspaceDir, scopeKey, skillsEligibility, skillsWorkspaceDir, workspaceDir } = + await ensureSandboxWorkspaceLayout({ + cfg, + agentId: runtime.agentId, + rawSessionKey, + config: params.config, + workspaceDir: params.workspaceDir, + }); + const containerWorkdir = resolveSandboxWorkspaceInfoWorkdir({ + cfg, + rawSessionKey, + scopeKey, + workspaceDir, + agentWorkspaceDir, + skillsWorkspaceDir, + }); return { workspaceDir, - containerWorkdir: cfg.docker.workdir, + ...(containerWorkdir ? { containerWorkdir } : {}), + skillsWorkspaceDir, + ...(skillsEligibility ? { skillsEligibility } : {}), + workspaceAccess: cfg.workspaceAccess, }; } diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts index ba8a4fa58fd..2656440a950 100644 --- a/src/agents/sandbox/ssh-backend.ts +++ b/src/agents/sandbox/ssh-backend.ts @@ -333,7 +333,10 @@ async function isExistingDirectory(dir: string): Promise { } } -function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths { +export function resolveSshRuntimePaths( + workspaceRoot: string, + scopeKey: string, +): ResolvedSshRuntimePaths { const runtimeId = buildSshSandboxRuntimeId(scopeKey); const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId); return { diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index ee6e9202a19..b04ab165c1c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,3 +1,4 @@ +import type { SkillEligibilityContext } from "../../skills/types.js"; /** * Sandbox runtime configuration and context types. * @@ -6,7 +7,6 @@ import type { SandboxBackendHandle, SandboxBackendId } from "./backend-handle.types.js"; import type { SandboxFsBridge } from "./fs-bridge.types.js"; import type { SandboxDockerConfig } from "./types.docker.js"; -import type { SkillEligibilityContext } from "../../skills/types.js"; export type { SandboxDockerConfig } from "./types.docker.js"; @@ -115,5 +115,8 @@ export type SandboxContext = { export type SandboxWorkspaceInfo = { workspaceDir: string; - containerWorkdir: string; + containerWorkdir?: string; + skillsWorkspaceDir?: string; + skillsEligibility?: SkillEligibilityContext; + workspaceAccess?: SandboxWorkspaceAccess; }; diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts index a889011bde6..f8e19f26638 100644 --- a/src/auto-reply/reply/commands-system-prompt.test.ts +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -1,10 +1,17 @@ // Tests system prompt command output and bundled prompt section selection. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveSessionAgentIds } from "../../agents/agent-scope.js"; import { createOpenClawCodingTools } from "../../agents/agent-tools.js"; import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; -import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import { + ensureSandboxWorkspaceForSession, + resolveSandboxRuntimeStatus, +} from "../../agents/sandbox.js"; import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; +import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js"; import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -20,6 +27,7 @@ vi.mock("../../agents/bootstrap-files.js", () => ({ })); vi.mock("../../agents/sandbox.js", () => ({ + ensureSandboxWorkspaceForSession: vi.fn(async () => null), resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false, mode: "off" })), })); @@ -130,6 +138,12 @@ describe("resolveCommandsSystemPromptBundle", () => { vi.clearAllMocks(); createOpenClawCodingToolsMock.mockClear(); createOpenClawCodingToolsMock.mockReturnValue([]); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue(null); + vi.mocked(resolveReusableWorkspaceSkillSnapshot).mockReturnValue({ + snapshot: { prompt: "", skills: [], resolvedSkills: [] }, + shouldRefresh: false, + snapshotVersion: "test-snapshot", + } as never); }); it("opts command tool builds into gateway subagent binding", async () => { @@ -254,6 +268,87 @@ describe("resolveCommandsSystemPromptBundle", () => { expect(sandboxInfo?.elevated?.fullAccessBlockedReason).toBe("host-policy"); }); + it("uses materialized sandbox skill paths for sandbox command prompts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-sandbox-skills-")); + try { + const workspaceDir = path.join(root, "workspace"); + const skillsWorkspaceDir = path.join(root, "state", "sandbox-skills"); + const skillDir = path.join(skillsWorkspaceDir, "skills", "gog"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", "name: gog", "description: Gog skill", "---", "# Gog", ""].join("\n"), + "utf8", + ); + const params = makeParams(); + params.workspaceDir = workspaceDir; + vi.mocked(resolveSandboxRuntimeStatus).mockReturnValue({ + sandboxed: true, + mode: "workspace-write", + } as never); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + workspaceDir, + containerWorkdir: "/workspace", + skillsWorkspaceDir, + skillsEligibility: { + remote: { + platforms: ["linux"], + hasBin: () => true, + hasAnyBin: () => true, + note: "sandbox", + }, + }, + workspaceAccess: "rw", + } as never); + vi.mocked(resolveReusableWorkspaceSkillSnapshot).mockReturnValue({ + snapshot: { + prompt: + "~/.npm-global/lib/node_modules/openclaw/skills/gog/SKILL.md", + skills: [], + resolvedSkills: [], + }, + shouldRefresh: false, + snapshotVersion: "host-snapshot", + } as never); + + const result = await resolveCommandsSystemPromptBundle(params); + + expect(result.skillsPrompt).toContain( + "/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md", + ); + expect(result.skillsPrompt).not.toContain("~/.npm-global"); + expect(vi.mocked(resolveReusableWorkspaceSkillSnapshot)).not.toHaveBeenCalled(); + const promptParams = requireFirstArg( + vi.mocked(buildAgentSystemPrompt), + "buildAgentSystemPrompt", + ); + expect(promptParams.skillsPrompt).toContain( + "/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md", + ); + expect(String(promptParams.skillsPrompt)).not.toContain("~/.npm-global"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("preserves host skill snapshots for custom backends without a declared workdir", async () => { + const params = makeParams(); + vi.mocked(resolveSandboxRuntimeStatus).mockReturnValue({ + sandboxed: true, + mode: "workspace-write", + } as never); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + workspaceDir: params.workspaceDir, + skillsWorkspaceDir: "/tmp/sandbox-skills", + workspaceAccess: "rw", + }); + + const result = await resolveCommandsSystemPromptBundle(params); + + expect(result.skillsPrompt).toBe(""); + expect(vi.mocked(resolveReusableWorkspaceSkillSnapshot)).toHaveBeenCalledOnce(); + }); + it("uses config-backed prompt settings for the target agent", async () => { vi.mocked(resolveSandboxRuntimeStatus).mockReturnValue({ sandboxed: false, diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index c64e0e23b73..2d58eb9fc6e 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -5,17 +5,27 @@ import { createOpenClawCodingTools } from "../../agents/agent-tools.js"; import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; import type { EmbeddedContextFile } from "../../agents/embedded-agent-helpers.js"; import { resolveEmbeddedFullAccessState } from "../../agents/embedded-agent-runner/sandbox-info.js"; +import { + mapSandboxSkillEntriesForPrompt, + resolveSandboxSkillRuntimeInputs, +} from "../../agents/embedded-agent-runner/sandbox-skills.js"; import { canExecRequestNode } from "../../agents/exec-defaults.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../../agents/prompt-surface.js"; import type { AgentTool } from "../../agents/runtime/index.js"; -import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import { + ensureSandboxWorkspaceForSession, + resolveSandboxRuntimeStatus, +} from "../../agents/sandbox.js"; import { buildConfiguredAgentSystemPrompt } from "../../agents/system-prompt-config.js"; import { buildSystemPromptParams } from "../../agents/system-prompt-params.js"; import type { WorkspaceBootstrapFile } from "../../agents/workspace.js"; import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js"; +import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js"; +import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; import { getRemoteSkillEligibility } from "../../skills/runtime/remote.js"; import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js"; +import type { SkillEligibilityContext } from "../../skills/types.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; @@ -28,6 +38,122 @@ export type CommandsSystemPromptBundle = { sandboxRuntime: ReturnType; }; +function resolveCommandSkillsEligibility(params: { + agentId: string; + config: HandleCommandsParams["cfg"]; + sessionEntry: HandleCommandsParams["sessionEntry"] | undefined; + sessionKey: string | undefined; +}): SkillEligibilityContext { + try { + return { + remote: getRemoteSkillEligibility({ + advertiseExecNode: canExecRequestNode({ + cfg: params.config, + sessionEntry: params.sessionEntry, + sessionKey: params.sessionKey, + agentId: params.agentId, + }), + }), + }; + } catch { + try { + return { + remote: getRemoteSkillEligibility({ + advertiseExecNode: false, + }), + }; + } catch { + return {}; + } + } +} + +async function resolveCommandSkillsPrompt(params: { + agentId: string; + config: HandleCommandsParams["cfg"]; + eligibility: SkillEligibilityContext; + sandboxed: boolean; + sessionKey: string | undefined; + workspaceDir: string; +}): Promise { + if (params.sandboxed) { + try { + // Sandboxed prompt inspection must not fall back to host skill snapshots: + // those paths can be unreadable inside the container. + const sandboxWorkspace = await ensureSandboxWorkspaceForSession({ + config: params.config, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + }); + if (!sandboxWorkspace) { + return ""; + } + if (sandboxWorkspace.containerWorkdir) { + const { + skillsEligibility, + skillsPromptWorkspaceDir, + skillsSnapshot: skillsSnapshotForRun, + skillsWorkspaceDir, + workspaceOnly, + } = resolveSandboxSkillRuntimeInputs({ + sandbox: { + enabled: true, + containerWorkdir: sandboxWorkspace.containerWorkdir, + ...(sandboxWorkspace.skillsEligibility + ? { skillsEligibility: sandboxWorkspace.skillsEligibility } + : {}), + ...(sandboxWorkspace.skillsWorkspaceDir + ? { skillsWorkspaceDir: sandboxWorkspace.skillsWorkspaceDir } + : {}), + ...(sandboxWorkspace.workspaceAccess + ? { workspaceAccess: sandboxWorkspace.workspaceAccess } + : {}), + }, + effectiveWorkspace: sandboxWorkspace.workspaceDir, + }); + const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ + workspaceDir: skillsWorkspaceDir, + config: params.config, + agentId: params.agentId, + eligibility: skillsEligibility, + skillsSnapshot: skillsSnapshotForRun, + workspaceOnly, + }); + const promptSkillEntries = mapSandboxSkillEntriesForPrompt({ + entries: shouldLoadSkillEntries ? skillEntries : undefined, + skillsWorkspaceDir, + skillsPromptWorkspaceDir, + }); + return resolveSkillsPromptForRun({ + skillsSnapshot: skillsSnapshotForRun, + entries: promptSkillEntries, + config: params.config, + workspaceDir: skillsPromptWorkspaceDir, + agentId: params.agentId, + eligibility: skillsEligibility, + }); + } + // Existing third-party backends may not expose the optional workdir + // resolver yet. Preserve their previous host-snapshot inspection path. + } catch { + return ""; + } + } + + try { + const skillsSnapshot = resolveReusableWorkspaceSkillSnapshot({ + workspaceDir: params.workspaceDir, + config: params.config, + agentId: params.agentId, + eligibility: params.eligibility, + watch: false, + }); + return skillsSnapshot.snapshot.prompt ?? ""; + } catch { + return ""; + } +} + export async function resolveCommandsSystemPromptBundle( params: HandleCommandsParams, ): Promise { @@ -45,42 +171,29 @@ export async function resolveCommandsSystemPromptBundle( sessionId: targetSessionEntry?.sessionId, agentId: sessionAgentId, }); - const sandboxRuntime = resolveSandboxRuntimeStatus({ - cfg: params.cfg, - sessionKey: resolveRuntimePolicySessionKey({ - cfg: params.cfg, - ctx: params.ctx, - sessionKey: params.sessionKey ?? params.ctx.SessionKey, - }), - }); const toolPolicySessionKey = resolveRuntimePolicySessionKey({ cfg: params.cfg, ctx: params.ctx, sessionKey: params.sessionKey, }); - const skillsSnapshot = (() => { - try { - return resolveReusableWorkspaceSkillSnapshot({ - workspaceDir, - config: params.cfg, - agentId: sessionAgentId, - eligibility: { - remote: getRemoteSkillEligibility({ - advertiseExecNode: canExecRequestNode({ - cfg: params.cfg, - sessionEntry: targetSessionEntry, - sessionKey: params.sessionKey, - agentId: sessionAgentId, - }), - }), - }, - watch: false, - }); - } catch { - return { snapshot: { prompt: "", skills: [], resolvedSkills: [] } }; - } - })(); - const skillsPrompt = skillsSnapshot.snapshot.prompt ?? ""; + const sandboxRuntime = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: toolPolicySessionKey, + }); + const skillsEligibility = resolveCommandSkillsEligibility({ + agentId: sessionAgentId, + config: params.cfg, + sessionEntry: targetSessionEntry, + sessionKey: params.sessionKey, + }); + const skillsPrompt = await resolveCommandSkillsPrompt({ + agentId: sessionAgentId, + config: params.cfg, + eligibility: skillsEligibility, + sandboxed: sandboxRuntime.sandboxed, + sessionKey: toolPolicySessionKey, + workspaceDir, + }); const tools = (() => { try { return createOpenClawCodingTools({ diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index bd4da8af61c..49856134487 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -16,6 +16,7 @@ export type { SandboxBackendManager, SandboxBackendRegistration, SandboxBackendRuntimeInfo, + SandboxBackendWorkdirResolver, SandboxContext, SandboxResolvedPath, SandboxSshConfig, @@ -36,6 +37,7 @@ export { disposeSshSandboxSession, getSandboxBackendFactory, getSandboxBackendManager, + getSandboxBackendWorkdirResolver, isToolAllowed, registerSandboxBackend, requireSandboxBackendFactory,