diff --git a/claude_agent_pipe_sandboxed.py b/claude_agent_pipe_sandboxed.py index ccb83f4..8b37575 100644 --- a/claude_agent_pipe_sandboxed.py +++ b/claude_agent_pipe_sandboxed.py @@ -269,6 +269,30 @@ class OpenTerminalClient: async for _ in self.stream_output(handle): pass + async def ensure_skills( + self, user_id: str, workdir: str, skills: List[str] + ) -> None: + """Symlink system-vendored skills from /opt/claude-skills/ into the + chat's CLAUDE_CONFIG_DIR so Claude picks them up at startup. Uses + `ln -sfn` so image rebuilds that change the skill set propagate to + existing chats on their next turn (the old symlink is replaced, not + skipped). No-op if the skills list is empty.""" + if not skills: + return + for s in skills: + # Skill names are baked into the image path; reject anything + # that could break out of the /opt/claude-skills/ prefix. + if not re.match(r"^[a-zA-Z0-9_-]+$", s): + raise OpenTerminalError(f"unsafe skill name: {s!r}") + parts = [f"mkdir -p {workdir}/.claude/skills"] + parts += [ + f"ln -sfn /opt/claude-skills/{s} {workdir}/.claude/skills/{s}" + for s in skills + ] + handle = await self.start(user_id, " && ".join(parts)) + async for _ in self.stream_output(handle): + pass + async def run_capture(self, user_id: str, command: str) -> str: """Run a short command and return its merged stdout/stderr as a single string. For one-shot utility commands (ls, cat of tiny files) where @@ -715,6 +739,16 @@ class Pipe: "{chat_id} is substituted. Artifacts persist across turns." ), ) + SKILLS: str = Field( + default="docx,pdf,pptx,xlsx,frontend-design", + description=( + "Comma-separated list of skill names to symlink into each " + "chat's .claude/skills/ from /opt/claude-skills/ in the " + "sandbox image. Must match directories baked into the image " + "at build time (see sandbox/Dockerfile CLAUDE_SKILLS arg). " + "Empty string disables." + ), + ) def __init__(self) -> None: self.valves = self.Valves() @@ -810,6 +844,13 @@ class Pipe: ) as client: await client.ensure_dir(sandbox_user, workdir) + skills = [ + s.strip() + for s in (self.valves.SKILLS or "").split(",") + if s.strip() + ] + await client.ensure_skills(sandbox_user, workdir, skills) + # Cache miss = either a brand-new chat OR an OWUI restart # dropped the in-process map (#5). Claude's own on-disk # session JSONL tells us which — look there before giving diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile index cafae20..c953d30 100644 --- a/sandbox/Dockerfile +++ b/sandbox/Dockerfile @@ -17,11 +17,32 @@ RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ # bubblewrap + socat are the Linux backends for Claude Code's built-in # `/sandbox` feature. Without them, Claude falls back to unsandboxed bash — # our managed-settings.json sets failIfUnavailable=true so that silent -# degradation becomes a hard error instead. +# degradation becomes a hard error instead. git is needed for the skill +# vendoring step below. RUN apt-get update \ - && apt-get install -y --no-install-recommends bubblewrap socat \ + && apt-get install -y --no-install-recommends bubblewrap socat git ca-certificates \ && rm -rf /var/lib/apt/lists/* +# Vendor a curated set of Anthropic skills into /opt/claude-skills. Each +# chat's CLAUDE_CONFIG_DIR/.claude/skills/ symlinks here at runtime, so all +# users share a read-only copy without per-chat duplication. To update the +# set, change the sparse-checkout list and rebuild — running chats pick up +# the new set on their next turn (symlinks re-point). +ARG CLAUDE_SKILLS="docx pdf pptx xlsx frontend-design" +RUN set -eux; \ + mkdir -p /opt/claude-skills; \ + tmp="$(mktemp -d)"; \ + git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/anthropics/skills.git "$tmp"; \ + cd "$tmp"; \ + paths=""; for s in $CLAUDE_SKILLS; do paths="$paths skills/$s"; done; \ + git sparse-checkout set $paths; \ + for s in $CLAUDE_SKILLS; do \ + cp -r "skills/$s" "/opt/claude-skills/$s"; \ + done; \ + cd /; rm -rf "$tmp"; \ + ls /opt/claude-skills/ + # Managed (enterprise) settings live at the system-wide path and take # precedence over per-user and per-project settings. This is how we pin # Bash egress + WebFetch allowlists: users can extend via their own