Make the monitor mode and delivery.sh set work under Claude Code's sandboxed Bash tool#106
Merged
Merged
Conversation
When Claude Code's sandbox is enabled, monitor mode fails because: - Filesystem writes are restricted to the project directory, blocking pidfile and SQLite WAL writes under ~/.agents/skills/agmsg/ - BASH_SOURCE[0] is empty inside sourced functions (pipe/eval context), breaking storage.sh path resolution - ps is blocked, causing watch.sh to crash on cmdline validation Fixes: - storage.sh: fall back to SKILL_DIR (set by caller from $0) when BASH_SOURCE[0] is empty - watch.sh: handle empty ps output gracefully, skip cmdline validation when ps is unavailable - README.md + SKILL.md: document allowWrite sandbox config for users
The settings-injection helpers in delivery.sh (strip_agmsg_event_file,
add_event_entry_file, prune_empty_hooks_file, apply_settings) and the
release helper release/sync-version.sh all call bare `mktemp`. On macOS,
BSD mktemp with no template resolves the temp dir via
confstr(_CS_DARWIN_USER_TEMP_DIR) to /var/folders/.../T/ and ignores the
$TMPDIR env var. Under Claude Code's sandbox that path is not writable, so
mktemp fails with "Operation not permitted"; combined with `set -euo pipefail`,
this aborted `delivery.sh set` before it ever wrote settings.local.json.
Anchor every mktemp to ${TMPDIR:-/tmp} so the temp file lands in the
sandbox-writable temp dir. GNU mktemp (Linux) already honors $TMPDIR, so the
explicit form is portable and strictly safer there too.
Verified under the macOS sandbox: `delivery.sh set monitor|turn|off` now
complete and write settings.local.json.
…d template The sandbox compatibility section added in 0d9b2a3 only landed in the repo-root SKILL.md (the marketplace-plugin skill source). install.sh generates the installed Claude Code command from templates/cmd.claude-code.md and never reads root SKILL.md, so ./install.sh users never saw the allowWrite guidance. Port the section into cmd.claude-code.md, adapting paths to the __SKILL_NAME__ placeholder so custom command names resolve correctly.
Owner
|
Thanks for the thorough writeup and fix, @tatsuya6502 — the sandbox breakdown (hooks run outside, Monitor/Bash-tool run inside) is accurate, and the One trivial, non-blocking follow-up: in Heads-up that we tend to merge contributions quickly, so follow-ups are welcome as separate PRs. Thanks again! |
fujibee
added a commit
that referenced
this pull request
Jun 14, 2026
…114 review) Addresses aggie-co's review: busy_timeout fixes concurrent writes to an existing DB, but a leader fanning out as the FIRST write to a fresh/override store (the #106 sandbox case) still dropped — init-db.sh raced its own initialization. - init-db.sh: run idempotently and unconditionally (drop the file-existence guard) with CREATE TABLE/INDEX IF NOT EXISTS, so concurrent initializers no-op instead of aborting on "already exists". Set journal_mode=WAL best-effort (the mode change wants exclusive access and can hit "database is locked" on a brand-new DB even with a busy_timeout; it's an optimization, and whichever initializer wins makes it stick). - send.sh: retry the INSERT once after re-running init when the first attempt fails — covers the window where a process sees the DB file before the winning initializer has committed the schema ("no such table"). Test: a 10-way concurrent fan-out to a FRESH (uninitialized) override store all lands (was 0/10, then 8-9/10 with IF NOT EXISTS alone, now 10/10). Full suite green (236).
fujibee
added a commit
that referenced
this pull request
Jun 15, 2026
…no longer drop (SQLITE_BUSY) (#115) * fix(storage): busy_timeout on all DB connections to survive concurrent writes (#114) send.sh (and every other sqlite3 caller) opened the DB with the default busy_timeout=0, so concurrent writers failed immediately with SQLITE_BUSY instead of waiting. A leader fanning a job out to N members hit this every time — only one write landed, the rest errored, and since send.sh just exits non-zero the dropped messages were silently lost (they never reached the DB). WAL allows readers + one writer but does not let writers run concurrently. Add agmsg_sqlite() to lib/storage.sh — sqlite3 with a `.timeout` so a writer waits for the lock instead of failing — and route every DB-backed call site (send/inbox/check-inbox/history/rename/rename-team/watch/init-db) through it. In-memory JSON parsing (sqlite3 :memory:) is untouched (no file lock). Uses the `.timeout` dot-command, not `PRAGMA busy_timeout=N`: the PRAGMA returns its value as a row that sqlite3 would print, corrupting every SELECT (and the watch stream). `.timeout` sets the same timeout silently. Tests: a 10-way concurrent send fan-out all lands (no SQLITE_BUSY); agmsg_sqlite output is not polluted. Full suite green (235). * fix(storage): make first-write init race-safe for concurrent fan-out (#114 review) Addresses aggie-co's review: busy_timeout fixes concurrent writes to an existing DB, but a leader fanning out as the FIRST write to a fresh/override store (the #106 sandbox case) still dropped — init-db.sh raced its own initialization. - init-db.sh: run idempotently and unconditionally (drop the file-existence guard) with CREATE TABLE/INDEX IF NOT EXISTS, so concurrent initializers no-op instead of aborting on "already exists". Set journal_mode=WAL best-effort (the mode change wants exclusive access and can hit "database is locked" on a brand-new DB even with a busy_timeout; it's an optimization, and whichever initializer wins makes it stick). - send.sh: retry the INSERT once after re-running init when the first attempt fails — covers the window where a process sees the DB file before the winning initializer has committed the schema ("no such table"). Test: a 10-way concurrent fan-out to a FRESH (uninitialized) override store all lands (was 0/10, then 8-9/10 with IF NOT EXISTS alone, now 10/10). Full suite green (236).
fujibee
added a commit
that referenced
this pull request
Jun 15, 2026
- Direct script section now shows the `git clone` + `cd` it assumed, and notes it's the path that tracks latest main. - Community: credit @lucianlamp (native Windows PowerShell helpers, #103) and @tatsuya6502 (sandboxed Bash tool support, #106) — merged but uncredited.
fujibee
added a commit
that referenced
this pull request
Jun 15, 2026
* docs(readme): document npm and Claude Code plugin install paths Adds two install paths the README didn't cover after the PH-launch rework: - **npm / npx** — published since #89 via npm Trusted Publisher (OIDC) with SLSA provenance. `npx agmsg` is the lowest-friction path for Node-having users. - **Claude Code plugin marketplace** — `/plugin marketplace add fujibee/agmsg` + `/plugin install agmsg@fujibee-agmsg` + `/reload-plugins` + `/agmsg`. Verified end-to-end against a fresh Debian-based Claude Code container today: the in-CC slash command flow runs the SKILL.md Step 0 bootstrap (added in #85) and lands on the same `~/.agents/skills/agmsg/` runtime as the direct-script install. Also surfaces the `bash + sqlite3` prerequisite at the top of Quick Start. The dogfood revealed that minimal Linux images (Debian slim, etc.) don't include sqlite3 by default; the bootstrap installer surfaces a clear error, but it's worth flagging up front. macOS users are unaffected. The Install section is restructured into subsections (npm, plugin marketplace, direct script) so each path stands on its own. A note in the direct-script subsection clarifies that `--cmd` / `--agent-type` flags are direct-script only — the other paths always install as `agmsg` with auto-detected agent type. * docs(readme): note which install path tracks main vs tagged releases git clone / setup.sh install from main (always current); the npm package and Claude Code plugin are cut from tagged releases and can lag. Point readers at `/agmsg version` to see exactly what they're running (#117 provenance). * docs(readme): add clone step to Direct script; credit new contributors - Direct script section now shows the `git clone` + `cd` it assumed, and notes it's the path that tracks latest main. - Community: credit @lucianlamp (native Windows PowerShell helpers, #103) and @tatsuya6502 (sandboxed Bash tool support, #106) — merged but uncredited.
fujibee
added a commit
that referenced
this pull request
Jun 15, 2026
Wire up changelog generation and fold it into the release pipeline so a single command does packaging + changelog. - cliff.toml: git-cliff config, Keep a Changelog output from Conventional Commits. Non-conventional squash subjects are dropped except two headline community PRs (native Windows #103, sandbox #106) captured by subject. - CHANGELOG.md: bootstrapped from history; everything since v1.0.3 sits under [Unreleased] until the next release promotes it. - scripts/release/cut-release.sh <version>: bump VERSION + sync derived files + regenerate CHANGELOG.md + open a release PR + auto-merge on green + tag. PR- based because main now requires status checks (direct push is rejected). - release.yml: generate the GitHub Release notes for the tag with git-cliff (replaces the bare "see commit history"); checkout fetch-depth: 0 for it. - package.json: ship CHANGELOG.md in the npm tarball. - RELEASING.md: document the new one-shot flow. No VERSION bump here — this only adds the tooling.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thank you for creating
agmsg! This PR fixes two issues that arise when using it with Claude Code's sandbox enabled.Note that I used LLMs to investigate and fix these issues, as well as writing this PR description. The models I used were the followings:
I used Claude Code as the harness.
All code changes were reviewed and tested by me.
Summary
When Claude Code (CC) runs its Bash tool with the sandbox enabled (macOS
sandbox-exec/ Seatbelt; Linux seccomp-bpf), two categories ofagmsgoperations break:monitordelivery —watch.sh(launched via the Monitor tool) runs inside the sandbox and can't resolve its DB path, validate a prior watcher, or (without a one-line user config) write its pidfile.delivery.sh set <mode>— invoked from a sandboxed Bash tool (e.g. via/agmsg), it aborts before writingsettings.local.json.This PR fixes both, and documents the single required user-side setting.
Why it breaks
session-start.sh,session-end.sh,check-inbox.shwatch.sh/agmsg)delivery.sh set …,agmsg send …The sandbox allows reads anywhere, writes only inside an allowlist, and blocks
ps. (It also blockskill -0for arbitrary PIDs — see Known limitations.) Only the "inside sandbox" rows are affected.Changes
1.
scripts/lib/storage.sh— DB path whenBASH_SOURCE[0]is emptyCC's Bash tool runs commands via pipe/eval, so
BASH_SOURCE[0]is empty insidesourced functions.agmsg_storage_dir()used it to derive the DB path and fell back to CWD, resolvingmessages.dbunder the project dir instead of under the skill.Fix: add a
SKILL_DIRenv-var fallback. Every script that sourcesstorage.shalready resolvesSKILL_DIRfrom$0(which is populated when the script is invoked as a command), so this needs no caller changes. Resolution order is nowAGMSG_STORAGE_PATH→BASH_SOURCE[0]→SKILL_DIR→ error.2.
scripts/watch.sh— gracefulpsfallbackwatch.shvalidates a prior watcher's cmdline viaps -o args=before killing it (pid-recycling defense, #66). Under the sandboxpsis blocked, which crashed the branch. Now it treats emptypsoutput as "ps unavailable" and proceeds without the cmdline check.3.
scripts/delivery.sh+scripts/release/sync-version.sh— anchormktempto$TMPDIRdelivery.sh set …runs underset -euo pipefail. Its settings-injection helpers (strip_agmsg_event_file,add_event_entry_file,prune_empty_hooks_file,apply_settings) each call baremktemp. On macOS, BSDmktempwith no template resolves the temp dir viaconfstr(_CS_DARWIN_USER_TEMP_DIR)to/var/folders/.../T/and ignores the$TMPDIRenv var. CC's sandbox blocks that path, so:That aborts the entire
setbefore it ever reaches thesettings.local.jsonwrite. (The hooks file itself is innocent — it lives inside the project and is writable.)Fix: anchor every
mktempto the sandbox temp dir. GNUmktemp(Linux) already honors$TMPDIR, so the explicit form is portable and strictly safer there too.4.
README.md+SKILL.md— document the requiredallowWritesettingMonitor mode needs to write pidfiles and SQLite WAL/SHM under
~/.agents/skills/agmsg/, which is outside the project dir. Users with the sandbox enabled must allowlist it. Both docs now describe this and theBASH_SOURCEhandling.User-facing requirement
Users with Claude Code's sandbox enabled need to add (any settings scope):
{ "sandbox": { "filesystem": { "allowWrite": ["~/.agents/skills/agmsg/"] } } }Future improvement (not in this PR):
delivery.sh set monitorcould auto-inject this into.claude/settings.local.jsonso no manual config is needed.Testing
sandbox-exec(Seatbelt)monitordelivery end-to-end;delivery.sh set monitor|turn|offall writesettings.local.jsonmonitordelivery verified end-to-endKnown limitations
There are two minor issues remaining under the sandbox.
delivery.sh statusmisreports live watchers as dead.do_statusclassifies pidfiles withkill -0 "$pid", which the sandbox blocks for any pid ≠$$. A genuinely-running watcher is reported0 alive, 1 stale. Same mechanism as thepsblock; reproduces on macOS and Linux.watch.shprior-watcher dedup is skipped under the sandbox (thekill -0gate in change Hook UI message semantics (Claude Code and Codex) #2), so a supersededwatch.shcan be orphaned until it exits on its own. Minor; the new watcher still takes over via the pidfile.Both stem from
kill -0/psbeing unavailable in-sandbox. The right fix is a design choice — a heartbeat file (watch.shtouchesrun/watch.<sid>.heartbeateach poll cycle; liveness checks read its mtime) is the leading candidate, vs. degrading gracefully ("N pidfiles present, liveness unverifiable"). Left for a follow-up PR because it's a behavior decision best made by the maintainer. Happy to open a separate PR for it.