Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 43 additions & 2 deletions bin/bwclaude
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# - statsig/: bind-mounted read-write (persistent)
# - file-history/: bind-mounted read-write (per-file edit history for undo/restore)
# - sessions/: bind-mounted read-write (session metadata for --continue)
# - skills/, agents/, commands/: bind-mounted read-write, always created
# on host (user-authored definitions; persist across sessions)
# - history.jsonl: bind-mounted read-write (prompt history across sessions)
# - .last-cleanup: bind-mounted read-write (throttles Claude's cleanup runs)
# - settings.json: bind-mounted read-write (if it exists; --init-auth creates a stub)
Expand Down Expand Up @@ -227,6 +229,21 @@ resolve_claude_paths() {

CLAUDE_CREDENTIALS_FILE="${CLAUDE_HOME_DIR}/.credentials.json"

# Authoring directories under ~/.claude/ that hold user-created
# markdown definitions. Mounted read-write (see
# build_claude_authoring_mounts) so they can be created and edited
# from inside the sandbox and persist on the host.
#
# Each kind's primary (Claude-native) location is force-created so
# authoring works out of the box. Claude has a single global search
# location per kind, so there are no alternates.
# - skills/ : SKILL.md skill definitions
# - agents/ : custom agent markdown profiles
# - commands/ : custom slash-command markdown templates
CLAUDE_SKILLS_PRIMARY="${CLAUDE_HOME_DIR}/skills"
CLAUDE_AGENTS_PRIMARY="${CLAUDE_HOME_DIR}/agents"
CLAUDE_COMMANDS_PRIMARY="${CLAUDE_HOME_DIR}/commands"

# State file at $HOME/.claude.json (not inside .claude/) tracks
# hasCompletedOnboarding, numStartups, userID, project history.
CLAUDE_STATE_FILE="${_HOME}/.claude.json"
Expand All @@ -250,6 +267,11 @@ ensure_host_dirs() {
"${CLAUDE_HOME_DIR}/sessions"
touch "${CLAUDE_HOME_DIR}/history.jsonl" \
"${CLAUDE_HOME_DIR}/.last-cleanup"

# Create user-writable authoring directories so a read-write bind
# always has a real target on the host (a tmpfs would silently
# discard a user's first skill/agent/command).
mkdir -p "${CLAUDE_SKILLS_PRIMARY}" "${CLAUDE_AGENTS_PRIMARY}" "${CLAUDE_COMMANDS_PRIMARY}"
}

# ── Claude home (ephemeral tmpfs + selective persistence) ────────
Expand Down Expand Up @@ -337,6 +359,18 @@ build_claude_cache_mount() {
BWRAP_ARGS+=(--bind "${CLAUDE_CACHE_DIR}" "${CLAUDE_CACHE_DIR}")
}

# ── Claude authoring dirs (read-write, persistent) ──────────────
# Mount skills/, agents/, commands/ (and any present fallback dirs)
# read-write so users can author reusable definitions from inside the
# sandbox and have them persist on the host. Must run AFTER
# build_claude_home_mount (which creates the writable ${CLAUDE_HOME_DIR}
# tmpfs) so these binds land on top of it.
build_claude_authoring_mounts() {
build_authoring_kind "claude skills dir" "${CLAUDE_SKILLS_PRIMARY}"
build_authoring_kind "claude agents dir" "${CLAUDE_AGENTS_PRIMARY}"
build_authoring_kind "claude commands dir" "${CLAUDE_COMMANDS_PRIMARY}"
}

# ── Claude env vars ──────────────────────────────────────────────
build_claude_env() {
# Foundry mode is forced on (see CLAUDE_CODE_USE_FOUNDRY below).
Expand All @@ -352,7 +386,7 @@ build_claude_env() {
echo "Error: bwclaude (foundry mode) requires an API key." >&2
echo "Export one of:" >&2
echo " ANTHROPIC_FOUNDRY_API_KEY=<key> # direct foundry key" >&2
echo " AIFAPIM_API_KEY=<key> # NSLS-II hermes/AIFAPIM key (preferred)" >&2
echo " AIFAPIM_API_KEY=<key> # NSLS-II AIFAPIM key (preferred)" >&2
exit 1
fi

Expand All @@ -373,7 +407,7 @@ build_claude_env() {
else
echo "Error: bwclaude requires AIFAPIM_HOST (or ANTHROPIC_FOUNDRY_BASE_URL) to be set." >&2
echo "Export one of:" >&2
echo " AIFAPIM_HOST=hermes.nsls2.bnl.gov # NSLS-II Hermes gateway hostname" >&2
echo " AIFAPIM_HOST=<gateway-hostname> # NSLS-II AIFAPIM gateway hostname" >&2
echo " ANTHROPIC_FOUNDRY_BASE_URL=https://... # full Foundry base URL override" >&2
exit 1
fi
Expand All @@ -390,6 +424,12 @@ build_claude_env() {
BWRAP_ARGS+=(--setenv CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS 1)
BWRAP_ARGS+=(--setenv CLAUDE_CODE_USE_FOUNDRY 1)

# In foundry mode the haiku model used for background tasks defaults to a
# date-pinned ID that the foundry proxy may not recognise. Override with
# the un-pinned alias unless the caller has already set a preference.
BWRAP_ARGS+=(--setenv ANTHROPIC_DEFAULT_HAIKU_MODEL \
"${ANTHROPIC_DEFAULT_HAIKU_MODEL:-claude-haiku-4-5}")

# Debug/diagnostic env vars β€” passed through if set on the host.
pass_through_if_set ANTHROPIC_LOG
pass_through_if_set NODE_DEBUG
Expand Down Expand Up @@ -448,6 +488,7 @@ main() {
build_workdir_mount
build_claude_home_mount
build_claude_cache_mount
build_claude_authoring_mounts
build_git_mounts
build_pixi_mounts
build_ccache_mounts
Expand Down
38 changes: 33 additions & 5 deletions bin/bwcodex
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
# - /etc/codex/requirements.toml: admin-enforced restrictions (if
# present); bind-mounted read-only. Users cannot loosen these.
# - AGENTS.md: bind-mounted read-only (if it exists)
# - skills/: bind-mounted read-only (if it exists)
# - skills/, prompts/: bind-mounted read-write, always created on host
# (user-authored definitions; persist across sessions)
# - packages/: bind-mounted read-only (if it exists, installer cache)
# - auth.json: bind-mounted read-write only if it already exists on host;
# --init-auth creates a {} stub for first-time ChatGPT-login setup
Expand Down Expand Up @@ -260,10 +261,20 @@ resolve_codex_paths() {
CODEX_AUTH_FILE="${CODEX_HOME_DIR}/auth.json"
CODEX_SESSIONS_DIR="${CODEX_HOME_DIR}/sessions"
CODEX_LOG_DIR="${CODEX_HOME_DIR}/log"
CODEX_SKILLS_DIR="${CODEX_HOME_DIR}/skills"
CODEX_HISTORY_FILE="${CODEX_HOME_DIR}/history.jsonl"
CODEX_PACKAGES_DIR="${CODEX_HOME_DIR}/packages"

# Authoring directories under ~/.codex/ that hold user-created
# markdown definitions. Mounted read-write (see
# build_codex_authoring_mounts) so they can be created and edited
# from inside the sandbox and persist on the host. Each kind's
# primary (Codex-native) location is force-created so authoring works
# out of the box.
# - skills/ : SKILL.md skill definitions
# - prompts/ : custom prompt markdown templates (Codex slash prompts)
CODEX_SKILLS_PRIMARY="${CODEX_HOME_DIR}/skills"
CODEX_PROMPTS_PRIMARY="${CODEX_HOME_DIR}/prompts"

# Validate the user-controllable root directory once here so that all
# sub-path mounts derived from it are implicitly covered.
_check_dir_safe "${CODEX_HOME_DIR}" "CODEX_HOME"
Expand All @@ -275,6 +286,11 @@ ensure_host_dirs() {
"${CODEX_SESSIONS_DIR}" \
"${CODEX_LOG_DIR}"
touch "${CODEX_HISTORY_FILE}"

# Create user-writable authoring directories so a read-write bind
# always has a real target on the host (a tmpfs would silently
# discard a user's first skill/prompt).
mkdir -p "${CODEX_SKILLS_PRIMARY}" "${CODEX_PROMPTS_PRIMARY}"
}

# Fail fast if no config.toml exists. Codex requires a config.toml to
Expand Down Expand Up @@ -367,9 +383,10 @@ build_codex_home_mount() {
BWRAP_ARGS+=(--ro-bind "${CODEX_AGENTS_FILE}" "${CODEX_AGENTS_FILE}")
fi

if [[ -d "${CODEX_SKILLS_DIR}" ]]; then
BWRAP_ARGS+=(--ro-bind "${CODEX_SKILLS_DIR}" "${CODEX_SKILLS_DIR}")
fi
# skills/ and other authoring directories are mounted read-write by
# build_codex_authoring_mounts (called from main after this function)
# so users can author skills/prompts from inside the sandbox and have
# them persist on the host.

if [[ -d "${CODEX_PACKAGES_DIR}" ]]; then
BWRAP_ARGS+=(--ro-bind "${CODEX_PACKAGES_DIR}" "${CODEX_PACKAGES_DIR}")
Expand All @@ -386,6 +403,16 @@ build_codex_home_mount() {
fi
}

# ── Codex authoring dirs (read-write, persistent) ───────────────
# Mount skills/ and prompts/ read-write so users can author reusable
# definitions from inside the sandbox and have them persist on the host.
# Must run AFTER build_codex_home_mount (which creates the writable
# ${CODEX_HOME_DIR} tmpfs) so these binds land on top of it.
build_codex_authoring_mounts() {
build_authoring_kind "codex skills dir" "${CODEX_SKILLS_PRIMARY}"
build_authoring_kind "codex prompts dir" "${CODEX_PROMPTS_PRIMARY}"
}

# ── Codex env vars ───────────────────────────────────────────────
# Pass through AIFAPIM_* (Hermes routing) and the documented Codex
# env vars; default OPENAI_API_KEY to "dummy" if unset so the Codex
Expand Down Expand Up @@ -470,6 +497,7 @@ main() {
build_home_tmpfs
build_workdir_mount
build_codex_home_mount
build_codex_authoring_mounts
build_git_mounts
build_pixi_mounts
build_ccache_mounts
Expand Down
44 changes: 32 additions & 12 deletions bin/bwcopilot
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
# - Copilot home (~/.copilot or $COPILOT_HOME): tmpfs (ephemeral by default)
# - session-state/: bind-mounted read-write (persistent)
# - logs/: bind-mounted read-write (persistent)
# - skills/: bind-mounted read-write (user-created skill definitions)
# - agents/: bind-mounted read-write (user-created custom agents)
# - skills/, agents/: bind-mounted read-write, always created on host
# (user-authored definitions; persist across sessions)
# - installed-plugins/: bind-mounted read-only (prevents agent from installing plugins)
# - config.json: COPIED into tmpfs (host auth available, changes ephemeral)
# - Use --init-auth to make config.json persistent (for personal machines)
Expand Down Expand Up @@ -231,6 +231,17 @@ resolve_copilot_paths() {
COPILOT_SESSION_DIR="${COPILOT_HOME_DIR}/session-state"
COPILOT_LOGS_DIR="${COPILOT_HOME_DIR}/logs"

# Authoring directories under ~/.copilot/ that hold user-created
# markdown definitions. Mounted read-write (see
# build_copilot_authoring_mounts) so they can be created and edited
# from inside the sandbox and persist on the host. Each kind's
# primary (Copilot-native) location is force-created so authoring
# works out of the box.
# - skills/ : SKILL.md skill definitions
# - agents/ : custom agent markdown profiles
COPILOT_SKILLS_PRIMARY="${COPILOT_HOME_DIR}/skills"
COPILOT_AGENTS_PRIMARY="${COPILOT_HOME_DIR}/agents"

# Validate the user-controllable root directories once here so that all
# sub-path mounts derived from them are implicitly covered.
_check_dir_safe "${COPILOT_HOME_DIR}" "COPILOT_HOME"
Expand All @@ -242,8 +253,10 @@ ensure_host_dirs() {
mkdir -p "${COPILOT_HOME_DIR}" "${COPILOT_CACHE_DIR}"
# Create persistent subdirectories for bind-mounts
mkdir -p "${COPILOT_SESSION_DIR}" "${COPILOT_LOGS_DIR}"
# Create user-writable customization directories
mkdir -p "${COPILOT_HOME_DIR}/skills" "${COPILOT_HOME_DIR}/agents"
# Create user-writable authoring directories so a read-write bind
# always has a real target on the host (a tmpfs would silently
# discard a user's first skill/agent).
mkdir -p "${COPILOT_SKILLS_PRIMARY}" "${COPILOT_AGENTS_PRIMARY}"
}

# ── Copilot home (ephemeral tmpfs + selective persistence) ──────
Expand Down Expand Up @@ -301,14 +314,10 @@ build_copilot_home_mount() {
fi
done

# Bind-mount user-writable customization directories (read-write)
# Skills and agents are user-created files, not installed packages.
for _cfg in skills agents; do
local _path="${COPILOT_HOME_DIR}/${_cfg}"
if [[ -e "${_path}" ]]; then
BWRAP_ARGS+=(--bind "${_path}" "${_path}")
fi
done
# User-writable authoring directories (skills/, agents/, ...) are
# mounted read-write by build_copilot_authoring_mounts (called from
# main after this function) so they are symlink-resolved, sanitized,
# and created on the host when missing.

# Handle config.json: copy or bind depending on --init-auth
if [[ "${INIT_AUTH}" -eq 1 ]]; then
Expand All @@ -328,6 +337,16 @@ build_copilot_home_mount() {
fi
}

# ── Copilot authoring dirs (read-write, persistent) ─────────────
# Mount skills/ and agents/ read-write so users can author reusable
# definitions from inside the sandbox and have them persist on the host.
# Must run AFTER build_copilot_home_mount (which creates the writable
# ${COPILOT_HOME_DIR} tmpfs) so these binds land on top of it.
build_copilot_authoring_mounts() {
build_authoring_kind "copilot skills dir" "${COPILOT_SKILLS_PRIMARY}"
build_authoring_kind "copilot agents dir" "${COPILOT_AGENTS_PRIMARY}"
}

# ── Copilot cache (read-write) ──────────────────────────────────
build_copilot_cache_mount() {
BWRAP_ARGS+=(--bind "${COPILOT_CACHE_DIR}" "${COPILOT_CACHE_DIR}")
Expand Down Expand Up @@ -406,6 +425,7 @@ main() {
build_home_tmpfs
build_workdir_mount
build_copilot_home_mount
build_copilot_authoring_mounts
build_copilot_cache_mount
build_gh_auth_mount
build_git_mounts
Expand Down
74 changes: 72 additions & 2 deletions bin/bwopencode
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@
#
# The sandbox provides filesystem isolation:
# - Current directory: read-write
# - OpenCode config: read-only (symlinks resolved)
# - OpenCode config: read-only (symlinks resolved), EXCEPT the
# authoring directories below, which are read-write and persistent
# - OpenCode authoring dirs (read-write, persistent on host):
# For each kind the opencode-native primary location is always
# created so users can author from inside the sandbox out of the box:
# skills -> ~/.config/opencode/skills/ (primary)
# agents -> ~/.config/opencode/agents/ (primary)
# commands -> ~/.config/opencode/commands/ (primary)
# Additional search locations for the SAME kind are mounted rw only
# if they already exist on the host:
# skills -> ~/.claude/skills/, ~/.agents/skills/
# agents -> ~/.claude/agents/, ~/.agents/agents/
# commands -> ~/.claude/commands/
# - OpenCode data dir: ephemeral tmpfs with selective persistence
# - auth.json: persisted only if it already exists on host;
# otherwise created in tmpfs (discarded on exit)
Expand Down Expand Up @@ -196,6 +208,30 @@ resolve_opencode_paths() {
OC_CACHE_DIR="${XDG_CACHE_HOME}/opencode"
OC_AUTH_FILE="${OC_DATA_DIR}/auth.json"

# Authoring directories that hold user-created markdown definitions.
# These are mounted read-write (see build_opencode_authoring_mounts)
# so they can be created and edited from inside the sandbox and
# persist on the host.
#
# opencode searches several locations per kind; the primary
# (opencode-native) location is force-created so authoring works out
# of the box, while the Claude-/agent-compatible search locations are
# mounted read-write only if they already exist on the host.
#
# The opencode-native primaries (under ${OC_CONFIG_DIR}) are excluded
# from the read-only config bind in resolve_config_symlinks so the
# read-write bind is not shadowed.
OC_SKILLS_PRIMARY="${OC_CONFIG_DIR}/skills"
OC_SKILLS_ALT=("${_HOME}/.claude/skills" "${_HOME}/.agents/skills")
OC_AGENTS_PRIMARY="${OC_CONFIG_DIR}/agents"
OC_AGENTS_ALT=("${_HOME}/.claude/agents" "${_HOME}/.agents/agents")
OC_COMMANDS_PRIMARY="${OC_CONFIG_DIR}/commands"
OC_COMMANDS_ALT=("${_HOME}/.claude/commands")

# Basenames under ${OC_CONFIG_DIR} that resolve_config_symlinks must
# skip (they are handled read-write below).
OC_CONFIG_AUTHORING_SKIP="skills agents commands"

# Validate the user-controllable root directories once here so that all
# sub-path mounts derived from them are implicitly covered.
# XDG_CONFIG_HOME, XDG_DATA_HOME, and XDG_CACHE_HOME are already checked
Expand All @@ -222,6 +258,14 @@ ensure_host_dirs() {
touch "${OC_DATA_DIR}/opencode.db" \
"${OC_DATA_DIR}/opencode.db-shm" \
"${OC_DATA_DIR}/opencode.db-wal"

# Authoring directories: create the primary location for each kind on
# the host so a read-write bind always has a real target. Without
# this, the parent ~/.config/opencode/ is a tmpfs inside the sandbox
# and a user's first skill/agent/command would be silently discarded
# on exit. Alternate search locations are NOT created here β€” they are
# only mounted read-write if they already exist.
mkdir -p "${OC_SKILLS_PRIMARY}" "${OC_AGENTS_PRIMARY}" "${OC_COMMANDS_PRIMARY}"
}

# Resolve opencode config symlinks.
Expand All @@ -237,9 +281,17 @@ resolve_config_symlinks() {
OC_CONFIG_BINDS=()
[[ -d "${OC_CONFIG_DIR}" ]] || return 0

local entry local_resolved
local entry base local_resolved
for entry in "${OC_CONFIG_DIR}"/*; do
[[ -e "${entry}" ]] || continue
# Skip authoring directories β€” they are mounted read-write by
# build_opencode_authoring_mounts so users can create/edit
# skills, agents, and commands from inside the sandbox. Binding
# them read-only here would shadow that read-write bind.
base="${entry##*/}"
case " ${OC_CONFIG_AUTHORING_SKIP} " in
*" ${base} "*) continue ;;
esac
if [[ -L "${entry}" ]]; then
local_resolved="$(readlink -f "${entry}")"
else
Expand Down Expand Up @@ -315,6 +367,23 @@ build_opencode_config_mount() {
fi
}

# ── OpenCode authoring dirs (read-write, persistent) ────────────
# Mount the skills/agents/commands search locations read-write so users
# can author reusable definitions from inside the sandbox and have them
# persist on the host. For each kind, the opencode-native location is
# created and mounted; the Claude-/agent-compatible search locations are
# mounted read-write only if they already exist. Must run AFTER
# build_opencode_config_mount (which creates the writable ${OC_CONFIG_DIR}
# --dir) so these binds land on top of it.
build_opencode_authoring_mounts() {
build_authoring_kind "opencode skills dir" \
"${OC_SKILLS_PRIMARY}" "${OC_SKILLS_ALT[@]}"
build_authoring_kind "opencode agents dir" \
"${OC_AGENTS_PRIMARY}" "${OC_AGENTS_ALT[@]}"
build_authoring_kind "opencode commands dir" \
"${OC_COMMANDS_PRIMARY}" "${OC_COMMANDS_ALT[@]}"
}

# ── OpenCode data (ephemeral tmpfs + selective persistence) ──────
# Mount the data dir as ephemeral tmpfs, then selectively bind-mount
# the files and directories that must persist across sessions.
Expand Down Expand Up @@ -412,6 +481,7 @@ main() {
build_workdir_mount
build_persistent_dotdir
build_opencode_config_mount
build_opencode_authoring_mounts
build_opencode_data_mounts
build_opencode_cache_mount
build_git_mounts
Expand Down
Loading