refactor: convert project from bash to Python
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).
- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #2.
This commit is contained in:
+40
-20
@@ -1,25 +1,45 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env python3
|
||||||
# Enforce Conventional Commits on the first line of the commit message.
|
"""Enforce Conventional Commits on the first line of the commit message.
|
||||||
# https://www.conventionalcommits.org/en/v1.0.0/
|
https://www.conventionalcommits.org/en/v1.0.0/
|
||||||
#
|
|
||||||
# Activate per clone with:
|
|
||||||
# git config core.hooksPath .githooks
|
|
||||||
|
|
||||||
set -euo pipefail
|
Activate per clone with:
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
"""
|
||||||
|
|
||||||
msg_file="${1:?commit-msg: missing message file path}"
|
from __future__ import annotations
|
||||||
first_line="$(awk 'NR==1{print; exit}' "$msg_file")"
|
|
||||||
|
|
||||||
case "$first_line" in
|
import re
|
||||||
"Merge "*|"Revert "*|"fixup! "*|"squash! "*|"amend! "*) exit 0 ;;
|
import sys
|
||||||
esac
|
from pathlib import Path
|
||||||
|
|
||||||
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?!?: .+'
|
ALLOWED_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ")
|
||||||
|
PATTERN = re.compile(
|
||||||
|
r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
|
||||||
|
r"(\([a-z0-9._-]+\))?!?: .+"
|
||||||
|
)
|
||||||
|
|
||||||
if ! printf '%s' "$first_line" | grep -qE "$pattern"; then
|
|
||||||
printf 'commit-msg: aborting — message does not follow Conventional Commits.\n' >&2
|
def main(argv: list[str]) -> int:
|
||||||
printf ' expected: <type>[(<scope>)][!]: <description>\n' >&2
|
if len(argv) < 1:
|
||||||
printf ' types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert\n' >&2
|
print("commit-msg: missing message file path", file=sys.stderr)
|
||||||
printf ' got: %s\n' "$first_line" >&2
|
return 1
|
||||||
exit 1
|
|
||||||
fi
|
msg_file = Path(argv[0])
|
||||||
|
text = msg_file.read_text(encoding="utf-8", errors="replace")
|
||||||
|
first_line = text.splitlines()[0] if text else ""
|
||||||
|
|
||||||
|
if any(first_line.startswith(p) for p in ALLOWED_PREFIXES):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not PATTERN.match(first_line):
|
||||||
|
sys.stderr.write("commit-msg: aborting — message does not follow Conventional Commits.\n")
|
||||||
|
sys.stderr.write(" expected: <type>[(<scope>)][!]: <description>\n")
|
||||||
|
sys.stderr.write(" types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert\n")
|
||||||
|
sys.stderr.write(f" got: {first_line}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""claude-bottle: Python implementation of the agent container launcher."""
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
"""Main CLI dispatcher.
|
||||||
|
|
||||||
|
Commands: build, cleanup, edit, info, init, list, start
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import docker as docker_mod
|
||||||
|
from . import network as network_mod
|
||||||
|
from . import pipelock
|
||||||
|
from . import skills as skills_mod
|
||||||
|
from . import ssh as ssh_mod
|
||||||
|
from .env_resolve import env_resolve
|
||||||
|
from .log import Die, die, info, warn
|
||||||
|
from .manifest import (
|
||||||
|
Manifest,
|
||||||
|
manifest_agent_bottle,
|
||||||
|
manifest_env_names,
|
||||||
|
manifest_prompt,
|
||||||
|
manifest_require_agent,
|
||||||
|
manifest_require_bottle,
|
||||||
|
manifest_resolve,
|
||||||
|
manifest_skills,
|
||||||
|
manifest_ssh,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROG = "cli.py"
|
||||||
|
USER_CWD = os.getcwd()
|
||||||
|
REPO_DIR = str(Path(__file__).resolve().parent.parent)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Subcommands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_build(_argv: list[str]) -> int:
|
||||||
|
docker_mod.require_docker()
|
||||||
|
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
|
||||||
|
docker_mod.build_image(image, REPO_DIR)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_info(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
|
||||||
|
parser.add_argument("name", help="agent name defined in claude-bottle.json")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
manifest = manifest_resolve(USER_CWD)
|
||||||
|
manifest_require_agent(manifest, args.name)
|
||||||
|
|
||||||
|
env_names = manifest_env_names(manifest, args.name)
|
||||||
|
skill_names = manifest_skills(manifest, args.name)
|
||||||
|
prompt_content = manifest_prompt(manifest, args.name)
|
||||||
|
prompt_first_line = prompt_content.splitlines()[0] if prompt_content else ""
|
||||||
|
|
||||||
|
bottle_name = manifest_agent_bottle(manifest, args.name)
|
||||||
|
ssh_entries = manifest_ssh(manifest, args.name)
|
||||||
|
|
||||||
|
print()
|
||||||
|
info(f"agent : {args.name}")
|
||||||
|
info(f"env (names only): {', '.join(env_names) if env_names else '(none)'}")
|
||||||
|
info(f"skills : {' '.join(skill_names) if skill_names else '(none)'}")
|
||||||
|
info(
|
||||||
|
f"prompt : {len(prompt_content)} chars; "
|
||||||
|
f"first line: {prompt_first_line or '(empty)'}"
|
||||||
|
)
|
||||||
|
if bottle_name:
|
||||||
|
info(f"bottle : {bottle_name}")
|
||||||
|
if ssh_entries:
|
||||||
|
for e in ssh_entries:
|
||||||
|
info(
|
||||||
|
f" ssh host : {e.get('Host')} "
|
||||||
|
f"(Hostname={e.get('Hostname')}, User={e.get('User')}, "
|
||||||
|
f"Port={e.get('Port')}, IdentityFile={e.get('IdentityFile')})"
|
||||||
|
)
|
||||||
|
if e.get("KnownHostKey"):
|
||||||
|
info(f" KnownHostKey: {e['KnownHostKey']}")
|
||||||
|
else:
|
||||||
|
info(" ssh hosts : (none)")
|
||||||
|
else:
|
||||||
|
info("bottle : (none)")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
|
||||||
|
parser.add_argument("scope", choices=["available", "active"])
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.scope == "available":
|
||||||
|
manifest = manifest_resolve(USER_CWD)
|
||||||
|
for name in (manifest.get("agents") or {}).keys():
|
||||||
|
print(name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
docker_mod.require_docker()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker", "ps",
|
||||||
|
"--filter", "name=^claude-bottle-",
|
||||||
|
"--format", "{{.Names}}\t{{.Status}}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
containers = (result.stdout or "").strip()
|
||||||
|
if not containers:
|
||||||
|
info("no active claude-bottle containers")
|
||||||
|
return 0
|
||||||
|
print()
|
||||||
|
for line in containers.splitlines():
|
||||||
|
name, _, status = line.partition("\t")
|
||||||
|
info(f"container: {name} status: {status}")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_cleanup(_argv: list[str]) -> int:
|
||||||
|
docker_mod.require_docker()
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--filter", "name=^claude-bottle-", "--format", "{{.Names}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
containers = (result.stdout or "").strip()
|
||||||
|
if not containers:
|
||||||
|
info("no active claude-bottle containers")
|
||||||
|
return 0
|
||||||
|
print(file=sys.stderr)
|
||||||
|
for name in containers.splitlines():
|
||||||
|
info(f"found: {name}")
|
||||||
|
print(file=sys.stderr)
|
||||||
|
sys.stderr.write("claude-bottle: remove all of the above? [y/N] ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
reply = _read_tty_line()
|
||||||
|
if reply not in ("y", "Y", "yes", "YES"):
|
||||||
|
info("aborted")
|
||||||
|
return 0
|
||||||
|
for name in containers.splitlines():
|
||||||
|
info(f"removing {name}")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
info("done")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
|
||||||
|
parser.add_argument("--remote-control", action="store_true")
|
||||||
|
parser.add_argument("name", help="agent name defined in claude-bottle.json")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
|
name = args.name
|
||||||
|
slug = docker_mod.slugify(name)
|
||||||
|
|
||||||
|
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
|
||||||
|
default_container = f"claude-bottle-{slug}"
|
||||||
|
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
|
||||||
|
|
||||||
|
runtime_image = image
|
||||||
|
derived_image = ""
|
||||||
|
if args.cwd:
|
||||||
|
derived_image = os.environ.get("CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}")
|
||||||
|
runtime_image = derived_image
|
||||||
|
|
||||||
|
docker_mod.require_docker()
|
||||||
|
manifest = manifest_resolve(USER_CWD)
|
||||||
|
manifest_require_agent(manifest, name)
|
||||||
|
|
||||||
|
container = pinned_container or default_container
|
||||||
|
suffix = 2
|
||||||
|
if pinned_container:
|
||||||
|
if docker_mod.container_exists(container):
|
||||||
|
die(
|
||||||
|
f"container '{container}' already exists "
|
||||||
|
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
|
||||||
|
f"Remove it with 'docker rm -f {container}' or unset the override."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
while docker_mod.container_exists(container):
|
||||||
|
container = f"{default_container}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
if suffix > 100:
|
||||||
|
die(
|
||||||
|
f"could not find a free container name after "
|
||||||
|
f"{default_container}-99; clean up old containers with "
|
||||||
|
f"'docker rm -f <name>'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Plan resolution (host-only, no container yet) ---
|
||||||
|
env_names = manifest_env_names(manifest, name)
|
||||||
|
|
||||||
|
# CLAUDE_BOTTLE_OAUTH_TOKEN → CLAUDE_CODE_OAUTH_TOKEN forwarding.
|
||||||
|
# Host-side token is always forwarded so every container can authenticate.
|
||||||
|
forward_oauth_token = bool(os.environ.get("CLAUDE_BOTTLE_OAUTH_TOKEN"))
|
||||||
|
display_env_names = list(env_names)
|
||||||
|
if forward_oauth_token:
|
||||||
|
display_env_names.append("CLAUDE_CODE_OAUTH_TOKEN")
|
||||||
|
|
||||||
|
skill_names = manifest_skills(manifest, name)
|
||||||
|
if skill_names:
|
||||||
|
skills_mod.skills_validate_all(skill_names)
|
||||||
|
|
||||||
|
bottle_name = manifest_agent_bottle(manifest, name)
|
||||||
|
if not bottle_name:
|
||||||
|
die(
|
||||||
|
f"agent '{name}' has no 'bottle' field. "
|
||||||
|
f"Add a bottle association to this agent in claude-bottle.json."
|
||||||
|
)
|
||||||
|
manifest_require_bottle(manifest, bottle_name)
|
||||||
|
|
||||||
|
ssh_entries = manifest_ssh(manifest, name)
|
||||||
|
if ssh_entries:
|
||||||
|
ssh_mod.ssh_validate_entries(ssh_entries)
|
||||||
|
|
||||||
|
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
|
||||||
|
env_file = stage_dir / "agent.env"
|
||||||
|
args_file = stage_dir / "docker-args"
|
||||||
|
prompt_file = stage_dir / "prompt.txt"
|
||||||
|
pipelock_yaml_filename = "pipelock.yaml"
|
||||||
|
pipelock_yaml = stage_dir / pipelock_yaml_filename
|
||||||
|
env_file.write_text("")
|
||||||
|
env_file.chmod(0o600)
|
||||||
|
args_file.write_text("")
|
||||||
|
prompt_file.write_text("")
|
||||||
|
prompt_file.chmod(0o600)
|
||||||
|
|
||||||
|
# cleanup state — populated as resources come up.
|
||||||
|
state: dict[str, str] = {
|
||||||
|
"container": "",
|
||||||
|
"pipelock": "",
|
||||||
|
"internal_network": "",
|
||||||
|
"egress_network": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def cleanup_all() -> None:
|
||||||
|
try:
|
||||||
|
if state["container"] and docker_mod.container_exists(state["container"]):
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", state["container"]],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if state["pipelock"]:
|
||||||
|
pipelock.pipelock_stop(slug)
|
||||||
|
if state["internal_network"]:
|
||||||
|
network_mod.network_remove(state["internal_network"])
|
||||||
|
if state["egress_network"]:
|
||||||
|
network_mod.network_remove(state["egress_network"])
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml)
|
||||||
|
allowlist_summary = pipelock.pipelock_allowlist_summary(manifest, bottle_name)
|
||||||
|
|
||||||
|
env_resolve(manifest, name, env_file, args_file)
|
||||||
|
|
||||||
|
prompt_content = manifest_prompt(manifest, name)
|
||||||
|
prompt_file.write_text(prompt_content)
|
||||||
|
prompt_first_line = prompt_content.splitlines()[0] if prompt_content else ""
|
||||||
|
|
||||||
|
# --- Plan + confirm ---
|
||||||
|
print(file=sys.stderr)
|
||||||
|
info(f"agent : {name}")
|
||||||
|
info(f"image : {image}")
|
||||||
|
if derived_image:
|
||||||
|
info(f"cwd : {USER_CWD} -> /home/node/workspace (derived: {derived_image})")
|
||||||
|
info(f"container : {container}")
|
||||||
|
info(f"stage dir : {stage_dir}")
|
||||||
|
info(
|
||||||
|
"env (names only): "
|
||||||
|
+ (", ".join(display_env_names) if display_env_names else "(none)")
|
||||||
|
)
|
||||||
|
info("skills : " + (" ".join(skill_names) if skill_names else "(none)"))
|
||||||
|
info(f"bottle : {bottle_name}")
|
||||||
|
if ssh_entries:
|
||||||
|
ssh_names = ", ".join(e.get("Host", "") for e in ssh_entries)
|
||||||
|
info(f" ssh hosts : {ssh_names}")
|
||||||
|
else:
|
||||||
|
info(" ssh hosts : (none)")
|
||||||
|
info(f" egress : {allowlist_summary}")
|
||||||
|
info(
|
||||||
|
f"prompt : {len(prompt_content)} chars; "
|
||||||
|
f"first line: {prompt_first_line or '(empty)'}"
|
||||||
|
)
|
||||||
|
info("remote-control : " + ("enabled" if args.remote_control else "disabled"))
|
||||||
|
print(file=sys.stderr)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
info("dry-run requested; not starting container.")
|
||||||
|
cleanup_all()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sys.stderr.write("claude-bottle: launch this agent? [y/N] ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
reply = _read_tty_line()
|
||||||
|
if reply not in ("y", "Y", "yes", "YES"):
|
||||||
|
info("aborted by user")
|
||||||
|
cleanup_all()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# --- Build & launch ---
|
||||||
|
docker_mod.build_image(image, REPO_DIR)
|
||||||
|
if derived_image:
|
||||||
|
docker_mod.build_image_with_cwd(derived_image, image, USER_CWD)
|
||||||
|
|
||||||
|
state["internal_network"] = network_mod.network_create_internal(slug)
|
||||||
|
state["egress_network"] = network_mod.network_create_egress(slug)
|
||||||
|
state["pipelock"] = pipelock.pipelock_start(
|
||||||
|
slug,
|
||||||
|
state["internal_network"],
|
||||||
|
state["egress_network"],
|
||||||
|
stage_dir,
|
||||||
|
pipelock_yaml_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_url = pipelock.pipelock_proxy_url(slug)
|
||||||
|
docker_args: list[str] = [
|
||||||
|
"--rm", "-d",
|
||||||
|
"--name", container,
|
||||||
|
"--network", state["internal_network"],
|
||||||
|
"-e", f"HTTPS_PROXY={proxy_url}",
|
||||||
|
"-e", f"HTTP_PROXY={proxy_url}",
|
||||||
|
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||||
|
]
|
||||||
|
if env_file.stat().st_size > 0:
|
||||||
|
docker_args.extend(["--env-file", str(env_file)])
|
||||||
|
|
||||||
|
# ARGS_FILE pairs (-e, NAME) line-by-line.
|
||||||
|
args_lines = args_file.read_text().splitlines()
|
||||||
|
i = 0
|
||||||
|
while i < len(args_lines):
|
||||||
|
flag = args_lines[i]
|
||||||
|
i += 1
|
||||||
|
if not flag:
|
||||||
|
continue
|
||||||
|
if i >= len(args_lines):
|
||||||
|
break
|
||||||
|
vname = args_lines[i]
|
||||||
|
i += 1
|
||||||
|
docker_args.extend([flag, vname])
|
||||||
|
|
||||||
|
if forward_oauth_token:
|
||||||
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
|
||||||
|
docker_args.extend(["-e", "CLAUDE_CODE_OAUTH_TOKEN"])
|
||||||
|
|
||||||
|
docker_args.extend([runtime_image, "sleep", "infinity"])
|
||||||
|
|
||||||
|
info(f"starting container {container} from {runtime_image}")
|
||||||
|
# Retry-on-name-conflict loop to mirror the bash version.
|
||||||
|
while True:
|
||||||
|
full_argv = ["docker", "run", *docker_args]
|
||||||
|
run_result = subprocess.run(full_argv, capture_output=True, text=True)
|
||||||
|
if run_result.returncode == 0:
|
||||||
|
state["container"] = container
|
||||||
|
break
|
||||||
|
err_text = run_result.stderr
|
||||||
|
if pinned_container or "is already in use" not in err_text:
|
||||||
|
sys.stderr.write(err_text + "\n")
|
||||||
|
die(f"docker run failed for container '{container}'")
|
||||||
|
if suffix > 100:
|
||||||
|
die(
|
||||||
|
f"could not find a free container name after "
|
||||||
|
f"{default_container}-99 retries; clean up old containers"
|
||||||
|
)
|
||||||
|
container = f"{default_container}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
# Replace --name slot in docker_args.
|
||||||
|
name_idx = docker_args.index("--name") + 1
|
||||||
|
docker_args[name_idx] = container
|
||||||
|
info(f"name conflict; retrying as {container}")
|
||||||
|
|
||||||
|
container_prompt_path = (
|
||||||
|
os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
|
+ "/.claude-bottle-prompt.txt"
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(prompt_file), f"{container}:{container_prompt_path}"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
# `docker cp` preserves host UID; re-own/mode as root in the container
|
||||||
|
# so node can read its own mode-600 prompt regardless of host UID.
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "chown", "node:node", container_prompt_path],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "chmod", "600", container_prompt_path],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if skill_names:
|
||||||
|
skills_mod.skills_copy_into(container, skill_names)
|
||||||
|
|
||||||
|
if ssh_entries:
|
||||||
|
proxy_host_port = pipelock.pipelock_proxy_host_port(slug)
|
||||||
|
ssh_mod.ssh_setup(container, stage_dir, proxy_host_port, ssh_entries)
|
||||||
|
|
||||||
|
if args.cwd and Path(USER_CWD, ".git").is_dir():
|
||||||
|
info(f"copying {USER_CWD}/.git -> {container}:/home/node/workspace/.git")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", f"{USER_CWD}/.git", f"{container}:/home/node/workspace/.git"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "chown", "-R", "node:node", "/home/node/workspace/.git"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
info(
|
||||||
|
"attaching interactive claude session "
|
||||||
|
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||||
|
)
|
||||||
|
claude_args = ["--dangerously-skip-permissions"]
|
||||||
|
if args.remote_control:
|
||||||
|
claude_args.append("--remote-control")
|
||||||
|
if prompt_content:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker", "exec", "-it", container, "claude",
|
||||||
|
*claude_args,
|
||||||
|
"--append-system-prompt-file", container_prompt_path,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-it", container, "claude", *claude_args]
|
||||||
|
)
|
||||||
|
info(f"session ended; container {container} will be removed")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
cleanup_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init / edit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_init(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} init", add_help=True)
|
||||||
|
parser.add_argument("scope", choices=["user", "project"])
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.scope == "user":
|
||||||
|
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
||||||
|
else:
|
||||||
|
target_file = Path(USER_CWD) / "claude-bottle.json"
|
||||||
|
|
||||||
|
print(file=sys.stderr)
|
||||||
|
info(f"claude-bottle init — adding a new agent to {target_file}")
|
||||||
|
print(file=sys.stderr)
|
||||||
|
|
||||||
|
# Agent name
|
||||||
|
agent_name = ""
|
||||||
|
while not agent_name:
|
||||||
|
sys.stderr.write("Agent name: ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
agent_name = _read_tty_line().replace(" ", "-")
|
||||||
|
if not agent_name:
|
||||||
|
warn("agent name cannot be empty")
|
||||||
|
|
||||||
|
if not re.match(r"^[a-z0-9][a-z0-9-]*$", agent_name):
|
||||||
|
warn(
|
||||||
|
f"agent name '{agent_name}' contains non-slug characters; "
|
||||||
|
f"it will still work but may cause container naming issues"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing: dict[str, Any] = {}
|
||||||
|
if target_file.is_file():
|
||||||
|
try:
|
||||||
|
existing = json.loads(target_file.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
||||||
|
if agent_name in (existing.get("agents") or {}):
|
||||||
|
sys.stderr.write(
|
||||||
|
f'claude-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
|
||||||
|
)
|
||||||
|
sys.stderr.flush()
|
||||||
|
ow = _read_tty_line()
|
||||||
|
if ow not in ("y", "Y", "yes", "YES"):
|
||||||
|
info("aborted")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Skills
|
||||||
|
print(file=sys.stderr)
|
||||||
|
sys.stderr.write("Skills (space or comma separated, or Enter for none): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
skills_input = _read_tty_line()
|
||||||
|
skill_list: list[str] = []
|
||||||
|
if skills_input:
|
||||||
|
cleaned = skills_input.replace(",", " ")
|
||||||
|
skill_list = [s for s in cleaned.split() if s]
|
||||||
|
|
||||||
|
# Prompt
|
||||||
|
print(file=sys.stderr)
|
||||||
|
info("System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):")
|
||||||
|
prompt_lines: list[str] = []
|
||||||
|
while True:
|
||||||
|
line = _read_tty_line()
|
||||||
|
if line == ".":
|
||||||
|
break
|
||||||
|
prompt_lines.append(line)
|
||||||
|
prompt_content = "\n".join(prompt_lines)
|
||||||
|
|
||||||
|
# Bottle association
|
||||||
|
print(file=sys.stderr)
|
||||||
|
sys.stderr.write("Associate this agent with a bottle? [y/N] ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
bottle_yn = _read_tty_line()
|
||||||
|
bottle_name = ""
|
||||||
|
bottle_env: dict[str, str] = {}
|
||||||
|
bottle_ssh: list[dict[str, Any]] = []
|
||||||
|
bottle_exists_already = False
|
||||||
|
if bottle_yn in ("y", "Y", "yes", "YES"):
|
||||||
|
while not bottle_name:
|
||||||
|
sys.stderr.write(" Bottle name: ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
bottle_name = _read_tty_line().replace(" ", "-")
|
||||||
|
if not bottle_name:
|
||||||
|
warn("bottle name cannot be empty")
|
||||||
|
|
||||||
|
if bottle_name in (existing.get("bottles") or {}):
|
||||||
|
bottle_exists_already = True
|
||||||
|
info(f"Bottle '{bottle_name}' already exists in {target_file}; agent will reference it.")
|
||||||
|
else:
|
||||||
|
info(f"Creating new bottle '{bottle_name}'.")
|
||||||
|
bottle_env = _prompt_for_env_vars()
|
||||||
|
sys.stderr.write(" Add SSH host entries to this bottle? [y/N] ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
ssh_yn = _read_tty_line()
|
||||||
|
if ssh_yn in ("y", "Y", "yes", "YES"):
|
||||||
|
bottle_ssh = _prompt_for_ssh_entries()
|
||||||
|
|
||||||
|
# Build agent JSON
|
||||||
|
agent_def: dict[str, Any] = {"skills": skill_list, "prompt": prompt_content}
|
||||||
|
if bottle_name:
|
||||||
|
agent_def["bottle"] = bottle_name
|
||||||
|
|
||||||
|
merged: dict[str, Any] = {
|
||||||
|
"bottles": dict(existing.get("bottles") or {}),
|
||||||
|
"agents": dict(existing.get("agents") or {}),
|
||||||
|
}
|
||||||
|
if bottle_name and not bottle_exists_already:
|
||||||
|
merged["bottles"][bottle_name] = {"env": bottle_env, "ssh": bottle_ssh}
|
||||||
|
merged["agents"][agent_name] = agent_def
|
||||||
|
|
||||||
|
target_file.write_text(json.dumps(merged, indent=2) + "\n")
|
||||||
|
info(f"Agent '{agent_name}' written to {target_file}.")
|
||||||
|
info(f"Run '{PROG} info {agent_name}' to verify.")
|
||||||
|
print(file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_for_env_vars() -> dict[str, str]:
|
||||||
|
print(file=sys.stderr)
|
||||||
|
info("Env vars — enter each var name then its mode. Press Enter with no name to finish.")
|
||||||
|
info(" Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)")
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
while True:
|
||||||
|
print(file=sys.stderr)
|
||||||
|
sys.stderr.write(" Var name (or Enter to finish): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
vname = _read_tty_line()
|
||||||
|
if not vname:
|
||||||
|
break
|
||||||
|
sys.stderr.write(" Mode [secret/interpolated/literal] (default: secret): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
vmode = _read_tty_line() or "secret"
|
||||||
|
if vmode == "secret":
|
||||||
|
sys.stderr.write(f' Prompt message shown to user (default: "enter {vname}"): ')
|
||||||
|
sys.stderr.flush()
|
||||||
|
smsg = _read_tty_line()
|
||||||
|
value = f"?{smsg}" if smsg else "?"
|
||||||
|
elif vmode == "interpolated":
|
||||||
|
sys.stderr.write(f" Host env var to read from (default: {vname}): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
hvar = _read_tty_line() or vname
|
||||||
|
value = "${" + hvar + "}"
|
||||||
|
elif vmode == "literal":
|
||||||
|
sys.stderr.write(" Value: ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
value = _read_tty_line()
|
||||||
|
else:
|
||||||
|
warn(f"unknown mode '{vmode}'; using secret")
|
||||||
|
value = "?"
|
||||||
|
out[vname] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_for_ssh_entries() -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
while True:
|
||||||
|
print(file=sys.stderr)
|
||||||
|
sys.stderr.write(" SSH Host alias (or Enter to finish): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
shost = _read_tty_line()
|
||||||
|
if not shost:
|
||||||
|
break
|
||||||
|
sys.stderr.write(" Hostname (actual hostname or IP): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
shostname = _read_tty_line()
|
||||||
|
sys.stderr.write(" User: ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
suser = _read_tty_line()
|
||||||
|
sys.stderr.write(" Port (default: 22): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
sport_raw = _read_tty_line() or "22"
|
||||||
|
if not sport_raw.isdigit():
|
||||||
|
warn("port must be a number; defaulting to 22")
|
||||||
|
sport_raw = "22"
|
||||||
|
sport = int(sport_raw)
|
||||||
|
sys.stderr.write(" IdentityFile (path to private key on host): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
sidentity = _read_tty_line()
|
||||||
|
sys.stderr.write(" KnownHostKey (optional, Enter to skip): ")
|
||||||
|
sys.stderr.flush()
|
||||||
|
skhk = _read_tty_line()
|
||||||
|
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"Host": shost,
|
||||||
|
"Hostname": shostname,
|
||||||
|
"User": suser,
|
||||||
|
"Port": sport,
|
||||||
|
"IdentityFile": sidentity,
|
||||||
|
}
|
||||||
|
if skhk:
|
||||||
|
entry["KnownHostKey"] = skhk
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_edit(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog=f"{PROG} edit", add_help=True)
|
||||||
|
parser.add_argument("scope", choices=["user", "project"])
|
||||||
|
parser.add_argument("name")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.scope == "user":
|
||||||
|
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
||||||
|
else:
|
||||||
|
target_file = Path(USER_CWD) / "claude-bottle.json"
|
||||||
|
|
||||||
|
if not target_file.is_file():
|
||||||
|
die(f"{target_file} does not exist")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = json.loads(target_file.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
die(f"{target_file} is not valid JSON")
|
||||||
|
|
||||||
|
if args.name not in (doc.get("agents") or {}):
|
||||||
|
die(f"agent '{args.name}' not found in {target_file}")
|
||||||
|
|
||||||
|
line = 1
|
||||||
|
text = target_file.read_text().splitlines()
|
||||||
|
needle = f'"{args.name}"'
|
||||||
|
for idx, l in enumerate(text, start=1):
|
||||||
|
if needle in l:
|
||||||
|
line = idx
|
||||||
|
break
|
||||||
|
os.execvp("vim", ["vim", f"+{line}", str(target_file)])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tty_line() -> str:
|
||||||
|
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
|
||||||
|
try:
|
||||||
|
with open("/dev/tty", "r") as tty:
|
||||||
|
return tty.readline().rstrip("\n")
|
||||||
|
except OSError:
|
||||||
|
return sys.stdin.readline().rstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
COMMANDS = {
|
||||||
|
"build": cmd_build,
|
||||||
|
"cleanup": cmd_cleanup,
|
||||||
|
"edit": cmd_edit,
|
||||||
|
"info": cmd_info,
|
||||||
|
"init": cmd_init,
|
||||||
|
"list": cmd_list,
|
||||||
|
"start": cmd_start,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def usage() -> None:
|
||||||
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
|
sys.stderr.write("Commands:\n")
|
||||||
|
sys.stderr.write(" build build (or rebuild) the claude-bottle Docker image\n")
|
||||||
|
sys.stderr.write(" cleanup stop and remove all active claude-bottle containers\n")
|
||||||
|
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||||
|
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||||
|
sys.stderr.write(" init interactively create a new agent and add it to claude-bottle.json\n")
|
||||||
|
sys.stderr.write(" list list available agents or active containers\n")
|
||||||
|
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
|
||||||
|
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
if argv is None:
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if not argv:
|
||||||
|
usage()
|
||||||
|
return 2
|
||||||
|
command = argv[0]
|
||||||
|
rest = argv[1:]
|
||||||
|
if command in ("-h", "--help"):
|
||||||
|
usage()
|
||||||
|
return 0
|
||||||
|
handler = COMMANDS.get(command)
|
||||||
|
if handler is None:
|
||||||
|
usage()
|
||||||
|
die(f"unknown command: {command}")
|
||||||
|
try:
|
||||||
|
return handler(rest) or 0
|
||||||
|
except Die as e:
|
||||||
|
return e.code if isinstance(e.code, int) else 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Docker helpers. Build/inspect primitives shared by the CLI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .log import die, info
|
||||||
|
|
||||||
|
|
||||||
|
def require_docker() -> None:
|
||||||
|
"""Fail with an install pointer if `docker` is not on PATH."""
|
||||||
|
if shutil.which("docker") is None:
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def image_exists(ref: str) -> bool:
|
||||||
|
return _silent_run(["docker", "image", "inspect", ref]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def container_exists(name: str) -> bool:
|
||||||
|
"""Returns True if a container (running or stopped) with the given
|
||||||
|
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
|
||||||
|
matches don't false-positive."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: str) -> str:
|
||||||
|
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
|
||||||
|
if not name:
|
||||||
|
die("slugify: missing name")
|
||||||
|
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
||||||
|
if not slug:
|
||||||
|
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def build_image(ref: str, context: str) -> None:
|
||||||
|
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||||
|
rebuilds cheap; running every time means Dockerfile edits land
|
||||||
|
without manual `docker rmi`."""
|
||||||
|
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||||
|
subprocess.run(["docker", "build", "-t", ref, context], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
_TRUST_DIALOG_NODE_SCRIPT = (
|
||||||
|
'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));'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image_with_cwd(derived: str, base: str, cwd: str) -> None:
|
||||||
|
"""Build a thin derived image that copies <cwd> into
|
||||||
|
/home/node/workspace and adds a trust-dialog entry for it."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not os.path.isdir(cwd):
|
||||||
|
die(f"cwd not found at {cwd}")
|
||||||
|
info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace")
|
||||||
|
dockerfile = (
|
||||||
|
f"FROM {base}\n"
|
||||||
|
f"COPY --chown=node:node . /home/node/workspace\n"
|
||||||
|
f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n"
|
||||||
|
f"WORKDIR /home/node/workspace\n"
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", derived, "-f", "-", cwd],
|
||||||
|
input=dockerfile,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _silent_run(cmd: Iterable[str]) -> int:
|
||||||
|
return subprocess.run(
|
||||||
|
list(cmd),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Env resolver. Walks the env entries for one agent 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.
|
||||||
|
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 string. Mode is selected by sentinel prefix:
|
||||||
|
"?" → secret (prompt at runtime). Bare "?" uses 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 string is the value verbatim)
|
||||||
|
|
||||||
|
Critical rules:
|
||||||
|
- NEVER echo, log, or interpolate the value of a secret or
|
||||||
|
interpolated env var. Both are treated as potentially sensitive:
|
||||||
|
nothing about their value (other than presence) ever lands on
|
||||||
|
disk, in a log line, or on argv.
|
||||||
|
- The env-file written for literals lives under mktemp -d with mode
|
||||||
|
600, removed by the caller's cleanup.
|
||||||
|
- Errors mention only the variable NAME, never any portion of the value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .log import die
|
||||||
|
from .manifest import Manifest, manifest_env_entry, manifest_env_names
|
||||||
|
|
||||||
|
_INTERPOLATED_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
||||||
|
|
||||||
|
|
||||||
|
def env_entry_kind(raw: str) -> str:
|
||||||
|
"""Returns 'secret', 'interpolated', or 'literal'."""
|
||||||
|
if raw.startswith("?"):
|
||||||
|
return "secret"
|
||||||
|
if _INTERPOLATED_RE.match(raw):
|
||||||
|
return "interpolated"
|
||||||
|
return "literal"
|
||||||
|
|
||||||
|
|
||||||
|
def env_entry_secret_prompt(raw: str) -> str:
|
||||||
|
"""For a secret entry, the prompt body (after the leading '?').
|
||||||
|
Empty for bare '?', meaning use default."""
|
||||||
|
if raw.startswith("?"):
|
||||||
|
return raw[1:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def env_entry_interpolated_from(raw: str) -> str:
|
||||||
|
"""For an interpolated entry, the host var name between '${' and '}'."""
|
||||||
|
m = _INTERPOLATED_RE.match(raw)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_secret_silent(name: str, prompt_body: str) -> str:
|
||||||
|
"""Read a secret value from the controlling tty without echoing.
|
||||||
|
The "(input hidden): " tail is always appended; manifest authors
|
||||||
|
write only the message text."""
|
||||||
|
if not (sys.stdin.isatty() or sys.stderr.isatty()):
|
||||||
|
# Fall back to /dev/tty so this still works when stdin is a pipe.
|
||||||
|
try:
|
||||||
|
tty = open("/dev/tty", "r+")
|
||||||
|
except OSError:
|
||||||
|
die(
|
||||||
|
f"cannot prompt for secret '{name}': no tty available. "
|
||||||
|
f"Run from an interactive shell."
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
f"{prompt_body} (input hidden): "
|
||||||
|
if prompt_body
|
||||||
|
else f"claude-bottle: secret value for {name} (input hidden): "
|
||||||
|
)
|
||||||
|
value = getpass.getpass(prompt, stream=tty)
|
||||||
|
tty.close()
|
||||||
|
else:
|
||||||
|
prompt = (
|
||||||
|
f"{prompt_body} (input hidden): "
|
||||||
|
if prompt_body
|
||||||
|
else f"claude-bottle: secret value for {name} (input hidden): "
|
||||||
|
)
|
||||||
|
value = getpass.getpass(prompt)
|
||||||
|
if not value:
|
||||||
|
die(f"empty value provided for secret '{name}'. Re-run and supply a value.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def env_resolve(
|
||||||
|
manifest: Manifest,
|
||||||
|
agent: str,
|
||||||
|
env_file: Path,
|
||||||
|
out_args: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Iterate the agent's env entries:
|
||||||
|
- secret: always prompt; export into this process; append `-e NAME` to out_args
|
||||||
|
- interpolated: copy host value; export under target name; append `-e NAME`
|
||||||
|
- literal: append `NAME=VALUE` to env_file
|
||||||
|
"""
|
||||||
|
for name in manifest_env_names(manifest, agent):
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
raw = manifest_env_entry(manifest, agent, name)
|
||||||
|
kind = env_entry_kind(raw)
|
||||||
|
if kind == "secret":
|
||||||
|
prompt_body = env_entry_secret_prompt(raw)
|
||||||
|
value = _read_secret_silent(name, prompt_body)
|
||||||
|
os.environ[name] = value
|
||||||
|
with out_args.open("a") as f:
|
||||||
|
f.write(f"-e\n{name}\n")
|
||||||
|
elif kind == "interpolated":
|
||||||
|
host_var = env_entry_interpolated_from(raw)
|
||||||
|
host_value = os.environ.get(host_var, "")
|
||||||
|
if not host_value:
|
||||||
|
die(
|
||||||
|
f"env entry {name} is interpolated from ${host_var}, "
|
||||||
|
f"but ${host_var} is unset or empty in the host environment."
|
||||||
|
)
|
||||||
|
os.environ[name] = host_value
|
||||||
|
with out_args.open("a") as f:
|
||||||
|
f.write(f"-e\n{name}\n")
|
||||||
|
else: # literal
|
||||||
|
if "\n" in raw:
|
||||||
|
die(
|
||||||
|
f"env entry {name} (literal) contains a newline; "
|
||||||
|
f"docker --env-file cannot represent multi-line values."
|
||||||
|
)
|
||||||
|
with env_file.open("a") as f:
|
||||||
|
f.write(f"{name}={raw}\n")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Tiny logging wrappers. All output goes to stderr."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def info(msg: str) -> None:
|
||||||
|
print(f"claude-bottle: {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
print(f"claude-bottle: warning: {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class Die(SystemExit):
|
||||||
|
"""Raised by die() so callers (and tests) can distinguish a deliberate
|
||||||
|
fatal exit from an unrelated SystemExit."""
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> "Die":
|
||||||
|
print(f"claude-bottle: error: {msg}", file=sys.stderr)
|
||||||
|
raise Die(1)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Manifest helpers. Read claude-bottle.json and pull the definition for a
|
||||||
|
named agent.
|
||||||
|
|
||||||
|
Schema (see CLAUDE.md "Intended design"):
|
||||||
|
{
|
||||||
|
"bottles": {
|
||||||
|
"<bottle-name>": {
|
||||||
|
"env": { "<NAME>": <env-entry>, ... },
|
||||||
|
"ssh": [ <ssh-entry>, ... ],
|
||||||
|
"egress": { "allowlist": [ "<hostname>", ... ] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"<agent-name>": {
|
||||||
|
"skills": [ "<skill-name>", ... ],
|
||||||
|
"prompt": "<string>",
|
||||||
|
"bottle": "<bottle-name>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Bottles group shared infrastructure (SSH keys, known hosts, egress allowlist)
|
||||||
|
that multiple agents can reference. Every agent must reference a bottle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .log import die
|
||||||
|
|
||||||
|
Manifest = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_resolve(cwd: str) -> Manifest:
|
||||||
|
"""Look for claude-bottle.json in <cwd> and in $HOME, deep-merge
|
||||||
|
them (cwd entries override home entries on key conflict for both
|
||||||
|
bottles and agents). Dies if neither file is found or either is
|
||||||
|
invalid JSON."""
|
||||||
|
cwd_file = Path(cwd) / "claude-bottle.json"
|
||||||
|
home_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
||||||
|
|
||||||
|
cwd_doc = _load_json_or_die(cwd_file) if cwd_file.is_file() else None
|
||||||
|
home_doc = _load_json_or_die(home_file) if home_file.is_file() else None
|
||||||
|
|
||||||
|
if cwd_doc is None and home_doc is None:
|
||||||
|
die(f"no claude-bottle.json found in {cwd} or {os.environ['HOME']}")
|
||||||
|
|
||||||
|
if cwd_doc is None:
|
||||||
|
return home_doc # type: ignore[return-value]
|
||||||
|
if home_doc is None:
|
||||||
|
return cwd_doc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bottles": {**(home_doc.get("bottles") or {}), **(cwd_doc.get("bottles") or {})},
|
||||||
|
"agents": {**(home_doc.get("agents") or {}), **(cwd_doc.get("agents") or {})},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_or_die(path: Path) -> Manifest:
|
||||||
|
try:
|
||||||
|
with path.open() as f:
|
||||||
|
doc = json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
die(f"claude-bottle.json at {path} is not valid JSON")
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
die(f"claude-bottle.json at {path} must be a JSON object")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_has_agent(manifest: Manifest, name: str) -> bool:
|
||||||
|
return name in (manifest.get("agents") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_require_agent(manifest: Manifest, name: str) -> None:
|
||||||
|
"""Like has_agent but dies with the available agent names listed."""
|
||||||
|
if manifest_has_agent(manifest, name):
|
||||||
|
return
|
||||||
|
available = ", ".join((manifest.get("agents") or {}).keys())
|
||||||
|
if available:
|
||||||
|
die(f"agent '{name}' not defined in claude-bottle.json. Available: {available}")
|
||||||
|
else:
|
||||||
|
die(f"agent '{name}' not defined in claude-bottle.json (manifest is empty).")
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_env_names(manifest: Manifest, name: str) -> list[str]:
|
||||||
|
"""Names (not values) of bottles[agent.bottle].env, in declaration
|
||||||
|
order. Empty list if the agent has no bottle or the bottle has no env."""
|
||||||
|
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||||
|
bottle_name = agent.get("bottle") or ""
|
||||||
|
if not bottle_name:
|
||||||
|
return []
|
||||||
|
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||||
|
return list((bottle.get("env") or {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_env_entry(manifest: Manifest, agent: str, var: str) -> str:
|
||||||
|
"""Raw string value of one env entry. Used by env_resolve, which
|
||||||
|
classifies the result by sentinel. Dies if the agent has no bottle,
|
||||||
|
or the entry is not a string."""
|
||||||
|
agent_def = (manifest.get("agents") or {}).get(agent) or {}
|
||||||
|
bottle_name = agent_def.get("bottle") or ""
|
||||||
|
if not bottle_name:
|
||||||
|
die(f"env entry {var} for agent {agent}: agent has no 'bottle' field")
|
||||||
|
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||||
|
env = bottle.get("env") or {}
|
||||||
|
value = env.get(var)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
actual = _json_type(value)
|
||||||
|
die(
|
||||||
|
f"env entry {var} for agent {agent} must be a JSON string "
|
||||||
|
f"(was {actual}). Use \"?<message>\" for prompt-at-runtime."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_skills(manifest: Manifest, name: str) -> list[str]:
|
||||||
|
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||||
|
return list(agent.get("skills") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_prompt(manifest: Manifest, name: str) -> str:
|
||||||
|
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||||
|
return agent.get("prompt") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_agent_bottle(manifest: Manifest, name: str) -> str:
|
||||||
|
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||||
|
return agent.get("bottle") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_has_bottle(manifest: Manifest, bottle_name: str) -> bool:
|
||||||
|
return bottle_name in (manifest.get("bottles") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_require_bottle(manifest: Manifest, bottle_name: str) -> None:
|
||||||
|
if manifest_has_bottle(manifest, bottle_name):
|
||||||
|
return
|
||||||
|
available = ", ".join((manifest.get("bottles") or {}).keys())
|
||||||
|
if available:
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' not defined in claude-bottle.json. "
|
||||||
|
f"Available bottles: {available}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
die(f"bottle '{bottle_name}' not defined in claude-bottle.json (no bottles defined).")
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_bottle_ssh(manifest: Manifest, bottle_name: str) -> list[dict[str, Any]]:
|
||||||
|
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||||
|
return list(bottle.get("ssh") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_bottle_egress_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||||
|
"""Hostnames in bottles[bottle_name].egress.allowlist. Dies if the
|
||||||
|
field is present but not an array. Per-element string typing is
|
||||||
|
re-checked at use-time in pipelock."""
|
||||||
|
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||||
|
allowlist = (bottle.get("egress") or {}).get("allowlist")
|
||||||
|
if allowlist is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(allowlist, list):
|
||||||
|
die(
|
||||||
|
f"bottle '{bottle_name}' egress.allowlist must be an array "
|
||||||
|
f"(was {_json_type(allowlist)})."
|
||||||
|
)
|
||||||
|
return list(allowlist)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_ssh(manifest: Manifest, agent_name: str) -> list[dict[str, Any]]:
|
||||||
|
"""SSH entries resolved via the agent's "bottle" field; empty if no bottle set."""
|
||||||
|
bottle_name = manifest_agent_bottle(manifest, agent_name)
|
||||||
|
if not bottle_name:
|
||||||
|
return []
|
||||||
|
return manifest_bottle_ssh(manifest, bottle_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_type(value: Any) -> str:
|
||||||
|
"""Mirror jq's type names for parity with the bash error messages."""
|
||||||
|
if value is None:
|
||||||
|
return "null"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "boolean"
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return "number"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return "string"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "array"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return "object"
|
||||||
|
return type(value).__name__
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Docker network plumbing for the per-agent egress-proxy topology.
|
||||||
|
|
||||||
|
The agent container sits on a Docker `--internal` network (no default
|
||||||
|
gateway). Pipelock straddles that network and a per-agent user-defined
|
||||||
|
bridge for upstream egress. We deliberately do NOT use Docker's legacy
|
||||||
|
`bridge` network because only user-defined bridges run Docker's
|
||||||
|
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com
|
||||||
|
and similar upstream hostnames.
|
||||||
|
|
||||||
|
Naming: claude-bottle-net-<slug> (internal),
|
||||||
|
claude-bottle-egress-<slug> (egress). Numeric suffix on conflict
|
||||||
|
(-2, -3, ..., capped at 100).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .log import die, info, warn
|
||||||
|
|
||||||
|
|
||||||
|
def network_name_for_slug(slug: str) -> str:
|
||||||
|
return f"claude-bottle-net-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def network_egress_name_for_slug(slug: str) -> str:
|
||||||
|
return f"claude-bottle-egress-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def network_exists(name: str) -> bool:
|
||||||
|
"""Uses `docker network inspect`, not `docker network ls -f name=...`,
|
||||||
|
because the latter does substring matching."""
|
||||||
|
return (
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "network", "inspect", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _network_create_with_prefix(base: str, internal: bool) -> str:
|
||||||
|
"""Create a per-agent Docker network whose name is <base> (with
|
||||||
|
-2, -3, ... appended on conflict, capped at 100). Returns the
|
||||||
|
resolved name."""
|
||||||
|
name = base
|
||||||
|
suffix = 2
|
||||||
|
while network_exists(name):
|
||||||
|
name = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
if suffix > 100:
|
||||||
|
die(
|
||||||
|
f"could not find a free network name after {base}-99; "
|
||||||
|
f"clean up old networks with 'docker network rm <name>'"
|
||||||
|
)
|
||||||
|
|
||||||
|
kind = "internal" if internal else "bridge (egress)"
|
||||||
|
args = ["docker", "network", "create"]
|
||||||
|
if internal:
|
||||||
|
args.append("--internal")
|
||||||
|
args.append(name)
|
||||||
|
info(f"creating {kind} network {name}")
|
||||||
|
result = subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
|
if result.returncode != 0:
|
||||||
|
flag = " --internal" if internal else ""
|
||||||
|
die(f"docker network create{flag} {name} failed")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def network_create_internal(slug: str) -> str:
|
||||||
|
"""Create a Docker `--internal` network for the agent. Returns the
|
||||||
|
resolved name."""
|
||||||
|
return _network_create_with_prefix(network_name_for_slug(slug), internal=True)
|
||||||
|
|
||||||
|
|
||||||
|
def network_create_egress(slug: str) -> str:
|
||||||
|
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`)
|
||||||
|
so the pipelock sidecar has working DNS for upstream hostnames."""
|
||||||
|
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
|
||||||
|
|
||||||
|
|
||||||
|
def network_attach(network: str, container: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "connect", network, container],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"docker network connect {network} {container} failed")
|
||||||
|
|
||||||
|
|
||||||
|
def network_remove(name: str) -> bool:
|
||||||
|
"""Idempotent: a missing network is treated as success so this can
|
||||||
|
be called from a teardown trap. Returns True if removal succeeded
|
||||||
|
(or the network was already gone)."""
|
||||||
|
if not network_exists(name):
|
||||||
|
return True
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "rm", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
warn(f"failed to remove network {name}; clean up with 'docker network rm {name}'")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""Pipelock sidecar lifecycle for the per-agent egress topology.
|
||||||
|
|
||||||
|
Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
|
||||||
|
forward proxy with hostname allowlisting + DLP scanning + URL-entropy
|
||||||
|
checks. One sidecar per agent, attached to the agent's --internal
|
||||||
|
network and a per-agent user-defined egress bridge. Combined with
|
||||||
|
HTTPS_PROXY/HTTP_PROXY pointing at the sidecar's service name, pipelock
|
||||||
|
is the only egress route the agent has.
|
||||||
|
|
||||||
|
Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .log import die, info, warn
|
||||||
|
from .manifest import Manifest, manifest_bottle_egress_allowlist, manifest_bottle_ssh
|
||||||
|
|
||||||
|
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||||
|
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
||||||
|
PIPELOCK_IMAGE = os.environ.get(
|
||||||
|
"CLAUDE_BOTTLE_PIPELOCK_IMAGE",
|
||||||
|
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Listening port for pipelock's forward proxy.
|
||||||
|
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
|
||||||
|
|
||||||
|
# Baked-in default allowlist for hosts Claude Code itself needs.
|
||||||
|
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
||||||
|
"api.anthropic.com",
|
||||||
|
"statsig.anthropic.com",
|
||||||
|
"sentry.io",
|
||||||
|
"claude.ai",
|
||||||
|
"platform.claude.com",
|
||||||
|
"downloads.claude.ai",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_container_name(slug: str) -> str:
|
||||||
|
return f"claude-bottle-pipelock-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_proxy_url(slug: str) -> str:
|
||||||
|
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_proxy_host_port(slug: str) -> str:
|
||||||
|
return f"{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Allowlist resolution --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_bottle_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||||
|
"""Hostnames in bottles[<bottle_name>].egress.allowlist. Validates
|
||||||
|
that each entry is a string."""
|
||||||
|
raw = manifest_bottle_egress_allowlist(manifest, bottle_name)
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
t = _json_type(entry)
|
||||||
|
die(f"bottle '{bottle_name}' egress.allowlist must contain only strings; found a '{t}' entry.")
|
||||||
|
return list(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_bottle_ssh_hostnames(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for entry in manifest_bottle_ssh(manifest, bottle_name):
|
||||||
|
h = entry.get("Hostname") or ""
|
||||||
|
if h:
|
||||||
|
out.append(h)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def is_ipv4_literal(s: str) -> bool:
|
||||||
|
"""Pipelock's SSRF check fires on resolved IP, so an IP-literal
|
||||||
|
Hostname goes to ssrf.ip_allowlist while a hostname goes to
|
||||||
|
trusted_domains."""
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
return bool(_IPV4_RE.match(s))
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_bottle_ssh_trusted_domains(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||||
|
return [h for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if not is_ipv4_literal(h)]
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_bottle_ssh_ip_cidrs(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||||
|
return [f"{h}/32" for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if is_ipv4_literal(h)]
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_effective_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||||
|
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
|
||||||
|
bottle.ssh[].Hostname. Sorted for stability."""
|
||||||
|
seen: dict[str, None] = {}
|
||||||
|
for h in DEFAULT_ALLOWLIST:
|
||||||
|
seen.setdefault(h, None)
|
||||||
|
for h in pipelock_bottle_allowlist(manifest, bottle_name):
|
||||||
|
if h:
|
||||||
|
seen.setdefault(h, None)
|
||||||
|
for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name):
|
||||||
|
if h:
|
||||||
|
seen.setdefault(h, None)
|
||||||
|
return sorted(seen.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str:
|
||||||
|
"""One-line summary for the y/N preflight display:
|
||||||
|
"<N> hosts allowed (host1, host2, host3, +M more)"."""
|
||||||
|
hosts = pipelock_effective_allowlist(manifest, bottle_name)
|
||||||
|
count = len(hosts)
|
||||||
|
if count == 0:
|
||||||
|
return "0 hosts allowed (none)"
|
||||||
|
show = count
|
||||||
|
more = 0
|
||||||
|
if count > 5:
|
||||||
|
show = 3
|
||||||
|
more = count - show
|
||||||
|
joined = ", ".join(hosts[:show])
|
||||||
|
if more > 0:
|
||||||
|
return f"{count} hosts allowed ({joined}, +{more} more)"
|
||||||
|
return f"{count} hosts allowed ({joined})"
|
||||||
|
|
||||||
|
|
||||||
|
# --- YAML generation -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_write_yaml(manifest: Manifest, bottle_name: str, out_path: Path) -> None:
|
||||||
|
"""Write a pipelock YAML config (mode 600) carrying:
|
||||||
|
- the effective allowlist (hostnames),
|
||||||
|
- a fixed listen port,
|
||||||
|
- strict mode + forward_proxy.enabled + DLP defaults + scan_env.
|
||||||
|
|
||||||
|
Deliberately contains no env values, no secrets, no per-agent
|
||||||
|
customization beyond the hostname list."""
|
||||||
|
allowlist = pipelock_effective_allowlist(manifest, bottle_name)
|
||||||
|
trusted = pipelock_bottle_ssh_trusted_domains(manifest, bottle_name)
|
||||||
|
ip_cidrs = pipelock_bottle_ssh_ip_cidrs(manifest, bottle_name)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("version: 1")
|
||||||
|
lines.append("mode: strict")
|
||||||
|
lines.append("enforce: true")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Hostnames the agent is allowed to reach. Effective list is")
|
||||||
|
lines.append("# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).")
|
||||||
|
lines.append("api_allowlist:")
|
||||||
|
for h in allowlist:
|
||||||
|
lines.append(f' - "{h}"')
|
||||||
|
lines.append("")
|
||||||
|
lines.append("forward_proxy:")
|
||||||
|
lines.append(" enabled: true")
|
||||||
|
lines.append("")
|
||||||
|
if trusted:
|
||||||
|
lines.append("trusted_domains:")
|
||||||
|
for td in trusted:
|
||||||
|
lines.append(f' - "{td}"')
|
||||||
|
lines.append("")
|
||||||
|
if ip_cidrs:
|
||||||
|
lines.append("ssrf:")
|
||||||
|
lines.append(" ip_allowlist:")
|
||||||
|
for cidr in ip_cidrs:
|
||||||
|
lines.append(f' - "{cidr}"')
|
||||||
|
lines.append("")
|
||||||
|
lines.append("dlp:")
|
||||||
|
lines.append(" include_defaults: true")
|
||||||
|
lines.append(" scan_env: true")
|
||||||
|
|
||||||
|
out_path.write_text("\n".join(lines) + "\n")
|
||||||
|
out_path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sidecar lifecycle -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_start(
|
||||||
|
slug: str,
|
||||||
|
internal_network: str,
|
||||||
|
egress_network: str,
|
||||||
|
yaml_dir: Path,
|
||||||
|
yaml_filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""Boot the pipelock sidecar:
|
||||||
|
1. `docker create` on the internal network with the canonical name
|
||||||
|
and argv `run --config /etc/pipelock.yaml --listen 0.0.0.0:<port>`.
|
||||||
|
2. `docker cp` the YAML config to /etc/pipelock.yaml in the
|
||||||
|
writable layer (parent dir must already exist; image is distroless).
|
||||||
|
3. Attach to the per-agent egress network.
|
||||||
|
4. `docker start`.
|
||||||
|
Returns the container name."""
|
||||||
|
name = pipelock_container_name(slug)
|
||||||
|
host_yaml = yaml_dir / yaml_filename
|
||||||
|
if not host_yaml.is_file():
|
||||||
|
die(f"pipelock yaml not found at {host_yaml}; pipelock_write_yaml must run first")
|
||||||
|
|
||||||
|
info(f"starting pipelock sidecar {name} on network {internal_network}")
|
||||||
|
|
||||||
|
create_args = [
|
||||||
|
"docker", "create",
|
||||||
|
"--name", name,
|
||||||
|
"--network", internal_network,
|
||||||
|
PIPELOCK_IMAGE,
|
||||||
|
"run", "--config", "/etc/pipelock.yaml",
|
||||||
|
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
|
||||||
|
]
|
||||||
|
if subprocess.run(create_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
|
||||||
|
die(f"failed to create pipelock sidecar {name}")
|
||||||
|
|
||||||
|
cp_result = subprocess.run(
|
||||||
|
["docker", "cp", str(host_yaml), f"{name}:/etc/pipelock.yaml"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if cp_result.returncode != 0:
|
||||||
|
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
die(f"failed to copy pipelock yaml into {name}: {cp_result.stderr.strip()}")
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "network", "connect", egress_network, name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode != 0:
|
||||||
|
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
die(f"failed to attach pipelock sidecar {name} to egress network {egress_network}")
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "start", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode != 0:
|
||||||
|
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
die(f"failed to start pipelock sidecar {name}")
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def pipelock_stop(slug: str) -> None:
|
||||||
|
"""Idempotent: missing container is success."""
|
||||||
|
name = pipelock_container_name(slug)
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "inspect", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode == 0:
|
||||||
|
if subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode != 0:
|
||||||
|
warn(f"failed to remove pipelock sidecar {name}; clean up with 'docker rm -f {name}'")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_type(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "null"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "boolean"
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return "number"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return "string"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "array"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return "object"
|
||||||
|
return type(value).__name__
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Skill copier: host's ~/.claude/skills/<name>/ -> container's
|
||||||
|
~/.claude/skills/<name>/, preserving directory structure."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .log import die, info
|
||||||
|
|
||||||
|
CONTAINER_HOME = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
|
CONTAINER_SKILLS_DIR = os.environ.get(
|
||||||
|
"CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{CONTAINER_HOME}/.claude/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def host_skill_dir(name: str) -> str:
|
||||||
|
home = os.environ.get("HOME")
|
||||||
|
if not home:
|
||||||
|
die("HOME not set")
|
||||||
|
return f"{home}/.claude/skills/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
def host_skill_exists(name: str) -> bool:
|
||||||
|
return os.path.isdir(host_skill_dir(name))
|
||||||
|
|
||||||
|
|
||||||
|
def require_host_skill(name: str) -> None:
|
||||||
|
if not host_skill_exists(name):
|
||||||
|
die(
|
||||||
|
f"skill '{name}' not found on host at {host_skill_dir(name)}. "
|
||||||
|
f"Create it under ~/.claude/skills/, then re-run."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def skills_validate_all(names: list[str]) -> None:
|
||||||
|
"""Use BEFORE the y/N so the user does not get asked about a plan
|
||||||
|
that's already known to fail."""
|
||||||
|
for n in names:
|
||||||
|
require_host_skill(n)
|
||||||
|
|
||||||
|
|
||||||
|
def skills_copy_into(container: str, names: list[str]) -> None:
|
||||||
|
"""For each named skill, ensure the parent dir exists, wipe any
|
||||||
|
prior copy, then `docker cp <host>/. <container>:<dst>/` so the
|
||||||
|
contents are copied into a freshly-created destination dir."""
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", container, "mkdir", "-p", CONTAINER_SKILLS_DIR],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for n in names:
|
||||||
|
src = host_skill_dir(n)
|
||||||
|
if not os.path.isdir(src):
|
||||||
|
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
|
||||||
|
dst = f"{CONTAINER_SKILLS_DIR}/{n}"
|
||||||
|
info(f"copying skill {n} into {container}:{dst}")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", container, "rm", "-rf", dst],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", container, "mkdir", "-p", dst],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", f"{src}/.", f"{container}:{dst}/"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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 can use the keys for SSH 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 VM
|
||||||
|
boundary — connect() returns ENOTSUP. Running ssh-agent inside the
|
||||||
|
container sidesteps that entirely.
|
||||||
|
|
||||||
|
Isolation:
|
||||||
|
- Keys live at /root/.claude-bottle-keys/ (mode 700, root-owned).
|
||||||
|
/root is mode 700 in node:22-slim, so node (uid 1000) can't even
|
||||||
|
traverse in.
|
||||||
|
- ssh-agent runs as root, listening on /run/claude-bottle-agent.sock.
|
||||||
|
Each key is loaded with ssh-add, then deleted; the bytes now live
|
||||||
|
only in the agent process's memory.
|
||||||
|
- ssh-agent's SO_PEERCRED-based UID match rejects every connection
|
||||||
|
whose peer euid is neither 0 nor the agent's. 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.
|
||||||
|
- node can't ptrace root-owned agent or socat, so /proc/<pid>/mem is
|
||||||
|
off-limits and key bytes never leave root-owned memory.
|
||||||
|
- ~/.ssh/config in node's home points each Host at the public socket
|
||||||
|
via IdentityAgent.
|
||||||
|
|
||||||
|
Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty
|
||||||
|
for passphrases, but our docker exec has no TTY.
|
||||||
|
|
||||||
|
Each ssh entry has keys: Host, IdentityFile, Hostname, User, Port
|
||||||
|
(required); KnownHostKey (optional).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .log import die, info
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_validate_entries(entries: list[dict[str, Any]]) -> None:
|
||||||
|
"""Each entry must have Host + IdentityFile, and the IdentityFile
|
||||||
|
must exist on the host (after expanding leading ~)."""
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.get("Host", "")
|
||||||
|
key = entry.get("IdentityFile", "")
|
||||||
|
if not name:
|
||||||
|
die(f"ssh entry missing required field 'Host': {entry}")
|
||||||
|
if not key:
|
||||||
|
die(f"ssh entry '{name}' missing required field 'IdentityFile'")
|
||||||
|
key = _expand_tilde(key)
|
||||||
|
if not os.path.isfile(key):
|
||||||
|
die(f"ssh key file not found for host '{name}': {key}")
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_setup(
|
||||||
|
container: str,
|
||||||
|
stage_dir: Path,
|
||||||
|
proxy_host_port: str,
|
||||||
|
entries: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Set up SSH in the container so node can authenticate using each
|
||||||
|
entry's key without the key file being readable by node."""
|
||||||
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
|
container_ssh = f"{container_home}/.ssh"
|
||||||
|
agent_socket = "/run/claude-bottle-agent.sock"
|
||||||
|
public_socket = "/run/claude-bottle-agent-public.sock"
|
||||||
|
keys_dir = "/root/.claude-bottle-keys"
|
||||||
|
|
||||||
|
# ~/.ssh for node (700, owned by node).
|
||||||
|
_docker_exec_root(container, ["mkdir", "-p", container_ssh])
|
||||||
|
_docker_exec_root(container, ["chown", "node:node", container_ssh])
|
||||||
|
_docker_exec_root(container, ["chmod", "700", container_ssh])
|
||||||
|
|
||||||
|
# /root/.claude-bottle-keys for root (700, root-owned).
|
||||||
|
_docker_exec_root(container, ["mkdir", "-p", keys_dir])
|
||||||
|
_docker_exec_root(container, ["chown", "root:root", keys_dir])
|
||||||
|
_docker_exec_root(container, ["chmod", "700", keys_dir])
|
||||||
|
|
||||||
|
config_file = stage_dir / "ssh_config"
|
||||||
|
known_hosts_file = stage_dir / "ssh_known_hosts"
|
||||||
|
config_file.write_text("")
|
||||||
|
config_file.chmod(0o600)
|
||||||
|
known_hosts_file.write_text("")
|
||||||
|
known_hosts_file.chmod(0o600)
|
||||||
|
|
||||||
|
proxy_host, _, proxy_port = proxy_host_port.partition(":")
|
||||||
|
|
||||||
|
container_key_paths: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
name = entry["Host"]
|
||||||
|
key = _expand_tilde(entry["IdentityFile"])
|
||||||
|
hostname = entry["Hostname"]
|
||||||
|
user = entry["User"]
|
||||||
|
port = str(entry["Port"])
|
||||||
|
known_host_key = entry.get("KnownHostKey", "")
|
||||||
|
|
||||||
|
key_basename = os.path.basename(key)
|
||||||
|
container_key_path = f"{keys_dir}/{key_basename}"
|
||||||
|
|
||||||
|
info(f"copying ssh key for '{name}' -> {container} (root-only staging)")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", key, f"{container}:{container_key_path}"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
_docker_exec_root(container, ["chown", "root:root", container_key_path])
|
||||||
|
_docker_exec_root(container, ["chmod", "600", container_key_path])
|
||||||
|
|
||||||
|
container_key_paths.append(container_key_path)
|
||||||
|
|
||||||
|
# ProxyCommand tunnels SSH through pipelock via HTTP CONNECT.
|
||||||
|
# %h / %p expand to this block's HostName / Port. socat's
|
||||||
|
# PROXY: mode does CONNECT host:port to the proxy.
|
||||||
|
block = (
|
||||||
|
f"Host {name}\n"
|
||||||
|
f" HostName {hostname}\n"
|
||||||
|
f" User {user}\n"
|
||||||
|
f" Port {port}\n"
|
||||||
|
f" IdentityAgent {public_socket}\n"
|
||||||
|
f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n"
|
||||||
|
f"\n"
|
||||||
|
)
|
||||||
|
with config_file.open("a") as f:
|
||||||
|
f.write(block)
|
||||||
|
|
||||||
|
if known_host_key:
|
||||||
|
entries_to_write: list[str] = []
|
||||||
|
if port == "22":
|
||||||
|
entries_to_write.append(f"{name} {known_host_key}\n")
|
||||||
|
if hostname != name:
|
||||||
|
entries_to_write.append(f"{hostname} {known_host_key}\n")
|
||||||
|
else:
|
||||||
|
entries_to_write.append(f"[{name}]:{port} {known_host_key}\n")
|
||||||
|
if hostname != name:
|
||||||
|
entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n")
|
||||||
|
with known_hosts_file.open("a") as f:
|
||||||
|
for e in entries_to_write:
|
||||||
|
f.write(e)
|
||||||
|
|
||||||
|
# Boot the agent, load each key, delete the key files, then start
|
||||||
|
# the root-owned socat forwarder. One docker exec so the whole
|
||||||
|
# sequence is atomic.
|
||||||
|
info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})")
|
||||||
|
setup_lines = [
|
||||||
|
"set -eu",
|
||||||
|
f"ssh-agent -a {agent_socket} >/dev/null",
|
||||||
|
]
|
||||||
|
for kp in container_key_paths:
|
||||||
|
setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}")
|
||||||
|
setup_lines.append(f"rm -f {kp}")
|
||||||
|
setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true")
|
||||||
|
# Forwarder: socat (uid 0) connects to the agent on node's behalf.
|
||||||
|
setup_lines.append(
|
||||||
|
f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 "
|
||||||
|
f"UNIX-CONNECT:{agent_socket} </dev/null >/dev/null 2>&1 &"
|
||||||
|
)
|
||||||
|
# Wait briefly for the forwarder to bind.
|
||||||
|
setup_lines.extend([
|
||||||
|
"i=0",
|
||||||
|
"while [ $i -lt 20 ]; do",
|
||||||
|
f" [ -S {public_socket} ] && break",
|
||||||
|
" i=$((i + 1))",
|
||||||
|
" sleep 0.1",
|
||||||
|
"done",
|
||||||
|
f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}",
|
||||||
|
])
|
||||||
|
setup_script = "\n".join(setup_lines) + "\n"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "sh", "-c", setup_script],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
info(f"writing {container_ssh}/config")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
_docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
|
||||||
|
_docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
|
||||||
|
|
||||||
|
if known_hosts_file.stat().st_size > 0:
|
||||||
|
info(f"writing {container_ssh}/known_hosts")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
_docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
|
||||||
|
_docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_exec_root(container: str, argv: list[str]) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, *argv],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_tilde(path: str) -> str:
|
||||||
|
if path.startswith("~"):
|
||||||
|
home = os.environ.get("HOME", "")
|
||||||
|
return home + path[1:]
|
||||||
|
return path
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""cli.py — manage claude-bottle containers.
|
||||||
|
|
||||||
|
usage: cli.py <command> [args...]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
build build (or rebuild) the claude-bottle Docker image.
|
||||||
|
cleanup stop and remove all active claude-bottle containers.
|
||||||
|
edit open an agent in vim for editing.
|
||||||
|
info print env, skills, and prompt details for a named agent.
|
||||||
|
init interactively create a new agent and add it to claude-bottle.json.
|
||||||
|
list list available agents or active containers.
|
||||||
|
start boot a sandboxed container for a named agent and attach an
|
||||||
|
interactive claude-code session. The container is torn down
|
||||||
|
when the session ends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from claude_bottle.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/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
@@ -1,33 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
#!/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
@@ -1,24 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}
|
|
||||||
-270
@@ -1,270 +0,0 @@
|
|||||||
#!/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:
|
|
||||||
# {
|
|
||||||
# "bottles": {
|
|
||||||
# "<bottle-name>": {
|
|
||||||
# "env": { "<NAME>": <env-entry>, ... },
|
|
||||||
# "ssh": [ <ssh-entry>, ... ],
|
|
||||||
# "egress": { "allowlist": [ "<hostname>", ... ] }
|
|
||||||
# },
|
|
||||||
# ...
|
|
||||||
# },
|
|
||||||
# "agents": {
|
|
||||||
# "<agent-name>": {
|
|
||||||
# "skills": [ "<skill-name>", ... ],
|
|
||||||
# "prompt": "<string>",
|
|
||||||
# "bottle": "<bottle-name>"
|
|
||||||
# },
|
|
||||||
# ...
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# A bottle groups shared infrastructure (SSH keys, known hosts, egress
|
|
||||||
# allowlist) that multiple agents can reference by name. The "bottle" field
|
|
||||||
# is required on every agent; cli.sh start rejects agents that omit it.
|
|
||||||
#
|
|
||||||
# The "egress" object is added in PRD 0001. Today it carries one key:
|
|
||||||
# - allowlist: array of hostnames the agent is allowed to reach. The
|
|
||||||
# effective allowlist at launch is this list UNIONED with the
|
|
||||||
# baked-in defaults for Claude Code's required hosts (see
|
|
||||||
# lib/pipelock.sh). Bottles with no "egress" block use defaults
|
|
||||||
# only. Future keys (mode, dlp, data_budget, ...) are reserved
|
|
||||||
# under the same object; v1 ignores anything we don't recognize.
|
|
||||||
#
|
|
||||||
# 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 bottles and agents.
|
|
||||||
jq -s '{
|
|
||||||
"bottles": ((.[0].bottles // {}) * (.[1].bottles // {})),
|
|
||||||
"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 bottles[agent.bottle].env, in declaration order). No values.
|
|
||||||
# Prints nothing if the agent has no bottle or the bottle 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].bottle as $bottle |
|
|
||||||
if ($bottle == null or $bottle == "") then empty
|
|
||||||
else (.bottles[$bottle].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 bottle (bottles[agent.bottle].env).
|
|
||||||
# Used by env_resolve.sh, which classifies the result by sentinel. Dies
|
|
||||||
# if the agent has no bottle, 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 bottle
|
|
||||||
bottle="$(jq -r --arg a "$agent" '.agents[$a].bottle // ""' "$manifest_file")"
|
|
||||||
if [ -z "$bottle" ]; then
|
|
||||||
die "env entry ${var} for agent ${agent}: agent has no 'bottle' field"
|
|
||||||
fi
|
|
||||||
local entry_type
|
|
||||||
entry_type="$(jq -r --arg b "$bottle" --arg v "$var" '.bottles[$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 "$bottle" --arg v "$var" '.bottles[$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_bottle <manifest_file> <name> — prints the bottle name referenced
|
|
||||||
# by the agent on stdout, or an empty string if the agent has no "bottle" field.
|
|
||||||
manifest_agent_bottle() {
|
|
||||||
local manifest_file="${1:?manifest_agent_bottle: missing manifest file}"
|
|
||||||
local name="${2:?manifest_agent_bottle: missing agent name}"
|
|
||||||
jq -r --arg n "$name" '.agents[$n].bottle // ""' "$manifest_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# manifest_has_bottle <manifest_file> <bottle_name> — returns 0 if the named bottle
|
|
||||||
# exists in the manifest, else 1.
|
|
||||||
manifest_has_bottle() {
|
|
||||||
local manifest_file="${1:?manifest_has_bottle: missing manifest file}"
|
|
||||||
local bottle_name="${2:?manifest_has_bottle: missing bottle name}"
|
|
||||||
jq -e --arg b "$bottle_name" '.bottles | has($b)' "$manifest_file" >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# manifest_require_bottle <manifest_file> <bottle_name> — like manifest_has_bottle but
|
|
||||||
# dies with a useful message (and prints available bottle names) if the bottle is
|
|
||||||
# not defined.
|
|
||||||
manifest_require_bottle() {
|
|
||||||
local manifest_file="${1:?manifest_require_bottle: missing manifest file}"
|
|
||||||
local bottle_name="${2:?manifest_require_bottle: missing bottle name}"
|
|
||||||
if ! manifest_has_bottle "$manifest_file" "$bottle_name"; then
|
|
||||||
local available
|
|
||||||
available="$(jq -r '.bottles // {} | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
|
|
||||||
if [ -n "$available" ]; then
|
|
||||||
die "bottle '${bottle_name}' not defined in claude-bottle.json. Available bottles: ${available}"
|
|
||||||
else
|
|
||||||
die "bottle '${bottle_name}' not defined in claude-bottle.json (no bottles defined)."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# manifest_bottle_ssh <manifest_file> <bottle_name> — prints one compact JSON object
|
|
||||||
# per line for each ssh entry in bottles[bottle_name].ssh. Prints nothing if the
|
|
||||||
# bottle has no ssh array or it is empty.
|
|
||||||
manifest_bottle_ssh() {
|
|
||||||
local manifest_file="${1:?manifest_bottle_ssh: missing manifest file}"
|
|
||||||
local bottle_name="${2:?manifest_bottle_ssh: missing bottle name}"
|
|
||||||
jq -c --arg b "$bottle_name" '.bottles[$b].ssh // [] | .[]' "$manifest_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# manifest_bottle_egress_allowlist <manifest_file> <bottle_name> — prints one
|
|
||||||
# hostname per line on stdout for the entries in
|
|
||||||
# bottles[bottle_name].egress.allowlist. Prints nothing if the field is missing
|
|
||||||
# or the array is empty. Validates only that the field, when present, is an
|
|
||||||
# array; per-element string typing is checked at use-time in lib/pipelock.sh
|
|
||||||
# so the validation lives next to the YAML generator that consumes it.
|
|
||||||
manifest_bottle_egress_allowlist() {
|
|
||||||
local manifest_file="${1:?manifest_bottle_egress_allowlist: missing manifest file}"
|
|
||||||
local bottle_name="${2:?manifest_bottle_egress_allowlist: missing bottle name}"
|
|
||||||
local field_type
|
|
||||||
field_type="$(jq -r --arg b "$bottle_name" '.bottles[$b].egress.allowlist | type' "$manifest_file" 2>/dev/null || echo "null")"
|
|
||||||
case "$field_type" in
|
|
||||||
array|null) : ;;
|
|
||||||
*) die "bottle '${bottle_name}' egress.allowlist must be an array (was ${field_type})." ;;
|
|
||||||
esac
|
|
||||||
jq -r --arg b "$bottle_name" '.bottles[$b].egress.allowlist // [] | .[]' "$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 "bottle" field: if set, entries come from bottles[bottle].ssh; if the
|
|
||||||
# agent has no "bottle" 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 bottle
|
|
||||||
bottle="$(jq -r --arg n "$name" '.agents[$n].bottle // ""' "$manifest_file")"
|
|
||||||
if [ -z "$bottle" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
jq -c --arg b "$bottle" '.bottles[$b].ssh // [] | .[]' "$manifest_file"
|
|
||||||
}
|
|
||||||
-182
@@ -1,182 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Docker network plumbing for the per-agent egress-proxy topology
|
|
||||||
# (PRD 0001).
|
|
||||||
#
|
|
||||||
# The egress design (see docs/research/pipelock-assessment.md
|
|
||||||
# §"Deployment topology") puts the agent container on a Docker
|
|
||||||
# `--internal` network — Docker omits the default gateway from
|
|
||||||
# `internal: true` networks at the iptables level inside the engine /
|
|
||||||
# LinuxKit VM, so the only address the agent can reach is the pipelock
|
|
||||||
# sidecar attached to the same network. The pipelock sidecar itself
|
|
||||||
# also needs egress to the upstream internet, so it is placed on a
|
|
||||||
# second (user-defined bridge) network as well. We deliberately do
|
|
||||||
# NOT use Docker's legacy `bridge` network for this: the legacy bridge
|
|
||||||
# has no embedded DNS resolver, so pipelock would be unable to resolve
|
|
||||||
# `api.anthropic.com` and Claude Code traffic would dead-end. Only
|
|
||||||
# user-defined bridges run Docker's built-in DNS, so we create one
|
|
||||||
# per agent.
|
|
||||||
#
|
|
||||||
# This module is the network-only half of that split: create / attach
|
|
||||||
# / teardown of both the per-agent internal network and the per-agent
|
|
||||||
# user-defined egress bridge, with no pipelock specifics. Keeping
|
|
||||||
# pipelock-agnostic helpers here means a future PRD can reuse them
|
|
||||||
# for a different sidecar (e.g. an iptables-only layer) without
|
|
||||||
# entangling the two concerns.
|
|
||||||
#
|
|
||||||
# Naming: claude-bottle-net-<slug> (internal),
|
|
||||||
# claude-bottle-egress-<slug> (egress). On conflict we append a
|
|
||||||
# numeric suffix (-2, -3, ...) to mirror the container-naming scheme
|
|
||||||
# in cli.sh, so two parallel starts of the same agent get distinct
|
|
||||||
# networks.
|
|
||||||
#
|
|
||||||
# Idempotent: safe to source multiple times.
|
|
||||||
|
|
||||||
if [ -n "${CLAUDE_BOTTLE_LIB_NETWORK_SOURCED:-}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
CLAUDE_BOTTLE_LIB_NETWORK_SOURCED=1
|
|
||||||
|
|
||||||
_iso_lib_network_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=./log.sh
|
|
||||||
. "${_iso_lib_network_dir}/log.sh"
|
|
||||||
|
|
||||||
# network_name_for_slug <slug> — prints the canonical internal-network
|
|
||||||
# name for a given agent slug. No conflict resolution; that lives in
|
|
||||||
# network_create_internal.
|
|
||||||
network_name_for_slug() {
|
|
||||||
local slug="${1:?network_name_for_slug: missing slug}"
|
|
||||||
printf 'claude-bottle-net-%s' "$slug"
|
|
||||||
}
|
|
||||||
|
|
||||||
# network_egress_name_for_slug <slug> — prints the canonical egress-network
|
|
||||||
# name for a given agent slug. No conflict resolution; that lives in
|
|
||||||
# network_create_egress.
|
|
||||||
network_egress_name_for_slug() {
|
|
||||||
local slug="${1:?network_egress_name_for_slug: missing slug}"
|
|
||||||
printf 'claude-bottle-egress-%s' "$slug"
|
|
||||||
}
|
|
||||||
|
|
||||||
# network_exists <name> — returns 0 if the named docker network exists,
|
|
||||||
# else 1. Uses `docker network inspect` (not `docker network ls -f name=...`)
|
|
||||||
# because the latter does substring matching, which would falsely report
|
|
||||||
# claude-bottle-net-foo as existing when only claude-bottle-net-foo-2 was
|
|
||||||
# present.
|
|
||||||
network_exists() {
|
|
||||||
local name="${1:?network_exists: missing network name}"
|
|
||||||
docker network inspect "$name" >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# _network_create_with_prefix <prefix> <internal: 0|1>
|
|
||||||
#
|
|
||||||
# Internal helper. Creates a per-agent Docker network whose name is
|
|
||||||
# <prefix> (with -2, -3, ... appended on conflict, capped at 100).
|
|
||||||
# When <internal> is 1, the network is created with `--internal` (no
|
|
||||||
# default gateway). When 0, it's a plain user-defined bridge with
|
|
||||||
# upstream connectivity. Echoes the resolved name on stdout.
|
|
||||||
_network_create_with_prefix() {
|
|
||||||
local base="${1:?_network_create_with_prefix: missing prefix}"
|
|
||||||
local internal_flag="${2:?_network_create_with_prefix: missing internal flag}"
|
|
||||||
|
|
||||||
local name="$base"
|
|
||||||
local _suffix=2
|
|
||||||
while network_exists "$name"; do
|
|
||||||
name="${base}-${_suffix}"
|
|
||||||
_suffix=$((_suffix + 1))
|
|
||||||
if [ "$_suffix" -gt 100 ]; then
|
|
||||||
die "could not find a free network name after ${base}-99; clean up old networks with 'docker network rm <name>'"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
local kind="bridge (egress)"
|
|
||||||
local args=()
|
|
||||||
if [ "$internal_flag" = "1" ]; then
|
|
||||||
kind="internal"
|
|
||||||
args+=(--internal)
|
|
||||||
fi
|
|
||||||
info "creating ${kind} network ${name}"
|
|
||||||
# Defaults give us a bridge driver with Docker-managed addressing,
|
|
||||||
# which is what we want for both internal and egress networks.
|
|
||||||
if ! docker network create "${args[@]}" "$name" >/dev/null; then
|
|
||||||
die "docker network create ${args[*]} ${name} failed"
|
|
||||||
fi
|
|
||||||
printf '%s' "$name"
|
|
||||||
}
|
|
||||||
|
|
||||||
# network_create_internal <slug>
|
|
||||||
#
|
|
||||||
# Creates a Docker `--internal` network for the agent and prints the
|
|
||||||
# resolved network name on stdout. If the canonical name is already
|
|
||||||
# taken, appends -2, -3, ... (capped at 100, matching the
|
|
||||||
# container-name retry loop in cli.sh) until a free name is found.
|
|
||||||
#
|
|
||||||
# `--internal` is the load-bearing flag: Docker creates the bridge
|
|
||||||
# without a default route, so the agent container attached here cannot
|
|
||||||
# reach the public internet directly. The pipelock sidecar (attached
|
|
||||||
# to both this network and a per-agent egress network) is the only
|
|
||||||
# egress route.
|
|
||||||
#
|
|
||||||
# Side effect: emits one info line naming the network actually created.
|
|
||||||
network_create_internal() {
|
|
||||||
local slug="${1:?network_create_internal: missing slug}"
|
|
||||||
local base
|
|
||||||
base="$(network_name_for_slug "$slug")"
|
|
||||||
_network_create_with_prefix "$base" 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# network_create_egress <slug>
|
|
||||||
#
|
|
||||||
# Creates a per-agent user-defined bridge network used by the pipelock
|
|
||||||
# sidecar for upstream egress, and prints the resolved network name on
|
|
||||||
# stdout. Conflict resolution mirrors network_create_internal.
|
|
||||||
#
|
|
||||||
# We use a user-defined bridge (NOT the legacy `bridge` network)
|
|
||||||
# because only user-defined bridges run Docker's embedded DNS resolver
|
|
||||||
# — pipelock needs DNS to resolve `api.anthropic.com` and similar
|
|
||||||
# upstream hostnames. The legacy `bridge` network would force pipelock
|
|
||||||
# onto the host's resolv.conf and fail in environments where Docker
|
|
||||||
# Desktop's NAT path is the only working DNS route.
|
|
||||||
#
|
|
||||||
# Side effect: emits one info line naming the network actually created.
|
|
||||||
network_create_egress() {
|
|
||||||
local slug="${1:?network_create_egress: missing slug}"
|
|
||||||
local base
|
|
||||||
base="$(network_egress_name_for_slug "$slug")"
|
|
||||||
_network_create_with_prefix "$base" 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# network_attach <network> <container>
|
|
||||||
#
|
|
||||||
# Attaches an already-running container to the named network. Used to
|
|
||||||
# add the pipelock sidecar to a second (default-bridge) network so it
|
|
||||||
# has upstream egress, while staying reachable from the agent on the
|
|
||||||
# internal network.
|
|
||||||
#
|
|
||||||
# Note: for the agent container itself we pass `--network <name>` to
|
|
||||||
# `docker run` directly in cli.sh rather than using this function. The
|
|
||||||
# agent never touches anything except the internal network.
|
|
||||||
network_attach() {
|
|
||||||
local network="${1:?network_attach: missing network name}"
|
|
||||||
local container="${2:?network_attach: missing container name}"
|
|
||||||
if ! docker network connect "$network" "$container" >/dev/null 2>&1; then
|
|
||||||
die "docker network connect ${network} ${container} failed"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# network_remove <name>
|
|
||||||
#
|
|
||||||
# Removes the named network. Idempotent: a missing network is treated
|
|
||||||
# as success so this can be called unconditionally from a teardown
|
|
||||||
# trap. A network that still has containers attached will fail to
|
|
||||||
# remove; the caller is expected to tear those containers down first.
|
|
||||||
network_remove() {
|
|
||||||
local name="${1:?network_remove: missing network name}"
|
|
||||||
if ! network_exists "$name"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if ! docker network rm "$name" >/dev/null 2>&1; then
|
|
||||||
# Don't `die` here: this runs in cleanup paths where we'd rather
|
|
||||||
# warn and continue than abort and leave more orphans behind.
|
|
||||||
warn "failed to remove network ${name}; clean up with 'docker network rm ${name}'"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
-489
@@ -1,489 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Pipelock sidecar lifecycle for the per-agent egress topology
|
|
||||||
# (PRD 0001).
|
|
||||||
#
|
|
||||||
# Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
|
|
||||||
# forward proxy with hostname allowlisting + DLP scanning + URL-entropy
|
|
||||||
# checks. We run one sidecar container per agent, attached to the
|
|
||||||
# agent's --internal network (created by lib/network.sh) and to a
|
|
||||||
# per-agent user-defined bridge network for upstream egress (also
|
|
||||||
# created by lib/network.sh — see the comment in network_create_egress
|
|
||||||
# for why we don't use Docker's legacy `bridge` network). The agent's
|
|
||||||
# HTTPS_PROXY / HTTP_PROXY env vars point at the sidecar's service
|
|
||||||
# name on the internal network; combined with --internal (which omits
|
|
||||||
# the default gateway), pipelock is the only egress route the agent
|
|
||||||
# has.
|
|
||||||
#
|
|
||||||
# Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest>. The
|
|
||||||
# digest is resolved by hand against ghcr.io for tag 2.3.0 (the
|
|
||||||
# `v2.3.0` GitHub release maps to the unprefixed `2.3.0` Docker tag —
|
|
||||||
# see pipelock-assessment.md and the resolution log in PRD 0001's
|
|
||||||
# implementation thread). Bump deliberately when upgrading.
|
|
||||||
#
|
|
||||||
# YAML config we generate: minimum-viable settings to satisfy the PRD's
|
|
||||||
# observable success criteria.
|
|
||||||
# - mode: strict — only api_allowlist domains are reachable
|
|
||||||
# (per docs/configuration.md §Modes)
|
|
||||||
# - enforce: true — blocks rather than warn-only
|
|
||||||
# - api_allowlist: [...] — defaults ∪ bottle.egress.allowlist
|
|
||||||
# - forward_proxy.enabled: true — turns on the CONNECT-tunnel proxy
|
|
||||||
# the agent's HTTPS_PROXY actually uses
|
|
||||||
# (docs §Forward Proxy: this is off by
|
|
||||||
# default, restart-required to flip)
|
|
||||||
# - dlp.include_defaults: true — load all 48 built-in patterns
|
|
||||||
# (docs §DLP §Pattern Merging)
|
|
||||||
# - dlp.scan_env: true — flags URLs containing high-entropy env
|
|
||||||
# values (≥16 chars, Shannon entropy >3.0,
|
|
||||||
# checked in raw/base64/hex/base32). This
|
|
||||||
# is the documented home for pipelock's
|
|
||||||
# "subdomain entropy detection" surface
|
|
||||||
# (docs §Environment Variable Leak
|
|
||||||
# Detection); the URL-path-entropy knob
|
|
||||||
# under fetch_proxy.monitoring is for the
|
|
||||||
# /fetch?url=... helper, not the forward
|
|
||||||
# proxy we use.
|
|
||||||
# We deliberately do NOT set tls_interception (out of PRD scope), and
|
|
||||||
# do NOT carry any env-var values into the YAML — only hostnames.
|
|
||||||
#
|
|
||||||
# Idempotent: safe to source multiple times.
|
|
||||||
|
|
||||||
if [ -n "${CLAUDE_BOTTLE_LIB_PIPELOCK_SOURCED:-}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
CLAUDE_BOTTLE_LIB_PIPELOCK_SOURCED=1
|
|
||||||
|
|
||||||
_iso_lib_pipelock_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=./log.sh
|
|
||||||
. "${_iso_lib_pipelock_dir}/log.sh"
|
|
||||||
# shellcheck source=./manifest.sh
|
|
||||||
. "${_iso_lib_pipelock_dir}/manifest.sh"
|
|
||||||
# shellcheck source=./network.sh
|
|
||||||
. "${_iso_lib_pipelock_dir}/network.sh"
|
|
||||||
|
|
||||||
# --- Constants -------------------------------------------------------------
|
|
||||||
|
|
||||||
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
|
||||||
# index for ghcr.io/luckypipewrench/pipelock:2.3.0 (resolved 2026-05-08
|
|
||||||
# from the ghcr.io v2 manifests endpoint). Ties match the v2.3.0 GitHub
|
|
||||||
# release; the registry uses unprefixed tags so v2.3.0→2.3.0.
|
|
||||||
CLAUDE_BOTTLE_PIPELOCK_IMAGE="${CLAUDE_BOTTLE_PIPELOCK_IMAGE:-ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9}"
|
|
||||||
|
|
||||||
# Listening port for pipelock's forward proxy. Default per
|
|
||||||
# docs/configuration.md §Forward Proxy / §Fetch Proxy and the
|
|
||||||
# deployment-recipes generator. Override via env if a future image
|
|
||||||
# changes it.
|
|
||||||
CLAUDE_BOTTLE_PIPELOCK_PORT="${CLAUDE_BOTTLE_PIPELOCK_PORT:-8888}"
|
|
||||||
|
|
||||||
# Baked-in default allowlist for hosts Claude Code itself needs.
|
|
||||||
# Source: pipelock-assessment.md and the Claude Code network-config
|
|
||||||
# docs (https://code.claude.com/docs/en/network-config). The effective
|
|
||||||
# allowlist used at launch is this set unioned with whatever the
|
|
||||||
# bottle's egress.allowlist names. Kept as a newline-separated string
|
|
||||||
# because bash arrays don't survive sourcing into a function-only
|
|
||||||
# context cleanly; callers split on newlines.
|
|
||||||
CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST="api.anthropic.com
|
|
||||||
statsig.anthropic.com
|
|
||||||
sentry.io
|
|
||||||
claude.ai
|
|
||||||
platform.claude.com
|
|
||||||
downloads.claude.ai
|
|
||||||
raw.githubusercontent.com"
|
|
||||||
|
|
||||||
# --- Naming ----------------------------------------------------------------
|
|
||||||
|
|
||||||
# pipelock_container_name <slug> — prints the canonical sidecar
|
|
||||||
# container name for a given agent slug. The agent reaches the sidecar
|
|
||||||
# at this name as a hostname on the internal network.
|
|
||||||
pipelock_container_name() {
|
|
||||||
local slug="${1:?pipelock_container_name: missing slug}"
|
|
||||||
printf 'claude-bottle-pipelock-%s' "$slug"
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_proxy_url <slug> — prints http://<sidecar>:<port>, suitable
|
|
||||||
# for HTTPS_PROXY / HTTP_PROXY in the agent container.
|
|
||||||
pipelock_proxy_url() {
|
|
||||||
local slug="${1:?pipelock_proxy_url: missing slug}"
|
|
||||||
local name
|
|
||||||
name="$(pipelock_container_name "$slug")"
|
|
||||||
printf 'http://%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT"
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_proxy_host_port <slug> — prints <sidecar>:<port> (no scheme),
|
|
||||||
# suitable for socat's PROXY: directive in an SSH ProxyCommand. The
|
|
||||||
# agent's --internal network has no default route, so SSH (and any other
|
|
||||||
# raw TCP) must tunnel via pipelock's HTTP CONNECT.
|
|
||||||
pipelock_proxy_host_port() {
|
|
||||||
local slug="${1:?pipelock_proxy_host_port: missing slug}"
|
|
||||||
local name
|
|
||||||
name="$(pipelock_container_name "$slug")"
|
|
||||||
printf '%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Allowlist resolution --------------------------------------------------
|
|
||||||
|
|
||||||
# pipelock_bottle_allowlist <manifest_file> <bottle_name>
|
|
||||||
#
|
|
||||||
# Prints one hostname per line on stdout for the allowlist declared at
|
|
||||||
# bottles[<bottle_name>].egress.allowlist. Empty (no output) if the
|
|
||||||
# field is missing or the array is empty. Validates that each entry is
|
|
||||||
# a JSON string; dies with a clear message if any element is not.
|
|
||||||
pipelock_bottle_allowlist() {
|
|
||||||
local manifest_file="${1:?pipelock_bottle_allowlist: missing manifest file}"
|
|
||||||
local bottle_name="${2:?pipelock_bottle_allowlist: missing bottle name}"
|
|
||||||
|
|
||||||
# Validate shape first: if egress.allowlist exists, every element
|
|
||||||
# must be a string. We do this in one jq pass.
|
|
||||||
local types
|
|
||||||
types="$(jq -r --arg b "$bottle_name" '
|
|
||||||
.bottles[$b].egress.allowlist // [] | map(type) | unique[]
|
|
||||||
' "$manifest_file")"
|
|
||||||
local t
|
|
||||||
while IFS= read -r t; do
|
|
||||||
[ -z "$t" ] && continue
|
|
||||||
if [ "$t" != "string" ]; then
|
|
||||||
die "bottle '${bottle_name}' egress.allowlist must contain only strings; found a '${t}' entry."
|
|
||||||
fi
|
|
||||||
done <<< "$types"
|
|
||||||
|
|
||||||
jq -r --arg b "$bottle_name" '
|
|
||||||
.bottles[$b].egress.allowlist // [] | .[]
|
|
||||||
' "$manifest_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_bottle_ssh_hostnames <manifest_file> <bottle_name>
|
|
||||||
#
|
|
||||||
# Prints one hostname per line for each entry in bottles[<name>].ssh[].Hostname.
|
|
||||||
# These need to reach pipelock's allowlist so the agent can tunnel SSH
|
|
||||||
# through pipelock via HTTP CONNECT (see ssh_setup's ProxyCommand
|
|
||||||
# wiring). Empty output if the bottle has no ssh entries.
|
|
||||||
pipelock_bottle_ssh_hostnames() {
|
|
||||||
local manifest_file="${1:?pipelock_bottle_ssh_hostnames: missing manifest file}"
|
|
||||||
local bottle_name="${2:?pipelock_bottle_ssh_hostnames: missing bottle name}"
|
|
||||||
|
|
||||||
jq -r --arg b "$bottle_name" '
|
|
||||||
.bottles[$b].ssh // [] | .[] | .Hostname // empty
|
|
||||||
' "$manifest_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# _pipelock_is_ipv4_literal <s> — exit 0 if <s> looks like an IPv4
|
|
||||||
# literal (four dot-separated octets). Pipelock's SSRF check fires on
|
|
||||||
# the resolved IP, so a Hostname that's already an IP literal needs
|
|
||||||
# `ssrf.ip_allowlist`, while a hostname needs `trusted_domains`.
|
|
||||||
_pipelock_is_ipv4_literal() {
|
|
||||||
local s="${1:?}"
|
|
||||||
[[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_bottle_ssh_trusted_domains <manifest> <bottle>
|
|
||||||
#
|
|
||||||
# Hostname-shaped ssh[].Hostname entries that should bypass pipelock's
|
|
||||||
# SSRF check (so a name resolving to a private IP — e.g. internal API
|
|
||||||
# behind a VPN — is reachable). IP-literal entries are excluded;
|
|
||||||
# trusted_domains is hostname-based per pipelock's docs.
|
|
||||||
pipelock_bottle_ssh_trusted_domains() {
|
|
||||||
local manifest_file="${1:?}"
|
|
||||||
local bottle_name="${2:?}"
|
|
||||||
local h
|
|
||||||
while IFS= read -r h; do
|
|
||||||
[ -z "$h" ] && continue
|
|
||||||
_pipelock_is_ipv4_literal "$h" && continue
|
|
||||||
printf '%s\n' "$h"
|
|
||||||
done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name")
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_bottle_ssh_ip_cidrs <manifest> <bottle>
|
|
||||||
#
|
|
||||||
# Emits one canonical /32 CIDR per IPv4-literal ssh[].Hostname so they
|
|
||||||
# pass pipelock's SSRF IP-range check (which blocks RFC 1918, RFC 6598
|
|
||||||
# CGNAT, link-local, loopback, etc. by default). Hostnames are skipped
|
|
||||||
# — they go through trusted_domains instead.
|
|
||||||
pipelock_bottle_ssh_ip_cidrs() {
|
|
||||||
local manifest_file="${1:?}"
|
|
||||||
local bottle_name="${2:?}"
|
|
||||||
local h
|
|
||||||
while IFS= read -r h; do
|
|
||||||
[ -z "$h" ] && continue
|
|
||||||
if _pipelock_is_ipv4_literal "$h"; then
|
|
||||||
printf '%s/32\n' "$h"
|
|
||||||
fi
|
|
||||||
done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name")
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_effective_allowlist <manifest_file> <bottle_name>
|
|
||||||
#
|
|
||||||
# Prints the deduplicated union of: the baked-in default allowlist, the
|
|
||||||
# bottle's declared egress.allowlist, and any bottle.ssh[].Hostname
|
|
||||||
# entries (so SSH tunneling through pipelock is permitted by the same
|
|
||||||
# allowlist check that gates HTTP CONNECT). One hostname per line,
|
|
||||||
# sorted for stability. This is the single source of truth callers
|
|
||||||
# should use for both YAML generation and the preflight summary.
|
|
||||||
pipelock_effective_allowlist() {
|
|
||||||
local manifest_file="${1:?pipelock_effective_allowlist: missing manifest file}"
|
|
||||||
local bottle_name="${2:?pipelock_effective_allowlist: missing bottle name}"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf '%s\n' "$CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST"
|
|
||||||
pipelock_bottle_allowlist "$manifest_file" "$bottle_name"
|
|
||||||
pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name"
|
|
||||||
} | awk 'NF && !seen[$0]++' | LC_ALL=C sort
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_allowlist_summary <manifest_file> <bottle_name>
|
|
||||||
#
|
|
||||||
# One-line summary of the effective allowlist for the y/N preflight
|
|
||||||
# display. Format:
|
|
||||||
# "<N> hosts allowed (host1, host2, host3 +M more)"
|
|
||||||
# When the allowlist has 5 or fewer entries, all are listed and the
|
|
||||||
# "+M more" suffix is omitted.
|
|
||||||
pipelock_allowlist_summary() {
|
|
||||||
local manifest_file="${1:?pipelock_allowlist_summary: missing manifest file}"
|
|
||||||
local bottle_name="${2:?pipelock_allowlist_summary: missing bottle name}"
|
|
||||||
|
|
||||||
local hosts=()
|
|
||||||
local h
|
|
||||||
while IFS= read -r h; do
|
|
||||||
[ -z "$h" ] && continue
|
|
||||||
hosts+=("$h")
|
|
||||||
done < <(pipelock_effective_allowlist "$manifest_file" "$bottle_name")
|
|
||||||
|
|
||||||
local count="${#hosts[@]}"
|
|
||||||
if [ "$count" -eq 0 ]; then
|
|
||||||
printf '0 hosts allowed (none)'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local show=$count
|
|
||||||
local more=0
|
|
||||||
if [ "$count" -gt 5 ]; then
|
|
||||||
show=3
|
|
||||||
more=$((count - show))
|
|
||||||
fi
|
|
||||||
|
|
||||||
local first_n=()
|
|
||||||
local i=0
|
|
||||||
while [ "$i" -lt "$show" ]; do
|
|
||||||
first_n+=("${hosts[$i]}")
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
local joined=""
|
|
||||||
local h2
|
|
||||||
for h2 in "${first_n[@]}"; do
|
|
||||||
if [ -z "$joined" ]; then
|
|
||||||
joined="$h2"
|
|
||||||
else
|
|
||||||
joined="${joined}, ${h2}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$more" -gt 0 ]; then
|
|
||||||
printf '%s hosts allowed (%s, +%s more)' "$count" "$joined" "$more"
|
|
||||||
else
|
|
||||||
printf '%s hosts allowed (%s)' "$count" "$joined"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- YAML generation -------------------------------------------------------
|
|
||||||
|
|
||||||
# pipelock_write_yaml <manifest_file> <bottle_name> <out_path>
|
|
||||||
#
|
|
||||||
# Writes a pipelock YAML config file to <out_path> (mode 600). The
|
|
||||||
# config carries only:
|
|
||||||
# - the effective allowlist (hostnames),
|
|
||||||
# - a fixed listen port (CLAUDE_BOTTLE_PIPELOCK_PORT),
|
|
||||||
# - the minimum knobs needed to satisfy PRD 0001 success criteria
|
|
||||||
# (strict mode, forward_proxy on, DLP defaults + env scanning).
|
|
||||||
#
|
|
||||||
# It deliberately contains no env values, no secrets, and no per-agent
|
|
||||||
# customization beyond the hostname list.
|
|
||||||
#
|
|
||||||
# YAML keys + defaults sourced from
|
|
||||||
# https://github.com/luckyPipewrench/pipelock/blob/main/docs/configuration.md
|
|
||||||
# (top-level fields, api_allowlist, forward_proxy, dlp).
|
|
||||||
pipelock_write_yaml() {
|
|
||||||
local manifest_file="${1:?pipelock_write_yaml: missing manifest file}"
|
|
||||||
local bottle_name="${2:?pipelock_write_yaml: missing bottle name}"
|
|
||||||
local out_path="${3:?pipelock_write_yaml: missing out_path}"
|
|
||||||
|
|
||||||
: > "$out_path"
|
|
||||||
chmod 600 "$out_path"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf 'version: 1\n'
|
|
||||||
printf 'mode: strict\n'
|
|
||||||
printf 'enforce: true\n'
|
|
||||||
printf '\n'
|
|
||||||
printf '# Hostnames the agent is allowed to reach. Effective list is\n'
|
|
||||||
printf '# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).\n'
|
|
||||||
printf 'api_allowlist:\n'
|
|
||||||
local h
|
|
||||||
while IFS= read -r h; do
|
|
||||||
[ -z "$h" ] && continue
|
|
||||||
# Validate: pipelock allows hostnames + wildcards. We accept
|
|
||||||
# anything that does not contain whitespace or the YAML special
|
|
||||||
# chars that would break unquoted strings; quote on output to be
|
|
||||||
# safe.
|
|
||||||
printf ' - "%s"\n' "$h"
|
|
||||||
done < <(pipelock_effective_allowlist "$manifest_file" "$bottle_name")
|
|
||||||
printf '\n'
|
|
||||||
printf 'forward_proxy:\n'
|
|
||||||
printf ' enabled: true\n'
|
|
||||||
printf '\n'
|
|
||||||
# SSRF exemptions for declared SSH hosts. Pipelock blocks the CGNAT
|
|
||||||
# range (100.64.0.0/10, where Tailscale IPs live) and the rest of
|
|
||||||
# RFC 1918 / link-local by default. Hostname entries go to
|
|
||||||
# trusted_domains; IP-literal entries to ssrf.ip_allowlist as /32.
|
|
||||||
local trusted_count=0 ssrf_count=0
|
|
||||||
local td
|
|
||||||
while IFS= read -r td; do
|
|
||||||
[ -z "$td" ] && continue
|
|
||||||
if [ "$trusted_count" -eq 0 ]; then
|
|
||||||
printf 'trusted_domains:\n'
|
|
||||||
fi
|
|
||||||
printf ' - "%s"\n' "$td"
|
|
||||||
trusted_count=$((trusted_count + 1))
|
|
||||||
done < <(pipelock_bottle_ssh_trusted_domains "$manifest_file" "$bottle_name")
|
|
||||||
[ "$trusted_count" -gt 0 ] && printf '\n'
|
|
||||||
local cidr
|
|
||||||
while IFS= read -r cidr; do
|
|
||||||
[ -z "$cidr" ] && continue
|
|
||||||
if [ "$ssrf_count" -eq 0 ]; then
|
|
||||||
printf 'ssrf:\n'
|
|
||||||
printf ' ip_allowlist:\n'
|
|
||||||
fi
|
|
||||||
printf ' - "%s"\n' "$cidr"
|
|
||||||
ssrf_count=$((ssrf_count + 1))
|
|
||||||
done < <(pipelock_bottle_ssh_ip_cidrs "$manifest_file" "$bottle_name")
|
|
||||||
[ "$ssrf_count" -gt 0 ] && printf '\n'
|
|
||||||
printf 'dlp:\n'
|
|
||||||
printf ' include_defaults: true\n'
|
|
||||||
printf ' scan_env: true\n'
|
|
||||||
} > "$out_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Sidecar lifecycle -----------------------------------------------------
|
|
||||||
|
|
||||||
# pipelock_start <slug> <internal_network> <egress_network> <yaml_dir> <yaml_filename>
|
|
||||||
#
|
|
||||||
# Boots the pipelock sidecar:
|
|
||||||
# 1. `docker run -d` on the internal network with the canonical
|
|
||||||
# service name. The image runs `pipelock` as its CMD; we override
|
|
||||||
# with `run --config <path>` and the listen address.
|
|
||||||
# 2. `docker cp` the YAML config from the host mktemp dir into the
|
|
||||||
# container at /etc/pipelock.yaml.
|
|
||||||
#
|
|
||||||
# We use docker cp rather than `-v <host>:<container>` because Docker
|
|
||||||
# Desktop bind mounts have ownership / case-sensitivity quirks on
|
|
||||||
# macOS; copying the file in sidesteps both. The host-side mktemp dir
|
|
||||||
# is the caller's responsibility to clean up.
|
|
||||||
#
|
|
||||||
# After the cp the container is restarted so pipelock picks up the
|
|
||||||
# config it boots from. Pipelock's hot-reload feature would let us
|
|
||||||
# avoid the restart, but `forward_proxy.enabled` is one of the few
|
|
||||||
# restart-required keys (per docs/configuration.md), so a restart is
|
|
||||||
# the simplest correct path on first boot.
|
|
||||||
#
|
|
||||||
# Args:
|
|
||||||
# <slug> — agent slug; sidecar name will be claude-bottle-pipelock-<slug>
|
|
||||||
# <internal_network> — name of the agent's internal docker network
|
|
||||||
# <egress_network> — name of the agent's user-defined egress
|
|
||||||
# network; the sidecar joins this so it can
|
|
||||||
# reach upstream hostnames with working DNS
|
|
||||||
# <yaml_dir> — host directory containing the YAML
|
|
||||||
# <yaml_filename> — filename within yaml_dir
|
|
||||||
#
|
|
||||||
# Echoes the container name on stdout on success.
|
|
||||||
pipelock_start() {
|
|
||||||
local slug="${1:?pipelock_start: missing slug}"
|
|
||||||
local internal_network="${2:?pipelock_start: missing internal network}"
|
|
||||||
local egress_network="${3:?pipelock_start: missing egress network}"
|
|
||||||
local yaml_dir="${4:?pipelock_start: missing yaml dir}"
|
|
||||||
local yaml_filename="${5:?pipelock_start: missing yaml filename}"
|
|
||||||
|
|
||||||
local name
|
|
||||||
name="$(pipelock_container_name "$slug")"
|
|
||||||
local host_yaml="${yaml_dir}/${yaml_filename}"
|
|
||||||
if [ ! -f "$host_yaml" ]; then
|
|
||||||
die "pipelock yaml not found at ${host_yaml}; pipelock_write_yaml must run first"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Container layout: pipelock reads its config from /etc/pipelock.yaml.
|
|
||||||
# We `docker create` the sidecar, `docker cp` the YAML into the
|
|
||||||
# writable layer, then `docker start` it — no bind mount, no shell
|
|
||||||
# shim. The image is distroless (no `sh`), and `docker cp` to a
|
|
||||||
# stopped container does NOT create intermediate parent directories,
|
|
||||||
# so the YAML lives directly under /etc rather than in a /etc/pipelock
|
|
||||||
# subdirectory.
|
|
||||||
info "starting pipelock sidecar ${name} on network ${internal_network}"
|
|
||||||
|
|
||||||
# Sidecar argv verification (PR #1 review). The pinned digest
|
|
||||||
# (CLAUDE_BOTTLE_PIPELOCK_IMAGE above) has:
|
|
||||||
# ENTRYPOINT ["/pipelock"]
|
|
||||||
# CMD ["run", "--listen", "0.0.0.0:8888"]
|
|
||||||
# `pipelock run --help` documents `-l, --listen` (default
|
|
||||||
# 127.0.0.1:8888) as the forward-proxy listen address — the
|
|
||||||
# `--mcp-listen` flag is for the separate MCP HTTP listener and is
|
|
||||||
# not what we want here. `--config` reads the YAML and hot-reloads
|
|
||||||
# on file change; values in YAML can also drive the listen address
|
|
||||||
# via `fetch_proxy.listen`, but the CLI flag takes precedence and
|
|
||||||
# is the simpler contract for our launcher. Smoke-tested 2026-05-08
|
|
||||||
# by running this exact argv against the digest and confirming the
|
|
||||||
# /health endpoint responded on :8888.
|
|
||||||
if ! docker create \
|
|
||||||
--name "$name" \
|
|
||||||
--network "$internal_network" \
|
|
||||||
"$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \
|
|
||||||
run --config /etc/pipelock.yaml --listen "0.0.0.0:${CLAUDE_BOTTLE_PIPELOCK_PORT}" \
|
|
||||||
>/dev/null 2>&1; then
|
|
||||||
die "failed to create pipelock sidecar ${name}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# `docker cp` to a created-but-not-started container writes into the
|
|
||||||
# writable layer directly. The parent directory must already exist in
|
|
||||||
# the image — docker cp does NOT create missing intermediate dirs to
|
|
||||||
# a stopped container, contrary to a common assumption. The pipelock
|
|
||||||
# image is distroless (no `sh`), so we cannot prepopulate dirs with a
|
|
||||||
# shell shim either. We therefore put the config in /etc/pipelock.yaml
|
|
||||||
# (file directly under /etc) rather than /etc/pipelock/pipelock.yaml.
|
|
||||||
local cp_err
|
|
||||||
cp_err="$(docker cp "$host_yaml" "${name}:/etc/pipelock.yaml" 2>&1)" || {
|
|
||||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
|
||||||
die "failed to copy pipelock yaml into ${name}: ${cp_err}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Attach to a per-agent user-defined bridge network for upstream
|
|
||||||
# egress. The internal network has no gateway by definition, so
|
|
||||||
# without a second network the sidecar can't reach the public
|
|
||||||
# internet at all. We deliberately do NOT use Docker's legacy
|
|
||||||
# `bridge` network: only user-defined bridges run Docker's embedded
|
|
||||||
# DNS resolver, which pipelock needs to resolve `api.anthropic.com`
|
|
||||||
# and similar upstream hostnames. The egress network is created by
|
|
||||||
# network_create_egress in lib/network.sh.
|
|
||||||
if ! docker network connect "$egress_network" "$name" >/dev/null 2>&1; then
|
|
||||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
|
||||||
die "failed to attach pipelock sidecar ${name} to egress network ${egress_network}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker start "$name" >/dev/null 2>&1; then
|
|
||||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
|
||||||
die "failed to start pipelock sidecar ${name}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "$name"
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipelock_stop <slug>
|
|
||||||
#
|
|
||||||
# Stops and removes the sidecar by canonical name. Idempotent: a
|
|
||||||
# missing container is treated as success so this can be wired into
|
|
||||||
# cli.sh's exit trap unconditionally. Used as the first step of
|
|
||||||
# teardown — must run BEFORE the network is torn down, because docker
|
|
||||||
# refuses to remove a network that still has containers attached.
|
|
||||||
pipelock_stop() {
|
|
||||||
local slug="${1:?pipelock_stop: missing slug}"
|
|
||||||
local name
|
|
||||||
name="$(pipelock_container_name "$slug")"
|
|
||||||
if docker inspect "$name" >/dev/null 2>&1; then
|
|
||||||
docker rm -f "$name" >/dev/null 2>&1 || warn "failed to remove pipelock sidecar ${name}; clean up with 'docker rm -f ${name}'"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}
|
|
||||||
-220
@@ -1,220 +0,0 @@
|
|||||||
#!/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}"
|
|
||||||
# proxy_host_port is the pipelock sidecar as <host>:<port> (no scheme).
|
|
||||||
# Used as socat's PROXY: argument so the agent can reach SSH hosts
|
|
||||||
# over the agent's --internal network — the only egress route is the
|
|
||||||
# pipelock CONNECT proxy. Required.
|
|
||||||
local proxy_host_port="${3:?ssh_setup: missing proxy_host_port}"
|
|
||||||
shift 3
|
|
||||||
|
|
||||||
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).
|
|
||||||
#
|
|
||||||
# ProxyCommand tunnels the SSH connection through pipelock via HTTP
|
|
||||||
# CONNECT. The agent container has no default route (--internal
|
|
||||||
# network); pipelock is the only path to anywhere. socat's PROXY:
|
|
||||||
# mode does CONNECT host:port to the proxy. %h / %p expand to this
|
|
||||||
# block's HostName / Port. The SSH host must also appear in
|
|
||||||
# pipelock's allowlist — pipelock_effective_allowlist auto-includes
|
|
||||||
# bottle.ssh[].Hostname entries so this just works for declared
|
|
||||||
# hosts.
|
|
||||||
printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n ProxyCommand socat - PROXY:%s:%%h:%%p,proxyport=%s\n\n' \
|
|
||||||
"$name" "$hostname" "$user" "$port" "$public_socket" \
|
|
||||||
"${proxy_host_port%:*}" "${proxy_host_port##*:}" >> "$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
|
|
||||||
}
|
|
||||||
+50
-54
@@ -1,83 +1,79 @@
|
|||||||
# Tests
|
# Tests
|
||||||
|
|
||||||
Plain-bash test suite. No framework dependency — assertions are tiny
|
Plain-Python test suite using stdlib `unittest`. No external
|
||||||
helpers in `tests/lib/assert.sh` and the runner is a shell script.
|
dependencies. Unit tests run anywhere Python 3 is present; integration
|
||||||
The unit tests run anywhere bash + jq are present; the integration
|
|
||||||
tests need Docker and skip cleanly otherwise.
|
tests need Docker and skip cleanly otherwise.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
tests/
|
tests/
|
||||||
run_tests.sh # entry point
|
run_tests.py # entry point
|
||||||
lib/
|
fixtures.py # JSON manifest builders
|
||||||
assert.sh # assert_eq, assert_contains, assert_match, ...
|
_docker.py # docker-availability skip helper
|
||||||
common.sh # sources assert + fixtures, sets REPO_ROOT
|
test_pipelock_naming.py # unit
|
||||||
fixtures.sh # JSON manifest builders
|
test_pipelock_classify.py # unit
|
||||||
unit/ # no docker; fast
|
test_pipelock_allowlist.py # unit
|
||||||
test_pipelock_naming.sh
|
test_pipelock_yaml.py # unit
|
||||||
test_pipelock_classify.sh
|
test_pipelock_image.py # integration
|
||||||
test_pipelock_allowlist.sh
|
test_pipelock_sidecar_smoke.py # integration
|
||||||
test_pipelock_yaml.sh
|
test_dry_run_plan.py # integration
|
||||||
integration/ # require docker
|
test_orphan_cleanup.py # integration
|
||||||
test_pipelock_image.sh
|
|
||||||
test_pipelock_sidecar_smoke.sh
|
|
||||||
test_dry_run_plan.sh
|
|
||||||
test_orphan_cleanup.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tests/run_tests.sh # everything
|
tests/run_tests.py # everything
|
||||||
tests/run_tests.sh unit # unit only
|
tests/run_tests.py unit # unit only
|
||||||
tests/run_tests.sh integration # integration only
|
tests/run_tests.py integration # integration only
|
||||||
tests/run_tests.sh tests/unit/test_pipelock_yaml.sh # one file
|
tests/run_tests.py tests/test_pipelock_yaml.py # one file
|
||||||
```
|
```
|
||||||
|
|
||||||
Each test file exits 0 on pass, 1 on fail. The runner aggregates and
|
You can also run via `python -m unittest`:
|
||||||
prints a one-line summary.
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s tests
|
||||||
|
python -m unittest tests.test_pipelock_yaml
|
||||||
|
```
|
||||||
|
|
||||||
## What the integration tests cover
|
## What the integration tests cover
|
||||||
|
|
||||||
These are versions of the smoke tests run during PR #1:
|
- `test_pipelock_image.py` — the pinned digest is reachable, ENTRYPOINT
|
||||||
|
is `/pipelock`, and `CMD` includes `run`.
|
||||||
- `test_pipelock_image.sh` — the pinned digest is reachable, ENTRYPOINT
|
- `test_pipelock_sidecar_smoke.py` — `docker create` + `docker cp` the
|
||||||
is `/pipelock`, and `CMD` includes `run`. Catches a pipelock release
|
|
||||||
that bumps the argv shape.
|
|
||||||
- `test_pipelock_sidecar_smoke.sh` — `docker create` + `docker cp` the
|
|
||||||
generated YAML to `/etc/pipelock.yaml` + `docker start`, then probe
|
generated YAML to `/etc/pipelock.yaml` + `docker start`, then probe
|
||||||
`/health`. Catches the YAML-path bug we hit (the image is distroless,
|
`/health`.
|
||||||
so `/etc/pipelock/` does not exist) and YAML structural breakage.
|
- `test_dry_run_plan.py` — `cli.py start --dry-run` shows the resolved
|
||||||
- `test_dry_run_plan.sh` — `cli.sh start --dry-run` shows the resolved
|
|
||||||
egress allowlist and creates zero docker resources.
|
egress allowlist and creates zero docker resources.
|
||||||
- `test_orphan_cleanup.sh` — when the sidecar fails to start (bogus
|
- `test_orphan_cleanup.py` — network_remove and pipelock_stop are
|
||||||
image digest), the EXIT trap removes both the internal and egress
|
idempotent against missing resources, so the EXIT trap can call them
|
||||||
networks. Catches regressions in trap-installation ordering.
|
unconditionally.
|
||||||
|
|
||||||
## What's NOT covered
|
## What's NOT covered
|
||||||
|
|
||||||
- `lib/ssh.sh` end-to-end (would need a fake SSH host inside the
|
- `claude_bottle/ssh.py` end-to-end (would need a fake SSH host inside
|
||||||
container; high effort for v1).
|
the container).
|
||||||
- A live SSH-through-pipelock tunnel against a real Tailscale-style
|
- A live SSH-through-pipelock tunnel against a real Tailscale-style IP.
|
||||||
internal IP.
|
|
||||||
- DLP false-positive measurements.
|
- DLP false-positive measurements.
|
||||||
- TLS handling / cert pinning behavior.
|
- TLS handling / cert pinning behavior.
|
||||||
|
|
||||||
## Adding a test
|
## Adding a test
|
||||||
|
|
||||||
1. Pick `unit/` (no docker) or `integration/` (docker required).
|
1. Pick a filename: `test_<topic>.py`. Add it to `INTEGRATION_NAMES`
|
||||||
2. Name it `test_<topic>.sh`. Make it executable: `chmod +x`.
|
in `run_tests.py` if it needs Docker.
|
||||||
3. Start with the boilerplate the existing files use:
|
2. Boilerplate:
|
||||||
```bash
|
```python
|
||||||
#!/usr/bin/env bash
|
import unittest
|
||||||
TEST_NAME="<topic>"
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
from claude_bottle.<module> import <symbol>
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
. "${REPO_ROOT}/lib/<file-under-test>.sh"
|
class TestThing(unittest.TestCase):
|
||||||
# ...assert_eq / assert_contains / ...
|
def test_x(self):
|
||||||
test_summary
|
...
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
```
|
```
|
||||||
4. For integration tests: call `skip_test_if_no_docker` after the
|
3. For Docker-dependent tests, decorate the class with
|
||||||
boilerplate and ensure your trap cleans up any docker resources you
|
`@skip_unless_docker()` from `tests._docker`.
|
||||||
create.
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Docker availability check used by integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
def docker_available() -> bool:
|
||||||
|
if shutil.which("docker") is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "info"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def skip_unless_docker(reason: str = "docker unreachable"):
|
||||||
|
return unittest.skipUnless(docker_available(), reason)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Manifest fixtures for the test suite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_minimal() -> dict[str, Any]:
|
||||||
|
"""One bottle, one agent, no env / ssh / skills."""
|
||||||
|
return {
|
||||||
|
"bottles": {"dev": {}},
|
||||||
|
"agents": {
|
||||||
|
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_with_egress() -> dict[str, Any]:
|
||||||
|
"""Bottle declares an egress.allowlist."""
|
||||||
|
return {
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"egress": {
|
||||||
|
"allowlist": ["github.com", "gitlab.com", "registry.npmjs.org"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_with_ssh() -> dict[str, Any]:
|
||||||
|
"""Bottle has both an IPv4-literal SSH host (CGNAT) and a hostname host,
|
||||||
|
exercising both ssrf.ip_allowlist and trusted_domains code paths."""
|
||||||
|
return {
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"Host": "tailscale-gitea",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "100.78.141.42",
|
||||||
|
"User": "git",
|
||||||
|
"Port": 30009,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Host": "github",
|
||||||
|
"IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "github.com",
|
||||||
|
"User": "git",
|
||||||
|
"Port": 22,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_fixture(fn) -> Path:
|
||||||
|
"""Write fixture dict to a temp file; return the path. Caller must rm."""
|
||||||
|
f = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||||
|
)
|
||||||
|
json.dump(fn(), f)
|
||||||
|
f.close()
|
||||||
|
return Path(f.name)
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Integration: cli.sh start --dry-run renders the planned shape and
|
|
||||||
# does not create any docker resources. Confirms the preflight contract
|
|
||||||
# from PRD 0001 (allowlist line in the plan, no docker side effects).
|
|
||||||
TEST_NAME="dry_run_plan"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
|
|
||||||
skip_test_if_no_docker
|
|
||||||
|
|
||||||
work_dir="$(mktemp -d)"
|
|
||||||
manifest="${work_dir}/claude-bottle.json"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$work_dir"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# Manifest with an egress.allowlist so we can grep for a known host.
|
|
||||||
cat > "$manifest" <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"egress": { "allowlist": ["example.org"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {
|
|
||||||
"skills": [],
|
|
||||||
"prompt": "",
|
|
||||||
"bottle": "dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
# Snapshot docker state before we run.
|
|
||||||
nets_before="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
|
|
||||||
ctrs_before="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
|
|
||||||
|
|
||||||
# Override HOME so the user's ~/claude-bottle.json doesn't leak in via
|
|
||||||
# manifest_resolve's home+cwd merge.
|
|
||||||
out="$(cd "$work_dir" \
|
|
||||||
&& HOME="$work_dir" CLAUDE_BOTTLE_DRY_RUN=1 \
|
|
||||||
"${REPO_ROOT}/cli.sh" start demo 2>&1 || true)"
|
|
||||||
|
|
||||||
assert_contains "$out" "egress" "preflight: egress line present"
|
|
||||||
# 7 baked defaults + 1 bottle entry = 8. The summary line shows the
|
|
||||||
# total count regardless of which entries fit in the visible
|
|
||||||
# "<a>, <b>, <c>, +N more" prefix, so this assertion is robust against
|
|
||||||
# alphabetical sort order changes.
|
|
||||||
assert_match "$out" "8 hosts allowed" "preflight: bottle entry counted in effective allowlist"
|
|
||||||
assert_contains "$out" "api.anthropic.com" "preflight: baked default shown"
|
|
||||||
assert_contains "$out" "dry-run requested" "dry-run banner present"
|
|
||||||
assert_not_contains "$out" "/dev/tty" "no /dev/tty prompt reached (dry-run exited first)"
|
|
||||||
|
|
||||||
# No docker side effects.
|
|
||||||
nets_after="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
|
|
||||||
ctrs_after="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
|
|
||||||
assert_eq "$nets_before" "$nets_after" "dry-run: no claude-bottle networks created"
|
|
||||||
assert_eq "$ctrs_before" "$ctrs_after" "dry-run: no claude-bottle containers created"
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Integration: the cleanup primitives the start-flow trap depends on
|
|
||||||
# are idempotent. The original orphan-network bug was a trap-ordering
|
|
||||||
# issue (cleanup_all installed AFTER networks were created); the fix
|
|
||||||
# moved the install earlier. The trap is only safe if the helpers it
|
|
||||||
# calls — network_remove, pipelock_stop — are no-ops against
|
|
||||||
# already-missing or never-existed resources. We test that here.
|
|
||||||
#
|
|
||||||
# (The full end-to-end "cli.sh dies mid-run, networks gone" flow needs
|
|
||||||
# a TTY and is documented as a manual verification step in tests/README.md.)
|
|
||||||
TEST_NAME="orphan_cleanup"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/docker.sh
|
|
||||||
. "${REPO_ROOT}/lib/docker.sh"
|
|
||||||
# shellcheck source=../../lib/network.sh
|
|
||||||
. "${REPO_ROOT}/lib/network.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
skip_test_if_no_docker
|
|
||||||
|
|
||||||
slug="cb-test-orphan-$$"
|
|
||||||
internal_name=""
|
|
||||||
egress_name=""
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
for n in "$internal_name" "$egress_name"; do
|
|
||||||
[ -n "$n" ] && docker network rm "$n" >/dev/null 2>&1 || true
|
|
||||||
done
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# 1. network_remove against a name that doesn't exist returns 0
|
|
||||||
# (the trap can call it eagerly without crashing on the first run
|
|
||||||
# where the network was never created).
|
|
||||||
assert_exit_zero "network_remove: missing network is a no-op" \
|
|
||||||
network_remove "claude-bottle-net-${slug}-does-not-exist"
|
|
||||||
|
|
||||||
# 2. Create both networks the way cli.sh does, then remove them with
|
|
||||||
# network_remove. Both should succeed and the networks should be
|
|
||||||
# gone afterwards.
|
|
||||||
internal_name="$(network_create_internal "$slug")"
|
|
||||||
egress_name="$(network_create_egress "$slug")"
|
|
||||||
|
|
||||||
assert_match "$(docker network ls --format '{{.Name}}')" "^${internal_name}$" \
|
|
||||||
"internal network was created"
|
|
||||||
assert_match "$(docker network ls --format '{{.Name}}')" "^${egress_name}$" \
|
|
||||||
"egress network was created"
|
|
||||||
|
|
||||||
assert_exit_zero "network_remove: removes existing internal network" \
|
|
||||||
network_remove "$internal_name"
|
|
||||||
assert_exit_zero "network_remove: removes existing egress network" \
|
|
||||||
network_remove "$egress_name"
|
|
||||||
|
|
||||||
nets_after="$(docker network ls --format '{{.Name}}')"
|
|
||||||
assert_not_contains "$nets_after" "$internal_name" "internal network gone after removal"
|
|
||||||
assert_not_contains "$nets_after" "$egress_name" "egress network gone after removal"
|
|
||||||
|
|
||||||
# 3. Removing a second time is still safe — the trap may run after a
|
|
||||||
# clean exit, where the resources are already gone.
|
|
||||||
assert_exit_zero "network_remove: idempotent on already-removed internal" \
|
|
||||||
network_remove "$internal_name"
|
|
||||||
assert_exit_zero "network_remove: idempotent on already-removed egress" \
|
|
||||||
network_remove "$egress_name"
|
|
||||||
|
|
||||||
# 4. pipelock_stop against a slug whose sidecar was never started must
|
|
||||||
# also be a no-op — same reason.
|
|
||||||
assert_exit_zero "pipelock_stop: missing sidecar is a no-op" \
|
|
||||||
pipelock_stop "missing-${slug}"
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Integration: verify the pinned pipelock image. Requires docker.
|
|
||||||
# - Pinned digest is reachable on the registry.
|
|
||||||
# - Image's ENTRYPOINT/CMD match what lib/pipelock.sh assumes
|
|
||||||
# (`/pipelock` and `run --listen 0.0.0.0:8888`).
|
|
||||||
# - The /pipelock binary actually runs (--version succeeds).
|
|
||||||
#
|
|
||||||
# This is the test that would have caught the runtime bug where the
|
|
||||||
# CMD shape diverged from what the launcher passed.
|
|
||||||
TEST_NAME="pipelock_image"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
skip_test_if_no_docker
|
|
||||||
|
|
||||||
# Pull the pinned image (cheap if already cached).
|
|
||||||
if ! docker pull "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" >/dev/null 2>&1; then
|
|
||||||
skip "could not pull ${CLAUDE_BOTTLE_PIPELOCK_IMAGE}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ENTRYPOINT must be the binary path lib/pipelock.sh expects.
|
|
||||||
entrypoint="$(docker image inspect "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --format '{{json .Config.Entrypoint}}')"
|
|
||||||
assert_contains "$entrypoint" "/pipelock" "entrypoint contains /pipelock"
|
|
||||||
|
|
||||||
# CMD must include `run` — the subcommand the launcher overrides via
|
|
||||||
# `docker create ... run --config ... --listen ...`. If a future image
|
|
||||||
# bumps the CMD shape, this fails loudly.
|
|
||||||
cmd="$(docker image inspect "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --format '{{json .Config.Cmd}}')"
|
|
||||||
assert_contains "$cmd" "run" "cmd contains 'run'"
|
|
||||||
|
|
||||||
# Binary actually runs.
|
|
||||||
ver="$(docker run --rm "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --version 2>&1 || true)"
|
|
||||||
assert_match "$ver" "[Pp]ipelock|2\\.[0-9]+\\.[0-9]+" "binary --version produces version-shaped output"
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Integration: full sidecar smoke test. Boots a pipelock container the
|
|
||||||
# same way cli.sh does (docker create + docker cp YAML + docker start),
|
|
||||||
# then probes /health. Catches regressions in:
|
|
||||||
# - the YAML-cp path (the /etc/pipelock.yaml vs /etc/pipelock/ bug)
|
|
||||||
# - argv shape (the `run --listen 0.0.0.0:N` invocation)
|
|
||||||
# - YAML structural validity (pipelock would refuse to start on a bad config)
|
|
||||||
TEST_NAME="pipelock_sidecar_smoke"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
skip_test_if_no_docker
|
|
||||||
|
|
||||||
# Use a distinct name so concurrent runs don't collide.
|
|
||||||
name="cb-test-pipelock-smoke-$$"
|
|
||||||
work_dir="$(mktemp -d)"
|
|
||||||
yaml="${work_dir}/pipelock.yaml"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
|
||||||
rm -rf "$work_dir"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# Generate a real config from a fixture manifest.
|
|
||||||
m="$(write_fixture fixture_minimal)"
|
|
||||||
pipelock_write_yaml "$m" dev "$yaml"
|
|
||||||
rm -f "$m"
|
|
||||||
|
|
||||||
# Same lifecycle as lib/pipelock.sh's pipelock_start, minus the
|
|
||||||
# network-attach steps (we just need a port we can curl).
|
|
||||||
docker create --name "$name" -p 0:8888 \
|
|
||||||
"$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \
|
|
||||||
run --config /etc/pipelock.yaml --listen "0.0.0.0:8888" \
|
|
||||||
>/dev/null 2>&1 \
|
|
||||||
|| { _fail "docker create failed"; test_summary; }
|
|
||||||
|
|
||||||
# This is the exact cp path that broke before — guard against
|
|
||||||
# regressing to a /etc/pipelock/ subdirectory destination.
|
|
||||||
if ! docker cp "$yaml" "${name}:/etc/pipelock.yaml" >/dev/null 2>&1; then
|
|
||||||
_fail "docker cp to /etc/pipelock.yaml failed (parent dir must already exist in image)"
|
|
||||||
test_summary
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker start "$name" >/dev/null 2>&1; then
|
|
||||||
_fail "docker start failed; check that argv 'run --listen 0.0.0.0:8888' still matches image"
|
|
||||||
test_summary
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find the host-side port docker mapped 8888 to.
|
|
||||||
hostport="$(docker port "$name" 8888 2>/dev/null | head -1 | awk -F: '{print $NF}')"
|
|
||||||
if [ -z "$hostport" ]; then
|
|
||||||
_fail "could not determine published port" "docker port output: $(docker port "$name" 2>&1)"
|
|
||||||
test_summary
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait up to 15 seconds for /health to come up.
|
|
||||||
healthy=0
|
|
||||||
for _ in $(seq 1 15); do
|
|
||||||
if curl -fsS "http://127.0.0.1:${hostport}/health" >/dev/null 2>&1; then
|
|
||||||
healthy=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$healthy" -eq 1 ]; then
|
|
||||||
_pass "sidecar /health responded"
|
|
||||||
else
|
|
||||||
_fail "sidecar /health did not respond within 15s" "logs:" "$(docker logs "$name" 2>&1 | tail -20)"
|
|
||||||
test_summary
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Body should mention the version we pinned. We don't pin the exact
|
|
||||||
# version string here because the digest we test against is one
|
|
||||||
# release; the next release will change the version field but should
|
|
||||||
# keep the schema. Keep the assertion at "field is present and has
|
|
||||||
# a numeric-dotted shape".
|
|
||||||
body="$(curl -fsS "http://127.0.0.1:${hostport}/health" 2>&1)"
|
|
||||||
assert_contains "$body" '"status":"healthy"' "/health body status:healthy"
|
|
||||||
assert_match "$body" '"version":"[0-9]+\.[0-9]+\.[0-9]+"' "/health body has version field"
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tiny assertion helpers. No framework — each test file sources this,
|
|
||||||
# calls `assert_*` functions, and ends with `test_summary` which exits
|
|
||||||
# 0 if every assertion passed and 1 otherwise.
|
|
||||||
#
|
|
||||||
# Counters are file-local: every test process gets its own TEST_PASS /
|
|
||||||
# TEST_FAIL. run_tests.sh aggregates by exit code, not by reading these.
|
|
||||||
|
|
||||||
if [ -n "${CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED:-}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED=1
|
|
||||||
|
|
||||||
TEST_PASS=0
|
|
||||||
TEST_FAIL=0
|
|
||||||
TEST_NAME="${TEST_NAME:-unnamed}"
|
|
||||||
|
|
||||||
if [ -t 1 ]; then
|
|
||||||
_C_PASS=$'\033[32m'
|
|
||||||
_C_FAIL=$'\033[31m'
|
|
||||||
_C_SKIP=$'\033[33m'
|
|
||||||
_C_RESET=$'\033[0m'
|
|
||||||
else
|
|
||||||
_C_PASS=""
|
|
||||||
_C_FAIL=""
|
|
||||||
_C_SKIP=""
|
|
||||||
_C_RESET=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
_pass() {
|
|
||||||
TEST_PASS=$((TEST_PASS + 1))
|
|
||||||
printf ' %sPASS%s %s\n' "$_C_PASS" "$_C_RESET" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
_fail() {
|
|
||||||
TEST_FAIL=$((TEST_FAIL + 1))
|
|
||||||
printf ' %sFAIL%s %s\n' "$_C_FAIL" "$_C_RESET" "$1" >&2
|
|
||||||
shift
|
|
||||||
local line
|
|
||||||
for line in "$@"; do
|
|
||||||
printf ' %s\n' "$line" >&2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq() {
|
|
||||||
local expected="$1" actual="$2" msg="${3:-equal}"
|
|
||||||
if [ "$expected" = "$actual" ]; then
|
|
||||||
_pass "$msg"
|
|
||||||
else
|
|
||||||
_fail "$msg" "expected: ${expected}" "actual: ${actual}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_contains() {
|
|
||||||
local haystack="$1" needle="$2" msg="${3:-contains}"
|
|
||||||
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
|
|
||||||
_pass "$msg"
|
|
||||||
else
|
|
||||||
_fail "$msg" "expected to contain: ${needle}" "haystack: ${haystack}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_not_contains() {
|
|
||||||
local haystack="$1" needle="$2" msg="${3:-does not contain}"
|
|
||||||
if ! printf '%s' "$haystack" | grep -qF -- "$needle"; then
|
|
||||||
_pass "$msg"
|
|
||||||
else
|
|
||||||
_fail "$msg" "expected NOT to contain: ${needle}" "haystack: ${haystack}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_match() {
|
|
||||||
local haystack="$1" pattern="$2" msg="${3:-matches}"
|
|
||||||
if printf '%s' "$haystack" | grep -qE -- "$pattern"; then
|
|
||||||
_pass "$msg"
|
|
||||||
else
|
|
||||||
_fail "$msg" "expected pattern: ${pattern}" "haystack: ${haystack}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# assert_exit_zero <cmd...> — runs the command, fails the assertion
|
|
||||||
# if it exits non-zero. Captures stdout+stderr for the failure message.
|
|
||||||
assert_exit_zero() {
|
|
||||||
local label="$1"; shift
|
|
||||||
local out
|
|
||||||
if out="$("$@" 2>&1)"; then
|
|
||||||
_pass "$label"
|
|
||||||
else
|
|
||||||
_fail "$label" "exit non-zero" "output: ${out}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_exit_nonzero() {
|
|
||||||
local label="$1"; shift
|
|
||||||
local out
|
|
||||||
if out="$("$@" 2>&1)"; then
|
|
||||||
_fail "$label" "exit was 0; expected non-zero" "output: ${out}"
|
|
||||||
else
|
|
||||||
_pass "$label"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
skip() {
|
|
||||||
printf ' %sSKIP%s %s\n' "$_C_SKIP" "$_C_RESET" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
skip_test_if_no_docker() {
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
printf '%sSKIP%s %s — docker not on PATH\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if ! docker info >/dev/null 2>&1; then
|
|
||||||
printf '%sSKIP%s %s — docker daemon unreachable\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
test_summary() {
|
|
||||||
printf '\n%s: %d passed, %d failed\n' "$TEST_NAME" "$TEST_PASS" "$TEST_FAIL"
|
|
||||||
if [ "$TEST_FAIL" -gt 0 ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Common scaffolding for every test file. Sources assert.sh and computes
|
|
||||||
# REPO_ROOT so tests can `. "${REPO_ROOT}/lib/<x>.sh"` to load the code
|
|
||||||
# they're exercising.
|
|
||||||
|
|
||||||
if [ -n "${CLAUDE_BOTTLE_TESTS_COMMON_SOURCED:-}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
CLAUDE_BOTTLE_TESTS_COMMON_SOURCED=1
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
_tests_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
TESTS_ROOT="$_tests_dir"
|
|
||||||
REPO_ROOT="$(CDPATH= cd -- "${TESTS_ROOT}/.." && pwd)"
|
|
||||||
|
|
||||||
# shellcheck source=./assert.sh
|
|
||||||
. "${TESTS_ROOT}/lib/assert.sh"
|
|
||||||
# shellcheck source=./fixtures.sh
|
|
||||||
. "${TESTS_ROOT}/lib/fixtures.sh"
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Manifest fixture builders. Each function prints a JSON manifest on
|
|
||||||
# stdout; callers can pipe to a temp file or pass through `write_fixture`.
|
|
||||||
|
|
||||||
if [ -n "${CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED:-}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED=1
|
|
||||||
|
|
||||||
# fixture_minimal — one bottle, one agent, no env / ssh / skills.
|
|
||||||
fixture_minimal() {
|
|
||||||
cat <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": {
|
|
||||||
"dev": {}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {
|
|
||||||
"skills": [],
|
|
||||||
"prompt": "",
|
|
||||||
"bottle": "dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
# fixture_with_egress — bottle declares an egress.allowlist.
|
|
||||||
fixture_with_egress() {
|
|
||||||
cat <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"egress": {
|
|
||||||
"allowlist": [
|
|
||||||
"github.com",
|
|
||||||
"gitlab.com",
|
|
||||||
"registry.npmjs.org"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {
|
|
||||||
"skills": [],
|
|
||||||
"prompt": "",
|
|
||||||
"bottle": "dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
# fixture_with_ssh — bottle has both an IPv4-literal SSH host (Tailscale
|
|
||||||
# CGNAT range) and a hostname SSH host, exercising both
|
|
||||||
# ssrf.ip_allowlist and trusted_domains code paths.
|
|
||||||
fixture_with_ssh() {
|
|
||||||
cat <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [
|
|
||||||
{
|
|
||||||
"Host": "tailscale-gitea",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "100.78.141.42",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 30009
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Host": "github",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "github.com",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 22
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {
|
|
||||||
"skills": [],
|
|
||||||
"prompt": "",
|
|
||||||
"bottle": "dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
# write_fixture <fixture_func> — write fixture to a temp file, print
|
|
||||||
# the path. Caller must rm.
|
|
||||||
write_fixture() {
|
|
||||||
local fn="${1:?write_fixture: missing fixture function}"
|
|
||||||
local f
|
|
||||||
f="$(mktemp)"
|
|
||||||
"$fn" > "$f"
|
|
||||||
printf '%s' "$f"
|
|
||||||
}
|
|
||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test runner. Wraps unittest's discovery so we can split unit /
|
||||||
|
integration the same way the bash runner did.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tests/run_tests.py # unit + integration
|
||||||
|
tests/run_tests.py unit # unit only (no docker)
|
||||||
|
tests/run_tests.py integration # integration only (need docker)
|
||||||
|
tests/run_tests.py tests/test_x.py # one specific file (or path)
|
||||||
|
|
||||||
|
Tests are auto-classified as integration when their filename matches
|
||||||
|
one of INTEGRATION_NAMES below; everything else is a unit test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TESTS_DIR = REPO_ROOT / "tests"
|
||||||
|
|
||||||
|
INTEGRATION_NAMES = {
|
||||||
|
"test_dry_run_plan.py",
|
||||||
|
"test_orphan_cleanup.py",
|
||||||
|
"test_pipelock_image.py",
|
||||||
|
"test_pipelock_sidecar_smoke.py",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _all_test_files() -> list[Path]:
|
||||||
|
return sorted(TESTS_DIR.glob("test_*.py"))
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(path: Path) -> str:
|
||||||
|
return "integration" if path.name in INTEGRATION_NAMES else "unit"
|
||||||
|
|
||||||
|
|
||||||
|
def _modname(path: Path) -> str:
|
||||||
|
return f"tests.{path.stem}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_suite(files: list[Path]) -> unittest.TestSuite:
|
||||||
|
loader = unittest.TestLoader()
|
||||||
|
suite = unittest.TestSuite()
|
||||||
|
for f in files:
|
||||||
|
suite.addTests(loader.loadTestsFromName(_modname(f)))
|
||||||
|
return suite
|
||||||
|
|
||||||
|
|
||||||
|
def usage() -> None:
|
||||||
|
sys.stderr.write(
|
||||||
|
"usage: tests/run_tests.py [unit|integration|path/to/test.py]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
if not argv:
|
||||||
|
files = _all_test_files()
|
||||||
|
else:
|
||||||
|
arg = argv[0]
|
||||||
|
if arg in ("-h", "--help"):
|
||||||
|
usage()
|
||||||
|
return 0
|
||||||
|
if arg == "unit":
|
||||||
|
files = [f for f in _all_test_files() if _classify(f) == "unit"]
|
||||||
|
elif arg == "integration":
|
||||||
|
files = [f for f in _all_test_files() if _classify(f) == "integration"]
|
||||||
|
else:
|
||||||
|
p = Path(arg).resolve()
|
||||||
|
if not p.is_file():
|
||||||
|
sys.stderr.write(f"no such file: {arg}\n")
|
||||||
|
usage()
|
||||||
|
return 2
|
||||||
|
files = [p]
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
sys.stderr.write("no test files found\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
suite = _build_suite(files)
|
||||||
|
runner = unittest.TextTestRunner(verbosity=2)
|
||||||
|
result = runner.run(suite)
|
||||||
|
return 0 if result.wasSuccessful() else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Test runner. Iterates over test_*.sh files in unit/ and integration/
|
|
||||||
# (or just one of them when given a `unit` / `integration` argument)
|
|
||||||
# and runs each as a separate process. Aggregates exit codes and
|
|
||||||
# prints a summary.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# tests/run_tests.sh # unit + integration
|
|
||||||
# tests/run_tests.sh unit # unit only
|
|
||||||
# tests/run_tests.sh integration # integration only
|
|
||||||
# tests/run_tests.sh path/to/test_x.sh # one specific file
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
||||||
|
|
||||||
if [ -t 1 ]; then
|
|
||||||
C_PASS=$'\033[32m'
|
|
||||||
C_FAIL=$'\033[31m'
|
|
||||||
C_HEAD=$'\033[36m'
|
|
||||||
C_RESET=$'\033[0m'
|
|
||||||
else
|
|
||||||
C_PASS=""
|
|
||||||
C_FAIL=""
|
|
||||||
C_HEAD=""
|
|
||||||
C_RESET=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
usage: $(basename "$0") [unit|integration|path/to/test.sh]
|
|
||||||
no arg run unit + integration
|
|
||||||
unit run only tests/unit/test_*.sh
|
|
||||||
integration run only tests/integration/test_*.sh
|
|
||||||
<path> run a single test file
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# Collect test files.
|
|
||||||
declare -a FILES=()
|
|
||||||
case "${1:-}" in
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
unit) FILES=("${_dir}"/unit/test_*.sh) ;;
|
|
||||||
integration) FILES=("${_dir}"/integration/test_*.sh) ;;
|
|
||||||
"") FILES=("${_dir}"/unit/test_*.sh "${_dir}"/integration/test_*.sh) ;;
|
|
||||||
*)
|
|
||||||
if [ -f "$1" ]; then
|
|
||||||
FILES=("$1")
|
|
||||||
else
|
|
||||||
printf 'no such file: %s\n' "$1" >&2
|
|
||||||
usage
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Filter out non-existent globs (no matching files).
|
|
||||||
declare -a EXISTING=()
|
|
||||||
for f in "${FILES[@]}"; do
|
|
||||||
[ -f "$f" ] && EXISTING+=("$f")
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "${#EXISTING[@]}" -eq 0 ]; then
|
|
||||||
printf 'no test files found\n' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
PASS_COUNT=0
|
|
||||||
FAIL_COUNT=0
|
|
||||||
declare -a FAIL_FILES=()
|
|
||||||
|
|
||||||
for f in "${EXISTING[@]}"; do
|
|
||||||
rel="${f#${_dir}/}"
|
|
||||||
printf '%s== %s ==%s\n' "$C_HEAD" "$rel" "$C_RESET"
|
|
||||||
if bash "$f"; then
|
|
||||||
PASS_COUNT=$((PASS_COUNT + 1))
|
|
||||||
else
|
|
||||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
||||||
FAIL_FILES+=("$rel")
|
|
||||||
fi
|
|
||||||
printf '\n'
|
|
||||||
done
|
|
||||||
|
|
||||||
# Summary.
|
|
||||||
TOTAL=$((PASS_COUNT + FAIL_COUNT))
|
|
||||||
printf '%ssummary%s: %d/%d test files passed\n' "$C_HEAD" "$C_RESET" "$PASS_COUNT" "$TOTAL"
|
|
||||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
|
||||||
printf '%sfailed%s:\n' "$C_FAIL" "$C_RESET"
|
|
||||||
for f in "${FAIL_FILES[@]}"; do
|
|
||||||
printf ' - %s\n' "$f"
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%sall tests passed%s\n' "$C_PASS" "$C_RESET"
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Integration: cli.py start --dry-run renders the planned shape and
|
||||||
|
does not create any docker resources. Confirms the preflight contract
|
||||||
|
from PRD 0001 (allowlist line in the plan, no docker side effects)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestDryRunPlan(unittest.TestCase):
|
||||||
|
def test_dry_run(self):
|
||||||
|
work_dir = Path(tempfile.mkdtemp())
|
||||||
|
try:
|
||||||
|
manifest = work_dir / "claude-bottle.json"
|
||||||
|
manifest.write_text(json.dumps({
|
||||||
|
"bottles": {"dev": {"egress": {"allowlist": ["example.org"]}}},
|
||||||
|
"agents": {
|
||||||
|
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
nets_before = self._count_claude_bottle_networks()
|
||||||
|
ctrs_before = self._count_claude_bottle_containers()
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(work_dir)
|
||||||
|
env["CLAUDE_BOTTLE_DRY_RUN"] = "1"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(REPO_ROOT / "cli.py"), "start", "demo"],
|
||||||
|
cwd=work_dir,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
out = result.stdout + result.stderr
|
||||||
|
|
||||||
|
self.assertIn("egress", out, "preflight: egress line present")
|
||||||
|
# 7 baked defaults + 1 bottle entry = 8.
|
||||||
|
self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted")
|
||||||
|
self.assertIn("api.anthropic.com", out, "preflight: baked default shown")
|
||||||
|
self.assertIn("dry-run requested", out, "dry-run banner present")
|
||||||
|
self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt")
|
||||||
|
|
||||||
|
self.assertEqual(nets_before, self._count_claude_bottle_networks(),
|
||||||
|
"no networks created")
|
||||||
|
self.assertEqual(ctrs_before, self._count_claude_bottle_containers(),
|
||||||
|
"no containers created")
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _count_claude_bottle_networks(self) -> int:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "ls", "--format", "{{.Name}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return sum(1 for n in result.stdout.splitlines() if n.startswith("claude-bottle"))
|
||||||
|
|
||||||
|
def _count_claude_bottle_containers(self) -> int:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{.Names}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return sum(1 for n in result.stdout.splitlines() if n.startswith("claude-bottle"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Integration: the cleanup primitives the start-flow trap depends on
|
||||||
|
are idempotent. The original orphan-network bug was a trap-ordering
|
||||||
|
issue; the fix moved the install earlier. The trap is only safe if
|
||||||
|
network_remove and pipelock_stop are no-ops against missing resources."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.network import (
|
||||||
|
network_create_egress,
|
||||||
|
network_create_internal,
|
||||||
|
network_remove,
|
||||||
|
)
|
||||||
|
from claude_bottle.pipelock import pipelock_stop
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestOrphanCleanup(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.slug = f"cb-test-orphan-{os.getpid()}"
|
||||||
|
self.internal_name = ""
|
||||||
|
self.egress_name = ""
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for n in (self.internal_name, self.egress_name):
|
||||||
|
if n:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "network", "rm", n],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remove_missing_is_noop(self):
|
||||||
|
# Returning True == idempotent success.
|
||||||
|
self.assertTrue(network_remove(f"claude-bottle-net-{self.slug}-does-not-exist"))
|
||||||
|
|
||||||
|
def test_create_and_remove(self):
|
||||||
|
self.internal_name = network_create_internal(self.slug)
|
||||||
|
self.egress_name = network_create_egress(self.slug)
|
||||||
|
|
||||||
|
nets = subprocess.run(
|
||||||
|
["docker", "network", "ls", "--format", "{{.Name}}"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
).stdout.splitlines()
|
||||||
|
self.assertIn(self.internal_name, nets)
|
||||||
|
self.assertIn(self.egress_name, nets)
|
||||||
|
|
||||||
|
self.assertTrue(network_remove(self.internal_name))
|
||||||
|
self.assertTrue(network_remove(self.egress_name))
|
||||||
|
|
||||||
|
nets_after = subprocess.run(
|
||||||
|
["docker", "network", "ls", "--format", "{{.Name}}"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
).stdout.splitlines()
|
||||||
|
self.assertNotIn(self.internal_name, nets_after)
|
||||||
|
self.assertNotIn(self.egress_name, nets_after)
|
||||||
|
|
||||||
|
# Idempotent on already-removed.
|
||||||
|
self.assertTrue(network_remove(self.internal_name))
|
||||||
|
self.assertTrue(network_remove(self.egress_name))
|
||||||
|
|
||||||
|
def test_pipelock_stop_missing_sidecar(self):
|
||||||
|
# Should not raise.
|
||||||
|
pipelock_stop(f"missing-{self.slug}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Unit: allowlist resolution — pipelock_bottle_allowlist,
|
||||||
|
pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
|
||||||
|
pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.log import Die
|
||||||
|
from claude_bottle.pipelock import (
|
||||||
|
pipelock_bottle_allowlist,
|
||||||
|
pipelock_bottle_ssh_hostnames,
|
||||||
|
pipelock_bottle_ssh_ip_cidrs,
|
||||||
|
pipelock_bottle_ssh_trusted_domains,
|
||||||
|
pipelock_effective_allowlist,
|
||||||
|
)
|
||||||
|
from tests.fixtures import fixture_minimal, fixture_with_egress, fixture_with_ssh
|
||||||
|
|
||||||
|
|
||||||
|
class TestBottleAllowlist(unittest.TestCase):
|
||||||
|
def test_egress_allowlist_present(self):
|
||||||
|
out = pipelock_bottle_allowlist(fixture_with_egress(), "dev")
|
||||||
|
self.assertIn("github.com", out)
|
||||||
|
self.assertIn("gitlab.com", out)
|
||||||
|
self.assertIn("registry.npmjs.org", out)
|
||||||
|
|
||||||
|
def test_empty_when_no_egress_block(self):
|
||||||
|
out = pipelock_bottle_allowlist(fixture_minimal(), "dev")
|
||||||
|
self.assertEqual([], out)
|
||||||
|
|
||||||
|
def test_rejects_non_string_entry(self):
|
||||||
|
bad = {
|
||||||
|
"bottles": {"dev": {"egress": {"allowlist": ["github.com", 42]}}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
with self.assertRaises(Die):
|
||||||
|
pipelock_bottle_allowlist(bad, "dev")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSSHHostnames(unittest.TestCase):
|
||||||
|
def test_hostnames_include_both(self):
|
||||||
|
hosts = pipelock_bottle_ssh_hostnames(fixture_with_ssh(), "dev")
|
||||||
|
self.assertIn("100.78.141.42", hosts)
|
||||||
|
self.assertIn("github.com", hosts)
|
||||||
|
|
||||||
|
def test_ip_cidrs_only_ipv4(self):
|
||||||
|
cidrs = pipelock_bottle_ssh_ip_cidrs(fixture_with_ssh(), "dev")
|
||||||
|
self.assertIn("100.78.141.42/32", cidrs)
|
||||||
|
self.assertNotIn("github.com", cidrs)
|
||||||
|
|
||||||
|
def test_trusted_domains_only_hostnames(self):
|
||||||
|
trusted = pipelock_bottle_ssh_trusted_domains(fixture_with_ssh(), "dev")
|
||||||
|
self.assertIn("github.com", trusted)
|
||||||
|
self.assertNotIn("100.78.141.42", trusted)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveAllowlist(unittest.TestCase):
|
||||||
|
def test_union_and_dedup(self):
|
||||||
|
manifest = {
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"egress": {"allowlist": ["registry.npmjs.org"]},
|
||||||
|
"ssh": [
|
||||||
|
{"Host": "ts", "IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "100.78.141.42", "User": "git", "Port": 30009},
|
||||||
|
{"Host": "gh", "IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "github.com", "User": "git", "Port": 22},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
eff = pipelock_effective_allowlist(manifest, "dev")
|
||||||
|
self.assertIn("api.anthropic.com", eff)
|
||||||
|
self.assertIn("registry.npmjs.org", eff)
|
||||||
|
self.assertIn("100.78.141.42", eff)
|
||||||
|
self.assertIn("github.com", eff)
|
||||||
|
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
||||||
|
self.assertEqual(eff, sorted(eff), "sorted")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Unit: is_ipv4_literal — the classifier that decides whether
|
||||||
|
bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4 literal) or
|
||||||
|
trusted_domains (hostname)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.pipelock import is_ipv4_literal
|
||||||
|
|
||||||
|
|
||||||
|
class TestIPv4Classify(unittest.TestCase):
|
||||||
|
def test_positive(self):
|
||||||
|
for ip in ("127.0.0.1", "10.0.0.5", "100.78.141.42", "0.0.0.0", "255.255.255.255"):
|
||||||
|
with self.subTest(ip=ip):
|
||||||
|
self.assertTrue(is_ipv4_literal(ip), ip)
|
||||||
|
|
||||||
|
def test_negative(self):
|
||||||
|
for hn in (
|
||||||
|
"github.com",
|
||||||
|
"gitea.dideric.is",
|
||||||
|
"100.78.141",
|
||||||
|
"100.78.141.42.5",
|
||||||
|
"::1",
|
||||||
|
"fe80::1",
|
||||||
|
"localhost",
|
||||||
|
"",
|
||||||
|
"1.2.3.4.example.com",
|
||||||
|
):
|
||||||
|
with self.subTest(hn=hn):
|
||||||
|
self.assertFalse(is_ipv4_literal(hn), hn)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Integration: verify the pinned pipelock image. Requires docker.
|
||||||
|
- Pinned digest is reachable on the registry.
|
||||||
|
- Image's ENTRYPOINT/CMD match what claude_bottle.pipelock assumes
|
||||||
|
(`/pipelock` and `run --listen 0.0.0.0:8888`).
|
||||||
|
- The /pipelock binary actually runs (--version succeeds)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.pipelock import PIPELOCK_IMAGE
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestPipelockImage(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Pull the pinned image (cheap if cached).
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "pull", PIPELOCK_IMAGE],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise unittest.SkipTest(f"could not pull {PIPELOCK_IMAGE}")
|
||||||
|
|
||||||
|
def test_entrypoint_contains_pipelock(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "image", "inspect", PIPELOCK_IMAGE,
|
||||||
|
"--format", "{{json .Config.Entrypoint}}"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
self.assertIn("/pipelock", result.stdout)
|
||||||
|
|
||||||
|
def test_cmd_contains_run(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "image", "inspect", PIPELOCK_IMAGE,
|
||||||
|
"--format", "{{json .Config.Cmd}}"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
self.assertIn("run", result.stdout)
|
||||||
|
|
||||||
|
def test_binary_runs(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "run", "--rm", PIPELOCK_IMAGE, "--version"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
out = result.stdout + result.stderr
|
||||||
|
self.assertRegex(out, r"[Pp]ipelock|2\.[0-9]+\.[0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.pipelock import (
|
||||||
|
pipelock_container_name,
|
||||||
|
pipelock_proxy_host_port,
|
||||||
|
pipelock_proxy_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelockNaming(unittest.TestCase):
|
||||||
|
def test_container_name_simple(self):
|
||||||
|
self.assertEqual("claude-bottle-pipelock-foo", pipelock_container_name("foo"))
|
||||||
|
|
||||||
|
def test_container_name_with_hyphens(self):
|
||||||
|
self.assertEqual(
|
||||||
|
"claude-bottle-pipelock-some-slug", pipelock_container_name("some-slug")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proxy_url_default_port(self):
|
||||||
|
self.assertEqual(
|
||||||
|
"http://claude-bottle-pipelock-foo:8888", pipelock_proxy_url("foo")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proxy_host_port_default_port(self):
|
||||||
|
self.assertEqual(
|
||||||
|
"claude-bottle-pipelock-foo:8888", pipelock_proxy_host_port("foo")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Integration: full sidecar smoke test. Boots a pipelock container the
|
||||||
|
same way cli.py does (docker create + docker cp YAML + docker start),
|
||||||
|
then probes /health."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.pipelock import PIPELOCK_IMAGE, pipelock_write_yaml
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
from tests.fixtures import fixture_minimal
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestPipelockSidecarSmoke(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.name = f"cb-test-pipelock-smoke-{os.getpid()}"
|
||||||
|
self.work_dir = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", self.name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
shutil.rmtree(self.work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_smoke(self):
|
||||||
|
yaml_path = self.work_dir / "pipelock.yaml"
|
||||||
|
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||||
|
|
||||||
|
create = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker", "create",
|
||||||
|
"--name", self.name,
|
||||||
|
"-p", "0:8888",
|
||||||
|
PIPELOCK_IMAGE,
|
||||||
|
"run", "--config", "/etc/pipelock.yaml",
|
||||||
|
"--listen", "0.0.0.0:8888",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, create.returncode, f"docker create failed: {create.stderr}")
|
||||||
|
|
||||||
|
# Guard against /etc/pipelock/ regressions: the path must be
|
||||||
|
# /etc/pipelock.yaml, since the image is distroless.
|
||||||
|
cp = subprocess.run(
|
||||||
|
["docker", "cp", str(yaml_path), f"{self.name}:/etc/pipelock.yaml"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, cp.returncode, f"docker cp failed: {cp.stderr}")
|
||||||
|
|
||||||
|
start = subprocess.run(
|
||||||
|
["docker", "start", self.name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, start.returncode,
|
||||||
|
f"docker start failed; check argv 'run --listen 0.0.0.0:8888'")
|
||||||
|
|
||||||
|
port_result = subprocess.run(
|
||||||
|
["docker", "port", self.name, "8888"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
first_line = (port_result.stdout or "").splitlines()[0] if port_result.stdout else ""
|
||||||
|
host_port = first_line.rsplit(":", 1)[-1] if first_line else ""
|
||||||
|
self.assertTrue(host_port, "could not determine published port")
|
||||||
|
|
||||||
|
health_url = f"http://127.0.0.1:{host_port}/health"
|
||||||
|
body = ""
|
||||||
|
for _ in range(15):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(health_url, timeout=2) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
break
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError):
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
self.assertIn('"status":"healthy"', body, "health body status:healthy")
|
||||||
|
self.assertRegex(body, r'"version":"[0-9]+\.[0-9]+\.[0-9]+"',
|
||||||
|
"health body has version field")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Unit: pipelock_write_yaml — produces a YAML config containing the
|
||||||
|
expected top-level keys and per-bottle entries. We don't fully parse
|
||||||
|
YAML; we grep for content shape."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.pipelock import pipelock_write_yaml
|
||||||
|
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelockYaml(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.out_dir = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.out_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_minimal(self):
|
||||||
|
yaml_path = self.out_dir / "min.yaml"
|
||||||
|
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||||
|
content = yaml_path.read_text()
|
||||||
|
self.assertIn("mode: strict", content)
|
||||||
|
self.assertIn("enforce: true", content)
|
||||||
|
self.assertIn("api_allowlist:", content)
|
||||||
|
self.assertIn("api.anthropic.com", content)
|
||||||
|
self.assertIn("raw.githubusercontent.com", content)
|
||||||
|
self.assertIn("forward_proxy:", content)
|
||||||
|
self.assertIn("enabled: true", content)
|
||||||
|
self.assertIn("dlp:", content)
|
||||||
|
self.assertIn("include_defaults: true", content)
|
||||||
|
self.assertIn("scan_env: true", content)
|
||||||
|
# No ssh entries → no trusted_domains nor ssrf block.
|
||||||
|
self.assertNotIn("trusted_domains:", content)
|
||||||
|
self.assertNotIn("ssrf:", content)
|
||||||
|
|
||||||
|
def test_ssh_blocks(self):
|
||||||
|
yaml_path = self.out_dir / "ssh.yaml"
|
||||||
|
pipelock_write_yaml(fixture_with_ssh(), "dev", yaml_path)
|
||||||
|
content = yaml_path.read_text()
|
||||||
|
self.assertIn("trusted_domains:", content)
|
||||||
|
self.assertIn("github.com", content)
|
||||||
|
self.assertIn("ssrf:", content)
|
||||||
|
self.assertIn("ip_allowlist:", content)
|
||||||
|
self.assertIn("100.78.141.42/32", content)
|
||||||
|
# ipv4 host should also be in api_allowlist (strict mode requires both).
|
||||||
|
self.assertIn("100.78.141.42", content)
|
||||||
|
|
||||||
|
def test_secret_hygiene(self):
|
||||||
|
manifest = {
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"env": {
|
||||||
|
"MY_SECRET": "literal-value-should-not-appear",
|
||||||
|
"ANOTHER": "?prompt-message",
|
||||||
|
},
|
||||||
|
"egress": {"allowlist": ["github.com"]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}
|
||||||
|
yaml_path = self.out_dir / "secret.yaml"
|
||||||
|
pipelock_write_yaml(manifest, "dev", yaml_path)
|
||||||
|
content = yaml_path.read_text()
|
||||||
|
self.assertNotIn("literal-value-should-not-appear", content)
|
||||||
|
self.assertNotIn("MY_SECRET", content)
|
||||||
|
self.assertNotIn("prompt-message", content)
|
||||||
|
|
||||||
|
def test_file_mode_is_600(self):
|
||||||
|
yaml_path = self.out_dir / "min.yaml"
|
||||||
|
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
|
||||||
|
mode = os.stat(yaml_path).st_mode & 0o777
|
||||||
|
self.assertEqual(0o600, mode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Unit: allowlist resolution — pipelock_bottle_allowlist,
|
|
||||||
# pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
|
|
||||||
# pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist.
|
|
||||||
TEST_NAME="pipelock_allowlist"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
# --- bottle_allowlist (egress.allowlist parsing) ---
|
|
||||||
|
|
||||||
m="$(write_fixture fixture_with_egress)"
|
|
||||||
out="$(pipelock_bottle_allowlist "$m" dev)"
|
|
||||||
assert_contains "$out" "github.com" "bottle_allowlist: github.com present"
|
|
||||||
assert_contains "$out" "gitlab.com" "bottle_allowlist: gitlab.com present"
|
|
||||||
assert_contains "$out" "registry.npmjs.org" "bottle_allowlist: npmjs present"
|
|
||||||
rm -f "$m"
|
|
||||||
|
|
||||||
m="$(write_fixture fixture_minimal)"
|
|
||||||
out="$(pipelock_bottle_allowlist "$m" dev)"
|
|
||||||
assert_eq "" "$out" "bottle_allowlist: empty when no egress block"
|
|
||||||
rm -f "$m"
|
|
||||||
|
|
||||||
# --- ssh hostnames + classification ---
|
|
||||||
|
|
||||||
m="$(write_fixture fixture_with_ssh)"
|
|
||||||
hosts="$(pipelock_bottle_ssh_hostnames "$m" dev)"
|
|
||||||
assert_contains "$hosts" "100.78.141.42" "ssh_hostnames: ipv4 included"
|
|
||||||
assert_contains "$hosts" "github.com" "ssh_hostnames: hostname included"
|
|
||||||
|
|
||||||
cidrs="$(pipelock_bottle_ssh_ip_cidrs "$m" dev)"
|
|
||||||
assert_contains "$cidrs" "100.78.141.42/32" "ssh_ip_cidrs: ipv4 emitted as /32"
|
|
||||||
assert_not_contains "$cidrs" "github.com" "ssh_ip_cidrs: hostname not in cidr list"
|
|
||||||
|
|
||||||
trusted="$(pipelock_bottle_ssh_trusted_domains "$m" dev)"
|
|
||||||
assert_contains "$trusted" "github.com" "ssh_trusted_domains: hostname present"
|
|
||||||
assert_not_contains "$trusted" "100.78.141.42" "ssh_trusted_domains: ipv4 not present"
|
|
||||||
rm -f "$m"
|
|
||||||
|
|
||||||
# --- effective_allowlist union (defaults + bottle.allowlist + ssh.Hostname) ---
|
|
||||||
|
|
||||||
# Combine egress + ssh fixtures into one manifest.
|
|
||||||
combined="$(mktemp)"
|
|
||||||
cat > "$combined" <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"egress": { "allowlist": ["registry.npmjs.org"] },
|
|
||||||
"ssh": [
|
|
||||||
{ "Host": "ts", "IdentityFile": "/dev/null", "Hostname": "100.78.141.42", "User": "git", "Port": 30009 },
|
|
||||||
{ "Host": "gh", "IdentityFile": "/dev/null", "Hostname": "github.com", "User": "git", "Port": 22 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
eff="$(pipelock_effective_allowlist "$combined" dev)"
|
|
||||||
assert_contains "$eff" "api.anthropic.com" "effective: baked-in default present"
|
|
||||||
assert_contains "$eff" "registry.npmjs.org" "effective: bottle egress entry present"
|
|
||||||
assert_contains "$eff" "100.78.141.42" "effective: ssh ipv4 hostname present"
|
|
||||||
assert_contains "$eff" "github.com" "effective: ssh hostname present"
|
|
||||||
|
|
||||||
# Ensure dedup + sort: count lines, then count unique lines, expect equal.
|
|
||||||
total="$(printf '%s\n' "$eff" | wc -l | tr -d ' ')"
|
|
||||||
uniq="$(printf '%s\n' "$eff" | sort -u | wc -l | tr -d ' ')"
|
|
||||||
assert_eq "$total" "$uniq" "effective: deduplicated"
|
|
||||||
|
|
||||||
rm -f "$combined"
|
|
||||||
|
|
||||||
# --- non-string entry rejection ---
|
|
||||||
|
|
||||||
bad="$(mktemp)"
|
|
||||||
cat > "$bad" <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": { "dev": { "egress": { "allowlist": ["github.com", 42] } } },
|
|
||||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
assert_exit_nonzero "bottle_allowlist: rejects non-string entry" \
|
|
||||||
bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_bottle_allowlist "'"$bad"'" dev'
|
|
||||||
rm -f "$bad"
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Unit: _pipelock_is_ipv4_literal — the classifier that decides
|
|
||||||
# whether bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4
|
|
||||||
# literal) or trusted_domains (hostname).
|
|
||||||
TEST_NAME="pipelock_classify"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
# Positive cases — these should be classified as IPv4 literals.
|
|
||||||
for ip in "127.0.0.1" "10.0.0.5" "100.78.141.42" "0.0.0.0" "255.255.255.255"; do
|
|
||||||
assert_exit_zero "ipv4: ${ip}" _pipelock_is_ipv4_literal "$ip"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Negative cases — hostnames, partial IPs, IPv6, and edge garbage
|
|
||||||
# should NOT match.
|
|
||||||
for hn in \
|
|
||||||
"github.com" \
|
|
||||||
"gitea.dideric.is" \
|
|
||||||
"100.78.141" \
|
|
||||||
"100.78.141.42.5" \
|
|
||||||
"::1" \
|
|
||||||
"fe80::1" \
|
|
||||||
"localhost" \
|
|
||||||
"" \
|
|
||||||
"1.2.3.4.example.com"
|
|
||||||
do
|
|
||||||
assert_exit_nonzero "non-ipv4: '${hn}'" _pipelock_is_ipv4_literal "$hn"
|
|
||||||
done
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port).
|
|
||||||
TEST_NAME="pipelock_naming"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
assert_eq "claude-bottle-pipelock-foo" "$(pipelock_container_name foo)" "container_name simple slug"
|
|
||||||
assert_eq "claude-bottle-pipelock-some-slug" "$(pipelock_container_name some-slug)" "container_name with hyphens"
|
|
||||||
|
|
||||||
# proxy_url and proxy_host_port use whatever CLAUDE_BOTTLE_PIPELOCK_PORT
|
|
||||||
# is at source time. We sourced with default (8888).
|
|
||||||
assert_eq "http://claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_url foo)" "proxy_url default port"
|
|
||||||
assert_eq "claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_host_port foo)" "proxy_host_port default port"
|
|
||||||
|
|
||||||
# Both helpers should fail loudly without a slug (the `${1:?...}` guards).
|
|
||||||
assert_exit_nonzero "container_name: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_container_name'
|
|
||||||
assert_exit_nonzero "proxy_url: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_proxy_url'
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Unit: pipelock_write_yaml — produces a YAML config containing the
|
|
||||||
# expected top-level keys and per-bottle entries. We don't fully parse
|
|
||||||
# YAML (no yq dependency); we grep for content shape.
|
|
||||||
TEST_NAME="pipelock_yaml"
|
|
||||||
|
|
||||||
. "$(dirname "$0")/../lib/common.sh"
|
|
||||||
# shellcheck source=../../lib/log.sh
|
|
||||||
. "${REPO_ROOT}/lib/log.sh"
|
|
||||||
# shellcheck source=../../lib/pipelock.sh
|
|
||||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
|
||||||
|
|
||||||
out_dir="$(mktemp -d)"
|
|
||||||
cleanup() { rm -rf "$out_dir"; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# --- minimal bottle (no egress, no ssh): only api_allowlist defaults ---
|
|
||||||
|
|
||||||
m_min="$(write_fixture fixture_minimal)"
|
|
||||||
yaml_min="${out_dir}/min.yaml"
|
|
||||||
pipelock_write_yaml "$m_min" dev "$yaml_min"
|
|
||||||
|
|
||||||
content="$(cat "$yaml_min")"
|
|
||||||
assert_contains "$content" "mode: strict" "min: mode strict"
|
|
||||||
assert_contains "$content" "enforce: true" "min: enforce true"
|
|
||||||
assert_contains "$content" "api_allowlist:" "min: api_allowlist block"
|
|
||||||
assert_contains "$content" "api.anthropic.com" "min: anthropic baked default"
|
|
||||||
assert_contains "$content" "raw.githubusercontent.com" "min: github raw baked default"
|
|
||||||
assert_contains "$content" "forward_proxy:" "min: forward_proxy block"
|
|
||||||
assert_contains "$content" "enabled: true" "min: forward_proxy enabled"
|
|
||||||
assert_contains "$content" "dlp:" "min: dlp block"
|
|
||||||
assert_contains "$content" "include_defaults: true" "min: dlp include_defaults"
|
|
||||||
assert_contains "$content" "scan_env: true" "min: dlp scan_env"
|
|
||||||
# No ssh entries in the manifest, so neither ssrf nor trusted_domains
|
|
||||||
# blocks should be emitted.
|
|
||||||
assert_not_contains "$content" "trusted_domains:" "min: no trusted_domains"
|
|
||||||
assert_not_contains "$content" "ssrf:" "min: no ssrf block"
|
|
||||||
|
|
||||||
rm -f "$m_min"
|
|
||||||
|
|
||||||
# --- ssh bottle: trusted_domains for hostname, ssrf.ip_allowlist for ipv4 ---
|
|
||||||
|
|
||||||
m_ssh="$(write_fixture fixture_with_ssh)"
|
|
||||||
yaml_ssh="${out_dir}/ssh.yaml"
|
|
||||||
pipelock_write_yaml "$m_ssh" dev "$yaml_ssh"
|
|
||||||
|
|
||||||
content="$(cat "$yaml_ssh")"
|
|
||||||
assert_contains "$content" "trusted_domains:" "ssh: trusted_domains block emitted"
|
|
||||||
assert_contains "$content" "github.com" "ssh: hostname in trusted_domains (or allowlist)"
|
|
||||||
assert_contains "$content" "ssrf:" "ssh: ssrf block emitted"
|
|
||||||
assert_contains "$content" "ip_allowlist:" "ssh: ip_allowlist key under ssrf"
|
|
||||||
assert_contains "$content" "100.78.141.42/32" "ssh: ipv4 host emitted as /32"
|
|
||||||
# Belt-and-suspenders: the ipv4 host should also be in api_allowlist
|
|
||||||
# (strict mode requires both).
|
|
||||||
assert_contains "$content" "100.78.141.42" "ssh: ipv4 host in api_allowlist too"
|
|
||||||
|
|
||||||
rm -f "$m_ssh"
|
|
||||||
|
|
||||||
# --- secret hygiene: env values from the manifest never enter the YAML ---
|
|
||||||
|
|
||||||
m_secret="$(mktemp)"
|
|
||||||
cat > "$m_secret" <<'JSON'
|
|
||||||
{
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"env": {
|
|
||||||
"MY_SECRET": "literal-value-should-not-appear",
|
|
||||||
"ANOTHER": "?prompt-message"
|
|
||||||
},
|
|
||||||
"egress": { "allowlist": ["github.com"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
yaml_sec="${out_dir}/secret.yaml"
|
|
||||||
pipelock_write_yaml "$m_secret" dev "$yaml_sec"
|
|
||||||
content="$(cat "$yaml_sec")"
|
|
||||||
assert_not_contains "$content" "literal-value-should-not-appear" "secret: literal env value not leaked"
|
|
||||||
assert_not_contains "$content" "MY_SECRET" "secret: env var name not leaked"
|
|
||||||
assert_not_contains "$content" "prompt-message" "secret: prompt sentinel not leaked"
|
|
||||||
rm -f "$m_secret"
|
|
||||||
|
|
||||||
# --- file mode is 600 ---
|
|
||||||
mode="$(stat -f '%p' "$yaml_min" 2>/dev/null || stat -c '%a' "$yaml_min")"
|
|
||||||
# macOS stat -f '%p' returns full mode like 100600; trim. Linux stat -c '%a' gives just 600.
|
|
||||||
mode="${mode: -3}"
|
|
||||||
assert_eq "600" "$mode" "yaml file mode is 600"
|
|
||||||
|
|
||||||
test_summary
|
|
||||||
Reference in New Issue
Block a user