docs: document sandbox backend bridges

This commit is contained in:
Peter Steinberger
2026-06-04 01:14:14 -04:00
parent cf36b9456d
commit d8a67ef39a
4 changed files with 25 additions and 0 deletions
+6
View File
@@ -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<SandboxBackendId, RegisteredSandboxBackend> {
const globalStore = globalThis as typeof globalThis & {
[SANDBOX_BACKEND_FACTORIES_STATE_KEY]?: Map<SandboxBackendId, RegisteredSandboxBackend>;
@@ -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) {
+5
View File
@@ -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<SandboxBackendCommandResult> {
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, {
+8
View File
@@ -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<SandboxBackendCommandResult>;
};
/** 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<string> {
// 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<void> {
// 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',
+6
View File
@@ -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<SandboxBackendHandle> {
@@ -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, "")