fix: mirror skills for read-only sandbox

This commit is contained in:
Peter Steinberger
2026-01-11 04:24:11 +01:00
parent 29884f8d6f
commit 7660a78330
3 changed files with 163 additions and 2 deletions

View File

@@ -15,6 +15,7 @@
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.
- CLI/Status: replace the footer with a 3-line “Next steps” recommendation (share/debug/probe), and gate probes behind gateway reachability.
- CLI/Status: format non-JSON-serializable provider issue values more predictably and show which auth was used when the gateway is reachable (`token`/`password`/`none`).
- Docs: make `clawdbot status` the first diagnostic step and clarify `status --deep` behavior (requires a reachable gateway).

View File

@@ -0,0 +1,160 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
type SpawnCall = {
command: string;
args: string[];
};
const spawnCalls: SpawnCall[] = [];
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: (command: string, args: string[]) => {
spawnCalls.push({ command, args });
const child = new EventEmitter() as {
stdout?: Readable;
stderr?: Readable;
on: (event: string, cb: (...args: unknown[]) => void) => void;
};
child.stdout = new Readable({ read() {} });
child.stderr = new Readable({ read() {} });
const dockerArgs = command === "docker" ? args : [];
const shouldFailContainerInspect =
dockerArgs[0] === "inspect" &&
dockerArgs[1] === "-f" &&
dockerArgs[2] === "{{.State.Running}}";
const shouldSucceedImageInspect =
dockerArgs[0] === "image" && dockerArgs[1] === "inspect";
const code = shouldFailContainerInspect ? 1 : 0;
if (shouldSucceedImageInspect) {
queueMicrotask(() => child.emit("close", 0));
} else {
queueMicrotask(() => child.emit("close", code));
}
return child;
},
};
});
async function writeSkill(params: {
dir: string;
name: string;
description: string;
}) {
const { dir, name, description } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`,
"utf-8",
);
}
function restoreEnv(snapshot: Record<string, string | undefined>) {
for (const key of Object.keys(process.env)) {
if (!(key in snapshot)) delete process.env[key];
}
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
describe("sandbox skill mirroring", () => {
let envSnapshot: Record<string, string | undefined>;
beforeEach(() => {
spawnCalls.length = 0;
envSnapshot = { ...process.env };
});
afterEach(() => {
restoreEnv(envSnapshot);
vi.resetModules();
});
const runContext = async (workspaceAccess: "none" | "ro") => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-state-"));
const bundledDir = path.join(stateDir, "bundled-skills");
await fs.mkdir(bundledDir, { recursive: true });
process.env.CLAWDBOT_STATE_DIR = stateDir;
process.env.CLAWDBOT_BUNDLED_SKILLS_DIR = bundledDir;
vi.resetModules();
const { resolveSandboxContext } = await import("./sandbox.js");
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-workspace-"),
);
await writeSkill({
dir: path.join(workspaceDir, "skills", "demo-skill"),
name: "demo-skill",
description: "Demo skill",
});
const cfg: ClawdbotConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "session",
workspaceAccess,
workspaceRoot: path.join(stateDir, "sandboxes"),
},
},
},
};
const context = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir,
});
return { context, workspaceDir };
};
it("copies skills into the sandbox when workspaceAccess is ro", async () => {
const { context } = await runContext("ro");
expect(context?.enabled).toBe(true);
const skillPath = path.join(
context?.workspaceDir ?? "",
"skills",
"demo-skill",
"SKILL.md",
);
await expect(fs.readFile(skillPath, "utf-8")).resolves.toContain(
"demo-skill",
);
});
it("copies skills into the sandbox when workspaceAccess is none", async () => {
const { context } = await runContext("none");
expect(context?.enabled).toBe(true);
const skillPath = path.join(
context?.workspaceDir ?? "",
"skills",
"demo-skill",
"SKILL.md",
);
await expect(fs.readFile(skillPath, "utf-8")).resolves.toContain(
"demo-skill",
);
});
});

View File

@@ -1313,7 +1313,7 @@ export async function resolveSandboxContext(params: {
agentWorkspaceDir,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
if (cfg.workspaceAccess !== "rw") {
try {
await syncSkillsToWorkspace({
sourceWorkspaceDir: agentWorkspaceDir,
@@ -1391,7 +1391,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
agentWorkspaceDir,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
if (cfg.workspaceAccess !== "rw") {
try {
await syncSkillsToWorkspace({
sourceWorkspaceDir: agentWorkspaceDir,