Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cc1de66
feat(model): pin OSS default to claude-opus-4-8[1m]
ramseymcgrath Jun 5, 2026
820bc79
feat(statusline): vendor claude-hud, retire hand-rolled statusline
ramseymcgrath Jun 5, 2026
7eb1c8e
test(migrate): update bats for schema 5 + claude-hud migration
ramseymcgrath Jun 5, 2026
a23ee5a
feat(compose): BACK2BASE_DATA_DIR mounts local plans + memories
ramseymcgrath Jun 5, 2026
e9b4467
test(compose): assert data-dir mounts are rw + cover env-file fallback
ramseymcgrath Jun 5, 2026
705b9b1
fix(compose): use bare paths in data-dir override (Go %q broke compos…
ramseymcgrath Jun 5, 2026
1be4111
style: trim verbose doc comments on data-dir + statusline helpers
ramseymcgrath Jun 5, 2026
f426add
fix(compose): quote whole volume item in managed-settings override
ramseymcgrath Jun 5, 2026
2e6e3b2
feat(data-dir): mount plans+memories under ~/.back2base/ not ~/.claude/
ramseymcgrath Jun 5, 2026
5beafcc
feat(entrypoint): wire ~/.back2base/memories as persistent memory store
ramseymcgrath Jun 5, 2026
5c50f72
refactor(entrypoint): hoist memory-path alignment out of both branche…
ramseymcgrath Jun 5, 2026
fd46c7b
test(entrypoint): extract memory alignment to lib + bats coverage
ramseymcgrath Jun 5, 2026
31b5ef1
test(firewall): drop stale cloud OTel-collector allow-list assertion
ramseymcgrath Jun 5, 2026
7513847
ci+test: gate bats on macOS; harden prelaunch-overview timing tests
ramseymcgrath Jun 5, 2026
13cc74a
fix: address Copilot review (sanitize ns, reject newline data-dir, te…
ramseymcgrath Jun 5, 2026
4c495f2
docs: overhaul README
ramseymcgrath Jun 5, 2026
1e37803
docs: drop phantom --model flag, add --namespace to root-flags line
ramseymcgrath Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,20 @@ jobs:
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}

bats:
name: Container scripts (bats, macOS)
# macOS is the primary target; run the bats suite on a mac runner so the
# container shell scripts (entrypoint, hooks, migrate-settings, firewall
# dry-runs, prelaunch overview) are gated on every PR. Run from the repo
# root — several tests source back2base-container/entrypoint.sh by a
# root-relative path.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Install bats + jq
run: brew install bats-core jq

- name: Run container test suite
run: bats back2base-container/test/
202 changes: 142 additions & 60 deletions README.md

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions back2base-container/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ COPY --chown=node:node --chmod=0755 lib/firewall-refresh.sh /opt/back2base/firew
# symlink lands in the wrong place; this catches it loudly.
COPY --chown=node:node --chmod=0755 lib/path-sentinel.sh /opt/back2base/path-sentinel.sh

# Memory-path alignment lib — sourced by entrypoint.sh and unit-tested by
# test/memory-align.bats. Defines b2b_align_memory_dir.
COPY --chown=node:node lib/memory-align.sh /opt/back2base/memory-align.sh

# CLAUDE.md renderer (stdlib Python). Replaces the bash+jq generate_claude_md
# function in entrypoint.sh. Reassembles ~/.claude/CLAUDE.md from the template
# and the live mcp.json/commands dir on every container start.
Expand All @@ -81,17 +85,21 @@ COPY --chown=node:node lib/prelaunch-prompt.txt /opt/back2base/prelaunch-prompt.
# skills are usable without a Skill tool roundtrip. Stdlib Python only.
COPY --chown=node:node --chmod=0755 lib/render-skill-preplan.py /opt/back2base/render-skill-preplan.py

# Cold-start phase helpers + in-session statusLine renderer.
# Cold-start phase helpers + in-session statusLine.
COPY --chown=node:node lib/status.sh /opt/back2base/status.sh
COPY --chown=node:node --chmod=0755 lib/statusline.sh /opt/back2base/statusline.sh
# claude-hud renders the in-session HUD (vendored, precompiled ESM JS, runs on
# node, zero deps). statusline-extra.sh feeds the back2base namespace segment
# via --extra-cmd. Vendored from https://github.com/jarrodwatts/claude-hud (MIT).
COPY --chown=node:node vendor/claude-hud/ /opt/back2base/claude-hud/
COPY --chown=node:node --chmod=0755 lib/statusline-extra.sh /opt/back2base/statusline-extra.sh

# Power-steering supervisor — async drift-detection daemon. Auths the
# same way Claude Code does; idles cleanly when no auth is set.
COPY --chown=node:node --chmod=0755 lib/power-steering.py /opt/back2base/power-steering.py
COPY --chown=node:node lib/power-steering-prompt.md /opt/back2base/power-steering-prompt.md

# Belt + suspenders for legacy docker builders that silently ignore --chmod.
RUN chmod +x /opt/back2base/hooks/_common.sh /opt/back2base/hooks/power-steering-drain.sh /opt/back2base/hooks/power-steering-tick.sh /opt/back2base/hooks/claude-md-audit-tally.sh /opt/back2base/hooks/claude-md-audit-trigger.sh /opt/back2base/session-snapshot.sh /opt/back2base/migrate-settings.py /opt/back2base/render-hooks.py /opt/back2base/firewall-refresh.sh /opt/back2base/path-sentinel.sh /opt/back2base/render-claude-md.py /opt/back2base/claude-md-audit.py /opt/back2base/prelaunch-overview.sh /opt/back2base/render-overview.py /opt/back2base/render-skill-preplan.py /opt/back2base/status.sh /opt/back2base/statusline.sh /opt/back2base/power-steering.py 2>/dev/null || true
RUN chmod +x /opt/back2base/hooks/_common.sh /opt/back2base/hooks/power-steering-drain.sh /opt/back2base/hooks/power-steering-tick.sh /opt/back2base/hooks/claude-md-audit-tally.sh /opt/back2base/hooks/claude-md-audit-trigger.sh /opt/back2base/session-snapshot.sh /opt/back2base/migrate-settings.py /opt/back2base/render-hooks.py /opt/back2base/firewall-refresh.sh /opt/back2base/path-sentinel.sh /opt/back2base/render-claude-md.py /opt/back2base/claude-md-audit.py /opt/back2base/prelaunch-overview.sh /opt/back2base/render-overview.py /opt/back2base/render-skill-preplan.py /opt/back2base/status.sh /opt/back2base/statusline-extra.sh /opt/back2base/power-steering.py 2>/dev/null || true

# Copy firewall and entrypoint scripts
COPY init-firewall.sh entrypoint.sh /usr/local/bin/
Expand Down
10 changes: 10 additions & 0 deletions back2base-container/defaults/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ TZ=America/New_York
# Set to 1 to disable outbound network filtering (allow all traffic).
# DISABLE_FIREWALL=0

# ── Local plans + memories ────────────────────────────────────────────────────
# Point this at a host directory to persist plans and memories outside the
# opaque back2base state dir. The CLI creates `plans/` and `memories/`
# subfolders inside it and bind-mounts them at ~/.back2base/plans and
# ~/.back2base/memories in the container. The memory tool reads and writes the
# mounted memories dir directly, so memories persist across sessions. Leave
# unset for the default (memories live under the back2base state dir, cleared
# each session start).
# BACK2BASE_DATA_DIR=~/back2base-data

# ── Optional: Managed Claude Code policy (enterprise) ────────────────────────
# Claude Code reads enterprise-deployed policy from a platform-specific
# host path (macOS: /Library/Application Support/ClaudeCode/, Linux:
Expand Down
14 changes: 7 additions & 7 deletions back2base-container/defaults/profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"profiles": {
"full": {
"description": "All MCP servers (max capability, highest token cost)",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": [
"aws-knowledge", "brave-search", "buildkite", "context7",
"datadog", "fetch", "filesystem", "git", "github", "godevmcp",
Expand All @@ -13,32 +13,32 @@
},
"go": {
"description": "Go backend development",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": ["context7", "fetch", "github", "godevmcp", "sequential-thinking", "sqlite"]
},
"python": {
"description": "Python development",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": ["context7", "fetch", "github", "sequential-thinking", "sqlite"]
},
"frontend": {
"description": "Web / TypeScript frontend development",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": ["context7", "fetch", "github", "lsmcp"]
},
"infra": {
"description": "Infrastructure and platform operations",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": ["aws-knowledge", "datadog", "fetch", "github", "kubernetes", "terraform"]
},
"research": {
"description": "Research, docs, and exploration",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": ["brave-search", "context7", "fetch", "sequential-thinking"]
},
"documentation": {
"description": "Technical writing and documentation authoring",
"model": "claude-opus-4-7[1m]",
"model": "claude-opus-4-8[1m]",
"servers": ["brave-search", "context7", "fetch", "github", "sequential-thinking"]
},
"minimal": {
Expand Down
4 changes: 2 additions & 2 deletions back2base-container/defaults/settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"_back2base_schema": 4,
"_back2base_schema": 5,
"statusLine": {
"type": "command",
"command": "/opt/back2base/statusline.sh",
"command": "bash -c 'export COLUMNS=\"${COLUMNS:-120}\"; exec node /opt/back2base/claude-hud/index.js --extra-cmd /opt/back2base/statusline-extra.sh'",
"padding": 0
}
}
66 changes: 4 additions & 62 deletions back2base-container/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -160,68 +160,10 @@ _phase2_workspace_setup() {
export GIT_SSH_COMMAND="ssh -F $HOME/.ssh_local/config"
fi

# Memory directories.
#
# Two paths are involved and they MUST point at the same physical files:
#
# 1. The "namespaced" path under MEMORY_NAMESPACE (human-friendly, derived
# from the git remote basename, e.g. ~/.claude/projects/myrepo/memory).
#
# 2. Claude Code's auto-memory path, which it derives from the current
# working directory by replacing every '/' with '-'
# (e.g. /workspace → ~/.claude/projects/-workspace/memory). This is
# where Claude Code reads MEMORY.md from at session start and writes
# new memories to during the session.
#
# Without alignment, memories saved under (1) land outside what Claude
# Code only reads (2) — they ships past each other and "memory doesn't
# load". We make (1) the canonical store and symlink (2) → (1). If a user
# already has real files at (2) from a previous version, migrate them into
# (1) once before replacing (2) with the symlink.
if [ -n "${MEMORY_NAMESPACE:-}" ]; then
ns_memory_dir="$HOME/.claude/projects/${MEMORY_NAMESPACE}/memory"
mkdir -p "$ns_memory_dir"

# Always start with a blank memory folder so old session memory does not
# silently bleed into a new session via Claude Code's MEMORY.md autoload.
if [ -n "$(ls -A "$ns_memory_dir" 2>/dev/null)" ]; then
rm -rf "${ns_memory_dir:?}"/* "${ns_memory_dir:?}"/.[!.]* 2>/dev/null || true
fi

# Claude Code's project dir name = $PWD with '/' → '-'. Compute against
# the cwd at this point in the entrypoint, which is what claude inherits
# via the final `exec "$@"` below (after any /repos cd above).
cwd_dir_name="${PWD//\//-}"
cc_memory_dir="$HOME/.claude/projects/${cwd_dir_name}/memory"

# Export the absolute project dir so daemons (power-steering,
# session-snapshot) can find Claude Code's session JSONLs without
# re-deriving the slug. Session files live at depth 1 under this dir.
export CLAUDE_PROJECT_DIR="$HOME/.claude/projects/${cwd_dir_name}"

if [ "$cc_memory_dir" != "$ns_memory_dir" ]; then
mkdir -p "$(dirname "$cc_memory_dir")"
if [ -L "$cc_memory_dir" ]; then
# Already a symlink — repoint only if it's stale (e.g. namespace
# changed because the user switched MEMORY_NAMESPACE override).
if [ "$(readlink "$cc_memory_dir")" != "$ns_memory_dir" ]; then
ln -sfn "$ns_memory_dir" "$cc_memory_dir"
fi
elif [ -d "$cc_memory_dir" ]; then
# Real directory left over from before the alignment was added.
# Migrate any existing files into the namespaced location, then
# replace with a symlink. cp -an preserves perms (-a) and never
# clobbers existing files in the destination (-n).
if [ -n "$(ls -A "$cc_memory_dir" 2>/dev/null)" ]; then
cp -an "$cc_memory_dir"/. "$ns_memory_dir"/ 2>/dev/null || true
fi
rm -rf "$cc_memory_dir"
ln -sfn "$ns_memory_dir" "$cc_memory_dir"
else
ln -sfn "$ns_memory_dir" "$cc_memory_dir"
fi
fi
fi
# Memory-path alignment — see lib/memory-align.sh. Sourced so the logic is
# unit-testable (test/memory-align.bats); entrypoint just invokes it here.
. /opt/back2base/memory-align.sh
b2b_align_memory_dir

# Path-naming sentinel. Background daemon: 30s after launch, verifies that
# Claude Code wrote its session JSONL under the directory name we predicted
Expand Down
53 changes: 53 additions & 0 deletions back2base-container/lib/memory-align.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Memory-path alignment for the back2base container entrypoint.
#
# Sourced by entrypoint.sh and by test/memory-align.bats. Defines one function,
# b2b_align_memory_dir, which selects the canonical memory store and points
# Claude Code's auto-memory path at it. Reads HOME, PWD, MEMORY_NAMESPACE;
# exports CLAUDE_PROJECT_DIR and (when the bind mount is present)
# BACK2BASE_PLANS_DIR.

# b2b_align_memory_dir picks the canonical memory store and symlinks Claude
# Code's auto-memory path (PWD with '/' -> '-') to it:
# - BACK2BASE_DATA_DIR mounted ~/.back2base/memories -> use it, never wiped.
# - else MEMORY_NAMESPACE set -> ~/.claude/projects/<ns>/memory, wiped each
# session to prevent bleed.
# - else -> no-op.
b2b_align_memory_dir() {
local ns_memory_dir=""
if [ -d "$HOME/.back2base/memories" ]; then
ns_memory_dir="$HOME/.back2base/memories"
mkdir -p "$HOME/.back2base/plans"
export BACK2BASE_PLANS_DIR="$HOME/.back2base/plans"
elif [ -n "${MEMORY_NAMESPACE:-}" ]; then
ns_memory_dir="$HOME/.claude/projects/${MEMORY_NAMESPACE}/memory"
mkdir -p "$ns_memory_dir"
# Start blank so old session memory doesn't bleed via MEMORY.md autoload.
if [ -n "$(ls -A "$ns_memory_dir" 2>/dev/null)" ]; then
rm -rf "${ns_memory_dir:?}"/* "${ns_memory_dir:?}"/.[!.]* 2>/dev/null || true
fi
fi

[ -n "$ns_memory_dir" ] || return 0

local cwd_dir_name cc_memory_dir
cwd_dir_name="${PWD//\//-}"
cc_memory_dir="$HOME/.claude/projects/${cwd_dir_name}/memory"
export CLAUDE_PROJECT_DIR="$HOME/.claude/projects/${cwd_dir_name}"
[ "$cc_memory_dir" != "$ns_memory_dir" ] || return 0

mkdir -p "$(dirname "$cc_memory_dir")"
if [ -L "$cc_memory_dir" ]; then
if [ "$(readlink "$cc_memory_dir")" != "$ns_memory_dir" ]; then
ln -sfn "$ns_memory_dir" "$cc_memory_dir"
fi
elif [ -d "$cc_memory_dir" ]; then
if [ -n "$(ls -A "$cc_memory_dir" 2>/dev/null)" ]; then
cp -an "$cc_memory_dir"/. "$ns_memory_dir"/ 2>/dev/null || true
fi
rm -rf "$cc_memory_dir"
ln -sfn "$ns_memory_dir" "$cc_memory_dir"
else
ln -sfn "$ns_memory_dir" "$cc_memory_dir"
fi
}
34 changes: 33 additions & 1 deletion back2base-container/lib/migrate-settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@

# ── Migrations ──────────────────────────────────────────────────────────────

# The back2base statusLine command. claude-hud (vendored at
# /opt/back2base/claude-hud) renders the HUD; statusline-extra.sh feeds the
# back2base namespace segment via --extra-cmd. COLUMNS is exported because the
# container TTY may not set it and claude-hud uses it for width.
STATUSLINE_COMMAND = (
"bash -c 'export COLUMNS=\"${COLUMNS:-120}\"; "
"exec node /opt/back2base/claude-hud/index.js "
"--extra-cmd /opt/back2base/statusline-extra.sh'"
)

# The legacy hand-rolled renderer, replaced by claude-hud in schema 5. Used by
# migrate_5 to detect an unmodified back2base statusLine worth upgrading.
LEGACY_STATUSLINE_COMMAND = "/opt/back2base/statusline.sh"


def migrate_1_envelope_hooks(d):
"""Wrap bare {type:"command",...} hook entries in {matcher,hooks} envelope.
Expand Down Expand Up @@ -98,7 +112,7 @@ def migrate_3_add_statusline(d):
return False
d["statusLine"] = {
"type": "command",
"command": "/opt/back2base/statusline.sh",
"command": STATUSLINE_COMMAND,
"padding": 0,
}
return True
Expand All @@ -117,6 +131,22 @@ def migrate_4_strip_hooks_block(d):
return True


def migrate_5_statusline_to_claude_hud(d):
"""Repoint an unmodified back2base statusLine at vendored claude-hud.

Only rewrites a statusLine still pointing at the legacy renderer
(/opt/back2base/statusline.sh). A user who customized the command to
something else is left alone — same defensive posture as migrate_3.
"""
sl = d.get("statusLine")
if not isinstance(sl, dict):
return False
if sl.get("command") != LEGACY_STATUSLINE_COMMAND:
return False
sl["command"] = STATUSLINE_COMMAND
return True


# Registry: (number, callable). Numbers must be strictly increasing. Each
# callable mutates the dict in place and returns True if it changed anything
# (return value is informational; presence in the registry is what matters).
Expand All @@ -125,6 +155,7 @@ def migrate_4_strip_hooks_block(d):
(2, migrate_2_prune_defunct_sessionstart),
(3, migrate_3_add_statusline),
(4, migrate_4_strip_hooks_block),
(5, migrate_5_statusline_to_claude_hud),
]
CURRENT_SCHEMA = max(n for n, _ in MIGRATIONS)

Expand All @@ -137,6 +168,7 @@ def migrate_4_strip_hooks_block(d):
2: ":: settings.json: pruned defunct SessionStart prefetch hook",
3: ":: settings.json: seeded statusLine block",
4: ":: settings.json: stripped hooks block (renderer now owns it)",
5: ":: settings.json: repointed statusLine at claude-hud",
}


Expand Down
26 changes: 26 additions & 0 deletions back2base-container/lib/statusline-extra.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
# claude-hud --extra-cmd helper for oss-back2base.
#
# claude-hud (vendored at /opt/back2base/claude-hud) renders the HUD; this adds
# the one segment it can't know — the memory namespace — as a JSON object on
# stdout: {"label":"ns:<namespace>"}. Omitted when no namespace is set. (The
# hosted build's auth/pwr segments don't exist in OSS.)

set +e

label=""
# Strip control chars (newlines etc.) from the namespace so a pathological
# value can't emit invalid JSON or leak terminal-control sequences into the HUD.
ns=$(printf '%s' "${MEMORY_NAMESPACE:-}" | tr -d '[:cntrl:]')
if [ -n "$ns" ]; then
label="ns:${ns}"
fi

# claude-hud truncates past 50 chars anyway; cap here to keep this self-contained.
if [ "${#label}" -gt 50 ]; then
label="${label:0:49}…"
fi

label=${label//\\/\\\\}
label=${label//\"/\\\"}
printf '{"label":"%s"}\n' "$label"
Comment on lines +11 to +26

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13cc74a. statusline-extra.sh now strips control chars from the namespace (ns=$(printf '%s' "$MEMORY_NAMESPACE" | tr -d '[:cntrl:]')) and only emits the ns: label when the sanitized value is non-empty. Verified a namespace with an embedded newline + control char now yields valid single-line JSON. Same fix applied to the hosted build's statusline-extra.sh.

Loading
Loading