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