feat: add remote openshell sandbox mode
This commit is contained in:
parent
3b26da4b82
commit
ae7f18e503
@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||
- 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 in mirror mode, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@ -1117,7 +1117,7 @@ See [Typing Indicators](/concepts/typing-indicators).
|
||||
|
||||
### `agents.defaults.sandbox`
|
||||
|
||||
Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide.
|
||||
Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide.
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -1125,6 +1125,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "non-main", // off | non-main | all
|
||||
backend: "docker", // docker | openshell
|
||||
scope: "agent", // session | agent | shared
|
||||
workspaceAccess: "none", // none | ro | rw
|
||||
workspaceRoot: "~/.openclaw/sandboxes",
|
||||
@ -1260,6 +1261,11 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
|
||||
|
||||
</Accordion>
|
||||
|
||||
When `backend: "openshell"` is selected, runtime-specific settings move to
|
||||
`plugins.entries.openshell.config` (for example `mode: "mirror" | "remote"` and
|
||||
`remoteWorkspaceDir`). Browser sandboxing and `sandbox.docker.binds` are
|
||||
currently Docker-only.
|
||||
|
||||
Build images:
|
||||
|
||||
```bash
|
||||
|
||||
@ -7,7 +7,7 @@ status: active
|
||||
|
||||
# Sandboxing
|
||||
|
||||
OpenClaw can run **tools inside Docker containers** to reduce blast radius.
|
||||
OpenClaw can run **tools inside sandbox backends** to reduce blast radius.
|
||||
This is **optional** and controlled by configuration (`agents.defaults.sandbox` or
|
||||
`agents.list[].sandbox`). If sandboxing is off, tools run on the host.
|
||||
The Gateway stays on the host; tool execution runs in an isolated sandbox
|
||||
@ -54,6 +54,54 @@ Not sandboxed:
|
||||
- `"agent"`: one container per agent.
|
||||
- `"shared"`: one container shared by all sandboxed sessions.
|
||||
|
||||
## Backend
|
||||
|
||||
`agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox:
|
||||
|
||||
- `"docker"` (default): local Docker-backed sandbox runtime.
|
||||
- `"openshell"`: OpenShell-backed sandbox runtime provided by the bundled `openshell` plugin.
|
||||
|
||||
OpenShell-specific config lives under `plugins.entries.openshell.config`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
backend: "openshell",
|
||||
scope: "session",
|
||||
workspaceAccess: "rw",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
openshell: {
|
||||
enabled: true,
|
||||
config: {
|
||||
from: "openclaw",
|
||||
mode: "remote", // mirror | remote
|
||||
remoteWorkspaceDir: "/sandbox",
|
||||
remoteAgentWorkspaceDir: "/agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Current OpenShell limitations:
|
||||
|
||||
- sandbox browser is not supported yet
|
||||
- `sandbox.docker.binds` is not supported on the OpenShell backend
|
||||
- Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend
|
||||
|
||||
## Workspace access
|
||||
|
||||
`agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**:
|
||||
@ -116,7 +164,7 @@ Security notes:
|
||||
|
||||
## Images + setup
|
||||
|
||||
Default image: `openclaw-sandbox:bookworm-slim`
|
||||
Default Docker image: `openclaw-sandbox:bookworm-slim`
|
||||
|
||||
Build it once:
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
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;
|
||||
@ -34,6 +35,7 @@ type PendingExec = {
|
||||
};
|
||||
|
||||
export type OpenShellSandboxBackend = SandboxBackendHandle & {
|
||||
mode: "mirror" | "remote";
|
||||
remoteWorkspaceDir: string;
|
||||
remoteAgentWorkspaceDir: string;
|
||||
runRemoteShellScript(params: SandboxBackendCommandParams): Promise<SandboxBackendCommandResult>;
|
||||
@ -109,6 +111,7 @@ async function createOpenShellSandboxBackend(params: {
|
||||
runtimeLabel: sandboxName,
|
||||
workdir: params.pluginConfig.remoteWorkspaceDir,
|
||||
env: params.createParams.cfg.docker.env,
|
||||
mode: params.pluginConfig.mode,
|
||||
configLabel: params.pluginConfig.from,
|
||||
configLabelKind: "Source",
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
@ -125,10 +128,15 @@ async function createOpenShellSandboxBackend(params: {
|
||||
},
|
||||
runShellCommand: async (command) => await impl.runRemoteShellScript(command),
|
||||
createFsBridge: ({ sandbox }) =>
|
||||
createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: impl.asHandle(),
|
||||
}),
|
||||
params.pluginConfig.mode === "remote"
|
||||
? createOpenShellRemoteFsBridge({
|
||||
sandbox,
|
||||
backend: impl.asHandle(),
|
||||
})
|
||||
: createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: impl.asHandle(),
|
||||
}),
|
||||
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
|
||||
runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command),
|
||||
@ -139,6 +147,7 @@ async function createOpenShellSandboxBackend(params: {
|
||||
|
||||
class OpenShellSandboxBackendImpl {
|
||||
private ensurePromise: Promise<void> | null = null;
|
||||
private remoteSeedPending = false;
|
||||
|
||||
constructor(
|
||||
private readonly params: {
|
||||
@ -157,6 +166,7 @@ class OpenShellSandboxBackendImpl {
|
||||
runtimeLabel: this.params.execContext.sandboxName,
|
||||
workdir: this.params.remoteWorkspaceDir,
|
||||
env: this.params.createParams.cfg.docker.env,
|
||||
mode: this.params.execContext.config.mode,
|
||||
configLabel: this.params.execContext.config.from,
|
||||
configLabelKind: "Source",
|
||||
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
||||
@ -175,10 +185,15 @@ class OpenShellSandboxBackendImpl {
|
||||
},
|
||||
runShellCommand: async (command) => await self.runRemoteShellScript(command),
|
||||
createFsBridge: ({ sandbox }) =>
|
||||
createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: self.asHandle(),
|
||||
}),
|
||||
this.params.execContext.config.mode === "remote"
|
||||
? createOpenShellRemoteFsBridge({
|
||||
sandbox,
|
||||
backend: self.asHandle(),
|
||||
})
|
||||
: createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: self.asHandle(),
|
||||
}),
|
||||
runRemoteShellScript: async (command) => await self.runRemoteShellScript(command),
|
||||
syncLocalPathToRemote: async (localPath, remotePath) =>
|
||||
await self.syncLocalPathToRemote(localPath, remotePath),
|
||||
@ -192,7 +207,11 @@ class OpenShellSandboxBackendImpl {
|
||||
usePty: boolean;
|
||||
}): Promise<{ argv: string[]; token: PendingExec }> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.syncWorkspaceToRemote();
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceToRemote();
|
||||
} else {
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
}
|
||||
const sshSession = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
@ -218,7 +237,9 @@ class OpenShellSandboxBackendImpl {
|
||||
|
||||
async finalizeExec(token?: PendingExec): Promise<void> {
|
||||
try {
|
||||
await this.syncWorkspaceFromRemote();
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceFromRemote();
|
||||
}
|
||||
} finally {
|
||||
if (token?.sshSession) {
|
||||
await disposeOpenShellSshSession(token.sshSession);
|
||||
@ -230,6 +251,13 @@ class OpenShellSandboxBackendImpl {
|
||||
params: SandboxBackendCommandParams,
|
||||
): Promise<SandboxBackendCommandResult> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
return await this.runRemoteShellScriptInternal(params);
|
||||
}
|
||||
|
||||
private async runRemoteShellScriptInternal(
|
||||
params: SandboxBackendCommandParams,
|
||||
): Promise<SandboxBackendCommandResult> {
|
||||
const session = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
@ -254,6 +282,7 @@ class OpenShellSandboxBackendImpl {
|
||||
|
||||
async syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
const stats = await fs.lstat(localPath).catch(() => null);
|
||||
if (!stats) {
|
||||
await this.runRemoteShellScript({
|
||||
@ -340,10 +369,11 @@ class OpenShellSandboxBackendImpl {
|
||||
if (createResult.code !== 0) {
|
||||
throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
|
||||
}
|
||||
this.remoteSeedPending = true;
|
||||
}
|
||||
|
||||
private async syncWorkspaceToRemote(): Promise<void> {
|
||||
await this.runRemoteShellScript({
|
||||
await this.runRemoteShellScriptInternal({
|
||||
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
args: [this.params.remoteWorkspaceDir],
|
||||
});
|
||||
@ -357,7 +387,7 @@ class OpenShellSandboxBackendImpl {
|
||||
path.resolve(this.params.createParams.agentWorkspaceDir) !==
|
||||
path.resolve(this.params.createParams.workspaceDir)
|
||||
) {
|
||||
await this.runRemoteShellScript({
|
||||
await this.runRemoteShellScriptInternal({
|
||||
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
args: [this.params.remoteAgentWorkspaceDir],
|
||||
});
|
||||
@ -413,6 +443,19 @@ class OpenShellSandboxBackendImpl {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeSeedRemoteWorkspace(): Promise<void> {
|
||||
if (!this.remoteSeedPending) {
|
||||
return;
|
||||
}
|
||||
this.remoteSeedPending = false;
|
||||
try {
|
||||
await this.syncWorkspaceToRemote();
|
||||
} catch (error) {
|
||||
this.remoteSeedPending = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenShellPluginConfigFromConfig(
|
||||
|
||||
@ -4,6 +4,7 @@ import { resolveOpenShellPluginConfig } from "./config.js";
|
||||
describe("openshell plugin config", () => {
|
||||
it("applies defaults", () => {
|
||||
expect(resolveOpenShellPluginConfig(undefined)).toEqual({
|
||||
mode: "mirror",
|
||||
command: "openshell",
|
||||
gateway: undefined,
|
||||
gatewayEndpoint: undefined,
|
||||
@ -18,6 +19,10 @@ describe("openshell plugin config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts remote mode", () => {
|
||||
expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote");
|
||||
});
|
||||
|
||||
it("rejects relative remote paths", () => {
|
||||
expect(() =>
|
||||
resolveOpenShellPluginConfig({
|
||||
@ -25,4 +30,12 @@ describe("openshell plugin config", () => {
|
||||
}),
|
||||
).toThrow("OpenShell remote path must be absolute");
|
||||
});
|
||||
|
||||
it("rejects unknown mode", () => {
|
||||
expect(() =>
|
||||
resolveOpenShellPluginConfig({
|
||||
mode: "bogus",
|
||||
}),
|
||||
).toThrow("mode must be one of mirror, remote");
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export type OpenShellPluginConfig = {
|
||||
mode?: string;
|
||||
command?: string;
|
||||
gateway?: string;
|
||||
gatewayEndpoint?: string;
|
||||
@ -16,6 +17,7 @@ export type OpenShellPluginConfig = {
|
||||
};
|
||||
|
||||
export type ResolvedOpenShellPluginConfig = {
|
||||
mode: "mirror" | "remote";
|
||||
command: string;
|
||||
gateway?: string;
|
||||
gatewayEndpoint?: string;
|
||||
@ -30,6 +32,7 @@ export type ResolvedOpenShellPluginConfig = {
|
||||
};
|
||||
|
||||
const DEFAULT_COMMAND = "openshell";
|
||||
const DEFAULT_MODE = "mirror";
|
||||
const DEFAULT_SOURCE = "openclaw";
|
||||
const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox";
|
||||
const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent";
|
||||
@ -99,6 +102,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema
|
||||
};
|
||||
}
|
||||
const allowedKeys = new Set([
|
||||
"mode",
|
||||
"command",
|
||||
"gateway",
|
||||
"gatewayEndpoint",
|
||||
@ -156,6 +160,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
mode: trimString(value.mode),
|
||||
command: trimString(value.command),
|
||||
gateway: trimString(value.gateway),
|
||||
gatewayEndpoint: trimString(value.gatewayEndpoint),
|
||||
@ -178,6 +183,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
command: { type: "string" },
|
||||
mode: { type: "string", enum: ["mirror", "remote"] },
|
||||
gateway: { type: "string" },
|
||||
gatewayEndpoint: { type: "string" },
|
||||
from: { type: "string" },
|
||||
@ -203,7 +209,12 @@ export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellP
|
||||
}
|
||||
const raw = parsed.data ?? {};
|
||||
const cfg = (raw ?? {}) as OpenShellPluginConfig;
|
||||
const mode = cfg.mode ?? DEFAULT_MODE;
|
||||
if (mode !== "mirror" && mode !== "remote") {
|
||||
throw new Error(`Invalid openshell plugin config: mode must be one of mirror, remote`);
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
command: cfg.command ?? DEFAULT_COMMAND,
|
||||
gateway: cfg.gateway,
|
||||
gatewayEndpoint: cfg.gatewayEndpoint,
|
||||
|
||||
@ -43,13 +43,14 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Buffer> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
return await fsPromises.readFile(target.hostPath);
|
||||
return await fsPromises.readFile(hostPath);
|
||||
}
|
||||
|
||||
async writeFile(params: {
|
||||
@ -61,6 +62,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
this.ensureWritable(target, "write files");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
@ -71,21 +73,22 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
const buffer = Buffer.isBuffer(params.data)
|
||||
? params.data
|
||||
: Buffer.from(params.data, params.encoding ?? "utf8");
|
||||
const parentDir = path.dirname(target.hostPath);
|
||||
const parentDir = path.dirname(hostPath);
|
||||
if (params.mkdir !== false) {
|
||||
await fsPromises.mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
const tempPath = path.join(
|
||||
parentDir,
|
||||
`.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`,
|
||||
`.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
await fsPromises.writeFile(tempPath, buffer);
|
||||
await fsPromises.rename(tempPath, target.hostPath);
|
||||
await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath);
|
||||
await fsPromises.rename(tempPath, hostPath);
|
||||
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
|
||||
}
|
||||
|
||||
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
this.ensureWritable(target, "create directories");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
@ -93,7 +96,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
await fsPromises.mkdir(target.hostPath, { recursive: true });
|
||||
await fsPromises.mkdir(hostPath, { recursive: true });
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1"',
|
||||
args: [target.containerPath],
|
||||
@ -109,6 +112,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
this.ensureWritable(target, "remove files");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
@ -116,7 +120,7 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
allowMissingLeaf: params.force !== false,
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
});
|
||||
await fsPromises.rm(target.hostPath, {
|
||||
await fsPromises.rm(hostPath, {
|
||||
recursive: params.recursive ?? false,
|
||||
force: params.force !== false,
|
||||
});
|
||||
@ -138,6 +142,8 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
}): Promise<void> {
|
||||
const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd });
|
||||
const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd });
|
||||
const fromHostPath = this.requireHostPath(from);
|
||||
const toHostPath = this.requireHostPath(to);
|
||||
this.ensureWritable(from, "rename files");
|
||||
this.ensureWritable(to, "rename files");
|
||||
await assertLocalPathSafety({
|
||||
@ -152,8 +158,8 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true });
|
||||
await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath });
|
||||
await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true });
|
||||
await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath });
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"',
|
||||
args: [from.containerPath, to.containerPath],
|
||||
@ -167,7 +173,8 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SandboxFsStat | null> {
|
||||
const target = this.resolveTarget(params);
|
||||
const stats = await fsPromises.lstat(target.hostPath).catch(() => null);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
const stats = await fsPromises.lstat(hostPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
@ -190,6 +197,15 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private requireHostPath(target: ResolvedMountPath): string {
|
||||
if (!target.hostPath) {
|
||||
throw new Error(
|
||||
`OpenShell mirror bridge requires a local host path: ${target.containerPath}`,
|
||||
);
|
||||
}
|
||||
return target.hostPath;
|
||||
}
|
||||
|
||||
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath {
|
||||
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
|
||||
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
|
||||
@ -282,6 +298,9 @@ async function assertLocalPathSafety(params: {
|
||||
allowMissingLeaf: boolean;
|
||||
allowFinalSymlinkForUnlink: boolean;
|
||||
}): Promise<void> {
|
||||
if (!params.target.hostPath) {
|
||||
throw new Error(`Missing local host path for ${params.target.containerPath}`);
|
||||
}
|
||||
const canonicalRoot = await fsPromises
|
||||
.realpath(params.root)
|
||||
.catch(() => path.resolve(params.root));
|
||||
|
||||
191
extensions/openshell/src/remote-fs-bridge.test.ts
Normal file
191
extensions/openshell/src/remote-fs-bridge.test.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
|
||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||
import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function translateRemotePath(value: string, roots: { workspace: string; agent: string }) {
|
||||
if (value === "/sandbox" || value.startsWith("/sandbox/")) {
|
||||
return path.join(roots.workspace, value.slice("/sandbox".length));
|
||||
}
|
||||
if (value === "/agent" || value.startsWith("/agent/")) {
|
||||
return path.join(roots.agent, value.slice("/agent".length));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runLocalShell(params: {
|
||||
script: string;
|
||||
args?: string[];
|
||||
stdin?: Buffer | string;
|
||||
allowFailure?: boolean;
|
||||
roots: { workspace: string; agent: string };
|
||||
}) {
|
||||
const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots));
|
||||
const script = normalizeScriptForLocalShell(params.script);
|
||||
const result = await new Promise<{ stdout: Buffer; stderr: Buffer; code: number }>(
|
||||
(resolve, reject) => {
|
||||
const child = spawn("/bin/sh", ["-c", script, "openshell-test", ...translatedArgs], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
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 result = {
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr: Buffer.concat(stderrChunks),
|
||||
code: code ?? 0,
|
||||
};
|
||||
if (result.code !== 0 && !params.allowFailure) {
|
||||
reject(
|
||||
new Error(
|
||||
result.stderr.toString("utf8").trim() || `script exited with code ${result.code}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
if (params.stdin !== undefined) {
|
||||
child.stdin.end(params.stdin);
|
||||
return;
|
||||
}
|
||||
child.stdin.end();
|
||||
},
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function createBackendMock(roots: { workspace: string; agent: string }): OpenShellSandboxBackend {
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: "openshell-test",
|
||||
runtimeLabel: "openshell-test",
|
||||
workdir: "/sandbox",
|
||||
env: {},
|
||||
mode: "remote",
|
||||
remoteWorkspaceDir: "/sandbox",
|
||||
remoteAgentWorkspaceDir: "/agent",
|
||||
buildExecSpec: vi.fn(),
|
||||
runShellCommand: vi.fn(),
|
||||
runRemoteShellScript: vi.fn(
|
||||
async (params) =>
|
||||
await runLocalShell({
|
||||
...params,
|
||||
roots,
|
||||
}),
|
||||
),
|
||||
syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as OpenShellSandboxBackend;
|
||||
}
|
||||
|
||||
function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) {
|
||||
return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent");
|
||||
}
|
||||
|
||||
function normalizeScriptForLocalShell(script: string) {
|
||||
return script
|
||||
.replace(
|
||||
'stats=$(stat -c "%F|%h" -- "$1")',
|
||||
`stats=$(python3 - "$1" <<'PY'
|
||||
import os, stat, sys
|
||||
st = os.stat(sys.argv[1])
|
||||
kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other'
|
||||
print(f"{kind}|{st.st_nlink}")
|
||||
PY
|
||||
)`,
|
||||
)
|
||||
.replace(
|
||||
'stat -c "%F|%s|%Y" -- "$1"',
|
||||
`python3 - "$1" <<'PY'
|
||||
import os, stat, sys
|
||||
st = os.stat(sys.argv[1])
|
||||
kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other'
|
||||
print(f"{kind}|{st.st_size}|{int(st.st_mtime)}")
|
||||
PY`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("openshell remote fs bridge", () => {
|
||||
it("writes, reads, renames, and removes files without local host paths", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-");
|
||||
const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-");
|
||||
const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-");
|
||||
const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir);
|
||||
const remoteAgentRealDir = await fs.realpath(remoteAgentDir);
|
||||
const backend = createBackendMock({
|
||||
workspace: remoteWorkspaceRealDir,
|
||||
agent: remoteAgentRealDir,
|
||||
});
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const bridge = createOpenShellRemoteFsBridge({ sandbox, backend });
|
||||
await bridge.writeFile({
|
||||
filePath: "nested/file.txt",
|
||||
data: "hello",
|
||||
mkdir: true,
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe(
|
||||
"hello",
|
||||
);
|
||||
expect(await fs.readdir(workspaceDir)).toEqual([]);
|
||||
|
||||
const resolved = bridge.resolvePath({ filePath: "nested/file.txt" });
|
||||
expect(resolved.hostPath).toBeUndefined();
|
||||
expect(resolved.containerPath).toBe("/sandbox/nested/file.txt");
|
||||
expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello"));
|
||||
expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "file",
|
||||
size: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.rename({
|
||||
from: "nested/file.txt",
|
||||
to: "nested/renamed.txt",
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"),
|
||||
).rejects.toBeDefined();
|
||||
expect(
|
||||
await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"),
|
||||
).toBe("hello");
|
||||
|
||||
await bridge.remove({
|
||||
filePath: "nested/renamed.txt",
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
550
extensions/openshell/src/remote-fs-bridge.ts
Normal file
550
extensions/openshell/src/remote-fs-bridge.ts
Normal file
@ -0,0 +1,550 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
SandboxContext,
|
||||
SandboxFsBridge,
|
||||
SandboxFsStat,
|
||||
SandboxResolvedPath,
|
||||
} 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;
|
||||
}): 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<Buffer> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SandboxFsStat | null> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@ -361,4 +361,46 @@ describe("applyPatch", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("uses container paths when the sandbox bridge has no local host path", async () => {
|
||||
const files = new Map<string, string>([["/sandbox/source.txt", "before\n"]]);
|
||||
const bridge = {
|
||||
resolvePath: ({ filePath }: { filePath: string }) => ({
|
||||
relativePath: filePath,
|
||||
containerPath: `/sandbox/${filePath}`,
|
||||
}),
|
||||
readFile: vi.fn(async ({ filePath }: { filePath: string }) =>
|
||||
Buffer.from(files.get(filePath) ?? "", "utf8"),
|
||||
),
|
||||
writeFile: vi.fn(async ({ filePath, data }: { filePath: string; data: Buffer | string }) => {
|
||||
files.set(filePath, Buffer.isBuffer(data) ? data.toString("utf8") : data);
|
||||
}),
|
||||
remove: vi.fn(async ({ filePath }: { filePath: string }) => {
|
||||
files.delete(filePath);
|
||||
}),
|
||||
mkdirp: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const patch = `*** Begin Patch
|
||||
*** Update File: source.txt
|
||||
@@
|
||||
-before
|
||||
+after
|
||||
*** End Patch`;
|
||||
|
||||
const result = await applyPatch(patch, {
|
||||
cwd: "/local/workspace",
|
||||
sandbox: {
|
||||
root: "/local/workspace",
|
||||
bridge: bridge as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(files.get("/sandbox/source.txt")).toBe("after\n");
|
||||
expect(result.summary.modified).toEqual(["source.txt"]);
|
||||
expect(bridge.readFile).toHaveBeenCalledWith({
|
||||
filePath: "/sandbox/source.txt",
|
||||
cwd: "/local/workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -313,7 +313,7 @@ async function resolvePatchPath(
|
||||
filePath,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
if (options.workspaceOnly !== false) {
|
||||
if (options.workspaceOnly !== false && resolved.hostPath) {
|
||||
await assertSandboxPath({
|
||||
filePath: resolved.hostPath,
|
||||
cwd: options.cwd,
|
||||
@ -323,8 +323,8 @@ async function resolvePatchPath(
|
||||
});
|
||||
}
|
||||
return {
|
||||
resolved: resolved.hostPath,
|
||||
display: resolved.relativePath || resolved.hostPath,
|
||||
resolved: resolved.hostPath ?? resolved.containerPath,
|
||||
display: resolved.relativePath || resolved.containerPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSandboxBridgeReadFile } from "./sandbox-media-paths.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
} from "./sandbox-media-paths.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
|
||||
describe("createSandboxBridgeReadFile", () => {
|
||||
@ -19,4 +22,24 @@ describe("createSandboxBridgeReadFile", () => {
|
||||
cwd: "/tmp/sandbox-root",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to container paths when the bridge has no host path", async () => {
|
||||
const stat = vi.fn(async () => ({ type: "file", size: 1, mtimeMs: 1 }));
|
||||
const resolved = await resolveSandboxedBridgeMediaPath({
|
||||
sandbox: {
|
||||
root: "/tmp/sandbox-root",
|
||||
bridge: {
|
||||
resolvePath: ({ filePath }: { filePath: string }) => ({
|
||||
relativePath: filePath,
|
||||
containerPath: `/sandbox/${filePath}`,
|
||||
}),
|
||||
stat,
|
||||
} as unknown as SandboxFsBridge,
|
||||
},
|
||||
mediaPath: "image.png",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ resolved: "/sandbox/image.png" });
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -44,8 +44,10 @@ export async function resolveSandboxedBridgeMediaPath(params: {
|
||||
});
|
||||
try {
|
||||
const resolved = resolveDirect();
|
||||
await enforceWorkspaceBoundary(resolved.hostPath);
|
||||
return { resolved: resolved.hostPath };
|
||||
if (resolved.hostPath) {
|
||||
await enforceWorkspaceBoundary(resolved.hostPath);
|
||||
}
|
||||
return { resolved: resolved.hostPath ?? resolved.containerPath };
|
||||
} catch (err) {
|
||||
const fallbackDir = params.inboundFallbackDir?.trim();
|
||||
if (!fallbackDir) {
|
||||
@ -67,7 +69,12 @@ export async function resolveSandboxedBridgeMediaPath(params: {
|
||||
filePath: fallbackPath,
|
||||
cwd: params.sandbox.root,
|
||||
});
|
||||
await enforceWorkspaceBoundary(resolvedFallback.hostPath);
|
||||
return { resolved: resolvedFallback.hostPath, rewrittenFrom: filePath };
|
||||
if (resolvedFallback.hostPath) {
|
||||
await enforceWorkspaceBoundary(resolvedFallback.hostPath);
|
||||
}
|
||||
return {
|
||||
resolved: resolvedFallback.hostPath ?? resolvedFallback.containerPath,
|
||||
rewrittenFrom: filePath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ type RunCommandOptions = {
|
||||
};
|
||||
|
||||
export type SandboxResolvedPath = {
|
||||
hostPath: string;
|
||||
hostPath?: string;
|
||||
relativePath: string;
|
||||
containerPath: string;
|
||||
};
|
||||
|
||||
@ -10,10 +10,16 @@ export function createSandboxFsBridgeFromResolver(
|
||||
resolvePath: ({ filePath, cwd }) => resolvePath(filePath, cwd),
|
||||
readFile: async ({ filePath, cwd }) => {
|
||||
const target = resolvePath(filePath, cwd);
|
||||
if (!target.hostPath) {
|
||||
throw new Error(`Expected hostPath for ${target.containerPath}`);
|
||||
}
|
||||
return fs.readFile(target.hostPath);
|
||||
},
|
||||
writeFile: async ({ filePath, cwd, data, mkdir = true }) => {
|
||||
const target = resolvePath(filePath, cwd);
|
||||
if (!target.hostPath) {
|
||||
throw new Error(`Expected hostPath for ${target.containerPath}`);
|
||||
}
|
||||
if (mkdir) {
|
||||
await fs.mkdir(path.dirname(target.hostPath), { recursive: true });
|
||||
}
|
||||
@ -22,10 +28,16 @@ export function createSandboxFsBridgeFromResolver(
|
||||
},
|
||||
mkdirp: async ({ filePath, cwd }) => {
|
||||
const target = resolvePath(filePath, cwd);
|
||||
if (!target.hostPath) {
|
||||
throw new Error(`Expected hostPath for ${target.containerPath}`);
|
||||
}
|
||||
await fs.mkdir(target.hostPath, { recursive: true });
|
||||
},
|
||||
remove: async ({ filePath, cwd, recursive, force }) => {
|
||||
const target = resolvePath(filePath, cwd);
|
||||
if (!target.hostPath) {
|
||||
throw new Error(`Expected hostPath for ${target.containerPath}`);
|
||||
}
|
||||
await fs.rm(target.hostPath, {
|
||||
recursive: recursive ?? false,
|
||||
force: force ?? false,
|
||||
@ -34,12 +46,20 @@ export function createSandboxFsBridgeFromResolver(
|
||||
rename: async ({ from, to, cwd }) => {
|
||||
const source = resolvePath(from, cwd);
|
||||
const target = resolvePath(to, cwd);
|
||||
if (!source.hostPath || !target.hostPath) {
|
||||
throw new Error(
|
||||
`Expected hostPath for rename: ${source.containerPath} -> ${target.containerPath}`,
|
||||
);
|
||||
}
|
||||
await fs.mkdir(path.dirname(target.hostPath), { recursive: true });
|
||||
await fs.rename(source.hostPath, target.hostPath);
|
||||
},
|
||||
stat: async ({ filePath, cwd }) => {
|
||||
try {
|
||||
const target = resolvePath(filePath, cwd);
|
||||
if (!target.hostPath) {
|
||||
throw new Error(`Expected hostPath for ${target.containerPath}`);
|
||||
}
|
||||
const stats = await fs.stat(target.hostPath);
|
||||
return {
|
||||
type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user