From 0a2f95916be6354d6e898ec3b8eb45015e16f16a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:38:22 -0700 Subject: [PATCH] test: expand ssh sandbox coverage and docs --- docs/cli/sandbox.md | 6 + docs/gateway/configuration-reference.md | 7 + docs/gateway/sandboxing.md | 12 + docs/gateway/secrets.md | 29 ++ src/agents/sandbox/ssh-backend.test.ts | 338 ++++++++++++++++++++++++ src/secrets/runtime.test.ts | 33 +++ 6 files changed, 425 insertions(+) create mode 100644 src/agents/sandbox/ssh-backend.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index f320be3b771..5764851dc70 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -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` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ecefd8bbc4e..0653fd3834f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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 diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index b37757334c0..c6cf839e42d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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 `. +- 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 diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index eb044eaf03c..05554b1f6d3 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -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: diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts new file mode 100644 index 00000000000..c8ec3b5f750 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 837a174efaa..8e7e549ae51 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -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({