From d84a4f2b12a2241452b8753f32db2c2eef983f69 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 12 Jun 2026 00:32:44 -0400 Subject: [PATCH 1/3] ENH: make tool authoring location rw by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enables using the TUI to write skills and agents from inside of the wrapper. Done with 🤖 --- bin/bwclaude | 35 ++++++++++++++ bin/bwcodex | 38 +++++++++++++-- bin/bwcopilot | 44 ++++++++++++----- bin/bwopencode | 74 +++++++++++++++++++++++++++- lib/bwrap_sandbox_lib.sh | 102 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 19 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index a22a085..fab63a9 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -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) @@ -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" @@ -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) ──────── @@ -329,6 +351,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() { # NOTE: bwclaude is hard-wired for the NSLS-II AIFAPIM/foundry gateway. @@ -451,6 +485,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 diff --git a/bin/bwcodex b/bin/bwcodex index 7f3b346..4ae21f3 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -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 @@ -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" @@ -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 @@ -359,9 +375,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}") @@ -378,6 +395,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 @@ -463,6 +490,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 diff --git a/bin/bwcopilot b/bin/bwcopilot index 437b65d..61e182d 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -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) @@ -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" @@ -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) ────── @@ -293,14 +306,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 @@ -320,6 +329,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}") @@ -399,6 +418,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 diff --git a/bin/bwopencode b/bin/bwopencode index fbd76f4..5937434 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -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) @@ -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 @@ -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. @@ -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 @@ -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. @@ -413,6 +482,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 diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 77ceb6e..735e7c2 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -622,6 +622,108 @@ build_extra_path_mounts() { done } +# ── Authoring directories (read-write, persistent) ─────────────── +# +# Tools (opencode, claude, copilot, codex) let users author reusable +# definitions — skills, agents, commands/prompts — as plain markdown +# files under their config/home directory. For these to be created +# and edited *from inside the sandbox* and survive across sessions, +# the directories must be bind-mounted read-write from the real host +# location (a tmpfs would silently discard a user's first skill). +# +# A single *kind* of definition (e.g. "skills") may be searched for in +# several locations by the tool — for opencode, skills are discovered +# in ~/.config/opencode/skills/, ~/.claude/skills/, and ~/.agents/skills/. +# Within one kind, exactly the PRIMARY location is force-created so that +# authoring works out of the box; every other search location for that +# kind is mounted read-write only if it already exists on the host (we +# do not manufacture empty alternate trees the tool may not expect). +# +# _bind_authoring_dir DIR CREATE CONTEXT +# Resolve, sanitize, and read-write bind-mount the authoring directory +# DIR (an absolute path). +# +# DIR : absolute path to the authoring directory. +# CREATE : "create" -> mkdir -p the host dir first so first-time +# authoring works out of the box; "if-exists" -> only +# mount when the directory already exists on the host. +# CONTEXT : label for safety-check error messages. +# +# The target path is canonicalized with readlink -f (resolving any +# symlink, e.g. skills/ pointing into a dotfiles repo) and passed +# through _check_path_safe before the bind is emitted — identical +# treatment to --rw-path. Intermediate --dir entries are created +# for paths under $HOME so the bind mount point exists in the tmpfs. +# +# Idempotent: a path already registered in _MOUNTED_PREFIXES (or a +# parent of it) is skipped so we never emit a duplicate bind. +# +# Must be called AFTER build_home_tmpfs (for intermediate --dir +# creation) and AFTER the tool's own home/config tmpfs+mounts so the +# read-write bind lands on top of the writable parent --dir rather +# than being shadowed by a broader read-only bind. +_bind_authoring_dir() { + local dir="$1" create="$2" context="$3" + + if [[ "${create}" == "create" ]]; then + mkdir -p "${dir}" + elif [[ ! -e "${dir}" ]]; then + return 0 + fi + + # Resolve symlinks so we bind the real target, then sanitize it. + local canon + canon="$(readlink -f "${dir}" 2> /dev/null || echo "${dir}")" + _check_path_safe "${canon}" "${context}" + + # Skip if this path (or a parent tree) is already mounted. The walk + # tests every ancestor including "/" itself. + local check="${canon}" + while :; do + if [[ -n "${_MOUNTED_PREFIXES["${check}"]:-}" ]]; then + return 0 + fi + [[ "${check}" == "/" ]] && break + check="${check%/*}" + [[ -z "${check}" ]] && check="/" + done + + # Create intermediate --dir entries for paths under $HOME so the + # bind mount point exists inside the tmpfs. CANON is the bind mount + # point, so only its ancestors are pre-created. + _emit_home_intermediate_dirs "${canon}" + + BWRAP_ARGS+=(--bind "${canon}" "${canon}") + _MOUNTED_PREFIXES["${canon}"]=1 +} + +# build_authoring_kind CONTEXT_LABEL PRIMARY [ALT_SEARCH_DIR ...] +# Read-write bind-mount every search location for ONE kind of authored +# definition (skills, agents, commands, ...) so the user can create and +# edit them from inside the sandbox with persistence. +# +# CONTEXT_LABEL : label for safety-check error messages (e.g. +# "opencode skills dir"). +# PRIMARY : the canonical/primary search location for this kind. +# Always created (mkdir -p) and mounted read-write so +# authoring works out of the box on a fresh install. +# ALT_SEARCH_DIR : zero or more additional search locations the tool +# consults for this SAME kind. Each is mounted +# read-write only if it already exists on the host. +# +# All paths are absolute, symlink-resolved, and sanitized by +# _bind_authoring_dir. +build_authoring_kind() { + local context_label="$1" + local primary="$2" + shift 2 + _bind_authoring_dir "${primary}" "create" "${context_label} (primary)" + local _alt + for _alt in "$@"; do + _bind_authoring_dir "${_alt}" "if-exists" "${context_label} (alternate)" + done +} + # ── Path resolution ────────────────────────────────────────────── resolve_common_paths() { From b9304c0da89e2b161457dc629bea603325a6dd33 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Mon, 6 Jul 2026 23:04:58 -0400 Subject: [PATCH 2/3] ENH: fix codex config mounting via symlinks --- bin/bwcodex | 229 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 191 insertions(+), 38 deletions(-) diff --git a/bin/bwcodex b/bin/bwcodex index 4ae21f3..9454efd 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -25,13 +25,19 @@ # - sessions/: bind-mounted read-write (for `codex resume` / `codex fork`) # - log/: bind-mounted read-write (RUST_LOG / opt-in plaintext logs) # - history.jsonl: bind-mounted read-write (prompt history) -# - config.toml: by default, copied to a per-session staging file and -# bind-mounted RW so Codex can persist project-trust grants for the -# session without touching the host file (the staging copy is removed -# on script exit). With --persist-config, the host config.toml is -# bind-mounted RW so writes persist across sessions at the cost of -# weaker isolation. Either the user file or the system file -# (/etc/codex/config.toml) is sufficient for fail-fast. +# - config.toml: persists to the host by default (like settings.json in +# bwclaude). Codex rewrites config.toml via temp-file + rename, which +# fails on a single-file bind mount (EBUSY -> "failed to persist +# config"); so the real file is relocated to +# ~/.codex/persisted/config.toml (a symlink is left at the original +# path, transparent to non-wrapped codex) and only that one +# sub-directory is bind-mounted RW. Codex resolves the symlink and +# renames inside it, so writes land on the host without exposing the +# rest of ~/.codex. With --ephemeral-config, the host file's +# *contents* are instead injected as a regular tmpfs file (bwrap +# --file) and any writes are discarded on exit. Either the user file +# or the system file (/etc/codex/config.toml) is sufficient for +# fail-fast. # - /etc/codex/config.toml: system-wide provider/defaults config (if # present); bind-mounted read-only. Codex layers user config on # top of this file. @@ -77,9 +83,12 @@ # BNL ITD ChatGPT subscription auth path; has no effect for # Hermes-routed setups. On shared accounts, do NOT use this # flag — tokens will be ephemeral by default. -# --persist-config Bind ~/.codex/config.toml read-write into the sandbox so -# writes persist across sessions. Default: config.toml is -# copied to a per-session staging file; host file is unchanged. +# --ephemeral-config Do NOT persist config.toml. By default config.toml +# writes persist to the host (real file relocated to +# ~/.codex/persisted/config.toml; a symlink is left behind, +# and only that sub-dir is bind-mounted RW). This flag +# injects config.toml as a tmpfs file so writes are discarded +# on exit. (--persist-config is accepted but deprecated.) # --debug Enable verbose Codex logging (sets RUST_LOG=debug). # --new-session Force bwrap --new-session even on kernels with the TIOCSTI cap # guard. Detaches the controlling tty for stricter isolation at the @@ -107,7 +116,9 @@ source "${SCRIPT_DIR}/../lib/bwrap_sandbox_lib.sh" INIT_AUTH=0 DEBUG_MODE=0 -PERSIST_CONFIG=0 +# config.toml persists to the host by default (like settings.json in bwclaude). +# --ephemeral-config opts out (e.g. on shared accounts) so writes are discarded. +EPHEMERAL_CONFIG=0 # ═══════════════════════════════════════════════════════════════════ # Tool-specific functions @@ -136,8 +147,15 @@ parse_wrapper_args() { INIT_AUTH=1 shift ;; + --ephemeral-config) + EPHEMERAL_CONFIG=1 + shift + ;; --persist-config) - PERSIST_CONFIG=1 + # Deprecated: config.toml now persists by default. Accept the + # flag as a no-op so existing invocations keep working. + echo "bwcodex: --persist-config is deprecated; config.toml" \ + "now persists by default (use --ephemeral-config to opt out)." >&2 shift ;; --debug) @@ -209,15 +227,18 @@ Wrapper options (consumed before codex sees the command line): BNL ITD ChatGPT subscription auth path; has no effect for Hermes-routed setups. On shared accounts, do NOT use this flag — tokens will be ephemeral by default. - --persist-config Bind ~/.codex/config.toml read-write into the sandbox so - Codex can persist project trust decisions and other - config.toml mutations across sessions. WARNING: this - weakens sandbox isolation — a compromised Codex could - modify provider URLs, auth headers, or other config keys. - Concurrent --persist-config sessions may race on writes. - Without this flag (the default), config.toml is copied to - a per-session staging file and any writes are discarded - on exit. + --ephemeral-config Do NOT persist config.toml. By default Codex's + config.toml writes (project-trust grants, etc.) persist to + the host: the real file is relocated to + ~/.codex/persisted/config.toml (a symlink is left at the + original path, transparent to non-wrapped codex) and only + that one sub-directory is bind-mounted read-write — the rest + of ~/.codex stays ephemeral. With this flag, config.toml is + injected as a regular tmpfs file and any writes are discarded + on exit. Use on shared accounts, or to avoid recording + trust grants / config edits to the host. + (--persist-config is accepted but deprecated — now the + default.) --debug Enable verbose Codex logging (sets RUST_LOG=debug). --new-session Force bwrap to call setsid(2), detaching the sandbox from the controlling terminal. This is the default on older kernels @@ -275,6 +296,17 @@ resolve_codex_paths() { CODEX_SKILLS_PRIMARY="${CODEX_HOME_DIR}/skills" CODEX_PROMPTS_PRIMARY="${CODEX_HOME_DIR}/prompts" + # Persistent-config layout (default; disabled by --ephemeral-config): the + # real config.toml lives in a dedicated sub-directory that is bind-mounted + # read-write, and ${CODEX_CONFIG_FILE} + # is a symlink into it. Codex resolves the symlink and performs its + # temp-file + rename inside this directory, so writes persist on the host + # without exposing the rest of ~/.codex. Kept relative ("persisted/...") + # for the in-sandbox symlink so it resolves the same way on host and in + # the tmpfs. + CODEX_CONFIG_PERSIST_DIR="${CODEX_HOME_DIR}/persisted" + CODEX_CONFIG_PERSIST_FILE="${CODEX_CONFIG_PERSIST_DIR}/config.toml" + # 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" @@ -293,6 +325,70 @@ ensure_host_dirs() { mkdir -p "${CODEX_SKILLS_PRIMARY}" "${CODEX_PROMPTS_PRIMARY}" } +# Prepare the host layout for persistent config (the default; skipped under +# --ephemeral-config). Idempotent and reversible: the real config.toml is +# moved into ${CODEX_CONFIG_PERSIST_DIR} and ${CODEX_CONFIG_FILE} becomes a +# relative symlink to it. Plain (non-wrapped) codex follows it transparently. +ensure_persist_config_layout() { + # Case A — config.toml is already a symlink. Skip ONLY when it targets + # persisted/config.toml; any other target is relocated *into* persisted/ + # so the persistent layout is canonical. + if [[ -L "${CODEX_CONFIG_FILE}" ]]; then + local _have + _have="$(readlink "${CODEX_CONFIG_FILE}")" + # Resolve a relative target against config.toml's directory so it can + # be compared/preserved as an absolute path. + [[ "${_have}" == /* ]] || _have="${CODEX_HOME_DIR}/${_have}" + + mkdir -p "${CODEX_CONFIG_PERSIST_DIR}" + if [[ "${_have}" == "${CODEX_CONFIG_PERSIST_FILE}" ]]; then + # Canonical already — just ensure the bind target exists. + [[ -e "${CODEX_CONFIG_PERSIST_FILE}" ]] || : > "${CODEX_CONFIG_PERSIST_FILE}" + return + fi + + # Points elsewhere: move the symlink into persisted/ (preserving its + # target as an absolute path so the relocation does not change where + # it resolves), then repoint config.toml -> persisted/config.toml. + if [[ ! -e "${CODEX_CONFIG_PERSIST_FILE}" && ! -L "${CODEX_CONFIG_PERSIST_FILE}" ]]; then + ln -sfn "${_have}" "${CODEX_CONFIG_PERSIST_FILE}" + echo "bwcodex: relocated config.toml symlink into" \ + "${CODEX_CONFIG_PERSIST_DIR} (target ${_have})." >&2 + else + # persisted/config.toml already exists — adopt it rather than + # clobber, and just repoint config.toml at it. + echo "bwcodex: ${CODEX_CONFIG_PERSIST_FILE} already exists; adopting it" \ + "and repointing ${CODEX_CONFIG_FILE} (was -> ${_have})." >&2 + fi + ln -sfn "persisted/config.toml" "${CODEX_CONFIG_FILE}" + return + fi + + # Case B — config.toml is a real file and persisted/ does not yet exist: + # first-time migration. Move the file in and leave a symlink. + if [[ -f "${CODEX_CONFIG_FILE}" && ! -d "${CODEX_CONFIG_PERSIST_DIR}" ]]; then + mkdir -p "${CODEX_CONFIG_PERSIST_DIR}" + mv "${CODEX_CONFIG_FILE}" "${CODEX_CONFIG_PERSIST_FILE}" + ln -sfn "persisted/config.toml" "${CODEX_CONFIG_FILE}" + echo "bwcodex: migrated ${CODEX_CONFIG_FILE} ->" \ + "${CODEX_CONFIG_PERSIST_FILE} (config.toml is now a symlink)." >&2 + return + fi + + # Case C — anything else: ensure the bind source exists, seed a symlink + # when there is no user config yet, and leave a stray real file that + # coexists with an existing persisted/ untouched (no clobber). + mkdir -p "${CODEX_CONFIG_PERSIST_DIR}" + [[ -e "${CODEX_CONFIG_PERSIST_FILE}" ]] || : > "${CODEX_CONFIG_PERSIST_FILE}" + if [[ ! -e "${CODEX_CONFIG_FILE}" ]]; then + ln -sfn "persisted/config.toml" "${CODEX_CONFIG_FILE}" + else + echo "bwcodex: ${CODEX_CONFIG_FILE} is a regular file but" \ + "${CODEX_CONFIG_PERSIST_DIR} already exists; leaving it untouched" \ + "and using ${CODEX_CONFIG_PERSIST_FILE}." >&2 + fi +} + # Fail fast if no config.toml exists. Codex requires a config.toml to # select a provider; running bwcodex without one would produce a confusing # error from Codex itself. Either the system-wide file (deployed by @@ -352,23 +448,37 @@ build_codex_home_mount() { fi # User config.toml mount strategy (see file header): - # - Default: stage a writable copy in /tmp, bind RW into sandbox. - # Codex can persist project trust / other mutations for the session, - # but the host ~/.codex/config.toml is never touched. The staging - # file is cleaned up on script exit via the EXIT trap. - # - --persist-config: bind host config.toml RW so writes persist. - # If the user file does not exist (managed host using only system - # config), skip the user mount entirely. - if [[ -f "${CODEX_CONFIG_FILE}" ]]; then - if [[ "${PERSIST_CONFIG}" -eq 1 ]]; then - BWRAP_ARGS+=(--bind "${CODEX_CONFIG_FILE}" "${CODEX_CONFIG_FILE}") - else - _STAGED_CONFIG="$(mktemp --tmpdir bwcodex-config.XXXXXX.toml)" - cp -p "${CODEX_CONFIG_FILE}" "${_STAGED_CONFIG}" - chmod u+w "${_STAGED_CONFIG}" - trap 'rm -f "${_STAGED_CONFIG}"' EXIT INT TERM - BWRAP_ARGS+=(--bind "${_STAGED_CONFIG}" "${CODEX_CONFIG_FILE}") - fi + # - Default: bind ~/.codex/persisted/ RW and symlink config.toml into it, + # so Codex's temp-file + rename persists to the host. + # - --ephemeral-config: inject config.toml as a regular tmpfs file via + # bwrap --file; writes are discarded on exit, host file is untouched. + if [[ "${EPHEMERAL_CONFIG}" -eq 0 ]]; then + # Default: persist config.toml writes (project-trust grants, etc.) to + # the host WITHOUT exposing the rest of ~/.codex. Codex rewrites + # config.toml via temp-file + rename (write_atomically), which needs a + # real (non-mountpoint) parent directory; a single-file bind mount + # cannot be renamed over (EBUSY -> "failed to persist config", + # JSON-RPC -32603). So we bind one dedicated sub-directory RW and make + # config.toml a symlink into it. Codex resolves the symlink + # (resolve_symlink_write_paths) and renames inside the bound dir, so + # the new config lands on the host. ensure_persist_config_layout() + # has already established the host symlink + persisted/config.toml. + BWRAP_ARGS+=(--bind "${CODEX_CONFIG_PERSIST_DIR}" "${CODEX_CONFIG_PERSIST_DIR}") + BWRAP_ARGS+=(--symlink "persisted/config.toml" "${CODEX_CONFIG_FILE}") + # A file in persisted/ may itself be a symlink pointing OUTSIDE the + # bound directory (e.g. config.toml relocated from a dotfiles repo by + # ensure_persist_config_layout). Following it inside the sandbox would + # reach a tmpfs path and Codex's writes would be lost, so chase each + # symlink and bind its real target's directory RW. + build_codex_persist_symlink_targets + elif [[ -f "${CODEX_CONFIG_FILE}" ]]; then + # --ephemeral-config: inject config.toml as a *regular file* inside the + # CODEX_HOME tmpfs via bwrap --file. bwrap copies the host file's + # contents into a plain file (not a mount point), so Codex's temp-file + # + rename succeeds. Writes live only in the tmpfs and are discarded + # on exit, leaving the host config.toml untouched. + exec {_CFG_FD}<"${CODEX_CONFIG_FILE}" + BWRAP_ARGS+=(--file "${_CFG_FD}" "${CODEX_CONFIG_FILE}") fi if [[ -e "${CODEX_AGENTS_FILE}" ]]; then @@ -395,6 +505,43 @@ build_codex_home_mount() { fi } +# ── Persisted-config symlink chaser (read-write) ──────────────── +# persisted/config.toml (and any other file in persisted/) may be a symlink +# pointing outside the bound persisted/ directory — e.g. a config.toml that +# ensure_persist_config_layout relocated from a dotfiles repo. The persisted/ +# directory itself is bind-mounted RW, but the symlink target is not, so inside +# the sandbox $HOME is tmpfs and following the symlink reaches a non-persistent +# path: Codex's temp-file + rename would be discarded on exit. +# +# Chase each symlink to its real target (readlink -f) and bind the target's +# *directory* read-write, so Codex's atomic write (temp file + rename, which +# happens in the resolved file's directory) lands on the host. Mirrors the +# readlink -f + _check_path_safe pattern used for ~/.gitconfig and tool +# binaries; _check_path_safe aborts if a target resolves into a blocked path +# (~/.ssh, ~/.gnupg, ...). Dedups via _MOUNTED_PREFIXES so a target dir shared +# by several entries is bound once. +build_codex_persist_symlink_targets() { + [[ -d "${CODEX_CONFIG_PERSIST_DIR}" ]] || return 0 + + local _entry _real _parent + for _entry in "${CODEX_CONFIG_PERSIST_DIR}"/*; do + [[ -L "${_entry}" ]] || continue + _real="$(readlink -f "${_entry}" 2> /dev/null || true)" + # Skip dangling symlinks (nothing to bind) and targets that already + # resolve back inside persisted/ (covered by the directory bind). + [[ -n "${_real}" && -e "${_real}" ]] || continue + [[ "${_real}" == "${CODEX_CONFIG_PERSIST_DIR}"/* ]] && continue + + _parent="$(dirname "${_real}")" + _check_path_safe "${_parent}" "persisted/ symlink target" + if [[ -z "${_MOUNTED_PREFIXES["${_parent}"]:-}" ]]; then + _emit_home_intermediate_dirs "${_parent}" inclusive + BWRAP_ARGS+=(--bind "${_parent}" "${_parent}") + _MOUNTED_PREFIXES["${_parent}"]=1 + fi + done +} + # ── 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. @@ -477,7 +624,13 @@ main() { resolve_codex_paths detect_shell ensure_host_dirs + # Fail-fast on a truly missing config BEFORE the persist layout seeds an + # empty one (otherwise the seed would mask the "no provider configured" + # error). A pre-existing user symlink resolves through to its target here. check_codex_config + if [[ "${EPHEMERAL_CONFIG}" -eq 0 ]]; then + ensure_persist_config_layout + fi parse_all_git_includes init_sandbox_path From 4ac9199657836173134f8f02113a71cf4a26c608 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 7 Jul 2026 22:27:31 -0400 Subject: [PATCH 3/3] STY: run shfmt --- bin/bwcodex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/bwcodex b/bin/bwcodex index 9454efd..0f05101 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -477,7 +477,7 @@ build_codex_home_mount() { # contents into a plain file (not a mount point), so Codex's temp-file # + rename succeeds. Writes live only in the tmpfs and are discarded # on exit, leaving the host config.toml untouched. - exec {_CFG_FD}<"${CODEX_CONFIG_FILE}" + exec {_CFG_FD}< "${CODEX_CONFIG_FILE}" BWRAP_ARGS+=(--file "${_CFG_FD}" "${CODEX_CONFIG_FILE}") fi