feat: move ssh sandboxing into core

This commit is contained in:
Peter Steinberger 2026-03-15 21:35:20 -07:00
parent 33edb57e74
commit b8bb8510a2
No known key found for this signature in database
28 changed files with 1724 additions and 684 deletions

View File

@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode.
### Fixes

View File

@ -16,6 +16,7 @@ OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox`
Today that usually means:
- Docker sandbox containers
- SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"`
- OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"`
## Commands
@ -97,6 +98,22 @@ openclaw sandbox recreate --all
openclaw sandbox recreate --all
```
### After changing SSH target or SSH auth material
```bash
# Edit config:
# - agents.defaults.sandbox.backend
# - agents.defaults.sandbox.ssh.target
# - agents.defaults.sandbox.ssh.workspaceRoot
# - agents.defaults.sandbox.ssh.identityFile / certificateFile / knownHostsFile
# - agents.defaults.sandbox.ssh.identityData / certificateData / knownHostsData
openclaw sandbox recreate --all
```
For the core `ssh` backend, recreate deletes the per-scope remote workspace root
on the SSH target. The next run seeds it again from the local workspace.
### After changing OpenShell source, policy, or mode
```bash
@ -150,7 +167,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand
"defaults": {
"sandbox": {
"mode": "all", // off, non-main, all
"backend": "docker", // docker, openshell
"backend": "docker", // docker, ssh, openshell
"scope": "agent", // session, agent, shared
"docker": {
"image": "openclaw-sandbox:bookworm-slim",

View File

@ -1125,7 +1125,7 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing
defaults: {
sandbox: {
mode: "non-main", // off | non-main | all
backend: "docker", // docker | openshell
backend: "docker", // docker | ssh | openshell
scope: "agent", // session | agent | shared
workspaceAccess: "none", // none | ro | rw
workspaceRoot: "~/.openclaw/sandboxes",
@ -1154,6 +1154,20 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing
extraHosts: ["internal.service:10.0.0.5"],
binds: ["/home/user/source:/source:rw"],
},
ssh: {
target: "user@gateway-host:22",
command: "ssh",
workspaceRoot: "/tmp/openclaw-sandboxes",
strictHostKeyChecking: true,
updateHostKeys: true,
identityFile: "~/.ssh/id_ed25519",
certificateFile: "~/.ssh/id_ed25519-cert.pub",
knownHostsFile: "~/.ssh/known_hosts",
// SecretRefs / inline contents also supported:
// identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" },
// certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" },
// knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" },
},
browser: {
enabled: false,
image: "openclaw-sandbox-browser:bookworm-slim",
@ -1203,11 +1217,29 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing
**Backend:**
- `docker`: local Docker runtime (default)
- `ssh`: generic SSH-backed remote runtime
- `openshell`: OpenShell runtime
When `backend: "openshell"` is selected, runtime-specific settings move to
`plugins.entries.openshell.config`.
**SSH backend config:**
- `target`: SSH target in `user@host[:port]` form
- `command`: SSH client command (default: `ssh`)
- `workspaceRoot`: absolute remote root used for per-scope workspaces
- `identityFile` / `certificateFile` / `knownHostsFile`: existing local files passed to OpenSSH
- `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime
- `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs
**SSH backend behavior:**
- seeds the remote workspace once after create or recreate
- then keeps the remote SSH workspace canonical
- routes `exec`, file tools, and media paths over SSH
- does not sync remote changes back to the host automatically
- does not support sandbox browser containers
**Workspace access:**
- `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes`
@ -1252,6 +1284,7 @@ When `backend: "openshell"` is selected, runtime-specific settings move to
- `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical
In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step.
Transport is SSH into the OpenShell sandbox, but the plugin owns sandbox lifecycle and optional mirror sync.
**`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user.

View File

@ -59,10 +59,61 @@ Not sandboxed:
`agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox:
- `"docker"` (default): local Docker-backed sandbox runtime.
- `"ssh"`: generic SSH-backed remote sandbox runtime.
- `"openshell"`: OpenShell-backed sandbox runtime.
SSH-specific config lives under `agents.defaults.sandbox.ssh`.
OpenShell-specific config lives under `plugins.entries.openshell.config`.
### SSH backend
Use `backend: "ssh"` when you want OpenClaw to sandbox `exec`, file tools, and media reads on
an arbitrary SSH-accessible machine.
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
ssh: {
target: "user@gateway-host:22",
workspaceRoot: "/tmp/openclaw-sandboxes",
strictHostKeyChecking: true,
updateHostKeys: true,
identityFile: "~/.ssh/id_ed25519",
certificateFile: "~/.ssh/id_ed25519-cert.pub",
knownHostsFile: "~/.ssh/known_hosts",
// Or use SecretRefs / inline contents instead of local files:
// identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" },
// certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" },
// knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" },
},
},
},
},
}
```
How it works:
- OpenClaw creates a per-scope remote root under `sandbox.ssh.workspaceRoot`.
- On first use after create or recreate, OpenClaw seeds that remote workspace from the local workspace once.
- After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH.
- OpenClaw does not sync remote changes back to the local workspace automatically.
This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed.
Important consequences:
- Host-local edits made outside OpenClaw after the seed step are not visible remotely until you recreate the sandbox.
- `openclaw sandbox recreate` deletes the per-scope remote root and seeds again from local on next use.
- Browser sandboxing is not supported on the SSH backend.
- `sandbox.docker.*` settings do not apply to the SSH backend.
```json5
{
agents: {
@ -96,6 +147,9 @@ OpenShell modes:
- `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec.
- `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back.
OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend.
The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode.
Current OpenShell limitations:
- sandbox browser is not supported yet
@ -136,6 +190,7 @@ Behavior:
- After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace.
- OpenClaw does **not** sync remote changes back into the local workspace after exec.
- Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path.
- Transport is SSH into the OpenShell sandbox returned by `openshell sandbox ssh-config`.
Important consequences:

View File

@ -41,6 +41,9 @@ Examples of inactive surfaces:
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`,
`certificateData`, `knownHostsData`, plus per-agent overrides) is active only
when the effective sandbox backend is `ssh` for the default agent or an enabled agent.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured

View File

@ -101,6 +101,7 @@ describe("openshell backend manager", () => {
image: "openclaw",
configLabelKind: "Source",
},
config: {},
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({

View File

@ -4,43 +4,44 @@ import path from "node:path";
import type {
CreateSandboxBackendParams,
OpenClawConfig,
RemoteShellSandboxHandle,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendFactory,
SandboxBackendHandle,
SandboxBackendManager,
SshSandboxSession,
} from "openclaw/plugin-sdk/core";
import {
createRemoteShellSandboxFsBridge,
disposeSshSandboxSession,
resolvePreferredOpenClawTmpDir,
runSshSandboxCommand,
} from "openclaw/plugin-sdk/core";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core";
import {
buildExecRemoteCommand,
buildRemoteCommand,
createOpenShellSshSession,
disposeOpenShellSshSession,
runOpenShellCli,
runOpenShellSshCommand,
type OpenShellExecContext,
type OpenShellSshSession,
} from "./cli.js";
import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js";
import { createOpenShellFsBridge } from "./fs-bridge.js";
import { replaceDirectoryContents } from "./mirror.js";
import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js";
type CreateOpenShellSandboxBackendFactoryParams = {
pluginConfig: ResolvedOpenShellPluginConfig;
};
type PendingExec = {
sshSession: OpenShellSshSession;
sshSession: SshSandboxSession;
};
export type OpenShellSandboxBackend = SandboxBackendHandle & {
mode: "mirror" | "remote";
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
runRemoteShellScript(params: SandboxBackendCommandParams): Promise<SandboxBackendCommandResult>;
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};
export type OpenShellSandboxBackend = SandboxBackendHandle &
RemoteShellSandboxHandle & {
mode: "mirror" | "remote";
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};
export function createOpenShellSandboxBackendFactory(
params: CreateOpenShellSandboxBackendFactoryParams,
@ -129,9 +130,9 @@ async function createOpenShellSandboxBackend(params: {
runShellCommand: async (command) => await impl.runRemoteShellScript(command),
createFsBridge: ({ sandbox }) =>
params.pluginConfig.mode === "remote"
? createOpenShellRemoteFsBridge({
? createRemoteShellSandboxFsBridge({
sandbox,
backend: impl.asHandle(),
runtime: impl.asHandle(),
})
: createOpenShellFsBridge({
sandbox,
@ -186,9 +187,9 @@ class OpenShellSandboxBackendImpl {
runShellCommand: async (command) => await self.runRemoteShellScript(command),
createFsBridge: ({ sandbox }) =>
this.params.execContext.config.mode === "remote"
? createOpenShellRemoteFsBridge({
? createRemoteShellSandboxFsBridge({
sandbox,
backend: self.asHandle(),
runtime: self.asHandle(),
})
: createOpenShellFsBridge({
sandbox,
@ -242,7 +243,7 @@ class OpenShellSandboxBackendImpl {
}
} finally {
if (token?.sshSession) {
await disposeOpenShellSshSession(token.sshSession);
await disposeSshSandboxSession(token.sshSession);
}
}
}
@ -262,7 +263,7 @@ class OpenShellSandboxBackendImpl {
context: this.params.execContext,
});
try {
return await runOpenShellSshCommand({
return await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
@ -276,7 +277,7 @@ class OpenShellSandboxBackendImpl {
signal: params.signal,
});
} finally {
await disposeOpenShellSshSession(session);
await disposeSshSandboxSession(session);
}
}

View File

@ -1,34 +1,20 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
resolvePreferredOpenClawTmpDir,
buildExecRemoteCommand,
createSshSandboxSessionFromConfigText,
runPluginCommandWithTimeout,
shellEscape,
type SshSandboxSession,
} from "openclaw/plugin-sdk/core";
import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core";
import type { ResolvedOpenShellPluginConfig } from "./config.js";
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core";
export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig;
sandboxName: string;
timeoutMs?: number;
};
export type OpenShellSshSession = {
configPath: string;
host: string;
};
export type OpenShellRunSshCommandParams = {
session: OpenShellSshSession;
remoteCommand: string;
stdin?: Buffer | string;
allowFailure?: boolean;
signal?: AbortSignal;
tty?: boolean;
};
export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] {
const argv = [config.command];
if (config.gateway) {
@ -40,10 +26,6 @@ export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): s
return argv;
}
export function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
export function buildRemoteCommand(argv: string[]): string {
return argv.map((entry) => shellEscape(entry)).join(" ");
}
@ -64,7 +46,7 @@ export async function runOpenShellCli(params: {
export async function createOpenShellSshSession(params: {
context: OpenShellExecContext;
}): Promise<OpenShellSshSession> {
}): Promise<SshSandboxSession> {
const result = await runOpenShellCli({
context: params.context,
args: ["sandbox", "ssh-config", params.context.sandboxName],
@ -72,95 +54,7 @@ export async function createOpenShellSshSession(params: {
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed");
}
const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m);
const host = hostMatch?.[1]?.trim();
if (!host) {
throw new Error("Failed to parse openshell ssh-config output.");
}
const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir();
await fs.mkdir(tmpRoot, { recursive: true });
const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-"));
const configPath = path.join(configDir, "config");
await fs.writeFile(configPath, result.stdout, "utf8");
return { configPath, host };
}
export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise<void> {
await fs.rm(path.dirname(session.configPath), { recursive: true, force: true });
}
export async function runOpenShellSshCommand(
params: OpenShellRunSshCommandParams,
): Promise<SandboxBackendCommandResult> {
const argv = [
"ssh",
"-F",
params.session.configPath,
...(params.tty
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
: ["-T", "-o", "RequestTTY=no"]),
params.session.host,
params.remoteCommand,
];
const result = await new Promise<SandboxBackendCommandResult>((resolve, reject) => {
const child = spawn(argv[0]!, argv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
signal: params.signal,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (code) => {
const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks);
const exitCode = code ?? 0;
if (exitCode !== 0 && !params.allowFailure) {
const error = Object.assign(
new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`),
{
code: exitCode,
stdout,
stderr,
},
);
reject(error);
return;
}
resolve({ stdout, stderr, code: exitCode });
});
if (params.stdin !== undefined) {
child.stdin.end(params.stdin);
return;
}
child.stdin.end();
return await createSshSandboxSessionFromConfigText({
configText: result.stdout,
});
return result;
}
export function buildExecRemoteCommand(params: {
command: string;
workdir?: string;
env: Record<string, string>;
}): string {
const body = params.workdir
? `cd ${shellEscape(params.workdir)} && ${params.command}`
: params.command;
const argv =
Object.keys(params.env).length > 0
? [
"env",
...Object.entries(params.env).map(([key, value]) => `${key}=${value}`),
"/bin/sh",
"-c",
body,
]
: ["/bin/sh", "-c", body];
return buildRemoteCommand(argv);
}

View File

@ -1,550 +1,16 @@
import path from "node:path";
import type {
SandboxContext,
SandboxFsBridge,
SandboxFsStat,
SandboxResolvedPath,
import {
createRemoteShellSandboxFsBridge,
type RemoteShellSandboxHandle,
type SandboxContext,
type SandboxFsBridge,
} from "openclaw/plugin-sdk/core";
import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js";
import type { OpenShellSandboxBackend } from "./backend.js";
type ResolvedRemotePath = SandboxResolvedPath & {
writable: boolean;
mountRootPath: string;
source: "workspace" | "agent";
};
type MountInfo = {
containerRoot: string;
writable: boolean;
source: "workspace" | "agent";
};
export function createOpenShellRemoteFsBridge(params: {
sandbox: SandboxContext;
backend: OpenShellSandboxBackend;
backend: RemoteShellSandboxHandle;
}): SandboxFsBridge {
return new OpenShellRemoteFsBridge(params.sandbox, params.backend);
}
class OpenShellRemoteFsBridge implements SandboxFsBridge {
constructor(
private readonly sandbox: SandboxContext,
private readonly backend: OpenShellSandboxBackend,
) {}
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
const target = this.resolveTarget(params);
return {
relativePath: target.relativePath,
containerPath: target.containerPath,
};
}
async readFile(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<Buffer> {
const target = this.resolveTarget(params);
const canonical = await this.resolveCanonicalPath({
containerPath: target.containerPath,
action: "read files",
});
await this.assertNoHardlinkedFile({
containerPath: canonical,
action: "read files",
signal: params.signal,
});
const result = await this.runRemoteScript({
script: 'set -eu\ncat -- "$1"',
args: [canonical],
signal: params.signal,
});
return result.stdout;
}
async writeFile(params: {
filePath: string;
cwd?: string;
data: Buffer | string;
encoding?: BufferEncoding;
mkdir?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveTarget(params);
this.ensureWritable(target, "write files");
const pinned = await this.resolvePinnedParent({
containerPath: target.containerPath,
action: "write files",
requireWritable: true,
});
await this.assertNoHardlinkedFile({
containerPath: target.containerPath,
action: "write files",
signal: params.signal,
});
const buffer = Buffer.isBuffer(params.data)
? params.data
: Buffer.from(params.data, params.encoding ?? "utf8");
await this.runMutation({
args: [
"write",
pinned.mountRootPath,
pinned.relativeParentPath,
pinned.basename,
params.mkdir !== false ? "1" : "0",
],
stdin: buffer,
signal: params.signal,
});
}
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
const target = this.resolveTarget(params);
this.ensureWritable(target, "create directories");
const relativePath = path.posix.relative(target.mountRootPath, target.containerPath);
if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`,
);
}
await this.runMutation({
args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath],
signal: params.signal,
});
}
async remove(params: {
filePath: string;
cwd?: string;
recursive?: boolean;
force?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveTarget(params);
this.ensureWritable(target, "remove files");
const exists = await this.remotePathExists(target.containerPath, params.signal);
if (!exists) {
if (params.force === false) {
throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`);
}
return;
}
const pinned = await this.resolvePinnedParent({
containerPath: target.containerPath,
action: "remove files",
requireWritable: true,
allowFinalSymlinkForUnlink: true,
});
await this.runMutation({
args: [
"remove",
pinned.mountRootPath,
pinned.relativeParentPath,
pinned.basename,
params.recursive ? "1" : "0",
params.force === false ? "0" : "1",
],
signal: params.signal,
allowFailure: params.force !== false,
});
}
async rename(params: {
from: string;
to: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<void> {
const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd });
const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd });
this.ensureWritable(from, "rename files");
this.ensureWritable(to, "rename files");
const fromPinned = await this.resolvePinnedParent({
containerPath: from.containerPath,
action: "rename files",
requireWritable: true,
allowFinalSymlinkForUnlink: true,
});
const toPinned = await this.resolvePinnedParent({
containerPath: to.containerPath,
action: "rename files",
requireWritable: true,
});
await this.runMutation({
args: [
"rename",
fromPinned.mountRootPath,
fromPinned.relativeParentPath,
fromPinned.basename,
toPinned.mountRootPath,
toPinned.relativeParentPath,
toPinned.basename,
"1",
],
signal: params.signal,
});
}
async stat(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<SandboxFsStat | null> {
const target = this.resolveTarget(params);
const exists = await this.remotePathExists(target.containerPath, params.signal);
if (!exists) {
return null;
}
const canonical = await this.resolveCanonicalPath({
containerPath: target.containerPath,
action: "stat files",
signal: params.signal,
});
await this.assertNoHardlinkedFile({
containerPath: canonical,
action: "stat files",
signal: params.signal,
});
const result = await this.runRemoteScript({
script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"',
args: [canonical],
signal: params.signal,
});
const output = result.stdout.toString("utf8").trim();
const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|");
return {
type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other",
size: Number(sizeRaw),
mtimeMs: Number(mtimeRaw) * 1000,
};
}
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath {
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir);
const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir);
const mounts: MountInfo[] = [
{
containerRoot: workspaceContainerRoot,
writable: this.sandbox.workspaceAccess === "rw",
source: "workspace",
},
];
if (
this.sandbox.workspaceAccess !== "none" &&
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
) {
mounts.push({
containerRoot: agentContainerRoot,
writable: this.sandbox.workspaceAccess === "rw",
source: "agent",
});
}
const input = params.filePath.trim();
const inputPosix = input.replace(/\\/g, "/");
const maybeContainerMount = path.posix.isAbsolute(inputPosix)
? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix))
: null;
if (maybeContainerMount) {
return this.toResolvedPath({
mount: maybeContainerMount,
containerPath: normalizeContainerPath(inputPosix),
});
}
const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
const hostCandidate = path.isAbsolute(input)
? path.resolve(input)
: path.resolve(hostCwd, input);
if (isPathInside(workspaceRoot, hostCandidate)) {
const relative = toPosixRelative(workspaceRoot, hostCandidate);
return this.toResolvedPath({
mount: mounts[0]!,
containerPath: relative
? path.posix.join(workspaceContainerRoot, relative)
: workspaceContainerRoot,
});
}
if (mounts[1] && isPathInside(agentRoot, hostCandidate)) {
const relative = toPosixRelative(agentRoot, hostCandidate);
return this.toResolvedPath({
mount: mounts[1],
containerPath: relative
? path.posix.join(agentContainerRoot, relative)
: agentContainerRoot,
});
}
if (params.cwd) {
const cwdPosix = params.cwd.replace(/\\/g, "/");
if (path.posix.isAbsolute(cwdPosix)) {
const cwdContainer = normalizeContainerPath(cwdPosix);
const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer);
if (cwdMount) {
return this.toResolvedPath({
mount: cwdMount,
containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)),
});
}
}
}
throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`);
}
private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath {
const relative = path.posix.relative(params.mount.containerRoot, params.containerPath);
if (relative.startsWith("..") || path.posix.isAbsolute(relative)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`,
);
}
return {
relativePath:
params.mount.source === "workspace"
? relative === "."
? ""
: relative
: relative === "."
? params.mount.containerRoot
: `${params.mount.containerRoot}/${relative}`,
containerPath: params.containerPath,
writable: params.mount.writable,
mountRootPath: params.mount.containerRoot,
source: params.mount.source,
};
}
private resolveMountByContainerPath(
mounts: MountInfo[],
containerPath: string,
): MountInfo | null {
const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length);
for (const mount of ordered) {
if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) {
return mount;
}
}
return null;
}
private ensureWritable(target: ResolvedRemotePath, action: string) {
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
}
}
private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise<boolean> {
const result = await this.runRemoteScript({
script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi',
args: [containerPath],
signal,
});
return result.stdout.toString("utf8").trim() === "1";
}
private async resolveCanonicalPath(params: {
containerPath: string;
action: string;
allowFinalSymlinkForUnlink?: boolean;
signal?: AbortSignal;
}): Promise<string> {
const script = [
"set -eu",
'target="$1"',
'allow_final="$2"',
'suffix=""',
'probe="$target"',
'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi',
'cursor="$probe"',
'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do',
' parent=$(dirname -- "$cursor")',
' if [ "$parent" = "$cursor" ]; then break; fi',
' base=$(basename -- "$cursor")',
' suffix="/$base$suffix"',
' cursor="$parent"',
"done",
'canonical=$(readlink -f -- "$cursor")',
'printf "%s%s\\n" "$canonical" "$suffix"',
].join("\n");
const result = await this.runRemoteScript({
script,
args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"],
signal: params.signal,
});
const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim());
const mount = this.resolveMountByContainerPath(
[
{
containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir),
writable: this.sandbox.workspaceAccess === "rw",
source: "workspace",
},
...(this.sandbox.workspaceAccess !== "none" &&
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
? [
{
containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir),
writable: this.sandbox.workspaceAccess === "rw",
source: "agent" as const,
},
]
: []),
],
canonical,
);
if (!mount) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
);
}
return canonical;
}
private async assertNoHardlinkedFile(params: {
containerPath: string;
action: string;
signal?: AbortSignal;
}): Promise<void> {
const result = await this.runRemoteScript({
script: [
'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi',
'stats=$(stat -c "%F|%h" -- "$1")',
'printf "%s\\n" "$stats"',
].join("\n"),
args: [params.containerPath],
signal: params.signal,
allowFailure: true,
});
const output = result.stdout.toString("utf8").trim();
if (!output) {
return;
}
const [kind = "", linksRaw = "1"] = output.split("|");
if (kind === "regular file" && Number(linksRaw) > 1) {
throw new Error(
`Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`,
);
}
}
private async resolvePinnedParent(params: {
containerPath: string;
action: string;
requireWritable?: boolean;
allowFinalSymlinkForUnlink?: boolean;
}): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> {
const basename = path.posix.basename(params.containerPath);
if (!basename || basename === "." || basename === "/") {
throw new Error(`Invalid sandbox entry target: ${params.containerPath}`);
}
const canonicalParent = await this.resolveCanonicalPath({
containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)),
action: params.action,
allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink,
});
const mount = this.resolveMountByContainerPath(
[
{
containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir),
writable: this.sandbox.workspaceAccess === "rw",
source: "workspace",
},
...(this.sandbox.workspaceAccess !== "none" &&
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
? [
{
containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir),
writable: this.sandbox.workspaceAccess === "rw",
source: "agent" as const,
},
]
: []),
],
canonicalParent,
);
if (!mount) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
);
}
if (params.requireWritable && !mount.writable) {
throw new Error(
`Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`,
);
}
const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent);
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
);
}
return {
mountRootPath: mount.containerRoot,
relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
basename,
};
}
private async runMutation(params: {
args: string[];
stdin?: Buffer | string;
signal?: AbortSignal;
allowFailure?: boolean;
}) {
await this.runRemoteScript({
script: [
"set -eu",
"python3 /dev/fd/3 \"$@\" 3<<'PY'",
SANDBOX_PINNED_MUTATION_PYTHON,
"PY",
].join("\n"),
args: params.args,
stdin: params.stdin,
signal: params.signal,
allowFailure: params.allowFailure,
});
}
private async runRemoteScript(params: {
script: string;
args?: string[];
stdin?: Buffer | string;
signal?: AbortSignal;
allowFailure?: boolean;
}) {
return await this.backend.runRemoteShellScript({
script: params.script,
args: params.args,
stdin: params.stdin,
signal: params.signal,
allowFailure: params.allowFailure,
});
}
}
function normalizeContainerPath(value: string): string {
const normalized = path.posix.normalize(value.trim() || "/");
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function isPathInsideContainerRoot(root: string, candidate: string): boolean {
const normalizedRoot = normalizeContainerPath(root);
const normalizedCandidate = normalizeContainerPath(candidate);
return (
normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
);
}
function isPathInside(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function toPosixRelative(root: string, candidate: string): string {
return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep);
return createRemoteShellSandboxFsBridge({
sandbox: params.sandbox,
runtime: params.backend,
});
}

View File

@ -5,6 +5,7 @@ import {
resolveSandboxDockerConfig,
resolveSandboxPruneConfig,
resolveSandboxScope,
resolveSandboxSshConfig,
} from "./sandbox/config.js";
describe("sandbox config merges", () => {
@ -130,6 +131,41 @@ describe("sandbox config merges", () => {
expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 });
});
it("merges sandbox ssh settings and ignores agent overrides under shared scope", () => {
const ssh = resolveSandboxSshConfig({
scope: "agent",
globalSsh: {
target: "global@example.com:22",
command: "ssh",
identityFile: "~/.ssh/global",
strictHostKeyChecking: true,
},
agentSsh: {
target: "agent@example.com:2222",
certificateFile: "~/.ssh/agent-cert.pub",
strictHostKeyChecking: false,
},
});
expect(ssh).toMatchObject({
target: "agent@example.com:2222",
command: "ssh",
identityFile: "~/.ssh/global",
certificateFile: "~/.ssh/agent-cert.pub",
strictHostKeyChecking: false,
});
const sshShared = resolveSandboxSshConfig({
scope: "shared",
globalSsh: {
target: "global@example.com:22",
},
agentSsh: {
target: "agent@example.com:2222",
},
});
expect(sshShared.target).toBe("global@example.com:22");
});
it("defaults sandbox backend to docker", () => {
expect(resolveSandboxConfigForAgent().backend).toBe("docker");
});

View File

@ -34,6 +34,18 @@ export {
export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js";
export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js";
export {
buildExecRemoteCommand,
buildRemoteCommand,
buildSshSandboxArgv,
createSshSandboxSessionFromConfigText,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,
} from "./sandbox/ssh.js";
export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js";
export type {
CreateSandboxBackendParams,
@ -47,6 +59,12 @@ export type {
SandboxBackendRegistration,
SandboxBackendRuntimeInfo,
} from "./sandbox/backend.js";
export type { RemoteShellSandboxHandle } from "./sandbox/remote-fs-bridge.js";
export type {
RunSshSandboxCommandParams,
SshSandboxSession,
SshSandboxSettings,
} from "./sandbox/ssh.js";
export type {
SandboxBrowserConfig,
@ -56,6 +74,7 @@ export type {
SandboxDockerConfig,
SandboxPruneConfig,
SandboxScope,
SandboxSshConfig,
SandboxToolPolicy,
SandboxToolPolicyResolved,
SandboxToolPolicySource,

View File

@ -65,7 +65,11 @@ export type SandboxBackendManager = {
config: OpenClawConfig;
agentId?: string;
}): Promise<SandboxBackendRuntimeInfo>;
removeRuntime(params: { entry: SandboxRegistryEntry }): Promise<void>;
removeRuntime(params: {
entry: SandboxRegistryEntry;
config: OpenClawConfig;
agentId?: string;
}): Promise<void>;
};
export type CreateSandboxBackendParams = {
@ -141,8 +145,14 @@ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory
}
import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js";
import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js";
registerSandboxBackend("docker", {
factory: createDockerSandboxBackend,
manager: dockerSandboxBackendManager,
});
registerSandboxBackend("ssh", {
factory: createSshSandboxBackend,
manager: sshSandboxBackendManager,
});

View File

@ -62,6 +62,12 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig {
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
ssh: {
command: "ssh",
workspaceRoot: "/tmp/openclaw-sandboxes",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: true,
image: "openclaw-sandbox-browser:bookworm-slim",

View File

@ -1,4 +1,6 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { SandboxSshSettings } from "../../config/types.sandbox.js";
import { normalizeSecretInputString } from "../../config/types.secrets.js";
import { resolveAgentConfig } from "../agent-scope.js";
import {
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
@ -22,6 +24,7 @@ import type {
SandboxDockerConfig,
SandboxPruneConfig,
SandboxScope,
SandboxSshConfig,
} from "./types.js";
export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [
@ -30,6 +33,9 @@ export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [
"dangerouslyAllowContainerNamespaceJoin",
] as const;
const DEFAULT_SANDBOX_SSH_COMMAND = "ssh";
const DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT = "/tmp/openclaw-sandboxes";
type DangerousSandboxDockerBooleanKey = (typeof DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS)[number];
type DangerousSandboxDockerBooleans = Pick<SandboxDockerConfig, DangerousSandboxDockerBooleanKey>;
@ -167,6 +173,54 @@ export function resolveSandboxPruneConfig(params: {
};
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeRemoteRoot(value: string | undefined, fallback: string): string {
const normalized = normalizeOptionalString(value) ?? fallback;
const posix = normalized.replaceAll("\\", "/");
if (!posix.startsWith("/")) {
throw new Error(`Sandbox SSH workspaceRoot must be an absolute POSIX path: ${normalized}`);
}
return posix.replace(/\/+$/g, "") || "/";
}
export function resolveSandboxSshConfig(params: {
scope: SandboxScope;
globalSsh?: Partial<SandboxSshSettings>;
agentSsh?: Partial<SandboxSshSettings>;
}): SandboxSshConfig {
const agentSsh = params.scope === "shared" ? undefined : params.agentSsh;
const globalSsh = params.globalSsh;
return {
target: normalizeOptionalString(agentSsh?.target ?? globalSsh?.target),
command:
normalizeOptionalString(agentSsh?.command ?? globalSsh?.command) ??
DEFAULT_SANDBOX_SSH_COMMAND,
workspaceRoot: normalizeRemoteRoot(
agentSsh?.workspaceRoot ?? globalSsh?.workspaceRoot,
DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT,
),
strictHostKeyChecking:
agentSsh?.strictHostKeyChecking ?? globalSsh?.strictHostKeyChecking ?? true,
updateHostKeys: agentSsh?.updateHostKeys ?? globalSsh?.updateHostKeys ?? true,
identityFile: normalizeOptionalString(agentSsh?.identityFile ?? globalSsh?.identityFile),
certificateFile: normalizeOptionalString(
agentSsh?.certificateFile ?? globalSsh?.certificateFile,
),
knownHostsFile: normalizeOptionalString(agentSsh?.knownHostsFile ?? globalSsh?.knownHostsFile),
identityData: normalizeSecretInputString(agentSsh?.identityData ?? globalSsh?.identityData),
certificateData: normalizeSecretInputString(
agentSsh?.certificateData ?? globalSsh?.certificateData,
),
knownHostsData: normalizeSecretInputString(
agentSsh?.knownHostsData ?? globalSsh?.knownHostsData,
),
};
}
export function resolveSandboxConfigForAgent(
cfg?: OpenClawConfig,
agentId?: string,
@ -199,6 +253,11 @@ export function resolveSandboxConfigForAgent(
globalDocker: agent?.docker,
agentDocker: agentSandbox?.docker,
}),
ssh: resolveSandboxSshConfig({
scope,
globalSsh: agent?.ssh,
agentSsh: agentSandbox?.ssh,
}),
browser: resolveSandboxBrowserConfig({
scope,
globalBrowser: agent?.browser,

View File

@ -109,6 +109,12 @@ function createSandboxConfig(
binds: binds ?? ["/tmp/workspace:/workspace:rw"],
dangerouslyAllowReservedContainerTargets: true,
},
ssh: {
command: "ssh",
workspaceRoot: "/tmp/openclaw-sandboxes",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "openclaw-browser:test",

View File

@ -85,16 +85,22 @@ export async function listSandboxBrowsers(): Promise<SandboxBrowserInfo[]> {
}
export async function removeSandboxContainer(containerName: string): Promise<void> {
const config = loadConfig();
const registry = await readRegistry();
const entry = registry.entries.find((item) => item.containerName === containerName);
if (entry) {
const manager = getSandboxBackendManager(entry.backendId ?? "docker");
await manager?.removeRuntime({ entry });
await manager?.removeRuntime({
entry,
config,
agentId: resolveSandboxAgentId(entry.sessionKey),
});
}
await removeRegistryEntry(containerName);
}
export async function removeSandboxBrowserContainer(containerName: string): Promise<void> {
const config = loadConfig();
const registry = await readBrowserRegistry();
const entry = registry.entries.find((item) => item.containerName === containerName);
if (entry) {
@ -105,6 +111,7 @@ export async function removeSandboxBrowserContainer(containerName: string): Prom
runtimeLabel: entry.containerName,
configLabelKind: "Image",
},
config,
});
}
await removeBrowserRegistryEntry(containerName);

View File

@ -1,4 +1,5 @@
import { stopBrowserBridgeServer } from "../../browser/bridge-server.js";
import { loadConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { getSandboxBackendManager } from "./backend.js";
import { BROWSER_BRIDGES } from "./browser-bridges.js";
@ -62,18 +63,23 @@ async function pruneSandboxRegistryEntries<TEntry extends SandboxRegistryEntry>(
}
async function pruneSandboxContainers(cfg: SandboxConfig) {
const config = loadConfig();
await pruneSandboxRegistryEntries<SandboxRegistryEntry>({
cfg,
read: readRegistry,
remove: removeRegistryEntry,
removeRuntime: async (entry) => {
const manager = getSandboxBackendManager(entry.backendId ?? "docker");
await manager?.removeRuntime({ entry });
await manager?.removeRuntime({
entry,
config,
});
},
});
}
async function pruneSandboxBrowsers(cfg: SandboxConfig) {
const config = loadConfig();
await pruneSandboxRegistryEntries<
SandboxBrowserRegistryEntry & {
backendId?: string;
@ -92,6 +98,7 @@ async function pruneSandboxBrowsers(cfg: SandboxConfig) {
runtimeLabel: entry.containerName,
configLabelKind: "Image",
},
config,
});
},
onRemoved: async (entry) => {

View File

@ -0,0 +1,518 @@
import path from "node:path";
import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js";
import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js";
import type { SandboxContext } from "./types.js";
type ResolvedRemotePath = SandboxResolvedPath & {
writable: boolean;
mountRootPath: string;
source: "workspace" | "agent";
};
type MountInfo = {
containerRoot: string;
writable: boolean;
source: "workspace" | "agent";
};
export type RemoteShellSandboxHandle = {
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
runRemoteShellScript(params: SandboxBackendCommandParams): Promise<SandboxBackendCommandResult>;
};
export function createRemoteShellSandboxFsBridge(params: {
sandbox: SandboxContext;
runtime: RemoteShellSandboxHandle;
}): SandboxFsBridge {
return new RemoteShellSandboxFsBridge(params.sandbox, params.runtime);
}
class RemoteShellSandboxFsBridge implements SandboxFsBridge {
constructor(
private readonly sandbox: SandboxContext,
private readonly runtime: RemoteShellSandboxHandle,
) {}
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
const target = this.resolveTarget(params);
return {
relativePath: target.relativePath,
containerPath: target.containerPath,
};
}
async readFile(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<Buffer> {
const target = this.resolveTarget(params);
const canonical = await this.resolveCanonicalPath({
containerPath: target.containerPath,
action: "read files",
signal: params.signal,
});
await this.assertNoHardlinkedFile({
containerPath: canonical,
action: "read files",
signal: params.signal,
});
const result = await this.runRemoteScript({
script: 'set -eu\ncat -- "$1"',
args: [canonical],
signal: params.signal,
});
return result.stdout;
}
async writeFile(params: {
filePath: string;
cwd?: string;
data: Buffer | string;
encoding?: BufferEncoding;
mkdir?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveTarget(params);
this.ensureWritable(target, "write files");
const pinned = await this.resolvePinnedParent({
containerPath: target.containerPath,
action: "write files",
requireWritable: true,
});
await this.assertNoHardlinkedFile({
containerPath: target.containerPath,
action: "write files",
signal: params.signal,
});
const buffer = Buffer.isBuffer(params.data)
? params.data
: Buffer.from(params.data, params.encoding ?? "utf8");
await this.runMutation({
args: [
"write",
pinned.mountRootPath,
pinned.relativeParentPath,
pinned.basename,
params.mkdir !== false ? "1" : "0",
],
stdin: buffer,
signal: params.signal,
});
}
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
const target = this.resolveTarget(params);
this.ensureWritable(target, "create directories");
const relativePath = path.posix.relative(target.mountRootPath, target.containerPath);
if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`,
);
}
await this.runMutation({
args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath],
signal: params.signal,
});
}
async remove(params: {
filePath: string;
cwd?: string;
recursive?: boolean;
force?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveTarget(params);
this.ensureWritable(target, "remove files");
const exists = await this.remotePathExists(target.containerPath, params.signal);
if (!exists) {
if (params.force === false) {
throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`);
}
return;
}
const pinned = await this.resolvePinnedParent({
containerPath: target.containerPath,
action: "remove files",
requireWritable: true,
allowFinalSymlinkForUnlink: true,
});
await this.runMutation({
args: [
"remove",
pinned.mountRootPath,
pinned.relativeParentPath,
pinned.basename,
params.recursive ? "1" : "0",
params.force === false ? "0" : "1",
],
signal: params.signal,
allowFailure: params.force !== false,
});
}
async rename(params: {
from: string;
to: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<void> {
const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd });
const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd });
this.ensureWritable(from, "rename files");
this.ensureWritable(to, "rename files");
const fromPinned = await this.resolvePinnedParent({
containerPath: from.containerPath,
action: "rename files",
requireWritable: true,
allowFinalSymlinkForUnlink: true,
});
const toPinned = await this.resolvePinnedParent({
containerPath: to.containerPath,
action: "rename files",
requireWritable: true,
});
await this.runMutation({
args: [
"rename",
fromPinned.mountRootPath,
fromPinned.relativeParentPath,
fromPinned.basename,
toPinned.mountRootPath,
toPinned.relativeParentPath,
toPinned.basename,
"1",
],
signal: params.signal,
});
}
async stat(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<SandboxFsStat | null> {
const target = this.resolveTarget(params);
const exists = await this.remotePathExists(target.containerPath, params.signal);
if (!exists) {
return null;
}
const canonical = await this.resolveCanonicalPath({
containerPath: target.containerPath,
action: "stat files",
signal: params.signal,
});
await this.assertNoHardlinkedFile({
containerPath: canonical,
action: "stat files",
signal: params.signal,
});
const result = await this.runRemoteScript({
script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"',
args: [canonical],
signal: params.signal,
});
const output = result.stdout.toString("utf8").trim();
const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|");
return {
type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other",
size: Number(sizeRaw),
mtimeMs: Number(mtimeRaw) * 1000,
};
}
private getMounts(): MountInfo[] {
const mounts: MountInfo[] = [
{
containerRoot: normalizeContainerPath(this.runtime.remoteWorkspaceDir),
writable: this.sandbox.workspaceAccess === "rw",
source: "workspace",
},
];
if (
this.sandbox.workspaceAccess !== "none" &&
path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)
) {
mounts.push({
containerRoot: normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir),
writable: this.sandbox.workspaceAccess === "rw",
source: "agent",
});
}
return mounts;
}
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath {
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
const workspaceContainerRoot = normalizeContainerPath(this.runtime.remoteWorkspaceDir);
const agentContainerRoot = normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir);
const mounts = this.getMounts();
const input = params.filePath.trim();
const inputPosix = input.replace(/\\/g, "/");
const maybeContainerMount = path.posix.isAbsolute(inputPosix)
? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix))
: null;
if (maybeContainerMount) {
return this.toResolvedPath({
mount: maybeContainerMount,
containerPath: normalizeContainerPath(inputPosix),
});
}
const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
const hostCandidate = path.isAbsolute(input)
? path.resolve(input)
: path.resolve(hostCwd, input);
if (isPathInside(workspaceRoot, hostCandidate)) {
const relative = toPosixRelative(workspaceRoot, hostCandidate);
return this.toResolvedPath({
mount: mounts[0],
containerPath: relative
? path.posix.join(workspaceContainerRoot, relative)
: workspaceContainerRoot,
});
}
if (mounts[1] && isPathInside(agentRoot, hostCandidate)) {
const relative = toPosixRelative(agentRoot, hostCandidate);
return this.toResolvedPath({
mount: mounts[1],
containerPath: relative
? path.posix.join(agentContainerRoot, relative)
: agentContainerRoot,
});
}
if (params.cwd) {
const cwdPosix = params.cwd.replace(/\\/g, "/");
if (path.posix.isAbsolute(cwdPosix)) {
const cwdContainer = normalizeContainerPath(cwdPosix);
const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer);
if (cwdMount) {
return this.toResolvedPath({
mount: cwdMount,
containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)),
});
}
}
}
throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`);
}
private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath {
const relative = path.posix.relative(params.mount.containerRoot, params.containerPath);
if (relative.startsWith("..") || path.posix.isAbsolute(relative)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`,
);
}
return {
relativePath:
params.mount.source === "workspace"
? relative === "."
? ""
: relative
: relative === "."
? params.mount.containerRoot
: `${params.mount.containerRoot}/${relative}`,
containerPath: params.containerPath,
writable: params.mount.writable,
mountRootPath: params.mount.containerRoot,
source: params.mount.source,
};
}
private resolveMountByContainerPath(
mounts: MountInfo[],
containerPath: string,
): MountInfo | null {
const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length);
for (const mount of ordered) {
if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) {
return mount;
}
}
return null;
}
private ensureWritable(target: ResolvedRemotePath, action: string) {
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
}
}
private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise<boolean> {
const result = await this.runRemoteScript({
script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi',
args: [containerPath],
signal,
});
return result.stdout.toString("utf8").trim() === "1";
}
private async resolveCanonicalPath(params: {
containerPath: string;
action: string;
allowFinalSymlinkForUnlink?: boolean;
signal?: AbortSignal;
}): Promise<string> {
const script = [
"set -eu",
'target="$1"',
'allow_final="$2"',
'suffix=""',
'probe="$target"',
'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi',
'cursor="$probe"',
'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do',
' parent=$(dirname -- "$cursor")',
' if [ "$parent" = "$cursor" ]; then break; fi',
' base=$(basename -- "$cursor")',
' suffix="/$base$suffix"',
' cursor="$parent"',
"done",
'canonical=$(readlink -f -- "$cursor")',
'printf "%s%s\\n" "$canonical" "$suffix"',
].join("\n");
const result = await this.runRemoteScript({
script,
args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"],
signal: params.signal,
});
const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim());
if (!this.resolveMountByContainerPath(this.getMounts(), canonical)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
);
}
return canonical;
}
private async assertNoHardlinkedFile(params: {
containerPath: string;
action: string;
signal?: AbortSignal;
}): Promise<void> {
const result = await this.runRemoteScript({
script: [
'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi',
'stats=$(stat -c "%F|%h" -- "$1")',
'printf "%s\\n" "$stats"',
].join("\n"),
args: [params.containerPath],
signal: params.signal,
allowFailure: true,
});
const output = result.stdout.toString("utf8").trim();
if (!output) {
return;
}
const [kind = "", linksRaw = "1"] = output.split("|");
if (kind === "regular file" && Number(linksRaw) > 1) {
throw new Error(
`Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`,
);
}
}
private async resolvePinnedParent(params: {
containerPath: string;
action: string;
requireWritable?: boolean;
allowFinalSymlinkForUnlink?: boolean;
}): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> {
const basename = path.posix.basename(params.containerPath);
if (!basename || basename === "." || basename === "/") {
throw new Error(`Invalid sandbox entry target: ${params.containerPath}`);
}
const canonicalParent = await this.resolveCanonicalPath({
containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)),
action: params.action,
allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink,
});
const mount = this.resolveMountByContainerPath(this.getMounts(), canonicalParent);
if (!mount) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
);
}
if (params.requireWritable && !mount.writable) {
throw new Error(
`Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`,
);
}
const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent);
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`,
);
}
return {
mountRootPath: mount.containerRoot,
relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
basename,
};
}
private async runMutation(params: {
args: string[];
stdin?: Buffer | string;
signal?: AbortSignal;
allowFailure?: boolean;
}) {
await this.runRemoteScript({
script: [
"set -eu",
"python3 /dev/fd/3 \"$@\" 3<<'PY'",
SANDBOX_PINNED_MUTATION_PYTHON,
"PY",
].join("\n"),
args: params.args,
stdin: params.stdin,
signal: params.signal,
allowFailure: params.allowFailure,
});
}
private async runRemoteScript(params: {
script: string;
args?: string[];
stdin?: Buffer | string;
signal?: AbortSignal;
allowFailure?: boolean;
}) {
return await this.runtime.runRemoteShellScript({
script: params.script,
args: params.args,
stdin: params.stdin,
signal: params.signal,
allowFailure: params.allowFailure,
});
}
}
function normalizeContainerPath(value: string): string {
const normalized = path.posix.normalize(value.trim() || "/");
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function isPathInsideContainerRoot(root: string, candidate: string): boolean {
const normalizedRoot = normalizeContainerPath(root);
const normalizedCandidate = normalizeContainerPath(candidate);
return (
normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
);
}
function isPathInside(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function toPosixRelative(root: string, candidate: string): string {
return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep);
}

View File

@ -0,0 +1,303 @@
import path from "node:path";
import type {
CreateSandboxBackendParams,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendHandle,
SandboxBackendManager,
} from "./backend.js";
import { resolveSandboxConfigForAgent } from "./config.js";
import {
createRemoteShellSandboxFsBridge,
type RemoteShellSandboxHandle,
} from "./remote-fs-bridge.js";
import {
buildExecRemoteCommand,
buildRemoteCommand,
buildSshSandboxArgv,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,
runSshSandboxCommand,
uploadDirectoryToSshTarget,
type SshSandboxSession,
} from "./ssh.js";
type PendingExec = {
sshSession: SshSandboxSession;
};
type ResolvedSshRuntimePaths = {
runtimeId: string;
runtimeRootDir: string;
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
};
export const sshSandboxBackendManager: SandboxBackendManager = {
async describeRuntime({ entry, config, agentId }) {
const cfg = resolveSandboxConfigForAgent(config, agentId);
if (cfg.backend !== "ssh" || !cfg.ssh.target) {
return {
running: false,
actualConfigLabel: cfg.ssh.target,
configLabelMatch: false,
};
}
const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey);
const session = await createSshSandboxSessionFromSettings({
...cfg.ssh,
target: cfg.ssh.target,
});
try {
const result = await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi',
"openclaw-sandbox-check",
runtimePaths.runtimeRootDir,
]),
});
return {
running: result.stdout.toString("utf8").trim() === "1",
actualConfigLabel: cfg.ssh.target,
configLabelMatch: entry.image === cfg.ssh.target,
};
} finally {
await disposeSshSandboxSession(session);
}
},
async removeRuntime({ entry, config, agentId }) {
const cfg = resolveSandboxConfigForAgent(config, agentId);
if (cfg.backend !== "ssh" || !cfg.ssh.target) {
return;
}
const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey);
const session = await createSshSandboxSessionFromSettings({
...cfg.ssh,
target: cfg.ssh.target,
});
try {
await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
'rm -rf -- "$1"',
"openclaw-sandbox-remove",
runtimePaths.runtimeRootDir,
]),
allowFailure: true,
});
} finally {
await disposeSshSandboxSession(session);
}
},
};
export async function createSshSandboxBackend(
params: CreateSandboxBackendParams,
): Promise<SandboxBackendHandle> {
if ((params.cfg.docker.binds?.length ?? 0) > 0) {
throw new Error("SSH sandbox backend does not support sandbox.docker.binds.");
}
const target = params.cfg.ssh.target;
if (!target) {
throw new Error('Sandbox backend "ssh" requires agents.defaults.sandbox.ssh.target.');
}
const runtimePaths = resolveSshRuntimePaths(params.cfg.ssh.workspaceRoot, params.scopeKey);
const impl = new SshSandboxBackendImpl({
createParams: params,
target,
runtimePaths,
});
return impl.asHandle();
}
class SshSandboxBackendImpl {
private ensurePromise: Promise<void> | null = null;
constructor(
private readonly params: {
createParams: CreateSandboxBackendParams;
target: string;
runtimePaths: ResolvedSshRuntimePaths;
},
) {}
asHandle(): SandboxBackendHandle & RemoteShellSandboxHandle {
return {
id: "ssh",
runtimeId: this.params.runtimePaths.runtimeId,
runtimeLabel: this.params.runtimePaths.runtimeId,
workdir: this.params.runtimePaths.remoteWorkspaceDir,
env: this.params.createParams.cfg.docker.env,
configLabel: this.params.target,
configLabelKind: "Target",
remoteWorkspaceDir: this.params.runtimePaths.remoteWorkspaceDir,
remoteAgentWorkspaceDir: this.params.runtimePaths.remoteAgentWorkspaceDir,
buildExecSpec: async ({ command, workdir, env, usePty }) => {
await this.ensureRuntime();
const sshSession = await this.createSession();
const remoteCommand = buildExecRemoteCommand({
command,
workdir: workdir ?? this.params.runtimePaths.remoteWorkspaceDir,
env,
});
return {
argv: buildSshSandboxArgv({
session: sshSession,
remoteCommand,
tty: usePty,
}),
env: process.env,
stdinMode: "pipe-open",
finalizeToken: { sshSession } satisfies PendingExec,
};
},
finalizeExec: async ({ token }) => {
const sshSession = (token as PendingExec | undefined)?.sshSession;
if (sshSession) {
await disposeSshSandboxSession(sshSession);
}
},
runShellCommand: async (command) => await this.runRemoteShellScript(command),
createFsBridge: ({ sandbox }) =>
createRemoteShellSandboxFsBridge({
sandbox,
runtime: this.asHandle(),
}),
runRemoteShellScript: async (command) => await this.runRemoteShellScript(command),
};
}
private async createSession(): Promise<SshSandboxSession> {
return await createSshSandboxSessionFromSettings({
...this.params.createParams.cfg.ssh,
target: this.params.target,
});
}
private async ensureRuntime(): Promise<void> {
if (this.ensurePromise) {
return await this.ensurePromise;
}
this.ensurePromise = this.ensureRuntimeInner();
try {
await this.ensurePromise;
} catch (error) {
this.ensurePromise = null;
throw error;
}
}
private async ensureRuntimeInner(): Promise<void> {
const session = await this.createSession();
try {
const exists = await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi',
"openclaw-sandbox-check",
this.params.runtimePaths.runtimeRootDir,
]),
});
if (exists.stdout.toString("utf8").trim() === "1") {
return;
}
await this.replaceRemoteDirectoryFromLocal(
session,
this.params.createParams.workspaceDir,
this.params.runtimePaths.remoteWorkspaceDir,
);
if (
this.params.createParams.cfg.workspaceAccess !== "none" &&
path.resolve(this.params.createParams.agentWorkspaceDir) !==
path.resolve(this.params.createParams.workspaceDir)
) {
await this.replaceRemoteDirectoryFromLocal(
session,
this.params.createParams.agentWorkspaceDir,
this.params.runtimePaths.remoteAgentWorkspaceDir,
);
}
} finally {
await disposeSshSandboxSession(session);
}
}
private async replaceRemoteDirectoryFromLocal(
session: SshSandboxSession,
localDir: string,
remoteDir: string,
): Promise<void> {
await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
"openclaw-sandbox-clear",
remoteDir,
]),
});
await uploadDirectoryToSshTarget({
session,
localDir,
remoteDir,
});
}
async runRemoteShellScript(
params: SandboxBackendCommandParams,
): Promise<SandboxBackendCommandResult> {
await this.ensureRuntime();
const session = await this.createSession();
try {
return await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
params.script,
"openclaw-sandbox-fs",
...(params.args ?? []),
]),
stdin: params.stdin,
allowFailure: params.allowFailure,
signal: params.signal,
});
} finally {
await disposeSshSandboxSession(session);
}
}
}
function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths {
const runtimeId = buildSshSandboxRuntimeId(scopeKey);
const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId);
return {
runtimeId,
runtimeRootDir,
remoteWorkspaceDir: path.posix.join(runtimeRootDir, "workspace"),
remoteAgentWorkspaceDir: path.posix.join(runtimeRootDir, "agent"),
};
}
function buildSshSandboxRuntimeId(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
const safe = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
const hash = Array.from(trimmed).reduce(
(acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0,
5381,
);
return `openclaw-ssh-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
}

View File

@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import {
buildExecRemoteCommand,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,
type SshSandboxSession,
} from "./ssh.js";
const sessions: SshSandboxSession[] = [];
afterEach(async () => {
await Promise.all(
sessions.splice(0).map(async (session) => {
await disposeSshSandboxSession(session);
}),
);
});
describe("sandbox ssh helpers", () => {
it("materializes inline ssh auth data into a temp config", async () => {
const session = await createSshSandboxSessionFromSettings({
command: "ssh",
target: "peter@example.com:2222",
strictHostKeyChecking: true,
updateHostKeys: false,
identityData: "PRIVATE KEY",
certificateData: "SSH CERT",
knownHostsData: "example.com ssh-ed25519 AAAATEST",
});
sessions.push(session);
const config = await fs.readFile(session.configPath, "utf8");
expect(config).toContain("Host openclaw-sandbox");
expect(config).toContain("HostName example.com");
expect(config).toContain("User peter");
expect(config).toContain("Port 2222");
expect(config).toContain("StrictHostKeyChecking yes");
expect(config).toContain("UpdateHostKeys no");
const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/"));
expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY");
expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT");
expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe(
"example.com ssh-ed25519 AAAATEST",
);
});
it("wraps remote exec commands with env and workdir", () => {
const command = buildExecRemoteCommand({
command: "pwd && printenv TOKEN",
workdir: "/sandbox/project",
env: {
TOKEN: "abc 123",
},
});
expect(command).toContain(`'env'`);
expect(command).toContain(`'TOKEN=abc 123'`);
expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`);
});
});

334
src/agents/sandbox/ssh.ts Normal file
View File

@ -0,0 +1,334 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { parseSshTarget } from "../../infra/ssh-tunnel.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { resolveUserPath } from "../../utils.js";
import type { SandboxBackendCommandResult } from "./backend.js";
export type SshSandboxSettings = {
command: string;
target: string;
strictHostKeyChecking: boolean;
updateHostKeys: boolean;
identityFile?: string;
certificateFile?: string;
knownHostsFile?: string;
identityData?: string;
certificateData?: string;
knownHostsData?: string;
};
export type SshSandboxSession = {
command: string;
configPath: string;
host: string;
};
export type RunSshSandboxCommandParams = {
session: SshSandboxSession;
remoteCommand: string;
stdin?: Buffer | string;
allowFailure?: boolean;
signal?: AbortSignal;
tty?: boolean;
};
export function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function buildRemoteCommand(argv: string[]): string {
return argv.map((entry) => shellEscape(entry)).join(" ");
}
export function buildExecRemoteCommand(params: {
command: string;
workdir?: string;
env: Record<string, string>;
}): string {
const body = params.workdir
? `cd ${shellEscape(params.workdir)} && ${params.command}`
: params.command;
const argv =
Object.keys(params.env).length > 0
? [
"env",
...Object.entries(params.env).map(([key, value]) => `${key}=${value}`),
"/bin/sh",
"-c",
body,
]
: ["/bin/sh", "-c", body];
return buildRemoteCommand(argv);
}
export function buildSshSandboxArgv(params: {
session: SshSandboxSession;
remoteCommand: string;
tty?: boolean;
}): string[] {
return [
params.session.command,
"-F",
params.session.configPath,
...(params.tty
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
: ["-T", "-o", "RequestTTY=no"]),
params.session.host,
params.remoteCommand,
];
}
export async function createSshSandboxSessionFromConfigText(params: {
configText: string;
host?: string;
command?: string;
}): Promise<SshSandboxSession> {
const host = params.host?.trim() || parseSshConfigHost(params.configText);
if (!host) {
throw new Error("Failed to parse SSH config output.");
}
const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-"));
const configPath = path.join(configDir, "config");
await fs.writeFile(configPath, params.configText, { encoding: "utf8", mode: 0o600 });
await fs.chmod(configPath, 0o600);
return {
command: params.command?.trim() || "ssh",
configPath,
host,
};
}
export async function createSshSandboxSessionFromSettings(
settings: SshSandboxSettings,
): Promise<SshSandboxSession> {
const parsed = parseSshTarget(settings.target);
if (!parsed) {
throw new Error(`Invalid sandbox SSH target: ${settings.target}`);
}
const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-"));
try {
const materializedIdentity = settings.identityData
? await writeSecretMaterial(configDir, "identity", settings.identityData)
: undefined;
const materializedCertificate = settings.certificateData
? await writeSecretMaterial(configDir, "certificate.pub", settings.certificateData)
: undefined;
const materializedKnownHosts = settings.knownHostsData
? await writeSecretMaterial(configDir, "known_hosts", settings.knownHostsData)
: undefined;
const identityFile = materializedIdentity ?? resolveOptionalLocalPath(settings.identityFile);
const certificateFile =
materializedCertificate ?? resolveOptionalLocalPath(settings.certificateFile);
const knownHostsFile =
materializedKnownHosts ?? resolveOptionalLocalPath(settings.knownHostsFile);
const hostAlias = "openclaw-sandbox";
const configPath = path.join(configDir, "config");
const lines = [
`Host ${hostAlias}`,
` HostName ${parsed.host}`,
` Port ${parsed.port}`,
" BatchMode yes",
" ConnectTimeout 5",
" ServerAliveInterval 15",
" ServerAliveCountMax 3",
` StrictHostKeyChecking ${settings.strictHostKeyChecking ? "yes" : "no"}`,
` UpdateHostKeys ${settings.updateHostKeys ? "yes" : "no"}`,
];
if (parsed.user) {
lines.push(` User ${parsed.user}`);
}
if (knownHostsFile) {
lines.push(` UserKnownHostsFile ${knownHostsFile}`);
} else if (!settings.strictHostKeyChecking) {
lines.push(" UserKnownHostsFile /dev/null");
}
if (identityFile) {
lines.push(` IdentityFile ${identityFile}`);
}
if (certificateFile) {
lines.push(` CertificateFile ${certificateFile}`);
}
if (identityFile || certificateFile) {
lines.push(" IdentitiesOnly yes");
}
await fs.writeFile(configPath, `${lines.join("\n")}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(configPath, 0o600);
return {
command: settings.command.trim() || "ssh",
configPath,
host: hostAlias,
};
} catch (error) {
await fs.rm(configDir, { recursive: true, force: true });
throw error;
}
}
export async function disposeSshSandboxSession(session: SshSandboxSession): Promise<void> {
await fs.rm(path.dirname(session.configPath), { recursive: true, force: true });
}
export async function runSshSandboxCommand(
params: RunSshSandboxCommandParams,
): Promise<SandboxBackendCommandResult> {
const argv = buildSshSandboxArgv({
session: params.session,
remoteCommand: params.remoteCommand,
tty: params.tty,
});
return await new Promise<SandboxBackendCommandResult>((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
signal: params.signal,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (code) => {
const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks);
const exitCode = code ?? 0;
if (exitCode !== 0 && !params.allowFailure) {
reject(
Object.assign(
new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`),
{
code: exitCode,
stdout,
stderr,
},
),
);
return;
}
resolve({ stdout, stderr, code: exitCode });
});
if (params.stdin !== undefined) {
child.stdin.end(params.stdin);
return;
}
child.stdin.end();
});
}
export async function uploadDirectoryToSshTarget(params: {
session: SshSandboxSession;
localDir: string;
remoteDir: string;
signal?: AbortSignal;
}): Promise<void> {
const remoteCommand = buildRemoteCommand([
"/bin/sh",
"-c",
'mkdir -p -- "$1" && tar -xf - -C "$1"',
"openclaw-sandbox-upload",
params.remoteDir,
]);
const sshArgv = buildSshSandboxArgv({
session: params.session,
remoteCommand,
});
await new Promise<void>((resolve, reject) => {
const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], {
stdio: ["ignore", "pipe", "pipe"],
signal: params.signal,
});
const ssh = spawn(sshArgv[0], sshArgv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
signal: params.signal,
});
const tarStderr: Buffer[] = [];
const sshStdout: Buffer[] = [];
const sshStderr: Buffer[] = [];
let tarClosed = false;
let sshClosed = false;
let tarCode = 0;
let sshCode = 0;
tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk)));
ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk)));
ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk)));
const fail = (error: unknown) => {
tar.kill("SIGKILL");
ssh.kill("SIGKILL");
reject(error);
};
tar.on("error", fail);
ssh.on("error", fail);
tar.stdout.pipe(ssh.stdin);
tar.on("close", (code) => {
tarClosed = true;
tarCode = code ?? 0;
maybeResolve();
});
ssh.on("close", (code) => {
sshClosed = true;
sshCode = code ?? 0;
maybeResolve();
});
function maybeResolve() {
if (!tarClosed || !sshClosed) {
return;
}
if (tarCode !== 0) {
reject(
new Error(
Buffer.concat(tarStderr).toString("utf8").trim() || `tar exited with code ${tarCode}`,
),
);
return;
}
if (sshCode !== 0) {
reject(
new Error(
Buffer.concat(sshStderr).toString("utf8").trim() || `ssh exited with code ${sshCode}`,
),
);
return;
}
resolve();
}
});
}
function parseSshConfigHost(configText: string): string | null {
const hostMatch = configText.match(/^\s*Host\s+(\S+)/m);
return hostMatch?.[1]?.trim() || null;
}
function resolveSshTmpRoot(): string {
return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir());
}
function resolveOptionalLocalPath(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? resolveUserPath(trimmed) : undefined;
}
async function writeSecretMaterial(
dir: string,
filename: string,
contents: string,
): Promise<string> {
const pathname = path.join(dir, filename);
await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 });
await fs.chmod(pathname, 0o600);
return pathname;
}

View File

@ -51,6 +51,20 @@ export type SandboxPruneConfig = {
maxAgeDays: number;
};
export type SandboxSshConfig = {
target?: string;
command: string;
workspaceRoot: string;
strictHostKeyChecking: boolean;
updateHostKeys: boolean;
identityFile?: string;
certificateFile?: string;
knownHostsFile?: string;
identityData?: string;
certificateData?: string;
knownHostsData?: string;
};
export type SandboxScope = "session" | "agent" | "shared";
export type SandboxConfig = {
@ -60,6 +74,7 @@ export type SandboxConfig = {
workspaceAccess: SandboxWorkspaceAccess;
workspaceRoot: string;
docker: SandboxDockerConfig;
ssh: SandboxSshConfig;
browser: SandboxBrowserConfig;
tools: SandboxToolPolicy;
prune: SandboxPruneConfig;

View File

@ -2,6 +2,7 @@ import type {
SandboxBrowserSettings,
SandboxDockerSettings,
SandboxPruneSettings,
SandboxSshSettings,
} from "./types.sandbox.js";
export type AgentModelConfig =
@ -32,6 +33,8 @@ export type AgentSandboxConfig = {
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings;
/** SSH-specific sandbox settings. */
ssh?: SandboxSshSettings;
/** Optional sandboxed browser settings. */
browser?: SandboxBrowserSettings;
/** Auto-prune sandbox settings. */

View File

@ -1,3 +1,5 @@
import type { SecretInput } from "./types.secrets.js";
export type SandboxDockerSettings = {
/** Docker image to use for sandbox containers. */
image?: string;
@ -94,3 +96,28 @@ export type SandboxPruneSettings = {
/** Prune if older than N days (0 disables). */
maxAgeDays?: number;
};
export type SandboxSshSettings = {
/** SSH target in user@host[:port] form. */
target?: string;
/** SSH client command. Default: "ssh". */
command?: string;
/** Absolute remote root used for per-scope workspaces. */
workspaceRoot?: string;
/** Enforce host-key verification. Default: true. */
strictHostKeyChecking?: boolean;
/** Allow OpenSSH host-key updates. Default: true. */
updateHostKeys?: boolean;
/** Existing private key path on the host. */
identityFile?: string;
/** Existing SSH certificate path on the host. */
certificateFile?: string;
/** Existing known_hosts file path on the host. */
knownHostsFile?: string;
/** Inline or SecretRef-backed private key contents. */
identityData?: SecretInput;
/** Inline or SecretRef-backed SSH certificate contents. */
certificateData?: SecretInput;
/** Inline or SecretRef-backed known_hosts contents. */
knownHostsData?: SecretInput;
};

View File

@ -501,6 +501,23 @@ const ToolLoopDetectionSchema = z
})
.optional();
export const SandboxSshSchema = z
.object({
target: z.string().min(1).optional(),
command: z.string().min(1).optional(),
workspaceRoot: z.string().min(1).optional(),
strictHostKeyChecking: z.boolean().optional(),
updateHostKeys: z.boolean().optional(),
identityFile: z.string().min(1).optional(),
certificateFile: z.string().min(1).optional(),
knownHostsFile: z.string().min(1).optional(),
identityData: SecretInputSchema.optional().register(sensitive),
certificateData: SecretInputSchema.optional().register(sensitive),
knownHostsData: SecretInputSchema.optional().register(sensitive),
})
.strict()
.optional();
export const AgentSandboxSchema = z
.object({
mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(),
@ -511,6 +528,7 @@ export const AgentSandboxSchema = z
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
ssh: SandboxSshSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})

View File

@ -31,6 +31,8 @@ export type {
} from "../plugins/types.js";
export type {
CreateSandboxBackendParams,
RemoteShellSandboxHandle,
RunSshSandboxCommandParams,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendExecSpec,
@ -44,6 +46,9 @@ export type {
SandboxBackendRuntimeInfo,
SandboxContext,
SandboxResolvedPath,
SandboxSshConfig,
SshSandboxSession,
SshSandboxSettings,
} from "../agents/sandbox.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { PluginRuntime } from "../plugins/runtime/types.js";
@ -57,9 +62,19 @@ export type {
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export {
buildExecRemoteCommand,
buildRemoteCommand,
buildSshSandboxArgv,
createRemoteShellSandboxFsBridge,
createSshSandboxSessionFromConfigText,
createSshSandboxSessionFromSettings,
disposeSshSandboxSession,
getSandboxBackendFactory,
getSandboxBackendManager,
registerSandboxBackend,
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,
requireSandboxBackendFactory,
} from "../agents/sandbox.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";

View File

@ -313,6 +313,90 @@ function collectCronAssignments(params: {
});
}
function collectSandboxSshAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const agents = isRecord(params.config.agents) ? params.config.agents : undefined;
if (!agents) {
return;
}
const defaultsAgent = isRecord(agents.defaults) ? agents.defaults : undefined;
const defaultsSandbox = isRecord(defaultsAgent?.sandbox) ? defaultsAgent.sandbox : undefined;
const defaultsSsh = isRecord(defaultsSandbox?.ssh)
? (defaultsSandbox.ssh as Record<string, unknown>)
: undefined;
const defaultsBackend =
typeof defaultsSandbox?.backend === "string" ? defaultsSandbox.backend : undefined;
const defaultsMode = typeof defaultsSandbox?.mode === "string" ? defaultsSandbox.mode : undefined;
const inheritedDefaultsUsage = {
identityData: false,
certificateData: false,
knownHostsData: false,
};
const list = Array.isArray(agents.list) ? agents.list : [];
list.forEach((rawAgent, index) => {
const agentRecord = isRecord(rawAgent) ? (rawAgent as Record<string, unknown>) : null;
if (!agentRecord || agentRecord.enabled === false) {
return;
}
const sandbox = isRecord(agentRecord.sandbox) ? agentRecord.sandbox : undefined;
const ssh = isRecord(sandbox?.ssh) ? sandbox.ssh : undefined;
const effectiveBackend =
(typeof sandbox?.backend === "string" ? sandbox.backend : undefined) ??
defaultsBackend ??
"docker";
const effectiveMode =
(typeof sandbox?.mode === "string" ? sandbox.mode : undefined) ?? defaultsMode ?? "off";
const active = effectiveBackend.trim().toLowerCase() === "ssh" && effectiveMode !== "off";
for (const key of ["identityData", "certificateData", "knownHostsData"] as const) {
if (ssh && Object.prototype.hasOwnProperty.call(ssh, key)) {
collectSecretInputAssignment({
value: ssh[key],
path: `agents.list.${index}.sandbox.ssh.${key}`,
expected: "string",
defaults: params.defaults,
context: params.context,
active,
inactiveReason: "sandbox SSH backend is not active for this agent.",
apply: (value) => {
ssh[key] = value;
},
});
} else if (active) {
inheritedDefaultsUsage[key] = true;
}
}
});
if (!defaultsSsh) {
return;
}
const defaultsActive =
(defaultsBackend?.trim().toLowerCase() === "ssh" && defaultsMode !== "off") ||
inheritedDefaultsUsage.identityData ||
inheritedDefaultsUsage.certificateData ||
inheritedDefaultsUsage.knownHostsData;
for (const key of ["identityData", "certificateData", "knownHostsData"] as const) {
collectSecretInputAssignment({
value: defaultsSsh[key],
path: `agents.defaults.sandbox.ssh.${key}`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: defaultsActive || inheritedDefaultsUsage[key],
inactiveReason: "sandbox SSH backend is not active.",
apply: (value) => {
defaultsSsh[key] = value;
},
});
}
}
export function collectCoreConfigAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
@ -339,6 +423,7 @@ export function collectCoreConfigAssignments(params: {
collectAgentMemorySearchAssignments(params);
collectTalkAssignments(params);
collectGatewayAssignments(params);
collectSandboxSshAssignments(params);
collectMessagesTtsAssignments(params);
collectCronAssignments(params);
}

View File

@ -221,6 +221,46 @@ describe("secrets runtime snapshot", () => {
).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" });
});
it("resolves sandbox ssh secret refs for active ssh backends", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "ssh",
ssh: {
target: "peter@example.com:22",
identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" },
certificateData: {
source: "env",
provider: "default",
id: "SSH_CERTIFICATE_DATA",
},
knownHostsData: {
source: "env",
provider: "default",
id: "SSH_KNOWN_HOSTS_DATA",
},
},
},
},
},
}),
env: {
SSH_IDENTITY_DATA: "PRIVATE KEY",
SSH_CERTIFICATE_DATA: "SSH CERT",
SSH_KNOWN_HOSTS_DATA: "example.com ssh-ed25519 AAAATEST",
},
});
expect(snapshot.config.agents?.defaults?.sandbox?.ssh).toMatchObject({
identityData: "PRIVATE KEY",
certificateData: "SSH CERT",
knownHostsData: "example.com ssh-ed25519 AAAATEST",
});
});
it("normalizes inline SecretRef object on token to tokenRef", async () => {
const config: OpenClawConfig = { models: {}, secrets: {} };
const snapshot = await prepareSecretsRuntimeSnapshot({