test: expand ssh sandbox coverage and docs
This commit is contained in:
parent
b8bb8510a2
commit
0a2f95916b
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
338
src/agents/sandbox/ssh-backend.test.ts
Normal file
338
src/agents/sandbox/ssh-backend.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user