diff --git a/CHANGELOG.md b/CHANGELOG.md index 335795364..377892414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/src/agents/sandbox-skills.test.ts b/src/agents/sandbox-skills.test.ts new file mode 100644 index 000000000..73ba49a6a --- /dev/null +++ b/src/agents/sandbox-skills.test.ts @@ -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(); + 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) { + 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; + + 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", + ); + }); +}); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index c6afcc16e..7732becce 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -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,