test: expand ssh sandbox coverage and docs

This commit is contained in:
Peter Steinberger 2026-03-15 21:38:22 -07:00
parent b8bb8510a2
commit 0a2f95916b
No known key found for this signature in database
6 changed files with 425 additions and 0 deletions

View File

@ -19,6 +19,12 @@ Today that usually means:
- SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"`
- OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"`
For `ssh` and OpenShell `remote`, recreate matters more than with Docker:
- the remote workspace is canonical after the initial seed
- `openclaw sandbox recreate` deletes that canonical remote workspace for the selected scope
- next use seeds it again from the current local workspace
## Commands
### `openclaw sandbox explain`

View File

@ -1232,6 +1232,13 @@ When `backend: "openshell"` is selected, runtime-specific settings move to
- `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime
- `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs
**SSH auth precedence:**
- `identityData` wins over `identityFile`
- `certificateData` wins over `certificateFile`
- `knownHostsData` wins over `knownHostsFile`
- SecretRef-backed `*Data` values are resolved from the active secrets runtime snapshot before the sandbox session starts
**SSH backend behavior:**
- seeds the remote workspace once after create or recreate

View File

@ -105,6 +105,12 @@ How it works:
- 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.
Authentication material:
- `identityFile`, `certificateFile`, `knownHostsFile`: use existing local files and pass them through OpenSSH config.
- `identityData`, `certificateData`, `knownHostsData`: use inline strings or SecretRefs. OpenClaw resolves them through the normal secrets runtime snapshot, writes them to temp files with `0600`, and deletes them when the SSH session ends.
- If both `*File` and `*Data` are set for the same item, `*Data` wins for that SSH session.
This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed.
Important consequences:
@ -150,6 +156,12 @@ OpenShell modes:
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.
Remote transport details:
- OpenClaw asks OpenShell for sandbox-specific SSH config via `openshell sandbox ssh-config <name>`.
- Core writes that SSH config to a temp file, opens the SSH session, and reuses the same remote filesystem bridge used by `backend: "ssh"`.
- In `mirror` mode only the lifecycle differs: sync local to remote before exec, then sync back after exec.
Current OpenShell limitations:
- sandbox browser is not supported yet

View File

@ -288,6 +288,35 @@ Optional per-id errors:
}
```
## Sandbox SSH auth material
The core `ssh` sandbox backend also supports SecretRefs for SSH auth material:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "ssh",
ssh: {
target: "user@gateway-host:22",
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" },
},
},
},
},
}
```
Runtime behavior:
- OpenClaw resolves these refs during sandbox activation, not lazily during each SSH call.
- Resolved values are written to temp files with restrictive permissions and used in generated SSH config.
- If the effective sandbox backend is not `ssh`, these refs stay inactive and do not block startup.
## Supported credential surface
Canonical supported and unsupported credentials are listed in:

View File

@ -0,0 +1,338 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
const sshMocks = vi.hoisted(() => ({
createSshSandboxSessionFromSettings: vi.fn(),
disposeSshSandboxSession: vi.fn(),
runSshSandboxCommand: vi.fn(),
uploadDirectoryToSshTarget: vi.fn(),
buildSshSandboxArgv: vi.fn(),
}));
vi.mock("./ssh.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./ssh.js")>();
return {
...actual,
createSshSandboxSessionFromSettings: sshMocks.createSshSandboxSessionFromSettings,
disposeSshSandboxSession: sshMocks.disposeSshSandboxSession,
runSshSandboxCommand: sshMocks.runSshSandboxCommand,
uploadDirectoryToSshTarget: sshMocks.uploadDirectoryToSshTarget,
buildSshSandboxArgv: sshMocks.buildSshSandboxArgv,
};
});
import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js";
function createConfig(): OpenClawConfig {
return {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
ssh: {
target: "peter@example.com:2222",
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
},
},
},
};
}
function createSession() {
return {
command: "ssh",
configPath: path.join(os.tmpdir(), "openclaw-test-ssh-config"),
host: "openclaw-sandbox",
};
}
describe("ssh sandbox backend", () => {
beforeEach(() => {
vi.clearAllMocks();
sshMocks.createSshSandboxSessionFromSettings.mockResolvedValue(createSession());
sshMocks.disposeSshSandboxSession.mockResolvedValue(undefined);
sshMocks.runSshSandboxCommand.mockResolvedValue({
stdout: Buffer.from("1\n"),
stderr: Buffer.alloc(0),
code: 0,
});
sshMocks.uploadDirectoryToSshTarget.mockResolvedValue(undefined);
sshMocks.buildSshSandboxArgv.mockImplementation(({ session, remoteCommand, tty }) => [
session.command,
"-F",
session.configPath,
tty ? "-tt" : "-T",
session.host,
remoteCommand,
]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("describes runtimes via the configured ssh target", async () => {
const result = await sshSandboxBackendManager.describeRuntime({
entry: {
containerName: "openclaw-ssh-worker-abcd1234",
backendId: "ssh",
runtimeLabel: "openclaw-ssh-worker-abcd1234",
sessionKey: "agent:worker",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "peter@example.com:2222",
configLabelKind: "Target",
},
config: createConfig(),
});
expect(result).toEqual({
running: true,
actualConfigLabel: "peter@example.com:2222",
configLabelMatch: true,
});
expect(sshMocks.createSshSandboxSessionFromSettings).toHaveBeenCalledWith(
expect.objectContaining({
target: "peter@example.com:2222",
workspaceRoot: "/remote/openclaw",
}),
);
expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith(
expect.objectContaining({
remoteCommand: expect.stringContaining("/remote/openclaw/openclaw-ssh-agent-worker"),
}),
);
});
it("removes runtimes by deleting the remote scope root", async () => {
await sshSandboxBackendManager.removeRuntime({
entry: {
containerName: "openclaw-ssh-worker-abcd1234",
backendId: "ssh",
runtimeLabel: "openclaw-ssh-worker-abcd1234",
sessionKey: "agent:worker",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "peter@example.com:2222",
configLabelKind: "Target",
},
config: createConfig(),
});
expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith(
expect.objectContaining({
allowFailure: true,
remoteCommand: expect.stringContaining('rm -rf -- "$1"'),
}),
);
});
it("creates a remote-canonical backend that seeds once and reuses ssh exec", async () => {
sshMocks.runSshSandboxCommand
.mockResolvedValueOnce({
stdout: Buffer.from("0\n"),
stderr: Buffer.alloc(0),
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
});
const backend = await createSshSandboxBackend({
sessionKey: "agent:worker:task",
scopeKey: "agent:worker",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/agent",
cfg: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
ssh: {
target: "peter@example.com:2222",
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "openclaw-browser",
containerPrefix: "openclaw-browser-",
network: "bridge",
cdpPort: 9222,
vncPort: 5900,
noVncPort: 6080,
headless: true,
enableNoVnc: false,
allowHostControl: false,
autoStart: false,
autoStartTimeoutMs: 1000,
},
tools: { allow: [], deny: [] },
prune: { idleHours: 24, maxAgeDays: 7 },
},
});
const execSpec = await backend.buildExecSpec({
command: "pwd",
env: { TEST_TOKEN: "1" },
usePty: false,
});
expect(execSpec.argv).toEqual(
expect.arrayContaining(["ssh", "-F", createSession().configPath, "-T", createSession().host]),
);
expect(execSpec.argv.at(-1)).toContain("/remote/openclaw/openclaw-ssh-agent-worker");
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(2);
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
localDir: "/tmp/workspace",
remoteDir: expect.stringContaining("/workspace"),
}),
);
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
localDir: "/tmp/agent",
remoteDir: expect.stringContaining("/agent"),
}),
);
await backend.finalizeExec?.({
status: "completed",
exitCode: 0,
timedOut: false,
token: execSpec.finalizeToken,
});
expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalled();
});
it("rejects docker binds and missing ssh target", async () => {
await expect(
createSshSandboxBackend({
sessionKey: "s",
scopeKey: "s",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "img",
containerPrefix: "prefix-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: {},
binds: ["/tmp:/tmp:rw"],
},
ssh: {
target: "peter@example.com:22",
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "img",
containerPrefix: "prefix-",
network: "bridge",
cdpPort: 1,
vncPort: 2,
noVncPort: 3,
headless: true,
enableNoVnc: false,
allowHostControl: false,
autoStart: false,
autoStartTimeoutMs: 1,
},
tools: { allow: [], deny: [] },
prune: { idleHours: 24, maxAgeDays: 7 },
},
}),
).rejects.toThrow("does not support sandbox.docker.binds");
await expect(
createSshSandboxBackend({
sessionKey: "s",
scopeKey: "s",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "img",
containerPrefix: "prefix-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: {},
},
ssh: {
command: "ssh",
workspaceRoot: "/remote/openclaw",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "img",
containerPrefix: "prefix-",
network: "bridge",
cdpPort: 1,
vncPort: 2,
noVncPort: 3,
headless: true,
enableNoVnc: false,
allowHostControl: false,
autoStart: false,
autoStartTimeoutMs: 1,
},
tools: { allow: [], deny: [] },
prune: { idleHours: 24, maxAgeDays: 7 },
},
}),
).rejects.toThrow("requires agents.defaults.sandbox.ssh.target");
});
});

View File

@ -261,6 +261,39 @@ describe("secrets runtime snapshot", () => {
});
});
it("treats sandbox ssh secret refs as inactive when ssh backend is not selected", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "docker",
ssh: {
identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" },
},
},
},
},
}),
env: {},
});
expect(snapshot.config.agents?.defaults?.sandbox?.ssh?.identityData).toEqual({
source: "env",
provider: "default",
id: "SSH_IDENTITY_DATA",
});
expect(snapshot.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "agents.defaults.sandbox.ssh.identityData",
}),
]),
);
});
it("normalizes inline SecretRef object on token to tokenRef", async () => {
const config: OpenClawConfig = { models: {}, secrets: {} };
const snapshot = await prepareSecretsRuntimeSnapshot({