diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index 82755c828e39..fdc057b57d66 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -24,6 +24,8 @@ export type { const SANDBOX_BACKEND_FACTORIES_STATE_KEY = Symbol.for("openclaw.sandboxBackendFactories"); +// Process-wide sandbox backend registry. Tests and plugins can install temporary +// factories while core still auto-registers the bundled Docker and SSH backends. function getSandboxBackendFactories(): Map { const globalStore = globalThis as typeof globalThis & { [SANDBOX_BACKEND_FACTORIES_STATE_KEY]?: Map; @@ -40,6 +42,7 @@ function normalizeSandboxBackendId(id: string): SandboxBackendId { return normalized; } +/** Register or replace a sandbox backend and return a restore callback. */ export function registerSandboxBackend( id: string, registration: SandboxBackendRegistration, @@ -59,14 +62,17 @@ export function registerSandboxBackend( }; } +/** Look up a sandbox backend factory by normalized backend id. */ export function getSandboxBackendFactory(id: string): SandboxBackendFactory | null { return getSandboxBackendFactories().get(normalizeSandboxBackendId(id))?.factory ?? null; } +/** Look up optional lifecycle management hooks for a registered backend. */ export function getSandboxBackendManager(id: string): SandboxBackendManager | null { return getSandboxBackendFactories().get(normalizeSandboxBackendId(id))?.manager ?? null; } +/** Resolve a backend factory or throw the user-facing configuration error. */ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory { const factory = getSandboxBackendFactory(id); if (factory) { diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 94bfa42c287d..03a01601378d 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -31,6 +31,7 @@ type RunCommandOptions = { export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.types.js"; +/** Create the filesystem bridge for local Docker-style mounted sandboxes. */ export function createSandboxFsBridge(params: { sandbox: SandboxFsBridgeContext; }): SandboxFsBridge { @@ -48,6 +49,8 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { const mountsByContainer = [...this.mounts].toSorted( (a, b) => b.containerRoot.length - a.containerRoot.length, ); + // Longest mount first keeps nested agent/skill mounts from being claimed by + // the broader workspace root during symlink and mutation safety checks. this.pathGuard = new SandboxFsPathGuard({ mountsByContainer, runCommand: (script, options) => this.runCommand(script, options), @@ -253,6 +256,8 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ): Promise { await this.pathGuard.assertPathChecks(plan.checks); if (plan.recheckBeforeCommand) { + // Mutations that can create or swap path parents re-run the anchored + // checks immediately before command execution to close TOCTOU gaps. await this.pathGuard.assertPathChecks(plan.checks); } return await this.runCommand(plan.script, { diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts index cd4ee3ab2149..b5a7bee63e41 100644 --- a/src/agents/sandbox/remote-fs-bridge.ts +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -40,12 +40,14 @@ type MountInfo = { source: RemoteMountSource; }; +/** Minimal remote shell contract used by the SSH filesystem bridge. */ export type RemoteShellSandboxHandle = { remoteWorkspaceDir: string; remoteAgentWorkspaceDir: string; runRemoteShellScript(params: SandboxBackendCommandParams): Promise; }; +/** Create the filesystem bridge for remote shell-backed sandbox runtimes. */ export function createRemoteShellSandboxFsBridge(params: { sandbox: SandboxFsBridgeContext; runtime: RemoteShellSandboxHandle; @@ -283,6 +285,8 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { }); } if (this.sandbox.workspaceAccess === "rw") { + // Skill directories inside writable remote workspaces stay protected when + // the original host mount exists, matching local bridge read-only rules. mounts.push( ...buildRemoteProtectedSkillMounts({ localRoot: agentRoot, @@ -470,6 +474,8 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { allowFinalSymlinkForUnlink?: boolean; signal?: AbortSignal; }): Promise { + // Canonicalize the nearest existing ancestor and append the missing suffix. + // This lets create/write operations validate paths that do not exist yet. const script = [ "set -eu", 'target="$1"', @@ -507,6 +513,8 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { action: string; signal?: AbortSignal; }): Promise { + // Remote mutation helpers pin by parent path. Rejecting hardlinked regular + // files avoids editing another mount-visible name through the same inode. const result = await this.runRemoteScript({ script: [ 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts index af63d5152eec..a89560e93ce1 100644 --- a/src/agents/sandbox/ssh-backend.ts +++ b/src/agents/sandbox/ssh-backend.ts @@ -37,6 +37,7 @@ type ResolvedSshRuntimePaths = { remoteAgentWorkspaceDir: string; }; +/** SSH backend lifecycle hooks for probing and removing remote sandbox copies. */ export const sshSandboxBackendManager: SandboxBackendManager = { async describeRuntime({ entry, config, agentId }) { const cfg = resolveSandboxConfigForAgent(config, agentId); @@ -100,6 +101,7 @@ export const sshSandboxBackendManager: SandboxBackendManager = { }, }; +/** Create an SSH sandbox backend that mirrors the workspace to a remote target. */ export async function createSshSandboxBackend( params: CreateSandboxBackendParams, ): Promise { @@ -188,6 +190,8 @@ class SshSandboxBackendImpl { if (this.ensurePromise) { return await this.ensurePromise; } + // Concurrent exec/fs calls share one remote copy bootstrap; failures reset + // the promise so the next call can retry after transient SSH errors. this.ensurePromise = this.ensureRuntimeInner(); try { await this.ensurePromise; @@ -294,6 +298,8 @@ function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): Resolv function buildSshSandboxRuntimeId(scopeKey: string): string { const trimmed = scopeKey.trim() || "session"; + // Keep the path human-readable while hashing the original scope to avoid + // collisions after normalization and truncation. const safe = normalizeLowercaseStringOrEmpty(trimmed) .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "")