mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: make sandbox skills readable in writable sandboxes
Materializes prompt-visible skills into a protected sandbox-readable workspace for rw sandboxes, refreshes Docker/SSH/OpenShell views, and hardens stale or poisoned remote skill copies. Fixes #90410.
This commit is contained in:
@@ -318,7 +318,7 @@ With the OpenShell backend:
|
||||
Inbound media is copied into the active sandbox workspace (`media/inbound/*`).
|
||||
|
||||
<Note>
|
||||
**Skills note:** the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`, OpenClaw mirrors eligible skills into the sandbox workspace (`.../skills`) so they can be read. With `"rw"`, workspace skills are readable from `/workspace/skills`.
|
||||
**Skills note:** the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`, OpenClaw mirrors eligible skills into the sandbox workspace (`.../skills`) so they can be read. With `"rw"`, workspace skills are readable from `/workspace/skills`, and eligible managed, bundled, or plugin skills are materialized into the generated read-only path `/workspace/.openclaw/sandbox-skills/skills`.
|
||||
</Note>
|
||||
|
||||
## Custom bind mounts
|
||||
|
||||
@@ -31,6 +31,7 @@ import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from
|
||||
import { createOpenShellFsBridge } from "./fs-bridge.js";
|
||||
import {
|
||||
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
movePathWithCopyFallback,
|
||||
replaceDirectoryContents,
|
||||
stageDirectoryContents,
|
||||
} from "./mirror.js";
|
||||
@@ -43,6 +44,48 @@ type PendingExec = {
|
||||
sshSession: SshSandboxSession;
|
||||
};
|
||||
|
||||
const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"] as const;
|
||||
const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
|
||||
"set -e",
|
||||
'target="$1"',
|
||||
'root="${2:-$1}"',
|
||||
'case "$target" in /*) ;; *) echo "remote directory must be absolute: $target" >&2; exit 1 ;; esac',
|
||||
'case "$root" in /*) ;; *) echo "remote root must be absolute: $root" >&2; exit 1 ;; esac',
|
||||
'target="${target%/}"',
|
||||
'root="${root%/}"',
|
||||
'[ -n "$target" ] || target="/"',
|
||||
'[ -n "$root" ] || root="/"',
|
||||
'case "$target/" in "$root"/*|"$root/") ;; *) echo "remote directory must stay under root: $target" >&2; exit 1 ;; esac',
|
||||
'old_ifs="$IFS"',
|
||||
'IFS="/"',
|
||||
"set -- ${target#/} ${root#/}",
|
||||
'IFS="$old_ifs"',
|
||||
"for part do",
|
||||
' [ -n "$part" ] || continue',
|
||||
' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac',
|
||||
"done",
|
||||
'if [ -L "$root" ]; then echo "unsafe remote root symlink: $root" >&2; exit 1; fi',
|
||||
'mkdir -p -- "$root"',
|
||||
'canonical_root="$(cd "$root" && pwd -P)"',
|
||||
'relative="${target#"$root"}"',
|
||||
'relative="${relative#/}"',
|
||||
'current="$canonical_root"',
|
||||
'IFS="/"',
|
||||
"set -- $relative",
|
||||
'IFS="$old_ifs"',
|
||||
"for part do",
|
||||
' [ -n "$part" ] || continue',
|
||||
' if [ "$current" = "/" ]; then next="/$part"; else next="$current/$part"; fi',
|
||||
' if [ -L "$next" ]; then echo "unsafe remote directory symlink: $next" >&2; exit 1; fi',
|
||||
' if [ -e "$next" ]; then',
|
||||
' if [ ! -d "$next" ]; then echo "unsafe remote directory component: $next" >&2; exit 1; fi',
|
||||
" else",
|
||||
' mkdir -- "$next"',
|
||||
" fi",
|
||||
' current="$next"',
|
||||
"done",
|
||||
].join("\n");
|
||||
|
||||
export function buildOpenShellSshExecEnv(): NodeJS.ProcessEnv {
|
||||
return sanitizeEnvVars(process.env).allowed;
|
||||
}
|
||||
@@ -221,7 +264,10 @@ class OpenShellSandboxBackendImpl {
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceToRemote();
|
||||
} else {
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
const seeded = await this.maybeSeedRemoteWorkspace();
|
||||
if (!seeded) {
|
||||
await this.syncSkillsWorkspaceToRemote();
|
||||
}
|
||||
}
|
||||
const sshSession = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
@@ -257,7 +303,10 @@ class OpenShellSandboxBackendImpl {
|
||||
params: SandboxBackendCommandParams,
|
||||
): Promise<SandboxBackendCommandResult> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
const seeded = await this.maybeSeedRemoteWorkspace();
|
||||
if (!seeded) {
|
||||
await this.syncSkillsWorkspaceToRemote();
|
||||
}
|
||||
return await this.runRemoteShellScriptInternal(params);
|
||||
}
|
||||
|
||||
@@ -410,6 +459,31 @@ class OpenShellSandboxBackendImpl {
|
||||
this.params.remoteAgentWorkspaceDir,
|
||||
);
|
||||
}
|
||||
await this.syncSkillsWorkspaceToRemote();
|
||||
}
|
||||
|
||||
private async syncSkillsWorkspaceToRemote(): Promise<void> {
|
||||
if (
|
||||
this.params.createParams.cfg.workspaceAccess !== "rw" ||
|
||||
!this.params.createParams.skillsWorkspaceDir
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir(
|
||||
this.params.remoteWorkspaceDir,
|
||||
);
|
||||
await this.runRemoteShellScriptInternal({
|
||||
script: `${ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
|
||||
args: [remoteSkillsWorkspaceDir, this.params.remoteWorkspaceDir],
|
||||
});
|
||||
const stats = await fs.lstat(this.params.createParams.skillsWorkspaceDir).catch(() => null);
|
||||
if (!stats?.isDirectory() || stats.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
await this.uploadPathToRemote(
|
||||
this.params.createParams.skillsWorkspaceDir,
|
||||
remoteSkillsWorkspaceDir,
|
||||
);
|
||||
}
|
||||
|
||||
private async syncWorkspaceFromRemote(): Promise<void> {
|
||||
@@ -430,13 +504,25 @@ class OpenShellSandboxBackendImpl {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
||||
}
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: tmpDir,
|
||||
targetDir: this.params.createParams.workspaceDir,
|
||||
// Never sync trusted host hook directories or repository metadata from
|
||||
// the remote sandbox.
|
||||
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
await removeMaterializedSkillsFromDownloadedWorkspace(tmpDir);
|
||||
const preservedSandboxSkills = await moveMaterializedSkillsShadowAside({
|
||||
workspaceDir: this.params.createParams.workspaceDir,
|
||||
tmpDir,
|
||||
});
|
||||
try {
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: tmpDir,
|
||||
targetDir: this.params.createParams.workspaceDir,
|
||||
// Never sync trusted host hook directories or repository metadata from
|
||||
// the remote sandbox.
|
||||
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
});
|
||||
} finally {
|
||||
await restoreMaterializedSkillsShadow({
|
||||
workspaceDir: this.params.createParams.workspaceDir,
|
||||
preserved: preservedSandboxSkills,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -470,13 +556,14 @@ class OpenShellSandboxBackendImpl {
|
||||
);
|
||||
}
|
||||
|
||||
private async maybeSeedRemoteWorkspace(): Promise<void> {
|
||||
private async maybeSeedRemoteWorkspace(): Promise<boolean> {
|
||||
if (!this.remoteSeedPending) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
this.remoteSeedPending = false;
|
||||
try {
|
||||
await this.syncWorkspaceToRemote();
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.remoteSeedPending = true;
|
||||
throw error;
|
||||
@@ -508,6 +595,84 @@ export function buildOpenShellSandboxName(scopeKey: string): string {
|
||||
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir: string): string {
|
||||
const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
||||
return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
|
||||
}
|
||||
|
||||
async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir: string): Promise<void> {
|
||||
let cursor = tmpDir;
|
||||
for (const [index, part] of MATERIALIZED_SKILLS_REMOTE_PARTS.entries()) {
|
||||
const next = path.join(cursor, part);
|
||||
const stats = await fs.lstat(next).catch(() => null);
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
if (index === MATERIALIZED_SKILLS_REMOTE_PARTS.length - 1) {
|
||||
await fs.rm(next, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
||||
await fs.rm(next, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMaterializedSkillsShadowAside(params: {
|
||||
workspaceDir: string;
|
||||
tmpDir: string;
|
||||
}): Promise<{ preservedPath: string; preserveRoot: string } | undefined> {
|
||||
const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
|
||||
const parentStats = await fs.lstat(path.dirname(shadowPath)).catch(() => null);
|
||||
if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
const shadowStats = await fs.lstat(shadowPath).catch(() => null);
|
||||
if (!shadowStats || shadowStats.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
const preserveRoot = await fs.mkdtemp(
|
||||
path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"),
|
||||
);
|
||||
const preservedPath = path.join(preserveRoot, "sandbox-skills");
|
||||
await movePathWithCopyFallback({ from: shadowPath, to: preservedPath });
|
||||
return { preservedPath, preserveRoot };
|
||||
}
|
||||
|
||||
async function restoreMaterializedSkillsShadow(params: {
|
||||
workspaceDir: string;
|
||||
preserved?: { preservedPath: string; preserveRoot: string };
|
||||
}): Promise<void> {
|
||||
if (!params.preserved) {
|
||||
return;
|
||||
}
|
||||
let restored = false;
|
||||
try {
|
||||
const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
|
||||
const parentPath = path.dirname(shadowPath);
|
||||
const parentStats = await fs.lstat(parentPath).catch(() => null);
|
||||
if (parentStats?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to restore sandbox skills through symlink parent: ${parentPath}`);
|
||||
}
|
||||
if (parentStats && !parentStats.isDirectory()) {
|
||||
await fs.rm(parentPath, { recursive: true, force: true });
|
||||
}
|
||||
await fs.mkdir(parentPath, { recursive: true });
|
||||
await fs.rm(shadowPath, { recursive: true, force: true });
|
||||
await movePathWithCopyFallback({
|
||||
from: params.preserved.preservedPath,
|
||||
to: shadowPath,
|
||||
});
|
||||
restored = true;
|
||||
} finally {
|
||||
if (restored) {
|
||||
await fs.rm(params.preserved.preserveRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenShellTmpRoot(): string {
|
||||
return path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import { movePathWithCopyFallback } from "./mirror.js";
|
||||
type ResolvedMountPath = SandboxResolvedPath & {
|
||||
mountHostRoot: string;
|
||||
writable: boolean;
|
||||
source: "workspace" | "agent";
|
||||
source: "workspace" | "agent" | "protectedSkill";
|
||||
};
|
||||
|
||||
const MATERIALIZED_SKILLS_CONTAINER_PARTS = [".openclaw", "sandbox-skills", "skills"] as const;
|
||||
|
||||
export function createOpenShellFsBridge(params: {
|
||||
sandbox: OpenShellFsBridgeContext;
|
||||
backend: OpenShellSandboxBackend;
|
||||
@@ -230,8 +232,30 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
"/",
|
||||
);
|
||||
const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
|
||||
const skillsRoot = this.sandbox.skillsWorkspaceDir
|
||||
? path.resolve(this.sandbox.skillsWorkspaceDir, "skills")
|
||||
: undefined;
|
||||
const skillsContainerRoot = path.posix.join(
|
||||
workspaceContainerRoot,
|
||||
...MATERIALIZED_SKILLS_CONTAINER_PARTS,
|
||||
);
|
||||
const workspaceSkillsShadowRoot = path.resolve(
|
||||
workspaceRoot,
|
||||
...MATERIALIZED_SKILLS_CONTAINER_PARTS,
|
||||
);
|
||||
const input = params.filePath.trim();
|
||||
|
||||
if (skillsRoot && this.sandbox.workspaceAccess === "rw") {
|
||||
const protectedSkillTarget = resolveProtectedSkillTarget({
|
||||
input,
|
||||
skillsRoot,
|
||||
skillsContainerRoot,
|
||||
});
|
||||
if (protectedSkillTarget) {
|
||||
return protectedSkillTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
|
||||
const relative = path.posix.relative(workspaceContainerRoot, input) || "";
|
||||
const hostPath = relative
|
||||
@@ -270,6 +294,18 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
||||
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
||||
|
||||
if (skillsRoot && this.sandbox.workspaceAccess === "rw") {
|
||||
const protectedSkillShadowTarget = resolveProtectedSkillShadowTarget({
|
||||
hostPath,
|
||||
workspaceSkillsShadowRoot,
|
||||
skillsRoot,
|
||||
skillsContainerRoot,
|
||||
});
|
||||
if (protectedSkillShadowTarget) {
|
||||
return protectedSkillShadowTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPathInside(workspaceRoot, hostPath)) {
|
||||
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
@@ -284,6 +320,22 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
};
|
||||
}
|
||||
|
||||
if (skillsRoot && this.sandbox.workspaceAccess === "rw" && isPathInside(skillsRoot, hostPath)) {
|
||||
const relative = path.relative(skillsRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative
|
||||
? path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS, relative)
|
||||
: path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS),
|
||||
containerPath: relative
|
||||
? path.posix.join(skillsContainerRoot, relative)
|
||||
: skillsContainerRoot,
|
||||
mountHostRoot: skillsRoot,
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
||||
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
@@ -302,6 +354,72 @@ class OpenShellFsBridge implements SandboxFsBridge {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProtectedSkillTarget(params: {
|
||||
input: string;
|
||||
skillsRoot: string;
|
||||
skillsContainerRoot: string;
|
||||
}): ResolvedMountPath | null {
|
||||
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
||||
const normalizedInput = path.posix.normalize(params.input.replace(/\\/g, "/"));
|
||||
const isAbsoluteContainer =
|
||||
normalizedInput === params.skillsContainerRoot ||
|
||||
normalizedInput.startsWith(`${params.skillsContainerRoot}/`);
|
||||
const isRelativeContainer =
|
||||
normalizedInput === relativeRoot || normalizedInput.startsWith(`${relativeRoot}/`);
|
||||
if (!isAbsoluteContainer && !isRelativeContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relative = isAbsoluteContainer
|
||||
? path.posix.relative(params.skillsContainerRoot, normalizedInput)
|
||||
: path.posix.relative(relativeRoot, normalizedInput);
|
||||
const safeRelative = relative === "." ? "" : relative;
|
||||
const hostPath = safeRelative
|
||||
? path.resolve(params.skillsRoot, ...safeRelative.split("/"))
|
||||
: params.skillsRoot;
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: safeRelative ? path.posix.join(relativeRoot, safeRelative) : relativeRoot,
|
||||
containerPath: safeRelative
|
||||
? path.posix.join(params.skillsContainerRoot, safeRelative)
|
||||
: params.skillsContainerRoot,
|
||||
mountHostRoot: params.skillsRoot,
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProtectedSkillShadowTarget(params: {
|
||||
hostPath: string;
|
||||
workspaceSkillsShadowRoot: string;
|
||||
skillsRoot: string;
|
||||
skillsContainerRoot: string;
|
||||
}): ResolvedMountPath | null {
|
||||
if (!isPathInside(params.workspaceSkillsShadowRoot, params.hostPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relative = path
|
||||
.relative(params.workspaceSkillsShadowRoot, params.hostPath)
|
||||
.split(path.sep)
|
||||
.join(path.posix.sep);
|
||||
const safeRelative = relative === "." ? "" : relative;
|
||||
const hostPath = safeRelative
|
||||
? path.resolve(params.skillsRoot, ...safeRelative.split("/"))
|
||||
: params.skillsRoot;
|
||||
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: safeRelative ? path.posix.join(relativeRoot, safeRelative) : relativeRoot,
|
||||
containerPath: safeRelative
|
||||
? path.posix.join(params.skillsContainerRoot, safeRelative)
|
||||
: params.skillsContainerRoot,
|
||||
mountHostRoot: params.skillsRoot,
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
};
|
||||
}
|
||||
|
||||
async function assertLocalPathSafety(params: {
|
||||
target: ResolvedMountPath;
|
||||
root: string;
|
||||
|
||||
@@ -304,6 +304,155 @@ describe("openshell backend manager", () => {
|
||||
).rejects.toThrow(/unresolved placeholder token <name>/);
|
||||
expect(cliMocks.runOpenShellCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a local sandbox skills shadow when mirror sync crosses filesystems", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
|
||||
const shadowFile = path.join(workspaceDir, ".openclaw", "sandbox-skills", "user-note.txt");
|
||||
await fs.mkdir(path.dirname(shadowFile), { recursive: true });
|
||||
await fs.writeFile(shadowFile, "local shadow", "utf8");
|
||||
|
||||
const originalRename = fs.rename.bind(fs);
|
||||
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => {
|
||||
const source = String(from);
|
||||
const target = String(to);
|
||||
const shadowDir = path.dirname(shadowFile);
|
||||
const isFallbackStagedMove = path.basename(source).startsWith(".fs-safe-move-");
|
||||
if (source === shadowDir || (target === shadowDir && !isFallbackStagedMove)) {
|
||||
throw Object.assign(new Error("cross-device link not permitted"), { code: "EXDEV" });
|
||||
}
|
||||
return await originalRename(from, to);
|
||||
});
|
||||
cliMocks.runOpenShellCli.mockImplementation(async ({ args }: { args: string[] }) => {
|
||||
if (args[0] === "sandbox" && args[1] === "download") {
|
||||
const tmpDir = args[4];
|
||||
await fs.writeFile(path.join(tmpDir, "from-remote.txt"), "remote", "utf8");
|
||||
await fs.mkdir(path.join(tmpDir, ".openclaw", "sandbox-skills", "skills"), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, ".openclaw", "sandbox-skills", "skills", "generated.txt"),
|
||||
"generated",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
const factory = createOpenShellSandboxBackendFactory({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "openshell",
|
||||
mode: "mirror",
|
||||
}),
|
||||
});
|
||||
const backend = await factory({
|
||||
sessionKey: "agent:main:turn",
|
||||
scopeKey: "agent:main",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: createOpenShellBackendSandboxConfig(),
|
||||
});
|
||||
|
||||
try {
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
token: undefined,
|
||||
});
|
||||
|
||||
expect(renameSpy).toHaveBeenCalled();
|
||||
await expect(fs.readFile(shadowFile, "utf8")).resolves.toBe("local shadow");
|
||||
await expect(fs.readFile(path.join(workspaceDir, "from-remote.txt"), "utf8")).resolves.toBe(
|
||||
"remote",
|
||||
);
|
||||
await expectPathMissing(
|
||||
path.join(workspaceDir, ".openclaw", "sandbox-skills", "skills", "generated.txt"),
|
||||
);
|
||||
} finally {
|
||||
renameSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops non-directory materialized sandbox skills from mirror downloads", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
|
||||
cliMocks.runOpenShellCli.mockImplementation(async ({ args }: { args: string[] }) => {
|
||||
if (args[0] === "sandbox" && args[1] === "download") {
|
||||
const tmpDir = args[4];
|
||||
await fs.writeFile(path.join(tmpDir, "from-remote.txt"), "remote", "utf8");
|
||||
await fs.mkdir(path.join(tmpDir, ".openclaw"), { recursive: true });
|
||||
await fs.writeFile(path.join(tmpDir, ".openclaw", "sandbox-skills"), "poison", "utf8");
|
||||
}
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
const factory = createOpenShellSandboxBackendFactory({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "openshell",
|
||||
mode: "mirror",
|
||||
}),
|
||||
});
|
||||
const backend = await factory({
|
||||
sessionKey: "agent:main:turn",
|
||||
scopeKey: "agent:main",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: createOpenShellBackendSandboxConfig(),
|
||||
});
|
||||
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
token: undefined,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(workspaceDir, "from-remote.txt"), "utf8")).resolves.toBe(
|
||||
"remote",
|
||||
);
|
||||
await expectPathMissing(path.join(workspaceDir, ".openclaw", "sandbox-skills"));
|
||||
});
|
||||
|
||||
it("restores a local sandbox skills shadow when mirror download has a file parent", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
|
||||
const shadowFile = path.join(workspaceDir, ".openclaw", "sandbox-skills", "user-note.txt");
|
||||
await fs.mkdir(path.dirname(shadowFile), { recursive: true });
|
||||
await fs.writeFile(shadowFile, "local shadow", "utf8");
|
||||
cliMocks.runOpenShellCli.mockImplementation(async ({ args }: { args: string[] }) => {
|
||||
if (args[0] === "sandbox" && args[1] === "download") {
|
||||
const tmpDir = args[4];
|
||||
await fs.writeFile(path.join(tmpDir, "from-remote.txt"), "remote", "utf8");
|
||||
await fs.writeFile(path.join(tmpDir, ".openclaw"), "poison", "utf8");
|
||||
}
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
const factory = createOpenShellSandboxBackendFactory({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "openshell",
|
||||
mode: "mirror",
|
||||
}),
|
||||
});
|
||||
const backend = await factory({
|
||||
sessionKey: "agent:main:turn",
|
||||
scopeKey: "agent:main",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: createOpenShellBackendSandboxConfig(),
|
||||
});
|
||||
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
token: undefined,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(workspaceDir, "from-remote.txt"), "utf8")).resolves.toBe(
|
||||
"remote",
|
||||
);
|
||||
await expect(fs.readFile(shadowFile, "utf8")).resolves.toBe("local shadow");
|
||||
expect((await fs.stat(path.join(workspaceDir, ".openclaw"))).isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -517,6 +666,64 @@ describe("openshell fs bridges", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads materialized sandbox skills from the protected skills workspace", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const skillsWorkspaceDir = await makeTempDir("openclaw-openshell-skills-");
|
||||
const skillFile = path.join(skillsWorkspaceDir, "skills", "demo", "SKILL.md");
|
||||
const shadowFile = path.join(
|
||||
workspaceDir,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
"demo",
|
||||
"SKILL.md",
|
||||
);
|
||||
await fs.mkdir(path.dirname(skillFile), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shadowFile), { recursive: true });
|
||||
await fs.writeFile(skillFile, "# Demo\nmaterialized\n", "utf8");
|
||||
await fs.writeFile(shadowFile, "# Demo\nworkspace shadow\n", "utf8");
|
||||
|
||||
const backend = createMirrorBackendMock();
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
workspaceAccess: "rw",
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
|
||||
await expect(
|
||||
bridge.readFile({
|
||||
filePath: "/sandbox/.openclaw/sandbox-skills/skills/demo/SKILL.md",
|
||||
}),
|
||||
).resolves.toEqual(Buffer.from("# Demo\nmaterialized\n"));
|
||||
await expect(
|
||||
bridge.readFile({
|
||||
filePath: ".openclaw/sandbox-skills/skills/demo/SKILL.md",
|
||||
}),
|
||||
).resolves.toEqual(Buffer.from("# Demo\nmaterialized\n"));
|
||||
await expect(
|
||||
bridge.writeFile({
|
||||
filePath: ".openclaw/sandbox-skills/skills/demo/SKILL.md",
|
||||
data: "owned",
|
||||
}),
|
||||
).rejects.toThrow(/read-only/);
|
||||
await expect(
|
||||
bridge.writeFile({
|
||||
filePath: shadowFile,
|
||||
data: "owned",
|
||||
}),
|
||||
).rejects.toThrow(/read-only/);
|
||||
expect(await fs.readFile(shadowFile, "utf8")).toContain("workspace shadow");
|
||||
expect(backend["syncLocalPathToRemote"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects reads of a symlinked leaf", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const outsideDir = await makeTempDir("openclaw-openshell-outside-");
|
||||
|
||||
@@ -36,7 +36,6 @@ import type { AgentToolResult } from "./runtime/index.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
import { createEditTool, createReadTool, createWriteTool } from "./sessions/index.js";
|
||||
import { resolveReadPath } from "./sessions/tools/path-utils.js";
|
||||
import { sanitizeToolResultImages } from "./tool-images.js";
|
||||
|
||||
export {
|
||||
@@ -515,7 +514,7 @@ function mapContainerPathToRoot(params: {
|
||||
return { filePath: params.filePath, matched: false };
|
||||
}
|
||||
|
||||
const normalizedCandidate = candidate.replace(/\\/g, "/");
|
||||
const normalizedCandidate = path.posix.normalize(candidate.replace(/\\/g, "/"));
|
||||
if (normalizedCandidate === normalizedRoot) {
|
||||
return { filePath: path.resolve(params.root), matched: true };
|
||||
}
|
||||
@@ -779,28 +778,34 @@ export function wrapToolWorkspaceRootGuardWithOptions(
|
||||
normalizedRecord[key] = filePath;
|
||||
}
|
||||
let guardedRoot = root;
|
||||
const workspaceMapping = mapContainerPathToRoot({
|
||||
filePath,
|
||||
root,
|
||||
containerRoot: options?.containerWorkdir,
|
||||
});
|
||||
let sandboxPath = workspaceMapping.filePath;
|
||||
if (!workspaceMapping.matched) {
|
||||
for (const mount of options?.additionalContainerMounts ?? []) {
|
||||
const mountMapping = mapContainerPathToRoot({
|
||||
filePath,
|
||||
root: mount.hostRoot,
|
||||
containerRoot: mount.containerRoot,
|
||||
});
|
||||
if (mountMapping.matched) {
|
||||
guardedRoot = path.resolve(mount.hostRoot);
|
||||
sandboxPath = mountMapping.filePath;
|
||||
break;
|
||||
}
|
||||
let workspaceMapping: ReturnType<typeof mapContainerPathToRoot> | undefined;
|
||||
let sandboxPath = filePath;
|
||||
for (const mount of [...(options?.additionalContainerMounts ?? [])].toSorted(
|
||||
(a, b) => b.containerRoot.length - a.containerRoot.length,
|
||||
)) {
|
||||
const mountMapping = mapContainerPathToRoot({
|
||||
filePath,
|
||||
root: mount.hostRoot,
|
||||
containerRoot: mount.containerRoot,
|
||||
});
|
||||
if (mountMapping.matched) {
|
||||
guardedRoot = path.resolve(mount.hostRoot);
|
||||
sandboxPath = mountMapping.filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (guardedRoot === root) {
|
||||
workspaceMapping = mapContainerPathToRoot({
|
||||
filePath,
|
||||
root,
|
||||
containerRoot: options?.containerWorkdir,
|
||||
});
|
||||
sandboxPath = workspaceMapping.filePath;
|
||||
}
|
||||
const additionalRoots =
|
||||
guardedRoot === root && !workspaceMapping.matched ? (options?.additionalRoots ?? []) : [];
|
||||
guardedRoot === root && !workspaceMapping?.matched
|
||||
? (options?.additionalRoots ?? [])
|
||||
: [];
|
||||
let sandboxResult: Awaited<ReturnType<typeof assertSandboxPathWithinAnyRoot>>;
|
||||
try {
|
||||
sandboxResult = await assertSandboxPathWithinAnyRoot({
|
||||
@@ -905,12 +910,12 @@ export function createOpenClawReadTool(
|
||||
|
||||
function createSandboxReadOperations(params: SandboxToolParams) {
|
||||
return {
|
||||
resolvePath: (filePath: string, cwd: string) => {
|
||||
resolvePath: (filePath: string) => {
|
||||
const normalizedMediaSource = normalizeMediaReferenceSource(filePath);
|
||||
const resolvedPath = classifyMediaReferenceSource(normalizedMediaSource).isMediaStoreUrl
|
||||
? resolveMediaReferenceSandboxPath(normalizedMediaSource, "media/inbound").resolved
|
||||
: filePath;
|
||||
return resolveReadPath(resolvedPath, cwd);
|
||||
if (classifyMediaReferenceSource(normalizedMediaSource).isMediaStoreUrl) {
|
||||
return resolveMediaReferenceSandboxPath(normalizedMediaSource, "media/inbound").resolved;
|
||||
}
|
||||
return resolveContainerPathCandidate(filePath) ?? filePath;
|
||||
},
|
||||
readFile: (absolutePath: string) =>
|
||||
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
|
||||
|
||||
@@ -40,7 +40,7 @@ async function loadModule() {
|
||||
let wrapToolWorkspaceRootGuardWithOptions: typeof import("./agent-tools.read.js").wrapToolWorkspaceRootGuardWithOptions;
|
||||
|
||||
describe("wrapToolWorkspaceRootGuardWithOptions", () => {
|
||||
const root = "/tmp/root";
|
||||
const root = path.resolve("/tmp/root");
|
||||
const assertSandboxPathImpl: AssertSandboxPath = async ({ filePath }) => ({
|
||||
resolved:
|
||||
filePath.startsWith("file://") || path.isAbsolute(filePath)
|
||||
@@ -216,7 +216,7 @@ describe("wrapToolWorkspaceRootGuardWithOptions", () => {
|
||||
|
||||
it("maps additional container mounts to their own guarded host roots", async () => {
|
||||
const { tool } = createToolHarness();
|
||||
const agentRoot = "/tmp/agent-root";
|
||||
const agentRoot = path.resolve("/tmp/agent-root");
|
||||
const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, {
|
||||
additionalContainerMounts: [{ containerRoot: "/agent", hostRoot: agentRoot }],
|
||||
containerWorkdir: "/workspace",
|
||||
@@ -231,9 +231,26 @@ describe("wrapToolWorkspaceRootGuardWithOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes container paths before matching additional mounts", async () => {
|
||||
const { tool } = createToolHarness();
|
||||
const skillRoot = path.resolve("/tmp/skill-root");
|
||||
const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, {
|
||||
additionalContainerMounts: [{ containerRoot: "/workspace/skills", hostRoot: skillRoot }],
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
|
||||
await wrapped.execute("tc-skill-traverse", { path: "/workspace/skills/../README.md" });
|
||||
|
||||
expect(mocks.assertSandboxPath).toHaveBeenCalledWith({
|
||||
filePath: path.resolve(root, "README.md"),
|
||||
cwd: root,
|
||||
root,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps file URLs under additional container mounts", async () => {
|
||||
const { tool } = createToolHarness();
|
||||
const agentRoot = "/tmp/agent-root";
|
||||
const agentRoot = path.resolve("/tmp/agent-root");
|
||||
const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, {
|
||||
additionalContainerMounts: [{ containerRoot: "/agent", hostRoot: agentRoot }],
|
||||
containerWorkdir: "/workspace",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./agent-tools.read.js";
|
||||
import { createApplyPatchTool } from "./apply-patch.js";
|
||||
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js";
|
||||
import { resolveReadOnlyWorkspaceSkillMounts } from "./sandbox/workspace-mounts.js";
|
||||
import {
|
||||
expectReadWriteEditTools,
|
||||
expectReadWriteTools,
|
||||
@@ -75,12 +76,26 @@ function createSandboxFsTools(params: { sandbox: UnsafeMountedSandbox; workspace
|
||||
return tools.map((tool) =>
|
||||
wrapToolWorkspaceRootGuardWithOptions(tool, params.sandbox.workspaceDir, {
|
||||
additionalContainerMounts:
|
||||
tool.name === "read" && params.sandbox.workspaceAccess === "ro"
|
||||
tool.name === "read"
|
||||
? [
|
||||
{
|
||||
containerRoot: SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||
hostRoot: params.sandbox.agentWorkspaceDir,
|
||||
},
|
||||
...(params.sandbox.workspaceAccess === "ro"
|
||||
? [
|
||||
{
|
||||
containerRoot: SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||
hostRoot: params.sandbox.agentWorkspaceDir,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir: params.sandbox.workspaceDir,
|
||||
agentWorkspaceDir: params.sandbox.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.sandbox.skillsWorkspaceDir,
|
||||
workdir: params.sandbox.containerWorkdir,
|
||||
workspaceAccess: params.sandbox.workspaceAccess,
|
||||
}).map((mount) => ({
|
||||
containerRoot: mount.containerPath,
|
||||
hostRoot: mount.hostPath,
|
||||
})),
|
||||
]
|
||||
: undefined,
|
||||
containerWorkdir: params.sandbox.containerWorkdir,
|
||||
@@ -158,6 +173,55 @@ describe("tools.fs.workspaceOnly", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows read-only materialized sandbox skills for sandbox reads only", async () => {
|
||||
await withUnsafeMountedSandboxHarness(
|
||||
async ({ sandbox, skillsWorkspaceDir }) => {
|
||||
expect(skillsWorkspaceDir).toBeTruthy();
|
||||
const skillDir = path.join(skillsWorkspaceDir!, "skills", "demo");
|
||||
const userOwnedShadowDir = path.join(
|
||||
sandbox.workspaceDir,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
"demo",
|
||||
);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.mkdir(userOwnedShadowDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Demo\nmaterialized\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(userOwnedShadowDir, "SKILL.md"),
|
||||
"# Demo\nuser-owned shadow\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const tools = createSandboxFsTools({ sandbox, workspaceOnly: true });
|
||||
const { readTool } = expectReadWriteEditTools(tools);
|
||||
const containerSkillPath = "/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md";
|
||||
|
||||
const readResult = await readTool?.execute("t1", { path: containerSkillPath });
|
||||
expect(getTextContent(readResult)).toContain("materialized");
|
||||
expect(getTextContent(readResult)).not.toContain("user-owned shadow");
|
||||
const relativeReadResult = await readTool?.execute("t2", {
|
||||
path: ".openclaw/sandbox-skills/skills/demo/SKILL.md",
|
||||
});
|
||||
expect(getTextContent(relativeReadResult)).toContain("materialized");
|
||||
expect(getTextContent(relativeReadResult)).not.toContain("user-owned shadow");
|
||||
const fileUrlReadResult = await readTool?.execute("t3", {
|
||||
path: "file:///workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md",
|
||||
});
|
||||
expect(getTextContent(fileUrlReadResult)).toContain("materialized");
|
||||
expect(getTextContent(fileUrlReadResult)).not.toContain("user-owned shadow");
|
||||
expect(await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8")).toContain(
|
||||
"materialized",
|
||||
);
|
||||
expect(await fs.readFile(path.join(userOwnedShadowDir, "SKILL.md"), "utf8")).toContain(
|
||||
"user-owned shadow",
|
||||
);
|
||||
},
|
||||
{ includeSkillsWorkspace: true, workspaceAccess: "rw" },
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces apply_patch workspace-only in sandbox mounts by default", async () => {
|
||||
await withUnsafeMountedSandboxHarness(async ({ agentRoot, sandbox }) => {
|
||||
const applyPatchTool = resolveApplyPatchTool({
|
||||
|
||||
+24
-11
@@ -77,6 +77,7 @@ import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import type { SandboxContext } from "./sandbox.js";
|
||||
import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js";
|
||||
import { resolveReadOnlyWorkspaceSkillMounts } from "./sandbox/workspace-mounts.js";
|
||||
import { resolveSenderToolPolicy } from "./sender-tool-policy.js";
|
||||
import { createCodingTools, createReadTool } from "./sessions/index.js";
|
||||
import {
|
||||
@@ -122,22 +123,34 @@ type GuardContainerMount = {
|
||||
hostRoot: string;
|
||||
};
|
||||
|
||||
function readOnlyAgentWorkspaceMount(
|
||||
function readOnlySandboxReadMounts(
|
||||
sandbox: SandboxContext | null | undefined,
|
||||
): GuardContainerMount[] | undefined {
|
||||
if (
|
||||
!sandbox ||
|
||||
sandbox.workspaceAccess !== "ro" ||
|
||||
sandbox.agentWorkspaceDir === sandbox.workspaceDir
|
||||
) {
|
||||
if (!sandbox) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
{
|
||||
const mounts: GuardContainerMount[] = [];
|
||||
if (sandbox.workspaceAccess === "ro" && sandbox.agentWorkspaceDir !== sandbox.workspaceDir) {
|
||||
mounts.push({
|
||||
containerRoot: SANDBOX_AGENT_WORKSPACE_MOUNT,
|
||||
hostRoot: sandbox.agentWorkspaceDir,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
if (sandbox.workspaceAccess === "rw") {
|
||||
mounts.push(
|
||||
...resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
agentWorkspaceDir: sandbox.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: sandbox.skillsWorkspaceDir,
|
||||
workdir: sandbox.containerWorkdir,
|
||||
workspaceAccess: sandbox.workspaceAccess,
|
||||
}).map((mount) => ({
|
||||
containerRoot: mount.containerPath,
|
||||
hostRoot: mount.hostPath,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return mounts.length > 0 ? mounts : undefined;
|
||||
}
|
||||
|
||||
function resolveSkillReadRoots(skillsSnapshot?: SkillSnapshot): string[] | undefined {
|
||||
@@ -743,7 +756,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
base.push(
|
||||
workspaceOnly
|
||||
? wrapToolWorkspaceRootGuardWithOptions(sandboxed, sandboxRoot, {
|
||||
additionalContainerMounts: readOnlyAgentWorkspaceMount(sandbox),
|
||||
additionalContainerMounts: readOnlySandboxReadMounts(sandbox),
|
||||
containerWorkdir: sandbox.containerWorkdir,
|
||||
})
|
||||
: sandboxed,
|
||||
|
||||
@@ -49,6 +49,10 @@ import {
|
||||
} from "../agent-hooks/compaction-safeguard-runtime.js";
|
||||
import { createPreparedEmbeddedAgentSettingsManager } from "../agent-project-settings.js";
|
||||
import { isDefaultAgentRuntimeId } from "../agent-runtime-id.js";
|
||||
import {
|
||||
mapSandboxSkillEntriesForPrompt,
|
||||
resolveSandboxSkillRuntimeInputs,
|
||||
} from "./sandbox-skills.js";
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveRunModelFallbacksOverride,
|
||||
@@ -685,13 +689,24 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null;
|
||||
let checkpointSnapshotRetained = false;
|
||||
try {
|
||||
const skillsSnapshotForRun =
|
||||
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? undefined : params.skillsSnapshot;
|
||||
const {
|
||||
skillsEligibility,
|
||||
skillsPromptWorkspaceDir: effectiveSkillsPromptWorkspace,
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
skillsWorkspaceDir: effectiveSkillsWorkspace,
|
||||
workspaceOnly: loadSkillsWorkspaceOnly,
|
||||
} = resolveSandboxSkillRuntimeInputs({
|
||||
sandbox,
|
||||
effectiveWorkspace,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
});
|
||||
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
workspaceDir: effectiveSkillsWorkspace,
|
||||
config: params.config,
|
||||
agentId: effectiveSkillAgentId,
|
||||
eligibility: skillsEligibility,
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
workspaceOnly: loadSkillsWorkspaceOnly,
|
||||
});
|
||||
restoreSkillEnv = skillsSnapshotForRun
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
@@ -702,12 +717,18 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
const promptSkillEntries = mapSandboxSkillEntriesForPrompt({
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
skillsWorkspaceDir: effectiveSkillsWorkspace,
|
||||
skillsPromptWorkspaceDir: effectiveSkillsPromptWorkspace,
|
||||
});
|
||||
const skillsPrompt = resolveSkillsPromptForRun({
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
entries: promptSkillEntries,
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
workspaceDir: effectiveSkillsPromptWorkspace,
|
||||
agentId: effectiveSkillAgentId,
|
||||
eligibility: skillsEligibility,
|
||||
});
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
|
||||
@@ -176,6 +176,10 @@ import {
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import { resolveSandboxContext } from "../../sandbox.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||
import {
|
||||
mapSandboxSkillEntriesForPrompt,
|
||||
resolveSandboxSkillRuntimeInputs,
|
||||
} from "../sandbox-skills.js";
|
||||
import { repairSessionFileIfNeeded } from "../../session-file-repair.js";
|
||||
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
||||
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
|
||||
@@ -1034,13 +1038,24 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
};
|
||||
try {
|
||||
const skillsSnapshotForRun =
|
||||
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? undefined : params.skillsSnapshot;
|
||||
const {
|
||||
skillsEligibility,
|
||||
skillsPromptWorkspaceDir: effectiveSkillsPromptWorkspace,
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
skillsWorkspaceDir: effectiveSkillsWorkspace,
|
||||
workspaceOnly: loadSkillsWorkspaceOnly,
|
||||
} = resolveSandboxSkillRuntimeInputs({
|
||||
sandbox,
|
||||
effectiveWorkspace,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
});
|
||||
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
workspaceDir: effectiveSkillsWorkspace,
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
eligibility: skillsEligibility,
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
workspaceOnly: loadSkillsWorkspaceOnly,
|
||||
});
|
||||
restoreSkillEnv = skillsSnapshotForRun
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
@@ -1048,16 +1063,22 @@ export async function runEmbeddedAttempt(
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
const promptSkillEntries = mapSandboxSkillEntriesForPrompt({
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
skillsWorkspaceDir: effectiveSkillsWorkspace,
|
||||
skillsPromptWorkspaceDir: effectiveSkillsPromptWorkspace,
|
||||
});
|
||||
|
||||
const skillsPrompt = resolveSkillsPromptForRun({
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
entries: promptSkillEntries,
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
workspaceDir: effectiveSkillsPromptWorkspace,
|
||||
agentId: sessionAgentId,
|
||||
eligibility: skillsEligibility,
|
||||
});
|
||||
prepStages.mark("skills");
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Sandbox skill input tests cover snapshot suppression and synced skill workspace selection.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSyntheticSourceInfo } from "../../skills/loading/skill-contract.js";
|
||||
import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
|
||||
import type { SkillSnapshot } from "../../skills/types.js";
|
||||
import {
|
||||
mapSandboxSkillEntriesForPrompt,
|
||||
resolveSandboxSkillRuntimeInputs,
|
||||
} from "./sandbox-skills.js";
|
||||
|
||||
const hostSkillPath = "/usr/lib/node_modules/openclaw/skills/demo/SKILL.md";
|
||||
const hostSkillBaseDir = "/usr/lib/node_modules/openclaw/skills/demo";
|
||||
const snapshot: SkillSnapshot = {
|
||||
prompt:
|
||||
"<available_skills><skill><location>/usr/lib/node_modules/openclaw/skills/demo/SKILL.md</location></skill></available_skills>",
|
||||
skills: [{ name: "demo" }],
|
||||
resolvedSkills: [
|
||||
{
|
||||
name: "demo",
|
||||
description: "Demo skill",
|
||||
filePath: hostSkillPath,
|
||||
baseDir: hostSkillBaseDir,
|
||||
source: "openclaw-bundled",
|
||||
sourceInfo: createSyntheticSourceInfo(hostSkillPath, {
|
||||
source: "openclaw-bundled",
|
||||
baseDir: hostSkillBaseDir,
|
||||
}),
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("resolveSandboxSkillRuntimeInputs", () => {
|
||||
it("keeps snapshots for non-sandboxed runs", () => {
|
||||
expect(
|
||||
resolveSandboxSkillRuntimeInputs({
|
||||
effectiveWorkspace: "/workspace",
|
||||
skillsSnapshot: snapshot,
|
||||
}),
|
||||
).toEqual({
|
||||
skillsSnapshot: snapshot,
|
||||
skillsPromptWorkspaceDir: "/workspace",
|
||||
skillsWorkspaceDir: "/workspace",
|
||||
workspaceOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the materialized skills workspace and drops host-path snapshots for sandboxes", () => {
|
||||
const skillsEligibility = {
|
||||
remote: {
|
||||
platforms: ["linux"],
|
||||
hasBin: () => true,
|
||||
hasAnyBin: () => true,
|
||||
note: "sandbox",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveSandboxSkillRuntimeInputs({
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
containerWorkdir: "/workspace",
|
||||
skillsEligibility,
|
||||
skillsWorkspaceDir: "/state/sandbox-skills",
|
||||
workspaceAccess: "rw",
|
||||
},
|
||||
effectiveWorkspace: "/workspace",
|
||||
skillsSnapshot: snapshot,
|
||||
}),
|
||||
).toEqual({
|
||||
skillsEligibility,
|
||||
skillsSnapshot: undefined,
|
||||
skillsPromptWorkspaceDir: "/workspace/.openclaw/sandbox-skills",
|
||||
skillsWorkspaceDir: "/state/sandbox-skills",
|
||||
workspaceOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the effective workspace for older sandbox contexts", () => {
|
||||
expect(
|
||||
resolveSandboxSkillRuntimeInputs({
|
||||
sandbox: { enabled: true },
|
||||
effectiveWorkspace: "/workspace",
|
||||
skillsSnapshot: snapshot,
|
||||
}),
|
||||
).toEqual({
|
||||
skillsSnapshot: undefined,
|
||||
skillsPromptWorkspaceDir: "/workspace",
|
||||
skillsWorkspaceDir: "/workspace",
|
||||
workspaceOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds sandbox prompts from materialized skill paths", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-"));
|
||||
try {
|
||||
const effectiveWorkspace = path.join(root, "workspace");
|
||||
const materializedWorkspace = path.join(root, "state", "sandbox-skills");
|
||||
const skillDir = path.join(materializedWorkspace, "skills", "demo");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: demo",
|
||||
"description: Demo skill",
|
||||
'openclaw: {"requires":{"anyBins":["sandboxbin"]}}',
|
||||
"---",
|
||||
"# Demo",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const skillsEligibility = {
|
||||
remote: {
|
||||
platforms: ["linux"],
|
||||
hasBin: () => false,
|
||||
hasAnyBin: (bins: string[]) => bins.includes("sandboxbin"),
|
||||
note: "sandbox",
|
||||
},
|
||||
};
|
||||
|
||||
const {
|
||||
skillsEligibility: skillsEligibilityForRun,
|
||||
skillsPromptWorkspaceDir,
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
skillsWorkspaceDir,
|
||||
workspaceOnly,
|
||||
} = resolveSandboxSkillRuntimeInputs({
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
containerWorkdir: "/workspace",
|
||||
skillsEligibility,
|
||||
skillsWorkspaceDir: materializedWorkspace,
|
||||
workspaceAccess: "rw",
|
||||
},
|
||||
effectiveWorkspace,
|
||||
skillsSnapshot: snapshot,
|
||||
});
|
||||
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: skillsWorkspaceDir,
|
||||
eligibility: skillsEligibilityForRun,
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
workspaceOnly,
|
||||
});
|
||||
const promptSkillEntries = mapSandboxSkillEntriesForPrompt({
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
skillsWorkspaceDir,
|
||||
skillsPromptWorkspaceDir,
|
||||
});
|
||||
const prompt = resolveSkillsPromptForRun({
|
||||
skillsSnapshot: skillsSnapshotForRun,
|
||||
entries: promptSkillEntries,
|
||||
workspaceDir: skillsPromptWorkspaceDir,
|
||||
eligibility: skillsEligibilityForRun,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md");
|
||||
expect(prompt.replaceAll("\\", "/")).not.toContain(materializedWorkspace.replaceAll("\\", "/"));
|
||||
expect(prompt).not.toContain(hostSkillPath);
|
||||
expect(prompt).not.toContain("plugin-skills");
|
||||
expect(prompt.replaceAll("\\", "/")).not.toContain("/skills/canvas/SKILL.md");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves remote eligibility when rebuilding sandbox prompts", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-"));
|
||||
try {
|
||||
const skillDir = path.join(root, "skills", "macskill");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: macskill",
|
||||
"description: Mac-only remote skill",
|
||||
'openclaw: {"os":["darwin"]}',
|
||||
"---",
|
||||
"# Mac Skill",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const skillsEligibility = {
|
||||
remote: {
|
||||
platforms: ["darwin"],
|
||||
hasBin: () => false,
|
||||
hasAnyBin: () => false,
|
||||
note: "remote mac available",
|
||||
},
|
||||
};
|
||||
|
||||
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: root,
|
||||
eligibility: skillsEligibility,
|
||||
workspaceOnly: true,
|
||||
});
|
||||
const prompt = resolveSkillsPromptForRun({
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
workspaceDir: root,
|
||||
eligibility: skillsEligibility,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("remote mac available");
|
||||
expect(prompt).toContain("macskill");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Sandbox skill runtime input selection.
|
||||
*
|
||||
* Sandboxed runs must build prompt-facing skill entries from readable in-sandbox
|
||||
* copies instead of reusing host-path snapshots.
|
||||
*/
|
||||
import path from "node:path";
|
||||
import type { SkillEligibilityContext, SkillSnapshot } from "../../skills/types.js";
|
||||
import type { SkillEntry } from "../../skills/types.js";
|
||||
import type { SandboxContext } from "../sandbox/types.js";
|
||||
|
||||
const MATERIALIZED_SKILLS_WORKSPACE_CONTAINER_PARTS = [".openclaw", "sandbox-skills"] as const;
|
||||
type SandboxSkillRuntimeContext = Pick<SandboxContext, "enabled"> &
|
||||
Partial<
|
||||
Pick<
|
||||
SandboxContext,
|
||||
"skillsEligibility" | "skillsWorkspaceDir" | "containerWorkdir" | "workspaceAccess"
|
||||
>
|
||||
>;
|
||||
|
||||
function containerJoin(root: string, ...parts: string[]): string {
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
||||
const suffix = parts
|
||||
.map((part) => part.replace(/^\/+|\/+$/g, ""))
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
return suffix ? `${normalizedRoot}/${suffix}` : normalizedRoot;
|
||||
}
|
||||
|
||||
function pathEscapesRoot(relativePath: string): boolean {
|
||||
return (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function mapPathFromWorkspaceToContainer(params: {
|
||||
filePath: string | undefined;
|
||||
sourceWorkspaceDir: string;
|
||||
targetWorkspaceDir: string;
|
||||
}): string | undefined {
|
||||
if (!params.filePath || !path.isAbsolute(params.filePath)) {
|
||||
return params.filePath;
|
||||
}
|
||||
const relativePath = path.relative(
|
||||
path.resolve(params.sourceWorkspaceDir),
|
||||
path.resolve(params.filePath),
|
||||
);
|
||||
if (pathEscapesRoot(relativePath)) {
|
||||
return params.filePath;
|
||||
}
|
||||
if (!relativePath) {
|
||||
return params.targetWorkspaceDir.replace(/\\/g, "/");
|
||||
}
|
||||
return containerJoin(
|
||||
params.targetWorkspaceDir,
|
||||
...relativePath.split(path.sep).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function mapSandboxSkillEntriesForPrompt(params: {
|
||||
entries?: SkillEntry[];
|
||||
skillsWorkspaceDir: string;
|
||||
skillsPromptWorkspaceDir: string;
|
||||
}): SkillEntry[] | undefined {
|
||||
if (!params.entries || params.skillsWorkspaceDir === params.skillsPromptWorkspaceDir) {
|
||||
return params.entries;
|
||||
}
|
||||
return params.entries.map((entry) => {
|
||||
const filePath =
|
||||
mapPathFromWorkspaceToContainer({
|
||||
filePath: entry.skill.filePath,
|
||||
sourceWorkspaceDir: params.skillsWorkspaceDir,
|
||||
targetWorkspaceDir: params.skillsPromptWorkspaceDir,
|
||||
}) ?? entry.skill.filePath;
|
||||
const baseDir =
|
||||
mapPathFromWorkspaceToContainer({
|
||||
filePath: entry.skill.baseDir,
|
||||
sourceWorkspaceDir: params.skillsWorkspaceDir,
|
||||
targetWorkspaceDir: params.skillsPromptWorkspaceDir,
|
||||
}) ?? entry.skill.baseDir;
|
||||
const sourceInfoPath =
|
||||
mapPathFromWorkspaceToContainer({
|
||||
filePath: entry.skill.sourceInfo.path,
|
||||
sourceWorkspaceDir: params.skillsWorkspaceDir,
|
||||
targetWorkspaceDir: params.skillsPromptWorkspaceDir,
|
||||
}) ?? entry.skill.sourceInfo.path;
|
||||
const sourceInfoBaseDir = mapPathFromWorkspaceToContainer({
|
||||
filePath: entry.skill.sourceInfo.baseDir,
|
||||
sourceWorkspaceDir: params.skillsWorkspaceDir,
|
||||
targetWorkspaceDir: params.skillsPromptWorkspaceDir,
|
||||
});
|
||||
return {
|
||||
...entry,
|
||||
skill: {
|
||||
...entry.skill,
|
||||
filePath,
|
||||
baseDir,
|
||||
sourceInfo: {
|
||||
...entry.skill.sourceInfo,
|
||||
path: sourceInfoPath,
|
||||
...(sourceInfoBaseDir === undefined ? {} : { baseDir: sourceInfoBaseDir }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSandboxSkillRuntimeInputs(params: {
|
||||
sandbox?: SandboxSkillRuntimeContext | null;
|
||||
effectiveWorkspace: string;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
}): {
|
||||
skillsEligibility?: SkillEligibilityContext;
|
||||
skillsPromptWorkspaceDir: string;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
skillsWorkspaceDir: string;
|
||||
workspaceOnly: boolean;
|
||||
} {
|
||||
if (params.sandbox?.enabled === true) {
|
||||
const skillsWorkspaceDir = params.sandbox.skillsWorkspaceDir ?? params.effectiveWorkspace;
|
||||
const skillsPromptWorkspaceDir =
|
||||
params.sandbox.workspaceAccess === "rw" &&
|
||||
params.sandbox.skillsWorkspaceDir &&
|
||||
params.sandbox.containerWorkdir
|
||||
? containerJoin(
|
||||
params.sandbox.containerWorkdir,
|
||||
...MATERIALIZED_SKILLS_WORKSPACE_CONTAINER_PARTS,
|
||||
)
|
||||
: (params.sandbox.containerWorkdir ?? skillsWorkspaceDir);
|
||||
return {
|
||||
...(params.sandbox.skillsEligibility
|
||||
? { skillsEligibility: params.sandbox.skillsEligibility }
|
||||
: {}),
|
||||
skillsPromptWorkspaceDir,
|
||||
skillsSnapshot: undefined,
|
||||
skillsWorkspaceDir,
|
||||
workspaceOnly: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
skillsPromptWorkspaceDir: params.effectiveWorkspace,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
skillsWorkspaceDir: params.effectiveWorkspace,
|
||||
workspaceOnly: false,
|
||||
};
|
||||
}
|
||||
@@ -347,4 +347,127 @@ describe("resolveSandboxContext", () => {
|
||||
expect(syncOptions?.agentId).toBe("main");
|
||||
expect(syncOptions?.eligibility).toEqual({ remote: { note: "test-remote" } });
|
||||
}, 15_000);
|
||||
|
||||
it("materializes skills into a hidden read-only workspace for writable sandboxes", async () => {
|
||||
syncSkillsToWorkspaceMock.mockClear();
|
||||
const workspaceDir = await createSandboxFixtureDir("workspace");
|
||||
const userOwnedSandboxSkillsDir = path.join(
|
||||
workspaceDir,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
"user-owned",
|
||||
);
|
||||
await fs.mkdir(userOwnedSandboxSkillsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(userOwnedSandboxSkillsDir, "SKILL.md"), "# User owned\n");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
scope: "session",
|
||||
workspaceAccess: "rw",
|
||||
workspaceRoot: path.join(workspaceDir, ".openclaw", "sandboxes"),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(result?.workspaceDir).toBe(workspaceDir);
|
||||
const syncCalls = syncSkillsToWorkspaceMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
sourceWorkspaceDir?: string;
|
||||
targetWorkspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
eligibility?: unknown;
|
||||
},
|
||||
]
|
||||
>;
|
||||
const [syncOptions] = syncCalls[0] ?? [];
|
||||
expect(syncOptions?.sourceWorkspaceDir).toBe(workspaceDir);
|
||||
expect(syncOptions?.targetWorkspaceDir).toContain(
|
||||
path.join(".openclaw", "sandbox", "skills-workspaces"),
|
||||
);
|
||||
expect(syncOptions?.targetWorkspaceDir).toMatch(
|
||||
/[\\/]agent-main-main-[a-f0-9]{8}[\\/]\.openclaw[\\/]sandbox-skills$/,
|
||||
);
|
||||
expect(syncOptions?.targetWorkspaceDir).not.toBe(
|
||||
path.join(workspaceDir, ".openclaw", "sandbox-skills"),
|
||||
);
|
||||
expect(syncOptions?.targetWorkspaceDir?.startsWith(path.join(workspaceDir, ".openclaw"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(syncOptions?.config).toBe(cfg);
|
||||
expect(syncOptions?.agentId).toBe("main");
|
||||
expect(syncOptions?.eligibility).toEqual({ remote: { note: "test-remote" } });
|
||||
await expect(
|
||||
fs.readFile(path.join(userOwnedSandboxSkillsDir, "SKILL.md"), "utf8"),
|
||||
).resolves.toBe("# User owned\n");
|
||||
}, 15_000);
|
||||
|
||||
it("materializes skills for shared writable sandboxes even when roots match", async () => {
|
||||
syncSkillsToWorkspaceMock.mockClear();
|
||||
const workspaceDir = await createSandboxFixtureDir("shared-workspace");
|
||||
const userOwnedSandboxSkillsDir = path.join(
|
||||
workspaceDir,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
"user-owned",
|
||||
);
|
||||
await fs.mkdir(userOwnedSandboxSkillsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(userOwnedSandboxSkillsDir, "SKILL.md"), "# User owned\n");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
scope: "shared",
|
||||
workspaceAccess: "rw",
|
||||
workspaceRoot: workspaceDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(result?.workspaceDir).toBe(workspaceDir);
|
||||
const syncCalls = syncSkillsToWorkspaceMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
sourceWorkspaceDir?: string;
|
||||
targetWorkspaceDir?: string;
|
||||
},
|
||||
]
|
||||
>;
|
||||
const [syncOptions] = syncCalls[0] ?? [];
|
||||
expect(syncOptions?.sourceWorkspaceDir).toBe(workspaceDir);
|
||||
expect(syncOptions?.targetWorkspaceDir).toContain(
|
||||
path.join(".openclaw", "sandbox", "skills-workspaces"),
|
||||
);
|
||||
expect(syncOptions?.targetWorkspaceDir).toMatch(
|
||||
/[\\/]shared-[a-f0-9]{8}[\\/]\.openclaw[\\/]sandbox-skills$/,
|
||||
);
|
||||
expect(syncOptions?.targetWorkspaceDir).not.toBe(
|
||||
path.join(workspaceDir, ".openclaw", "sandbox-skills"),
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(path.join(userOwnedSandboxSkillsDir, "SKILL.md"), "utf8"),
|
||||
).resolves.toBe("# User owned\n");
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ export type SandboxBackendCommandResult = {
|
||||
export type SandboxFsBridgeContext = {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workspaceAccess: "none" | "ro" | "rw";
|
||||
containerName: string;
|
||||
containerWorkdir: string;
|
||||
|
||||
@@ -35,6 +35,7 @@ export type CreateSandboxBackendParams = {
|
||||
scopeKey: string;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
cfg: SandboxConfig;
|
||||
};
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
scopeKey: string;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
cfg: SandboxConfig;
|
||||
evaluateEnabled?: boolean;
|
||||
bridgeAuth?: { token?: string; password?: string };
|
||||
@@ -243,6 +244,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
});
|
||||
@@ -354,6 +356,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
args,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Prepares workspace layout, backend handle, filesystem bridge, browser bridge, and registry state for one run.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
ensureBrowserControlAuth,
|
||||
@@ -14,18 +15,59 @@ import {
|
||||
resolveBrowserConfig,
|
||||
} from "../../plugin-sdk/browser-profiles.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { SkillEligibilityContext } from "../../skills/types.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
||||
import { requireSandboxBackendFactory } from "./backend.js";
|
||||
import { ensureSandboxBrowser } from "./browser.js";
|
||||
import { resolveSandboxConfigForAgent } from "./config.js";
|
||||
import { SANDBOX_STATE_DIR } from "./constants.js";
|
||||
import { createSandboxFsBridge } from "./fs-bridge.js";
|
||||
import { updateRegistry } from "./registry.js";
|
||||
import { resolveSandboxRuntimeStatus } from "./runtime-status.js";
|
||||
import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js";
|
||||
import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js";
|
||||
import { resolveMaterializedSandboxSkillsWorkspaceDir } from "./workspace-mounts.js";
|
||||
import { ensureSandboxWorkspace } from "./workspace.js";
|
||||
|
||||
async function syncSandboxSkillsToWorkspace(params: {
|
||||
sourceWorkspaceDir: string;
|
||||
targetWorkspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
agentId: string;
|
||||
rawSessionKey: string;
|
||||
}): Promise<SkillEligibilityContext | undefined> {
|
||||
try {
|
||||
const [{ syncSkillsToWorkspace }, { getRemoteSkillEligibility }, { canExecRequestNode }] =
|
||||
await Promise.all([
|
||||
import("../../skills/loading/workspace.js"),
|
||||
import("../../skills/runtime/remote.js"),
|
||||
import("../exec-defaults.js"),
|
||||
]);
|
||||
const eligibility: SkillEligibilityContext = {
|
||||
remote: getRemoteSkillEligibility({
|
||||
advertiseExecNode: canExecRequestNode({
|
||||
cfg: params.config,
|
||||
sessionKey: params.rawSessionKey,
|
||||
agentId: params.agentId,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
await syncSkillsToWorkspace({
|
||||
sourceWorkspaceDir: params.sourceWorkspaceDir,
|
||||
targetWorkspaceDir: params.targetWorkspaceDir,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
eligibility,
|
||||
});
|
||||
return eligibility;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSandboxWorkspaceLayout(params: {
|
||||
cfg: ReturnType<typeof resolveSandboxConfigForAgent>;
|
||||
agentId: string;
|
||||
@@ -36,6 +78,8 @@ async function ensureSandboxWorkspaceLayout(params: {
|
||||
agentWorkspaceDir: string;
|
||||
scopeKey: string;
|
||||
sandboxWorkspaceDir: string;
|
||||
skillsWorkspaceDir: string;
|
||||
skillsEligibility?: SkillEligibilityContext;
|
||||
workspaceDir: string;
|
||||
}> {
|
||||
const { cfg, rawSessionKey } = params;
|
||||
@@ -48,47 +92,49 @@ async function ensureSandboxWorkspaceLayout(params: {
|
||||
const sandboxWorkspaceDir =
|
||||
cfg.scope === "shared" ? workspaceRoot : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
|
||||
const workspaceDir = cfg.workspaceAccess === "rw" ? agentWorkspaceDir : sandboxWorkspaceDir;
|
||||
const materializedSkillsRoot = resolveSandboxWorkspaceDir(
|
||||
path.join(SANDBOX_STATE_DIR, "skills-workspaces"),
|
||||
scopeKey,
|
||||
);
|
||||
const skillsWorkspaceDir =
|
||||
cfg.workspaceAccess === "rw"
|
||||
? resolveMaterializedSandboxSkillsWorkspaceDir(materializedSkillsRoot)
|
||||
: sandboxWorkspaceDir;
|
||||
|
||||
if (workspaceDir === sandboxWorkspaceDir) {
|
||||
let skillsEligibility: SkillEligibilityContext | undefined;
|
||||
if (cfg.workspaceAccess !== "rw") {
|
||||
await ensureSandboxWorkspace(
|
||||
sandboxWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
params.config?.agents?.defaults?.skipBootstrap,
|
||||
params.config?.agents?.defaults?.skipOptionalBootstrapFiles,
|
||||
);
|
||||
if (cfg.workspaceAccess !== "rw") {
|
||||
try {
|
||||
const [{ syncSkillsToWorkspace }, { getRemoteSkillEligibility }, { canExecRequestNode }] =
|
||||
await Promise.all([
|
||||
import("../../skills/loading/workspace.js"),
|
||||
import("../../skills/runtime/remote.js"),
|
||||
import("../exec-defaults.js"),
|
||||
]);
|
||||
await syncSkillsToWorkspace({
|
||||
sourceWorkspaceDir: agentWorkspaceDir,
|
||||
targetWorkspaceDir: sandboxWorkspaceDir,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
eligibility: {
|
||||
remote: getRemoteSkillEligibility({
|
||||
advertiseExecNode: canExecRequestNode({
|
||||
cfg: params.config,
|
||||
sessionKey: rawSessionKey,
|
||||
agentId: params.agentId,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
|
||||
}
|
||||
}
|
||||
skillsEligibility = await syncSandboxSkillsToWorkspace({
|
||||
sourceWorkspaceDir: agentWorkspaceDir,
|
||||
targetWorkspaceDir: sandboxWorkspaceDir,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
rawSessionKey,
|
||||
});
|
||||
} else {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
skillsEligibility = await syncSandboxSkillsToWorkspace({
|
||||
sourceWorkspaceDir: agentWorkspaceDir,
|
||||
targetWorkspaceDir: skillsWorkspaceDir,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
rawSessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
return { agentWorkspaceDir, scopeKey, sandboxWorkspaceDir, workspaceDir };
|
||||
return {
|
||||
agentWorkspaceDir,
|
||||
scopeKey,
|
||||
sandboxWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
...(skillsEligibility ? { skillsEligibility } : {}),
|
||||
workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSandboxDockerUser(params: {
|
||||
@@ -147,13 +193,14 @@ export async function resolveSandboxContext(params: {
|
||||
await (await import("./prune.js")).maybePruneSandboxes(cfg);
|
||||
}
|
||||
|
||||
const { agentWorkspaceDir, scopeKey, workspaceDir } = await ensureSandboxWorkspaceLayout({
|
||||
cfg,
|
||||
agentId: runtime.agentId,
|
||||
rawSessionKey,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const { agentWorkspaceDir, scopeKey, skillsEligibility, skillsWorkspaceDir, workspaceDir } =
|
||||
await ensureSandboxWorkspaceLayout({
|
||||
cfg,
|
||||
agentId: runtime.agentId,
|
||||
rawSessionKey,
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
|
||||
const docker = await resolveSandboxDockerUser({
|
||||
docker: cfg.docker,
|
||||
@@ -167,6 +214,7 @@ export async function resolveSandboxContext(params: {
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
cfg: resolvedCfg,
|
||||
});
|
||||
await updateRegistry({
|
||||
@@ -214,6 +262,7 @@ export async function resolveSandboxContext(params: {
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
cfg: resolvedCfg,
|
||||
evaluateEnabled,
|
||||
bridgeAuth,
|
||||
@@ -227,6 +276,8 @@ export async function resolveSandboxContext(params: {
|
||||
sessionKey: rawSessionKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
...(skillsEligibility ? { skillsEligibility } : {}),
|
||||
workspaceAccess: resolvedCfg.workspaceAccess,
|
||||
runtimeId: backend.runtimeId,
|
||||
runtimeLabel: backend.runtimeLabel,
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function createDockerSandboxBackend(
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
return createDockerSandboxBackendHandle({
|
||||
|
||||
@@ -564,6 +564,7 @@ async function createSandboxContainer(params: {
|
||||
workspaceDir: string;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
scopeKey: string;
|
||||
configHash?: string;
|
||||
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
|
||||
@@ -584,6 +585,7 @@ async function createSandboxContainer(params: {
|
||||
args,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: cfg.workdir,
|
||||
workspaceAccess: params.workspaceAccess,
|
||||
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts,
|
||||
@@ -623,6 +625,7 @@ export async function ensureSandboxContainer(params: {
|
||||
sessionKey: string;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
cfg: SandboxConfig;
|
||||
}) {
|
||||
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
|
||||
@@ -632,6 +635,7 @@ export async function ensureSandboxContainer(params: {
|
||||
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir: params.cfg.docker.workdir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
});
|
||||
@@ -689,6 +693,7 @@ export async function ensureSandboxContainer(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
workspaceAccess: params.cfg.workspaceAccess,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
scopeKey,
|
||||
configHash: expectedHash,
|
||||
readOnlyWorkspaceSkillMounts,
|
||||
|
||||
@@ -91,6 +91,7 @@ export function buildSandboxFsMounts(sandbox: SandboxFsBridgeContext): SandboxFs
|
||||
for (const mount of resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir: sandbox.workspaceDir,
|
||||
agentWorkspaceDir: sandbox.agentWorkspaceDir,
|
||||
skillsWorkspaceDir: sandbox.skillsWorkspaceDir,
|
||||
workdir: sandbox.containerWorkdir,
|
||||
workspaceAccess: sandbox.workspaceAccess,
|
||||
})) {
|
||||
@@ -183,23 +184,31 @@ export function resolveSandboxFsPathWithMounts(params: {
|
||||
if (path.posix.isAbsolute(inputPosix)) {
|
||||
const containerMount = findMountByContainerPath(mountsByContainer, inputPosix);
|
||||
if (containerMount) {
|
||||
const rel = path.posix.relative(containerMount.containerRoot, inputPosix);
|
||||
const hostPath = rel
|
||||
? path.resolve(containerMount.hostRoot, ...toHostSegments(rel))
|
||||
: containerMount.hostRoot;
|
||||
return {
|
||||
hostPath,
|
||||
containerPath: rel
|
||||
? path.posix.join(containerMount.containerRoot, rel)
|
||||
: containerMount.containerRoot,
|
||||
relativePath: toDisplayRelative({
|
||||
containerPath: rel
|
||||
? path.posix.join(containerMount.containerRoot, rel)
|
||||
: containerMount.containerRoot,
|
||||
defaultContainerRoot: params.defaultContainerRoot,
|
||||
}),
|
||||
writable: containerMount.writable,
|
||||
};
|
||||
return resolveMountedContainerPath({
|
||||
mount: containerMount,
|
||||
containerPath: inputPosix,
|
||||
defaultContainerRoot: params.defaultContainerRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.posix.isAbsolute(inputPosix)) {
|
||||
const protectedContainerCandidate = resolveRelativeContainerCandidate({
|
||||
inputPosix,
|
||||
cwd: params.cwd,
|
||||
defaultContainerRoot: params.defaultContainerRoot,
|
||||
mountsByHost,
|
||||
});
|
||||
const protectedContainerMount = findMountByContainerPath(
|
||||
mountsByContainer,
|
||||
protectedContainerCandidate,
|
||||
);
|
||||
if (protectedContainerMount?.source === "protectedSkill") {
|
||||
return resolveMountedContainerPath({
|
||||
mount: protectedContainerMount,
|
||||
containerPath: protectedContainerCandidate,
|
||||
defaultContainerRoot: params.defaultContainerRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +248,51 @@ export function resolveSandboxFsPathWithMounts(params: {
|
||||
throw new Error(escapeMessage);
|
||||
}
|
||||
|
||||
function resolveMountedContainerPath(params: {
|
||||
mount: SandboxFsMount;
|
||||
containerPath: string;
|
||||
defaultContainerRoot: string;
|
||||
}): SandboxResolvedFsPath {
|
||||
const rel = path.posix.relative(params.mount.containerRoot, params.containerPath);
|
||||
const hostPath = rel
|
||||
? path.resolve(params.mount.hostRoot, ...toHostSegments(rel))
|
||||
: params.mount.hostRoot;
|
||||
const containerPath = rel
|
||||
? path.posix.join(params.mount.containerRoot, rel)
|
||||
: params.mount.containerRoot;
|
||||
return {
|
||||
hostPath,
|
||||
containerPath,
|
||||
relativePath: toDisplayRelative({
|
||||
containerPath,
|
||||
defaultContainerRoot: params.defaultContainerRoot,
|
||||
}),
|
||||
writable: params.mount.writable,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelativeContainerCandidate(params: {
|
||||
inputPosix: string;
|
||||
cwd: string;
|
||||
defaultContainerRoot: string;
|
||||
mountsByHost: SandboxFsMount[];
|
||||
}): string {
|
||||
const cwdMount = findMountByHostPath(params.mountsByHost, path.resolve(params.cwd));
|
||||
if (cwdMount) {
|
||||
const relHost = path.relative(cwdMount.hostRoot, path.resolve(params.cwd));
|
||||
const relPosix = relHost ? relHost.split(path.sep).join(path.posix.sep) : "";
|
||||
const containerCwd = relPosix
|
||||
? path.posix.join(cwdMount.containerRoot, relPosix)
|
||||
: cwdMount.containerRoot;
|
||||
return normalizeContainerPath(path.posix.resolve(containerCwd, params.inputPosix));
|
||||
}
|
||||
const cwdPosix = normalizePosixInput(params.cwd);
|
||||
if (path.posix.isAbsolute(cwdPosix)) {
|
||||
return normalizeContainerPath(path.posix.resolve(cwdPosix, params.inputPosix));
|
||||
}
|
||||
return normalizeContainerPath(path.posix.resolve(params.defaultContainerRoot, params.inputPosix));
|
||||
}
|
||||
|
||||
function formatSandboxRootEscapeMessage(params: {
|
||||
input: string;
|
||||
defaultWorkspaceRoot: string;
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
normalizeContainerPath as normalizeSandboxContainerPath,
|
||||
relativePathEscapesContainerRoot,
|
||||
} from "./path-utils.js";
|
||||
import { isExistingWorkspaceSkillMountSource } from "./workspace-mounts.js";
|
||||
import {
|
||||
isExistingWorkspaceSkillMountSource,
|
||||
resolveMaterializedSandboxSkillsWorkspaceDir,
|
||||
} from "./workspace-mounts.js";
|
||||
|
||||
type RemoteMountSource = "workspace" | "agent" | "protectedSkill";
|
||||
|
||||
@@ -295,6 +298,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
|
||||
mounts.push(
|
||||
...buildRemoteProtectedSkillMounts({
|
||||
localRoot: agentRoot,
|
||||
skillsWorkspaceDir: this.sandbox.skillsWorkspaceDir,
|
||||
workspaceContainerRoot,
|
||||
agentContainerRoot,
|
||||
includeAgentMount:
|
||||
@@ -454,11 +458,13 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
|
||||
const roots = [
|
||||
path.posix.join(workspaceContainerRoot, "skills"),
|
||||
path.posix.join(workspaceContainerRoot, ".agents", "skills"),
|
||||
path.posix.join(workspaceContainerRoot, ".openclaw", "sandbox-skills", "skills"),
|
||||
];
|
||||
if (path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)) {
|
||||
roots.push(
|
||||
path.posix.join(agentContainerRoot, "skills"),
|
||||
path.posix.join(agentContainerRoot, ".agents", "skills"),
|
||||
path.posix.join(agentContainerRoot, ".openclaw", "sandbox-skills", "skills"),
|
||||
);
|
||||
}
|
||||
return roots;
|
||||
@@ -629,22 +635,40 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
|
||||
|
||||
function buildRemoteProtectedSkillMounts(params: {
|
||||
localRoot: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workspaceContainerRoot: string;
|
||||
agentContainerRoot: string;
|
||||
includeAgentMount: boolean;
|
||||
}): MountInfo[] {
|
||||
const mounts: MountInfo[] = [
|
||||
const materializedSkillsWorkspaceDir = path.resolve(
|
||||
params.skillsWorkspaceDir ?? resolveMaterializedSandboxSkillsWorkspaceDir(params.localRoot),
|
||||
);
|
||||
const mounts: Array<MountInfo & { allowedRoot: string }> = [
|
||||
{
|
||||
localRoot: path.join(params.localRoot, "skills"),
|
||||
containerRoot: path.posix.join(params.workspaceContainerRoot, "skills"),
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
allowedRoot: params.localRoot,
|
||||
},
|
||||
{
|
||||
localRoot: path.join(params.localRoot, ".agents", "skills"),
|
||||
containerRoot: path.posix.join(params.workspaceContainerRoot, ".agents", "skills"),
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
allowedRoot: params.localRoot,
|
||||
},
|
||||
{
|
||||
localRoot: path.join(materializedSkillsWorkspaceDir, "skills"),
|
||||
containerRoot: path.posix.join(
|
||||
params.workspaceContainerRoot,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
),
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
allowedRoot: materializedSkillsWorkspaceDir,
|
||||
},
|
||||
];
|
||||
if (params.includeAgentMount) {
|
||||
@@ -654,21 +678,37 @@ function buildRemoteProtectedSkillMounts(params: {
|
||||
containerRoot: path.posix.join(params.agentContainerRoot, "skills"),
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
allowedRoot: params.localRoot,
|
||||
},
|
||||
{
|
||||
localRoot: path.join(params.localRoot, ".agents", "skills"),
|
||||
containerRoot: path.posix.join(params.agentContainerRoot, ".agents", "skills"),
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
allowedRoot: params.localRoot,
|
||||
},
|
||||
{
|
||||
localRoot: path.join(materializedSkillsWorkspaceDir, "skills"),
|
||||
containerRoot: path.posix.join(
|
||||
params.agentContainerRoot,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
),
|
||||
writable: false,
|
||||
source: "protectedSkill",
|
||||
allowedRoot: materializedSkillsWorkspaceDir,
|
||||
},
|
||||
);
|
||||
}
|
||||
return mounts.filter((mount) =>
|
||||
isExistingWorkspaceSkillMountSource({
|
||||
agentWorkspaceDir: params.localRoot,
|
||||
hostPath: mount.localRoot,
|
||||
}),
|
||||
);
|
||||
return mounts
|
||||
.filter((mount) =>
|
||||
isExistingWorkspaceSkillMountSource({
|
||||
rootDir: mount.allowedRoot,
|
||||
hostPath: mount.localRoot,
|
||||
}),
|
||||
)
|
||||
.map(({ allowedRoot: _allowedRoot, ...mount }) => mount);
|
||||
}
|
||||
|
||||
function compareRemoteMountsByContainerPath(a: MountInfo, b: MountInfo): number {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SSH sandbox backend tests cover runtime description/removal, remote seeding,
|
||||
// command execution, bind validation, and backend config plumbing.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
@@ -33,6 +34,13 @@ vi.mock("./ssh.js", async () => {
|
||||
});
|
||||
|
||||
const { createSshSandboxBackend, sshSandboxBackendManager } = await import("./ssh-backend.js");
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
@@ -165,8 +173,11 @@ describe("ssh sandbox backend", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
envSnapshot.restore();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -238,12 +249,15 @@ describe("ssh sandbox backend", () => {
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
});
|
||||
const skillsWorkspaceDir = await createTempDir("openclaw-ssh-skills-");
|
||||
await fs.mkdir(path.join(skillsWorkspaceDir, "skills"), { recursive: true });
|
||||
|
||||
const backend = await createSshSandboxBackend({
|
||||
sessionKey: "agent:worker:task",
|
||||
scopeKey: "agent:worker",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/agent",
|
||||
skillsWorkspaceDir,
|
||||
cfg: {
|
||||
mode: "all",
|
||||
backend: "ssh",
|
||||
@@ -300,7 +314,7 @@ describe("ssh sandbox backend", () => {
|
||||
createSession().host,
|
||||
]);
|
||||
expect(execSpec.argv.at(-1)).toContain("/remote/openclaw/openclaw-ssh-agent-worker");
|
||||
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(2);
|
||||
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(3);
|
||||
const workspaceUploadParams = requireSshUploadParams(0, "workspace upload params");
|
||||
expect(workspaceUploadParams.localDir).toBe("/tmp/workspace");
|
||||
expect(workspaceUploadParams.remoteDir).toContain("/workspace");
|
||||
@@ -310,6 +324,12 @@ describe("ssh sandbox backend", () => {
|
||||
);
|
||||
expect(agentUploadParams.localDir).toBe("/tmp/agent");
|
||||
expect(agentUploadParams.remoteDir).toContain("/agent");
|
||||
const skillsUploadParams = requireRecord(
|
||||
sshMocks.uploadDirectoryToSshTarget.mock.calls.at(2)?.[0],
|
||||
"skills upload params",
|
||||
);
|
||||
expect(skillsUploadParams.localDir).toBe(skillsWorkspaceDir);
|
||||
expect(skillsUploadParams.remoteDir).toContain("/workspace/.openclaw/sandbox-skills");
|
||||
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
@@ -321,6 +341,111 @@ describe("ssh sandbox backend", () => {
|
||||
expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes materialized skills before each exec and remote fs command", async () => {
|
||||
const skillsWorkspaceDir = await createTempDir("openclaw-ssh-skills-");
|
||||
await fs.mkdir(path.join(skillsWorkspaceDir, "skills"), { recursive: true });
|
||||
const backend = await createSshSandboxBackend({
|
||||
sessionKey: "agent:worker:task",
|
||||
scopeKey: "agent:worker",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
skillsWorkspaceDir,
|
||||
cfg: createBackendSandboxConfig({
|
||||
target: "peter@example.com:2222",
|
||||
}),
|
||||
});
|
||||
|
||||
const firstExec = await backend.buildExecSpec({
|
||||
command: "pwd",
|
||||
env: {},
|
||||
usePty: false,
|
||||
});
|
||||
const secondExec = await backend.buildExecSpec({
|
||||
command: "pwd",
|
||||
env: {},
|
||||
usePty: false,
|
||||
});
|
||||
await backend.runShellCommand({
|
||||
script: "printf ok",
|
||||
});
|
||||
|
||||
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(3);
|
||||
const skillsUploadParams = requireSshUploadParams(0, "skills upload params");
|
||||
expect(skillsUploadParams.localDir).toBe(skillsWorkspaceDir);
|
||||
expect(skillsUploadParams.remoteDir).toContain("/workspace/.openclaw/sandbox-skills");
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
token: firstExec.finalizeToken,
|
||||
});
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
token: secondExec.finalizeToken,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale remote materialized skills when the local copy is missing", async () => {
|
||||
const tmpDir = await createTempDir("openclaw-ssh-skills-");
|
||||
const skillsWorkspaceDir = path.join(tmpDir, "missing");
|
||||
const backend = await createSshSandboxBackend({
|
||||
sessionKey: "agent:worker:task",
|
||||
scopeKey: "agent:worker",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
skillsWorkspaceDir,
|
||||
cfg: createBackendSandboxConfig({
|
||||
target: "peter@example.com:2222",
|
||||
}),
|
||||
});
|
||||
|
||||
const execSpec = await backend.buildExecSpec({
|
||||
command: "pwd",
|
||||
env: {},
|
||||
usePty: false,
|
||||
});
|
||||
|
||||
expect(sshMocks.uploadDirectoryToSshTarget).not.toHaveBeenCalled();
|
||||
const commandParams = requireSshRunCommandParams(1);
|
||||
expect(commandParams.remoteCommand).toContain("openclaw-sandbox-clear");
|
||||
expect(commandParams.remoteCommand).toContain("/workspace/.openclaw/sandbox-skills");
|
||||
await backend.finalizeExec?.({
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
token: execSpec.finalizeToken,
|
||||
});
|
||||
});
|
||||
|
||||
it("disposes the exec ssh session when materialized skills refresh fails", async () => {
|
||||
const skillsWorkspaceDir = await createTempDir("openclaw-ssh-skills-");
|
||||
await fs.mkdir(path.join(skillsWorkspaceDir, "skills"), { recursive: true });
|
||||
const backend = await createSshSandboxBackend({
|
||||
sessionKey: "agent:worker:task",
|
||||
scopeKey: "agent:worker",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
agentWorkspaceDir: "/tmp/workspace",
|
||||
skillsWorkspaceDir,
|
||||
cfg: createBackendSandboxConfig({
|
||||
target: "peter@example.com:2222",
|
||||
}),
|
||||
});
|
||||
sshMocks.uploadDirectoryToSshTarget.mockRejectedValueOnce(new Error("upload failed"));
|
||||
|
||||
await expect(
|
||||
backend.buildExecSpec({
|
||||
command: "pwd",
|
||||
env: {},
|
||||
usePty: false,
|
||||
}),
|
||||
).rejects.toThrow("upload failed");
|
||||
|
||||
expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(1);
|
||||
expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("filters blocked secrets from exec subprocess env", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test-secret";
|
||||
process.env.LANG = "en_US.UTF-8";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Creates remote workspace copies, builds remote exec specs, and exposes a backend-neutral filesystem bridge.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
buildValidatedExecRemoteCommand,
|
||||
createSshSandboxSessionFromSettings,
|
||||
disposeSshSandboxSession,
|
||||
ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT,
|
||||
runSshSandboxCommand,
|
||||
uploadDirectoryToSshTarget,
|
||||
type SshSandboxSession,
|
||||
@@ -40,6 +42,7 @@ type ResolvedSshRuntimePaths = {
|
||||
runtimeRootDir: string;
|
||||
remoteWorkspaceDir: string;
|
||||
remoteAgentWorkspaceDir: string;
|
||||
remoteSkillsWorkspaceDir: string;
|
||||
};
|
||||
|
||||
/** SSH backend lifecycle hooks for probing and removing remote sandbox copies. */
|
||||
@@ -157,16 +160,22 @@ class SshSandboxBackendImpl {
|
||||
});
|
||||
await this.ensureRuntime();
|
||||
const sshSession = await this.createSession();
|
||||
return {
|
||||
argv: buildSshSandboxArgv({
|
||||
session: sshSession,
|
||||
remoteCommand,
|
||||
tty: usePty,
|
||||
}),
|
||||
env: sanitizeEnvVars(process.env).allowed,
|
||||
stdinMode: "pipe-open",
|
||||
finalizeToken: { sshSession } satisfies PendingExec,
|
||||
};
|
||||
try {
|
||||
await this.refreshRemoteSkillsWorkspace(sshSession);
|
||||
return {
|
||||
argv: buildSshSandboxArgv({
|
||||
session: sshSession,
|
||||
remoteCommand,
|
||||
tty: usePty,
|
||||
}),
|
||||
env: sanitizeEnvVars(process.env).allowed,
|
||||
stdinMode: "pipe-open",
|
||||
finalizeToken: { sshSession } satisfies PendingExec,
|
||||
};
|
||||
} catch (error) {
|
||||
await disposeSshSandboxSession(sshSession);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
finalizeExec: async ({ token }) => {
|
||||
const sshSession = (token as PendingExec | undefined)?.sshSession;
|
||||
@@ -243,25 +252,50 @@ class SshSandboxBackendImpl {
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceRemoteDirectoryFromLocal(
|
||||
session: SshSandboxSession,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
): Promise<void> {
|
||||
private async refreshRemoteSkillsWorkspace(session: SshSandboxSession): Promise<void> {
|
||||
if (
|
||||
this.params.createParams.cfg.workspaceAccess !== "rw" ||
|
||||
!this.params.createParams.skillsWorkspaceDir
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.clearRemoteDirectory(session, this.params.runtimePaths.remoteSkillsWorkspaceDir);
|
||||
if (!(await isExistingDirectory(this.params.createParams.skillsWorkspaceDir))) {
|
||||
return;
|
||||
}
|
||||
await uploadDirectoryToSshTarget({
|
||||
session,
|
||||
localDir: this.params.createParams.skillsWorkspaceDir,
|
||||
remoteDir: this.params.runtimePaths.remoteSkillsWorkspaceDir,
|
||||
remoteRootDir: this.params.runtimePaths.runtimeRootDir,
|
||||
});
|
||||
}
|
||||
|
||||
private async clearRemoteDirectory(session: SshSandboxSession, remoteDir: string): Promise<void> {
|
||||
await runSshSandboxCommand({
|
||||
session,
|
||||
remoteCommand: buildRemoteCommand([
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
`${ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
|
||||
"openclaw-sandbox-clear",
|
||||
remoteDir,
|
||||
this.params.runtimePaths.runtimeRootDir,
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
private async replaceRemoteDirectoryFromLocal(
|
||||
session: SshSandboxSession,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
): Promise<void> {
|
||||
await this.clearRemoteDirectory(session, remoteDir);
|
||||
await uploadDirectoryToSshTarget({
|
||||
session,
|
||||
localDir,
|
||||
remoteDir,
|
||||
remoteRootDir: this.params.runtimePaths.runtimeRootDir,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,6 +305,7 @@ class SshSandboxBackendImpl {
|
||||
await this.ensureRuntime();
|
||||
const session = await this.createSession();
|
||||
try {
|
||||
await this.refreshRemoteSkillsWorkspace(session);
|
||||
return await runSshSandboxCommand({
|
||||
session,
|
||||
remoteCommand: buildRemoteCommand([
|
||||
@@ -290,6 +325,14 @@ class SshSandboxBackendImpl {
|
||||
}
|
||||
}
|
||||
|
||||
async function isExistingDirectory(dir: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(dir)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths {
|
||||
const runtimeId = buildSshSandboxRuntimeId(scopeKey);
|
||||
const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId);
|
||||
@@ -298,6 +341,12 @@ function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): Resolv
|
||||
runtimeRootDir,
|
||||
remoteWorkspaceDir: path.posix.join(runtimeRootDir, "workspace"),
|
||||
remoteAgentWorkspaceDir: path.posix.join(runtimeRootDir, "agent"),
|
||||
remoteSkillsWorkspaceDir: path.posix.join(
|
||||
runtimeRootDir,
|
||||
"workspace",
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
// SSH sandbox helper tests cover temp auth materialization, remote command
|
||||
// validation, shell quoting, and upload symlink safety.
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildValidatedExecRemoteCommand,
|
||||
createSshSandboxSessionFromSettings,
|
||||
disposeSshSandboxSession,
|
||||
ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT,
|
||||
type SshSandboxSession,
|
||||
uploadDirectoryToSshTarget,
|
||||
} from "./ssh.js";
|
||||
|
||||
const sessions: SshSandboxSession[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
@@ -176,6 +180,59 @@ describe("sandbox ssh helpers", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"fails closed when remote upload directory validation fails",
|
||||
() => {
|
||||
expect(ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT.split("\n")[0]).toBe("set -e");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"allows symlinked ancestors before the trusted remote root",
|
||||
async () => {
|
||||
const realParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-real-"));
|
||||
tempDirs.push(realParent);
|
||||
const linkParent = `${realParent}-link`;
|
||||
tempDirs.push(linkParent);
|
||||
await fs.symlink(realParent, linkParent);
|
||||
|
||||
const root = path.join(linkParent, "runtime");
|
||||
const target = path.join(root, "workspace", ".openclaw", "sandbox-skills");
|
||||
await execFileAsync("/bin/sh", [
|
||||
"-c",
|
||||
ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT,
|
||||
"openclaw-remote-dir",
|
||||
target,
|
||||
root,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.stat(path.join(realParent, "runtime", "workspace", ".openclaw", "sandbox-skills")),
|
||||
).resolves.toMatchObject({ dev: expect.any(Number) });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects symlinked directories inside the trusted remote root",
|
||||
async () => {
|
||||
const realParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-real-"));
|
||||
tempDirs.push(realParent);
|
||||
const root = path.join(realParent, "runtime");
|
||||
await fs.mkdir(path.join(root, "workspace"), { recursive: true });
|
||||
await fs.symlink(realParent, path.join(root, "workspace", ".openclaw"));
|
||||
|
||||
await expect(
|
||||
execFileAsync("/bin/sh", [
|
||||
"-c",
|
||||
ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT,
|
||||
"openclaw-remote-dir",
|
||||
path.join(root, "workspace", ".openclaw", "sandbox-skills"),
|
||||
root,
|
||||
]),
|
||||
).rejects.toThrow(/unsafe remote directory symlink/);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects upload trees with symlinks that escape the local workspace",
|
||||
async () => {
|
||||
|
||||
@@ -655,20 +655,63 @@ export async function runSshSandboxCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export const ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT = [
|
||||
"set -e",
|
||||
'target="$1"',
|
||||
'root="${2:-$1}"',
|
||||
'case "$target" in /*) ;; *) echo "remote directory must be absolute: $target" >&2; exit 1 ;; esac',
|
||||
'case "$root" in /*) ;; *) echo "remote root must be absolute: $root" >&2; exit 1 ;; esac',
|
||||
'target="${target%/}"',
|
||||
'root="${root%/}"',
|
||||
'[ -n "$target" ] || target="/"',
|
||||
'[ -n "$root" ] || root="/"',
|
||||
'case "$target/" in "$root"/*|"$root/") ;; *) echo "remote directory must stay under root: $target" >&2; exit 1 ;; esac',
|
||||
'old_ifs="$IFS"',
|
||||
'IFS="/"',
|
||||
"set -- ${target#/} ${root#/}",
|
||||
'IFS="$old_ifs"',
|
||||
"for part do",
|
||||
' [ -n "$part" ] || continue',
|
||||
' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac',
|
||||
"done",
|
||||
'if [ -L "$root" ]; then echo "unsafe remote root symlink: $root" >&2; exit 1; fi',
|
||||
'mkdir -p -- "$root"',
|
||||
'canonical_root="$(cd "$root" && pwd -P)"',
|
||||
'relative="${target#"$root"}"',
|
||||
'relative="${relative#/}"',
|
||||
'current="$canonical_root"',
|
||||
'IFS="/"',
|
||||
"set -- $relative",
|
||||
'IFS="$old_ifs"',
|
||||
"for part do",
|
||||
' [ -n "$part" ] || continue',
|
||||
' if [ "$current" = "/" ]; then next="/$part"; else next="$current/$part"; fi',
|
||||
' if [ -L "$next" ]; then echo "unsafe remote directory symlink: $next" >&2; exit 1; fi',
|
||||
' if [ -e "$next" ]; then',
|
||||
' if [ ! -d "$next" ]; then echo "unsafe remote directory component: $next" >&2; exit 1; fi',
|
||||
" else",
|
||||
' mkdir -- "$next"',
|
||||
" fi",
|
||||
' current="$next"',
|
||||
"done",
|
||||
].join("\n");
|
||||
|
||||
/** Stream a local directory to the remote sandbox with tar over ssh. */
|
||||
export async function uploadDirectoryToSshTarget(params: {
|
||||
session: SshSandboxSession;
|
||||
localDir: string;
|
||||
remoteDir: string;
|
||||
remoteRootDir?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
await assertSafeUploadSymlinks(params.localDir);
|
||||
const remoteCommand = buildRemoteCommand([
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'mkdir -p -- "$1" && tar -xf - -C "$1"',
|
||||
`${ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT}\ntar -xf - -C "$1"`,
|
||||
"openclaw-sandbox-upload",
|
||||
params.remoteDir,
|
||||
params.remoteRootDir ?? params.remoteDir,
|
||||
]);
|
||||
const sshArgv = buildSshSandboxArgv({
|
||||
session: params.session,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { SandboxBackendHandle, SandboxBackendId } from "./backend-handle.types.js";
|
||||
import type { SandboxFsBridge } from "./fs-bridge.types.js";
|
||||
import type { SandboxDockerConfig } from "./types.docker.js";
|
||||
import type { SkillEligibilityContext } from "../../skills/types.js";
|
||||
|
||||
export type { SandboxDockerConfig } from "./types.docker.js";
|
||||
|
||||
@@ -97,6 +98,8 @@ export type SandboxContext = {
|
||||
sessionKey: string;
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
skillsEligibility?: SkillEligibilityContext;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
runtimeId: string;
|
||||
runtimeLabel: string;
|
||||
|
||||
@@ -173,6 +173,32 @@ describe("appendWorkspaceMountArgs", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("overlays materialized sandbox skills read-only when workspaceAccess is rw", () => {
|
||||
const agentWorkspaceDir = makeTempWorkspace();
|
||||
const skillsWorkspaceDir = makeTempWorkspace();
|
||||
const materializedSkillsDir = path.join(skillsWorkspaceDir, "skills");
|
||||
fs.mkdirSync(path.join(materializedSkillsDir, "demo"), { recursive: true });
|
||||
fs.writeFileSync(path.join(materializedSkillsDir, "demo", "SKILL.md"), "# Demo\n");
|
||||
|
||||
const args: string[] = [];
|
||||
appendWorkspaceMountArgs({
|
||||
args,
|
||||
workspaceDir: agentWorkspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
workdir: "/workspace",
|
||||
workspaceAccess: "rw",
|
||||
});
|
||||
|
||||
const mounts = args.filter(
|
||||
(arg) => arg.startsWith(agentWorkspaceDir) || arg.startsWith(skillsWorkspaceDir),
|
||||
);
|
||||
expect(mounts).toEqual([
|
||||
`${agentWorkspaceDir}:/workspace:z`,
|
||||
`${materializedSkillsDir}:/workspace/.openclaw/sandbox-skills/skills:ro,z`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add a separate synced skill overlay when workspaceAccess is ro", () => {
|
||||
const agentWorkspaceDir = makeTempWorkspace();
|
||||
const sandboxWorkspaceDir = makeTempWorkspace();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js";
|
||||
import type { SandboxWorkspaceAccess } from "./types.js";
|
||||
|
||||
export const SANDBOX_MOUNT_FORMAT_VERSION = 3;
|
||||
const MATERIALIZED_SANDBOX_SKILLS_WORKSPACE_PARTS = [".openclaw", "sandbox-skills"] as const;
|
||||
|
||||
/** Read-only skill directory mounted from the agent workspace into the sandbox workspace. */
|
||||
export type ReadOnlyWorkspaceSkillMount = {
|
||||
@@ -35,9 +36,18 @@ function containerJoin(root: string, ...parts: string[]): string {
|
||||
return suffix ? `${normalizedRoot}/${suffix}` : normalizedRoot;
|
||||
}
|
||||
|
||||
/** Returns true when a skill mount source exists inside the canonical agent workspace. */
|
||||
/** Hidden workspace used to materialize non-workspace skills for rw sandboxes. */
|
||||
export function resolveMaterializedSandboxSkillsWorkspaceDir(rootDir: string): string {
|
||||
return path.join(rootDir, ...MATERIALIZED_SANDBOX_SKILLS_WORKSPACE_PARTS);
|
||||
}
|
||||
|
||||
export function resolveMaterializedSandboxSkillsRoot(rootDir: string): string {
|
||||
return path.join(resolveMaterializedSandboxSkillsWorkspaceDir(rootDir), "skills");
|
||||
}
|
||||
|
||||
/** Returns true when a skill mount source exists inside the canonical mount root. */
|
||||
export function isExistingWorkspaceSkillMountSource(params: {
|
||||
agentWorkspaceDir: string;
|
||||
rootDir: string;
|
||||
hostPath: string;
|
||||
}): boolean {
|
||||
try {
|
||||
@@ -48,9 +58,7 @@ export function isExistingWorkspaceSkillMountSource(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const agentRoot = resolveSandboxHostPathViaExistingAncestor(
|
||||
path.resolve(params.agentWorkspaceDir),
|
||||
);
|
||||
const agentRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(params.rootDir));
|
||||
const canonicalSource = resolveSandboxHostPathViaExistingAncestor(path.resolve(params.hostPath));
|
||||
return isPathInside(agentRoot, canonicalSource);
|
||||
}
|
||||
@@ -59,6 +67,7 @@ export function isExistingWorkspaceSkillMountSource(params: {
|
||||
export function resolveReadOnlyWorkspaceSkillMounts(params: {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workdir: string;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
}): ReadOnlyWorkspaceSkillMount[] {
|
||||
@@ -68,23 +77,38 @@ export function resolveReadOnlyWorkspaceSkillMounts(params: {
|
||||
|
||||
// RW workspaces mount the project as writable, but skill sources remain read-only so agent
|
||||
// instructions are visible without letting sandbox commands mutate them.
|
||||
const materializedSkillsWorkspaceDir =
|
||||
params.skillsWorkspaceDir ?? resolveMaterializedSandboxSkillsWorkspaceDir(params.agentWorkspaceDir);
|
||||
const mounts = [
|
||||
{
|
||||
hostPath: path.join(params.agentWorkspaceDir, "skills"),
|
||||
containerPath: containerJoin(params.workdir, "skills"),
|
||||
rootDir: params.agentWorkspaceDir,
|
||||
},
|
||||
{
|
||||
hostPath: path.join(params.agentWorkspaceDir, ".agents", "skills"),
|
||||
containerPath: containerJoin(params.workdir, ".agents", "skills"),
|
||||
rootDir: params.agentWorkspaceDir,
|
||||
},
|
||||
{
|
||||
hostPath: path.join(materializedSkillsWorkspaceDir, "skills"),
|
||||
containerPath: containerJoin(
|
||||
params.workdir,
|
||||
...MATERIALIZED_SANDBOX_SKILLS_WORKSPACE_PARTS,
|
||||
"skills",
|
||||
),
|
||||
rootDir: materializedSkillsWorkspaceDir,
|
||||
},
|
||||
];
|
||||
|
||||
return mounts.filter((mount) =>
|
||||
isExistingWorkspaceSkillMountSource({
|
||||
agentWorkspaceDir: params.agentWorkspaceDir,
|
||||
hostPath: mount.hostPath,
|
||||
}),
|
||||
);
|
||||
return mounts
|
||||
.filter((mount) =>
|
||||
isExistingWorkspaceSkillMountSource({
|
||||
rootDir: mount.rootDir,
|
||||
hostPath: mount.hostPath,
|
||||
}),
|
||||
)
|
||||
.map(({ hostPath, containerPath }) => ({ hostPath, containerPath }));
|
||||
}
|
||||
|
||||
/** Returns stable mount state for sandbox config hashes. */
|
||||
@@ -116,6 +140,7 @@ export function appendWorkspaceMountArgs(params: {
|
||||
args: string[];
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workdir: string;
|
||||
workspaceAccess: SandboxWorkspaceAccess;
|
||||
readOnlyWorkspaceSkillMounts?: readonly ReadOnlyWorkspaceSkillMount[];
|
||||
@@ -151,6 +176,7 @@ export function appendWorkspaceMountArgs(params: {
|
||||
resolveReadOnlyWorkspaceSkillMounts({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workdir,
|
||||
workspaceAccess,
|
||||
}),
|
||||
|
||||
@@ -45,10 +45,16 @@ describe("workspace skills bridge mount policy", () => {
|
||||
it("resolves workspace skill roots as read-only", async () => {
|
||||
await withTempDir("openclaw-skills-bridge-", async (stateDir) => {
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const skillsWorkspaceDir = path.join(stateDir, "sandbox-state");
|
||||
await fs.mkdir(path.join(workspaceDir, "skills", "demo"), { recursive: true });
|
||||
await fs.mkdir(path.join(workspaceDir, ".agents", "skills", "demo"), { recursive: true });
|
||||
await fs.mkdir(path.join(skillsWorkspaceDir, "skills", "demo"), { recursive: true });
|
||||
|
||||
const sandbox = createSandbox({ workspaceDir, agentWorkspaceDir: workspaceDir });
|
||||
const sandbox = createSandbox({
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
skillsWorkspaceDir,
|
||||
});
|
||||
const mounts = buildSandboxFsMounts(sandbox);
|
||||
const resolve = (filePath: string) =>
|
||||
resolveSandboxFsPathWithMounts({
|
||||
@@ -62,7 +68,14 @@ describe("workspace skills bridge mount policy", () => {
|
||||
expect(resolve("normal.txt").writable).toBe(true);
|
||||
expect(resolve("skills/demo/SKILL.md").writable).toBe(false);
|
||||
expect(resolve(".agents/skills/demo/SKILL.md").writable).toBe(false);
|
||||
expect(resolve(".openclaw/sandbox-skills/skills/demo/SKILL.md").writable).toBe(false);
|
||||
expect(resolve(".openclaw/sandbox-skills/skills/demo/SKILL.md").hostPath).toBe(
|
||||
path.join(skillsWorkspaceDir, "skills", "demo", "SKILL.md"),
|
||||
);
|
||||
expect(resolve("/workspace/skills/demo/SKILL.md").writable).toBe(false);
|
||||
expect(resolve("/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md").writable).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,16 +112,20 @@ describe("workspace skills bridge mount policy", () => {
|
||||
async () => {
|
||||
await withTempDir("openclaw-skills-remote-only-", async (stateDir) => {
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const skillsWorkspaceDir = path.join(stateDir, "sandbox-state");
|
||||
const remoteWorkspaceDir = path.join(stateDir, "remote-workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(path.join(remoteWorkspaceDir, "skills", "demo"), { recursive: true });
|
||||
await fs.mkdir(path.join(skillsWorkspaceDir, "skills", "demo"), { recursive: true });
|
||||
const canonicalWorkspaceDir = await fs.realpath(workspaceDir);
|
||||
const canonicalSkillsWorkspaceDir = await fs.realpath(skillsWorkspaceDir);
|
||||
const canonicalRemoteWorkspaceDir = await fs.realpath(remoteWorkspaceDir);
|
||||
|
||||
const bridge = createRemoteShellSandboxFsBridge({
|
||||
sandbox: createSandbox({
|
||||
workspaceDir: canonicalWorkspaceDir,
|
||||
agentWorkspaceDir: canonicalWorkspaceDir,
|
||||
skillsWorkspaceDir: canonicalSkillsWorkspaceDir,
|
||||
}),
|
||||
runtime: {
|
||||
remoteWorkspaceDir: canonicalRemoteWorkspaceDir,
|
||||
@@ -127,6 +144,26 @@ describe("workspace skills bridge mount policy", () => {
|
||||
await expect(
|
||||
fs.stat(path.join(canonicalRemoteWorkspaceDir, "skills", "demo", "SKILL.md")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
await expect(
|
||||
bridge.writeFile({
|
||||
filePath: ".openclaw/sandbox-skills/skills/demo/SKILL.md",
|
||||
cwd: canonicalRemoteWorkspaceDir,
|
||||
data: "# Demo\n",
|
||||
}),
|
||||
).rejects.toThrow(/read-only/);
|
||||
await expect(
|
||||
fs.stat(
|
||||
path.join(
|
||||
canonicalRemoteWorkspaceDir,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
"demo",
|
||||
"SKILL.md",
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ type AgentToolsSandboxContextParams = {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir?: string;
|
||||
workspaceAccess?: SandboxWorkspaceAccess;
|
||||
skillsWorkspaceDir?: string;
|
||||
fsBridge?: SandboxFsBridge;
|
||||
tools?: SandboxToolPolicy;
|
||||
browserAllowHostControl?: boolean;
|
||||
@@ -30,6 +31,7 @@ export function createAgentToolsSandboxContext(
|
||||
sessionKey: params.sessionKey ?? "sandbox:test",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: params.agentWorkspaceDir ?? workspaceDir,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workspaceAccess: params.workspaceAccess ?? "rw",
|
||||
runtimeId: params.containerName ?? "openclaw-sbx-test",
|
||||
runtimeLabel: params.containerName ?? "openclaw-sbx-test",
|
||||
|
||||
@@ -14,13 +14,29 @@ import { createSandboxFsBridgeFromResolver } from "./host-sandbox-fs-bridge.js";
|
||||
function createUnsafeMountedBridge(params: {
|
||||
root: string;
|
||||
agentHostRoot: string;
|
||||
skillsHostRoot?: string;
|
||||
workspaceContainerRoot?: string;
|
||||
}): SandboxFsBridge {
|
||||
const root = path.resolve(params.root);
|
||||
const agentHostRoot = path.resolve(params.agentHostRoot);
|
||||
const skillsHostRoot = params.skillsHostRoot ? path.resolve(params.skillsHostRoot) : undefined;
|
||||
const workspaceContainerRoot = params.workspaceContainerRoot ?? "/workspace";
|
||||
const skillsContainerRoot = path.posix.join(
|
||||
workspaceContainerRoot,
|
||||
".openclaw",
|
||||
"sandbox-skills",
|
||||
"skills",
|
||||
);
|
||||
const skillsRelativeRoot = ".openclaw/sandbox-skills/skills";
|
||||
|
||||
const resolvePath = (filePath: string, cwd?: string): SandboxResolvedPath => {
|
||||
const normalizedRelativePath = path.posix.normalize(filePath.replace(/\\/g, "/"));
|
||||
const skillsRelativePath =
|
||||
normalizedRelativePath === skillsRelativeRoot
|
||||
? ""
|
||||
: normalizedRelativePath.startsWith(`${skillsRelativeRoot}/`)
|
||||
? normalizedRelativePath.slice(skillsRelativeRoot.length + 1)
|
||||
: undefined;
|
||||
// Intentionally unsafe: simulate a sandbox FS bridge that maps /agent/* into a host path
|
||||
// outside the workspace root (e.g. an operator-configured bind mount).
|
||||
const hostPath =
|
||||
@@ -29,9 +45,14 @@ function createUnsafeMountedBridge(params: {
|
||||
agentHostRoot,
|
||||
filePath === "/agent" || filePath === "/agent/" ? "" : filePath.slice("/agent/".length),
|
||||
)
|
||||
: path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(cwd ?? root, filePath);
|
||||
: skillsHostRoot &&
|
||||
(filePath === skillsContainerRoot || filePath.startsWith(`${skillsContainerRoot}/`))
|
||||
? path.join(skillsHostRoot, filePath.slice(skillsContainerRoot.length + 1))
|
||||
: skillsHostRoot && skillsRelativePath !== undefined
|
||||
? path.join(skillsHostRoot, skillsRelativePath)
|
||||
: path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(cwd ?? root, filePath);
|
||||
|
||||
const relFromRoot = path.relative(root, hostPath);
|
||||
const relativePath =
|
||||
@@ -54,17 +75,22 @@ function createUnsafeMountedBridge(params: {
|
||||
export function createUnsafeMountedSandbox(params: {
|
||||
sandboxRoot: string;
|
||||
agentRoot: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
workspaceContainerRoot?: string;
|
||||
}): SandboxContext {
|
||||
const bridge = createUnsafeMountedBridge({
|
||||
root: params.sandboxRoot,
|
||||
agentHostRoot: params.agentRoot,
|
||||
skillsHostRoot: params.skillsWorkspaceDir
|
||||
? path.join(params.skillsWorkspaceDir, "skills")
|
||||
: undefined,
|
||||
workspaceContainerRoot: params.workspaceContainerRoot,
|
||||
});
|
||||
return createAgentToolsSandboxContext({
|
||||
workspaceDir: params.sandboxRoot,
|
||||
agentWorkspaceDir: params.agentRoot,
|
||||
skillsWorkspaceDir: params.skillsWorkspaceDir,
|
||||
workspaceAccess: params.workspaceAccess ?? "rw",
|
||||
fsBridge: bridge,
|
||||
tools: { allow: [], deny: [] },
|
||||
@@ -72,21 +98,34 @@ export function createUnsafeMountedSandbox(params: {
|
||||
}
|
||||
|
||||
export async function withUnsafeMountedSandboxHarness(
|
||||
run: (ctx: { sandboxRoot: string; agentRoot: string; sandbox: SandboxContext }) => Promise<void>,
|
||||
options?: { workspaceAccess?: "none" | "ro" | "rw" },
|
||||
run: (ctx: {
|
||||
sandboxRoot: string;
|
||||
agentRoot: string;
|
||||
skillsWorkspaceDir?: string;
|
||||
sandbox: SandboxContext;
|
||||
}) => Promise<void>,
|
||||
options?: {
|
||||
includeSkillsWorkspace?: boolean;
|
||||
skillsWorkspaceDir?: string;
|
||||
workspaceAccess?: "none" | "ro" | "rw";
|
||||
},
|
||||
) {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-"));
|
||||
const sandboxRoot = path.join(stateDir, "sandbox");
|
||||
const agentRoot = path.join(stateDir, "agent");
|
||||
const skillsWorkspaceDir =
|
||||
options?.skillsWorkspaceDir ??
|
||||
(options?.includeSkillsWorkspace ? path.join(stateDir, "skills-state") : undefined);
|
||||
await fs.mkdir(sandboxRoot, { recursive: true });
|
||||
await fs.mkdir(agentRoot, { recursive: true });
|
||||
const sandbox = createUnsafeMountedSandbox({
|
||||
sandboxRoot,
|
||||
agentRoot,
|
||||
skillsWorkspaceDir,
|
||||
workspaceAccess: options?.workspaceAccess,
|
||||
});
|
||||
try {
|
||||
await run({ sandboxRoot, agentRoot, sandbox });
|
||||
await run({ sandboxRoot, agentRoot, skillsWorkspaceDir, sandbox });
|
||||
} finally {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -461,6 +461,34 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
expect(entries.map((entry) => entry.skill.name)).toEqual(["remote-only"]);
|
||||
});
|
||||
|
||||
it("filters remote-ineligible skills when no agent skill filter is active", async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "local-only"),
|
||||
name: "local-only",
|
||||
description: "Always available",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "remote-only"),
|
||||
name: "remote-only",
|
||||
description: "Needs a remote bin",
|
||||
metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}',
|
||||
});
|
||||
|
||||
const entries = loadTestWorkspaceSkillEntries(workspaceDir, {
|
||||
eligibility: {
|
||||
remote: {
|
||||
platforms: ["linux"],
|
||||
hasBin: () => false,
|
||||
hasAnyBin: () => false,
|
||||
note: "sandbox",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toEqual(["local-only"]);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"skips workspace skill paths that resolve outside the workspace root",
|
||||
async () => {
|
||||
|
||||
@@ -145,6 +145,25 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"preserves the target skills directory while refreshing children",
|
||||
async () => {
|
||||
const sourceWorkspace = await cloneSourceTemplate();
|
||||
const targetWorkspace = await createCaseDir("target");
|
||||
const targetSkillsDir = path.join(targetWorkspace, "skills");
|
||||
await fs.mkdir(path.join(targetSkillsDir, "stale"), { recursive: true });
|
||||
await fs.writeFile(path.join(targetSkillsDir, "stale", "SKILL.md"), "# Stale\n", "utf8");
|
||||
const before = await fs.stat(targetSkillsDir);
|
||||
|
||||
await syncSourceSkillsToTarget(sourceWorkspace, targetWorkspace);
|
||||
|
||||
const after = await fs.stat(targetSkillsDir);
|
||||
expect(after.ino).toBe(before.ino);
|
||||
expect(await pathExists(path.join(targetSkillsDir, "stale", "SKILL.md"))).toBe(false);
|
||||
expect(await pathExists(path.join(targetSkillsDir, "demo-skill", "SKILL.md"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("syncs the explicit agent skill subset instead of inherited defaults", async () => {
|
||||
const sourceWorkspace = await createCaseDir("source");
|
||||
const targetWorkspace = await createCaseDir("target");
|
||||
|
||||
@@ -853,6 +853,7 @@ function loadSkillEntries(
|
||||
managedSkillsDir?: string;
|
||||
bundledSkillsDir?: string;
|
||||
pluginSkillsDir?: string;
|
||||
workspaceOnly?: boolean;
|
||||
},
|
||||
): SkillEntry[] {
|
||||
const limits = resolveSkillsLimits(opts?.config, opts?.agentId);
|
||||
@@ -1112,17 +1113,22 @@ function loadSkillEntries(
|
||||
return loadedSkills;
|
||||
};
|
||||
|
||||
const workspaceOnly = opts?.workspaceOnly === true;
|
||||
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||
const workspaceSkillsDir = path.resolve(workspaceDir, "skills");
|
||||
const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir();
|
||||
const bundledSkillsDir = workspaceOnly
|
||||
? undefined
|
||||
: (opts?.bundledSkillsDir ?? resolveBundledSkillsDir());
|
||||
const pluginSkillsDir = opts?.pluginSkillsDir ?? path.join(CONFIG_DIR, "plugin-skills");
|
||||
const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? [];
|
||||
const extraDirsRaw = workspaceOnly ? [] : (opts?.config?.skills?.load?.extraDirs ?? []);
|
||||
const extraDirs = normalizeTrimmedStringList(extraDirsRaw);
|
||||
const pluginSkillDirs = resolvePluginSkillDirs({
|
||||
workspaceDir,
|
||||
config: opts?.config,
|
||||
pluginSkillsDir,
|
||||
});
|
||||
const pluginSkillDirs = workspaceOnly
|
||||
? []
|
||||
: resolvePluginSkillDirs({
|
||||
workspaceDir,
|
||||
config: opts?.config,
|
||||
pluginSkillsDir,
|
||||
});
|
||||
const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs];
|
||||
|
||||
const bundledSkills = bundledSkillsDir
|
||||
@@ -1146,23 +1152,29 @@ function loadSkillEntries(
|
||||
limits,
|
||||
}),
|
||||
];
|
||||
const managedSkills = loadSkills({
|
||||
dir: managedSkillsDir,
|
||||
source: "openclaw-managed",
|
||||
});
|
||||
const managedSkills = workspaceOnly
|
||||
? []
|
||||
: loadSkills({
|
||||
dir: managedSkillsDir,
|
||||
source: "openclaw-managed",
|
||||
});
|
||||
const osHomeDir = resolveUserHomeDir();
|
||||
const personalAgentsSkillsDir = osHomeDir
|
||||
? path.resolve(osHomeDir, ".agents", "skills")
|
||||
: path.resolve(".agents", "skills");
|
||||
const personalAgentsSkills = loadSkills({
|
||||
dir: personalAgentsSkillsDir,
|
||||
source: "agents-skills-personal",
|
||||
});
|
||||
const personalAgentsSkills = workspaceOnly
|
||||
? []
|
||||
: loadSkills({
|
||||
dir: personalAgentsSkillsDir,
|
||||
source: "agents-skills-personal",
|
||||
});
|
||||
const projectAgentsSkillsDir = path.resolve(workspaceDir, ".agents", "skills");
|
||||
const projectAgentsSkills = loadSkills({
|
||||
dir: projectAgentsSkillsDir,
|
||||
source: "agents-skills-project",
|
||||
});
|
||||
const projectAgentsSkills = workspaceOnly
|
||||
? []
|
||||
: loadSkills({
|
||||
dir: projectAgentsSkillsDir,
|
||||
source: "agents-skills-project",
|
||||
});
|
||||
const workspaceSkills = loadSkills({
|
||||
dir: workspaceSkillsDir,
|
||||
source: "openclaw-workspace",
|
||||
@@ -1425,6 +1437,7 @@ export function resolveSkillsPromptForRun(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
eligibility?: SkillEligibilityContext;
|
||||
}): string {
|
||||
const snapshotPrompt = params.skillsSnapshot?.prompt?.trim();
|
||||
if (snapshotPrompt) {
|
||||
@@ -1435,6 +1448,7 @@ export function resolveSkillsPromptForRun(params: {
|
||||
entries: params.entries,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
eligibility: params.eligibility,
|
||||
});
|
||||
return prompt.trim() ? prompt : "";
|
||||
}
|
||||
@@ -1451,11 +1465,12 @@ export function loadWorkspaceSkillEntries(
|
||||
skillFilter?: string[];
|
||||
agentId?: string;
|
||||
eligibility?: SkillEligibilityContext;
|
||||
workspaceOnly?: boolean;
|
||||
},
|
||||
): SkillEntry[] {
|
||||
const entries = loadSkillEntries(workspaceDir, opts);
|
||||
const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts);
|
||||
if (effectiveSkillFilter === undefined) {
|
||||
if (effectiveSkillFilter === undefined && opts?.eligibility === undefined) {
|
||||
return entries;
|
||||
}
|
||||
return filterSkillEntries(entries, opts?.config, effectiveSkillFilter, opts?.eligibility);
|
||||
@@ -1518,6 +1533,29 @@ function resolveSyncedSkillDestinationPath(params: {
|
||||
}).resolved;
|
||||
}
|
||||
|
||||
async function prepareSyncedSkillsDirectory(targetSkillsDir: string): Promise<void> {
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = await fsp.lstat(targetSkillsDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
await fsp.mkdir(targetSkillsDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(targetSkillsDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of await fsp.readdir(targetSkillsDir)) {
|
||||
await fsp.rm(path.join(targetSkillsDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSkillsToWorkspace(params: {
|
||||
sourceWorkspaceDir: string;
|
||||
targetWorkspaceDir: string;
|
||||
@@ -1548,8 +1586,7 @@ export async function syncSkillsToWorkspace(params: {
|
||||
pluginSkillsDir: params.pluginSkillsDir,
|
||||
});
|
||||
|
||||
await fsp.rm(targetSkillsDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(targetSkillsDir, { recursive: true });
|
||||
await prepareSyncedSkillsDirectory(targetSkillsDir);
|
||||
|
||||
const usedDirNames = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -58,6 +58,37 @@ describe("resolveEmbeddedRunSkillEntries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can constrain live loading to materialized workspace skills", () => {
|
||||
const eligibility = {
|
||||
remote: {
|
||||
platforms: ["linux"],
|
||||
hasBin: () => false,
|
||||
hasAnyBin: () => true,
|
||||
note: "sandbox",
|
||||
},
|
||||
};
|
||||
|
||||
resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: "/tmp/workspace/.openclaw/sandbox-skills",
|
||||
config: {},
|
||||
eligibility,
|
||||
skillsSnapshot: {
|
||||
prompt: "skills prompt",
|
||||
skills: [],
|
||||
},
|
||||
workspaceOnly: true,
|
||||
});
|
||||
|
||||
expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/.openclaw/sandbox-skills",
|
||||
{
|
||||
config: {},
|
||||
eligibility,
|
||||
workspaceOnly: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the active runtime snapshot when caller config still contains SecretRefs", () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
skills: {
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resolveSkillRuntimeConfig } from "../loading/runtime-config.js";
|
||||
import { loadWorkspaceSkillEntries } from "../loading/workspace.js";
|
||||
import type { SkillEntry, SkillSnapshot } from "../types.js";
|
||||
import type { SkillEligibilityContext, SkillEntry, SkillSnapshot } from "../types.js";
|
||||
|
||||
/** Resolves skill entries embedded into a run payload into runtime-visible entries. */
|
||||
export function resolveEmbeddedRunSkillEntries(params: {
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
eligibility?: SkillEligibilityContext;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
workspaceOnly?: boolean;
|
||||
}): {
|
||||
shouldLoadSkillEntries: boolean;
|
||||
skillEntries: SkillEntry[];
|
||||
@@ -19,7 +21,12 @@ export function resolveEmbeddedRunSkillEntries(params: {
|
||||
return {
|
||||
shouldLoadSkillEntries,
|
||||
skillEntries: shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(params.workspaceDir, { config, agentId: params.agentId })
|
||||
? loadWorkspaceSkillEntries(params.workspaceDir, {
|
||||
config,
|
||||
agentId: params.agentId,
|
||||
...(params.eligibility ? { eligibility: params.eligibility } : {}),
|
||||
...(params.workspaceOnly === true ? { workspaceOnly: true } : {}),
|
||||
})
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user