Initial commit
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user