diff --git a/CHANGELOG.md b/CHANGELOG.md index 07937512400..ea4239d1e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 5ebac698175..f320be3b771 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -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", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 951f99f1165..ecefd8bbc4e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index db40b802832..b37757334c0 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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: diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 379e4a527d4..eb044eaf03c 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -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 diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts index 2999599c648..2685d7effa8 100644 --- a/extensions/openshell/src/backend.test.ts +++ b/extensions/openshell/src/backend.test.ts @@ -101,6 +101,7 @@ describe("openshell backend manager", () => { image: "openclaw", configLabelKind: "Source", }, + config: {}, }); expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 85c3d415904..d87b1c92af8 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -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; - syncLocalPathToRemote(localPath: string, remotePath: string): Promise; -}; +export type OpenShellSandboxBackend = SandboxBackendHandle & + RemoteShellSandboxHandle & { + mode: "mirror" | "remote"; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; + }; 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); } } diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 8f9808b5164..411166520e7 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -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 { +}): Promise { 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 { - await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); -} - -export async function runOpenShellSshCommand( - params: OpenShellRunSshCommandParams, -): Promise { - 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((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 { - 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); } diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 3560fa78f28..9cc1ddf704d 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, + }); } diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index d120ac84820..742701017d2 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -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"); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index b52cb5ab050..d26dc75204d 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -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, diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index c186b0fe4cc..013cb565176 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -65,7 +65,11 @@ export type SandboxBackendManager = { config: OpenClawConfig; agentId?: string; }): Promise; - removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; + removeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; }; 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, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index c62276c6b87..88b5feccccc 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -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", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index dda3e048ea7..c5bd29e9d11 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -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; @@ -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; + agentSsh?: Partial; +}): 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, diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 54941ba04d1..46d37f9fd61 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -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", diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index 0b5ba578d7d..c6e6f3fd7bf 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -85,16 +85,22 @@ export async function listSandboxBrowsers(): Promise { } export async function removeSandboxContainer(containerName: string): Promise { + 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 { + 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); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 6ccfd8ac238..8005c23330e 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -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( } async function pruneSandboxContainers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries({ 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) => { diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts new file mode 100644 index 00000000000..ef70e928eac --- /dev/null +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -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; +}; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts new file mode 100644 index 00000000000..f241103fc19 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.ts @@ -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 { + 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 | 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 { + return await createSshSandboxSessionFromSettings({ + ...this.params.createParams.cfg.ssh, + target: this.params.target, + }); + } + + private async ensureRuntime(): Promise { + 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 { + 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 { + 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 { + 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)}`; +} diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts new file mode 100644 index 00000000000..c2c07a3bf11 --- /dev/null +++ b/src/agents/sandbox/ssh.test.ts @@ -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'`); + }); +}); diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts new file mode 100644 index 00000000000..1590b515e8f --- /dev/null +++ b/src/agents/sandbox/ssh.ts @@ -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 { + 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 { + 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 { + 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 { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runSshSandboxCommand( + params: RunSshSandboxCommandParams, +): Promise { + const argv = buildSshSandboxArgv({ + session: params.session, + remoteCommand: params.remoteCommand, + tty: params.tty, + }); + return await new Promise((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 { + 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((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 { + const pathname = path.join(dir, filename); + await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(pathname, 0o600); + return pathname; +} diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 8244583ea0c..482ce6a922e 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -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; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 1e398cc1c70..3351d9903c9 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -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. */ diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 047f10cde53..04128e2ffaa 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -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; +}; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 9ddbedf929e..10cef396275 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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, }) diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index f3a6d1ca16b..025efaff67a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -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"; diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 99668371ad1..ef571b3f54f 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -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) + : 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) : 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); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 47628f1bfe2..837a174efaa 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -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({