Initial commit

This commit is contained in:
2026-05-07 22:45:36 -04:00
commit c45f384fb8
21 changed files with 2727 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Docker helpers. Build/inspect primitives shared by cli.sh
# (and reusable by future skill-sync / secret-injection scripts).
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_DOCKER_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_DOCKER_SOURCED=1
_iso_lib_docker_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_docker_dir}/log.sh"
# require_docker — fails with an install pointer if `docker` is not on PATH.
require_docker() {
if ! command -v docker >/dev/null 2>&1; then
info "Docker is required but was not found on PATH."
info "macOS: install Docker Desktop https://docs.docker.com/desktop/install/mac-install/"
info "Linux: install Docker Engine https://docs.docker.com/engine/install/"
die "docker not found"
fi
}
# image_exists <ref> — returns 0 if the named local image exists, else 1.
image_exists() {
local ref="${1:?image_exists: missing image reference}"
docker image inspect "$ref" >/dev/null 2>&1
}
# container_exists <name> — returns 0 if a container (running or stopped)
# with the given name exists, else 1.
container_exists() {
local name="${1:?container_exists: missing container name}"
# `docker ps -a -q -f name=^<name>$` prints the container id if it exists.
local id
id="$(docker ps -a -q -f "name=^${name}$" 2>/dev/null || true)"
[ -n "$id" ]
}
# slugify <name> — prints a DNS-safe slug (lowercase, non-alnum runs → '-',
# trimmed) on stdout. Exits non-zero if the result is empty.
slugify() {
local input="${1:?slugify: missing name}"
local slug
slug="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
if [ -z "$slug" ]; then
die "name '${input}' produced an empty slug; use alphanumeric characters"
fi
printf '%s' "$slug"
}
# build_image <ref> <context_dir> — invokes `docker build` every call. The
# layer cache makes no-change rebuilds cheap (typically <1s); always running
# the build means edits to the Dockerfile (or anything COPY'd in) take
# effect on the next cli.sh without the user having to manually `docker
# rmi` first.
build_image() {
local ref="${1:?build_image: missing image reference}"
local context="${2:?build_image: missing build context directory}"
info "building image ${ref} from ${context} (layer cache keeps repeat builds fast)"
docker build -t "$ref" "$context"
}
# build_image_with_cwd <derived_ref> <base_ref> <cwd>
#
# Builds a thin derived image that copies the contents of <cwd> into
# /home/node/workspace (owned by node:node) and sets WORKDIR there, so
# the launched claude session starts inside the user's project.
#
# The Dockerfile is piped via stdin (`-f -`) so no file is written into
# <cwd> — only the build context is read from there. Any .dockerignore
# already in <cwd> is honored automatically by docker build.
#
# A trust-dialog entry for /home/node/workspace is added to
# ~/.claude.json during the build, because the baked-in entry in the
# base image only covers /home/node and claude's "trust this folder"
# prompt is keyed on cwd.
build_image_with_cwd() {
local derived="${1:?build_image_with_cwd: missing derived ref}"
local base="${2:?build_image_with_cwd: missing base ref}"
local cwd="${3:?build_image_with_cwd: missing cwd}"
if [ ! -d "$cwd" ]; then
die "cwd not found at ${cwd}"
fi
info "building image ${derived} from ${base} with ${cwd} -> /home/node/workspace"
docker build -t "$derived" -f - "$cwd" <<DOCKERFILE
FROM ${base}
COPY --chown=node:node . /home/node/workspace
RUN node -e 'const fs=require("fs"),p=process.env.HOME+"/.claude.json",c=JSON.parse(fs.readFileSync(p,"utf8"));c.projects=c.projects||{};c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};fs.writeFileSync(p,JSON.stringify(c,null,2));'
WORKDIR /home/node/workspace
DOCKERFILE
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Env-var helpers. Set/unset checks only — never echo a secret value.
# See CLAUDE.md "Checking env vars safely" for the rule this enforces.
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_ENV_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_ENV_SOURCED=1
# Resolve sibling helpers regardless of caller's cwd.
_iso_lib_env_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_env_dir}/log.sh"
# require_env <NAME> — fails with a clear message if the named env var is
# unset or empty. Crucially does NOT print the value, the length, or any
# substring; only the variable name is echoed.
#
# Usage:
# require_env ANTHROPIC_API_KEY
require_env() {
local name="${1:-}"
if [ -z "$name" ]; then
die "require_env: missing variable name argument"
fi
# Indirect expansion to read the named variable without naming it twice.
local value="${!name-}"
if [ -z "$value" ]; then
die "required env var ${name} is not set. Export it in your shell and re-run."
fi
}
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# Env resolver. Walks the env entries for one agent in claude-bottle.json
# and produces:
# 1. The list of `docker run` arg fragments needed to forward each var.
# Both `secret` and `interpolated` entries become `-e NAME` (no
# `=value`) so Docker inherits the value from this process env
# without rendering it on argv or persisting it to disk.
# Only `literal` entries are written to a host-disk env-file and
# forwarded with `--env-file <path>`.
# 2. The export side-effect of populating this process's env with
# secret values prompted from the user, and with interpolated
# values copied from the matching host var, so `-e NAME` actually
# has something to inherit.
#
# Each env entry is a JSON string. Mode is selected by sentinel prefix:
# "?" → secret (prompt at runtime). Bare "?" uses a default
# prompt; "?<message>" uses <message> as the prompt body.
# "${HOST_VAR}" → interpolated from $HOST_VAR in the host process env
# any other str → literal (the JSON string is the value verbatim)
# A literal whose text starts with "?" or matches "${IDENT}" is not
# representable in v1 — pick a different value or change the convention.
#
# Critical rules (re-read CLAUDE.md "Checking env vars safely"):
# - NEVER echo, log, or interpolate the value of a secret or
# interpolated env var. Both modes are treated as potentially
# sensitive: nothing about their value (other than presence /
# length) ever lands on disk, in a log line, or on argv.
# - The env-file written for literal values lives under `mktemp -d`
# with mode 600 and is removed on script exit by the caller's trap.
# Secrets and interpolated values never go to this file.
# - Errors mention only the variable NAME, never any portion of the value.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_ENV_RESOLVE_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_ENV_RESOLVE_SOURCED=1
_iso_lib_env_resolve_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_env_resolve_dir}/log.sh"
# shellcheck source=./manifest.sh
. "${_iso_lib_env_resolve_dir}/manifest.sh"
# env_entry_kind <raw-string> — prints "secret", "interpolated", or
# "literal" based on the sentinel form of the entry. Never echoes the
# value of an interpolated entry — only its host-var NAME via the
# captured submatch. Secret-mode prompt text (everything after the
# leading "?") is extracted by env_entry_secret_prompt, not here.
env_entry_kind() {
local raw="${1-}"
case "$raw" in
\?*)
printf 'secret'
return 0
;;
esac
if [[ "$raw" =~ ^\$\{[A-Za-z_][A-Za-z0-9_]*\}$ ]]; then
printf 'interpolated'
return 0
fi
printf 'literal'
}
# env_entry_secret_prompt <raw-string> — for a secret entry (one whose
# raw value starts with "?"), prints the prompt body (everything after
# the leading "?"). Empty for a bare "?", which signals "use default
# prompt." Caller is responsible for falling back to a default.
env_entry_secret_prompt() {
local raw="${1-}"
printf '%s' "${raw#\?}"
}
# env_entry_interpolated_from <raw-string> — for an interpolated entry,
# prints the host var name (the identifier between `${` and `}`).
env_entry_interpolated_from() {
local raw="${1-}"
local inner="${raw#\$\{}"
inner="${inner%\}}"
printf '%s' "$inner"
}
# _read_secret_silent <NAME> [<prompt-body>] — prompt the user for a
# secret value on the tty without echoing the keystrokes. Stores the
# value in the global variable named by $1 via printf -v. Stdin
# redirection from /dev/tty so this still works under `<(...)` and
# other non-tty stdin situations.
#
# If <prompt-body> is provided and non-empty, the prompt rendered to
# the tty is "<prompt-body> (input hidden): "; otherwise it falls back
# to "claude-bottle: secret value for <NAME> (input hidden): ". The "(input
# hidden): " tail is always appended by this function — manifest
# authors write the message text only.
#
# We never `echo "$VALUE"` or interpolate it elsewhere; the only consumer
# is `export "$NAME=$VALUE"` immediately below.
_read_secret_silent() {
local target="${1:?_read_secret_silent: missing target var name}"
local prompt_body="${2-}"
local value=""
# Use the controlling tty for both the prompt and the read so this is
# robust even if stdin is a pipe.
if [ ! -t 0 ] && [ ! -t 2 ]; then
die "cannot prompt for secret '${target}': no tty available. Run from an interactive shell."
fi
# `printf` to /dev/tty for the prompt, `read -s` from /dev/tty for the value.
if [ -n "$prompt_body" ]; then
printf '%s (input hidden): ' "$prompt_body" >/dev/tty
else
printf 'claude-bottle: secret value for %s (input hidden): ' "$target" >/dev/tty
fi
# IFS= read -rs to read one line, raw, silent.
IFS= read -rs value </dev/tty
printf '\n' >/dev/tty
if [ -z "$value" ]; then
die "empty value provided for secret '${target}'. Re-run and supply a value."
fi
# Indirect assignment — never expose value via expansion in a string we
# log or pass anywhere else.
printf -v "$target" '%s' "$value"
# Scrub our local copy.
value=""
}
# env_resolve <manifest_file> <agent_name> <env_file_path> <out_args_path>
#
# Iterates the agent's env entries. For each entry:
# - secret → ALWAYS prompt for the value (even if already set in
# this process env), export it into this process, and
# append `-e NAME` to <out_args_path> (one arg per
# line; a NAME with no `=value`).
# - interpolated→ read the host process env value of the named host var;
# if unset, die with the host-var name. Copy into this
# process under the target name and append `-e NAME` to
# <out_args_path>. Never written to disk.
# - literal → append `NAME=VALUE` to <env_file_path>; the resolver
# does NOT add anything to <out_args_path> for this entry
# (the caller adds a single `--env-file <env_file_path>`
# if the file is non-empty).
#
# The caller is responsible for:
# - creating <env_file_path> as an empty file with mode 600 under a
# mktemp dir,
# - creating <out_args_path> as an empty file,
# - cleaning both up on exit (trap),
# - reading <out_args_path> line-by-line into the docker-run argv.
#
# Returns 0 on success, dies on any error.
env_resolve() {
local manifest_file="${1:?env_resolve: missing manifest file}"
local agent="${2:?env_resolve: missing agent name}"
local env_file="${3:?env_resolve: missing env_file path}"
local out_args="${4:?env_resolve: missing out_args path}"
local name raw kind from prompt_body
while IFS= read -r name; do
[ -z "$name" ] && continue
raw="$(manifest_env_entry "$manifest_file" "$agent" "$name")"
kind="$(env_entry_kind "$raw")"
case "$kind" in
secret)
# Always prompt — never trust an already-exported host value.
# A "?"-prefixed entry in the manifest is the user's signal
# that this variable must be supplied interactively at launch
# time, even if a same-named var is already in the parent shell.
prompt_body="$(env_entry_secret_prompt "$raw")"
_read_secret_silent "$name" "$prompt_body"
# Export so child processes (docker run) inherit. `-e NAME` (no
# value) on docker run picks up from the parent process env.
export "${name?}"
printf -- '-e\n%s\n' "$name" >>"$out_args"
;;
interpolated)
from="$(env_entry_interpolated_from "$raw")"
# Treat interpolated values as potentially sensitive: never write
# them to disk and never put them on argv. Instead, copy the host
# var into THIS process under the target name (so Docker can
# inherit it via `-e NAME`), and emit `-e NAME` in the args file.
# The check below uses indirect expansion only to determine
# presence — no expansion of the value lands in any output.
if [ -z "${!from-}" ]; then
die "env entry ${name} is interpolated from \$${from}, but \$${from} is unset or empty in the host environment."
fi
# Copy via printf -v + indirect read. We use a brief local then
# immediately export under $name and scrub the local.
local _interp_val
_interp_val="${!from}"
printf -v "${name?}" '%s' "$_interp_val"
_interp_val=""
export "${name?}"
printf -- '-e\n%s\n' "$name" >>"$out_args"
;;
literal)
# Multi-line literal values are not supported by docker --env-file,
# so reject them up front rather than letting docker fail with a
# confusing message.
case "$raw" in
*$'\n'*) die "env entry ${name} (literal) contains a newline; docker --env-file cannot represent multi-line values." ;;
esac
printf '%s=%s\n' "$name" "$raw" >>"$env_file"
;;
esac
done < <(manifest_env_names "$manifest_file" "$agent")
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Tiny logging wrappers. Sourced by entry-point scripts.
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_LOG_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_LOG_SOURCED=1
# info <msg...> — informational message to stderr.
info() {
printf 'claude-bottle: %s\n' "$*" >&2
}
# warn <msg...> — warning to stderr.
warn() {
printf 'claude-bottle: warning: %s\n' "$*" >&2
}
# die <msg...> — error to stderr, exit 1.
die() {
printf 'claude-bottle: error: %s\n' "$*" >&2
exit 1
}
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# Manifest helpers. Read claude-bottle.json and pull the definition for a named
# agent.
#
# The manifest schema is documented in CLAUDE.md "Intended design". In
# short:
# {
# "boxes": {
# "<box-name>": {
# "env": { "<NAME>": <env-entry>, ... },
# "ssh": [ <ssh-entry>, ... ]
# },
# ...
# },
# "agents": {
# "<agent-name>": {
# "skills": [ "<skill-name>", ... ],
# "prompt": "<string>",
# "box": "<box-name>"
# },
# ...
# }
# }
#
# A box groups shared infrastructure (SSH keys, known hosts) that multiple
# agents can reference by name. The "box" field is required on every agent;
# cli.sh start rejects agents that omit it.
#
# An <env-entry> is a JSON string. Mode is selected by sentinel prefix:
# "?<message>" → prompt for the value at runtime, displaying <message>
# (bare "?" is allowed; uses a default prompt)
# "${HOST_VAR}" → interpolate from $HOST_VAR in the host process env
# any other str → literal (the JSON string is the value verbatim)
# The classification lives in env_resolve.sh (env_entry_kind); this
# module only fetches the raw string and validates that it is a string.
#
# Manifest parsing happens on the host with `jq`, never inside the
# container. We never echo env *values* here — only names. For literal
# entries the "name" and the value happen to be the same shape (both
# are JSON strings), so callers must take care not to log the result of
# manifest_env_entry.
#
# All functions (except manifest_resolve) take a manifest_file argument —
# the path to a resolved JSON file, typically produced by manifest_resolve.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_MANIFEST_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_MANIFEST_SOURCED=1
_iso_lib_manifest_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_manifest_dir}/log.sh"
# require_jq — fails with an install pointer if `jq` is not on PATH.
require_jq() {
if ! command -v jq >/dev/null 2>&1; then
info "jq is required on the host for claude-bottle manifest parsing."
info "macOS: brew install jq"
info "Linux: apt-get install jq (or your distro equivalent)"
die "jq not found"
fi
}
# manifest_resolve <cwd> — looks for claude-bottle.json in <cwd> and in $HOME,
# merges the two (cwd entries override home entries for the same agent name),
# and prints the merged JSON to stdout. Dies if neither file is found or if
# either found file is not valid JSON.
manifest_resolve() {
local cwd="${1:?manifest_resolve: missing cwd}"
local cwd_file="${cwd}/claude-bottle.json"
local home_file="${HOME}/claude-bottle.json"
local has_cwd=0 has_home=0
if [ -f "$cwd_file" ]; then
if ! jq -e . "$cwd_file" >/dev/null 2>&1; then
die "claude-bottle.json at ${cwd_file} is not valid JSON"
fi
has_cwd=1
fi
if [ -f "$home_file" ]; then
if ! jq -e . "$home_file" >/dev/null 2>&1; then
die "claude-bottle.json at ${home_file} is not valid JSON"
fi
has_home=1
fi
if [ "$has_cwd" = "0" ] && [ "$has_home" = "0" ]; then
die "no claude-bottle.json found in ${cwd} or ${HOME}"
elif [ "$has_cwd" = "1" ] && [ "$has_home" = "0" ]; then
cat "$cwd_file"
elif [ "$has_cwd" = "0" ] && [ "$has_home" = "1" ]; then
cat "$home_file"
else
# Merge: home is the base, cwd overrides on name conflict for both boxes and agents.
jq -s '{
"boxes": ((.[0].boxes // {}) * (.[1].boxes // {})),
"agents": ((.[0].agents // {}) * (.[1].agents // {}))
}' "$home_file" "$cwd_file"
fi
}
# manifest_has_agent <manifest_file> <name> — returns 0 if the agent key
# exists in the manifest, else 1.
manifest_has_agent() {
local manifest_file="${1:?manifest_has_agent: missing manifest file}"
local name="${2:?manifest_has_agent: missing agent name}"
jq -e --arg n "$name" '.agents | has($n)' "$manifest_file" >/dev/null 2>&1
}
# manifest_require_agent <manifest_file> <name> — like manifest_has_agent but
# dies with a useful message (and prints the available agent names) if the
# named agent is not defined.
manifest_require_agent() {
local manifest_file="${1:?manifest_require_agent: missing manifest file}"
local name="${2:?manifest_require_agent: missing agent name}"
if ! manifest_has_agent "$manifest_file" "$name"; then
local available
available="$(jq -r '.agents | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
if [ -n "$available" ]; then
die "agent '${name}' not defined in claude-bottle.json. Available: ${available}"
else
die "agent '${name}' not defined in claude-bottle.json (manifest is empty)."
fi
fi
}
# manifest_env_names <manifest_file> <name> — prints one env-var name per line
# on stdout (the keys of boxes[agent.box].env, in declaration order). No values.
# Prints nothing if the agent has no box or the box has no env.
manifest_env_names() {
local manifest_file="${1:?manifest_env_names: missing manifest file}"
local name="${2:?manifest_env_names: missing agent name}"
jq -r --arg n "$name" '
.agents[$n].box as $box |
if ($box == null or $box == "") then empty
else (.boxes[$box].env // {} | keys_unsorted[])
end
' "$manifest_file"
}
# manifest_env_entry <manifest_file> <agent> <env_name> — prints the raw
# string value of a single env entry on stdout (no quoting, no JSON
# encoding). Env entries live on the agent's box (boxes[agent.box].env).
# Used by env_resolve.sh, which classifies the result by sentinel. Dies
# if the agent has no box, or the entry is not a JSON string; the
# prompt-at-runtime form is "?<message>", not JSON null.
manifest_env_entry() {
local manifest_file="${1:?manifest_env_entry: missing manifest file}"
local agent="${2:?manifest_env_entry: missing agent name}"
local var="${3:?manifest_env_entry: missing env var name}"
local box
box="$(jq -r --arg a "$agent" '.agents[$a].box // ""' "$manifest_file")"
if [ -z "$box" ]; then
die "env entry ${var} for agent ${agent}: agent has no 'box' field"
fi
local entry_type
entry_type="$(jq -r --arg b "$box" --arg v "$var" '.boxes[$b].env[$v] | type' "$manifest_file")"
if [ "$entry_type" != "string" ]; then
die "env entry ${var} for agent ${agent} must be a JSON string (was ${entry_type}). Use \"?<message>\" for prompt-at-runtime."
fi
jq -r --arg b "$box" --arg v "$var" '.boxes[$b].env[$v]' "$manifest_file"
}
# manifest_skills <manifest_file> <name> — prints one skill name per line on
# stdout (the elements of agent.skills, in order).
manifest_skills() {
local manifest_file="${1:?manifest_skills: missing manifest file}"
local name="${2:?manifest_skills: missing agent name}"
jq -r --arg n "$name" '.agents[$n].skills // [] | .[]' "$manifest_file"
}
# manifest_prompt <manifest_file> <name> — prints the prompt string on stdout
# (no trailing newline manipulation; the raw value goes out). Empty string
# if not set.
manifest_prompt() {
local manifest_file="${1:?manifest_prompt: missing manifest file}"
local name="${2:?manifest_prompt: missing agent name}"
jq -r --arg n "$name" '.agents[$n].prompt // ""' "$manifest_file"
}
# manifest_agent_box <manifest_file> <name> — prints the box name referenced
# by the agent on stdout, or an empty string if the agent has no "box" field.
manifest_agent_box() {
local manifest_file="${1:?manifest_agent_box: missing manifest file}"
local name="${2:?manifest_agent_box: missing agent name}"
jq -r --arg n "$name" '.agents[$n].box // ""' "$manifest_file"
}
# manifest_has_box <manifest_file> <box_name> — returns 0 if the named box
# exists in the manifest, else 1.
manifest_has_box() {
local manifest_file="${1:?manifest_has_box: missing manifest file}"
local box_name="${2:?manifest_has_box: missing box name}"
jq -e --arg b "$box_name" '.boxes | has($b)' "$manifest_file" >/dev/null 2>&1
}
# manifest_require_box <manifest_file> <box_name> — like manifest_has_box but
# dies with a useful message (and prints available box names) if the box is
# not defined.
manifest_require_box() {
local manifest_file="${1:?manifest_require_box: missing manifest file}"
local box_name="${2:?manifest_require_box: missing box name}"
if ! manifest_has_box "$manifest_file" "$box_name"; then
local available
available="$(jq -r '.boxes // {} | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
if [ -n "$available" ]; then
die "box '${box_name}' not defined in claude-bottle.json. Available boxes: ${available}"
else
die "box '${box_name}' not defined in claude-bottle.json (no boxes defined)."
fi
fi
}
# manifest_box_ssh <manifest_file> <box_name> — prints one compact JSON object
# per line for each ssh entry in boxes[box_name].ssh. Prints nothing if the
# box has no ssh array or it is empty.
manifest_box_ssh() {
local manifest_file="${1:?manifest_box_ssh: missing manifest file}"
local box_name="${2:?manifest_box_ssh: missing box name}"
jq -c --arg b "$box_name" '.boxes[$b].ssh // [] | .[]' "$manifest_file"
}
# manifest_ssh <manifest_file> <name> — prints one compact JSON object per line
# for each ssh entry associated with the agent. SSH entries are resolved via
# the agent's "box" field: if set, entries come from boxes[box].ssh; if the
# agent has no "box" field, prints nothing.
# Each object has: Host, IdentityFile, Hostname, User, Port (required);
# KnownHostKey (optional).
manifest_ssh() {
local manifest_file="${1:?manifest_ssh: missing manifest file}"
local name="${2:?manifest_ssh: missing agent name}"
local box
box="$(jq -r --arg n "$name" '.agents[$n].box // ""' "$manifest_file")"
if [ -z "$box" ]; then
return 0
fi
jq -c --arg b "$box" '.boxes[$b].ssh // [] | .[]' "$manifest_file"
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Skill copier. Copies named skills from the host's ~/.claude/skills/<name>/
# into the running container's ~/.claude/skills/<name>/, preserving
# directory structure (no flattening, no archives), per CLAUDE.md
# "Intended design".
#
# Scope of THIS file (matches PRD 0002 "Open question 3" resolution):
# - host → container only.
# - if a referenced skill is missing on the host, fail with a clear
# message naming the skill. No silent skipping. The repo-side
# `skills/<name>/` snapshot and host↔repo diff prompt described in
# CLAUDE.md "Intended design" are deferred.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_SKILLS_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_SKILLS_SOURCED=1
_iso_lib_skills_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_skills_dir}/log.sh"
# Container-side home/skills paths. The Dockerfile sets the user to `node`
# (uid 1000) with home /home/node, so this is where claude-code looks.
CLAUDE_BOTTLE_CONTAINER_HOME="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}"
CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR="${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR:-${CLAUDE_BOTTLE_CONTAINER_HOME}/.claude/skills}"
# host_skill_dir <name> — prints the absolute host path for a skill.
host_skill_dir() {
local name="${1:?host_skill_dir: missing skill name}"
printf '%s/.claude/skills/%s' "${HOME:?HOME not set}" "$name"
}
# host_skill_exists <name> — returns 0 if the host has a skill directory
# at ~/.claude/skills/<name>/, else 1.
host_skill_exists() {
local name="${1:?host_skill_exists: missing skill name}"
[ -d "$(host_skill_dir "$name")" ]
}
# require_host_skill <name> — dies with a clear message if the named
# skill is missing on the host. The error names the skill and the path
# checked.
require_host_skill() {
local name="${1:?require_host_skill: missing skill name}"
if ! host_skill_exists "$name"; then
die "skill '${name}' not found on host at $(host_skill_dir "$name"). Create it under ~/.claude/skills/, then re-run."
fi
}
# skills_validate_all <name1> [<name2> ...] — checks every named skill
# exists on the host, dies on the first one that does not. No copy yet.
# Use this BEFORE the confirmation prompt so the user does not get
# asked y/N for a plan that's already known to fail.
skills_validate_all() {
local n
for n in "$@"; do
require_host_skill "$n"
done
}
# skills_copy_into <container> <name1> [<name2> ...]
#
# For each named skill:
# 1. ensure ~/.claude/skills/ exists in the container (mkdir -p)
# 2. `docker cp <host_skill_dir>/. <container>:<container_skills>/<name>/`
# — the trailing `/.` on the source preserves directory structure
# and copies the contents into a freshly-created destination dir,
# avoiding the docker-cp quirk where copying `dir` (no slash) into
# an existing `dest/` would nest as `dest/dir/`.
#
# The destination directory is removed first if it already exists, so
# repeated calls produce a deterministic state.
skills_copy_into() {
local container="${1:?skills_copy_into: missing container name}"
shift
if [ "$#" -eq 0 ]; then
return 0
fi
# Ensure the target parent dir exists in the container. This is a
# no-op if the Dockerfile already created it, but cheap and defensive.
docker exec "$container" mkdir -p "${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR}" >/dev/null
local n src dst
for n in "$@"; do
src="$(host_skill_dir "$n")"
if [ ! -d "$src" ]; then
die "skill '${n}' disappeared from host between validation and copy at ${src}."
fi
dst="${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR}/${n}"
info "copying skill ${n} into ${container}:${dst}"
# Wipe any prior copy so we're deterministic, then create empty dst
# and copy contents-of-src into it via the `/.` source-suffix trick.
docker exec "$container" rm -rf "$dst" >/dev/null
docker exec "$container" mkdir -p "$dst" >/dev/null
docker cp "${src}/." "${container}:${dst}/" >/dev/null
done
}
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# SSH helpers. Validates ssh entries from claude-bottle.json, then sets up SSH
# inside the container via a root-owned ssh-agent so the `node` user (Claude)
# can use the keys for SSH operations but cannot read the key bytes.
#
# Why an in-container agent (not bind-mounted from host): Docker Desktop on
# macOS does not forward Unix-domain socket connect() across the macOS↔Linux
# VM boundary — connect() returns ENOTSUP. Running ssh-agent inside the
# container sidesteps that entirely and keeps the same isolation guarantee.
#
# How the isolation works:
# - Keys are docker cp'd to /root/.claude-bottle-keys/ (mode 700, root-owned).
# /root itself is mode 700 in the node:22-slim base image, so node (uid
# 1000) cannot even traverse into it.
# - ssh-agent runs as root, listening on /run/claude-bottle-agent.sock. Each
# key is loaded with ssh-add, then the key file is deleted. The bytes
# now live only in the agent process's memory.
# - The agent socket stays root-only. OpenSSH's ssh-agent enforces a
# SO_PEERCRED-based UID match: it rejects every connection whose peer
# euid is neither 0 nor the agent's own uid. chmod'ing the socket open
# does *not* defeat this — the kernel-level check still rejects node.
# - To bridge that, a root-owned socat forwarder listens on
# /run/claude-bottle-agent-public.sock (mode 666) and proxies bytes to the
# real agent socket. From the agent's view, socat (uid 0) is the peer
# and passes the UID check. From node's view, the public socket is the
# accessible endpoint.
# - node cannot ptrace the root-owned agent or socat (no CAP_SYS_PTRACE in
# a default container), so /proc/<pid>/mem is off-limits and the key
# bytes never leave root-owned memory.
# - ~/.ssh/config in node's home points each Host at the public socket via
# IdentityAgent, so SSH always reaches the forwarder regardless of
# SSH_AUTH_SOCK.
#
# Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty for
# passphrases, but our docker exec has no TTY. Adding SSH_ASKPASS support is
# possible but not implemented in v1.
#
# Each ssh entry is a JSON object (jq -c) with keys:
# Host SSH Host alias
# IdentityFile absolute path to the private key file on the host
# Hostname the actual hostname or IP
# User SSH username
# Port SSH port (number)
# KnownHostKey (optional) host public key — written to known_hosts under
# both the Host alias and the Hostname so the lookup works
# whether SSH connects via the alias or the raw IP/host.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_SSH_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_SSH_SOURCED=1
_iso_lib_ssh_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_ssh_dir}/log.sh"
# ssh_validate_entries <json_object>... — checks that each entry has the
# required fields and that its IdentityFile exists on the host. Dies on the
# first problem.
ssh_validate_entries() {
local entry name key
for entry in "$@"; do
name="$(printf '%s' "$entry" | jq -r '.Host // empty')"
key="$(printf '%s' "$entry" | jq -r '.IdentityFile // empty')"
[ -n "$name" ] || die "ssh entry missing required field 'Host': ${entry}"
[ -n "$key" ] || die "ssh entry '${name}' missing required field 'IdentityFile'"
# Expand a leading ~ so callers can use ~/... paths.
key="${key/#\~/$HOME}"
[ -f "$key" ] || die "ssh key file not found for host '${name}': ${key}"
done
}
# ssh_setup <container> <stage_dir> <json_object>... — sets up SSH in the
# container so node (Claude) can authenticate using each entry's key without
# the key file being readable by node.
#
# Lifecycle:
# 1. Create ~/.ssh (700) for node and /root/.claude-bottle-keys (700) for root.
# 2. docker cp each key into /root/.claude-bottle-keys/, chown root, chmod 600.
# 3. Boot ssh-agent at /run/claude-bottle-agent.sock (root-only), ssh-add each
# key, delete the key file, rmdir the keys staging dir.
# 4. Boot a root-owned socat forwarder on /run/claude-bottle-agent-public.sock
# (mode 666) proxying to the agent socket. Bridges the UID-match check
# that would otherwise reject node's connections (see file header).
# 5. Install ~/.ssh/config (IdentityAgent → public socket) and
# ~/.ssh/known_hosts under node's home.
ssh_setup() {
local container="${1:?ssh_setup: missing container}"
local stage_dir="${2:?ssh_setup: missing stage dir}"
shift 2
local container_home="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}"
local container_ssh="${container_home}/.ssh"
local agent_socket="/run/claude-bottle-agent.sock"
local public_socket="/run/claude-bottle-agent-public.sock"
local keys_dir="/root/.claude-bottle-keys"
# ~/.ssh for node (700, owned by node).
docker exec -u 0 "$container" mkdir -p "$container_ssh" >/dev/null
docker exec -u 0 "$container" chown node:node "$container_ssh" >/dev/null
docker exec -u 0 "$container" chmod 700 "$container_ssh" >/dev/null
# /root/.claude-bottle-keys for root (700, root-owned). /root is already 700
# in node:22-slim, so node can't traverse here either way; setting both
# layers keeps the intent explicit.
docker exec -u 0 "$container" mkdir -p "$keys_dir" >/dev/null
docker exec -u 0 "$container" chown root:root "$keys_dir" >/dev/null
docker exec -u 0 "$container" chmod 700 "$keys_dir" >/dev/null
local config_file="${stage_dir}/ssh_config"
local known_hosts_file="${stage_dir}/ssh_known_hosts"
: > "$config_file"
chmod 600 "$config_file"
: > "$known_hosts_file"
chmod 600 "$known_hosts_file"
local entry name key hostname user port known_host_key key_basename container_key_path
local container_key_paths=()
for entry in "$@"; do
name="$(printf '%s' "$entry" | jq -r '.Host')"
key="$(printf '%s' "$entry" | jq -r '.IdentityFile')"
hostname="$(printf '%s' "$entry" | jq -r '.Hostname')"
user="$(printf '%s' "$entry" | jq -r '.User')"
port="$(printf '%s' "$entry" | jq -r '.Port')"
known_host_key="$(printf '%s' "$entry" | jq -r '.KnownHostKey // empty')"
key="${key/#\~/$HOME}"
key_basename="$(basename "$key")"
container_key_path="${keys_dir}/${key_basename}"
info "copying ssh key for '${name}' -> ${container} (root-only staging)"
docker cp "$key" "${container}:${container_key_path}" >/dev/null
docker exec -u 0 "$container" chown root:root "$container_key_path" >/dev/null
docker exec -u 0 "$container" chmod 600 "$container_key_path" >/dev/null
container_key_paths+=("$container_key_path")
# No IdentityFile — IdentityAgent points SSH at the public (forwarded)
# socket. Pointing at the real agent socket directly would be rejected
# by ssh-agent's UID-match check (see file header).
printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n\n' \
"$name" "$hostname" "$user" "$port" "$public_socket" >> "$config_file"
if [ -n "$known_host_key" ]; then
# Write under both the Host alias and the Hostname so SSH finds the key
# whether the connection uses the alias (`ssh <name>`) or a raw IP/host
# (e.g. git remote URLs that bypass the alias). Skip the duplicate when
# they're already the same string.
if [ "$port" = "22" ]; then
printf '%s %s\n' "$name" "$known_host_key" >> "$known_hosts_file"
[ "$hostname" != "$name" ] && printf '%s %s\n' "$hostname" "$known_host_key" >> "$known_hosts_file"
else
printf '[%s]:%s %s\n' "$name" "$port" "$known_host_key" >> "$known_hosts_file"
[ "$hostname" != "$name" ] && printf '[%s]:%s %s\n' "$hostname" "$port" "$known_host_key" >> "$known_hosts_file"
fi
fi
done
# Boot the agent, load each key, delete the key files, then start the
# root-owned socat forwarder that exposes a node-accessible socket. One
# docker exec so the whole sequence is atomic — if any step fails (e.g.
# passphrase-protected key), set -e dies before we leave behind a
# half-initialized agent.
info "starting in-container ssh-agent at ${agent_socket} (forwarded via ${public_socket})"
local setup_script="set -eu
ssh-agent -a ${agent_socket} >/dev/null
"
local kp
for kp in "${container_key_paths[@]}"; do
setup_script+="SSH_AUTH_SOCK=${agent_socket} ssh-add ${kp}
rm -f ${kp}
"
done
setup_script+="rmdir ${keys_dir} 2>/dev/null || true
# Start the forwarder. Detach from the calling shell so it survives this
# docker exec returning. socat (running as root) connects to the agent on
# node's behalf; the agent's UID-match check sees uid 0 and accepts.
nohup socat UNIX-LISTEN:${public_socket},fork,reuseaddr,mode=666 UNIX-CONNECT:${agent_socket} </dev/null >/dev/null 2>&1 &
# Wait briefly for the forwarder to bind. Without this, an SSH client that
# fires immediately after this script returns can race the listener and hit
# ENOENT/ECONNREFUSED on the public socket.
i=0
while [ \$i -lt 20 ]; do
[ -S ${public_socket} ] && break
i=\$((i + 1))
sleep 0.1
done
[ -S ${public_socket} ] || { echo 'claude-bottle: socat forwarder failed to bind ${public_socket}' >&2; exit 1; }
"
docker exec -u 0 "$container" sh -c "$setup_script"
info "writing ${container_ssh}/config"
docker cp "$config_file" "${container}:${container_ssh}/config" >/dev/null
docker exec -u 0 "$container" chown node:node "${container_ssh}/config" >/dev/null
docker exec -u 0 "$container" chmod 600 "${container_ssh}/config" >/dev/null
if [ -s "$known_hosts_file" ]; then
info "writing ${container_ssh}/known_hosts"
docker cp "$known_hosts_file" "${container}:${container_ssh}/known_hosts" >/dev/null
docker exec -u 0 "$container" chown node:node "${container_ssh}/known_hosts" >/dev/null
docker exec -u 0 "$container" chmod 600 "${container_ssh}/known_hosts" >/dev/null
fi
}