From 1cd30e99016885b7fa62ecfc25a8ee2718d7c6a5 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 28 Sep 2026 17:27:57 -0700 Subject: [PATCH 01/15] Codex: record the effective profile with each seat (#1504) * Record the effective profile path with each seat * Use selected profile for rollout discovery * Normalize profile paths in resume tests --- .../types/codex/codex-record-session.sh | 35 ++++++++++++--- scripts/drivers/types/codex/template.md | 2 +- scripts/lib/role-session.sh | 4 +- tests/test_codex_resume.bats | 44 ++++++++++++++++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 843dce0f4..41e29d0b4 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -5,8 +5,9 @@ # otherwise send-side only and never runs actas-claim, so without this a codex # role would have no role-session record and could never be resumed (spawn would # always boot it fresh). This is the codex-side equivalent: the codex actas flow -# calls it, and it writes the record so a later spawn/resume brings the role back -# into its thread. +# calls it, and it writes the thread plus the effective CODEX_HOME so a later +# spawn/resume brings the role back into its thread and profile. The /clear +# recovery in self-fix.sh calls this same script after #1470 rebinds the seat. # # Usage: codex-record-session.sh [project] # @@ -160,6 +161,28 @@ if [ "$probe_ran" = "1" ]; then [ -n "$thread" ] || exit 0 fi +# Resolve the effective profile before either fallback can infer a thread. The +# rollout index belongs to CODEX_HOME, not necessarily to the process HOME. +# Codex defaults to $HOME/.codex when CODEX_HOME is unset; resolve either +# spelling to a physical absolute path so discovery and the stored destination +# use the same profile. A missing or malformed directory is not safe to publish +# as a delivery destination, so leave the previous record untouched. +codex_home="${CODEX_HOME:-}" +if [ -z "$codex_home" ]; then + [ -n "${HOME:-}" ] || exit 0 + codex_home="$HOME/.codex" +fi +case "$codex_home" in *[[:cntrl:]]*) exit 0 ;; esac +[ -d "$codex_home" ] || exit 0 +codex_home="$(agmsg_canonical_path "$codex_home")" +# Keep the absolute path in the cross-platform form used by Node consumers; +# Git Bash's physical /c/... spelling is normalized to C:/... on Windows. +codex_home="$(agmsg_normalize_project_path "$codex_home")" +case "$codex_home" in + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) exit 0 ;; +esac + if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside # monitor mode, a missing Node, a server that is not answering. The rollout scan @@ -167,10 +190,8 @@ if [ -z "$thread" ]; then # single-rollout case it always did, and on a project with history it records # nothing, which is what happens today. # - # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the - # dir check below fails -> fresh), not an unbound-variable abort (nit). - sessions_dir="${HOME:-}/.codex/sessions" - if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then + sessions_dir="$codex_home/sessions" + if [ -d "$sessions_dir" ]; then # Distinct thread ids whose session_meta cwd (canonicalized -- codex records # the physical cwd while agmsg may hold a symlinked path, #160) matches the # project, among the most recent rollouts. Exactly one => unambiguously ours. @@ -212,7 +233,7 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" || true +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 2877f0382..631887950 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -18,7 +18,7 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do If argument starts with "actas" followed by an agent name: 1. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" __AGENT_TYPE__`. If `` is not listed, join with `~/.agents/skills/__SKILL_NAME__/scripts/join.sh __AGENT_TYPE__ "$(pwd)"`. -2. Record the Codex thread so a later spawn can resume it: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. +2. Record this Codex thread and its effective profile directory so a later spawn can resume it and route notices to the right profile: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. 3. Use the role as the active FROM; monitor delivery is routed only to its recorded thread. diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index e00f926f8..71057b1c1 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -137,9 +137,10 @@ agmsg_role_session_load() { # from the type manifest. Empty when unknown. # project= the resolved project root # owner= the actas owner token written by actas-claim +# codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) agmsg_role_session_record() { - local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" @@ -163,6 +164,7 @@ agmsg_role_session_record() { printf 'type=%s\n' "$type" printf 'project=%s\n' "$project" [ -z "$owner" ] || printf 'owner=%s\n' "$owner" + [ -z "$codex_home" ] || printf 'codex_home=%s\n' "$codex_home" printf 'updated_at=%s\n' "$ts" [ -z "$named_ref" ] || printf 'named_ref=%s\n' "$named_ref" [ -z "$named_ref" ] || printf 'named_epoch=%s\n' "$named_epoch" diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 7a4d1a830..4a3738b64 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -14,6 +14,8 @@ setup() { export RUN_DIR="$SKILL_DIR/run" mkdir -p "$RUN_DIR" export CODEX_SESSIONS="$HOME/.codex/sessions" + export CODEX_HOME="$HOME/.codex" + mkdir -p "$CODEX_HOME" } teardown() { teardown_test_env; } @@ -68,21 +70,51 @@ recorded_uuid() { agmsg_role_session_uuid "$1" "$2" } -@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { - local proj; proj="$(mktemp -d)" - CODEX_THREAD_ID="env-thread-1" \ +@test "codex record: stores the thread and effective profile path" { + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/codex profile" + mkdir -p "$explicit_home" "$HOME/.codex" + CODEX_HOME="$explicit_home" CODEX_THREAD_ID="env-thread-1" \ bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "env-thread-1" ] - # type is recorded as codex. source "$SKILL_DIR/scripts/lib/role-session.sh" [ "$(agmsg_role_session_get team alice type)" = "codex" ] + expected_home="$(cd "$explicit_home" && pwd -P)" + # Match the recorder's cross-platform path spelling (not raw Git Bash /c/...). + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] + + # Unset CODEX_HOME uses the same default Codex uses, recorded as an absolute + # path rather than leaving a later reader to infer it from its own HOME. + env -u CODEX_HOME CODEX_THREAD_ID="env-thread-2" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(recorded_uuid team alice)" = "env-thread-2" ] + expected_home="$(cd "$HOME/.codex" && pwd -P)" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: falls back to the unique matching-cwd rollout when env is unset" { - local proj; proj="$(mktemp -d)" + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/profile-B" + mkdir -p "$explicit_home" + # A matching rollout in the default profile must not outrank the selected + # profile's own unique matching rollout. + CODEX_SESSIONS="$HOME/.codex/sessions" + make_rollout "wrong-profile-uuid" "$proj" + CODEX_SESSIONS="$explicit_home/sessions" make_rollout "fallback-uuid" "$proj" - ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + CODEX_HOME="$explicit_home" env -u CODEX_THREAD_ID \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "fallback-uuid" ] + source "$SKILL_DIR/scripts/lib/role-session.sh" + expected_home="$(cd "$explicit_home" && pwd -P)" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { From f2e134db73a31b9d8c3bd53bc9696a28d2b2d9ad Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 28 Sep 2026 22:12:38 -0700 Subject: [PATCH 02/15] agmsgd beta: skeleton (schema, owner/lifecycle, control socket, status, daemon.sh CLI) (#1505) ## Scope agmsgd beta skeleton: install.db schema (meta, daemon_owner, daemon_intent, daemon_start_attempts), executor liveness (pid + boot id), the daemon_owner CAS lifecycle, completion-record verification (install_id + digest against run/install-manifest.json), per-cycle drift watch (moved manifest / VERSION bump / in-place edit under scripts/), the control socket (stop/status only), log rotation, status.mjs (the owner/intent decision table, narrowed to the 3 beta-owned tables), main.mjs, the fixed bootstrap pair (agmsgd-launch.sh + agmsgd), and the top-level scripts/daemon.sh CLI (start/stop/status/enable/disable; resident registration for launchd/systemd --user/Task Scheduler). Also extracted the #963 detector (collectInstallBaseline / installChangedAgainst) out of scripts/internal/remote-sync.mjs into scripts/internal/install-baseline.mjs, with remote-sync.mjs re-exporting both names unchanged; tests/remote_sync_engine.test.mjs passes without modification (115/115). All plain .mjs, no build step, no external packages. ## Stop sequence verification The focused stop-sequence test passed 20 consecutive runs without reproducing the intermittent hang after closing inherited file descriptors in both the lock-holding sqlite3 child and the launcher child, and removing the duplicate sqlite3 .quit write. These are candidate causes; the root cause is not established. ## Spawn environment CODEX_HOME is intentionally inherited by spawned Codex seats because codex-record-session.sh records the active profile for resume, and the spawned Codex must use that same profile. ## Tested - .github/scripts/check-enforced-assertions.sh: 621 unenforceable assertions, at the existing baseline; passed. - Focused assertions in tests/test_agmsgd_daemon_sh.bats: 4/4 passed; tests/test_agmsgd_entrypoint.bats: 1/1 passed; tests/test_agmsgd_launch.bats: 1/1 passed. - The stop-sequence test in tests/test_agmsgd_daemon_sh.bats passed 20 consecutive runs. - launchd/systemd/schtasks commands are overridable (AGMSGD_LAUNCHCTL/AGMSGD_SYSTEMCTL/AGMSGD_SCHTASKS); automated tests never call the real resident-manager binaries, only a fake stand-in. Real registration is exercised by hand only, and always torn down right after. ## Not yet verified - Linux (systemd --user) and Windows (Task Scheduler / schtasks XML) resident-registration code paths are written and shellchecked but not yet run on real Linux/Windows hardware. --- scripts/daemon.sh | 530 ++++++++++++++++++++++++++ scripts/daemon/agmsgd | 147 +++++++ scripts/daemon/agmsgd-launch.sh | 122 ++++++ scripts/daemon/control.mjs | 193 ++++++++++ scripts/daemon/db.mjs | 48 +++ scripts/daemon/executor.mjs | 92 +++++ scripts/daemon/lifecycle.mjs | 187 +++++++++ scripts/daemon/log.mjs | 43 +++ scripts/daemon/main.mjs | 144 +++++++ scripts/daemon/owner.mjs | 130 +++++++ scripts/daemon/schema.sql | 79 ++++ scripts/daemon/status.mjs | 194 ++++++++++ scripts/internal/install-baseline.mjs | 104 +++++ scripts/internal/remote-sync.mjs | 102 +---- tests/agmsgd_control.test.mjs | 144 +++++++ tests/agmsgd_db.test.mjs | 69 ++++ tests/agmsgd_executor.test.mjs | 30 ++ tests/agmsgd_lifecycle.test.mjs | 177 +++++++++ tests/agmsgd_log.test.mjs | 33 ++ tests/agmsgd_main.test.mjs | 154 ++++++++ tests/agmsgd_owner.test.mjs | 155 ++++++++ tests/agmsgd_status.test.mjs | 101 +++++ tests/test_agmsgd_control.bats | 6 + tests/test_agmsgd_daemon_sh.bats | 365 ++++++++++++++++++ tests/test_agmsgd_db.bats | 6 + tests/test_agmsgd_entrypoint.bats | 116 ++++++ tests/test_agmsgd_executor.bats | 6 + tests/test_agmsgd_launch.bats | 130 +++++++ tests/test_agmsgd_lifecycle.bats | 6 + tests/test_agmsgd_log.bats | 6 + tests/test_agmsgd_main.bats | 6 + tests/test_agmsgd_owner.bats | 6 + tests/test_agmsgd_status.bats | 6 + tests/test_spawn.bats | 6 +- 34 files changed, 3548 insertions(+), 95 deletions(-) create mode 100644 scripts/daemon.sh create mode 100755 scripts/daemon/agmsgd create mode 100755 scripts/daemon/agmsgd-launch.sh create mode 100644 scripts/daemon/control.mjs create mode 100644 scripts/daemon/db.mjs create mode 100644 scripts/daemon/executor.mjs create mode 100644 scripts/daemon/lifecycle.mjs create mode 100644 scripts/daemon/log.mjs create mode 100644 scripts/daemon/main.mjs create mode 100644 scripts/daemon/owner.mjs create mode 100644 scripts/daemon/schema.sql create mode 100644 scripts/daemon/status.mjs create mode 100644 scripts/internal/install-baseline.mjs create mode 100644 tests/agmsgd_control.test.mjs create mode 100644 tests/agmsgd_db.test.mjs create mode 100644 tests/agmsgd_executor.test.mjs create mode 100644 tests/agmsgd_lifecycle.test.mjs create mode 100644 tests/agmsgd_log.test.mjs create mode 100644 tests/agmsgd_main.test.mjs create mode 100644 tests/agmsgd_owner.test.mjs create mode 100644 tests/agmsgd_status.test.mjs create mode 100644 tests/test_agmsgd_control.bats create mode 100644 tests/test_agmsgd_daemon_sh.bats create mode 100644 tests/test_agmsgd_db.bats create mode 100644 tests/test_agmsgd_entrypoint.bats create mode 100644 tests/test_agmsgd_executor.bats create mode 100644 tests/test_agmsgd_launch.bats create mode 100644 tests/test_agmsgd_lifecycle.bats create mode 100644 tests/test_agmsgd_log.bats create mode 100644 tests/test_agmsgd_main.bats create mode 100644 tests/test_agmsgd_owner.bats create mode 100644 tests/test_agmsgd_status.bats diff --git a/scripts/daemon.sh b/scripts/daemon.sh new file mode 100644 index 000000000..eb6ad7f1b --- /dev/null +++ b/scripts/daemon.sh @@ -0,0 +1,530 @@ +#!/usr/bin/env bash +# agmsgd's CLI ("agmsg daemon start|stop|status|enable|disable"). +# Written to take the subcommand as its own first argument so +# the future `agmsg` dispatcher (a separate PR/design, decided 2026-09-29) +# can wrap this file directly without restructuring it. Every message this +# file prints therefore already says `agmsg daemon ...`, the form users +# will actually type once that dispatcher exists. +# +# No Node is required for `status` on the fast path: it reads +# install.db directly with sqlite3 first, and only shells out to Node +# (status.mjs) for the fuller decision text when Node is available and +# the record even suggests it's worth asking. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_DB="$SKILL_DIR/run/install.db" +LOCK_DB="$SKILL_DIR/run/install-op.lock.db" +LAUNCHER="$SCRIPT_DIR/daemon/agmsgd-launch.sh" + +# Overridable so tests never register anything into the REAL resident +# manager (the real gui launchd domain, the real systemd --user, the real +# Task Scheduler) -- a test that killed a hung process outright, bypassing +# its own teardown, left exactly one real launchd unit behind on this +# machine. Tests point these at fake stand-ins; real registration is +# exercised only by hand and always torn down right after. +AGMSGD_LAUNCHCTL="${AGMSGD_LAUNCHCTL:-launchctl}" +AGMSGD_SYSTEMCTL="${AGMSGD_SYSTEMCTL:-systemctl}" +AGMSGD_SCHTASKS="${AGMSGD_SCHTASKS:-schtasks}" + +_usage() { + cat >&2 <<'EOF' +usage: agmsg daemon start|stop|status|enable|disable +EOF +} + +_require_install_db() { + if [ ! -f "$INSTALL_DB" ]; then + echo "agmsg daemon: run/install.db does not exist -- run install.sh first" >&2 + exit 1 + fi +} + +# Runs $1 (one or more SQL statements against install.db's tables, +# unqualified names -- see below) inside the operation lock. Optional +# remaining arguments name one resident-manager action that must share the +# same lock (enable/register or disable/unregister). +# +# The EXCLUSIVE lock belongs to $LOCK_DB (an empty file, PR 2's own +# design -- the lock IS the file, nothing is ever written into it +# directly). $1's statements target install.db, so install.db is ATTACHed +# into the SAME connection/transaction that holds the lock: a bare +# `sqlite3 "$LOCK_DB" "...UPDATE daemon_intent..."` would run that UPDATE +# against $LOCK_DB itself, which has no such table at all -- caught +# exactly this way while testing (`no such table: daemon_intent` even +# though install.db plainly has the table). ATTACH keeps the lock where +# PR 2 says it lives while still writing to the file that actually holds +# the data, in one real cross-file transaction. +_install_generation() { + local install_id manifest_path_sql manifest_id manifest_gen + install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" || return 1 + manifest_path_sql="$(printf '%s' "$SKILL_DIR/run/install-manifest.json" | sed "s/'/''/g")" + IFS='|' read -r manifest_id manifest_gen < <(sqlite3 :memory: "SELECT json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.install_id') || '|' || json_extract(CAST(readfile('$manifest_path_sql') AS TEXT), '\$.gen');" 2>/dev/null) || return 1 + [ -n "$install_id" ] && [ "$manifest_id" = "$install_id" ] && [ -n "$manifest_gen" ] || return 1 + printf '%s:%s' "$install_id" "$manifest_gen" +} + +# Hold the SQLite installation lock across the database change and the +# resident-manager operation. The install generation is captured before +# acquisition and rechecked while the lock is held, so an upgrade between +# those points cannot register an obsolete launcher. +_with_op_lock() { + local statements="$1" expected_generation current_generation ready applied lock_pid attach_path lock_dir + local AGMSGD_LOCK_INSTALL_ID + shift + expected_generation="$(_install_generation)" || { + echo "agmsg daemon: cannot read a complete install generation" >&2 + return 1 + } + AGMSGD_LOCK_INSTALL_ID="${expected_generation%%:*}" + lock_dir="$(mktemp -d "$SKILL_DIR/run/daemon-op-lock.XXXXXX")" || return 1 + mkfifo "$lock_dir/in" "$lock_dir/out" + sqlite3 -batch "$LOCK_DB" < "$lock_dir/in" > "$lock_dir/out" 3>&- 4>&- & + lock_pid=$! + exec 8> "$lock_dir/in" + exec 9< "$lock_dir/out" + attach_path="$(printf '%s' "$INSTALL_DB" | sed "s/'/''/g")" + printf '%s\n' '.bail on' "ATTACH DATABASE '$attach_path' AS installdb;" 'BEGIN EXCLUSIVE;' '.print LOCKED' >&8 + if ! IFS= read -r -t 10 ready <&9 || [ "$ready" != "LOCKED" ]; then + printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo "agmsg daemon: could not acquire the install operation lock" >&2 + return 1 + fi + local generation_row generation_marker manifest_sql + manifest_sql="$(printf '%s' "$SKILL_DIR/run/install-manifest.json" | sed "s/'/''/g")" + printf '%s\n' "SELECT CASE WHEN json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.install_id') = (SELECT install_id FROM installdb.meta) THEN (SELECT install_id FROM installdb.meta) || ':' || json_extract(CAST(readfile('$manifest_sql') AS TEXT), '\$.gen') ELSE '' END;" '.print GENERATION_END' >&8 + IFS= read -r -t 10 generation_row <&9 || generation_row="" + IFS= read -r -t 10 generation_marker <&9 || generation_marker="" + current_generation="$generation_row" + if [ "$generation_marker" != "GENERATION_END" ] || [ "$current_generation" != "$expected_generation" ]; then + printf 'ROLLBACK;\n.quit\n' >&8 + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo "agmsg daemon: install generation changed while acquiring the operation lock; try again" >&2 + return 1 + fi + if [ -n "$statements" ]; then + printf '%s\n.print APPLIED\n' "$statements" >&8 + if ! IFS= read -r -t 10 applied <&9 || [ "$applied" != "APPLIED" ]; then + printf 'ROLLBACK;\n.quit\n' >&8 2>/dev/null || true + exec 8>&- + exec 9<&- + wait "$lock_pid" 2>/dev/null || true + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + echo "agmsg daemon: install database update failed" >&2 + return 1 + fi + fi + local action_status=0 + if [ "$#" -gt 0 ]; then + "$@" || action_status=$? + fi + if [ "$action_status" -eq 0 ]; then + printf 'COMMIT;\n' >&8 + else + printf 'ROLLBACK;\n' >&8 + fi + printf '.quit\n' >&8 + exec 8>&- + exec 9<&- + wait "$lock_pid" || action_status=1 + rm -f "$lock_dir/in" "$lock_dir/out" + rmdir "$lock_dir" + return "$action_status" +} + +# A short, stable id for this install's resident-manager unit name, +# derived from the install anchor (realpath + install_id). Beta uses two +# of the three (root path, install_id); the record DB's own path is +# already implied by being run/install.db under this same root, so it is +# not a third independent input here. +_unit_id() { + local install_id="${AGMSGD_LOCK_INSTALL_ID:-}" + if [ -z "$install_id" ]; then + install_id="$(sqlite3 "$INSTALL_DB" "SELECT install_id FROM meta;" 2>/dev/null)" + fi + printf '%s:%s' "$SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12 +} + +_os() { + case "$(uname -s)" in + Darwin) echo darwin ;; + Linux) echo linux ;; + MINGW*|MSYS*|CYGWIN*) echo windows ;; + *) echo unknown ;; + esac +} + +# --------------------------------------------------------------------------- +# Resident registration. One function per OS; enable/ +# disable call whichever applies. Only the darwin path has been run for +# real in this environment -- linux (systemd --user) and windows (Task +# Scheduler) use the same contract and are shellchecked, but need +# their own hands-on confirmation on those platforms, flagged in the PR +# rather than claimed here. +# --------------------------------------------------------------------------- + +_launchd_label() { printf 'cc.agmsg.agmsgd.%s' "$(_unit_id)"; } +_launchd_plist_path() { printf '%s/Library/LaunchAgents/%s.plist' "$HOME" "$(_launchd_label)"; } + +_register_darwin() { + local node_path="$1" label plist + label="$(_launchd_label)" + plist="$(_launchd_plist_path)" + mkdir -p "$(dirname "$plist")" + cat > "$plist" < + + + + Label$label + ProgramArguments + + $LAUNCHER + + RunAtLoad + KeepAlive + + SuccessfulExit + + ThrottleInterval60 + StandardErrorPath$SKILL_DIR/run/agmsgd.stderr.log + + +EOF + "$AGMSGD_LAUNCHCTL" bootstrap "gui/$(id -u)" "$plist" 2>/dev/null || "$AGMSGD_LAUNCHCTL" load "$plist" +} + +_unregister_darwin() { + local label plist + label="$(_launchd_label)" + plist="$(_launchd_plist_path)" + if [ -f "$plist" ]; then + if ! "$AGMSGD_LAUNCHCTL" bootout "gui/$(id -u)/$label" 2>/dev/null && ! "$AGMSGD_LAUNCHCTL" unload "$plist" 2>/dev/null; then + echo "agmsg daemon: could not unregister launchd service $label" >&2 + return 1 + fi + rm -f "$plist" + fi +} + +_systemd_unit_path() { printf '%s/.config/systemd/user/agmsgd-%s.service' "$HOME" "$(_unit_id)"; } + +# NOT YET RUN ON LINUX in this environment -- configured with +# (Restart=on-failure, RestartSec=30s, StartLimitIntervalSec=600, +# StartLimitBurst=5) and shellchecked only. +_register_linux() { + local unit_path unit_name + unit_path="$(_systemd_unit_path)" + unit_name="$(basename "$unit_path")" + mkdir -p "$(dirname "$unit_path")" + cat > "$unit_path" <&2 + return 1 + fi + rm -f "$unit_path" + "$AGMSGD_SYSTEMCTL" --user daemon-reload + fi +} + +_schtasks_name() { printf 'agmsgd-%s' "$(_unit_id)"; } + +_service_registered() { + case "$(_os)" in + darwin) [ -f "$(_launchd_plist_path)" ] ;; + linux) [ -f "$(_systemd_unit_path)" ] ;; + windows) [ -f "$SKILL_DIR/run/$(_schtasks_name).xml" ] ;; + *) return 1 ;; + esac +} + +_start_registered_service() { + case "$(_os)" in + darwin) "$AGMSGD_LAUNCHCTL" kickstart "gui/$(id -u)/$(_launchd_label)" ;; + linux) "$AGMSGD_SYSTEMCTL" --user start "$(basename "$(_systemd_unit_path)")" ;; + windows) "$AGMSGD_SCHTASKS" /Run /TN "$(_schtasks_name)" ;; + *) echo "agmsg daemon start: unsupported resident manager" >&2; return 1 ;; + esac +} + +_start_unregistered_launcher() { + bash "$LAUNCHER" /dev/null 2>&1 3>&- 4>&- & + disown 2>/dev/null || true +} + +# NOT YET RUN ON WINDOWS in this environment -- written to the Windows +# Task Scheduler contract (LogonTrigger + RestartOnFailure via XML, since plain +# `/Create` flags do not set restart-on-failure) and shellchecked only. +_register_windows() { + local node_path="$1" name xml + name="$(_schtasks_name)" + xml="$SKILL_DIR/run/${name}.xml" + local native_launcher + native_launcher="$(cygpath -w "$LAUNCHER" 2>/dev/null || printf '%s' "$LAUNCHER")" + local xml_utf8="$xml.utf8" + cat > "$xml_utf8" < + + + true + + + + bash.exe + "$native_launcher" + + + + + PT1M + 3 + + + +EOF + { printf '\xff\xfe'; iconv -f UTF-8 -t UTF-16LE "$xml_utf8"; } > "$xml" + rm -f "$xml_utf8" + "$AGMSGD_SCHTASKS" /Create /TN "$name" /XML "$xml" /F +} + +_unregister_windows() { + local name xml + name="$(_schtasks_name)" + xml="$SKILL_DIR/run/$name.xml" + if [ ! -f "$xml" ]; then + return 0 + fi + if ! "$AGMSGD_SCHTASKS" /Delete /TN "$name" /F; then + echo "agmsg daemon: could not delete scheduled task $name" >&2 + return 1 + fi + rm -f "$xml" +} + +_register() { + case "$(_os)" in + darwin) _register_darwin "$1" ;; + linux) _register_linux "$1" ;; + windows) _register_windows "$1" ;; + *) echo "agmsg daemon: unsupported OS for resident registration" >&2; return 1 ;; + esac +} + +_unregister() { + case "$(_os)" in + darwin) _unregister_darwin ;; + linux) _unregister_linux ;; + windows) _unregister_windows ;; + *) return 0 ;; + esac +} + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +# Confirms Node by actually running it, the same check +# agmsgd-launch.sh itself does -- enable/start both need this before +# recording node_path/node_version. +_resolve_node() { + local candidate="${1:-node}" resolved + resolved="$(command -v "$candidate" 2>/dev/null)" || return 1 + "$resolved" -e ' + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj < 22 || (maj === 22 && min < 13)) process.exit(1); + require("node:sqlite"); + ' 2>/dev/null || return 1 + printf '%s' "$resolved" +} + +_wait_for_ready() { + local target_op_gen="$1" waited=0 + while [ "$waited" -lt 100 ]; do + local state op_gen + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null || true)" + if [ "$state" = "ready" ] && [ "$op_gen" = "$target_op_gen" ]; then + return 0 + fi + waited=$((waited + 1)) + sleep 0.1 + done + return 1 +} + +cmd_start() { + _require_install_db + local state + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" + if [ "$state" = "ready" ] && node "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" >/dev/null 2>&1; then + echo "agmsg daemon start: already running" + return 0 + fi + + local new_op_gen + _with_op_lock "UPDATE daemon_intent SET desired = 'on', op_gen = op_gen + 1, set_by = 'start', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + new_op_gen="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;")" + if _service_registered; then + if ! _start_registered_service; then + echo "agmsg daemon start: the registered service manager could not start agmsgd" >&2 + return 1 + fi + else + _start_unregistered_launcher + fi + if _wait_for_ready "$new_op_gen"; then + echo "agmsg daemon start: running" + else + echo "agmsg daemon start: did not become ready in time -- check 'agmsg daemon status'" >&2 + return 1 + fi +} + +# Best-effort courtesy nudge over the control socket -- NOT what confirms +# a stop; the caller's own polling of daemon_owner.state does that. A hard +# 5s ceiling: relying on the socket's own close/error events alone left +# this able to hang the whole command indefinitely if the daemon never +# answers (observed while testing: a daemon that had already started +# stepping aside for an unrelated reason at the same moment left this +# connection open with neither event firing). +_request_stop_over_socket() { + local socket="$1" + [ -n "$socket" ] || return 0 + node -e ' + const net = require("node:net"); + const s = net.createConnection(process.argv[1]); + const done = () => { try { s.destroy(); } catch {} process.exit(0); }; + setTimeout(done, 5000); + s.on("connect", () => { + s.write(JSON.stringify({type:"hello",protocol:1,role:"control"}) + "\n"); + s.write(JSON.stringify({type:"stop"}) + "\n"); + }); + s.on("close", done); + s.on("error", done); + ' "$socket" 2>/dev/null || true +} + +cmd_stop() { + _require_install_db + # Stop takes no operation lock. + local desired + desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || true)" + if [ "$desired" != "on" ]; then + echo "agmsg daemon stop: already stopped" + return 0 + fi + sqlite3 "$INSTALL_DB" "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'stop', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" + local socket + socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + _request_stop_over_socket "$socket" + local waited=0 + while [ "$waited" -lt 100 ]; do + local state + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + [ "$state" = "none" ] && { echo "agmsg daemon stop: stopped"; return 0; } + waited=$((waited + 1)) + sleep 0.1 + done + echo "agmsg daemon stop: requested, but it has not confirmed stopping yet -- check 'agmsg daemon status'" >&2 + return 1 +} + +cmd_status() { + _require_install_db + local node_path + node_path="$(_resolve_node node 2>/dev/null || true)" + if [ -n "$node_path" ]; then + "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" + return $? + fi + # K14 fallback: no usable Node at all -- a minimal, honest read straight + # from install.db rather than the fuller status.mjs decision text. + local state desired + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || echo "unknown")" + desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || echo "unknown")" + echo "agmsg daemon status: state=$state intent=$desired (no usable Node -- a fuller check needs 'agmsg daemon enable')" +} + +cmd_enable() { + _require_install_db + local node_path + node_path="$(_resolve_node node)" || { + echo "agmsg daemon enable: no usable Node found (need >= 22.13.0 with node:sqlite). Install one and try again." >&2 + return 1 + } + local node_version + node_version="$("$node_path" -e 'console.log(process.versions.node)')" + _with_op_lock " + UPDATE meta SET node_path = '$(printf '%s' "$node_path" | sed "s/'/''/g")', node_version = '$node_version'; + UPDATE daemon_intent SET desired = 'on'; + " _register "$node_path" || { + if ! _with_op_lock "" _unregister; then + echo "agmsg daemon enable: could not clean up the partially registered service" >&2 + fi + return 1 + } + cmd_start +} + +cmd_disable() { + _require_install_db + _with_op_lock "UPDATE daemon_intent SET desired = 'off', op_gen = op_gen + 1, set_by = 'disable', set_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');" _unregister + local socket + socket="$(sqlite3 "$INSTALL_DB" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null || true)" + _request_stop_over_socket "$socket" + local waited=0 state + while [ "$waited" -lt 100 ]; do + state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || true)" + [ "$state" = "none" ] && break + waited=$((waited + 1)) + sleep 0.1 + done + if [ "$state" != "none" ]; then + echo "agmsg daemon disable: service was unregistered but agmsgd has not confirmed stopping -- check 'agmsg daemon status'" >&2 + return 1 + fi + echo "agmsg daemon disable: agmsgd turned off. Codex messages will go through the existing bridge again." + echo "agmsg daemon disable: any Codex session started while agmsgd was in use needs to be restarted to get its bridge back." +} + +case "${1:-}" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + enable) cmd_enable ;; + disable) cmd_disable ;; + *) _usage; exit 2 ;; +esac diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd new file mode 100755 index 000000000..dc8ab5673 --- /dev/null +++ b/scripts/daemon/agmsgd @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// agmsgd's entrypoint, executed by agmsgd-launch.sh. +// +// FIXED BOOTSTRAP, on purpose, same reasoning as agmsgd-launch.sh: this +// file does NOT import any other scripts/daemon/*.mjs module until AFTER +// it has, itself, under the install-op lock, confirmed (1) an install +// generation exists, (2) ITS OWN bytes are exactly what the completion +// record says they should be, (3) its own BOOTSTRAP_VERSION matches the +// record, and (4) the full scripts/ tree matches the record too. Every +// check below is therefore self-contained -- duplicated, deliberately, +// from lifecycle.mjs's own digest logic rather than importing it, because +// importing it would be importing "another module" before step 4 has +// even run. +// +// install.sh extracts this exact line (`^const BOOTSTRAP_VERSION = `, +// PR 2, 2026-09-29) and cross-checks it against agmsgd-launch.sh's own +// `^BOOTSTRAP_VERSION=` line before writing either into the completion +// record's bootstrap_version field. +const BOOTSTRAP_VERSION = 1; + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DatabaseSync } from "node:sqlite"; + +function fail(code, message) { + process.stderr.write(`agmsgd: ${message}\n`); + process.exit(code); +} + +function collectScriptFiles(installRoot) { + const files = []; + const root = join(installRoot, "scripts"); + function walk(dir, relPrefix) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) throw new Error(`symlink under scripts/: ${rel}`); + if (entry.isDirectory()) walk(abs, rel); + else if (entry.isFile()) files.push(rel); + } + } + walk(root, "scripts"); + files.sort(); + return files; +} + +function sha256File(installRoot, relPath) { + return createHash("sha256").update(readFileSync(join(installRoot, relPath))).digest("hex"); +} + +async function main() { + const [installRoot, desired, opGenStr] = process.argv.slice(2); + if (!installRoot || !desired || !opGenStr) { + fail(75, "usage: agmsgd (only agmsgd-launch.sh should invoke this)"); + } + const expectedOpGen = Number(opGenStr); + + const lockPath = join(installRoot, "run", "install-op.lock.db"); + const manifestPath = join(installRoot, "run", "install-manifest.json"); + const dbPath = join(installRoot, "run", "install.db"); + + const lockDb = new DatabaseSync(lockPath); + lockDb.exec("BEGIN EXCLUSIVE;"); + let manifest; + let mainModule; + try { + // 1. An install generation exists at all. + if (!existsSync(manifestPath)) { + fail(0, "no completion record -- install.sh needs to run again"); + } + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + + // 2. This file's own digest is in the record, at its own path. + const selfPath = "scripts/daemon/agmsgd"; + const selfEntry = manifest.files.find((f) => f.path === selfPath); + if (!selfEntry) fail(75, `completion record has no entry for ${selfPath}`); + const selfDigest = sha256File(installRoot, selfPath); + if (selfDigest !== selfEntry.digest) { + fail(75, "this file's own digest does not match the completion record"); + } + + // 3. Bootstrap version matches; the launcher checks its own version too. + if (manifest.bootstrap_version !== BOOTSTRAP_VERSION) { + fail(75, `bootstrap version mismatch: this file is ${BOOTSTRAP_VERSION}, record has ${manifest.bootstrap_version}`); + } + + // 4. install_id: install.db's meta is the source of truth (PR 2); the + // manifest carries only a copy. + const installDb = new DatabaseSync(dbPath, { readOnly: true }); + let recordedInstallId; + try { + recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; + } finally { + installDb.close(); + } + if (recordedInstallId !== manifest.install_id) { + fail(75, `install_id mismatch: install.db has ${recordedInstallId ?? "(none)"}, manifest has ${manifest.install_id}`); + } + + // 5. Full digest verification: every file the manifest lists, no extras, + // no missing files, and no symlinks. + if (manifest.digest_algo !== "sha256") { + fail(75, `unsupported digest_algo: ${manifest.digest_algo}`); + } + let onDisk; + try { + onDisk = collectScriptFiles(installRoot); + } catch (error) { + fail(75, `digest verification failed: ${error.message}`); + } + const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const onDiskSet = new Set(onDisk); + const expectedPaths = expected.map((f) => f.path); + const expectedSet = new Set(expectedPaths); + const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); + if (missing.length > 0) fail(75, `digest verification failed: missing file(s): ${missing.join(", ")}`); + const extra = onDisk.filter((p) => !expectedSet.has(p)); + if (extra.length > 0) fail(75, `digest verification failed: unexpected file(s): ${extra.join(", ")}`); + for (const { path, digest } of expected) { + if (sha256File(installRoot, path) !== digest) fail(75, `digest verification failed: digest mismatch: ${path}`); + } + + // All checks passed -- now, and only now, import the rest of this + // component. main.mjs's own module graph (owner/control/lifecycle/ + // log/status) is loaded here, still under the lock; release it only + // after the component is fully loaded. + mainModule = await import("./main.mjs"); + } finally { + lockDb.exec("COMMIT;"); + lockDb.close(); + } + + const db = new DatabaseSync(dbPath); + db.exec(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "schema.sql"), "utf8")); + await mainModule.main(db, { + installRoot, + manifest, + manifestText: readFileSync(manifestPath, "utf8"), + expectedDesired: desired, + expectedOpGen, + version: manifest.version, + }); +} + +main(); diff --git a/scripts/daemon/agmsgd-launch.sh b/scripts/daemon/agmsgd-launch.sh new file mode 100755 index 000000000..82e3dd9e9 --- /dev/null +++ b/scripts/daemon/agmsgd-launch.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# agmsgd's launcher. This is what the resident manager +# (launchd / systemd --user / Task Scheduler) actually starts. +# +# FIXED BOOTSTRAP, on purpose: this file's own bytes are checked against +# the completion record the same way scripts/daemon/agmsgd's are, so it +# must never drift version to version except by an +# explicit bootstrap-version bump recorded there. It therefore does NOT +# source scripts/lib/*.sh (those can and do change release to release) -- +# only external tools (sqlite3) and bash builtins. +# +# install.sh extracts this exact line (`^BOOTSTRAP_VERSION=`, PR 2, +# 2026-09-29) and cross-checks it against scripts/daemon/agmsgd's +# own `const BOOTSTRAP_VERSION = ` line before writing either into the +# completion record's bootstrap_version field. The line's shape must not +# change without install.sh's own extraction changing with it. +set -euo pipefail + +BOOTSTRAP_VERSION=1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +INSTALL_DB="$SKILL_DIR/run/install.db" +MANIFEST="$SKILL_DIR/run/install-manifest.json" +LOCK_DB="$SKILL_DIR/run/install-op.lock.db" + +# Records a start attempt without claiming ownership. Best-effort: if the +# reason cannot be stored in install.db, it still reaches stderr. +_agmsgd_launch_record_attempt() { + local reason="$1" + if [ -f "$INSTALL_DB" ]; then + sqlite3 "$INSTALL_DB" \ + "INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (strftime('%Y-%m-%dT%H:%M:%fZ','now'), '$(printf '%s' "$reason" | sed "s/'/''/g")', $$);" \ + 2>/dev/null || true + fi + echo "agmsgd-launch: $reason" >&2 +} + +# Non-blocking probe of run/install-op.lock.db's BEGIN EXCLUSIVE (PR 2) -- +# an empty file this process never writes to, only locks against, exactly +# like lifecycle.mjs's own Node-side probe (kept in sync with it +# deliberately: same busy_timeout=0-then-BEGIN-EXCLUSIVE idiom, so bash and +# Node can never disagree about whether the lock is held). +_agmsgd_launch_lock_held() { + [ -f "$LOCK_DB" ] || return 1 + ! sqlite3 -cmd "PRAGMA busy_timeout=0;" "$LOCK_DB" "BEGIN EXCLUSIVE; ROLLBACK;" >/dev/null 2>&1 +} + +# Reads the completion record's bootstrap_version and compares it against +# this file's own $BOOTSTRAP_VERSION: both the launcher +# and the Node entrypoint check their own compiled-in constant against the +# SAME recorded value under the operation lock). Uses json_extract over +# readfile() -- the same idiom scripts/identities.sh already uses for team +# config.json -- rather than jq, which is not on this launcher's +# guaranteed PATH. Caller passes the already-confirmed-to-exist manifest +# path; this does not decide completeness, only the version match. +_agmsgd_launch_check_bootstrap_version() { + local manifest_path="$1" recorded + recorded="$(sqlite3 :memory: " + WITH raw(json) AS (SELECT CAST(readfile('$(printf '%s' "$manifest_path" | sed "s/'/''/g")') AS TEXT)) + SELECT json_extract(json, '\$.bootstrap_version') FROM raw; + " 2>/dev/null)" + [ "$recorded" = "$BOOTSTRAP_VERSION" ] +} + +# 1. install.db must be readable, or we must not assume `on`. +if [ ! -f "$INSTALL_DB" ]; then + echo "agmsgd-launch: run/install.db does not exist -- nothing to start" >&2 + exit 0 +fi +DESIRED="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null)" || { + _agmsgd_launch_record_attempt "install.db could not be read" + exit 0 +} + +# 2. Not `on` -> nothing to do, silently. +if [ "$DESIRED" != "on" ]; then + exit 0 +fi +OP_GEN="$(sqlite3 "$INSTALL_DB" "SELECT op_gen FROM daemon_intent;" 2>/dev/null)" + +# 3. Completion record state. +if [ -f "$MANIFEST" ]; then + : # complete -- proceed +elif [ -f "$MANIFEST.prev" ]; then + if _agmsgd_launch_lock_held; then + echo "agmsgd-launch: an install/uninstall is in progress" >&2 + exit 75 + fi + _agmsgd_launch_record_attempt "a previous update never completed -- run install.sh again" + exit 0 +else + _agmsgd_launch_record_attempt "no completion record -- install.sh needs to run again" + exit 0 +fi + +_agmsgd_launch_check_bootstrap_version "$MANIFEST" || { + _agmsgd_launch_record_attempt "bootstrap version mismatch" + exit 75 +} + +# 4. Node: resolve, then ACTUALLY RUN it; availability is confirmed by +# execution (version + node:sqlite loads), not by trusting the recorded +# path/version strings. +NODE_PATH="$(sqlite3 "$INSTALL_DB" "SELECT node_path FROM meta;" 2>/dev/null)" +if [ -z "$NODE_PATH" ] || [ ! -x "$NODE_PATH" ]; then + _agmsgd_launch_record_attempt "no usable Node recorded (run 'agmsg daemon enable' again)" + exit 0 +fi +NODE_CHECK_OUTPUT="$("$NODE_PATH" -e ' + const [maj, min] = process.versions.node.split(".").map(Number); + if (maj < 22 || (maj === 22 && min < 13)) { console.error("too old: " + process.versions.node); process.exit(1); } + require("node:sqlite"); + console.log(process.versions.node); +' 2>&1)" || { + _agmsgd_launch_record_attempt "recorded Node failed the version/node:sqlite check: $NODE_CHECK_OUTPUT" + exit 0 +} + +# 5. Hand off. `exec -a agmsgd` reads the entrypoint file exactly once, +# here, reading the entrypoint file only once. +exec -a agmsgd "$NODE_PATH" "$SCRIPT_DIR/agmsgd" "$SKILL_DIR" "$DESIRED" "$OP_GEN" diff --git a/scripts/daemon/control.mjs b/scripts/daemon/control.mjs new file mode 100644 index 000000000..8259c4476 --- /dev/null +++ b/scripts/daemon/control.mjs @@ -0,0 +1,193 @@ +// The UNIX control socket, narrowed to beta's own two +// requests: `stop` and `status`. Beta has no seat registration, no notice +// stream, no hook `turn` -- every connection is one-shot: connect, `hello`, +// exactly one request, exactly one response, close. +// +// Framing: one line, one JSON object. 1 MiB ceiling counted in +// BYTES from the first byte of the connection, checked before a newline +// ever arrives -- not after decoding to a string, since a byte count and a +// JS string's UTF-16 code-unit count are not the same number for non-ASCII +// input. Duplicate keys (including nested) are rejected via the existing +// parseStrictJson (scripts/internal/strict-jsonl.mjs) rather than a second +// implementation of the same rule. + +import { createServer } from "node:net"; +import { chmodSync, unlinkSync } from "node:fs"; +import { parseStrictJson } from "../internal/strict-jsonl.mjs"; + +const MAX_FRAME_BYTES = 1024 * 1024; +export const PROTOCOL_VERSION = 1; + +function writeLine(socket, obj) { + const line = `${JSON.stringify(obj)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) { + // Cannot happen for beta's tiny fixed responses; guarded anyway so a + // future response shape cannot silently violate the ceiling + // on what THIS daemon sends, not just what it accepts. + socket.destroy(); + return; + } + socket.write(line); +} + +// One connection's framing state machine: accumulate bytes, cut at each +// newline, enforce the ceiling on the UNTERMINATED prefix so an attacker +// (or a bug) cannot hold the connection open forever with a +// never-terminated multi-megabyte line. +// +// `onLine` is async (it awaits the caller's stop/status handler). Lines +// extracted from a single 'data' chunk are queued and processed ONE AT A +// TIME, each fully awaited before the next starts -- a socket is a byte +// stream, not a message queue, and more than one complete line can arrive +// in a single chunk (e.g. a client that pipelines two requests without +// waiting for the first response). Calling onLine for each without +// awaiting would let a later line's synchronous prefix (like the "one +// request per connection" rejection and its socket.end()) run and close +// the connection WHILE an earlier line's response is still pending -- +// observed directly: a pipelined second request's rejection reached the +// client before the first request's real answer did, and `socket.end()` +// then discarded that still-pending answer entirely. +function makeLineReader(onLine, onOverflow) { + let buf = Buffer.alloc(0); + let processing = Promise.resolve(); + let destroyed = false; + function overflowOnce() { + if (destroyed) return; + destroyed = true; + onOverflow(); + } + return function onData(chunk) { + buf = Buffer.concat([buf, chunk]); + for (;;) { + const nl = buf.indexOf(0x0a); + if (nl === -1) { + if (buf.length > MAX_FRAME_BYTES) overflowOnce(); + return; + } + const lineBuf = buf.subarray(0, nl); + buf = buf.subarray(nl + 1); + if (lineBuf.length > MAX_FRAME_BYTES) { + overflowOnce(); + return; + } + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(lineBuf); + } catch { + overflowOnce(); + return; + } + processing = processing.then(() => (destroyed ? undefined : onLine(text))); + } + }; +} + +// `handlers.onStop()` / `handlers.onStatus()` are async and each return the +// plain object to send back (e.g. {ok: true} / the status payload) -- this +// file owns none of the actual stop/status behavior, only the socket. +export function createControlServer(socketPath, handlers) { + const server = createServer((socket) => { + let helloSeen = false; + // Set the moment this connection sends its one response and calls + // socket.end() (or is destroyed) -- every queued line after that point + // must be a silent no-op. Writing (even an error) after end() throws + // ERR_STREAM_WRITE_AFTER_END; discovered by a pipelined-second-request + // test where that throw discarded the FIRST (real, already-sent-for) + // response along with the write it broke on. + let closing = false; + function finish(responseObj) { + if (closing) return; + closing = true; + writeLine(socket, responseObj); + socket.end(); + } + + const reader = makeLineReader( + async (line) => { + if (closing) return; + let msg; + try { + msg = parseStrictJson(line); + } catch { + finish({ type: "error", reason: "invalid JSON framing" }); + return; + } + if (!helloSeen) { + if (msg?.type !== "hello" || msg.protocol !== PROTOCOL_VERSION) { + finish({ type: "error", reason: "protocol mismatch" }); + return; + } + if (msg.role !== "control") { + finish({ type: "error", reason: "role must be control" }); + return; + } + helloSeen = true; + writeLine(socket, { type: "hello_ok" }); + return; + } + // Beta connections are one-shot -- exactly one request follows + // `hello`. `finish()` above guards against a SECOND queued line + // reaching this far at all (the `closing` check at the top of this + // function returns before this point once the first request has + // set `closing`), so there is no separate "already handled" branch + // to write here. + try { + if (msg?.type === "stop") { + finish({ type: "stop_ok", ...(await handlers.onStop()) }); + } else if (msg?.type === "status") { + finish({ type: "status", ...(await handlers.onStatus()) }); + } else { + finish({ type: "error", reason: `unknown request type: ${msg?.type}` }); + } + } catch (error) { + finish({ type: "error", reason: error.message }); + } + }, + () => { + closing = true; + socket.destroy(); // frame too large -- cut the connection + }, + ); + socket.on("data", reader); + socket.on("error", () => { + // A client that drops mid-frame is not this server's problem to + // report anywhere -- there is no notice/seat state to reconcile in + // beta's one-shot model. + }); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { + server.removeListener("error", reject); + try { + chmodSync(socketPath, 0o600); + } catch (error) { + server.close(); + reject(error); + return; + } + server.on("error", () => { + // Errors on already-accepted connections are handled per-socket + // above; this catches only listener-level errors after startup, + // which -- once listening -- have nowhere useful to propagate to + // in a bare fire-and-forget server object. Logged by the caller's + // own log.mjs if it chooses to listen for this event separately. + }); + resolve({ + server, + close: () => + new Promise((resolveClose) => { + server.close(() => { + try { + unlinkSync(socketPath); + } catch { + // best-effort + } + resolveClose(); + }); + }), + }); + }); + }); +} diff --git a/scripts/daemon/db.mjs b/scripts/daemon/db.mjs new file mode 100644 index 000000000..1225f4675 --- /dev/null +++ b/scripts/daemon/db.mjs @@ -0,0 +1,48 @@ +// Opens run/install.db with node:sqlite and applies scripts/daemon/schema.sql +// (idempotent -- see that file). One place so every Node component that +// touches install.db agrees on how it is opened and migrated. + +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), "schema.sql"); + +// `readonly: true` opens the DB without applying the schema and without the +// ability to write -- for a reader (status.mjs) that must never create the +// tables a stopped/never-started daemon would otherwise leave missing, and +// must never be the thing that turns "not installed yet" into "installed, +// empty" just by looking. +export function openInstallDb(path, { readonly = false } = {}) { + const db = new DatabaseSync(path, { readOnly: readonly, allowExtension: false }); + db.exec("PRAGMA busy_timeout = 5000;"); + if (!readonly) { + db.exec(readFileSync(SCHEMA_PATH, "utf8")); + } + return db; +} + +// Runs `fn(db)` inside a single BEGIN IMMEDIATE transaction, committing on +// return and rolling back on throw. BEGIN IMMEDIATE (not the default +// deferred BEGIN) takes the write lock up front. A deferred transaction +// that starts with a read and +// upgrades to a write partway through can hit SQLITE_BUSY at the upgrade +// point instead of at the start, which is a worse failure to reason about +// under contention. +export function withImmediateTransaction(db, fn) { + db.exec("BEGIN IMMEDIATE;"); + try { + const result = fn(db); + db.exec("COMMIT;"); + return result; + } catch (error) { + try { + db.exec("ROLLBACK;"); + } catch { + // The connection may already be unusable (e.g. the process is about + // to exit); the original error is what matters to the caller. + } + throw error; + } +} diff --git a/scripts/daemon/executor.mjs b/scripts/daemon/executor.mjs new file mode 100644 index 000000000..6c1810a07 --- /dev/null +++ b/scripts/daemon/executor.mjs @@ -0,0 +1,92 @@ +// The (pid, start-time evidence, boot id) triple that stands in for "this +// process, and not some later process that reused its pid." Shared +// between owner.mjs (which records the triple when it takes ownership) and +// status.mjs (which reads it back to answer "living / confirmed dead / +// cannot tell"). +// +// This small, cross-cutting helper is shared by both files, just as +// install-baseline.mjs is shared by the startup verifier and daemon. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { platform, uptime } from "node:os"; + +// A boot-scoped id: the same value for every process on this machine +// started since the current boot, and a DIFFERENT value after a reboot -- +// the property needed to tell "this executor" from "a later process +// that reused the same pid" across a restart. It is a boot EPOCH TIMESTAMP +// (seconds), not the UUID form some platforms also expose; either serves +// the same comparison purpose. +// +// linux: /proc/stat's `btime` line is the kernel's own authoritative boot +// epoch. darwin: `sysctl -n kern.boottime` reports the same thing in a +// different format. Neither drifts between calls. +// +// Anything else (win32 included): no simple authoritative file to read +// without shelling out to something heavier (wmic/PowerShell) for every +// check. Approximate instead, from Date.now() minus os.uptime(), rounded to +// the nearest second to absorb the two calls' own timing jitter. This can +// disagree by a handful of seconds between two calls in the SAME boot +// (uptime() does not always advance in lockstep with wall-clock sleep/wake +// accounting) -- callers must tolerate small drift on this path and must +// NOT expect exact equality the way linux/darwin's kernel-reported values +// give. +export function bootId() { + const plat = platform(); + if (plat === "linux") { + const stat = readFileSync("/proc/stat", "utf8"); + const line = stat.split("\n").find((l) => l.startsWith("btime ")); + if (line) { + const sec = line.slice("btime ".length).trim(); + if (/^\d+$/.test(sec)) return sec; + } + // /proc/stat without a btime line has not been observed; fall through + // to the approximate path rather than throw, since a boot id that is + // merely approximate is still useful (see caller contract below). + } else if (plat === "darwin") { + try { + const out = execFileSync("sysctl", ["-n", "kern.boottime"], { + encoding: "utf8", + }); + const m = out.match(/sec\s*=\s*(\d+)/); + if (m) return m[1]; + } catch { + // fall through to the approximate path + } + } + return String(Math.round(Date.now() / 1000 - uptime())); +} + +// This process's own executor triple, as of the call. `role` and `pid` are +// the caller's own; `bootId` is captured fresh (see above). +export function currentExecutor() { + return { pid: process.pid, bootId: bootId() }; +} + +// True/false/null for "living" / "confirmed dead" / "cannot tell", given a +// PREVIOUSLY captured executor triple `{pid, bootId}`. +// +// A bare `kill(pid, 0)` succeeding is NOT enough on its own -- pids recycle, +// and a live process at that number could be a stranger that started after +// the recorded one exited. The bootId compare is what this file exists for: +// if the boot has changed since the triple was captured, no pid from that +// boot can possibly still be running, so the answer is a confident `false` +// (confirmed dead) without even checking the pid -- this is the one case +// where a stale boot id is itself the proof, not a reason to say "cannot +// tell". +export function isAlive({ pid, bootId: capturedBootId }) { + if (bootId() !== capturedBootId) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + // EPERM: a process at that pid exists but this user cannot signal it -- + // that is itself proof it is alive (Node only reports it as a + // permission fact, but a permission-denied answer to `kill(pid, 0)` + // never happens for a pid that does not exist). Anything else is a + // genuine "cannot tell" (e.g. a platform where signal 0 is unsupported). + if (error?.code === "EPERM") return true; + return null; + } +} diff --git a/scripts/daemon/lifecycle.mjs b/scripts/daemon/lifecycle.mjs new file mode 100644 index 000000000..8cdd7e1be --- /dev/null +++ b/scripts/daemon/lifecycle.mjs @@ -0,0 +1,187 @@ +// Completion-record verification and update detection. +// +// The manifest itself (run/install-manifest.json, its .prev, and the +// install-op lock at run/install-op.lock.db) is PR 2's own format -- this +// file only reads it. Confirmed shape (2026-09-29): +// { install_id, gen, version, created_at, digest_algo: "sha256", +// files: [{ path, digest }, ...] } +// `path` is relative to installRoot (the SKILL_DIR), `files` sorted by path. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { collectInstallBaseline, installChangedAgainst } from "../internal/install-baseline.mjs"; + +const MANIFEST_NAME = "install-manifest.json"; +const LOCK_NAME = "install-op.lock.db"; + +// True if some other process currently holds run/install-op.lock.db's +// BEGIN EXCLUSIVE (PR 2). Non-blocking: `busy_timeout = 0` makes a +// contended BEGIN EXCLUSIVE fail immediately instead of waiting, so this +// never stalls waiting for the lock it is only trying to observe. +function isInstallLockHeld(installRoot) { + const lockPath = join(installRoot, "run", LOCK_NAME); + if (!existsSync(lockPath)) return false; + const db = new DatabaseSync(lockPath); + try { + db.exec("PRAGMA busy_timeout = 0;"); + try { + db.exec("BEGIN EXCLUSIVE;"); + db.exec("ROLLBACK;"); + return false; + } catch { + return true; + } + } finally { + db.close(); + } +} + +// Reads the completion record and distinguishes three states: 'complete' +// (manifest present -- carries it), 'updating' +// (no manifest, .prev present, lock held), 'crashed' (no manifest, .prev +// present, lock free -- an update that died mid-way), 'missing' (neither +// file -- installed by an install.sh old enough to never have written one). +export function readCompletionState(installRoot) { + const manifestPath = join(installRoot, "run", MANIFEST_NAME); + const prevPath = `${manifestPath}.prev`; + if (existsSync(manifestPath)) { + return { + state: "complete", + manifest: JSON.parse(readFileSync(manifestPath, "utf8")), + manifestText: readFileSync(manifestPath, "utf8"), + }; + } + if (existsSync(prevPath)) { + return { state: isInstallLockHeld(installRoot) ? "updating" : "crashed" }; + } + return { state: "missing" }; +} + +// Enumerates every regular file under /scripts, sorted by +// path relative to installRoot (matching the manifest's own path form and +// sort order). A symlink ANYWHERE under scripts/ is a verification +// failure, not a skip. +function collectScriptFiles(installRoot) { + const files = []; + const root = join(installRoot, "scripts"); + function walk(dir, relPrefix) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + throw new Error(`symlink under scripts/: ${rel}`); + } else if (entry.isDirectory()) { + walk(abs, rel); + } else if (entry.isFile()) { + files.push(rel); + } + } + } + walk(root, "scripts"); + files.sort(); + return files; +} + +function sha256File(installRoot, relPath) { + const data = readFileSync(join(installRoot, relPath)); + return createHash("sha256").update(data).digest("hex"); +} + +// install.db's meta.install_id is the source of truth (install.sh, PR 2); +// the manifest carries only a copy. A mismatch means the manifest belongs +// to a different install than the one this install.db actually is -- +// verified separately from the digest check below, and checked first, +// since a digest match against the wrong install's manifest proves nothing. +export function verifyInstallId(db, manifest) { + const row = db.prepare("SELECT install_id FROM meta").get(); + if (row.install_id !== manifest.install_id) { + return { + ok: false, + reason: `install_id mismatch: install.db has ${row.install_id ?? "(none)"}, manifest has ${manifest.install_id}`, + }; + } + return { ok: true }; +} + +// Full startup verification: every file the manifest lists +// must exist with a matching digest, no extra files, no missing files, no +// symlinks. Returns {ok: true} or {ok: false, reason}. Never throws for an +// ordinary mismatch -- only collectScriptFiles's symlink case surfaces as +// a caught, reported mismatch rather than an uncaught exception. +export function verifyDigest(installRoot, manifest) { + if (manifest.digest_algo !== "sha256") { + return { ok: false, reason: `unsupported digest_algo: ${manifest.digest_algo}` }; + } + let onDisk; + try { + onDisk = collectScriptFiles(installRoot); + } catch (error) { + return { ok: false, reason: error.message }; + } + const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const onDiskSet = new Set(onDisk); + const expectedPaths = expected.map((f) => f.path); + const expectedSet = new Set(expectedPaths); + + const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); + if (missing.length > 0) return { ok: false, reason: `missing file(s): ${missing.join(", ")}` }; + + const extra = onDisk.filter((p) => !expectedSet.has(p)); + if (extra.length > 0) return { ok: false, reason: `unexpected file(s): ${extra.join(", ")}` }; + + for (const { path, digest } of expected) { + const actual = sha256File(installRoot, path); + if (actual !== digest) return { ok: false, reason: `digest mismatch: ${path}` }; + } + return { ok: true }; +} + +// Returns true iff the completion record is no longer 'complete' since +// `lastKnownManifestText` was captured (an install/uninstall started). +function completionRecordChanged(installRoot, lastKnownManifestText) { + const now = readCompletionState(installRoot); + return now.state !== "complete" || now.manifestText !== lastKnownManifestText; +} + +// Captured once, right after a successful startup verification, and handed +// to watchForDrift() every cycle thereafter. +// `scriptsBaseline` is null when collectInstallBaseline itself could not +// observe the tree (see that function's own doc) -- watchForDrift treats a +// null baseline the same way collectInstallBaseline's own caller always +// has: the digest half of the check is simply off, not a false positive. +export async function captureWatchState(installRoot, manifest, manifestText) { + return { + manifestText, + version: manifest.version, + scriptsBaseline: await collectInstallBaseline(join(installRoot, "scripts")), + }; +} + +// The running daemon's per-cycle re-check: stand +// aside if EITHER (a) the completion record stopped being 'complete' (an +// install/uninstall started), OR (b) the VERSION string or any file under +// scripts/ changed IN PLACE without the manifest moving at all -- the #963 +// case, detected via install-baseline.mjs against the baseline +// captureWatchState took at startup. Returns {changed: false} or +// {changed: true, reason}. +export async function watchForDrift(installRoot, watchState) { + if (completionRecordChanged(installRoot, watchState.manifestText)) { + return { changed: true, reason: "completion record is no longer complete" }; + } + let currentVersion; + try { + currentVersion = readFileSync(join(installRoot, "VERSION"), "utf8").trim(); + } catch { + return { changed: true, reason: "VERSION file could not be read" }; + } + if (currentVersion !== watchState.version) { + return { changed: true, reason: `VERSION changed: ${watchState.version} -> ${currentVersion}` }; + } + if (watchState.scriptsBaseline) { + const changedPath = await installChangedAgainst(join(installRoot, "scripts"), watchState.scriptsBaseline); + if (changedPath) return { changed: true, reason: `scripts/ changed in place: ${changedPath}` }; + } + return { changed: false }; +} diff --git a/scripts/daemon/log.mjs b/scripts/daemon/log.mjs new file mode 100644 index 000000000..32843e321 --- /dev/null +++ b/scripts/daemon/log.mjs @@ -0,0 +1,43 @@ +// run/agmsgd.log, rotated at 1 MiB, one prior generation kept +// (run/agmsgd.log.1). Records startup/stop/step-aside reasons and +// channel counts/failures ONLY -- never message bodies. + +import { appendFileSync, existsSync, renameSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const MAX_BYTES = 1024 * 1024; + +export function logPath(installRoot) { + return join(installRoot, "run", "agmsgd.log"); +} + +// Appends one line (a trailing newline is added), rotating first if the +// file has already reached MAX_BYTES. Best-effort: a logging failure must +// never be why the daemon itself fails an operation, so this never throws +// -- it falls back to stderr, once, rather than silently dropping the line. +export function logLine(installRoot, text) { + const path = logPath(installRoot); + try { + let size = 0; + try { + size = statSync(path).size; + } catch { + size = 0; // file does not exist yet: nothing to rotate + } + if (size >= MAX_BYTES) { + try { + renameSync(path, `${path}.1`); + } catch { + // best-effort: if the rename fails, keep appending to the same + // file rather than lose the line + } + } + appendFileSync(path, `${new Date().toISOString()} ${text}\n`); + } catch (error) { + process.stderr.write(`agmsgd: could not write to ${path}: ${error.message}\n`); + } +} + +export function logExists(installRoot) { + return existsSync(logPath(installRoot)); +} diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs new file mode 100644 index 000000000..0cf626002 --- /dev/null +++ b/scripts/daemon/main.mjs @@ -0,0 +1,144 @@ +// Starts, runs, and stops one agmsgd process. Ties +// owner.mjs / control.mjs / lifecycle.mjs / log.mjs / status.mjs together; +// none of the actual CAS/socket/verification logic lives here. +// +// Split into separately-callable pieces on purpose: shutdown is handled +// in one place and in a known order. +// startup() / pollOnce() / gracefulStop() are each independently testable +// without wiring real signal handlers or a real interval timer, which +// main() (the actual CLI entrypoint) only assembles. +// +// channelHooks (an array of {stop()}) is beta's plug point for the Codex +// queue channel -- a separate PR/component this file does not own. Empty +// in this PR; gracefulStop() already calls stop() on every registered +// hook, in the correct place in the shutdown order, so that PR only needs +// to register its hook, not restructure this file. + +import { takeOwnership, markReady, markStopping, revertToNone, stopNormally } from "./owner.mjs"; +import { createControlServer } from "./control.mjs"; +import { captureWatchState, watchForDrift } from "./lifecycle.mjs"; +import { logLine } from "./log.mjs"; +import { classify } from "./status.mjs"; + +export const POLL_INTERVAL_MS = 5000; + +// Takes ownership and binds the control socket. Returns +// {ok: true, gen, controlHandle} or {ok: false, reason} -- a refusal from +// takeOwnership OR a bind failure, both reported the same shape so the +// caller (main()) can log+exit either without a separate branch. A bind +// failure additionally reverts daemon_owner back to 'none' (the +// step-2 failure path) before returning. +export async function startup(db, { installRoot, expectedDesired, expectedOpGen, version, handlers }) { + const owned = takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }); + if (!owned.ok) return owned; + + let controlHandle; + try { + controlHandle = await createControlServer(owned.socket, handlers); + } catch (error) { + revertToNone(db, owned.gen, "bind_failed"); + logLine(installRoot, `startup: bind failed for gen ${owned.gen}: ${error.message}`); + return { ok: false, reason: `bind failed: ${error.message}` }; + } + markReady(db, owned.gen); + logLine(installRoot, `startup: gen ${owned.gen} ready at ${owned.socket}`); + return { ok: true, gen: owned.gen, controlHandle }; +} + +// One poll cycle. Returns +// {action: "continue"} | {action: "step_aside", reason} | +// {action: "stop", reason} -- callers act on the verdict; this function +// itself performs no shutdown steps. +export async function pollOnce(db, installRoot, { gen, expectedOpGen, watchState }) { + const drift = await watchForDrift(installRoot, watchState); + if (drift.changed) { + return { action: "step_aside", reason: "stepped_aside_for_update" }; + } + const intent = db.prepare("SELECT op_gen FROM daemon_intent").get(); + if (intent.op_gen !== expectedOpGen) { + // An explicit operation happened since + // this process started; step aside regardless of what desired says + // now, so an old process from an off->on->off sequence never keeps + // running just because desired flipped back to on again later. + return { action: "stop", reason: "normal" }; + } + return { action: "continue" }; +} + +// The one place shutdown happens: mark stopping -> stop +// every registered channel -> close the control socket -> record the +// normal stop. `reason` becomes daemon_owner.last_end_reason (status.mjs +// pattern-matches "stepped_aside_for_update" specifically; anything else +// falls through to its generic "stopped" text). +export async function gracefulStop(db, installRoot, gen, controlHandle, channelHooks, reason) { + markStopping(db, gen); + for (const hook of channelHooks) { + try { + await hook.stop(); + } catch (error) { + logLine(installRoot, `shutdown: a channel's stop() threw: ${error.message}`); + } + } + await controlHandle.close(); + stopNormally(db, gen, reason); + logLine(installRoot, `shutdown: gen ${gen} stopped (${reason})`); +} + +// The actual CLI entrypoint: wires startup(), a real interval timer for +// pollOnce(), real SIGTERM handling, and control.mjs's stop/status +// handlers, onto db/manifest values the caller (agmsgd's bootstrap) has +// already verified. Exit codes: 0 for a declined +// start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 +// for a bind failure or any other unexpected error. +export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version }) { + const channelHooks = []; // beta's Codex-queue channel plugs in here, separately. + let controlHandle; + let gen; + let stopping = false; + + async function doStop(reason) { + if (stopping) return; + stopping = true; + clearInterval(timer); + await gracefulStop(db, installRoot, gen, controlHandle, channelHooks, reason); + process.exit(reason === "stepped_aside_for_update" ? 75 : 0); + } + + const started = await startup(db, { + installRoot, + expectedDesired, + expectedOpGen, + version, + handlers: { + onStop: async () => { + // Fires from inside a control-socket request; the response itself + // must still go out before the process exits, so this only + // schedules the stop rather than awaiting it inline. + setImmediate(() => doStop("normal")); + return { gen }; + }, + onStatus: async () => classify({ owner: { gen, state: "ready", version }, intent: {}, alive: true, reachable: true }), + }, + }); + if (!started.ok) { + logLine(installRoot, `startup declined: ${started.reason}`); + db.prepare("INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (?, ?, ?)").run( + new Date().toISOString(), + started.reason, + process.pid, + ); + process.exit(started.reason.startsWith("bind failed") ? 1 : 0); + return; + } + gen = started.gen; + controlHandle = started.controlHandle; + + process.on("SIGTERM", () => doStop("normal")); + + const watchState = await captureWatchState(installRoot, manifest, manifestText); + const timer = setInterval(async () => { + if (stopping) return; + const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); + if (verdict.action !== "continue") await doStop(verdict.reason); + }, POLL_INTERVAL_MS); +} diff --git a/scripts/daemon/owner.mjs b/scripts/daemon/owner.mjs new file mode 100644 index 000000000..48d4e0235 --- /dev/null +++ b/scripts/daemon/owner.mjs @@ -0,0 +1,130 @@ +// daemon_owner compare-and-swap lifecycle. +// +// daemon_intent itself is written only by scripts/daemon.sh (the explicit +// CLI operations, directly via sqlite3 -- bash cannot call into this file), +// never from here. This file only READS daemon_intent, to condition taking +// ownership on it still matching what the launcher observed. +// +// The install-op lock (run/install-op.lock.db, PR 2) is a SEPARATE +// mechanism the entrypoint holds only while loading code; by the time +// takeOwnership() runs, that lock has already been +// released. daemon_owner's own gen-conditioned CAS is self-contained and +// needs no external lock. + +import { join } from "node:path"; +import { currentExecutor, isAlive } from "./executor.mjs"; +import { withImmediateTransaction } from "./db.mjs"; + +export function readOwner(db) { + return db.prepare("SELECT * FROM daemon_owner").get(); +} + +export function readIntent(db) { + return db.prepare("SELECT * FROM daemon_intent").get(); +} + +function socketPath(installRoot, gen) { + return join(installRoot, "run", `agmsgd.${gen}.sock`); +} + +// Read daemon_owner, and if the current owner is +// state='none' or confirmed dead, take the next gen and move to +// 'starting' -- ALSO requiring, in the same read, that daemon_intent still +// matches what the caller (the launcher) already observed. Returns +// `{ok: true, gen, socket}` for the caller to go bind, or +// `{ok: false, reason}` -- the caller (agmsgd's entrypoint) is responsible +// for writing daemon_start_attempts and exiting 0 on a refusal; this +// function only decides, it does not log. +// +// `version` is this process's own build/version string, recorded in +// daemon_owner.version once ownership is taken. +export function takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }) { + return withImmediateTransaction(db, () => { + const intent = readIntent(db); + if (intent.desired !== expectedDesired || intent.op_gen !== expectedOpGen) { + return { ok: false, reason: "intent changed since the launcher read it" }; + } + + const owner = readOwner(db); + let refuse = null; + if (owner.state !== "none") { + if (owner.executor_pid == null) { + refuse = "owner state is not none but has no recorded executor"; + } else { + const alive = isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); + if (alive === true) refuse = "current owner is alive"; + else if (alive === null) refuse = "current owner's liveness cannot be determined"; + // alive === false (confirmed dead): fall through, this call takes over. + } + } + if (refuse) return { ok: false, reason: refuse }; + + const gen = owner.gen + 1; + const executor = currentExecutor(); + const socket = socketPath(installRoot, gen); + const startedAt = new Date().toISOString(); + db.prepare( + `UPDATE daemon_owner SET gen = ?, state = 'starting', + executor_pid = ?, executor_started_at = ?, executor_boot_id = ?, + socket = ?, version = ?, started_at = ?, + last_end_reason = NULL, last_end_at = NULL, last_end_gen = NULL + WHERE gen = ?`, + ).run(gen, executor.pid, startedAt, executor.bootId, socket, version, startedAt, owner.gen); + return { ok: true, gen, socket }; + }); +} + +// Step 3: bind succeeded -- move 'starting' -> 'ready', conditioned on gen +// A no-op-safe false return (rather than throwing) if some +// other actor already moved this gen off 'starting' -- that should not +// happen in beta (single-threaded event loop, nothing else CASes this gen), +// but the condition costs nothing and documents the invariant. +export function markReady(db, gen) { + const result = db + .prepare("UPDATE daemon_owner SET state = 'ready' WHERE gen = ? AND state = 'starting'") + .run(gen); + return result.changes > 0; +} + +// Bind failed: move back to 'none', +// conditioned on gen, and record why via last_end (this IS this gen's own +// CAS back to none, so last_end is the right place to record completion). +export function revertToNone(db, gen, reason) { + const at = new Date().toISOString(); + const result = db + .prepare( + `UPDATE daemon_owner SET state = 'none', socket = NULL, + last_end_reason = ?, last_end_at = ?, last_end_gen = ? + WHERE gen = ? AND state = 'starting'`, + ) + .run(reason, at, gen, gen); + return result.changes > 0; +} + +// Normal stop: this gen's own +// CAS from any running state to 'none', with last_end. Callers (main.mjs) +// must have already stopped accepting control requests, closed the +// listening socket, and confirmed their own child process group is empty +// BEFORE calling this -- this function only performs the final state +// transition, it does not do any of that stopping itself. +export function stopNormally(db, gen, reason = "normal") { + const at = new Date().toISOString(); + const result = db + .prepare( + `UPDATE daemon_owner SET state = 'none', socket = NULL, + last_end_reason = ?, last_end_at = ?, last_end_gen = ? + WHERE gen = ? AND state != 'none'`, + ) + .run(reason, at, gen, gen); + return result.changes > 0; +} + +// Called at the top of main.mjs's transition into 'stopping', before doing +// the actual shutdown work -- lets a reader (status.mjs) see the daemon is +// mid-shutdown rather than still 'ready'. +export function markStopping(db, gen) { + const result = db + .prepare("UPDATE daemon_owner SET state = 'stopping' WHERE gen = ? AND state = 'ready'") + .run(gen); + return result.changes > 0; +} diff --git a/scripts/daemon/schema.sql b/scripts/daemon/schema.sql new file mode 100644 index 000000000..d93c837c4 --- /dev/null +++ b/scripts/daemon/schema.sql @@ -0,0 +1,79 @@ +-- agmsgd beta schema for run/install.db. +-- +-- Applied identically from both bash (scripts/daemon.sh, via `sqlite3 < +-- this file`) and Node (owner.mjs, via node:sqlite exec()) -- one file so the +-- two sides can never drift into different table shapes for the same DB. +-- Every statement is idempotent: safe to re-run on every invocation, by +-- either side, in any order. +-- +-- These are the only four of the six beta tables this component owns. +-- beta_codex_queue and beta_codex_seat belong to the separate Codex channel +-- component and are created there, not here. +-- +-- meta: one row. install_id is the SOURCE OF TRUTH for this install's id +-- (written by install.sh, PR 2); run/install-manifest.json carries only a +-- COPY of it, and the entrypoint's startup verification confirms the two +-- still agree. schema_version is written by +-- install/agmsgd; node_path and node_version are written ONLY by the +-- enable/disable CLI (never by agmsgd itself). +CREATE TABLE IF NOT EXISTS meta ( + schema_version INTEGER NOT NULL, + install_id TEXT, + node_path TEXT, + node_version TEXT +); +INSERT INTO meta (schema_version, install_id, node_path, node_version) + SELECT 1, NULL, NULL, NULL WHERE NOT EXISTS (SELECT 1 FROM meta); + +-- daemon_owner: always exactly one row (never deleted, never a second row). +-- gen increases monotonically and is never reused. executor_* is the +-- (pid, start-time evidence, boot id) triple used to tell a live executor +-- from a reused pid. last_end is written ONLY by the owning gen's own CAS +-- when it transitions itself to state='none' (bind failure, stepped aside +-- for an update, or a normal stop) -- never by a start attempt that was +-- refused, and never by any reader (status/doctor). +CREATE TABLE IF NOT EXISTS daemon_owner ( + gen INTEGER NOT NULL, + state TEXT NOT NULL, + executor_pid INTEGER, + executor_started_at TEXT, + executor_boot_id TEXT, + socket TEXT, + version TEXT, + started_at TEXT, + last_end_reason TEXT, + last_end_at TEXT, + last_end_gen INTEGER, + node_path TEXT, + node_version TEXT +); +INSERT INTO daemon_owner (gen, state) + SELECT 0, 'none' WHERE NOT EXISTS (SELECT 1 FROM daemon_owner); + +-- daemon_intent: always exactly one row. desired starts NULL ("intent +-- unconfirmed", a row that was never written) and is +-- changed ONLY by an explicit operation (start/stop/enable/disable/ +-- uninstall) to the literal 'on' or 'off' -- never by a SIGTERM or an +-- OS/resident-manager restart, and never defaulted to 'off' at schema +-- creation: that would make a never-touched install indistinguishable +-- from one somebody deliberately disabled (status.mjs's classify() tells +-- them apart, but only if this row does not pre-decide it). op_gen +-- increases by 1 on every explicit operation (inside the operation lock +-- for start/enable/disable/uninstall; without the lock for stop). +CREATE TABLE IF NOT EXISTS daemon_intent ( + desired TEXT, + set_by TEXT, + set_at TEXT, + op_gen INTEGER NOT NULL +); +INSERT INTO daemon_intent (desired, set_by, set_at, op_gen) + SELECT NULL, NULL, NULL, 0 WHERE NOT EXISTS (SELECT 1 FROM daemon_intent); + +-- daemon_start_attempts: append-only history for display only (never a +-- safety-critical read). Written by the launcher and by a start attempt that +-- was refused ownership -- never touches daemon_owner. +CREATE TABLE IF NOT EXISTS daemon_start_attempts ( + at TEXT NOT NULL, + reason TEXT NOT NULL, + executor_pid INTEGER +); diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs new file mode 100644 index 000000000..a11079ffa --- /dev/null +++ b/scripts/daemon/status.mjs @@ -0,0 +1,194 @@ +// The daemon status decision table is scoped to beta's install.db +// (meta / daemon_owner / daemon_intent / daemon_start_attempts only). +// seat_route, sync_status, stuck, and ctrl_state belong to a later release +// and are reported as unsupported wherever a caller lists all categories, +// not as "0 rows". +// +// Callable two ways: +// - as a library: classify({owner, intent, alive}) -- the pure decision, +// used by control.mjs's onStatus handler (running INSIDE the daemon +// that already knows its own state) and by tests. +// - as a CLI (`node status.mjs `): reads install.db +// read-only, checks the recorded executor's liveness itself (works +// whether or not the daemon is actually running), and additionally +// tries the control socket if the record says 'ready' -- a record +// that says ready but a socket that refuses the connection is the +// "ready, but does not connect" case, which only an +// external prober (not the daemon answering about itself) can ever +// observe. Prints one JSON line to stdout; exit code follows the +// table's own exit column. + +import { existsSync, realpathSync } from "node:fs"; +import { createConnection } from "node:net"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isAlive } from "./executor.mjs"; +import { openInstallDb } from "./db.mjs"; +import { PROTOCOL_VERSION } from "./control.mjs"; + +const STARTING_STOPPING_GRACE_MS = 30_000; + +// The pure decision. `now` is injectable for tests. +export function classify({ owner, intent, alive, reachable }, now = Date.now()) { + const desired = intent?.desired ?? null; + + if (owner.state === "ready") { + if (reachable) { + return { + text: `agmsgd is running (gen ${owner.gen}, version ${owner.version ?? "unknown"})`, + exitCode: 0, + gen: owner.gen, + }; + } + return { + text: "agmsgd's record says ready, but it does not answer on its control socket", + exitCode: 1, + gen: owner.gen, + }; + } + + if (owner.state === "starting" || owner.state === "stopping") { + const startedAt = owner.started_at ? Date.parse(owner.started_at) : NaN; + const withinGrace = Number.isFinite(startedAt) && now - startedAt < STARTING_STOPPING_GRACE_MS; + const ok = alive === true && withinGrace; + return { + text: `agmsgd is ${owner.state} (executor ${alive === true ? "alive" : alive === false ? "confirmed dead" : "cannot be determined"}, started ${owner.started_at ?? "unknown"})`, + exitCode: ok ? 0 : 1, + gen: owner.gen, + }; + } + + // owner.state === "none" from here on. + if (desired === "off") { + return { + text: `agmsgd is not in use (turned off deliberately${intent.set_at ? ` at ${intent.set_at}` : ""})`, + exitCode: 0, + gen: owner.gen, + }; + } + if (owner.gen === 0) { + return { text: "agmsgd has never been started", exitCode: 0, gen: 0 }; + } + if (owner.last_end_reason === "bind_failed") { + return { + text: `agmsgd failed to start (${owner.last_end_reason}, ${owner.last_end_at ?? "unknown time"}, gen ${owner.last_end_gen})`, + exitCode: 1, + gen: owner.gen, + }; + } + if (owner.last_end_reason === "stepped_aside_for_update") { + return { + text: `agmsgd stopped for an update (${owner.last_end_at ?? "unknown time"}). The new version has not started yet.`, + exitCode: 1, + gen: owner.gen, + }; + } + if (desired === "on") { + return { text: "agmsgd is stopped (intent is on)", exitCode: 1, gen: owner.gen }; + } + return { + text: "agmsgd is stopped. No intent to use it is recorded.", + exitCode: 1, + gen: owner.gen, + }; +} + +// Reads install.db read-only and checks the recorded executor's liveness -- +// works whether or not agmsgd is actually running. +export function readOwnerAndIntentReadOnly(installRoot) { + const db = openInstallDb(join(installRoot, "run", "install.db"), { readonly: true }); + try { + const owner = db.prepare("SELECT * FROM daemon_owner").get(); + const intent = db.prepare("SELECT * FROM daemon_intent").get(); + const alive = + owner.state === "none" || owner.executor_pid == null + ? null + : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); + return { owner, intent, alive }; + } finally { + db.close(); + } +} + +// Attempts one hello+status round trip against the recorded socket, with a +// short deadline -- a prober must never hang the CLI command it backs. +function probeSocket(socketPath, timeoutMs = 2000) { + return new Promise((resolve) => { + const socket = createConnection(socketPath); + let buf = ""; + const done = (result) => { + socket.destroy(); + resolve(result); + }; + const timer = setTimeout(() => done(false), timeoutMs); + socket.on("connect", () => { + socket.write(`${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`); + socket.write(`${JSON.stringify({ type: "status" })}\n`); + }); + socket.on("data", (chunk) => { + buf += chunk.toString("utf8"); + if (buf.includes('"type":"status"')) { + clearTimeout(timer); + done(true); + } + }); + socket.on("error", () => { + clearTimeout(timer); + done(false); + }); + }); +} + +async function main() { + const installRoot = process.argv[2]; + if (!installRoot) { + process.stderr.write("usage: status.mjs \n"); + process.exit(2); + } + let owner, intent, alive; + try { + ({ owner, intent, alive } = readOwnerAndIntentReadOnly(installRoot)); + } catch (error) { + // An input that can be detected as an error is reported at + // that entry point, with a nonzero exit -- not a raw stack trace, and + // not folded into "never started" (which is itself a legitimate 0). + process.stderr.write(`agmsgd status: could not read install.db: ${error.message}\n`); + process.exit(1); + } + const reachable = owner.state === "ready" && owner.socket ? await probeSocket(owner.socket) : false; + const result = classify({ owner, intent, alive, reachable }); + + // The node:sqlite experimental-feature warning is surfaced here, always + // -- not hidden, not treated as a failure. + // + // `process.exitCode = ...` and returning, NOT `process.exit(...)`: when + // stdout is a pipe rather than a TTY (exactly what capturing this + // command's output does -- a test runner, `agmsg daemon status | + // ...`), the write above is buffered, and `process.exit()` tears the + // process down before that buffer flushes -- observed directly: this + // printed nothing and still exited 0 under bats, where stdout is + // captured through a pipe, while running the identical command directly + // in an interactive terminal (a TTY, where the same write is + // unbuffered) looked completely fine. Setting exitCode and letting the + // event loop drain naturally waits for the flush first. + process.stdout.write( + `${JSON.stringify({ ...result, node_sqlite_experimental: true, node_version: process.version })}\n`, + ); + process.exitCode = result.exitCode; +} + +// `fileURLToPath(import.meta.url)` is realpath'd by Node's own module +// loader (symlinks resolved); `process.argv[1]` is whatever the caller +// passed literally and is NOT realpath'd by Node. On any system where the +// temp/working directory itself sits behind a symlink -- macOS's +// /var -> /private/var is the common case -- a bare `===` between the two +// never matches, so this "am I the CLI entry" guard silently fails and +// main() never runs: the process still exits 0 (nothing here throws), but +// prints nothing beyond node:sqlite's own top-level-import warning. +// Observed exactly this way (bats invokes this file through a path under +// /var/folders while import.meta.url resolves through /private/var). +// realpathSync both sides so the comparison is meaningful regardless of +// which one the caller happened to pass. +if (existsSync(process.argv[1] ?? "") && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/internal/install-baseline.mjs b/scripts/internal/install-baseline.mjs new file mode 100644 index 000000000..ae7012ec8 --- /dev/null +++ b/scripts/internal/install-baseline.mjs @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; + +// The installation can be updated while an engine runs (#963). watch.sh +// already detects that and stands down on its own; the engine used to find +// out only by executing a half-written driver script -- and only when its +// timing was unlucky enough to hit the write window (observed live: a +// mid-rewrite storage-sync-driver.sh failed to parse, and the fatal named a +// syntax error instead of the update). +// +// What is proof here went through review twice, and both cuts were the same +// disease: an observable standing in for the fact. "mtime newer than the +// engine's start clock" stands down every engine under a pre-existing +// future mtime (clock skew, an archive with preserved timestamps). +// "mtime changed against a baseline" stands down on a touch, a +// metadata-only correction, a same-content re-copy -- the code in memory +// and on disk still identical, and a stood-down sync engine does not come +// back by itself. The fact that matters is CONTENT: the engine must stand +// down exactly when the bytes on disk are no longer the bytes it started +// from. So the baseline holds a content digest per file; a path or mtime +// difference merely nominates a file for re-reading, and only a digest +// that actually differs -- or a path that did not exist at start, a new +// install artifact -- is proof. A benign mtime change is remembered so the +// file is not re-read every cycle; content is the identity, the mtime is +// only a cheap change hint. +// +// The failure direction is deliberate, in both phases: an OBSERVATION +// failure is not evidence. +// - Baseline phase: one unreadable directory, one failed stat, one +// unreadable file and the whole detector is disabled (null), not +// partially armed. A partial baseline would recreate the false +// positive: a pre-existing file unreadable at start and readable later +// would look newly added. Disabled means exactly today's behavior. +// - Check phase: an entry that cannot be listed, stat'ed, or read is +// skipped and proves nothing. A rewrite that lands different bytes +// under the exact baseline mtime defeats the hint and is never +// re-read -- a miss, and a miss degrades to today's behavior, which +// is the safe side. A file DELETED by an update is deliberately not +// proof on its own; a real update always rewrites something. +// +// Extracted from remote-sync.mjs: this file is +// the one place the #963 detector lives now. remote-sync.mjs re-exports +// these two names unchanged, so `runLoop`'s own behavior there is +// untouched, and agmsgd's lifecycle.mjs imports the same two functions +// directly for its own per-cycle drift check. +export async function collectInstallBaseline(rootDir, dependencies = {}) { + const readdirCall = dependencies.readdirCall ?? readdir; + const statCall = dependencies.statCall ?? stat; + const readFileCall = dependencies.readFileCall ?? readFile; + const baseline = new Map(); + const pending = [rootDir]; + while (pending.length > 0) { + const dir = pending.pop(); + let entries; + try { entries = await readdirCall(dir, { withFileTypes: true }); } + catch { return null; } // incomplete observation: the detector stays OFF + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { pending.push(path); continue; } + if (!entry.isFile()) continue; // links and specials are not install artifacts + let stats, bytes; + try { + stats = await statCall(path); + bytes = await readFileCall(path); + } catch { return null; } // incomplete observation: the detector stays OFF + baseline.set(path, { + mtimeMs: stats.mtimeMs, + digest: createHash("sha256").update(bytes).digest("hex"), + }); + } + } + return baseline; +} + +export async function installChangedAgainst(rootDir, baseline, dependencies = {}) { + const readdirCall = dependencies.readdirCall ?? readdir; + const statCall = dependencies.statCall ?? stat; + const readFileCall = dependencies.readFileCall ?? readFile; + const pending = [rootDir]; + while (pending.length > 0) { + const dir = pending.pop(); + let entries; + try { entries = await readdirCall(dir, { withFileTypes: true }); } + catch { continue; } // unreadable now: no evidence either way + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { pending.push(path); continue; } + if (!entry.isFile()) continue; + let stats; + try { stats = await statCall(path); } + catch { continue; } // vanished or unreadable: no evidence + const known = baseline.get(path); + if (known === undefined) return path; // did not exist at start: a new install artifact + if (stats.mtimeMs === known.mtimeMs) continue; // no hint of change + let digest; + try { digest = createHash("sha256").update(await readFileCall(path)).digest("hex"); } + catch { continue; } // could not re-read: no evidence + if (digest !== known.digest) return path; // the bytes actually changed + known.mtimeMs = stats.mtimeMs; // benign touch: remember it, content is the identity + } + } + return null; +} diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 24b04d6cd..eaf6d2e36 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -3427,100 +3427,16 @@ export async function cycle(config, { pushLimit, pullLimit }, dependencies = {}) // cadence: after any retryable failure the loop always backs off (exponential, // capped), so a machine that can't reach the server never hot-loops even while // catch-up would otherwise skip the wait. -// The installation can be updated while an engine runs (#963). watch.sh -// already detects that and stands down on its own; the engine used to find -// out only by executing a half-written driver script -- and only when its -// timing was unlucky enough to hit the write window (observed live: a -// mid-rewrite storage-sync-driver.sh failed to parse, and the fatal named a -// syntax error instead of the update). +// The #963 install-changed detector now lives in install-baseline.mjs +// re-exported here, unchanged, so this +// file's own runLoop below keeps working exactly as it did. // -// What is proof here went through review twice, and both cuts were the same -// disease: an observable standing in for the fact. "mtime newer than the -// engine's start clock" stands down every engine under a pre-existing -// future mtime (clock skew, an archive with preserved timestamps). -// "mtime changed against a baseline" stands down on a touch, a -// metadata-only correction, a same-content re-copy -- the code in memory -// and on disk still identical, and a stood-down sync engine does not come -// back by itself. The fact that matters is CONTENT: the engine must stand -// down exactly when the bytes on disk are no longer the bytes it started -// from. So the baseline holds a content digest per file; a path or mtime -// difference merely nominates a file for re-reading, and only a digest -// that actually differs -- or a path that did not exist at start, a new -// install artifact -- is proof. A benign mtime change is remembered so the -// file is not re-read every cycle; content is the identity, the mtime is -// only a cheap change hint. -// -// The failure direction is deliberate, in both phases: an OBSERVATION -// failure is not evidence. -// - Baseline phase: one unreadable directory, one failed stat, one -// unreadable file and the whole detector is disabled (null), not -// partially armed. A partial baseline would recreate the false -// positive: a pre-existing file unreadable at start and readable later -// would look newly added. Disabled means exactly today's behavior. -// - Check phase: an entry that cannot be listed, stat'ed, or read is -// skipped and proves nothing. A rewrite that lands different bytes -// under the exact baseline mtime defeats the hint and is never -// re-read -- a miss, and a miss degrades to today's behavior, which -// is the safe side. A file DELETED by an update is deliberately not -// proof on its own; a real update always rewrites something. -export async function collectInstallBaseline(rootDir, dependencies = {}) { - const readdirCall = dependencies.readdirCall ?? readdir; - const statCall = dependencies.statCall ?? stat; - const readFileCall = dependencies.readFileCall ?? readFile; - const baseline = new Map(); - const pending = [rootDir]; - while (pending.length > 0) { - const dir = pending.pop(); - let entries; - try { entries = await readdirCall(dir, { withFileTypes: true }); } - catch { return null; } // incomplete observation: the detector stays OFF - for (const entry of entries) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { pending.push(path); continue; } - if (!entry.isFile()) continue; // links and specials are not install artifacts - let stats, bytes; - try { - stats = await statCall(path); - bytes = await readFileCall(path); - } catch { return null; } // incomplete observation: the detector stays OFF - baseline.set(path, { - mtimeMs: stats.mtimeMs, - digest: createHash("sha256").update(bytes).digest("hex"), - }); - } - } - return baseline; -} - -export async function installChangedAgainst(rootDir, baseline, dependencies = {}) { - const readdirCall = dependencies.readdirCall ?? readdir; - const statCall = dependencies.statCall ?? stat; - const readFileCall = dependencies.readFileCall ?? readFile; - const pending = [rootDir]; - while (pending.length > 0) { - const dir = pending.pop(); - let entries; - try { entries = await readdirCall(dir, { withFileTypes: true }); } - catch { continue; } // unreadable now: no evidence either way - for (const entry of entries) { - const path = join(dir, entry.name); - if (entry.isDirectory()) { pending.push(path); continue; } - if (!entry.isFile()) continue; - let stats; - try { stats = await statCall(path); } - catch { continue; } // vanished or unreadable: no evidence - const known = baseline.get(path); - if (known === undefined) return path; // did not exist at start: a new install artifact - if (stats.mtimeMs === known.mtimeMs) continue; // no hint of change - let digest; - try { digest = createHash("sha256").update(await readFileCall(path)).digest("hex"); } - catch { continue; } // could not re-read: no evidence - if (digest !== known.digest) return path; // the bytes actually changed - known.mtimeMs = stats.mtimeMs; // benign touch: remember it, content is the identity - } - } - return null; -} +// `export { X } from "mod"` alone does NOT bind X as a local name in THIS +// module (verified: it re-exports for importers of remote-sync.mjs but +// leaves a bare reference to X here as `undefined`) -- runLoop below calls +// these by their bare names, so the import is needed too. +import { collectInstallBaseline, installChangedAgainst } from "./install-baseline.mjs"; +export { collectInstallBaseline, installChangedAgainst }; export async function runLoop(config, options, dependencies = {}) { const cycleCall = dependencies.cycleCall ?? cycle; diff --git a/tests/agmsgd_control.test.mjs b/tests/agmsgd_control.test.mjs new file mode 100644 index 000000000..10c7db20c --- /dev/null +++ b/tests/agmsgd_control.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createControlServer, PROTOCOL_VERSION } from "../scripts/daemon/control.mjs"; + +function socketDir() { + return mkdtempSync(join(tmpdir(), "agmsgd-control-test-")); +} + +// Sends `lines` (already-terminated strings, or a raw prefix with no +// trailing newline for the overflow case) and collects every line the +// server sends back before the connection closes. +function talk(socketPath, rawBytesOrLines) { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + const received = []; + let buf = ""; + socket.on("connect", () => { + const payload = Array.isArray(rawBytesOrLines) ? rawBytesOrLines.join("") : rawBytesOrLines; + socket.write(payload); + }); + socket.on("data", (chunk) => { + buf += chunk.toString("utf8"); + }); + socket.on("close", () => { + for (const line of buf.split("\n")) { + if (line.length > 0) received.push(JSON.parse(line)); + } + resolve(received); + }); + socket.on("error", reject); + }); +} + +test("control server: hello -> status is a clean one-shot round trip", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + const handle = await createControlServer(socketPath, { + onStop: async () => ({ gen: 1 }), + onStatus: async () => ({ state: "ready", gen: 1 }), + }); + const lines = [ + `${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`, + `${JSON.stringify({ type: "status" })}\n`, + ]; + const received = await talk(socketPath, lines); + assert.deepEqual(received, [ + { type: "hello_ok" }, + { type: "status", state: "ready", gen: 1 }, + ]); + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("control server: hello -> stop round trip calls onStop and reports its result", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + let stopCalled = false; + const handle = await createControlServer(socketPath, { + onStop: async () => { + stopCalled = true; + return { gen: 3 }; + }, + onStatus: async () => ({}), + }); + const lines = [ + `${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`, + `${JSON.stringify({ type: "stop" })}\n`, + ]; + const received = await talk(socketPath, lines); + assert.equal(stopCalled, true); + assert.deepEqual(received, [{ type: "hello_ok" }, { type: "stop_ok", gen: 3 }]); + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("control server rejects a wrong protocol, a wrong role, a duplicate-key frame, and a second request on one connection", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + const handle = await createControlServer(socketPath, { + onStop: async () => ({}), + onStatus: async () => ({}), + }); + + let received = await talk(socketPath, [`${JSON.stringify({ type: "hello", protocol: 999, role: "control" })}\n`]); + assert.equal(received[0].type, "error"); + assert.match(received[0].reason, /protocol/); + + received = await talk(socketPath, [`${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "session" })}\n`]); + assert.equal(received[0].type, "error"); + assert.match(received[0].reason, /role/); + + // A hand-built frame with a duplicate key -- parseStrictJson must + // reject this even though JSON.parse alone would accept it silently. + received = await talk(socketPath, ['{"type":"hello","protocol":1,"role":"control","protocol":1}\n']); + assert.equal(received[0].type, "error"); + + // A second request pipelined onto the same connection after the first + // has already been answered: the connection is already ending by + // then, so it gets silently dropped rather than a second response -- + // the point of the test is that this must NOT disturb or lose the + // FIRST (real) response, which it did before this was fixed. + const twoRequests = [ + `${JSON.stringify({ type: "hello", protocol: PROTOCOL_VERSION, role: "control" })}\n`, + `${JSON.stringify({ type: "status" })}\n`, + `${JSON.stringify({ type: "status" })}\n`, + ]; + received = await talk(socketPath, twoRequests); + assert.deepEqual(received, [{ type: "hello_ok" }, { type: "status" }]); + + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("control server cuts a connection that exceeds the 1 MiB frame ceiling before a newline arrives", async () => { + const dir = socketDir(); + const socketPath = join(dir, "c.sock"); + try { + const handle = await createControlServer(socketPath, { + onStop: async () => ({}), + onStatus: async () => ({}), + }); + const oversized = "x".repeat(1024 * 1024 + 10); // no trailing newline + const received = await talk(socketPath, [oversized]); + // The connection is destroyed outright -- no response line at all, + // just a close. + assert.deepEqual(received, []); + await handle.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_db.test.mjs b/tests/agmsgd_db.test.mjs new file mode 100644 index 000000000..3bee749ae --- /dev/null +++ b/tests/agmsgd_db.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { openInstallDb, withImmediateTransaction } from "../scripts/daemon/db.mjs"; + +test("openInstallDb applies the schema idempotently; readOnly never migrates or writes", () => { + const dir = mkdtempSync(join(tmpdir(), "agmsgd-db-test-")); + const path = join(dir, "install.db"); + try { + const db = openInstallDb(path); + for (const table of ["meta", "daemon_owner", "daemon_intent"]) { + const row = db.prepare(`SELECT count(*) AS n FROM ${table}`).get(); + assert.equal(row.n, 1, `${table} must have exactly one row after first open`); + } + assert.equal( + db.prepare("SELECT count(*) AS n FROM daemon_start_attempts").get().n, + 0, + "daemon_start_attempts is append-only history with no seed row", + ); + db.close(); + + // Re-opening (a second component, or a restart) must not add rows or + // fail on the already-existing schema. + const db2 = openInstallDb(path); + assert.equal(db2.prepare("SELECT count(*) AS n FROM daemon_owner").get().n, 1); + db2.close(); + + // A readOnly open of a DB that was never created must not create it + // (readOnly + a missing file is an error, not a silent bootstrap) and + // must refuse to write to one that exists. + const neverCreated = join(dir, "never.db"); + assert.throws(() => openInstallDb(neverCreated, { readonly: true })); + + const ro = openInstallDb(path, { readonly: true }); + assert.throws(() => ro.exec("INSERT INTO daemon_start_attempts (at, reason) VALUES ('x','y')")); + ro.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("withImmediateTransaction commits on success and rolls back on throw", () => { + const dir = mkdtempSync(join(tmpdir(), "agmsgd-db-test-")); + const path = join(dir, "install.db"); + try { + const db = openInstallDb(path); + withImmediateTransaction(db, () => { + db.exec("INSERT INTO daemon_start_attempts (at, reason) VALUES ('t1','ok')"); + }); + assert.equal(db.prepare("SELECT count(*) AS n FROM daemon_start_attempts").get().n, 1); + + assert.throws(() => + withImmediateTransaction(db, () => { + db.exec("INSERT INTO daemon_start_attempts (at, reason) VALUES ('t2','bad')"); + throw new Error("boom"); + }), + ); + assert.equal( + db.prepare("SELECT count(*) AS n FROM daemon_start_attempts").get().n, + 1, + "the row inserted before the throw must be rolled back", + ); + db.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_executor.test.mjs b/tests/agmsgd_executor.test.mjs new file mode 100644 index 000000000..faded4ab6 --- /dev/null +++ b/tests/agmsgd_executor.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { bootId, currentExecutor, isAlive } from "../scripts/daemon/executor.mjs"; + +test("bootId is stable across calls and isAlive tells living/dead/wrong-boot apart", () => { + assert.equal(bootId(), bootId(), "bootId must not change within the same boot"); + + const self = currentExecutor(); + assert.equal(isAlive(self), true, "this process's own pid must read as alive"); + + // A pid that has already exited, at the CURRENT boot id: must read as + // confirmed dead (ESRCH), not "cannot tell". + const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"]); + assert.equal(dead.status, 0); + assert.equal( + isAlive({ pid: dead.pid, bootId: self.bootId }), + false, + "an exited pid at the current boot id must read as confirmed dead", + ); + + // A boot id that does not match the current one must short-circuit to + // confirmed dead even for this process's own (very much alive) pid -- + // no pid from a different boot can still be running. + assert.equal( + isAlive({ pid: self.pid, bootId: "0" }), + false, + "a mismatched boot id must read as confirmed dead regardless of the pid", + ); +}); diff --git a/tests/agmsgd_lifecycle.test.mjs b/tests/agmsgd_lifecycle.test.mjs new file mode 100644 index 000000000..5bee06a68 --- /dev/null +++ b/tests/agmsgd_lifecycle.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { + captureWatchState, + readCompletionState, + verifyDigest, + verifyInstallId, + watchForDrift, +} from "../scripts/daemon/lifecycle.mjs"; +import { openInstallDb } from "../scripts/daemon/db.mjs"; + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +function makeInstall() { + const root = mkdtempSync(join(tmpdir(), "agmsgd-lifecycle-test-")); + mkdirSync(join(root, "run"), { recursive: true }); + mkdirSync(join(root, "scripts", "lib"), { recursive: true }); + writeFileSync(join(root, "scripts", "team.sh"), "team\n"); + writeFileSync(join(root, "scripts", "lib", "storage.sh"), "storage\n"); + const files = [ + { path: "scripts/team.sh", digest: sha256("team\n") }, + { path: "scripts/lib/storage.sh", digest: sha256("storage\n") }, + ]; + const manifest = { + install_id: "test-install", + gen: 1, + version: "0.0.0-test", + created_at: new Date().toISOString(), + digest_algo: "sha256", + files, + }; + writeFileSync(join(root, "run", "install-manifest.json"), JSON.stringify(manifest)); + writeFileSync(join(root, "VERSION"), `${manifest.version}\n`); + return { root, manifest }; +} + +test("readCompletionState: missing / complete / crashed / updating", () => { + const { root, manifest } = makeInstall(); + try { + const complete = readCompletionState(root); + assert.equal(complete.state, "complete"); + assert.deepEqual(complete.manifest, manifest); + + // Simulate "install started" (rename to .prev, no new record yet). + rmSync(join(root, "run", "install-manifest.json.prev"), { force: true }); + renameSync( + join(root, "run", "install-manifest.json"), + join(root, "run", "install-manifest.json.prev"), + ); + assert.equal(readCompletionState(root).state, "crashed", "no lock held -> update died mid-way"); + + // Now hold the install-op lock -- must read as "updating", not "crashed". + const lockPath = join(root, "run", "install-op.lock.db"); + const holder = new DatabaseSync(lockPath); + holder.exec("BEGIN EXCLUSIVE;"); + try { + assert.equal(readCompletionState(root).state, "updating"); + } finally { + holder.exec("ROLLBACK;"); + holder.close(); + } + + rmSync(join(root, "run", "install-manifest.json.prev")); + assert.equal(readCompletionState(root).state, "missing", "no manifest, no .prev"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("verifyDigest: clean install passes; a missing file, an extra file, an edited file, and a symlink all fail", () => { + const { root, manifest } = makeInstall(); + try { + assert.deepEqual(verifyDigest(root, manifest), { ok: true }); + + // Edited file: digest mismatch. + + writeFileSync(join(root, "scripts", "team.sh"), "tampered\n"); + assert.equal(verifyDigest(root, manifest).ok, false); + writeFileSync(join(root, "scripts", "team.sh"), "team\n"); // restore + + // Missing file. + unlinkSync(join(root, "scripts", "lib", "storage.sh")); + let r = verifyDigest(root, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /missing/); + writeFileSync(join(root, "scripts", "lib", "storage.sh"), "storage\n"); // restore + + // Extra file not in the manifest. + writeFileSync(join(root, "scripts", "extra.sh"), "surprise\n"); + r = verifyDigest(root, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /unexpected/); + unlinkSync(join(root, "scripts", "extra.sh")); + + // A symlink anywhere under scripts/ fails verification outright, even + // though it points at an otherwise-correct file. + symlinkSync(join(root, "scripts", "team.sh"), join(root, "scripts", "team-link.sh")); + r = verifyDigest(root, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /symlink/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("verifyInstallId: matches, mismatches, and a never-set install.db all report correctly", () => { + const { root, manifest } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + // Never set: meta.install_id is NULL, must mismatch (not silently pass). + assert.equal(verifyInstallId(db, manifest).ok, false); + + db.exec(`UPDATE meta SET install_id = '${manifest.install_id}'`); + assert.equal(verifyInstallId(db, manifest).ok, true); + + db.exec("UPDATE meta SET install_id = 'some-other-install'"); + const r = verifyInstallId(db, manifest); + assert.equal(r.ok, false); + assert.match(r.reason, /mismatch/); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("watchForDrift: quiet when nothing changed; catches a moved manifest, a VERSION bump, and an in-place edit", async () => { + const { root, manifest } = makeInstall(); + try { + const first = readCompletionState(root); + const state = await captureWatchState(root, manifest, first.manifestText); + assert.deepEqual(await watchForDrift(root, state), { changed: false }); + + // Case 1: an install/uninstall starts (manifest moved to .prev). + renameSync( + join(root, "run", "install-manifest.json"), + join(root, "run", "install-manifest.json.prev"), + ); + let r = await watchForDrift(root, state); + assert.equal(r.changed, true); + assert.match(r.reason, /completion record/); + renameSync( + join(root, "run", "install-manifest.json.prev"), + join(root, "run", "install-manifest.json"), + ); + + // Case 2: VERSION changed without the manifest moving at all. + writeFileSync(join(root, "VERSION"), "0.0.1-different\n"); + r = await watchForDrift(root, state); + assert.equal(r.changed, true); + assert.match(r.reason, /VERSION/); + writeFileSync(join(root, "VERSION"), `${manifest.version}\n`); + + // Case 3: a file under scripts/ edited in place -- the #963 case -- + // with neither the manifest nor VERSION touched. + writeFileSync(join(root, "scripts", "team.sh"), "tampered in place\n"); + r = await watchForDrift(root, state); + assert.equal(r.changed, true); + assert.match(r.reason, /scripts\/ changed in place/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_log.test.mjs b/tests/agmsgd_log.test.mjs new file mode 100644 index 000000000..d2739a1c7 --- /dev/null +++ b/tests/agmsgd_log.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { logLine, logPath } from "../scripts/daemon/log.mjs"; + +test("logLine appends and rotates at 1 MiB, keeping exactly one prior generation", () => { + const root = mkdtempSync(join(tmpdir(), "agmsgd-log-test-")); + mkdirSync(join(root, "run"), { recursive: true }); + try { + logLine(root, "first line"); + const path = logPath(root); + assert.match(readFileSync(path, "utf8"), /first line/); + + // Force the file past the rotation threshold without writing 1 MiB + // through the real API one line at a time. + writeFileSync(path, "x".repeat(1024 * 1024 + 1)); + logLine(root, "after rotation"); + assert.equal(existsSync(`${path}.1`), true, "the oversized file must be rotated aside"); + assert.match(readFileSync(path, "utf8"), /after rotation/); + assert.ok(statSync(`${path}.1`).size >= 1024 * 1024); + + // A second rotation must not accumulate a .2 -- exactly one prior + // generation is kept. + writeFileSync(path, "y".repeat(1024 * 1024 + 1)); + logLine(root, "second rotation"); + assert.equal(existsSync(`${path}.2`), false); + assert.match(readFileSync(`${path}.1`, "utf8"), /^y+$/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_main.test.mjs b/tests/agmsgd_main.test.mjs new file mode 100644 index 000000000..e0e00627d --- /dev/null +++ b/tests/agmsgd_main.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { openInstallDb } from "../scripts/daemon/db.mjs"; +import { readOwner } from "../scripts/daemon/owner.mjs"; +import { gracefulStop, pollOnce, startup } from "../scripts/daemon/main.mjs"; +import { captureWatchState } from "../scripts/daemon/lifecycle.mjs"; +import { createHash } from "node:crypto"; + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +function makeInstall() { + const root = mkdtempSync(join(tmpdir(), "agmsgd-main-test-")); + mkdirSync(join(root, "run"), { recursive: true }); + mkdirSync(join(root, "scripts"), { recursive: true }); + writeFileSync(join(root, "scripts", "x.sh"), "x\n"); + writeFileSync(join(root, "VERSION"), "1.0.0\n"); + const manifest = { + install_id: "i1", + gen: 1, + version: "1.0.0", + created_at: new Date().toISOString(), + digest_algo: "sha256", + files: [{ path: "scripts/x.sh", digest: sha256("x\n") }], + }; + const manifestText = JSON.stringify(manifest); + writeFileSync(join(root, "run", "install-manifest.json"), manifestText); + return { root, manifest, manifestText }; +} + +test("startup: takes ownership, binds the socket, and marks ready", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + assert.equal(started.ok, true); + assert.equal(readOwner(db).state, "ready"); + await started.controlHandle.close(); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("startup: a refused takeOwnership never touches the control socket, and reports ok:false", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + // Wrong expectedOpGen -> takeOwnership refuses before ever trying to bind. + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 99, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + assert.equal(started.ok, false); + assert.equal(readOwner(db).state, "none", "a refused start must never move daemon_owner"); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("pollOnce: continues when nothing changed, steps aside on drift, stops on an intent change", async () => { + const { root, manifest, manifestText } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const watchState = await captureWatchState(root, manifest, manifestText); + + let verdict = await pollOnce(db, root, { gen: 1, expectedOpGen: 0, watchState }); + assert.deepEqual(verdict, { action: "continue" }); + + // Case: an explicit operation bumped op_gen since this process started. + db.exec("UPDATE daemon_intent SET op_gen = 1"); + verdict = await pollOnce(db, root, { gen: 1, expectedOpGen: 0, watchState }); + assert.equal(verdict.action, "stop"); + assert.equal(verdict.reason, "normal"); + db.exec("UPDATE daemon_intent SET op_gen = 0"); // restore + + // Case: an in-place edit under scripts/ (the #963 case) with neither + // the manifest nor the op_gen touched. + writeFileSync(join(root, "scripts", "x.sh"), "tampered\n"); + verdict = await pollOnce(db, root, { gen: 1, expectedOpGen: 0, watchState }); + assert.equal(verdict.action, "step_aside"); + assert.equal(verdict.reason, "stepped_aside_for_update"); + + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("gracefulStop: marks stopping, stops every channel hook, closes the socket, and records the reason", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + assert.equal(started.ok, true); + + let hookStopped = false; + const hooks = [{ stop: async () => { hookStopped = true; } }]; + await gracefulStop(db, root, started.gen, started.controlHandle, hooks, "normal"); + + assert.equal(hookStopped, true); + const owner = readOwner(db); + assert.equal(owner.state, "none"); + assert.equal(owner.last_end_reason, "normal"); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("gracefulStop: a channel hook that throws is logged but does not stop the rest of shutdown", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + const started = await startup(db, { + installRoot: root, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + handlers: { onStop: async () => ({}), onStatus: async () => ({}) }, + }); + let secondHookRan = false; + const hooks = [ + { stop: async () => { throw new Error("boom"); } }, + { stop: async () => { secondHookRan = true; } }, + ]; + await gracefulStop(db, root, started.gen, started.controlHandle, hooks, "normal"); + assert.equal(secondHookRan, true); + assert.equal(readOwner(db).state, "none", "shutdown must still complete despite the throwing hook"); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_owner.test.mjs b/tests/agmsgd_owner.test.mjs new file mode 100644 index 000000000..b44ca29a4 --- /dev/null +++ b/tests/agmsgd_owner.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { openInstallDb } from "../scripts/daemon/db.mjs"; +import { + markReady, + markStopping, + readOwner, + revertToNone, + stopNormally, + takeOwnership, +} from "../scripts/daemon/owner.mjs"; +import { bootId } from "../scripts/daemon/executor.mjs"; + +function freshDb() { + const dir = mkdtempSync(join(tmpdir(), "agmsgd-owner-test-")); + const db = openInstallDb(join(dir, "install.db")); + return { dir, db }; +} + +test("takeOwnership: none -> starting -> ready is a clean run", () => { + const { dir, db } = freshDb(); + try { + const r = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + }); + assert.equal(r.ok, true); + assert.equal(r.gen, 1); + assert.equal(readOwner(db).state, "starting"); + assert.equal(markReady(db, r.gen), true); + assert.equal(readOwner(db).state, "ready"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("takeOwnership refuses when the current owner is alive", () => { + const { dir, db } = freshDb(); + try { + const first = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v1", + }); + assert.equal(first.ok, true); + markReady(db, first.gen); + + const second = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v2", + }); + assert.equal(second.ok, false); + assert.match(second.reason, /alive/); + assert.equal(readOwner(db).gen, first.gen, "the alive owner's gen must not be disturbed"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("takeOwnership takes over when the recorded owner is confirmed dead", () => { + const { dir, db } = freshDb(); + try { + const first = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v1", + }); + markReady(db, first.gen); + + // Overwrite the recorded executor with one that has already exited, at + // the current boot id -- i.e. confirmed dead, not merely absent. + const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"]); + db.prepare("UPDATE daemon_owner SET executor_pid = ?, executor_boot_id = ? WHERE gen = ?").run( + dead.pid, + bootId(), + first.gen, + ); + + const second = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "v2", + }); + assert.equal(second.ok, true); + assert.equal(second.gen, first.gen + 1, "gen must advance and never be reused"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("takeOwnership refuses when the intent changed since the launcher observed it", () => { + const { dir, db } = freshDb(); + try { + db.exec("UPDATE daemon_intent SET desired = 'on', op_gen = 5"); + const r = takeOwnership(db, { + installRoot: dir, + expectedDesired: "on", + expectedOpGen: 4, // stale -- op_gen has since moved to 5 + version: "test", + }); + assert.equal(r.ok, false); + assert.match(r.reason, /intent changed/); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("revertToNone (bind failure) and stopNormally both record last_end under this gen's own CAS", () => { + const { dir, db } = freshDb(); + try { + const r = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + }); + assert.equal(revertToNone(db, r.gen, "bind failed"), true); + let owner = readOwner(db); + assert.equal(owner.state, "none"); + assert.equal(owner.last_end_reason, "bind failed"); + assert.equal(owner.last_end_gen, r.gen); + + const r2 = takeOwnership(db, { + installRoot: dir, + expectedDesired: null, + expectedOpGen: 0, + version: "test", + }); + markReady(db, r2.gen); + assert.equal(markStopping(db, r2.gen), true); + assert.equal(readOwner(db).state, "stopping"); + assert.equal(stopNormally(db, r2.gen, "normal"), true); + owner = readOwner(db); + assert.equal(owner.state, "none"); + assert.equal(owner.last_end_reason, "normal"); + } finally { + db.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/agmsgd_status.test.mjs b/tests/agmsgd_status.test.mjs new file mode 100644 index 000000000..9c5edd195 --- /dev/null +++ b/tests/agmsgd_status.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classify } from "../scripts/daemon/status.mjs"; + +const baseOwner = { gen: 1, version: "1.6.0", socket: "/tmp/x.sock" }; + +test("ready + reachable -> running, exit 0", () => { + const r = classify({ owner: { ...baseOwner, state: "ready" }, intent: { desired: "on" }, alive: true, reachable: true }); + assert.equal(r.exitCode, 0); + assert.match(r.text, /running/); +}); + +test("ready + NOT reachable -> exit 1, names the mismatch", () => { + const r = classify({ owner: { ...baseOwner, state: "ready" }, intent: { desired: "on" }, alive: true, reachable: false }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /does not answer/); +}); + +test("starting, alive, within 30s grace -> exit 0; past grace or dead -> exit 1", () => { + const now = Date.now(); + const recent = new Date(now - 5_000).toISOString(); + const old = new Date(now - 60_000).toISOString(); + + let r = classify( + { owner: { ...baseOwner, state: "starting", started_at: recent }, intent: {}, alive: true }, + now, + ); + assert.equal(r.exitCode, 0); + + r = classify( + { owner: { ...baseOwner, state: "starting", started_at: old }, intent: {}, alive: true }, + now, + ); + assert.equal(r.exitCode, 1, "past the 30s grace window must warn even while alive"); + + r = classify( + { owner: { ...baseOwner, state: "starting", started_at: recent }, intent: {}, alive: false }, + now, + ); + assert.equal(r.exitCode, 1, "a confirmed-dead executor must warn even within the grace window"); + + r = classify( + { owner: { ...baseOwner, state: "starting", started_at: recent }, intent: {}, alive: null }, + now, + ); + assert.equal(r.exitCode, 1, "undetermined liveness must warn, not pass silently"); +}); + +test("none + intent off -> exit 0, names it as deliberate", () => { + const r = classify({ owner: { ...baseOwner, gen: 3, state: "none" }, intent: { desired: "off", set_at: "t" }, alive: null }); + assert.equal(r.exitCode, 0); + assert.match(r.text, /not in use/); +}); + +test("none + gen 0 (never started) -> exit 0, never says 'not installed'", () => { + const r = classify({ owner: { ...baseOwner, gen: 0, state: "none" }, intent: {}, alive: null }); + assert.equal(r.exitCode, 0); + assert.match(r.text, /never been started/); + assert.doesNotMatch(r.text, /not installed/i); +}); + +test("none + bind_failed last_end -> exit 1, names the reason", () => { + const r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "bind_failed", last_end_at: "t", last_end_gen: 1 }, + intent: { desired: "on" }, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /failed to start/); +}); + +test("none + stepped_aside_for_update -> exit 1, says the new version has not started", () => { + const r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "stepped_aside_for_update", last_end_at: "t" }, + intent: { desired: "on" }, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /stopped for an update/); +}); + +test("none + intent on + normal stop -> exit 1, distinct from no-recorded-intent", () => { + let r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "normal" }, + intent: { desired: "on" }, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /intent is on/); + + r = classify({ + owner: { ...baseOwner, state: "none", last_end_reason: "normal" }, + intent: {}, + alive: null, + }); + assert.equal(r.exitCode, 1); + assert.match(r.text, /No intent/); + + // Never collapse a real problem into exit 0. + assert.notEqual(r.exitCode, 0); +}); diff --git a/tests/test_agmsgd_control.bats b/tests/test_agmsgd_control.bats new file mode 100644 index 000000000..fab356fae --- /dev/null +++ b/tests/test_agmsgd_control.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd control: hello/stop/status framing, protocol/role/duplicate-key rejection, frame ceiling" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_control.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats new file mode 100644 index 000000000..879b54f6e --- /dev/null +++ b/tests/test_agmsgd_daemon_sh.bats @@ -0,0 +1,365 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + DAEMON="$SCRIPTS/daemon.sh" + chmod +x "$SCRIPTS/daemon/agmsgd" "$SCRIPTS/daemon/agmsgd-launch.sh" +} + +teardown() { + # Safety net: recompute this test's own unit id/plist path the same way + # daemon.sh does and remove that exact, deterministic file -- never a + # glob or a freshly-recomputed variable standing in for "whatever is + # there now" (the anti-pattern is a rm target built from something that + # could resolve to someone else's entry; this is the same install + # re-deriving its own single, fixed name). + # + # Deliberately does NOT call the real launchctl here, even as a + # last-resort cleanup: `launchctl bootstrap "gui/$(id -u)" ` + # registers into the REAL, system-wide launchd session for this real + # user regardless of $HOME -- sandboxing $HOME only moves where the + # PLIST FILE lives, it does nothing to where the registration itself + # goes (found the hard way: a real unit leaked into the real + # ~/Library/LaunchAgents even though every path in this file's own + # $TEST_SKILL_DIR/$HOME was already sandboxed). Tests now only ever + # drive a fake launchctl (_fake_launchctl, below), so there is nothing + # real for this teardown to unregister; only the file needs removing. + if [ -n "${TEST_SKILL_DIR:-}" ] && [ -f "$TEST_SKILL_DIR/run/install.db" ]; then + local install_id label plist + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;" 2>/dev/null || true)" + if [ -n "$install_id" ]; then + local unit_id + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + rm -f "$plist" + fi + fi + teardown_test_env +} + +_seed_install_db() { + mkdir -p "$TEST_SKILL_DIR/run" + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + # node_path is normally recorded by `enable` -- seeded + # here directly for tests that exercise `start` on its own, without + # going through `enable` first. + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id = 'daemon-sh-test', node_path = '$(command -v node)';" +} + +_write_completion_record() { + # watchForDrift() compares the live VERSION file against the manifest's + # own copy of it every poll cycle; a manifest that names a version + # with no matching VERSION file on disk is drift on the very first + # cycle, indistinguishable from a real one. + echo "test" > "$TEST_SKILL_DIR/VERSION" + node -e " + const { createHash } = require('node:crypto'); + const { readFileSync, readdirSync, writeFileSync } = require('node:fs'); + const { join } = require('node:path'); + const root = process.argv[1]; + const files = []; + function walk(dir, rel) { + for (const e of readdirSync(join(root, dir), { withFileTypes: true })) { + const relPath = rel ? rel + '/' + e.name : e.name; + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) walk(dir + '/' + e.name, relPath); + else if (e.isFile()) { + const digest = createHash('sha256').update(readFileSync(join(root, dir, e.name))).digest('hex'); + files.push({ path: 'scripts/' + relPath, digest }); + } + } + } + walk('scripts', ''); + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const bootstrapLine = readFileSync(join(root, 'scripts/daemon/agmsgd'), 'utf8') + .split('\n').find((l) => l.startsWith('const BOOTSTRAP_VERSION = ')); + const bootstrapVersion = Number(bootstrapLine.match(/= (\d+);/)[1]); + writeFileSync(join(root, 'run/install-manifest.json'), JSON.stringify({ + install_id: 'daemon-sh-test', + gen: 1, + version: 'test', + bootstrap_version: bootstrapVersion, + created_at: new Date().toISOString(), + digest_algo: 'sha256', + files, + })); + " "$TEST_SKILL_DIR" +} + +@test "daemon.sh status: no install.db -> exit 1" { + run bash "$DAEMON" status + [ "$status" -eq 1 ] +} + +@test "daemon.sh status: fresh install.db, no completion record -> Node path reports 'never been started'" { + _seed_install_db + run bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"never been started"* ]] +} + +@test "daemon.sh status: falls back to a minimal read when Node is not on PATH" { + _seed_install_db + local no_node_path + no_node_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v -E '/(node|nodejs)([^/]*)?$' | while read -r d; do [ -x "$d/node" ] || printf '%s\n' "$d"; done | paste -sd: -)" + PATH="$no_node_path" run bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"no usable Node"* ]] +} + +# A bare `run` has no ceiling of its own: a real hang in the command +# under test (found twice already while writing this file) stalls the +# whole suite rather than failing the one test. Backgrounds the command, +# races it against a deadline, and reports "timed out" as a status bats +# can act on rather than a wedged bats process. +_run_with_deadline() { + local deadline_s="$1"; shift + local outfile exitfile + outfile="$(mktemp)" + exitfile="$(mktemp)" + (if "$@" > "$outfile" 2>&1; then echo 0 > "$exitfile"; else echo "$?" > "$exitfile"; fi) & + local pid=$! + local waited=0 + while kill -0 "$pid" 2>/dev/null; do + waited=$((waited + 1)) + if [ "$waited" -ge $((deadline_s * 10)) ]; then + kill -9 "$pid" 2>/dev/null || true + output="(timed out after ${deadline_s}s; partial output: $(cat "$outfile" 2>/dev/null))" + status=124 + rm -f "$outfile" "$exitfile" + return 0 + fi + sleep 0.1 + done + wait "$pid" 2>/dev/null || true + output="$(cat "$outfile")" + status="$(cat "$exitfile")" + rm -f "$outfile" "$exitfile" +} + +@test "daemon.sh start: a real run against the real entrypoint becomes ready" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'running' + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh status: reports running while the real daemon is up" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + + _run_with_deadline 10 bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"running"* ]] || [[ "$output" == *'"exitCode":0'* ]] + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh start: a second start while already running is a no-op" { + _seed_install_db + _write_completion_record + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + + _run_with_deadline 10 bash "$DAEMON" start + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'already running' + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh stop: stops a real running daemon, and a second stop is a no-op" { + _seed_install_db + _write_completion_record + local iteration + for iteration in 1 2 3; do + _run_with_deadline 15 bash "$DAEMON" start + [ "$status" -eq 0 ] + + _run_with_deadline 10 bash "$DAEMON" status + [ "$status" -eq 0 ] + [[ "$output" == *"running"* ]] || [[ "$output" == *'"exitCode":0'* ]] + + _run_with_deadline 10 bash "$DAEMON" start + [ "$status" -eq 0 ] + [[ "$output" == *"already running"* ]] + + _run_with_deadline 15 bash "$DAEMON" stop + [ "$status" -eq 0 ] + [[ "$output" == *"stopped"* ]] + + _run_with_deadline 10 bash "$DAEMON" stop + [ "$status" -eq 0 ] + [[ "$output" == *"already stopped"* ]] + done +} + +# A fake launchctl, never the real one: if a bats run is killed externally +# mid-test, it skips its own teardown, and the +# real gui launchd domain is not this test's to leave litter in even when +# nothing goes wrong -- exactly this happened once already while writing +# this file. Real registration is exercised only by hand, never from an +# automated run on any platform. +_fake_launchctl() { + mkdir -p "$TEST_SKILL_DIR/fake-launchd" + cat > "$TEST_SKILL_DIR/fake-launchd/launchctl" <<'EOF' +#!/usr/bin/env bash +# Records enough for the test to tell load/bootstrap from unload/bootout +# apart, against a marker file instead of the real launchd. +if [ -n "${AGMSGD_FAKE_OP_LOCK_DB:-}" ]; then + if ! sqlite3 -cmd 'PRAGMA busy_timeout=0;' "$AGMSGD_FAKE_OP_LOCK_DB" 'BEGIN EXCLUSIVE; ROLLBACK;' >/dev/null 2>&1; then + printf 'locked %s\n' "$1" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:?}" + else + printf 'unlocked %s\n' "$1" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:?}" + fi +fi +printf 'call %s\n' "$*" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:-/dev/null}" +case "$1" in + bootstrap|load) touch "${AGMSGD_FAKE_LAUNCHD_MARKER:?}" ;; + bootout|unload) + [ "${AGMSGD_FAKE_LAUNCHD_FAIL_UNREGISTER:-0}" != 1 ] || exit 1 + rm -f "${AGMSGD_FAKE_LAUNCHD_MARKER:?}" + ;; + list) [ -f "${AGMSGD_FAKE_LAUNCHD_MARKER:?}" ] ;; + kickstart) + printf '%s\n' "$*" >> "${AGMSGD_FAKE_LAUNCHD_EVENTS:?}" + if [ "${AGMSGD_FAKE_LAUNCHD_FAIL_UNREGISTER:-0}" = 1 ]; then exit 1; fi + bash "${AGMSGD_TEST_LAUNCHER:?}" /dev/null 2>&1 3>&- & + ;; +esac +EOF + chmod +x "$TEST_SKILL_DIR/fake-launchd/launchctl" + echo "$TEST_SKILL_DIR/fake-launchd/launchctl" +} + +_fake_windows_manager() { + mkdir -p "$TEST_SKILL_DIR/fake-windows" + cat > "$TEST_SKILL_DIR/fake-windows/uname" <<'EOF' +#!/usr/bin/env bash +echo MINGW64_NT-10.0 +EOF + cat > "$TEST_SKILL_DIR/fake-windows/schtasks" <<'EOF' +#!/usr/bin/env bash +case "$1" in + /Create) + while [ "$#" -gt 0 ]; do + if [ "$1" = /XML ]; then cp "$2" "${AGMSGD_FAKE_TASK_XML:?}"; fi + shift + done + touch "${AGMSGD_FAKE_TASK_MARKER:?}" + ;; + /Run) bash "${AGMSGD_TEST_LAUNCHER:?}" /dev/null 2>&1 3>&- & ;; + /Delete) rm -f "${AGMSGD_FAKE_TASK_MARKER:?}" ;; +esac +EOF + chmod +x "$TEST_SKILL_DIR/fake-windows/uname" "$TEST_SKILL_DIR/fake-windows/schtasks" +} + +@test "daemon.sh enable/disable: registers a resident unit (via a fake launchctl on macOS) and cleans it up" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + _seed_install_db + _write_completion_record + local fake_launchctl marker events + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + run bash "$DAEMON" enable + [ "$status" -eq 0 ] + + local install_id unit_id label plist + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + [ -f "$plist" ] + [ -f "$marker" ] + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" \ + run bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -f "$plist" ] + [ ! -f "$marker" ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] + grep -q '^locked bootstrap$' "$events" + grep -q '^locked bootout$' "$events" +} + +@test "daemon.sh start: stale ready state asks the registered manager to start the service" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + _seed_install_db + _write_completion_record + local fake_launchctl marker events install_id unit_id label plist + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$(dirname "$plist")" + : > "$plist" + touch "$marker" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on'; UPDATE daemon_owner SET state='ready', gen=1, executor_pid=99999999, executor_boot_id='stale', socket='$TEST_SKILL_DIR/run/missing.sock';" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + run bash "$DAEMON" start + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'running' + grep -q 'kickstart gui/' "$events" + bash "$DAEMON" stop >/dev/null 2>&1 || true +} + +@test "daemon.sh disable: unregister failure is reported and preserves registration" { + [ "$(uname -s)" = "Darwin" ] || skip "this test's fake stands in for launchd specifically" + _seed_install_db + _write_completion_record + local fake_launchctl marker events install_id unit_id label plist + fake_launchctl="$(_fake_launchctl)" + marker="$TEST_SKILL_DIR/fake-launchd/registered" + events="$TEST_SKILL_DIR/fake-launchd/events" + install_id="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT install_id FROM meta;")" + unit_id="$(printf '%s:%s' "$TEST_SKILL_DIR" "$install_id" | shasum -a 256 | cut -c1-12)" + label="cc.agmsg.agmsgd.$unit_id" + plist="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$(dirname "$plist")" + : > "$plist" + touch "$marker" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='on';" + + AGMSGD_LAUNCHCTL="$fake_launchctl" AGMSGD_FAKE_LAUNCHD_MARKER="$marker" AGMSGD_FAKE_LAUNCHD_EVENTS="$events" AGMSGD_FAKE_OP_LOCK_DB="$TEST_SKILL_DIR/run/install-op.lock.db" AGMSGD_FAKE_LAUNCHD_FAIL_UNREGISTER=1 \ + run bash "$DAEMON" disable + [ "$status" -ne 0 ] + printf '%s\n' "$output" | grep -Fq 'could not unregister launchd service' + [ -f "$plist" ] + [ -f "$marker" ] + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT desired FROM daemon_intent;")" = "on" ] + grep -q '^locked bootout$' "$events" +} + +@test "daemon.sh Windows registration writes the declared UTF-16 task XML" { + _seed_install_db + _write_completion_record + _fake_windows_manager + local capture marker + capture="$TEST_SKILL_DIR/fake-windows/captured.xml" + marker="$TEST_SKILL_DIR/fake-windows/registered" + + PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_XML="$capture" AGMSGD_FAKE_TASK_MARKER="$marker" AGMSGD_TEST_LAUNCHER="$SCRIPTS/daemon/agmsgd-launch.sh" \ + _run_with_deadline 15 bash "$DAEMON" enable + [ "$status" -eq 0 ] + [ "$(od -An -tx1 -N2 "$capture" | tr -d ' \n')" = "fffe" ] + iconv -f UTF-16LE -t UTF-8 "$capture" | grep -q 'encoding="UTF-16"' + + PATH="$TEST_SKILL_DIR/fake-windows:$PATH" AGMSGD_SCHTASKS="$TEST_SKILL_DIR/fake-windows/schtasks" AGMSGD_FAKE_TASK_MARKER="$marker" \ + _run_with_deadline 15 bash "$DAEMON" disable + [ "$status" -eq 0 ] + [ ! -f "$marker" ] + [ ! -f "$TEST_SKILL_DIR/run/agmsgd-$(_unit_id).xml" ] +} diff --git a/tests/test_agmsgd_db.bats b/tests/test_agmsgd_db.bats new file mode 100644 index 000000000..21593799b --- /dev/null +++ b/tests/test_agmsgd_db.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd db: schema is idempotent, readOnly never migrates or writes, transactions roll back on throw" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_db.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats new file mode 100644 index 000000000..c690a853f --- /dev/null +++ b/tests/test_agmsgd_entrypoint.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env +} + +teardown() { + teardown_test_env +} + +# Builds a real completion record for $TEST_SKILL_DIR/scripts (which +# setup_test_env already populated with a full, real copy of this repo's +# scripts/ tree, this PR's new scripts/daemon/* files included) and a real +# install.db whose meta.install_id matches it -- an end-to-end fixture for +# agmsgd's own self-verification, not a synthetic one. +_write_completion_record() { + # watchForDrift() compares the live VERSION file against the manifest's + # copy of it every poll cycle; without a matching VERSION file, + # this test only avoids that drift by finishing inside the 5s poll + # interval -- fragile on a slower machine. Write it for real. + echo "test" > "$TEST_SKILL_DIR/VERSION" + local install_id="test-install-id" + mkdir -p "$TEST_SKILL_DIR/run" + node -e " + const { createHash } = require('node:crypto'); + const { readFileSync, readdirSync, writeFileSync } = require('node:fs'); + const { join } = require('node:path'); + const root = process.argv[1]; + const files = []; + function walk(dir, rel) { + for (const e of readdirSync(join(root, dir), { withFileTypes: true })) { + const relPath = rel ? rel + '/' + e.name : e.name; + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) walk(dir + '/' + e.name, relPath); + else if (e.isFile()) { + const digest = createHash('sha256').update(readFileSync(join(root, dir, e.name))).digest('hex'); + files.push({ path: 'scripts/' + relPath, digest }); + } + } + } + walk('scripts', ''); + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const bootstrapLine = readFileSync(join(root, 'scripts/daemon/agmsgd'), 'utf8') + .split('\n').find((l) => l.startsWith('const BOOTSTRAP_VERSION = ')); + const bootstrapVersion = Number(bootstrapLine.match(/= (\d+);/)[1]); + writeFileSync(join(root, 'run/install-manifest.json'), JSON.stringify({ + install_id: process.argv[2], + gen: 1, + version: 'test', + bootstrap_version: bootstrapVersion, + created_at: new Date().toISOString(), + digest_algo: 'sha256', + files, + })); + " "$TEST_SKILL_DIR" "$install_id" + + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id = '$install_id', node_path = '$(command -v node)'; UPDATE daemon_intent SET desired = 'on', op_gen = 0;" +} + +@test "agmsgd end-to-end: starts, answers status over its real control socket, and stops cleanly on request" { + _write_completion_record + + node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 > "$TEST_SKILL_DIR/run/agmsgd.stdout" 2>&1 & + local daemon_pid=$! + + local socket="" waited=0 + while [ -z "$socket" ]; do + socket="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null)" + [ -n "$socket" ] && break + waited=$((waited + 1)) + if [ "$waited" -ge 100 ]; then + echo "agmsgd never became ready; stdout was:" >&2 + cat "$TEST_SKILL_DIR/run/agmsgd.stdout" >&2 + kill "$daemon_pid" 2>/dev/null || true + false + fi + sleep 0.05 + done + + run node -e " + const net = require('node:net'); + const s = net.createConnection(process.argv[1]); + let buf = ''; + s.on('connect', () => { + s.write(JSON.stringify({type:'hello',protocol:1,role:'control'}) + '\n'); + s.write(JSON.stringify({type:'status'}) + '\n'); + }); + s.on('data', (c) => { buf += c.toString('utf8'); if (buf.split('\n').filter(Boolean).length >= 2) { console.log(buf); s.end(); } }); + s.on('error', (e) => { console.error(e.message); process.exit(1); }); + " "$socket" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq '"type":"hello_ok"' + printf '%s\n' "$output" | grep -Fq '"type":"status"' + + run node -e " + const net = require('node:net'); + const s = net.createConnection(process.argv[1]); + s.on('connect', () => { + s.write(JSON.stringify({type:'hello',protocol:1,role:'control'}) + '\n'); + s.write(JSON.stringify({type:'stop'}) + '\n'); + }); + s.on('close', () => process.exit(0)); + s.on('error', (e) => { console.error(e.message); process.exit(1); }); + " "$socket" + [ "$status" -eq 0 ] + + wait "$daemon_pid" + [ "$?" -eq 0 ] + + local final_state + final_state="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" + [ "$final_state" = "none" ] +} diff --git a/tests/test_agmsgd_executor.bats b/tests/test_agmsgd_executor.bats new file mode 100644 index 000000000..49f8d1604 --- /dev/null +++ b/tests/test_agmsgd_executor.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd executor: bootId is stable, isAlive tells living/dead/wrong-boot apart" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_executor.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_launch.bats b/tests/test_agmsgd_launch.bats new file mode 100644 index 000000000..1cb52918b --- /dev/null +++ b/tests/test_agmsgd_launch.bats @@ -0,0 +1,130 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_test_env + LAUNCH="$SCRIPTS/daemon/agmsgd-launch.sh" +} + +teardown() { + teardown_test_env +} + +# A fresh Node binary path this launcher's own version/node:sqlite check +# will accept, and a throwaway install.db already migrated with the real +# schema.sql this launcher's own repo copy carries. +_seed_install_db() { + sqlite3 "$SKILLDIR_INSTALL_DB" < "$SCRIPTS/daemon/schema.sql" +} + +setup_install_db() { + mkdir -p "$TEST_SKILL_DIR/run" + SKILLDIR_INSTALL_DB="$TEST_SKILL_DIR/run/install.db" + _seed_install_db +} + +@test "agmsgd-launch: no install.db -> exits 0, starts nothing" { + run bash "$LAUNCH" + [ "$status" -eq 0 ] +} + +@test "agmsgd-launch: intent is not 'on' -> exits 0 silently" { + setup_install_db + # schema.sql seeds desired = NULL; explicitly set it to 'off' too. + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'off';" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agmsgd-launch: intent on, no completion record at all -> exits 0, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -Fq 'install.sh needs to run again' + count="$(sqlite3 "$SKILLDIR_INSTALL_DB" "SELECT count(*) FROM daemon_start_attempts;")" + [ "$count" -eq 1 ] +} + +@test "agmsgd-launch: an update in progress (a held install-op lock) -> exits 75" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + touch "$TEST_SKILL_DIR/run/install-manifest.json.prev" + + # sqlite3 given a whole statement list as one argument runs it and exits + # immediately -- releasing the lock before the launcher could ever see + # it held. An interactive session, fed through a FIFO kept open past + # the BEGIN EXCLUSIVE, is what actually holds it across commands, the + # way a real in-progress install.sh would. + local fifo="$TEST_SKILL_DIR/run/holder.fifo" + mkfifo "$fifo" + sqlite3 "$TEST_SKILL_DIR/run/install-op.lock.db" < "$fifo" & + local holder_pid=$! + exec 8> "$fifo" + echo "BEGIN EXCLUSIVE;" >&8 + # No fixed sleep budget: poll until the lock is actually held (a second, + # independent probe blocks), so this test does not race the holder's + # own startup time. + local waited=0 + while sqlite3 -cmd "PRAGMA busy_timeout=0;" "$TEST_SKILL_DIR/run/install-op.lock.db" "BEGIN EXCLUSIVE; ROLLBACK;" >/dev/null 2>&1; do + waited=$((waited + 1)) + [ "$waited" -lt 100 ] || break + sleep 0.05 + done + + run bash "$LAUNCH" + [ "$status" -eq 75 ] + + echo "ROLLBACK;" >&8 + exec 8>&- + wait "$holder_pid" 2>/dev/null || true +} + +@test "agmsgd-launch: a crashed update (.prev present, lock free) -> exits 0, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + touch "$TEST_SKILL_DIR/run/install-manifest.json.prev" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [[ "$output" == *"never completed"* ]] +} + +@test "agmsgd-launch: bootstrap_version mismatch -> exits 75, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + echo '{"bootstrap_version":999}' > "$TEST_SKILL_DIR/run/install-manifest.json" + run bash "$LAUNCH" + [ "$status" -eq 75 ] + [[ "$output" == *"bootstrap version mismatch"* ]] +} + +@test "agmsgd-launch: intent on, completion record present, but no usable Node recorded -> exits 0, records a start attempt" { + setup_install_db + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on';" + echo '{"bootstrap_version":1}' > "$TEST_SKILL_DIR/run/install-manifest.json" + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [[ "$output" == *"no usable Node"* ]] +} + +@test "agmsgd-launch: full happy path execs into scripts/daemon/agmsgd with (skillDir, desired, op_gen)" { + setup_install_db + local real_node + real_node="$(command -v node)" + sqlite3 "$SKILLDIR_INSTALL_DB" "UPDATE daemon_intent SET desired = 'on', op_gen = 7; UPDATE meta SET node_path = '$real_node';" + echo '{"bootstrap_version":1}' > "$TEST_SKILL_DIR/run/install-manifest.json" + # Stub the Node entrypoint so this test exercises only the launcher's + # own decision to hand off, not agmsgd itself (a separate component with + # its own tests). + cat > "$TEST_SKILL_DIR/scripts/daemon/agmsgd" <<'EOF' +#!/usr/bin/env node +console.log(JSON.stringify(process.argv.slice(2))); +EOF + chmod +x "$TEST_SKILL_DIR/scripts/daemon/agmsgd" + + run bash "$LAUNCH" + [ "$status" -eq 0 ] + [[ "$output" == *"\"$TEST_SKILL_DIR\",\"on\",\"7\""* ]] +} diff --git a/tests/test_agmsgd_lifecycle.bats b/tests/test_agmsgd_lifecycle.bats new file mode 100644 index 000000000..d0914aa86 --- /dev/null +++ b/tests/test_agmsgd_lifecycle.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd lifecycle: completion-record state, digest/install_id verification, and drift detection" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_lifecycle.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_log.bats b/tests/test_agmsgd_log.bats new file mode 100644 index 000000000..fbfc6faaa --- /dev/null +++ b/tests/test_agmsgd_log.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd log: appends and rotates at 1 MiB, one prior generation kept" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_log.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_main.bats b/tests/test_agmsgd_main.bats new file mode 100644 index 000000000..91e39d1e4 --- /dev/null +++ b/tests/test_agmsgd_main.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd main: startup/pollOnce/gracefulStop wire owner+control+lifecycle+log correctly" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_main.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_owner.bats b/tests/test_agmsgd_owner.bats new file mode 100644 index 000000000..f21701b60 --- /dev/null +++ b/tests/test_agmsgd_owner.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd owner: daemon_owner compare-and-swap lifecycle (take/ready/revert/stop)" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_owner.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_status.bats b/tests/test_agmsgd_status.bats new file mode 100644 index 000000000..c7fb675cd --- /dev/null +++ b/tests/test_agmsgd_status.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd status: decision table for daemon_owner/daemon_intent" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_status.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_spawn.bats b/tests/test_spawn.bats index c5512bef7..12e528dc0 100644 --- a/tests/test_spawn.bats +++ b/tests/test_spawn.bats @@ -1833,8 +1833,10 @@ _assert_bridged_argv() { # hooks that only bats sets; and names that are script-local variables # assigned inside the stack before they are read, never environment inputs # (role-session lookup results, codex-monitor's parsed command/args/version, - # the doc URL constant from delivery.sh). - local keep=" AGMSG_SPAWNED AGMSG_BASH AGMSG_WATCH_ONCE_INTERVAL AGMSG_WATCH_ONCE_TIMEOUT AGMSG_TEST_DISPATCHER_STALE_BARRIER AGMSG_TEST_ASSUME_CODEX_SOCKET AGMSG_ROLE_SESSION_UUID AGMSG_ROLE_SESSION_PROJECT CODEX_ARGS CODEX_COMMAND CODEX_VERSION CODEX_MONITOR_DOC_URL " + # the doc URL constant from delivery.sh). CODEX_HOME is intentionally + # inherited because the session recorder persists the active profile for + # resume, and the spawned Codex must use that same profile. + local keep=" AGMSG_SPAWNED AGMSG_BASH AGMSG_WATCH_ONCE_INTERVAL AGMSG_WATCH_ONCE_TIMEOUT AGMSG_TEST_DISPATCHER_STALE_BARRIER AGMSG_TEST_ASSUME_CODEX_SOCKET AGMSG_ROLE_SESSION_UUID AGMSG_ROLE_SESSION_PROJECT CODEX_HOME CODEX_ARGS CODEX_COMMAND CODEX_VERSION CODEX_MONITOR_DOC_URL " local inventory missing="" inventory="$( { grep -ohE '\$\{?(AGMSG_[A-Z0-9_]+|CODEX_[A-Z0-9_]+)' "$dir"/codex-shim.sh "$dir"/codex-monitor.sh "$dir"/_app-server.sh "$dir"/codex-bridge-launcher.sh "$dir"/codex-record-session.sh "$dir"/_session-start.sh "$dir"/codex-shim-install.sh "$dir"/_delivery.sh | sed -E 's/^\$\{?//'; grep -ohE 'process\.env\.(AGMSG_[A-Z0-9_]+|CODEX_[A-Z0-9_]+)' "$dir"/codex-bridge.js | sed 's/process\.env\.//'; } | sort -u )" while IFS= read -r v; do From db2a9a3c2769e3e60542e4dc078e682086ceca1a Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 07:57:40 -0700 Subject: [PATCH 03/15] feat(install): operation lock and completion manifest for agmsgd beta (#1506) ## Summary The installer and uninstaller use one per-install operation lock. Before the first protected write, each operation atomically publishes run/install-op-incomplete.json with an operation ID, operation kind and mode, target install path, actor PID, and start time. Later install and uninstall operations refuse to proceed while that record remains; the daemon entrypoint checks for it under the same exclusive lock before importing daemon code and exits with status 75 when it is present. A refusal prints the exact recovery command for the selected install and describes the recorded operation separately from the requested next operation. Before using it, an operator must verify that no writer from the recorded operation is still running; the command displays the recorded PID but does not infer liveness. Recovery removes only the matching operation ID under the lock, then continues the requested install or uninstall. Successful install publishes the completion manifest before removing its matching incomplete-operation record. A caught INT or TERM stops and reaps the tracked copy or removal command before clearing the record when the lock is still held; otherwise the record stays in place. Cancellation during the interval between writer spawn and PID publication also keeps the record, so later operations remain blocked until explicit recovery. A hard process death leaves the record as well. A small recovery helper is atomically published under run/ before installation files are rewritten. It remains available if an interrupted uninstall has removed scripts/, allowing the installed uninstall command to recover and continue. During a full uninstall, the installed uninstall command is moved to an operation-ID-specific retired path before the matching incomplete-operation record is cleared. The final cleanup removes only that retired path, never the canonical uninstall.sh path a later install may have recreated; the recovery helper follows the same matching-operation retirement rule. The installer preserves the last readable completion record before changing installation files. Generation 1 is used only when neither completion record exists; unreadable records without a valid fallback make the operation stop before changing installed files. The manifest records install identity, generation, source and bootstrap versions, timestamp, and SHA-256 digests for files under scripts/. The manifest writer hashes all safely named scripts files in one checked SHA-256 tool invocation and inserts their records with one SQLite session. Pending-operation validation reads all seven fields in one SQLite invocation, with hex-encoded values so delimiters and control characters in paths remain unambiguous. Lock confirmations occur at phase boundaries because the incomplete-operation record blocks competing operations during each phase; fresh-install marker and version writes share one checked phase. The tests/test_install.bats measurements progressed from 1614 seconds on the initial head to 171 seconds after the first batching change and 130 seconds after pending-record read batching; the integration baseline is 73 seconds. Bash 4 and later use a coprocess for the SQLite lock connection because the Windows SQLite CLI treats FIFO input as interactive; Bash 3.2 retains the FIFO path for compatibility with the system Bash on macOS. If the SQLite lock child exits during a copy or removal, the active command may finish, but the incomplete-operation record prevents later install/uninstall operations from entering concurrently; the next boundary check stops the original operation before subsequent writes. A daemon invocation that reaches its locked entry check while the record exists exits with status 75. ## Test plan - [x] Focused install tests cover lock-child loss during scripts copy and full uninstall removal, refusal of competing operations while the incomplete-operation record exists, recovery through the installed uninstaller after scripts/ is removed, a competing install during final retired-path cleanup, and cancellation before and after writer PID publication. - [x] Focused manifest and pending-record tests check batched hashes, per-file fallback for an ambiguous filename, refusal before publishing when the hash tool fails its known-answer probe, one SQLite read for all pending fields, Windows path conversion, CRLF normalization, and the legacy missing-mode default. - [x] Focused checks confirm lock liveness uses the shared local-pid helper when available and both lock/writer spawns close Bats file descriptors 3 and 4. - [x] The focused daemon-entrypoint test verifies status 75 while an incomplete-operation record exists. - [x] Focused install tests, bash -n, and git diff --check pass for the affected files; node --check passes for the daemon entrypoint. - [x] Windows CI (install helpers) passes, covering the coprocess lock path, CRLF normalization, and Windows path conversion. --- install.sh | 480 +++++++++++-- scripts/daemon/agmsgd | 5 + .../types/codex/codex-record-session.sh | 35 +- scripts/drivers/types/codex/template.md | 2 +- scripts/lib/install-db.sh | 61 ++ scripts/lib/install-manifest.sh | 326 +++++++++ scripts/lib/install-op-lock.sh | 558 +++++++++++++++ scripts/lib/role-session.sh | 4 +- scripts/lib/skill-render.sh | 7 + scripts/lib/sqlite-output.sh | 13 + tests/test_agmsgd_entrypoint.bats | 10 + tests/test_codex_resume.bats | 44 +- tests/test_install.bats | 657 +++++++++++++++++- tests/test_install_op_lock.bats | 112 +++ tests/test_spawn_fd_guard.bats | 10 +- uninstall.sh | 245 ++++++- 16 files changed, 2411 insertions(+), 158 deletions(-) create mode 100644 scripts/lib/install-db.sh create mode 100644 scripts/lib/install-manifest.sh create mode 100644 scripts/lib/install-op-lock.sh create mode 100644 scripts/lib/sqlite-output.sh create mode 100644 tests/test_install_op_lock.bats diff --git a/install.sh b/install.sh index 18579fe85..0ad180bd9 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,16 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" AGENTS_DIR="$HOME/.agents" +# Operation lock, install.db's meta row, and the completion manifest +# (agmsgd beta) -- the same lock uninstall.sh takes, and the record both a +# fresh install and an --update write around the scripts/ copy. +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/install-db.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/install-manifest.sh" + # Type registry — resolve each type's SKILL command template from its manifest # (scripts/drivers/types//template.md) instead of a hardcoded templates/ path. Read-only # helpers; safe to source. @@ -129,6 +139,7 @@ agmsg_source_version() { # --- Defaults --- CMD_NAME="" UPDATE_ONLY=false +RECOVER_ID="" INTERACTIVE=true AGENT_TYPE="" # claude-code, codex, gemini, antigravity — passed via --agent-type, or empty for auto/default @@ -168,8 +179,11 @@ agmsg_stage_overwrite_backups() { agmsg_scripts_rel_is_safe "$rel" || continue [ -f "$dest/$rel" ] || continue cmp -s "$src/$rel" "$dest/$rel" && continue + agmsg_install_op_require || return 1 mkdir -p "$(dirname "$trash_root/$rel")" + agmsg_install_op_require || return 1 cp -p "$dest/$rel" "$trash_root/$rel" + agmsg_install_op_require || return 1 AGMSG_TRASH_COUNT=$((AGMSG_TRASH_COUNT + 1)) done < <(cd "$src" && find . -type f -print0) } @@ -233,8 +247,11 @@ agmsg_prune_removed_scripts() { done if [ -z "$found" ]; then echo " - moving to .trash/ (no longer shipped): scripts/$rel" + agmsg_install_op_require || return 1 mkdir -p "$(dirname "$trash_root/$rel")" + agmsg_install_op_require || return 1 mv "$dest/$rel" "$trash_root/$rel" + agmsg_install_op_require || return 1 AGMSG_TRASH_COUNT=$((AGMSG_TRASH_COUNT + 1)) fi done < <(cd "$dest" && find . -type f -print0) @@ -247,8 +264,285 @@ agmsg_prune_removed_scripts() { # it -- a .trash/ nested under scripts/ would have its OWN prior contents # picked up by the very next prune as "not shipped". agmsg_reset_trash() { + agmsg_install_op_require || return 1 rm -rf "$1" + agmsg_install_op_require || return 1 mkdir -p "$1" + agmsg_install_op_require +} + +# Begin one install transaction before its first file change. The lock stays +# held through all rendering, script changes, VERSION writes, and setup work; +# the completion manifest is published only by the matching finish call. +# A crash before finish leaves .prev as the last known-good state. +# +# Requires (globals the caller must already have set): SCRIPT_DIR, SKILL_DIR, +# INSTALLED_VERSION. +agmsg_install_operation_exit() { + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + fi +} + +agmsg_install_write_recovery_helper() { + local helper="$SKILL_DIR/run/install-op-recovery.sh" temp + agmsg_install_op_require || return 1 + temp="$(mktemp "$SKILL_DIR/run/.install-op-recovery.XXXXXX")" || return 1 + if ! cat "$SCRIPT_DIR/scripts/lib/codex-config.sh" "$SCRIPT_DIR/scripts/lib/sqlpath.sh" \ + "$SCRIPT_DIR/scripts/lib/sqlite-output.sh" "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" > "$temp"; then + rm -f "$temp" + return 1 + fi + if ! chmod 600 "$temp"; then + rm -f "$temp" + return 1 + fi + agmsg_install_op_require || { rm -f "$temp"; return 1; } + if ! mv -f "$temp" "$helper"; then + rm -f "$temp" + return 1 + fi + agmsg_install_op_require +} + +agmsg_install_operation_begin() { + local manifest="$SKILL_DIR/run/install-manifest.json" + local lock_db="$SKILL_DIR/run/install-op.lock.db" + local install_db="$SKILL_DIR/run/install.db" + + mkdir -p "$SKILL_DIR/run" + if ! agmsg_install_op_lock "$lock_db"; then + echo " ! could not take the install operation lock: ${AGMSG_INSTALL_OP_LOCK_FAILURE_REASON:-unknown lock handshake failure}" >&2 + return 1 + fi + AGMSG_INSTALL_OP_ACTIVE=true + trap 'agmsg_install_operation_exit' EXIT + trap 'agmsg_install_op_handle_signal INT' INT + trap 'agmsg_install_op_handle_signal TERM' TERM + + agmsg_install_op_require || return 1 + + local pending="$SKILL_DIR/run/install-op-incomplete.json" + local recovery_prefix operation_mode=install + recovery_prefix="bash $(printf '%q' "$SCRIPT_DIR/install.sh") --cmd $(printf '%q' "$CMD_NAME")" + if [ "$UPDATE_ONLY" = true ]; then + operation_mode=update + recovery_prefix="$recovery_prefix --update" + fi + recovery_prefix="$recovery_prefix --recover" + if [ -n "$RECOVER_ID" ]; then + agmsg_install_op_pending_recover "$pending" "$RECOVER_ID" install "$operation_mode" || return 1 + RECOVER_ID="" + elif [ -e "$pending" ] || [ -L "$pending" ]; then + agmsg_install_op_pending_refuse "$pending" "$recovery_prefix" install "$operation_mode" + return 1 + fi + + AGMSG_INSTALL_OP_MARKER="$pending" + agmsg_install_op_pending_begin "$pending" install "$SKILL_DIR" "" "$operation_mode" || return 1 + + if [ "${UPDATE_ONLY:-false}" = true ] && [ ! -f "$SKILL_DIR/.agmsg" ]; then + echo " ! the selected installation was removed before the update could acquire its lock" >&2 + return 1 + fi + + # Validate the prior generation before making any installation change. A + # first install is generation 1 only when neither completion file exists. + if ! AGMSG_INSTALL_GEN="$(agmsg_install_manifest_next_gen "$manifest")"; then + return 1 + fi + agmsg_install_op_require || return 1 + AGMSG_INSTALL_ID="$(agmsg_install_db_ensure_meta "$install_db")" || return 1 + agmsg_install_op_require || return 1 + if [ -z "$AGMSG_INSTALL_ID" ]; then + echo " ! could not read or create install.db's meta row" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + if ! agmsg_install_manifest_rotate_prev "$manifest"; then + echo " ! could not move the previous completion record aside" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + AGMSG_INSTALL_MANIFEST="$manifest" + return 0 +} + +agmsg_install_operation_finish() { + if [ -z "${AGMSG_INSTALL_BOOTSTRAP_VERSION:-}" ]; then + echo " ! the installed bootstrap version was not verified; refusing to write a completion record" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + if ! agmsg_install_manifest_write \ + "$SKILL_DIR/scripts" "$AGMSG_INSTALL_MANIFEST" "$INSTALLED_VERSION" \ + "$AGMSG_INSTALL_ID" "$AGMSG_INSTALL_GEN" "$AGMSG_INSTALL_BOOTSTRAP_VERSION"; then + echo " ! could not write the new completion record; the previous install stays in place (see .prev)" >&2 + return 1 + fi + agmsg_install_op_require || return 1 + agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" || { + echo " ! could not clear the completed-operation record; later changes are blocked pending recovery" >&2 + return 1 + } + trap - EXIT + trap - INT TERM + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + unset AGMSG_INSTALL_ID AGMSG_INSTALL_GEN AGMSG_INSTALL_MANIFEST AGMSG_INSTALL_BOOTSTRAP_VERSION AGMSG_INSTALL_OP_ID AGMSG_INSTALL_OP_MARKER + return 0 +} + +# The scripts/ copy runs inside the install transaction opened above. It does +# not acquire or release the lock, and it does not publish the completion +# manifest; the caller does that only after every install-side write is done. +# +# Requires (globals the caller must already have set): SCRIPT_DIR, SKILL_DIR, +# TRASH_DIR (agmsg_reset_trash already run on it), INSTALLED_VERSION, plus an +# active transaction from agmsg_install_operation_begin. +# Updates AGMSG_TRASH_COUNT via the existing backup/prune helpers, same as +# before this existed. +# +# Lock ordering (2026-09-29 design decision): registry lock -> this +# install operation lock -> team store lock, never the reverse. This +# function never acquires a registry or team-store lock itself, so it +# cannot invert that order on its own; it is documented here as the +# constraint any future caller that nests this inside one of those must not +# break. +agmsg_install_copy_scripts() { + local stage_dir="" + local daemon_files="agmsgd agmsgd-launch.sh" + local f + + # Exclude the two rename-placed files from the bulk copy by giving cp -R a + # PRIVATE STAGING COPY of scripts/ with those two removed, rather than + # temporarily removing them from $SCRIPT_DIR itself. The first version of + # this function did the latter -- hid them from $SCRIPT_DIR, restored them via + # an EXIT trap -- and it was genuinely unsafe: $SCRIPT_DIR is the same, + # SHARED, live checkout every install.sh invocation reads from, so a + # second install run against it (even sequentially, one finishing before + # the next starts) could observe the files gone if anything landed between + # the hide and the restore. Measured: a two-install-in-a-row test hit + # exactly this and failed to place scripts/daemon/agmsgd. The staging copy + # never touches $SCRIPT_DIR at all, so there is nothing to race. + # + # agmsg_stage_overwrite_backups / agmsg_prune_removed_scripts still read + # the REAL $SCRIPT_DIR/scripts (not the staging copy) for "what does this + # release ship" -- they only ever READ that tree, never write it, and the + # two daemon files genuinely ARE shipped (just placed a different way), so + # excluding them from THAT membership list would wrongly trash a prior + # install's copies of them. + # Cleaned up explicitly at each exit point below, not via a RETURN trap: + # a RETURN trap set inside this function does NOT clear itself when the + # function returns -- it stays armed for the rest of the calling script + # and fires again on the NEXT function return or `source` anywhere later, + # by which point stage_dir (a local of THIS call) reads as empty in that + # unrelated context. Measured directly: a trap set this way fired a + # second time after an unrelated `source` command completed, well after + # this function had already returned. + stage_dir="$(mktemp -d)" || return 1 + + if ! cp -R "$SCRIPT_DIR/scripts/." "$stage_dir/"; then + rm -rf "$stage_dir" 2>/dev/null + echo " ! could not stage scripts/ for copying; the previous install stays in place (see .prev)" >&2 + return 1 + fi + for f in $daemon_files; do + rm -f "$stage_dir/daemon/$f" + done + + local copy_rc=0 + # Backups and the staged copy are one scripts-copy phase. The following + # prune phase rechecks the lock before it can remove anything from the target. + agmsg_install_op_phase_begin || copy_rc=1 + if [ "$copy_rc" -eq 0 ]; then + agmsg_stage_overwrite_backups "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" || copy_rc=1 + fi + if [ "$copy_rc" -eq 0 ]; then + agmsg_install_op_run_writer cp -R "$stage_dir/." "$SKILL_DIR/scripts/" || copy_rc=1 + fi + agmsg_install_op_phase_end + if [ "$copy_rc" -eq 0 ]; then + agmsg_install_op_run_phase agmsg_prune_removed_scripts \ + "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" || copy_rc=1 + fi + rm -rf "$stage_dir" 2>/dev/null + + if [ "$copy_rc" -ne 0 ]; then + echo " ! scripts/ copy failed; the previous install stays in place (see .prev)" >&2 + return 1 + fi + + agmsg_install_op_phase_begin || return 1 + mkdir -p "$SKILL_DIR/scripts/daemon" || return 1 + for f in $daemon_files; do + if ! agmsg_install_place_daemon_file \ + "$SCRIPT_DIR/scripts/daemon/$f" "$SKILL_DIR/scripts/daemon/$f"; then + echo " ! could not place scripts/daemon/$f; the previous install stays in place (see .prev)" >&2 + return 1 + fi + done + agmsg_install_op_phase_end + + # agmsgd and agmsgd-launch.sh are a fixed-shape pair that must carry the + # SAME bootstrap version (agmsgd beta) -- a mismatch + # here means the two files this install just placed together are already + # inconsistent with each other, which the manifest must never assert as a + # single agreed value. Extracted by exact pattern from each file's own + # embedded constant (see their own headers for why the pattern is pinned). + local launch_bv entry_bv + launch_bv="$(grep -m1 '^BOOTSTRAP_VERSION=' "$SKILL_DIR/scripts/daemon/agmsgd-launch.sh" 2>/dev/null | cut -d= -f2)" + entry_bv="$(grep -m1 '^const BOOTSTRAP_VERSION = ' "$SKILL_DIR/scripts/daemon/agmsgd" 2>/dev/null | sed 's/^const BOOTSTRAP_VERSION = \([0-9]*\);*$/\1/')" + if [ -z "$launch_bv" ] || [ -z "$entry_bv" ] || [ "$launch_bv" != "$entry_bv" ]; then + echo " ! agmsgd and agmsgd-launch.sh disagree on (or are missing) BOOTSTRAP_VERSION" >&2 + return 1 + fi + AGMSG_INSTALL_BOOTSTRAP_VERSION="$launch_bv" + return 0 +} + +agmsg_install_optional_copy() { + cp "$@" 2>/dev/null || true +} + +agmsg_install_optional_uninstaller_copy() { + cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && \ + chmod +x "$SKILL_DIR/uninstall.sh" 2>/dev/null || true +} + +agmsg_install_optional_codex_chmod() { + chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true +} + +agmsg_install_refresh_codex_shim() { + AGMSG_CODEX_SHIM_REFRESHED=false + if env AGMSG_CODEX_SHIM_INSTALL_QUIET=1 AGMSG_CODEX_SHIM_FORCE="${CODEX_SHIM_FORCE:-}" \ + "$CODEX_SHIM" install >/dev/null; then + AGMSG_CODEX_SHIM_REFRESHED=true + fi + return 0 +} + +agmsg_install_write_version() { + printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" +} + +agmsg_install_touch_marker_and_write_version() { + touch "$SKILL_DIR/.agmsg" || return 1 + agmsg_install_write_version +} + +agmsg_remove_retired_terminal_skills() { + local driver + for driver in herdr plain tmux; do + rm -f "$SKILL_DIR/scripts/drivers/terminals/$driver/SKILL.md" + done +} + +agmsg_install_place_daemon_file() { + agmsg_atomic_place_file "$1" "$2" || return 1 + chmod +x "$2" 2>/dev/null || true } # Put at , then remove any leftover . The arm is chosen by @@ -264,11 +558,13 @@ agmsg_reset_trash() { # POSIX tools), but the exposure is confined to symlink users, whose target # is typically a version-controlled dotfile. move_into_place() { + agmsg_install_op_require || return 1 if [ -L "$2" ]; then cat "$1" > "$2" && rm -f "$1" else mv "$1" "$2" fi + agmsg_install_op_require } # Adds this install's writable_paths (below) to ONE Codex config.toml. @@ -296,7 +592,9 @@ _configure_codex_sandbox_file() { return 0 fi + agmsg_install_op_require || return 1 cp "$code_config" "$code_config.bak" + agmsg_install_op_require || return 1 echo " ~ backed up $code_config → $code_config.bak" local entries inserts @@ -309,6 +607,7 @@ _configure_codex_sandbox_file() { # uniformly valid TOML for empty ([]), single-line and multiline arrays — # trailing commas are legal — and avoids the leading/double-comma corruption # that munging the closing ']' produced for an empty array (`[, "x"]`). + agmsg_install_op_require || return 1 awk -v ins="$inserts" ' !done && /writable_roots[[:space:]]*=[[:space:]]*\[/ { sub(/\[/, "[" ins) @@ -318,18 +617,23 @@ _configure_codex_sandbox_file() { ' "$code_config" > "$code_config.tmp" && move_into_place "$code_config.tmp" "$code_config" elif grep -q '^\[sandbox_workspace_write\]' "$code_config" 2>/dev/null; then # Section exists but no writable_roots + agmsg_install_op_require || return 1 awk -v entries="$entries" ' { print } /^\[sandbox_workspace_write\]/ { print "writable_roots = [" entries "]" } ' "$code_config" > "$code_config.tmp" && move_into_place "$code_config.tmp" "$code_config" else # No section at all + agmsg_install_op_require || return 1 printf '\n[sandbox_workspace_write]\nwritable_roots = [%s]\n' "$entries" >> "$code_config" + agmsg_install_op_require || return 1 fi + agmsg_install_op_require || return 1 echo " + added Codex writable_roots for db/, teams/, run/, and ext-tools/ ($code_config)" } configure_codex_sandbox() { + agmsg_install_op_require || return 1 # --- Configure Codex sandbox (if Codex is installed) --- # The Codex bridge writes pidfiles/sockets/request files under the # skill's db/, teams/, run/ dirs; Codex's sandbox blocks those writes unless @@ -381,6 +685,7 @@ configure_codex_sandbox() { for cfg in "${codex_configs[@]}"; do _configure_codex_sandbox_file "$cfg" "${writable_paths[@]}" done + agmsg_install_op_require } is_windows_host() { @@ -395,26 +700,36 @@ is_windows_host() { } install_windows_helpers() { + agmsg_install_op_require || return 1 if ! is_windows_host; then return 0 fi + agmsg_install_op_require || return 1 mkdir -p "$AGENTS_DIR" # Clean up legacy helpers created by the earlier native-Windows approaches. local ps_shortcut="$AGENTS_DIR/$CMD_NAME.ps1" if [ -f "$ps_shortcut" ] && grep -q "PowerShell shortcut for agmsg on native Windows" "$ps_shortcut" 2>/dev/null; then + agmsg_install_op_require || return 1 rm -f "$ps_shortcut" + agmsg_install_op_require || return 1 fi + agmsg_install_op_require || return 1 rm -f "$AGENTS_DIR/$CMD_NAME-run.sh" + agmsg_install_op_require || return 1 local sqlite_shim="$AGENTS_DIR/bin/sqlite3" local removed_sqlite_shim=false if [ -f "$sqlite_shim" ] && grep -q "sqlite3 compatibility shim for agmsg" "$sqlite_shim" 2>/dev/null; then + agmsg_install_op_require || return 1 rm -f "$sqlite_shim" + agmsg_install_op_require || return 1 removed_sqlite_shim=true fi if [ "$removed_sqlite_shim" = true ]; then + agmsg_install_op_require || return 1 rm -f "$AGENTS_DIR/run/sqlite3-shim.cache" + agmsg_install_op_require || return 1 fi } @@ -425,7 +740,9 @@ install_antigravity_tui_shim() { target_dir="$(dirname "$target")" owner="# agmsg-shim-owner: $source" expected_owner="" + agmsg_install_op_require || return 1 mkdir -p "$target_dir" + agmsg_install_op_require || return 1 if [ -e "$target" ] || [ -L "$target" ]; then expected_owner="$(grep '^# agmsg-shim-owner: ' "$target" 2>/dev/null || true)" if ! grep -q '^# agmsg Antigravity TUI launcher shim$' "$target" 2>/dev/null; then @@ -447,7 +764,9 @@ install_antigravity_tui_shim() { printf 'exec bash %s "$@"\n' "$quoted_source" } > "$tmp" chmod +x "$tmp" + agmsg_install_op_require || { rm -f "$tmp"; return 1; } mv "$tmp" "$target" + agmsg_install_op_require || return 1 if [ -n "$expected_owner" ]; then echo " + refreshed Antigravity TUI shim (~/.agents/bin/agy-tui)" else @@ -456,6 +775,7 @@ install_antigravity_tui_shim() { } install_antigravity_skill() { + agmsg_install_op_require || return 1 # Antigravity looks for global skills under ~/.gemini/config/skills, not the # cross-vendor ~/.agents/skills tree. Treat either of the installed agy # markers as evidence that this destination is available; the config tree @@ -464,8 +784,11 @@ install_antigravity_skill() { return 0 fi local skill_dir="$HOME/.gemini/config/skills/$CMD_NAME" + agmsg_install_op_require || return 1 mkdir -p "$skill_dir" + agmsg_install_op_require || return 1 agmsg_render_skill antigravity "$CMD_NAME" "$skill_dir/SKILL.md" + agmsg_install_op_require || return 1 echo " + installed /$CMD_NAME skill to ~/.gemini/config/skills/" } @@ -473,6 +796,7 @@ install_antigravity_skill() { while [[ $# -gt 0 ]]; do case "$1" in --cmd) CMD_NAME="$2"; INTERACTIVE=false; shift 2 ;; + --recover) RECOVER_ID="$2"; INTERACTIVE=false; shift 2 ;; --agent-type) AGENT_TYPE="$2"; shift 2 ;; --update) UPDATE_ONLY=true; shift ;; -h|--help) @@ -488,6 +812,7 @@ while [[ $# -gt 0 ]]; do echo " they have their own skill file." echo " ( matches the type arg passed to join.sh / whoami.sh)" echo " --update Update skill scripts only (preserve DB and teams)" + echo " --recover Clear a verified incomplete operation, then continue this install" echo "" echo "After install, join a team per-project:" echo " ~/.agents/skills//scripts/join.sh " @@ -498,6 +823,11 @@ while [[ $# -gt 0 ]]; do esac done +if [ -n "$RECOVER_ID" ] && [ -z "$CMD_NAME" ]; then + echo " ! --recover requires --cmd to select one installation" >&2 + exit 1 +fi + # Force non-interactive when stdin is not a terminal. Without this, the # command-name prompt below would call `read -r` on whatever stream is wired # to fd 0 — which for `curl ... | bash`-style entry paths (e.g. the npm @@ -578,6 +908,9 @@ if [ "$UPDATE_ONLY" = true ]; then SKILL_NAME="$(basename "$SKILL_DIR")" CMD_NAME="$SKILL_NAME" echo " Updating $SKILL_NAME..." + INSTALLED_VERSION="$(agmsg_source_version)" + agmsg_install_operation_begin || exit 1 + agmsg_install_op_run_phase agmsg_install_write_recovery_helper || exit 1 # #963: a sync engine that is running when the write below starts either # survives on the code it already loaded (silent -- `remote.sh status` still # reports it as running, and nothing about the new scripts takes effect) or @@ -649,16 +982,16 @@ $_agmsg_running_team" ;; esac unset _agmsg_explicit_agent_type _agmsg_detected_type - agmsg_render_skill "$TPL_TYPE" "$SKILL_NAME" "$SKILL_DIR/SKILL.md" + agmsg_install_op_run_phase agmsg_render_skill "$TPL_TYPE" "$SKILL_NAME" "$SKILL_DIR/SKILL.md" || exit 1 TRASH_DIR="$SKILL_DIR/.trash" AGMSG_TRASH_COUNT=0 - agmsg_reset_trash "$TRASH_DIR" - agmsg_stage_overwrite_backups "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" + agmsg_install_op_run_phase agmsg_reset_trash "$TRASH_DIR" || exit 1 # Recursive copy so nested helper dirs (scripts/lib/, scripts/drivers/types/) # ship without enumerating files. The agent-type manifests and per-type runtimes # live under scripts/drivers/types/ now, so this single copy carries them too. - cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" - agmsg_prune_removed_scripts "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" + # Runs under the install operation lock. The completion manifest is written + # only after the remaining install updates below are complete. + agmsg_install_copy_scripts || exit 1 echo " ~ $AGMSG_TRASH_COUNT file(s) backed up to .trash/ (cleared on next upgrade)" # #1249: drivers/terminals/{herdr,plain,tmux}/SKILL.md used to name each # driver's own doc file, and a directory-scanning skill loader (e.g. @@ -672,24 +1005,24 @@ $_agmsg_running_team" # scripts/drivers/terminals/ (nothing about that path is exclusive to # agmsg's own three); a glob there would delete a file this install # does not own (#1249 review). - for _agmsg_builtin_driver in herdr plain tmux; do - rm -f "$SKILL_DIR/scripts/drivers/terminals/$_agmsg_builtin_driver/SKILL.md" - done - unset _agmsg_builtin_driver + agmsg_install_op_run_phase agmsg_remove_retired_terminal_skills || exit 1 # Ship the external-plugin drop-in dir (just its README) so the location exists # post-install. A plain cp — not cp -R --delete — preserves any plugins the # user dropped in and their db/trusted-plugins opt-ins. - mkdir -p "$SKILL_DIR/plugins" - cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || true + agmsg_install_op_phase_begin || exit 1 + mkdir -p "$SKILL_DIR/plugins" || exit 1 + agmsg_install_optional_copy "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" # Ship uninstall.sh alongside the skill itself — npx/curl installs fetch a # temp checkout that gets deleted right after install, so without this copy # those users would have no local uninstaller to run later (only a manual # `git clone` checkout would). See the README's Uninstall section. - cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && chmod +x "$SKILL_DIR/uninstall.sh" || true + agmsg_install_optional_uninstaller_copy + agmsg_install_op_phase_end || exit 1 # Refresh the Claude Code slash command file (was missed in earlier --update flows). + agmsg_install_op_phase_begin || exit 1 CC_COMMANDS_DIR="$HOME/.claude/commands" if [ -d "$CC_COMMANDS_DIR" ] && [ -f "$CC_COMMANDS_DIR/$SKILL_NAME.md" ]; then - agmsg_render_skill claude-code "$SKILL_NAME" "$CC_COMMANDS_DIR/$SKILL_NAME.md" + agmsg_render_skill claude-code "$SKILL_NAME" "$CC_COMMANDS_DIR/$SKILL_NAME.md" || exit 1 fi # Refresh / install the Copilot CLI skill (Copilot reads SKILL.md from its # own skills dir; the shared ~/.agents/skills//SKILL.md is @@ -698,29 +1031,31 @@ $_agmsg_running_team" # from a pre-Copilot release via --update also gain the skill. COPILOT_SKILL_DIR="$HOME/.copilot/skills/$SKILL_NAME" if [ -d "$HOME/.copilot" ]; then - mkdir -p "$COPILOT_SKILL_DIR" - agmsg_render_skill copilot "$SKILL_NAME" "$COPILOT_SKILL_DIR/SKILL.md" + mkdir -p "$COPILOT_SKILL_DIR" || exit 1 + agmsg_render_skill copilot "$SKILL_NAME" "$COPILOT_SKILL_DIR/SKILL.md" || exit 1 fi # Refresh / install the OpenCode skill (same reasoning as Copilot above). OPENCODE_SKILL_DIR="$HOME/.config/opencode/skills/$SKILL_NAME" if [ -d "$HOME/.config/opencode" ]; then - mkdir -p "$OPENCODE_SKILL_DIR" - agmsg_render_skill opencode "$SKILL_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" + mkdir -p "$OPENCODE_SKILL_DIR" || exit 1 + agmsg_render_skill opencode "$SKILL_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" || exit 1 fi # Refresh / install the Hermes Agent skill (same reasoning as Copilot above). HERMES_SKILL_DIR="$HOME/.hermes/skills/$SKILL_NAME" if [ -d "$HOME/.hermes" ]; then - mkdir -p "$HERMES_SKILL_DIR" - agmsg_render_skill hermes "$SKILL_NAME" "$HERMES_SKILL_DIR/SKILL.md" + mkdir -p "$HERMES_SKILL_DIR" || exit 1 + agmsg_render_skill hermes "$SKILL_NAME" "$HERMES_SKILL_DIR/SKILL.md" || exit 1 fi # Refresh / install the Grok Build skill (same reasoning as Copilot above). GROK_SKILL_DIR="$HOME/.grok/skills/$SKILL_NAME" if [ -d "$HOME/.grok" ]; then - mkdir -p "$GROK_SKILL_DIR" - agmsg_render_skill grok-build "$SKILL_NAME" "$GROK_SKILL_DIR/SKILL.md" + mkdir -p "$GROK_SKILL_DIR" || exit 1 + agmsg_render_skill grok-build "$SKILL_NAME" "$GROK_SKILL_DIR/SKILL.md" || exit 1 fi - install_antigravity_skill - cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true + install_antigravity_skill || exit 1 + agmsg_install_op_phase_end || exit 1 + agmsg_install_op_phase_begin || exit 1 + agmsg_install_optional_copy "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" # A team config written by an older release can be group- or world-writable, # and the sync engine refuses to read one that is (#804). Upgrading does not # rewrite files that already exist, so without this the release we are asking @@ -791,9 +1126,9 @@ $_agmsg_running_team" done || true unset -f agmsg_shq fi - chmod +x "$SKILL_DIR/scripts/"*.sh - chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true - install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" + chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 + agmsg_install_optional_codex_chmod + install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update # cp's the new codex-shim-install.sh but does not re-run it, so a shim from an # older install keeps its stale baked exec path after the @@ -833,16 +1168,17 @@ $_agmsg_running_team" if printf '%s' "$CODEX_SHIM_STATUS" | grep -q '^installed:'; then CODEX_SHIM_FORCE="" [ "$CMD_WAS_EXPLICIT" = true ] && CODEX_SHIM_FORCE=1 - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 AGMSG_CODEX_SHIM_FORCE="$CODEX_SHIM_FORCE" "$CODEX_SHIM" install >/dev/null; then + agmsg_install_refresh_codex_shim || exit 1 + if [ "$AGMSG_CODEX_SHIM_REFRESHED" = true ]; then echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" fi fi - install_windows_helpers - INSTALLED_VERSION="$(agmsg_source_version)" - printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" + install_windows_helpers || exit 1 + agmsg_install_op_phase_end || exit 1 + agmsg_install_op_run_phase agmsg_install_write_version || exit 1 echo " + updated scripts, templates, and SKILL.md (version $INSTALLED_VERSION)" echo " ~ DB and team configs preserved" - configure_codex_sandbox + agmsg_install_op_run_phase configure_codex_sandbox || exit 1 echo "" echo " ! Restart any running agent sessions to pick up the updated scripts." echo " In-flight watch.sh processes detect this and stand down on their own;" @@ -853,7 +1189,7 @@ $_agmsg_running_team" if [ -n "$AGMSG_RUNNING_TEAMS" ]; then while IFS= read -r _agmsg_team; do [ -n "$_agmsg_team" ] || continue - if ! "$SKILL_DIR/scripts/remote.sh" sync restart "$_agmsg_team"; then + if ! agmsg_install_op_run_phase "$SKILL_DIR/scripts/remote.sh" sync restart "$_agmsg_team"; then echo " ! could not restart the sync engine for '$_agmsg_team'; run:" >&2 echo " bash $SKILL_DIR/scripts/remote.sh sync restart $_agmsg_team" >&2 fi @@ -872,6 +1208,7 @@ $_agmsg_running_team" echo " a project's settings, silently stopping delivery until it is re-registered." echo " Check with 'delivery.sh status '. (#133)" echo "" + agmsg_install_operation_finish || exit 1 echo " ✓ Update complete" echo "" exit 0 @@ -889,10 +1226,13 @@ fi # --- Apply defaults --- CMD_NAME="${CMD_NAME:-agmsg}" SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" +INSTALLED_VERSION="$(agmsg_source_version)" +agmsg_install_operation_begin || exit 1 +agmsg_install_op_run_phase agmsg_install_write_recovery_helper || exit 1 # --- Install skill --- echo " Installing to ~/.agents/skills/$CMD_NAME/ ..." -mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} +agmsg_install_op_run_phase mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} || exit 1 # SKILL.md is composed from the shared root and the agent-specific overlay # resolved from the type manifest (scripts/drivers/types//template.md). @@ -915,32 +1255,33 @@ case " $AGMSG_TYPES_WITH_OWN_SKILL_FILE " in esac ;; esac -agmsg_render_skill "$TPL_TYPE" "$CMD_NAME" "$SKILL_DIR/SKILL.md" +agmsg_install_op_run_phase agmsg_render_skill "$TPL_TYPE" "$CMD_NAME" "$SKILL_DIR/SKILL.md" || exit 1 TRASH_DIR="$SKILL_DIR/.trash" AGMSG_TRASH_COUNT=0 -agmsg_reset_trash "$TRASH_DIR" -agmsg_stage_overwrite_backups "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" +agmsg_install_op_run_phase agmsg_reset_trash "$TRASH_DIR" || exit 1 # Recursive copy so nested helper dirs (scripts/lib/, scripts/drivers/types/) ship # without enumerating files. The agent-type manifests and per-type runtimes live # under scripts/drivers/types/ now, so this single copy carries them too. -cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" -agmsg_prune_removed_scripts "$SCRIPT_DIR/scripts" "$SKILL_DIR/scripts" "$TRASH_DIR" +# Runs under the install operation lock. The completion manifest is written +# only after the remaining install updates below are complete. +agmsg_install_copy_scripts || exit 1 echo " ~ $AGMSG_TRASH_COUNT file(s) backed up to .trash/ (cleared on next upgrade)" # Ship the external-plugin drop-in dir (just its README) so the location exists # post-install. A plain cp — not cp -R --delete — preserves any plugins the user # dropped in and their db/trusted-plugins opt-ins. -mkdir -p "$SKILL_DIR/plugins" -cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || true +agmsg_install_op_phase_begin || exit 1 +mkdir -p "$SKILL_DIR/plugins" || exit 1 +agmsg_install_optional_copy "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" # Ship uninstall.sh alongside the skill itself — npx/curl installs fetch a # temp checkout that gets deleted right after install, so without this copy # those users would have no local uninstaller to run later (only a manual # `git clone` checkout would). See the README's Uninstall section. -cp "$SCRIPT_DIR/uninstall.sh" "$SKILL_DIR/uninstall.sh" 2>/dev/null && chmod +x "$SKILL_DIR/uninstall.sh" || true +agmsg_install_optional_uninstaller_copy -cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true -chmod +x "$SKILL_DIR/scripts/"*.sh -chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true -install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" +agmsg_install_optional_copy "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" +chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 +agmsg_install_optional_codex_chmod +install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Re-point an existing Codex monitor shim at the new path on a reinstall over an # older layout (no-op when no agmsg shim is present). See the --update block # above. NOT forced (#553): unlike --update, a fresh install here gives no @@ -957,22 +1298,22 @@ if printf '%s' "$CODEX_SHIM_STATUS" | grep -q '^installed:'; then # current owner and the exact consequence of forcing -- repeating a # shorter, separate version of that here would risk saying something # different from what actually happens. - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null; then + CODEX_SHIM_FORCE="" + agmsg_install_refresh_codex_shim || exit 1 + if [ "$AGMSG_CODEX_SHIM_REFRESHED" = true ]; then echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" fi fi -install_windows_helpers +install_windows_helpers || exit 1 +agmsg_install_op_phase_end || exit 1 -# Marker file for uninstall detection -touch "$SKILL_DIR/.agmsg" - -# Record the provenance version of the source we installed from (see #117). -INSTALLED_VERSION="$(agmsg_source_version)" -printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" +# Marker file for uninstall detection and source provenance version are one +# protected phase so neither write can occur after a lost-lock boundary. +agmsg_install_op_run_phase agmsg_install_touch_marker_and_write_version || exit 1 # Initialize DB if [ ! -f "$SKILL_DIR/db/messages.db" ]; then - bash "$SKILL_DIR/scripts/internal/init-db.sh" + agmsg_install_op_run_phase bash "$SKILL_DIR/scripts/internal/init-db.sh" || exit 1 fi # Nothing moves stores here. Installing must not change where a team's messages @@ -983,15 +1324,16 @@ fi # Initialize config if [ ! -f "$SKILL_DIR/db/config.yaml" ]; then - bash "$SKILL_DIR/scripts/config.sh" show >/dev/null + agmsg_install_op_run_phase bash "$SKILL_DIR/scripts/config.sh" show >/dev/null || exit 1 echo " + created default config at db/config.yaml" fi # --- Install Claude Code global command --- +agmsg_install_op_phase_begin || exit 1 CC_COMMANDS_DIR="$HOME/.claude/commands" if [ -d "$HOME/.claude" ]; then - mkdir -p "$CC_COMMANDS_DIR" - agmsg_render_skill claude-code "$CMD_NAME" "$CC_COMMANDS_DIR/$CMD_NAME.md" + mkdir -p "$CC_COMMANDS_DIR" || exit 1 + agmsg_render_skill claude-code "$CMD_NAME" "$CC_COMMANDS_DIR/$CMD_NAME.md" || exit 1 echo " + installed /$CMD_NAME command to ~/.claude/commands/" fi @@ -1001,8 +1343,8 @@ fi # would mis-identify a Copilot session — keep the Copilot copy separate. COPILOT_SKILL_DIR="$HOME/.copilot/skills/$CMD_NAME" if [ -d "$HOME/.copilot" ]; then - mkdir -p "$COPILOT_SKILL_DIR" - agmsg_render_skill copilot "$CMD_NAME" "$COPILOT_SKILL_DIR/SKILL.md" + mkdir -p "$COPILOT_SKILL_DIR" || exit 1 + agmsg_render_skill copilot "$CMD_NAME" "$COPILOT_SKILL_DIR/SKILL.md" || exit 1 echo " + installed /$CMD_NAME skill to ~/.copilot/skills/" fi @@ -1013,8 +1355,8 @@ fi # copy separate, same pattern as Copilot. OPENCODE_SKILL_DIR="$HOME/.config/opencode/skills/$CMD_NAME" if [ -d "$HOME/.config/opencode" ]; then - mkdir -p "$OPENCODE_SKILL_DIR" - agmsg_render_skill opencode "$CMD_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" + mkdir -p "$OPENCODE_SKILL_DIR" || exit 1 + agmsg_render_skill opencode "$CMD_NAME" "$OPENCODE_SKILL_DIR/SKILL.md" || exit 1 echo " + installed \$$CMD_NAME skill to ~/.config/opencode/skills/" fi @@ -1025,8 +1367,8 @@ fi # (manual inbox checks only), but the skill itself installs the same way. HERMES_SKILL_DIR="$HOME/.hermes/skills/$CMD_NAME" if [ -d "$HOME/.hermes" ]; then - mkdir -p "$HERMES_SKILL_DIR" - agmsg_render_skill hermes "$CMD_NAME" "$HERMES_SKILL_DIR/SKILL.md" + mkdir -p "$HERMES_SKILL_DIR" || exit 1 + agmsg_render_skill hermes "$CMD_NAME" "$HERMES_SKILL_DIR/SKILL.md" || exit 1 echo " + installed /$CMD_NAME skill to ~/.hermes/skills/" fi @@ -1040,8 +1382,8 @@ fi # hooks_file; see grok-build/_delivery.sh). GROK_SKILL_DIR="$HOME/.grok/skills/$CMD_NAME" if [ -d "$HOME/.grok" ]; then - mkdir -p "$GROK_SKILL_DIR" - agmsg_render_skill grok-build "$CMD_NAME" "$GROK_SKILL_DIR/SKILL.md" + mkdir -p "$GROK_SKILL_DIR" || exit 1 + agmsg_render_skill grok-build "$CMD_NAME" "$GROK_SKILL_DIR/SKILL.md" || exit 1 echo " + installed /$CMD_NAME skill to ~/.grok/skills/" fi @@ -1049,7 +1391,8 @@ fi # Antigravity (agy) reads global skills from ~/.gemini/config/skills//. # Its CLI may create ~/.gemini/antigravity-cli before the config directory, so # either path is a sufficient installation signal. -install_antigravity_skill +install_antigravity_skill || exit 1 +agmsg_install_op_phase_end || exit 1 # Codex sandbox writable_roots are configured by configure_codex_sandbox() at # the "Done" step below — the single source of truth for db/, teams/, and run/. @@ -1057,7 +1400,10 @@ install_antigravity_skill # produced invalid TOML on a fresh install; it has been removed.) # --- Done --- -configure_codex_sandbox +agmsg_install_op_phase_begin || exit 1 +configure_codex_sandbox || exit 1 +agmsg_install_op_phase_end || exit 1 +agmsg_install_operation_finish || exit 1 echo "" echo " ✓ Installed to ~/.agents/skills/$CMD_NAME/ (version $INSTALLED_VERSION)" echo "" diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index dc8ab5673..9b556e632 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -58,6 +58,7 @@ async function main() { const expectedOpGen = Number(opGenStr); const lockPath = join(installRoot, "run", "install-op.lock.db"); + const incompleteOperationPath = join(installRoot, "run", "install-op-incomplete.json"); const manifestPath = join(installRoot, "run", "install-manifest.json"); const dbPath = join(installRoot, "run", "install.db"); @@ -66,6 +67,10 @@ async function main() { let manifest; let mainModule; try { + if (existsSync(incompleteOperationPath)) { + fail(75, "an install or uninstall operation is incomplete; recover it before starting agmsgd"); + } + // 1. An install generation exists at all. if (!existsSync(manifestPath)) { fail(0, "no completion record -- install.sh needs to run again"); diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 41e29d0b4..843dce0f4 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -5,9 +5,8 @@ # otherwise send-side only and never runs actas-claim, so without this a codex # role would have no role-session record and could never be resumed (spawn would # always boot it fresh). This is the codex-side equivalent: the codex actas flow -# calls it, and it writes the thread plus the effective CODEX_HOME so a later -# spawn/resume brings the role back into its thread and profile. The /clear -# recovery in self-fix.sh calls this same script after #1470 rebinds the seat. +# calls it, and it writes the record so a later spawn/resume brings the role back +# into its thread. # # Usage: codex-record-session.sh [project] # @@ -161,28 +160,6 @@ if [ "$probe_ran" = "1" ]; then [ -n "$thread" ] || exit 0 fi -# Resolve the effective profile before either fallback can infer a thread. The -# rollout index belongs to CODEX_HOME, not necessarily to the process HOME. -# Codex defaults to $HOME/.codex when CODEX_HOME is unset; resolve either -# spelling to a physical absolute path so discovery and the stored destination -# use the same profile. A missing or malformed directory is not safe to publish -# as a delivery destination, so leave the previous record untouched. -codex_home="${CODEX_HOME:-}" -if [ -z "$codex_home" ]; then - [ -n "${HOME:-}" ] || exit 0 - codex_home="$HOME/.codex" -fi -case "$codex_home" in *[[:cntrl:]]*) exit 0 ;; esac -[ -d "$codex_home" ] || exit 0 -codex_home="$(agmsg_canonical_path "$codex_home")" -# Keep the absolute path in the cross-platform form used by Node consumers; -# Git Bash's physical /c/... spelling is normalized to C:/... on Windows. -codex_home="$(agmsg_normalize_project_path "$codex_home")" -case "$codex_home" in - /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; - *) exit 0 ;; -esac - if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside # monitor mode, a missing Node, a server that is not answering. The rollout scan @@ -190,8 +167,10 @@ if [ -z "$thread" ]; then # single-rollout case it always did, and on a project with history it records # nothing, which is what happens today. # - sessions_dir="$codex_home/sessions" - if [ -d "$sessions_dir" ]; then + # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the + # dir check below fails -> fresh), not an unbound-variable abort (nit). + sessions_dir="${HOME:-}/.codex/sessions" + if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then # Distinct thread ids whose session_meta cwd (canonicalized -- codex records # the physical cwd while agmsg may hold a symlinked path, #160) matches the # project, among the most recent rollouts. Exactly one => unambiguously ours. @@ -233,7 +212,7 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 631887950..2877f0382 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -18,7 +18,7 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do If argument starts with "actas" followed by an agent name: 1. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" __AGENT_TYPE__`. If `` is not listed, join with `~/.agents/skills/__SKILL_NAME__/scripts/join.sh __AGENT_TYPE__ "$(pwd)"`. -2. Record this Codex thread and its effective profile directory so a later spawn can resume it and route notices to the right profile: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. +2. Record the Codex thread so a later spawn can resume it: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. 3. Use the role as the active FROM; monitor delivery is routed only to its recorded thread. diff --git a/scripts/lib/install-db.sh b/scripts/lib/install-db.sh new file mode 100644 index 000000000..62d836be9 --- /dev/null +++ b/scripts/lib/install-db.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# install-db.sh — install.db's `meta` table. +# +# The single source of truth for this install's install_id: a fresh UUID, +# minted once by install.sh the first time install.db's meta row is +# created, and never changed afterward -- until an uninstall removes +# install.db outright, at which point the next install starts a genuinely +# new install_id. The completion manifest (install-manifest.sh) carries +# only a COPY of this value; it is never a second place that MINTS one +# (2026-09-29 design decision -- this replaced an earlier design where the +# manifest/.prev itself was the install_id's source). +# +# schema_version starts at 1 so a later migration has a value to gate a +# table-adding ALTER on. This script initially writes only +# `meta(schema_version, install_id)`; the Node/CLI-only columns +# (node_path, node_version) are added by whichever PR implements +# `enable`/`disable`. +# +# Required caller-set variable: none. Sources scripts/lib/compat.sh (for +# compat_uuid7) and scripts/lib/sqlite-output.sh (for normalized query output) +# if not already loaded. + +[ -n "${_AGMSG_INSTALL_DB_SH:-}" ] && return 0 +_AGMSG_INSTALL_DB_SH=1 + +if ! declare -F compat_uuid7 >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/compat.sh" +fi +if ! declare -F agmsg_sqlite_capture >/dev/null 2>&1; then + _agmsg_install_db_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + # shellcheck disable=SC1091 + . "$_agmsg_install_db_dir/sqlite-output.sh" + unset _agmsg_install_db_dir +fi + +# Ensures install.db exists with its meta row: creates the table if absent, +# and mints a fresh install_id (schema_version 1) only if the table has no +# row yet. An --update over an install.db that already has a meta row +# leaves its install_id exactly as it was -- this never overwrites an +# existing one. Prints the (possibly just-created) install_id on success; +# prints nothing and returns 1 on any sqlite failure. +agmsg_install_db_ensure_meta() { # + local db="$1" id + mkdir -p "$(dirname "$db")" 2>/dev/null || true + if ! sqlite3 "$db" \ + "CREATE TABLE IF NOT EXISTS meta (schema_version INTEGER NOT NULL, install_id TEXT NOT NULL);" \ + 2>/dev/null; then + return 1 + fi + id="$(agmsg_sqlite_capture "$db" "SELECT install_id FROM meta LIMIT 1;")" || return 1 + if [ -z "$id" ]; then + id="$(compat_uuid7)" + if ! sqlite3 "$db" \ + "INSERT INTO meta (schema_version, install_id) VALUES (1, '$id');" \ + 2>/dev/null; then + return 1 + fi + fi + printf '%s\n' "$id" +} diff --git a/scripts/lib/install-manifest.sh b/scripts/lib/install-manifest.sh new file mode 100644 index 000000000..f41afef88 --- /dev/null +++ b/scripts/lib/install-manifest.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# install-manifest.sh — the install completion record (agmsgd beta). +# +# run/install-manifest.json names one completed install: which generation it +# is (install_id, gen), what version it shipped, and the path+sha256 digest +# of every file under scripts/ at the moment it was written. Its presence, +# absence, and .prev sibling are how a stale generation and a crashed +# mid-update are told apart -- see agmsg_install_manifest_next_gen. +# +# install_id itself is NOT this file's concern -- see install-db.sh. +# +# Required caller-set variable: none. Sources scripts/lib/sqlpath.sh (for +# agmsg_sql_readfile_path), scripts/lib/sqlite-output.sh (for normalized +# sqlite3 output), and scripts/lib/hash.sh (for agmsg_sha256) if not already +# loaded; all are small, dependency-light files, not the whole of storage.sh. + +[ -n "${_AGMSG_INSTALL_MANIFEST_SH:-}" ] && return 0 +_AGMSG_INSTALL_MANIFEST_SH=1 + +_agmsg_install_manifest_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +if ! declare -F agmsg_sql_readfile_path >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_manifest_dir/sqlpath.sh" +fi +if ! declare -F agmsg_sqlite_capture >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_manifest_dir/sqlite-output.sh" +fi +if ! declare -F agmsg_sha256 >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_manifest_dir/hash.sh" +fi +unset _agmsg_install_manifest_dir + +# Same '' -> '' doubling every other SQL-string-literal caller in this +# codebase uses (leave.sh's own _agmsg_sqlesc, storage.sh's agmsg_sqlesc). +# Defined locally rather than pulling in storage.sh, which this file has no +# other reason to depend on. +_agmsg_install_manifest_sqlesc() { + local value="${1:-}" quote="'" + printf '%s' "${value//$quote/$quote$quote}" +} + +# Hash the known-answer probe and a batch of files in one selected-tool +# invocation. This is deliberately local to the install manifest: the shared +# agmsg_sha256 API still self-tests each individual digest. File names are +# supplied by the package, but only batch them when every relative name and the +# temporary probe path use characters whose tool output is unambiguous. The +# caller falls back to agmsg_sha256 for any other name. +_agmsg_install_manifest_hash_batch() { # ... + local scripts_dir="${1:-}" tool="" kind="" probe="" output="" line digest count=0 + shift || return 1 + local -a file_args=() + [ -n "$scripts_dir" ] || return 1 + for line in "$@"; do + case "$line" in *[!A-Za-z0-9._/-]*) return 1 ;; esac + file_args+=("./$line") + done + + if command -v shasum >/dev/null 2>&1; then + tool="$(command -v shasum)" || return 1 + kind=shasum + elif command -v sha256sum >/dev/null 2>&1; then + tool="$(command -v sha256sum)" || return 1 + kind=sha256sum + elif command -v openssl >/dev/null 2>&1; then + tool="$(command -v openssl)" || return 1 + kind=openssl + else + echo "agmsg: no SHA-256 tool found on PATH (looked for shasum, sha256sum, openssl)." >&2 + return 1 + fi + + probe="$(mktemp)" || return 1 + case "$probe" in *[!A-Za-z0-9._/-]*) rm -f "$probe"; return 1 ;; esac + if ! printf '%s' probe > "$probe"; then + rm -f "$probe" + return 1 + fi + case "$kind" in + shasum) + output="$(cd "$scripts_dir" && "$tool" -a 256 - "${file_args[@]}" < "$probe")" || { + rm -f "$probe" + return 1 + } + ;; + sha256sum) + output="$(cd "$scripts_dir" && "$tool" - "${file_args[@]}" < "$probe")" || { + rm -f "$probe" + return 1 + } + ;; + openssl) + output="$(cd "$scripts_dir" && "$tool" dgst -sha256 - "${file_args[@]}" < "$probe")" || { + rm -f "$probe" + return 1 + } + ;; + esac + rm -f "$probe" + + while IFS= read -r line; do + line="${line%$'\r'}" + case "$kind" in + openssl) digest="${line##*= }" ;; + *) digest="${line%%[[:space:]]*}" ;; + esac + case "$digest" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#digest}" -eq 64 ] || return 1 + if [ "$count" -eq 0 ]; then + [ "$digest" = 'ba9c736f19e7f60b7f6764adb0b7908c0a2b394e09b6c09863528c7f2bc86095' ] || { + echo "agmsg: the SHA-256 tool on PATH returned the wrong digest for the install-manifest probe." >&2 + return 1 + } + else + printf '%s\n' "$digest" + fi + count=$((count + 1)) + done <<< "$output" + [ "$count" -eq "$(( $# + 1 ))" ] || { + echo "agmsg: the SHA-256 tool returned an unexpected number of install-manifest digests." >&2 + return 1 + } +} + +# Prints the NEXT gen for the completion record install.sh is about to +# write: the current manifest's gen + 1, or a valid .prev's if the current +# manifest is absent or unreadable, or 1 only when neither file exists. +# +# install_id is not this function's concern: install.db's meta row is the +# source of truth, and the manifest only carries a copy of it. +# +# install.sh reads the current generation after acquiring the operation +# lock, then writes the next generation; it has no earlier observation that +# could have become stale while waiting for the lock. +_agmsg_install_manifest_read_gen() { # + local path="${1:-}" sqlpath gen + [ -f "$path" ] || return 1 + sqlpath="$(agmsg_sql_readfile_path "$path")" || return 1 + gen="$(agmsg_sqlite_capture :memory: "SELECT json_extract(CAST(readfile('$sqlpath') AS TEXT), '\$.gen');")" || return 1 + case "$gen" in + ''|*[!0-9]*) return 1 ;; + esac + # Keep the value inside Bash's portable signed arithmetic range. + [ "${#gen}" -le 18 ] || return 1 + [ "$gen" -gt 0 ] || return 1 + printf '%s\n' "$gen" +} + +agmsg_install_manifest_next_gen() { # + local manifest_path="${1:-}" prior_gen="" current_present=false prev_present=false + [ -n "$manifest_path" ] || return 1 + { [ -e "$manifest_path" ] || [ -L "$manifest_path" ]; } && current_present=true + { [ -e "$manifest_path.prev" ] || [ -L "$manifest_path.prev" ]; } && prev_present=true + + if [ "$current_present" = true ] && prior_gen="$(_agmsg_install_manifest_read_gen "$manifest_path")"; then + : + elif [ "$prev_present" = true ] && prior_gen="$(_agmsg_install_manifest_read_gen "$manifest_path.prev")"; then + : + elif [ "$current_present" = false ] && [ "$prev_present" = false ]; then + printf '1\n' + return 0 + else + echo "agmsg: install manifest and its previous copy are unreadable; refusing to reuse a generation" >&2 + return 1 + fi + + printf '%s\n' "$((prior_gen + 1))" +} + +# Renames the current manifest to its own .prev, so a half-done copy is +# never photographed as "complete" -- must run BEFORE the copy step, every +# time. A missing manifest (first install) is not an error: there is +# nothing to rotate. Overwrites any existing .prev -- an install that +# crashed twice in a row without ever completing in between only ever needs +# the most recent attempt's generation, which agmsg_install_manifest_next_gen +# already read before this runs. +agmsg_install_manifest_rotate_prev() { # + local manifest_path="${1:-}" + [ -n "$manifest_path" ] || return 1 + if [ -f "$manifest_path" ]; then + if _agmsg_install_manifest_read_gen "$manifest_path" >/dev/null; then + mv -f "$manifest_path" "$manifest_path.prev" + return $? + fi + elif [ ! -e "$manifest_path" ] && [ ! -L "$manifest_path" ]; then + [ -e "$manifest_path.prev" ] || [ -L "$manifest_path.prev" ] || return 0 + fi + + if _agmsg_install_manifest_read_gen "$manifest_path.prev" >/dev/null; then + # Preserve the last readable completion record instead of replacing it + # with an unreadable current file. + rm -f "$manifest_path" + return $? + fi + echo "agmsg: cannot rotate an unreadable install manifest without a readable previous copy" >&2 + return 1 +} + +# Writes the completion record for a just-finished copy: every file under +# , its path (relative to 's own parent, i.e. +# "scripts/...") and sha256 digest, plus , , , +# (the fixed shape agmsgd and +# agmsgd-launch.sh both carry, a SEPARATE version number from / +# that only bumps when that fixed contract itself changes; the real +# entrypoint, a later PR, checks this against its own embedded constant +# under the operation lock and exits 75 on a mismatch), and the write's own +# timestamp. Atomic (tmpfile + rename): a reader never sees a partially- +# written manifest. Symlinks are never listed (`find -type f` only) -- +# deliberately: the startup-side reader treats ANY symlink under scripts/ as +# a match failure on its own, so a writer that skipped listing one is +# consistent with that, not a gap in it. +agmsg_install_manifest_write() { # + # ${N:-}, not bare $N: this function's own caller has been seen, under + # heavy load on this shared machine, to somehow reach this call with fewer + # than 6 positional arguments -- root cause not yet pinned down (not + # reproducible in isolation). A bare $6 there turns that into an unbound- + # variable crash under set -u; reading it as "" instead lets the + # gen/bootstrap_version validation below report it as an ordinary, clearly + # worded refusal -- no manifest written, .prev left in place -- same as + # any other input this function already rejects. + local scripts_dir="${1:-}" manifest_path="${2:-}" version="${3:-}" install_id="${4:-}" gen="${5:-}" bootstrap_version="${6:-}" + local entry rel digest created_at sql tmp json rel_sql quote write_rc=0 i hash_lines batch_safe=true + local -a rels=() digests=() + + if [ -z "$scripts_dir" ] || [ -z "$manifest_path" ] || [ -z "$install_id" ]; then + echo "agmsg: install manifest write called with a missing argument (scripts_dir/manifest_path/install_id); refusing" >&2 + return 1 + fi + + created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + case "$gen" in + ''|*[!0-9]*) + echo "agmsg: install manifest gen must be a positive integer, got: $gen" >&2 + return 1 + ;; + esac + case "$bootstrap_version" in + ''|*[!0-9]*) + echo "agmsg: install manifest bootstrap_version must be a positive integer, got: $bootstrap_version" >&2 + return 1 + ;; + esac + + while IFS= read -r -d '' entry; do + rel="${entry#./}" + rels+=("$rel") + case "$rel" in *[!A-Za-z0-9._/-]*) batch_safe=false ;; esac + done < <(cd "$scripts_dir" && find . -type f -print0) + + if [ "$batch_safe" = true ]; then + if ! hash_lines="$(_agmsg_install_manifest_hash_batch "$scripts_dir" "${rels[@]}")"; then + return 1 + fi + while IFS= read -r digest; do + digests+=("$digest") + done <<< "$hash_lines" + [ "${#digests[@]}" -eq "${#rels[@]}" ] || return 1 + else + agmsg_sha256_usable || { + echo "agmsg: no usable sha256 tool found; refusing to write the install manifest" >&2 + return 1 + } + for rel in "${rels[@]}"; do + digest="$(agmsg_sha256 < "$scripts_dir/$rel")" || return 1 + digests+=("$digest") + done + fi + + sql="$(mktemp)" || return 1 + + { + printf 'CREATE TABLE files (path TEXT PRIMARY KEY, digest TEXT NOT NULL);\n' + for ((i = 0; i < ${#rels[@]}; i = i + 1)); do + rel="${rels[$i]}" + digest="${digests[$i]}" + quote="'" + rel_sql="${rel//$quote/$quote$quote}" + if ! printf "INSERT INTO files (path, digest) VALUES ('scripts/%s', '%s');\n" \ + "$rel_sql" "$digest"; then + write_rc=1 + break + fi + done + + if [ "$write_rc" -eq 0 ]; then + printf '%s\n' "SELECT json_object( + 'install_id', '$(_agmsg_install_manifest_sqlesc "$install_id")', + 'gen', $gen, + 'version', '$(_agmsg_install_manifest_sqlesc "$version")', + 'bootstrap_version', $bootstrap_version, + 'created_at', '$(_agmsg_install_manifest_sqlesc "$created_at")', + 'digest_algo', 'sha256', + 'files', (SELECT json_group_array(json_object('path', path, 'digest', digest)) + FROM (SELECT path, digest FROM files ORDER BY path)) + );" + fi + } > "$sql" || write_rc=1 + + if [ "$write_rc" -ne 0 ]; then + rm -f "$sql" + return 1 + fi + + if ! json="$(agmsg_sqlite_capture :memory: < "$sql")"; then + rm -f "$sql" + return 1 + fi + rm -f "$sql" + [ -n "$json" ] || return 1 + + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + return 1 + fi + tmp="$(mktemp "$(dirname "$manifest_path")/.$(basename "$manifest_path").XXXXXX")" || return 1 + if ! printf '%s\n' "$json" > "$tmp" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$manifest_path" +} diff --git a/scripts/lib/install-op-lock.sh b/scripts/lib/install-op-lock.sh new file mode 100644 index 000000000..8bc7cf500 --- /dev/null +++ b/scripts/lib/install-op-lock.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# install-op-lock.sh — the install/uninstall operation lock (agmsgd beta). +# +# One lock per install, held for the whole of install.sh / uninstall.sh / +# the `enable` / `disable` / `start` CLI operations. Mechanism: SQLite's own +# BEGIN EXCLUSIVE on run/install-op.lock.db -- a SEPARATE file from +# install.db, held by a single long-running sqlite3 process bash keeps alive +# for the whole operation. Bash 4+ feeds it through a coprocess; Bash 3.2 uses +# a pair of named pipes so bash can keep working while the transaction stays +# open. +# The OS releases the file lock the moment that sqlite3 process dies, for +# any reason -- there is no stale-lock recovery step. +# +# THE LOCK FILE ITSELF IS NEVER DELETED, not even by uninstall (citing +# https://www.sqlite.org/howtocorrupt.html): removing a DB a waiter still has +# open makes a fresh file recreated under the same name a DIFFERENT lock than +# the one that waiter is holding a reference to. +# +# THE LOCK DB HOLDS NO CONTENT (2026-09-29 design decision): no +# tables, no generation, no install_id, no owner -- an empty file whose only +# job is to be BEGIN EXCLUSIVE'd. Writing anything into it would make it a +# second place recording facts install.db and the completion manifest +# already own. +# +# LOCK ORDERING (2026-09-29 design decision): a caller that also +# needs the registry lock or a team-store lock takes them in this order -- +# registry lock -> this install operation lock -> team store lock -- and +# never the reverse. install.sh/uninstall.sh do not take a registry or +# team-store lock today, so this is a constraint on future callers, not a +# thing this file enforces itself. +# +# Required caller-set variables: none. Sources sqlpath.sh for native-path +# conversion and sqlite-output.sh for CRLF-safe captured output. + +[ -n "${_AGMSG_INSTALL_OP_LOCK_SH:-}" ] && return 0 +_AGMSG_INSTALL_OP_LOCK_SH=1 + +_agmsg_install_op_lock_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +if ! declare -F agmsg_sql_readfile_path >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_op_lock_dir/sqlpath.sh" +fi +if ! declare -F agmsg_sqlite_capture >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "$_agmsg_install_op_lock_dir/sqlite-output.sh" +fi +unset _agmsg_install_op_lock_dir + +# A write to a pipe whose reader has already died raises SIGPIPE. Received by +# the shell's OWN write (a builtin `printf` redirected to that fd runs IN +# this process, not a forked child), the default action terminates the whole +# script -- measured: an untrapped SIGPIPE here kills install.sh outright, +# not just the one write. Every caller of this file needs a failed write to +# come back as an ordinary nonzero status instead, so this is set once, on +# source, rather than left to each call site to remember. +trap '' PIPE + +# Globals set by agmsg_install_op_lock on success, cleared by +# agmsg_install_op_unlock: _AGMSG_LOCK_PID (the sqlite3 process), fd 9 (write +# into it) and fd 8 (read its output), and optionally _AGMSG_LOCK_TMPDIR (the +# Bash 3.2 FIFOs' directory). Fixed fd numbers, not `exec {fd}>`: that form +# needs bash 4.1+, and this file runs under macOS's /bin/bash 3.2. + +# Acquires the lock. Prints nothing; returns 0 once BEGIN EXCLUSIVE is +# CONFIRMED open (a canary SELECT read back over the same pipe -- not merely +# "the write didn't fail"), 1 on any failure (busy past timeout_ms, the +# sqlite3 coprocess could not be started, or its output didn't confirm). +# Leaves no fd/process/tmpdir behind on failure. +agmsg_install_op_lock() { # [timeout_ms, default 30000] + local db="$1" timeout_ms="${2:-30000}" + local dir="" in_fifo="" out_fifo="" line read_timeout read_fd write_fd coproc_prefix read_rc + AGMSG_INSTALL_OP_LOCK_FAILURE_REASON="" + coproc_prefix=AGMSG_INSTALL_OP_SQLITE + _AGMSG_INSTALL_OP_USE_COPROC=false + _AGMSG_LOCK_CHANNELS_READY=false + if ! mkdir -p "$(dirname "$db")" 2>/dev/null; then + _agmsg_install_op_lock_set_failure "preparing lock database directory" + return 1 + fi + if [ "${BASH_VERSINFO[0]:-3}" -ge 4 ]; then + # `coproc` is syntax unknown to Bash 3.2, so keep it in an eval string + # that is evaluated only on Bash 4+. Redirections close Bats' inherited + # TAP descriptors in the coprocess command. + unset AGMSG_INSTALL_OP_SQLITE AGMSG_INSTALL_OP_SQLITE_PID + if ! eval 'coproc AGMSG_INSTALL_OP_SQLITE { exec sqlite3 "$db" 2>&1; } 3>&- 4>&-' 2>/dev/null; then + _agmsg_install_op_lock_set_failure "starting sqlite3 coprocess" + return 1 + fi + _AGMSG_INSTALL_OP_USE_COPROC=true + _AGMSG_LOCK_PID="${AGMSG_INSTALL_OP_SQLITE_PID:-}" + eval "read_fd=\${${coproc_prefix}[0]:-}" + eval "write_fd=\${${coproc_prefix}[1]:-}" + case "$_AGMSG_LOCK_PID" in ''|*[!0-9]*) _agmsg_install_op_lock_set_failure "starting sqlite3 coprocess: no valid child PID"; _AGMSG_LOCK_PID=""; agmsg_install_op_unlock; return 1 ;; esac + case "$read_fd" in ''|*[!0-9]*) _agmsg_install_op_lock_set_failure "connecting to sqlite3 coprocess: missing read pipe"; agmsg_install_op_unlock; return 1 ;; esac + case "$write_fd" in ''|*[!0-9]*) _agmsg_install_op_lock_set_failure "connecting to sqlite3 coprocess: missing write pipe"; agmsg_install_op_unlock; return 1 ;; esac + if ! eval "exec 8<&${read_fd} 9>&${write_fd}"; then + _agmsg_install_op_lock_set_failure "connecting to sqlite3 coprocess: could not attach its pipes" + agmsg_install_op_unlock + return 1 + fi + eval "exec ${read_fd}<&- ${write_fd}>&-" + _AGMSG_LOCK_CHANNELS_READY=true + unset AGMSG_INSTALL_OP_SQLITE AGMSG_INSTALL_OP_SQLITE_PID + else + dir="$(mktemp -d "${TMPDIR:-/tmp}/agmsg-install-lock.XXXXXX")" || { + _agmsg_install_op_lock_set_failure "preparing SQLite lock pipes: could not create a temporary directory" + return 1 + } + in_fifo="$dir/in"; out_fifo="$dir/out" + if ! mkfifo "$in_fifo" "$out_fifo" 2>/dev/null; then + _agmsg_install_op_lock_set_failure "preparing SQLite lock pipes: mkfifo failed" + rm -rf "$dir" 2>/dev/null + return 1 + fi + + # 2>&1 into the SAME out fifo: a busy/failed BEGIN EXCLUSIVE prints its + # error there instead of the canary line, which is exactly how failure is + # told apart from success below -- one stream, read for one exact literal. + sqlite3 "$db" < "$in_fifo" > "$out_fifo" 2>&1 3>&- 4>&- & + _AGMSG_LOCK_PID=$! + exec 9> "$in_fifo" + exec 8< "$out_fifo" + _AGMSG_LOCK_TMPDIR="$dir" + _AGMSG_LOCK_CHANNELS_READY=true + fi + + # `.timeout` (a dot-command), not `PRAGMA busy_timeout=N;`: a PRAGMA that + # returns a value is echoed back on the out fifo like a query result, which + # would land as an extra line before the canary and make the read count + # fragile. The dot-command sets the same busy handler with no echo. + if ! printf '.timeout %s\nBEGIN EXCLUSIVE;\nSELECT '"'"'agmsg-lock-ok'"'"';\n' "$timeout_ms" >&9; then + _agmsg_install_op_lock_set_failure "writing the lock request to sqlite3: the pipe closed or rejected the write" + agmsg_install_op_unlock + return 1 + fi + + read_timeout=$((timeout_ms / 1000 + 5)) + if IFS= read -r -t "$read_timeout" -u 8 line; then + : + else + read_rc=$? + if [ "$read_rc" -gt 128 ]; then + _agmsg_install_op_lock_set_failure "waiting for lock confirmation from sqlite3: timed out after ${read_timeout}s" + else + _agmsg_install_op_lock_set_failure "waiting for lock confirmation from sqlite3: output closed before a reply" + fi + agmsg_install_op_unlock + return 1 + fi + line="${line%$'\r'}" + if [ "$line" != "agmsg-lock-ok" ]; then + if [ -n "$line" ]; then + _agmsg_install_op_lock_set_failure "unexpected lock confirmation from sqlite3" "$line" + else + _agmsg_install_op_lock_set_failure "unexpected empty lock confirmation from sqlite3" + fi + agmsg_install_op_unlock + return 1 + fi + return 0 +} + +# Preserve a short, shell-escaped first response so a caller can explain which +# handshake stage failed without allowing control characters into terminal output. +_agmsg_install_op_lock_set_failure() { + local reason="${1:-lock handshake failed}" detail="${2:-}" + if [ -n "$detail" ]; then + printf -v detail '%q' "$detail" + detail="${detail:0:80}" + if [ -n "$detail" ]; then + reason="$reason: $detail" + fi + fi + AGMSG_INSTALL_OP_LOCK_FAILURE_REASON="$reason" +} + +# Load the shared liveness check when its source file is still installed. The +# recovery helper can outlive scripts/, so its canary round-trip remains the +# authoritative fallback when instance-id.sh is unavailable. +_agmsg_install_op_load_pid_helper() { + declare -F _agmsg_pid_alive_local >/dev/null 2>&1 && return 0 + if [ -n "${SCRIPT_DIR:-}" ] && [ -r "$SCRIPT_DIR/scripts/lib/instance-id.sh" ]; then + . "$SCRIPT_DIR/scripts/lib/instance-id.sh" + elif [ -r "$(dirname "${BASH_SOURCE[0]}")/instance-id.sh" ]; then + . "$(dirname "${BASH_SOURCE[0]}")/instance-id.sh" + fi + declare -F _agmsg_pid_alive_local >/dev/null 2>&1 +} + +# Re-proves the lock is still genuinely held: use the shared local-pid +# liveness check when available, then require a fresh canary round-trip. The +# canary also covers the recovery-only helper after scripts/ has been removed. +# A caller that only checked liveness at acquire time would otherwise write +# past a lock it silently no longer holds. Returns 1 (lock not provably held; +# do not proceed) without ever killing the caller, since PIPE is trapped. +agmsg_install_op_confirm() { + local line + [ -n "${_AGMSG_LOCK_PID:-}" ] || return 1 + if _agmsg_install_op_load_pid_helper; then + _agmsg_pid_alive_local "$_AGMSG_LOCK_PID" || return 1 + fi + printf "SELECT 'agmsg-lock-ok';\n" >&9 || return 1 + read -r -t 5 -u 8 line || return 1 + line="${line%$'\r'}" + [ "$line" = "agmsg-lock-ok" ] +} + +# Refuse the next protected write unless the SQLite child still proves the +# transaction is held. Phase entry checks gate each group of writes; the next +# phase or operation_finish detects a child-only death before later work starts. +agmsg_install_op_require() { + # The incomplete-operation record excludes every later install/uninstall + # while a phase is in flight. Within that phase, the record -- not another + # SQLite round trip for each small write -- is what prevents overlap. The + # phase runner proves the lock at each phase entry so a lost lock cannot + # carry this operation into its next phase. + [ "${AGMSG_INSTALL_OP_PHASE_ACTIVE:-false}" = true ] && return 0 + if ! agmsg_install_op_confirm; then + echo " ! the install lock was lost partway through; stopping before the next write (see .prev)" >&2 + return 1 + fi +} + +# Run one logical group of protected writes. The operation marker remains in +# place throughout the group, so a later operation can acquire the SQLite lock +# after a child-only failure but must still refuse to write. The next phase +# checks the lock before it writes; operation_finish checks it after the last +# phase. That makes each boundary one canary round-trip, not two. +agmsg_install_op_phase_begin() { + agmsg_install_op_require || return 1 + AGMSG_INSTALL_OP_PHASE_ACTIVE=true +} + +agmsg_install_op_phase_end() { + AGMSG_INSTALL_OP_PHASE_ACTIVE=false + return 0 +} + +agmsg_install_op_run_phase() { + local phase_rc=0 + agmsg_install_op_phase_begin || return 1 + "$@" || phase_rc=$? + agmsg_install_op_phase_end || return 1 + return "$phase_rc" +} + +_agmsg_install_op_sql_quote() { + printf '%s' "$1" | sed "s/'/''/g" +} + +_agmsg_install_op_pending_fields() { + local path="$1" path_sql + path_sql="$(agmsg_sql_readfile_path "$path")" || return 1 + # Hex fields make the colon separator unambiguous even when a path contains + # tabs, quotes, or newlines, while keeping all seven reads in one sqlite3. + agmsg_sqlite_capture :memory: \ + "WITH record AS (SELECT CAST(readfile('$path_sql') AS TEXT) AS json) SELECT lower(hex(CAST(COALESCE(json_extract(json, '\$.operation_id'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.operation'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.mode'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.install_path'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.actor_pid'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.started_at'), '') AS BLOB))) || ':' || lower(hex(CAST(COALESCE(json_extract(json, '\$.state'), '') AS BLOB))) FROM record;" +} + +_agmsg_install_op_hex_decode() { + local hex="$1" decoded="" byte char + case "$hex" in *[!0123456789abcdefABCDEF]*) return 1 ;; esac + [ $(( ${#hex} % 2 )) -eq 0 ] || return 1 + # Bash variables cannot represent NUL, and no valid path or record field + # needs one. Reject it rather than silently changing the decoded value. + case "$hex" in *00*) return 1 ;; esac + while [ -n "$hex" ]; do + byte="${hex:0:2}" + hex="${hex:2}" + printf -v char '%b' "\\x$byte" || return 1 + decoded="${decoded}${char}" + done + AGMSG_INSTALL_OP_DECODED="$decoded" +} + +agmsg_install_op_pending_validate() { + local path="$1" encoded id_hex kind_hex mode_hex install_path_hex pid_hex started_hex state_hex extra + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + encoded="$(_agmsg_install_op_pending_fields "$path")" || return 1 + IFS=: read -r id_hex kind_hex mode_hex install_path_hex pid_hex started_hex state_hex extra <<< "$encoded" + [ -z "$extra" ] || return 1 + _agmsg_install_op_hex_decode "$id_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_ID="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$kind_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_KIND="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$mode_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_MODE="$AGMSG_INSTALL_OP_DECODED" + [ -n "$AGMSG_INSTALL_OP_PENDING_MODE" ] || AGMSG_INSTALL_OP_PENDING_MODE=legacy + _agmsg_install_op_hex_decode "$install_path_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_PATH="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$pid_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_PID="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$started_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_STARTED="$AGMSG_INSTALL_OP_DECODED" + _agmsg_install_op_hex_decode "$state_hex" || return 1 + AGMSG_INSTALL_OP_PENDING_STATE="$AGMSG_INSTALL_OP_DECODED" + case "$AGMSG_INSTALL_OP_PENDING_ID" in + ''|*[!0-9a-f]*) return 1 ;; + esac + [ "${#AGMSG_INSTALL_OP_PENDING_ID}" -eq 32 ] || return 1 + case "$AGMSG_INSTALL_OP_PENDING_KIND" in install|uninstall) ;; *) return 1 ;; esac + case "$AGMSG_INSTALL_OP_PENDING_MODE" in install|update|keep-data|remove-data|legacy) ;; *) return 1 ;; esac + case "$AGMSG_INSTALL_OP_PENDING_KIND:$AGMSG_INSTALL_OP_PENDING_MODE" in + install:install|install:update|install:legacy|uninstall:keep-data|uninstall:remove-data|uninstall:legacy) ;; + *) return 1 ;; + esac + case "$AGMSG_INSTALL_OP_PENDING_PID" in ''|*[!0-9]*) return 1 ;; esac + [ "${#AGMSG_INSTALL_OP_PENDING_PID}" -le 10 ] || return 1 + [ "$AGMSG_INSTALL_OP_PENDING_PID" -gt 0 ] || return 1 + case "$AGMSG_INSTALL_OP_PENDING_STATE" in + in_progress|interrupted_complete|completed) ;; + *) return 1 ;; + esac + [ -n "$AGMSG_INSTALL_OP_PENDING_PATH" ] && [ -n "$AGMSG_INSTALL_OP_PENDING_STARTED" ] +} + +agmsg_install_op_pending_report() { + local path="$1" + agmsg_install_op_pending_validate "$path" || return 1 + printf ' operation_id: %s\n' "$AGMSG_INSTALL_OP_PENDING_ID" >&2 + printf ' operation: %s\n' "$AGMSG_INSTALL_OP_PENDING_KIND" >&2 + printf ' mode: %s\n' "$AGMSG_INSTALL_OP_PENDING_MODE" >&2 + printf ' install: %s\n' "$AGMSG_INSTALL_OP_PENDING_PATH" >&2 + printf ' started_at: %s\n' "$AGMSG_INSTALL_OP_PENDING_STARTED" >&2 + printf ' actor_pid: %s (displayed only; no liveness inference)\n' "$AGMSG_INSTALL_OP_PENDING_PID" >&2 +} + +agmsg_install_op_pending_refuse() { + local path="$1" recovery_prefix="$2" next_kind="$3" next_mode="$4" + if [ -L "$path" ] || ! agmsg_install_op_pending_report "$path"; then + echo " ! an unreadable or malformed incomplete-operation record blocks this install; inspect $path before recovery" >&2 + return 1 + fi + printf ' ! an earlier %s operation (%s) is incomplete; %s (%s) will not start\n' \ + "$AGMSG_INSTALL_OP_PENDING_KIND" "$AGMSG_INSTALL_OP_PENDING_MODE" "$next_kind" "$next_mode" >&2 + echo " Verify that no writer from the recorded operation is still running before recovery." >&2 + printf ' Recovery command: %s %s\n' "$recovery_prefix" "$AGMSG_INSTALL_OP_PENDING_ID" >&2 + return 1 +} + +agmsg_install_op_pending_begin() { + local path="$1" kind="$2" install_path="$3" install_id="${4:-}" mode="${5:-legacy}" + local op_id started_at path_sql kind_sql mode_sql id_sql started_sql install_id_sql json tmp + [ -n "$path" ] && [ -n "$kind" ] && [ -n "$install_path" ] || return 1 + case "$kind:$mode" in + install:install|install:update|uninstall:keep-data|uninstall:remove-data) ;; + *) return 1 ;; + esac + agmsg_install_op_require || return 1 + mkdir -p "$(dirname "$path")" || return 1 + if [ -e "$path" ] || [ -L "$path" ]; then + echo " ! an incomplete-operation record already exists at $path" >&2 + return 1 + fi + op_id="$(agmsg_sqlite_capture :memory: 'SELECT lower(hex(randomblob(16)));')" || return 1 + case "$op_id" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#op_id}" -eq 32 ] || return 1 + started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" || return 1 + path_sql="$(_agmsg_install_op_sql_quote "$install_path")" || return 1 + kind_sql="$(_agmsg_install_op_sql_quote "$kind")" || return 1 + mode_sql="$(_agmsg_install_op_sql_quote "$mode")" || return 1 + id_sql="$(_agmsg_install_op_sql_quote "$op_id")" || return 1 + started_sql="$(_agmsg_install_op_sql_quote "$started_at")" || return 1 + install_id_sql="$(_agmsg_install_op_sql_quote "$install_id")" || return 1 + json="$(agmsg_sqlite_capture :memory: "SELECT json_object('operation_id','$id_sql','operation','$kind_sql','mode','$mode_sql','install_path','$path_sql','install_id','$install_id_sql','actor_pid',$$,'started_at','$started_sql','state','in_progress');")" || return 1 + [ -n "$json" ] || return 1 + tmp="$(mktemp "$(dirname "$path")/.$(basename "$path").XXXXXX")" || return 1 + if ! printf '%s\n' "$json" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + agmsg_install_op_require || { rm -f "$tmp"; return 1; } + if ! ln "$tmp" "$path" 2>/dev/null; then + rm -f "$tmp" + echo " ! an incomplete-operation record already exists at $path" >&2 + return 1 + fi + rm -f "$tmp" || return 1 + AGMSG_INSTALL_OP_ID="$op_id" + AGMSG_INSTALL_OP_MARKER="$path" + agmsg_install_op_require +} + +agmsg_install_op_pending_set_state() { + local path="$1" op_id="$2" state="$3" current_id path_sql json tmp + case "$state" in completed|interrupted_complete) ;; *) return 1 ;; esac + agmsg_install_op_require || return 1 + agmsg_install_op_pending_validate "$path" || return 1 + current_id="$AGMSG_INSTALL_OP_PENDING_ID" + [ "$current_id" = "$op_id" ] || return 1 + path_sql="$(agmsg_sql_readfile_path "$path")" || return 1 + op_id="$(_agmsg_install_op_sql_quote "$op_id")" || return 1 + state="$(_agmsg_install_op_sql_quote "$state")" || return 1 + json="$(agmsg_sqlite_capture :memory: "SELECT json_set(CAST(readfile('$path_sql') AS TEXT), '\$.state', '$state') WHERE json_extract(CAST(readfile('$path_sql') AS TEXT), '\$.operation_id') = '$op_id';")" || return 1 + [ -n "$json" ] || return 1 + tmp="$(mktemp "$(dirname "$path")/.$(basename "$path").XXXXXX")" || return 1 + if ! printf '%s\n' "$json" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + agmsg_install_op_require || { rm -f "$tmp"; return 1; } + agmsg_install_op_pending_validate "$path" || { rm -f "$tmp"; return 1; } + [ "$AGMSG_INSTALL_OP_PENDING_ID" = "$op_id" ] || { rm -f "$tmp"; return 1; } + if ! mv -f "$tmp" "$path"; then + rm -f "$tmp" + return 1 + fi + agmsg_install_op_require +} + +agmsg_install_op_pending_remove() { + local path="$1" op_id="$2" + agmsg_install_op_require || return 1 + agmsg_install_op_pending_validate "$path" || return 1 + [ "$AGMSG_INSTALL_OP_PENDING_ID" = "$op_id" ] || return 1 + rm -f "$path" || return 1 + agmsg_install_op_require +} + +agmsg_install_op_pending_complete() { + local path="$1" op_id="$2" state="${3:-completed}" + agmsg_install_op_pending_set_state "$path" "$op_id" "$state" || return 1 + agmsg_install_op_pending_remove "$path" "$op_id" +} + +agmsg_install_op_pending_recover() { + local path="$1" requested_id="$2" next_kind="$3" next_mode="$4" + agmsg_install_op_require || return 1 + if ! agmsg_install_op_pending_report "$path"; then + echo " ! the incomplete-operation record is missing or unreadable; it cannot be recovered automatically" >&2 + return 1 + fi + if [ "$AGMSG_INSTALL_OP_PENDING_ID" != "$requested_id" ]; then + echo " ! recovery operation id does not match the current record" >&2 + return 1 + fi + printf ' Recorded operation: %s (%s)\n' \ + "$AGMSG_INSTALL_OP_PENDING_KIND" "$AGMSG_INSTALL_OP_PENDING_MODE" >&2 + printf ' Continuing requested operation: %s (%s)\n' "$next_kind" "$next_mode" >&2 + echo " Verify that no writer from this operation can still modify the installation before continuing." >&2 + agmsg_install_op_pending_remove "$path" "$requested_id" +} + +agmsg_install_op_run_writer() { + local status=0 writer_pid + AGMSG_INSTALL_OP_WRITER_STARTING=true + "$@" 3>&- 4>&- & + writer_pid=$! + if [ -n "${AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE:-}" ]; then + printf '%s\n' "$writer_pid" > "${AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE}.pid" + while [ ! -e "${AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE}.release" ]; do sleep 0.02; done + fi + AGMSG_INSTALL_OP_WRITER_PID="$writer_pid" + AGMSG_INSTALL_OP_WRITER_STARTING=false + if wait "$writer_pid"; then + status=0 + else + status=$? + fi + AGMSG_INSTALL_OP_WRITER_PID="" + return "$status" +} + +# A caught cancellation is the only automatic path that removes an in-flight +# record before the completion manifest is published. Stop and reap the one +# tracked mutating child first; if the SQLite lock has already been lost, keep +# the record so a competing operation cannot enter while that child exits. +agmsg_install_op_handle_signal() { + local signal="$1" exit_status=130 + [ "$signal" = TERM ] && exit_status=143 + trap - INT TERM + AGMSG_INSTALL_OP_PHASE_ACTIVE=false + if [ "${AGMSG_INSTALL_OP_WRITER_STARTING:-false}" = true ] && [ -z "${AGMSG_INSTALL_OP_WRITER_PID:-}" ]; then + echo " ! writer launch was interrupted before its pid was published; the incomplete-operation record was kept for recovery" >&2 + agmsg_install_op_unlock + exit "$exit_status" + fi + if [ -n "${AGMSG_INSTALL_OP_WRITER_PID:-}" ]; then + kill -TERM "$AGMSG_INSTALL_OP_WRITER_PID" 2>/dev/null || true + wait "$AGMSG_INSTALL_OP_WRITER_PID" 2>/dev/null || true + AGMSG_INSTALL_OP_WRITER_PID="" + fi + AGMSG_INSTALL_OP_WRITER_STARTING=false + if [ -n "${AGMSG_INSTALL_OP_ID:-}" ] && [ -n "${AGMSG_INSTALL_OP_MARKER:-}" ] && agmsg_install_op_confirm; then + agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" interrupted_complete || true + fi + agmsg_install_op_unlock + exit "$exit_status" +} + +# Releases the lock and cleans up every resource agmsg_install_op_lock +# created, whether or not the lock was ever confirmed open (safe to call +# after a failed agmsg_install_op_lock, and safe to call twice). COMMIT is +# attempted but its failure is not itself an error here -- a coprocess that +# already died released the OS lock by dying; there is nothing left to +# commit, and this function's job is cleanup, not re-detecting that. +agmsg_install_op_unlock() { + local coproc_read_fd="" coproc_write_fd="" coproc_prefix=AGMSG_INSTALL_OP_SQLITE + eval "coproc_read_fd=\${${coproc_prefix}[0]:-}" + eval "coproc_write_fd=\${${coproc_prefix}[1]:-}" + if [ "${_AGMSG_LOCK_CHANNELS_READY:-false}" = true ] && [ -n "${_AGMSG_LOCK_PID:-}" ]; then + printf 'COMMIT;\n' >&9 2>/dev/null || true + fi + # `exec` with no command applies its redirections to the CURRENT SHELL, + # not to a single statement -- a bare `exec 9>&- 2>/dev/null` (to silence + # a "no such file descriptor" if 9 were somehow already closed) therefore + # redirects stderr for the REST OF THE CALLING SCRIPT, not just this line. + # Measured: it did, and every later stderr write -- including a + # downstream command's own error message -- went to /dev/null for good. + # `{ exec 9>&-; } 2>/dev/null` scopes the same suppression to the group + # instead: stderr is only silenced for the compound command inside the + # braces, and is itself restored once the group ends. + { exec 9>&-; } 2>/dev/null || true + { exec 8<&-; } 2>/dev/null || true + if [ "${_AGMSG_INSTALL_OP_USE_COPROC:-false}" = true ]; then + case "$coproc_read_fd" in ''|*[!0-9]*) ;; *) eval "exec ${coproc_read_fd}<&-" 2>/dev/null || true ;; esac + case "$coproc_write_fd" in ''|*[!0-9]*) ;; *) eval "exec ${coproc_write_fd}>&-" 2>/dev/null || true ;; esac + fi + if [ -n "${_AGMSG_LOCK_PID:-}" ]; then + kill "$_AGMSG_LOCK_PID" 2>/dev/null || true + wait "$_AGMSG_LOCK_PID" 2>/dev/null || true + fi + [ -n "${_AGMSG_LOCK_TMPDIR:-}" ] && rm -rf "$_AGMSG_LOCK_TMPDIR" 2>/dev/null + unset _AGMSG_LOCK_PID _AGMSG_LOCK_TMPDIR _AGMSG_INSTALL_OP_USE_COPROC _AGMSG_LOCK_CHANNELS_READY + unset AGMSG_INSTALL_OP_SQLITE AGMSG_INSTALL_OP_SQLITE_PID + AGMSG_INSTALL_OP_PHASE_ACTIVE=false +} + +# Places 's CONTENT at atomically: a reader of sees either +# the old file in full or the new one in full, never a torn write -- +# agmsgd-launch.sh and the agmsgd entrypoint must never be +# read half-written. The temp file lives in 's own directory so the +# final `mv` is a same-filesystem rename, not a cross-filesystem copy. +# install.sh already depends on `mktemp` elsewhere and has no join.sh-style +# minimal-PATH constraint, so this does not need registry-lock.sh's +# mkdir-only fallback. Mode is mktemp's default (0600); the caller chmod's +# afterward the same way it does for every other shipped script -- +# not this helper's job, and `chmod --reference` is a GNU-only flag this +# codebase's macOS/BSD chmod does not have. +agmsg_atomic_place_file() { # + local src="$1" dest="$2" tmp + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_require || return 1 + fi + tmp="$(mktemp "$(dirname "$dest")/.$(basename "$dest").XXXXXX")" || return 1 + if ! cp "$src" "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + return 1 + fi + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + rm -f "$tmp" 2>/dev/null + return 1 + fi + mv "$tmp" "$dest" + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_require || return 1 + fi +} diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index 71057b1c1..e00f926f8 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -137,10 +137,9 @@ agmsg_role_session_load() { # from the type manifest. Empty when unknown. # project= the resolved project root # owner= the actas owner token written by actas-claim -# codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) agmsg_role_session_record() { - local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" @@ -164,7 +163,6 @@ agmsg_role_session_record() { printf 'type=%s\n' "$type" printf 'project=%s\n' "$project" [ -z "$owner" ] || printf 'owner=%s\n' "$owner" - [ -z "$codex_home" ] || printf 'codex_home=%s\n' "$codex_home" printf 'updated_at=%s\n' "$ts" [ -z "$named_ref" ] || printf 'named_ref=%s\n' "$named_ref" [ -z "$named_ref" ] || printf 'named_epoch=%s\n' "$named_epoch" diff --git a/scripts/lib/skill-render.sh b/scripts/lib/skill-render.sh index 5efa62ab4..f037a61e8 100644 --- a/scripts/lib/skill-render.sh +++ b/scripts/lib/skill-render.sh @@ -114,9 +114,16 @@ agmsg_render_skill() { return 1 fi chmod 644 "$temp" || { rm -f "$temp"; return 1; } + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + rm -f "$temp" + return 1 + fi if ! mv -f "$temp" "$output"; then rm -f "$temp" echo "agmsg: cannot install rendered skill: $output" >&2 return 1 fi + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ] && ! agmsg_install_op_require; then + return 1 + fi } diff --git a/scripts/lib/sqlite-output.sh b/scripts/lib/sqlite-output.sh new file mode 100644 index 000000000..0e4d03b8d --- /dev/null +++ b/scripts/lib/sqlite-output.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Capture one sqlite3 result while normalizing the CR from native Windows +# sqlite3.exe's CRLF line ending. Callers use this for scalar or JSON output. + +[ -n "${_AGMSG_SQLITE_OUTPUT_SH:-}" ] && return 0 +_AGMSG_SQLITE_OUTPUT_SH=1 + +agmsg_sqlite_capture() { + local output + output="$(sqlite3 "$@" 2>/dev/null)" || return $? + output="${output%$'\r'}" + printf '%s' "$output" +} diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index c690a853f..3216ae390 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -114,3 +114,13 @@ _write_completion_record() { final_state="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" [ "$final_state" = "none" ] } + +@test "agmsgd refuses to start while an incomplete install operation is recorded" { + _write_completion_record + printf '%s\n' '{"operation_id":"0123456789abcdef0123456789abcdef"}' \ + > "$TEST_SKILL_DIR/run/install-op-incomplete.json" + + run node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 + [ "$status" -eq 75 ] + [[ "$output" == *"operation is incomplete"* ]] +} diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 4a3738b64..7a4d1a830 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -14,8 +14,6 @@ setup() { export RUN_DIR="$SKILL_DIR/run" mkdir -p "$RUN_DIR" export CODEX_SESSIONS="$HOME/.codex/sessions" - export CODEX_HOME="$HOME/.codex" - mkdir -p "$CODEX_HOME" } teardown() { teardown_test_env; } @@ -70,51 +68,21 @@ recorded_uuid() { agmsg_role_session_uuid "$1" "$2" } -@test "codex record: stores the thread and effective profile path" { - local proj explicit_home expected_home; proj="$(mktemp -d)" - explicit_home="$TEST_SKILL_DIR/codex profile" - mkdir -p "$explicit_home" "$HOME/.codex" - CODEX_HOME="$explicit_home" CODEX_THREAD_ID="env-thread-1" \ +@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { + local proj; proj="$(mktemp -d)" + CODEX_THREAD_ID="env-thread-1" \ bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "env-thread-1" ] + # type is recorded as codex. source "$SKILL_DIR/scripts/lib/role-session.sh" [ "$(agmsg_role_session_get team alice type)" = "codex" ] - expected_home="$(cd "$explicit_home" && pwd -P)" - # Match the recorder's cross-platform path spelling (not raw Git Bash /c/...). - # shellcheck disable=SC1090 - source "$SCRIPTS/lib/resolve-project.sh" - expected_home="$(agmsg_normalize_project_path "$expected_home")" - [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] - - # Unset CODEX_HOME uses the same default Codex uses, recorded as an absolute - # path rather than leaving a later reader to infer it from its own HOME. - env -u CODEX_HOME CODEX_THREAD_ID="env-thread-2" \ - bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" - [ "$(recorded_uuid team alice)" = "env-thread-2" ] - expected_home="$(cd "$HOME/.codex" && pwd -P)" - expected_home="$(agmsg_normalize_project_path "$expected_home")" - [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: falls back to the unique matching-cwd rollout when env is unset" { - local proj explicit_home expected_home; proj="$(mktemp -d)" - explicit_home="$TEST_SKILL_DIR/profile-B" - mkdir -p "$explicit_home" - # A matching rollout in the default profile must not outrank the selected - # profile's own unique matching rollout. - CODEX_SESSIONS="$HOME/.codex/sessions" - make_rollout "wrong-profile-uuid" "$proj" - CODEX_SESSIONS="$explicit_home/sessions" + local proj; proj="$(mktemp -d)" make_rollout "fallback-uuid" "$proj" - CODEX_HOME="$explicit_home" env -u CODEX_THREAD_ID \ - bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) [ "$(recorded_uuid team alice)" = "fallback-uuid" ] - source "$SKILL_DIR/scripts/lib/role-session.sh" - expected_home="$(cd "$explicit_home" && pwd -P)" - # shellcheck disable=SC1090 - source "$SCRIPTS/lib/resolve-project.sh" - expected_home="$(agmsg_normalize_project_path "$expected_home")" - [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { diff --git a/tests/test_install.bats b/tests/test_install.bats index 3db5b6708..decf4ee65 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -300,7 +300,12 @@ teardown() { # install this run is about (#1400). HOME="$FAKE_HOME" CODEX_HOME="$codex_home2" bash "$SK/uninstall.sh" --yes - [ ! -e "$SK" ] + # $SK itself is no longer fully gone: run/install-op.lock.db + # is kept on purpose, so a residual $SK/run/ survives. Its substantive + # content is what must be gone -- covered in full by its own test + # ("uninstall keeps run/install-op.lock.db while removing everything else"). + [ ! -e "$SK/scripts" ] + [ ! -e "$SK/SKILL.md" ] [ ! -f "$cmd_first" ] [ ! -f "$proj_cmd_first" ] refute grep -qF "$SK/" "$settings" @@ -376,8 +381,10 @@ teardown() { # two installs present and no single one identified. HOME="$FAKE_HOME" bash "$REPO_ROOT/uninstall.sh" --all --yes - [ ! -e "$FAKE_HOME/.agents/skills/agmsg" ] - [ ! -e "$FAKE_HOME/.agents/skills/agmsg-second" ] + # Neither install is fully gone: run/install-op.lock.db + # is kept on purpose for each. Substantive content is what must be gone. + [ ! -e "$FAKE_HOME/.agents/skills/agmsg/scripts" ] + [ ! -e "$FAKE_HOME/.agents/skills/agmsg-second/scripts" ] [ ! -f "$cmd_first" ] [ ! -f "$cmd_second" ] [ ! -e "$shim" ] @@ -845,6 +852,92 @@ PS1 [ ! -f "$SK/scripts/windows/sqlite3-shim.sh" ] } +@test "install reports the lock handshake stage and a bounded SQLite response" { + local fake_bin="$FAKE_HOME/bin" banner expected + mkdir -p "$fake_bin" + banner="SQLite version $(printf '%100s' '' | tr ' ' X)" + cat > "$fake_bin/sqlite3" <<'SH' +#!/usr/bin/env bash +if [ "${SQLITE_MODE:-}" = crlf-canary ]; then + while IFS= read -r statement; do + if [ "$statement" = "SELECT 'agmsg-lock-ok';" ]; then + printf 'agmsg-lock-ok\r\n' + fi + done + exit 0 +fi +printf '%s\n' "${SQLITE_BANNER:-unexpected sqlite output}" +SH + chmod +x "$fake_bin/sqlite3" + + run env HOME="$FAKE_HOME" PATH="$fake_bin:$PATH" SQLITE_BANNER="$banner" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg + [ "$status" -ne 0 ] + printf -v expected '%q' "$banner" + expected="${expected:0:80}" + grep -Fq -- "could not take the install operation lock: unexpected lock confirmation from sqlite3: $expected" <<<"$output" + [ "${#expected}" -eq 80 ] + refute grep -Fq -- "another install/uninstall in progress?" <<<"$output" + + run env HOME="$FAKE_HOME" PATH="$fake_bin:$PATH" SQLITE_MODE=crlf-canary \ + bash -c 'source "$1"; agmsg_install_op_lock "$2" 1000 || exit 1; agmsg_install_op_confirm || exit 2; printf "lock acquired\\n"; agmsg_install_op_unlock' \ + _ "$REPO_ROOT/scripts/lib/install-op-lock.sh" "$FAKE_HOME/run/install-op.lock.db" + [ "$status" -eq 0 ] + grep -Fq -- "lock acquired" <<<"$output" +} + +@test "install SQLite readers batch pending fields, convert paths, and normalize CRLF" { + local fake_bin="$FAKE_HOME/bin" record="$FAKE_HOME/record.json" manifest="$FAKE_HOME/manifest.json" calls="$FAKE_HOME/sqlite-calls" + mkdir -p "$fake_bin" + printf '{}\n' > "$record" + printf '{"gen":1}\n' > "$manifest" + cat > "$fake_bin/cygpath" <<'SH' +#!/usr/bin/env bash +printf 'C:/converted\n' +SH + cat > "$fake_bin/sqlite3" <<'SH' +#!/usr/bin/env bash +query= +for arg do query="$arg"; done +printf 'call\n' >> "$SQLITE_CALLS" +case "$query" in + *"readfile('/"*) printf 'SQLite received an unconverted path\n' >&2; exit 20 ;; + *"readfile('C:/converted')"*'$.operation_id'*) + printf '%s\r\n' \ + '3031323334353637383961626364656630313233343536373839616263646566:696e7374616c6c::433a2f636f6e766572746564:3132333435:78:696e5f70726f6772657373' + ;; + *"readfile('C:/converted')"*'$.gen'*) printf '7\r\n' ;; + *"SELECT install_id FROM meta LIMIT 1;"*) printf 'install-id\r\n' ;; + *"CREATE TABLE IF NOT EXISTS meta"*) exit 0 ;; + *) printf 'unexpected SQL: %s\n' "$query" >&2; exit 21 ;; +esac +SH + chmod +x "$fake_bin/cygpath" "$fake_bin/sqlite3" + + run env HOME="$FAKE_HOME" PATH="$fake_bin:$PATH" \ + SQLITE_CALLS="$calls" bash -c ' + lib="$1" + . "$lib/install-op-lock.sh" + . "$lib/install-db.sh" + . "$lib/install-manifest.sh" + agmsg_install_op_pending_validate "$2" || exit 10 + [ "$AGMSG_INSTALL_OP_PENDING_ID" = "0123456789abcdef0123456789abcdef" ] || exit 13 + [ "$AGMSG_INSTALL_OP_PENDING_KIND" = "install" ] || exit 14 + [ "$AGMSG_INSTALL_OP_PENDING_MODE" = "legacy" ] || exit 15 + [ "$AGMSG_INSTALL_OP_PENDING_PATH" = "C:/converted" ] || exit 16 + [ "$AGMSG_INSTALL_OP_PENDING_PID" = "12345" ] || exit 17 + [ "$AGMSG_INSTALL_OP_PENDING_STARTED" = "x" ] || exit 18 + [ "$AGMSG_INSTALL_OP_PENDING_STATE" = "in_progress" ] || exit 19 + [ "$(wc -l < "$SQLITE_CALLS" | tr -d " ")" = 1 ] || exit 20 + op_id="$AGMSG_INSTALL_OP_PENDING_ID" + gen="$(_agmsg_install_manifest_read_gen "$3")" || exit 11 + install_id="$(agmsg_install_db_ensure_meta "$4")" || exit 12 + printf "%s|%s|%s\n" "$op_id" "$gen" "$install_id" + ' _ "$REPO_ROOT/scripts/lib" "$record" "$manifest" "$FAKE_HOME/install.db" + [ "$status" -eq 0 ] + [ "$output" = '0123456789abcdef0123456789abcdef|7|install-id' ] +} + @test "plugin SKILL.md bootstrap: a fresh plugin install path can bootstrap ~/.agents/skills/agmsg" { # Simulate the post-plugin-install state: no ~/.agents/skills/agmsg yet, but # the plugin marketplace flow has populated the cache dir with a copy of the @@ -2056,6 +2149,564 @@ CYG done < <(agmsg_renderable_types "$BATS_TEST_DIRNAME/..") } +@test "install writes a completion manifest, --update carries install_id and bumps gen" { + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + local manifest="$SK/run/install-manifest.json" + [ -f "$manifest" ] + # The operation lock DB must exist, never be empty-deleted by install + # itself, and never appear in the manifest's own file listing (it lives + # under run/, not scripts/). + [ -f "$SK/run/install-op.lock.db" ] + + local id1 gen1 + id1="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.install_id');")" + gen1="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.gen');")" + [ -n "$id1" ] + [ "$gen1" = "1" ] + local manifest_version + manifest_version="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.version');")" + [ "$manifest_version" = "$(cat "$SK/VERSION")" ] + + # bootstrap_version: a fixed constant embedded in + # both agmsgd and agmsgd-launch.sh, distinct from the overall install + # version/gen, and copied into the manifest for the real entrypoint (a + # later PR) to check against under the operation lock. + local bv + bv="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.bootstrap_version');")" + [ "$bv" = "1" ] + + # install_id's one source of truth is install.db's meta row; the manifest + # only carries a copy of it. + local db_id + db_id="$(sqlite3 "$SK/run/install.db" "SELECT install_id FROM meta LIMIT 1;")" + [ "$db_id" = "$id1" ] + + # The lock DB holds no tables of its own -- it exists only to be BEGIN + # EXCLUSIVE'd. + local lock_tables + lock_tables="$(sqlite3 "$SK/run/install-op.lock.db" "SELECT count(*) FROM sqlite_master WHERE type='table';")" + [ "$lock_tables" = "0" ] + + # The two daemon bootstrap files were placed (not left out by the bulk + # copy's own exclusion) and are executable, and their digests are in the + # manifest under their scripts/-relative path. + [ -x "$SK/scripts/daemon/agmsgd" ] + [ -x "$SK/scripts/daemon/agmsgd-launch.sh" ] + local listed + listed="$(sqlite3 :memory: "SELECT count(*) FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value,'\$.path') IN ('scripts/daemon/agmsgd','scripts/daemon/agmsgd-launch.sh');")" + [ "$listed" = "2" ] + + # A real file's manifest digest matches an independent sha256 of it right + # now -- not just "a digest is present for it". + local recorded_digest actual_digest + recorded_digest="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.files[0].digest');")" + local first_path + first_path="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.files[0].path');")" + actual_digest="$(bash -c 'source "$1/scripts/lib/hash.sh"; agmsg_sha256 < "$2"' _ "$REPO_ROOT" "$SK/$first_path")" + [ -n "$actual_digest" ] + [ "$recorded_digest" = "$actual_digest" ] + + mkdir -p "$SK/scripts/drivers/terminals/herdr" + printf 'stale\n' > "$SK/scripts/drivers/terminals/herdr/SKILL.md" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --update + [ -f "$manifest" ] + [ -f "$manifest.prev" ] + [ ! -e "$SK/scripts/drivers/terminals/herdr/SKILL.md" ] + local id2 gen2 + id2="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.install_id');")" + gen2="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.gen');")" + [ "$id2" = "$id1" ] + [ "$gen2" = "2" ] + manifest_version="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.version');")" + [ "$manifest_version" = "$(cat "$SK/VERSION")" ] + local stale_listed + stale_listed="$(sqlite3 :memory: "SELECT count(*) FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value,'\$.path')='scripts/drivers/terminals/herdr/SKILL.md';")" + [ "$stale_listed" = "0" ] + db_id="$(sqlite3 "$SK/run/install.db" "SELECT install_id FROM meta LIMIT 1;")" + [ "$db_id" = "$id1" ] + + # A readable previous generation can recover a damaged current manifest. + printf 'not json\n' > "$manifest" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --update + gen2="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$manifest")'), '\$.gen');")" + [ "$gen2" = "2" ] + + # With neither record readable, update must stop before touching VERSION or + # the installed scripts instead of silently reusing generation 1. + printf 'not json\n' > "$manifest" + printf 'not json\n' > "$manifest.prev" + local unchanged_digest before_version + before_version="$(cat "$SK/VERSION")" + unchanged_digest="$(shasum -a 256 "$SK/scripts/team.sh" | awk '{print $1}')" + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --update + [ "$status" -ne 0 ] + grep -qF -- "manifest and its previous copy are unreadable" <<<"$output" + [ "$(cat "$SK/VERSION")" = "$before_version" ] + [ "$(shasum -a 256 "$SK/scripts/team.sh" | awk '{print $1}')" = "$unchanged_digest" ] +} + +@test "install manifest batches safe names and checks its probe before publishing" { + local scripts="$FAKE_HOME/manifest-scripts" safe_scripts="$FAKE_HOME/safe-manifest-scripts" + local manifest="$FAKE_HOME/manifest.json" + local bad_manifest="$FAKE_HOME/bad-manifest.json" fake_bin="$FAKE_HOME/fake-bin" + mkdir -p "$scripts" "$fake_bin" + printf 'safe file\n' > "$scripts/safe.sh" + printf 'space file\n' > "$scripts/with space.sh" + + run bash -c '. "$1/scripts/lib/install-manifest.sh"; agmsg_install_manifest_write "$2" "$3" 1.5.1 test-install 1 1' \ + _ "$REPO_ROOT" "$scripts" "$manifest" + [ "$status" -eq 0 ] + local recorded_digest actual_digest + recorded_digest="$(sqlite3 :memory: "SELECT json_extract(value, '\$.digest') FROM json_each(json_extract(readfile('$(rf "$manifest")'), '\$.files')) WHERE json_extract(value, '\$.path')='scripts/with space.sh';")" + actual_digest="$(bash -c 'source "$1/scripts/lib/hash.sh"; agmsg_sha256 < "$2"' _ "$REPO_ROOT" "$scripts/with space.sh")" + [ "$recorded_digest" = "$actual_digest" ] + + mkdir -p "$safe_scripts" + printf 'safe file\n' > "$safe_scripts/safe.sh" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [ "${1:-}" = "-a" ]; then shift 2; fi' \ + 'for path do printf "%064d %s\\n" 0 "$path"; done' > "$fake_bin/shasum" + chmod +x "$fake_bin/shasum" + run env PATH="$fake_bin:$PATH" bash -c '. "$1/scripts/lib/install-manifest.sh"; agmsg_install_manifest_write "$2" "$3" 1.5.1 test-install 1 1' \ + _ "$REPO_ROOT" "$safe_scripts" "$bad_manifest" + [ "$status" -ne 0 ] + grep -qF -- 'returned the wrong digest for the install-manifest probe' <<<"$output" + [ ! -e "$bad_manifest" ] +} + +@test "install holds the operation lock while rendering the shared skill" { + local bin="$FAKE_HOME/bin" entered="$FAKE_HOME/render-entered" release="$FAKE_HOME/render-release" + local awk_real install_pid install_rc blocked=0 i + awk_real="$(command -v awk)" + mkdir -p "$bin" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'for arg do case "$arg" in fragment=*) touch "$AGMSG_TEST_RENDER_ENTERED"; while [ ! -e "$AGMSG_TEST_RENDER_RELEASE" ]; do sleep 0.02; done ;; esac; done' \ + 'exec "$AGMSG_TEST_REAL_AWK" "$@"' > "$bin/awk" + chmod +x "$bin/awk" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_RENDER_ENTERED="$entered" AGMSG_TEST_RENDER_RELEASE="$release" \ + AGMSG_TEST_REAL_AWK="$awk_real" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg > "$FAKE_HOME/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --cmd agmsg" + + for ((i = 0; i < 250; i++)); do + [ -e "$entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + if [ -e "$entered" ]; then + if bash -c 'source "$1"; if agmsg_install_op_lock "$2" 300; then agmsg_install_op_unlock; exit 3; else exit 0; fi' \ + _ "$REPO_ROOT/scripts/lib/install-op-lock.sh" "$SK/run/install-op.lock.db"; then + blocked=1 + fi + fi + touch "$release" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + + [ -e "$entered" ] + [ "$blocked" -eq 1 ] + [ "$install_rc" -eq 0 ] + [ -f "$SK/run/install-manifest.json" ] +} + +@test "install stops before pruning when the lock child dies during scripts copy" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/lock-loss" + local sqlite_real cp_real sqlite_q cp_q lock_pid lock_cmd install_pid install_rc=0 + local before_version manifest="$SK/run/install-manifest.json" op_id i + sqlite_real="$(command -v sqlite3)" + cp_real="$(command -v cp)" + sqlite_q="$(printf '%q' "$sqlite_real")" + cp_q="$(printf '%q' "$cp_real")" + mkdir -p "$bin" "$inject_dir" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + printf 'retired test file\n' > "$SK/scripts/retired-test-file" + before_version="$(cat "$SK/VERSION")" + [ -s "$manifest" ] + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'if [ "${1:-}" = "$lock_db" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid"; fi' \ + "exec $sqlite_q \"\$@\"" > "$bin/sqlite3" + chmod +x "$bin/sqlite3" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'target="$test_home/.agents/skills/agmsg/scripts/"' \ + 'dest=""; for arg do dest="$arg"; done' \ + 'if [ "$dest" = "$target" ] && [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ;; esac' \ + ' fi' \ + ' touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/copy-entered"' \ + ' while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/copy-release" ]; do sleep 0.02; done' \ + 'fi' \ + "exec $cp_q \"\$@\"" > "$bin/cp" + chmod +x "$bin/cp" + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/copy-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/copy-entered" ] + [ -e "$inject_dir/fired" ] + [ -e "$SK/run/install-op-incomplete.json" ] + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --keep-data --yes + [ "$status" -ne 0 ] + grep -qF -- "earlier install operation (update) is incomplete; uninstall (keep-data) will not start" <<<"$output" + grep -qF -- "--recover $op_id" <<<"$output" + touch "$inject_dir/copy-release" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -ne 0 ] + grep -qF "install lock was lost partway through" "$inject_dir/install.out" + [ -f "$SK/scripts/retired-test-file" ] + [ "$(cat "$SK/VERSION")" = "$before_version" ] + [ ! -e "$manifest" ] + [ -s "$manifest.prev" ] + + run bash -c 'exec "$@" 2>&1' _ env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --keep-data --yes --recover "$op_id" + [ "$status" -eq 0 ] + grep -qF -- "Recorded operation: install (update)" <<<"$output" + grep -qF -- "Continuing requested operation: uninstall (keep-data)" <<<"$output" + [ ! -e "$SK/run/install-op-incomplete.json" ] + [ ! -e "$SK/scripts" ] + [ ! -e "$SK/SKILL.md" ] + [ ! -e "$SK/run/install-op-recovery.sh" ] +} + +@test "install cancellation waits for the active writer before clearing its operation record" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/cancel-writer" + local cp_real cp_q sqlite_real sqlite_q install_pid writer_pid lock_pid install_rc=0 i op_id stage_source stage_dir tmp_root + cp_real="$(command -v cp)" + cp_q="$(printf '%q' "$cp_real")" + sqlite_real="$(command -v sqlite3)" + sqlite_q="$(printf '%q' "$sqlite_real")" + mkdir -p "$bin" "$inject_dir" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/cancel-writer}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'if [ "${1:-}" = "$lock_db" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid"; fi' \ + "exec $sqlite_q \"\$@\"" > "$bin/sqlite3" + chmod +x "$bin/sqlite3" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/cancel-writer}"' \ + 'target="$test_home/.agents/skills/agmsg/scripts/"' \ + 'dest=""; for arg do dest="$arg"; done' \ + 'if [ "$dest" = "$target" ]; then exec 8>&- 9>&-; printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer-entered"; if [ -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-kill-mode" ]; then printf "%s\\n" "$2" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/stage-source"; while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-release" ]; do sleep 0.02; done; exit 0; fi; while :; do sleep 1; done; fi' \ + "exec $cp_q \"\$@\"" > "$bin/cp" + chmod +x "$bin/cp" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/writer-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/writer-entered" ] + writer_pid="$(cat "$inject_dir/writer.pid")" + _agmsg_watch_pid "$writer_pid" "$bin/cp" + [ -e "$SK/run/install-op-incomplete.json" ] + + kill -TERM "$install_pid" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -eq 143 ] + refute kill -0 "$writer_pid" 2>/dev/null + [ ! -e "$SK/run/install-op-incomplete.json" ] + [ -f "$SK/run/install-manifest.json.prev" ] + + touch "$inject_dir/hard-kill-mode" + rm -f "$inject_dir/writer-entered" "$inject_dir/hard-release" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/hard-kill-install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/writer-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/writer-entered" ] + writer_pid="$(cat "$inject_dir/writer.pid")" + lock_pid="$(cat "$inject_dir/lock.pid")" + _agmsg_watch_pid "$writer_pid" "$bin/cp" + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + kill -KILL "$install_pid" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -eq 137 ] + kill -0 "$writer_pid" 2>/dev/null + wait_for_pid_exit "$lock_pid" + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --keep-data --yes + [ "$status" -ne 0 ] + grep -qF -- "earlier install operation (update) is incomplete" <<<"$output" + kill -0 "$writer_pid" 2>/dev/null + stage_source="$(cat "$inject_dir/stage-source")" + case "$stage_source" in */.) stage_dir="${stage_source%/.}" ;; *) return 1 ;; esac + touch "$inject_dir/hard-release" + wait_for_pid_exit "$writer_pid" + tmp_root="${TMPDIR:-/tmp}" + tmp_root="${tmp_root%/}" + case "$stage_dir" in "$tmp_root"/tmp.*) rm -rf "$stage_dir" ;; *) return 1 ;; esac + [ -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update --recover "$op_id" + [ "$status" -eq 0 ] + [ ! -e "$SK/run/install-op-incomplete.json" ] +} + +@test "install cancellation before writer pid publication keeps the incomplete-operation record" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/pid-publication" + local cp_real cp_q install_pid writer_pid install_rc=0 i op_id + cp_real="$(command -v cp)" + cp_q="$(printf '%q' "$cp_real")" + mkdir -p "$bin" "$inject_dir" + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'dest=""; for arg do dest="$arg"; done' \ + 'if [ "$dest" = "'"$SK"'/scripts/" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_WRITER_GATE_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_WRITER_GATE_DIR/writer-entered"; while [ ! -e "$AGMSG_TEST_INSTALL_WRITER_GATE_DIR/writer-release" ]; do sleep 0.02; done; fi' \ + "exec $cp_q \"\$@\"" > "$bin/cp" + chmod +x "$bin/cp" + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_WRITER_GATE_DIR="$inject_dir" \ + AGMSG_TEST_INSTALL_OP_WRITER_PID_PUBLISH_GATE="$inject_dir/publish" \ + bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + install_pid=$! + _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/publish.pid" ] && [ -e "$inject_dir/writer-entered" ] && break + kill -0 "$install_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/publish.pid" ] + [ -e "$inject_dir/writer-entered" ] + writer_pid="$(cat "$inject_dir/writer.pid")" + _agmsg_watch_pid "$writer_pid" "$bin/cp" + [ -e "$SK/run/install-op-incomplete.json" ] + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + + kill -TERM "$install_pid" + if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi + [ "$install_rc" -eq 143 ] + kill -0 "$writer_pid" 2>/dev/null + [ -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update + [ "$status" -ne 0 ] + grep -qF -- "earlier install operation (update) is incomplete" <<<"$output" + grep -qF -- "--recover $op_id" <<<"$output" + + touch "$inject_dir/writer-release" + wait_for_pid_exit "$writer_pid" + [ -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update --recover "$op_id" + [ "$status" -eq 0 ] + [ ! -e "$SK/run/install-op-incomplete.json" ] +} + +@test "uninstall keeps run/install-op.lock.db while removing everything else" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ -f "$SK/run/install-op.lock.db" ] + [ -f "$SK/run/install.db" ] + [ -f "$SK/scripts/team.sh" ] + + HOME="$FAKE_HOME" bash "$REPO_ROOT/uninstall.sh" --yes + + [ -f "$SK/run/install-op.lock.db" ] + [ ! -e "$SK/run/install.db" ] + [ ! -e "$SK/run/install-manifest.json" ] + [ ! -e "$SK/scripts" ] + [ ! -e "$SK/SKILL.md" ] +} + +@test "uninstall preserves recovery through removal and cannot delete a later install entrypoint" { + local bin="$FAKE_HOME/bin" inject_dir="$FAKE_HOME/lock-loss" + local sqlite_real rm_real mv_real sqlite_q rm_q mv_q uninstall_pid uninstall_rc=0 lock_pid lock_cmd op_id moved_op_id i retired_entrypoint candidate + sqlite_real="$(command -v sqlite3)" + rm_real="$(command -v rm)" + mv_real="$(command -v mv)" + sqlite_q="$(printf '%q' "$sqlite_real")" + rm_q="$(printf '%q' "$rm_real")" + mv_q="$(printf '%q' "$mv_real")" + mkdir -p "$bin" "$inject_dir" + + HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ -f "$SK/SKILL.md" ] + [ -d "$SK/scripts" ] + + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'if [ "${1:-}" = "$lock_db" ]; then printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid"; fi' \ + "exec $sqlite_q \"\$@\"" > "$bin/sqlite3" + chmod +x "$bin/sqlite3" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'target="$test_home/.agents/skills/agmsg/scripts"' \ + 'final_root="$test_home/.agents/skills/agmsg/uninstall.sh"' \ + 'final_retired="$test_home/.agents/skills/agmsg/.install-op-uninstaller-retired."' \ + 'has_target=false; has_final=false; for arg do [ "$arg" = "$target" ] && has_target=true; case "$arg" in "$final_root"|"$final_retired"*) has_final=true ;; esac; done' \ + 'if [ "$has_target" = true ] && [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/fired" ;; esac' \ + ' fi' \ + ' touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/rm-entered"' \ + ' while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/rm-release" ]; do sleep 0.02; done' \ + 'fi' \ + 'if [ "$has_final" = true ] && [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-fired" ;; esac' \ + ' fi' \ + ' touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-rm-entered"' \ + ' while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/final-rm-release" ]; do sleep 0.02; done' \ + 'fi' \ + "exec $rm_q \"\$@\"" > "$bin/rm" + chmod +x "$bin/rm" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/lock-loss}"' \ + 'lock_db="$test_home/.agents/skills/agmsg/run/install-op.lock.db"' \ + 'source_path="$1"; dest_path="$2"' \ + 'case "$source_path:$dest_path" in "$test_home/.agents/skills/agmsg/uninstall.sh:$test_home/.agents/skills/agmsg/.install-op-uninstaller-retired."*)' \ + ' if [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/move-fired" ]; then' \ + ' lock_pid="$(cat "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/lock.pid" 2>/dev/null)"' \ + ' case "$lock_pid" in ""|*[!0-9]*) lock_pid="" ;; esac' \ + ' if [ -n "$lock_pid" ]; then' \ + ' lock_cmd="$(/bin/ps -p "$lock_pid" -o args= 2>/dev/null)"' \ + ' case "$lock_cmd" in *"$lock_db"*) kill -9 "$lock_pid" 2>/dev/null && touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/move-fired" ;; esac' \ + ' fi' \ + ' fi ;;' \ + 'esac' \ + "exec $mv_q \"\$@\"" > "$bin/mv" + chmod +x "$bin/mv" + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$SK/uninstall.sh" --yes > "$inject_dir/uninstall.out" 2>&1 & + uninstall_pid=$! + _agmsg_watch_pid "$uninstall_pid" "$SK/uninstall.sh --yes" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/rm-entered" ] && break + kill -0 "$uninstall_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/rm-entered" ] + [ -e "$inject_dir/fired" ] + [ -e "$SK/run/install-op-incomplete.json" ] + op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + [ -x "$SK/uninstall.sh" ] + [ -r "$SK/run/install-op-recovery.sh" ] + run bash -c 'exec "$@" 2>&1' _ env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg --update + [ "$status" -ne 0 ] + grep -qF -- "earlier uninstall operation (remove-data) is incomplete; install (update) will not start" <<<"$output" + grep -qF -- "--recover $op_id" <<<"$output" + touch "$inject_dir/rm-release" + if wait "$uninstall_pid"; then uninstall_rc=0; else uninstall_rc=$?; fi + [ "$uninstall_rc" -ne 0 ] + grep -qF "install lock was lost partway through" "$inject_dir/uninstall.out" + # The full uninstall walks top-level entries in locale-dependent glob + # order. This injection stops immediately after removing scripts, so other + # entries may legitimately remain if they sort after scripts. + [ ! -e "$SK/scripts" ] + [ -f "$SK/run/install-op.lock.db" ] + [ -x "$SK/uninstall.sh" ] + [ -r "$SK/run/install-op-recovery.sh" ] + + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$SK/uninstall.sh" --cmd agmsg --yes --recover "$op_id" > "$inject_dir/recovery.out" 2>&1 & + uninstall_pid=$! + _agmsg_watch_pid "$uninstall_pid" "$SK/uninstall.sh --cmd agmsg --yes --recover $op_id" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/move-fired" ] && break + kill -0 "$uninstall_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/move-fired" ] + if wait "$uninstall_pid"; then uninstall_rc=0; else uninstall_rc=$?; fi + [ "$uninstall_rc" -ne 0 ] + grep -qF "install lock was lost partway through" "$inject_dir/recovery.out" + [ -e "$SK/run/install-op-incomplete.json" ] + moved_op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" + retired_entrypoint="" + for candidate in "$SK"/.install-op-uninstaller-retired.*; do + [ -e "$candidate" ] || continue + retired_entrypoint="$candidate" + break + done + [ -f "$retired_entrypoint" ] + [ ! -e "$SK/uninstall.sh" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/uninstall.sh" --cmd agmsg --yes + [ "$status" -ne 0 ] + grep -qF -- "operation: uninstall" <<<"$output" + grep -qF -- "mode: remove-data" <<<"$output" + grep -qF -- "Recovery command: bash $retired_entrypoint --cmd agmsg --yes --recover $moved_op_id" <<<"$output" + env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ + AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ + bash "$retired_entrypoint" --cmd agmsg --yes --recover "$moved_op_id" > "$inject_dir/final-recovery.out" 2>&1 & + uninstall_pid=$! + _agmsg_watch_pid "$uninstall_pid" "$retired_entrypoint --cmd agmsg --yes --recover $moved_op_id" + for ((i = 0; i < 250; i++)); do + [ -e "$inject_dir/final-rm-entered" ] && break + kill -0 "$uninstall_pid" 2>/dev/null || break + sleep 0.02 + done + [ -e "$inject_dir/final-rm-entered" ] + [ -e "$inject_dir/final-fired" ] + [ ! -e "$SK/run/install-op-incomplete.json" ] + run env HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" \ + bash "$REPO_ROOT/install.sh" --cmd agmsg + [ "$status" -eq 0 ] + [ -x "$SK/uninstall.sh" ] + [ -f "$SK/run/install-op-recovery.sh" ] + touch "$inject_dir/final-rm-release" + wait_for_pid_exit "$uninstall_pid" + diff "$REPO_ROOT/uninstall.sh" "$SK/uninstall.sh" + [ -f "$SK/run/install-op-recovery.sh" ] +} + @test "no rendered skill of any type still carries the unwired 'supplied by the type overlay' comment" { # The shared root SKILL.md used to carry two lines that read like slot # markers right after the spawn slot -- "shared actas/drop guidance is diff --git a/tests/test_install_op_lock.bats b/tests/test_install_op_lock.bats new file mode 100644 index 000000000..74eb79cbe --- /dev/null +++ b/tests/test_install_op_lock.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +# The install/uninstall operation lock (agmsgd beta): a +# single sqlite3 coprocess bash keeps alive across a whole install.sh / +# uninstall.sh run, fed through a pair of named pipes so BEGIN EXCLUSIVE +# stays open while bash does its own work in between. This file also covers +# the case where only the sqlite3 lock-holding child dies while bash remains +# alive and unaware. + +load test_helper + +setup() { + setup_test_env + LOCKLIB="$SCRIPTS/lib/install-op-lock.sh" + LOCK_DB="$BATS_TEST_TMPDIR/install-op.lock.db" +} + +@test "install-op-lock: acquires, confirms, blocks a second acquirer, and releases cleanly" { + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + agmsg_install_op_confirm || { echo "confirm failed while alive"; exit 1; } + echo "held pid=$_AGMSG_LOCK_PID" + + # A second, independent process trying the SAME db must fail fast, not + # hang for the full busy_timeout of its own accord succeeding. + ( source "$1" + if agmsg_install_op_lock "$2" 500; then + echo "second UNEXPECTEDLY acquired" + exit 1 + fi + echo "second correctly failed to acquire" + ) + + agmsg_install_op_unlock + + # The lock DB is never deleted and must be reusable immediately after. + agmsg_install_op_lock "$2" 3000 || { echo "re-acquire after unlock failed"; exit 1; } + agmsg_install_op_unlock + echo "re-acquire after unlock ok" + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qF "held pid=" + printf '%s\n' "$output" | grep -qF "second correctly failed to acquire" + printf '%s\n' "$output" | grep -qF "re-acquire after unlock ok" +} + +@test "install-op-lock: detects when only the lock-holding sqlite3 child dies" { + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + + # Kill ONLY the sqlite3 coprocess -- this process (the "bash" role in + # This process stays alive and unaware, as after a child-only crash + # during an install operation. + kill -9 "$_AGMSG_LOCK_PID" + sleep 0.3 + + if agmsg_install_op_confirm; then + echo "confirm WRONGLY reported the lock still held" + exit 1 + fi + echo "confirm correctly detected the dead lock-holder" + + # The OS file lock must be genuinely gone -- a fresh, independent + # acquirer (standing in for a retried operation) can now get it. This is + # the property that makes "abort rather than continue unprotected" safe + # to do here instead of trying to resurrect the same lock. + ( source "$1" + if ! agmsg_install_op_lock "$2" 3000; then + echo "independent re-acquire after the child died UNEXPECTEDLY failed" + exit 1 + fi + agmsg_install_op_unlock + echo "independent re-acquire after the child died ok" + ) + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | grep -qF "confirm correctly detected the dead lock-holder" + printf '%s\n' "$output" | grep -qF "independent re-acquire after the child died ok" +} + +@test "install-op-lock: unlock does not silently redirect the caller's stderr for the rest of the script" { + # Regression test: `exec 9>&- 2>/dev/null` (no command -- redirections on + # a bare `exec` apply to the CURRENT SHELL, not to that one statement) + # silently sent every later stderr write in the calling script to + # /dev/null for good, including a completely unrelated command's own + # error message. Caught because install.sh's own Codex-shim ownership + # refusal (written to stderr) stopped appearing in its output after any + # lock/unlock cycle earlier in the same run. + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + agmsg_install_op_unlock + echo "stderr still works after unlock" >&2 + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + [[ "$output" == *"stderr still works after unlock"* ]] +} + +@test "install-op-lock: the lock DB never holds any tables of its own" { + run bash -c ' + source "$1" + agmsg_install_op_lock "$2" 3000 || { echo "acquire failed"; exit 1; } + agmsg_install_op_unlock + ' _ "$LOCKLIB" "$LOCK_DB" + [ "$status" -eq 0 ] + [ -f "$LOCK_DB" ] + run sqlite3 "$LOCK_DB" "SELECT count(*) FROM sqlite_master WHERE type='table';" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} diff --git a/tests/test_spawn_fd_guard.bats b/tests/test_spawn_fd_guard.bats index 7e61e6412..2d10a1722 100644 --- a/tests/test_spawn_fd_guard.bats +++ b/tests/test_spawn_fd_guard.bats @@ -28,7 +28,10 @@ _unguarded_spawns() { ;; *) printf '%s:%s: closes neither fd 3 nor fd 4 -- %s\n' "$file" "$line" "$rest" ;; esac - done < <(grep -rnE '^[^#]*[^&|]&[[:space:]]*$' "$root" 2>/dev/null) + done < <( + grep -rnE '^[^#]*[^&|]&[[:space:]]*$' "$root" 2>/dev/null + grep -rnE '^[^#]*coproc[[:space:]]' "$root" 2>/dev/null + ) } @test "every background spawn under scripts/ closes bats' fd 3 and fd 4" { @@ -44,10 +47,13 @@ _unguarded_spawns() { # Without this, a pattern that quietly stopped matching would leave a test # passing because it found nothing -- the failure being guarded against is a # silent one, so the count is asserted rather than assumed. - local total + local total coproc_total total="$(grep -rcE '^[^#]*[^&|]&[[:space:]]*$' "$REPO_ROOT/scripts" 2>/dev/null \ | awk -F: '{s+=$2} END {print s+0}')" + coproc_total="$(grep -rcE '^[^#]*coproc[[:space:]]' "$REPO_ROOT/scripts" 2>/dev/null \ + | awk -F: '{s+=$2} END {print s+0}')" [ "$total" -ge 5 ] + [ "$coproc_total" -ge 1 ] } @test "a spawn closing only fd 3 is reported" { diff --git a/uninstall.sh b/uninstall.sh index a6e7b11ea..5ba2e28ba 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -16,18 +16,64 @@ set -euo pipefail AGENTS_DIR="$HOME/.agents" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck disable=SC1091 -. "$SCRIPT_DIR/scripts/lib/codex-config.sh" +AGMSG_INSTALL_OP_RECOVERY_SOURCE="" +if [ -e "$SCRIPT_DIR/run/install-op-incomplete.json" ] || [ -L "$SCRIPT_DIR/run/install-op-incomplete.json" ]; then + if [ -r "$SCRIPT_DIR/run/install-op-recovery.sh" ]; then + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$SCRIPT_DIR/run/install-op-recovery.sh" + else + for _agmsg_recovery_candidate in "$SCRIPT_DIR"/run/.install-op-recovery-retired.*; do + [ -r "$_agmsg_recovery_candidate" ] || continue + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$_agmsg_recovery_candidate" + break + done + unset _agmsg_recovery_candidate + fi +fi +if [ -n "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + # Prefer the atomically-published recovery code while an operation record + # exists; scripts/ may be incomplete after an interrupted update. + # shellcheck disable=SC1090 + . "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" +elif [ -r "$SCRIPT_DIR/scripts/lib/codex-config.sh" ] && [ -r "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" ]; then + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/scripts/lib/codex-config.sh" + # The same operation lock install.sh takes (agmsgd beta). + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" +else + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$SCRIPT_DIR/run/install-op-recovery.sh" + if [ ! -r "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + for _agmsg_recovery_candidate in "$SCRIPT_DIR"/run/.install-op-recovery-retired.*; do + [ -r "$_agmsg_recovery_candidate" ] || continue + AGMSG_INSTALL_OP_RECOVERY_SOURCE="$_agmsg_recovery_candidate" + break + done + unset _agmsg_recovery_candidate + fi + if [ ! -r "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + echo " ! uninstall support files are missing; restore the install before continuing" >&2 + exit 1 + fi + # This fallback remains under run/ while an uninstall is incomplete, so an + # installed copy can recover after --keep-data removed scripts/. + # shellcheck disable=SC1090 + . "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" +fi AUTO_YES=false KEEP_DATA=false REMOVE_ALL=false +CMD_NAME="" +RECOVER_ID="" +AGMSG_INSTALL_OP_ACTIVE=false while [[ $# -gt 0 ]]; do case "$1" in --yes|-y) AUTO_YES=true; shift ;; --keep-data) KEEP_DATA=true; shift ;; --all) REMOVE_ALL=true; shift ;; + --cmd) CMD_NAME="$2"; shift 2 ;; + --recover) RECOVER_ID="$2"; shift 2 ;; -h|--help) echo "Usage: ./uninstall.sh [options]" echo "" @@ -35,12 +81,19 @@ while [[ $# -gt 0 ]]; do echo " --yes, -y Remove without confirmation" echo " --keep-data Remove skill but keep DB and team configs" echo " --all Remove every agmsg install on the machine" + echo " --cmd Select one installation under ~/.agents/skills" + echo " --recover Clear a verified incomplete operation, then continue uninstall" exit 0 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done +if [ -n "$RECOVER_ID" ] && { [ -z "$CMD_NAME" ] || [ "$REMOVE_ALL" = true ]; }; then + echo " ! --recover requires --cmd and cannot be combined with --all" >&2 + exit 1 +fi + echo "" echo " agmsg — Uninstall" echo " ──────────────────" @@ -55,6 +108,33 @@ confirm() { REMOVED=false +_uninstall_operation_exit() { + if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + fi +} +trap '_uninstall_operation_exit' EXIT +trap 'agmsg_install_op_handle_signal INT' INT +trap 'agmsg_install_op_handle_signal TERM' TERM + +_uninstall_operation_require() { + if agmsg_install_op_require; then + return 0 + fi + agmsg_install_op_unlock + return 1 +} + +_uninstall_checked_rm() { + _uninstall_operation_require || return 1 + if ! agmsg_install_op_run_writer rm "$@"; then + agmsg_install_op_unlock + return 1 + fi + _uninstall_operation_require +} + # Removes this install's own writable_roots entries (SKILL_DIR's db/, # teams/, run/, ext-tools/) from ONE Codex config.toml, if it exists and # actually mentions them. Split out of _uninstall_one so it can be applied @@ -126,8 +206,11 @@ _uninstall_clean_codex_config() { if cmp -s "$CODEX_CONFIG" "$CODEX_CONFIG.tmp"; then rm -f "$CODEX_CONFIG.tmp" else + _uninstall_operation_require || return 1 cp "$CODEX_CONFIG" "$CODEX_CONFIG.bak" + _uninstall_operation_require || return 1 mv "$CODEX_CONFIG.tmp" "$CODEX_CONFIG" + _uninstall_operation_require || return 1 echo " - cleaned Codex writable_roots in $CODEX_CONFIG (backup: $(basename "$CODEX_CONFIG").bak)" REMOVED=true fi @@ -140,6 +223,55 @@ _uninstall_clean_codex_config() { _uninstall_one() { local SKILL_DIR="$1" local SKILL_NAME; SKILL_NAME="$(basename "$SKILL_DIR")" + + # Operation lock (agmsgd beta): held for the whole of this function, + # the same lock install.sh takes for the same + # SKILL_DIR. A failure here means another install/uninstall/enable/ + # disable is already in progress against this exact install -- refuse + # rather than race it. No trap releases this on an unexpected abort: the + # OS drops the file lock the moment this process dies; there is no + # stale-lock recovery step, so an uninstall + # that crashes mid-way is the same "held until the holder dies" state a + # crashed install.sh already leaves. + if ! agmsg_install_op_lock "$SKILL_DIR/run/install-op.lock.db"; then + echo " ! could not take the install operation lock for $SKILL_DIR: ${AGMSG_INSTALL_OP_LOCK_FAILURE_REASON:-unknown lock handshake failure}" >&2 + return 1 + fi + AGMSG_INSTALL_OP_ACTIVE=true + _uninstall_operation_require || return 1 + + local pending="$SKILL_DIR/run/install-op-incomplete.json" + local recovery_prefix recovery_entrypoint="$SKILL_DIR/uninstall.sh" running_entrypoint="" operation_mode=remove-data recovery_helper="$SKILL_DIR/run/install-op-recovery.sh" recovery_retired="" uninstaller_retired="" + if [ "$SCRIPT_DIR" = "$SKILL_DIR" ]; then + case "$(basename "$0")" in + .install-op-uninstaller-retired.*) running_entrypoint="$SKILL_DIR/$(basename "$0")" ;; + esac + fi + if [ ! -f "$recovery_entrypoint" ]; then + if [ -f "$pending" ] && agmsg_install_op_pending_validate "$pending"; then + local _recovery_entrypoint_candidate="$SKILL_DIR/.install-op-uninstaller-retired.$AGMSG_INSTALL_OP_PENDING_ID" + [ -f "$_recovery_entrypoint_candidate" ] && recovery_entrypoint="$_recovery_entrypoint_candidate" + unset _recovery_entrypoint_candidate + fi + fi + [ "$KEEP_DATA" = true ] && operation_mode=keep-data + recovery_prefix="bash $(printf '%q' "$recovery_entrypoint") --cmd $(printf '%q' "$(basename "$SKILL_DIR")")" + [ "$KEEP_DATA" = true ] && recovery_prefix="$recovery_prefix --keep-data" + [ "$AUTO_YES" = true ] && recovery_prefix="$recovery_prefix --yes" + recovery_prefix="$recovery_prefix --recover" + if [ -n "$RECOVER_ID" ]; then + agmsg_install_op_pending_recover "$pending" "$RECOVER_ID" uninstall "$operation_mode" || return 1 + RECOVER_ID="" + elif [ -e "$pending" ] || [ -L "$pending" ]; then + agmsg_install_op_pending_refuse "$pending" "$recovery_prefix" uninstall "$operation_mode" + return 1 + fi + AGMSG_INSTALL_OP_MARKER="$pending" + agmsg_install_op_pending_begin "$pending" uninstall "$SKILL_DIR" "" "$operation_mode" || return 1 + uninstaller_retired="$SKILL_DIR/.install-op-uninstaller-retired.$AGMSG_INSTALL_OP_ID" + if [ -n "$running_entrypoint" ] && [ ! -f "$SKILL_DIR/uninstall.sh" ]; then + uninstaller_retired="$running_entrypoint" + fi # This install's own path with its trailing slash (review): matching on # SKILL_NAME or a bare SKILL_DIR prefix is not a boundary -- "agmsg" is a # literal substring of "agmsg-second", and "$SKILL_DIR" (no trailing @@ -196,7 +328,7 @@ _uninstall_one() { [ -f "$cmd_file" ] || continue if grep -qF "$SKILL_DIR_SLASH" "$cmd_file" 2>/dev/null; then local cmd_name; cmd_name=$(basename "$cmd_file" .md) - rm "$cmd_file" + _uninstall_checked_rm "$cmd_file" || return 1 echo " - removed /$cmd_name command from $project" REMOVED=true fi @@ -246,7 +378,9 @@ _uninstall_one() { ); " 2>/dev/null) || true if [ -n "$UPDATED" ] && [ "$UPDATED" != "$SETTINGS_ESC" ]; then + _uninstall_operation_require || return 1 echo "$UPDATED" > "$settings_file" + _uninstall_operation_require || return 1 echo " - removed agmsg hook from $settings_file" REMOVED=true fi @@ -274,7 +408,7 @@ _uninstall_one() { [ -n "$project" ] || continue local copilot_hook="$project/.github/hooks/agmsg.json" if [ -f "$copilot_hook" ] && grep -qF "$SKILL_DIR_SLASH" "$copilot_hook" 2>/dev/null; then - rm "$copilot_hook" + _uninstall_checked_rm "$copilot_hook" || return 1 echo " - removed agmsg Copilot hook from $project" REMOVED=true fi @@ -307,7 +441,7 @@ _uninstall_one() { [ -n "$project" ] || continue local grok_rule="$project/.grok/rules/agmsg.md" if [ -f "$grok_rule" ] && grep -qF "$SKILL_DIR_SLASH" "$grok_rule" 2>/dev/null; then - rm "$grok_rule" + _uninstall_checked_rm "$grok_rule" || return 1 echo " - removed agmsg Grok Build rule from $project" REMOVED=true fi @@ -318,7 +452,7 @@ _uninstall_one() { # --- Remove Claude Code global command --- local CC_CMD="$HOME/.claude/commands/$SKILL_NAME.md" if [ -f "$CC_CMD" ]; then - rm "$CC_CMD" + _uninstall_checked_rm "$CC_CMD" || return 1 echo " - removed /$SKILL_NAME from ~/.claude/commands/" REMOVED=true fi @@ -326,7 +460,7 @@ _uninstall_one() { # --- Remove Copilot CLI skill --- local COPILOT_SKILL="$HOME/.copilot/skills/$SKILL_NAME" if [ -d "$COPILOT_SKILL" ]; then - rm -rf "$COPILOT_SKILL" + _uninstall_checked_rm -rf "$COPILOT_SKILL" || return 1 echo " - removed /$SKILL_NAME skill from ~/.copilot/skills/" REMOVED=true fi @@ -334,7 +468,7 @@ _uninstall_one() { # --- Remove Antigravity skill --- local ANTIGRAVITY_SKILL="$HOME/.gemini/config/skills/$SKILL_NAME" if [ -d "$ANTIGRAVITY_SKILL" ]; then - rm -rf "$ANTIGRAVITY_SKILL" + _uninstall_checked_rm -rf "$ANTIGRAVITY_SKILL" || return 1 echo " - removed /$SKILL_NAME skill from ~/.gemini/config/skills/" REMOVED=true fi @@ -343,7 +477,7 @@ _uninstall_one() { local helper for helper in "$AGENTS_DIR/$SKILL_NAME.ps1" "$AGENTS_DIR/$SKILL_NAME-run.sh"; do if [ -f "$helper" ]; then - rm "$helper" + _uninstall_checked_rm "$helper" || return 1 echo " - removed $helper" REMOVED=true fi @@ -353,16 +487,48 @@ _uninstall_one() { if [ "$KEEP_DATA" = true ]; then echo "" echo " Removing $SKILL_NAME skill (keeping DB and teams)..." - rm -rf "$SKILL_DIR/scripts" "$SKILL_DIR/templates" "$SKILL_DIR/agents" "$SKILL_DIR/.trash" - rm -f "$SKILL_DIR/SKILL.md" + _uninstall_checked_rm -rf "$SKILL_DIR/scripts" "$SKILL_DIR/templates" "$SKILL_DIR/agents" "$SKILL_DIR/.trash" || return 1 + _uninstall_checked_rm -f "$SKILL_DIR/SKILL.md" || return 1 echo " - removed scripts, templates, SKILL.md" echo " ~ preserved $SKILL_DIR/db/ and $SKILL_DIR/teams/" REMOVED=true else echo "" if confirm "Remove $SKILL_NAME (including DB and teams)?"; then - rm -rf "$SKILL_DIR" - echo " - removed $SKILL_DIR" + # run/install-op.lock.db is NEVER deleted, even here (agmsgd beta): + # removing a DB a waiter still has open makes a + # freshly recreated file of the same name a DIFFERENT lock than the + # one the waiter holds a reference to -- see install-op-lock.sh's own + # header). Remove every top-level entry EXCEPT run/ by name, then + # inside run/ remove everything except install-op.lock.db by name -- + # never a single recursive rm -rf "$SKILL_DIR" that cannot make this + # one exception. rmdir (not rm -rf) on SKILL_DIR itself: it correctly + # fails and is left in place, since run/install-op.lock.db means it + # is never truly empty after this. + local _entry _run_entry + for _entry in "$SKILL_DIR"/* "$SKILL_DIR"/.[!.]*; do + [ -e "$_entry" ] || continue + # Keep the installed recovery entrypoint available until the + # incomplete-operation record is cleared below. + case "$(basename "$_entry")" in + uninstall.sh|.install-op-uninstaller-retired.*) continue ;; + esac + if [ "$(basename "$_entry")" = "run" ]; then + for _run_entry in "$_entry"/*; do + [ -e "$_run_entry" ] || continue + case "$(basename "$_run_entry")" in + install-op.lock.db|install-op-incomplete.json|install-op-recovery.sh|.install-op-recovery-retired.*) continue ;; + esac + _uninstall_checked_rm -rf "$_run_entry" || return 1 + done + else + _uninstall_checked_rm -rf "$_entry" || return 1 + fi + done + unset _entry _run_entry + _uninstall_operation_require || return 1 + rmdir "$SKILL_DIR" 2>/dev/null || true + echo " - removed $SKILL_DIR (kept run/install-op.lock.db and the active operation record)" REMOVED=true fi fi @@ -375,7 +541,7 @@ _uninstall_one() { # agmsg_codex_config_paths, scripts/lib/codex-config.sh). local _codex_cfg while IFS= read -r _codex_cfg; do - _uninstall_clean_codex_config "$_codex_cfg" "$SKILL_DIR" + _uninstall_clean_codex_config "$_codex_cfg" "$SKILL_DIR" || return 1 done < <(agmsg_codex_config_paths) # --- Remove OpenCode, Hermes, and Grok Build skill files --- @@ -396,7 +562,7 @@ _uninstall_one() { _dedicated_dir="${_dedicated_dir_label%%|*}" _dedicated_label="${_dedicated_dir_label#*|}" if [ -f "$_dedicated_dir/SKILL.md" ]; then - rm -f "$_dedicated_dir/SKILL.md" + _uninstall_checked_rm -f "$_dedicated_dir/SKILL.md" || return 1 if rmdir "$_dedicated_dir" 2>/dev/null; then echo " - removed /$SKILL_NAME $_dedicated_label skill" else @@ -406,6 +572,47 @@ _uninstall_one() { fi done unset _dedicated_dir_label _dedicated_dir _dedicated_label + + if [ -f "$recovery_helper" ]; then + recovery_retired="$SKILL_DIR/run/.install-op-recovery-retired.$AGMSG_INSTALL_OP_ID" + _uninstall_operation_require || return 1 + mv "$recovery_helper" "$recovery_retired" || return 1 + _uninstall_operation_require || return 1 + elif [ -n "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ] && [ -f "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then + recovery_retired="$SKILL_DIR/run/.install-op-recovery-retired.$AGMSG_INSTALL_OP_ID" + _uninstall_operation_require || return 1 + mv "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" "$recovery_retired" || return 1 + _uninstall_operation_require || return 1 + fi + if [ "$KEEP_DATA" = false ] && [ -f "$SKILL_DIR/uninstall.sh" ]; then + _uninstall_operation_require || return 1 + if ! agmsg_install_op_run_writer mv "$SKILL_DIR/uninstall.sh" "$uninstaller_retired"; then + echo " ! could not preserve the installed recovery entrypoint: $SKILL_DIR/uninstall.sh" >&2 + return 1 + fi + _uninstall_operation_require || return 1 + fi + agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" || { + echo " ! could not clear the completed-operation record; later changes are blocked pending recovery" >&2 + return 1 + } + if [ -n "$uninstaller_retired" ] && [ -e "$uninstaller_retired" ]; then + # Remove only this generation's retired path after the record is cleared; + # a later install writes uninstall.sh and cannot be removed by this cleanup. + rm -f "$uninstaller_retired" || { + echo " ! uninstall completed but could not remove its retired entrypoint: $uninstaller_retired" >&2 + return 1 + } + fi + if [ -n "$recovery_retired" ]; then + rm -f "$recovery_retired" || { + echo " ! uninstall completed but could not remove its temporary recovery helper: $recovery_retired" >&2 + return 1 + } + fi + agmsg_install_op_unlock + AGMSG_INSTALL_OP_ACTIVE=false + unset AGMSG_INSTALL_OP_ID AGMSG_INSTALL_OP_MARKER } # Machine-wide pieces, shared by every install: only safe to remove once NO @@ -528,7 +735,13 @@ else # pointing at each one's own uninstall.sh, or --all to remove every # install on the machine at once. SELF_SKILL_DIR="$(cd "$(dirname "$0")" && pwd)" - if [ ! -f "$SELF_SKILL_DIR/.agmsg" ]; then + if [ -n "$CMD_NAME" ]; then + SELF_SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" + if [ ! -d "$SELF_SKILL_DIR" ]; then + echo " ! selected installation does not exist: $SELF_SKILL_DIR" >&2 + exit 1 + fi + elif [ ! -f "$SELF_SKILL_DIR/.agmsg" ]; then candidates=() for d in "$AGENTS_DIR"/skills/*/; do d="${d%/}" From 8e7ebbee678f0d813a981cf57a31439373b4ce35 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 09:33:56 -0700 Subject: [PATCH 04/15] Restore the Codex profile recording lost in the install-lock squash (#1513) ## Summary The squash of #1506 into `integration/agmsgd-beta` carried stale copies of four files and reverted #1504 in full. The #1506 diff for these files is the exact inverse of #1504's diff. Restores the state after #1505 for: - `scripts/drivers/types/codex/codex-record-session.sh`: resolve and record the effective CODEX_HOME with each seat - `scripts/lib/role-session.sh`: the `codex_home` field in the role-session record - `scripts/drivers/types/codex/template.md`: the actas step text about the profile directory - `tests/test_codex_resume.bats`: the tests for the above No other part of #1506 touched these files. ## Test plan - [x] `bats tests/test_codex_resume.bats`: 35/35 pass locally --- .../types/codex/codex-record-session.sh | 35 ++++++++++++--- scripts/drivers/types/codex/template.md | 2 +- scripts/lib/role-session.sh | 4 +- tests/test_codex_resume.bats | 44 ++++++++++++++++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/scripts/drivers/types/codex/codex-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 843dce0f4..41e29d0b4 100755 --- a/scripts/drivers/types/codex/codex-record-session.sh +++ b/scripts/drivers/types/codex/codex-record-session.sh @@ -5,8 +5,9 @@ # otherwise send-side only and never runs actas-claim, so without this a codex # role would have no role-session record and could never be resumed (spawn would # always boot it fresh). This is the codex-side equivalent: the codex actas flow -# calls it, and it writes the record so a later spawn/resume brings the role back -# into its thread. +# calls it, and it writes the thread plus the effective CODEX_HOME so a later +# spawn/resume brings the role back into its thread and profile. The /clear +# recovery in self-fix.sh calls this same script after #1470 rebinds the seat. # # Usage: codex-record-session.sh [project] # @@ -160,6 +161,28 @@ if [ "$probe_ran" = "1" ]; then [ -n "$thread" ] || exit 0 fi +# Resolve the effective profile before either fallback can infer a thread. The +# rollout index belongs to CODEX_HOME, not necessarily to the process HOME. +# Codex defaults to $HOME/.codex when CODEX_HOME is unset; resolve either +# spelling to a physical absolute path so discovery and the stored destination +# use the same profile. A missing or malformed directory is not safe to publish +# as a delivery destination, so leave the previous record untouched. +codex_home="${CODEX_HOME:-}" +if [ -z "$codex_home" ]; then + [ -n "${HOME:-}" ] || exit 0 + codex_home="$HOME/.codex" +fi +case "$codex_home" in *[[:cntrl:]]*) exit 0 ;; esac +[ -d "$codex_home" ] || exit 0 +codex_home="$(agmsg_canonical_path "$codex_home")" +# Keep the absolute path in the cross-platform form used by Node consumers; +# Git Bash's physical /c/... spelling is normalized to C:/... on Windows. +codex_home="$(agmsg_normalize_project_path "$codex_home")" +case "$codex_home" in + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) exit 0 ;; +esac + if [ -z "$thread" ]; then # No app-server to ask, or it could not be reached -- a codex session outside # monitor mode, a missing Node, a server that is not answering. The rollout scan @@ -167,10 +190,8 @@ if [ -z "$thread" ]; then # single-rollout case it always did, and on a project with history it records # nothing, which is what happens today. # - # ${HOME:-} so an unset HOME under `set -u` is a silent no-op (empty -> the - # dir check below fails -> fresh), not an unbound-variable abort (nit). - sessions_dir="${HOME:-}/.codex/sessions" - if [ -n "${HOME:-}" ] && [ -d "$sessions_dir" ]; then + sessions_dir="$codex_home/sessions" + if [ -d "$sessions_dir" ]; then # Distinct thread ids whose session_meta cwd (canonicalized -- codex records # the physical cwd while agmsg may hold a symlinked path, #160) matches the # project, among the most recent rollouts. Exactly one => unambiguously ours. @@ -212,7 +233,7 @@ fi # as-is. The project is recorded in its canonical (physical) form so records # carry one path spelling regardless of how the caller spelled the argument. agmsg_role_session_load "$TEAM" "$AGENT" 2>/dev/null || true -agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" || true +agmsg_role_session_record "$TEAM" "$AGENT" "$thread" "$project_phys" codex "${AGMSG_ROLE_SESSION_OWNER:-}" "$codex_home" || true # The Codex actas flow reaches this script instead of actas-claim.sh. Publish # the same seat request here so a resumed seat's dispatcher has an authority diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 2877f0382..631887950 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -18,7 +18,7 @@ Do not use POSIX `'"'"'` quote splicing in PowerShell, and do not use escaped do If argument starts with "actas" followed by an agent name: 1. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" __AGENT_TYPE__`. If `` is not listed, join with `~/.agents/skills/__SKILL_NAME__/scripts/join.sh __AGENT_TYPE__ "$(pwd)"`. -2. Record the Codex thread so a later spawn can resume it: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. +2. Record this Codex thread and its effective profile directory so a later spawn can resume it and route notices to the right profile: `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-record-session.sh `. Both arguments are required; `` is the team `` belongs to (from step 1). Without them nothing is recorded and the monitor cannot deliver to this thread. 3. Use the role as the active FROM; monitor delivery is routed only to its recorded thread. diff --git a/scripts/lib/role-session.sh b/scripts/lib/role-session.sh index e00f926f8..71057b1c1 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -137,9 +137,10 @@ agmsg_role_session_load() { # from the type manifest. Empty when unknown. # project= the resolved project root # owner= the actas owner token written by actas-claim +# codex_home= the effective absolute Codex profile directory # updated_at= best-effort timestamp (empty if date(1) unavailable) agmsg_role_session_record() { - local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" + local team="$1" agent="$2" bare_sid="$3" project="${4:-}" type="${5:-}" owner="${6:-}" codex_home="${7:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$bare_sid" ] || return 0 local path dir tmp ts named_ref="" named_epoch="" named_at="" _agmsg_role_session_path_into "$team" "$agent" @@ -163,6 +164,7 @@ agmsg_role_session_record() { printf 'type=%s\n' "$type" printf 'project=%s\n' "$project" [ -z "$owner" ] || printf 'owner=%s\n' "$owner" + [ -z "$codex_home" ] || printf 'codex_home=%s\n' "$codex_home" printf 'updated_at=%s\n' "$ts" [ -z "$named_ref" ] || printf 'named_ref=%s\n' "$named_ref" [ -z "$named_ref" ] || printf 'named_epoch=%s\n' "$named_epoch" diff --git a/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 7a4d1a830..4a3738b64 100644 --- a/tests/test_codex_resume.bats +++ b/tests/test_codex_resume.bats @@ -14,6 +14,8 @@ setup() { export RUN_DIR="$SKILL_DIR/run" mkdir -p "$RUN_DIR" export CODEX_SESSIONS="$HOME/.codex/sessions" + export CODEX_HOME="$HOME/.codex" + mkdir -p "$CODEX_HOME" } teardown() { teardown_test_env; } @@ -68,21 +70,51 @@ recorded_uuid() { agmsg_role_session_uuid "$1" "$2" } -@test "codex record: prefers CODEX_THREAD_ID (unambiguous env path)" { - local proj; proj="$(mktemp -d)" - CODEX_THREAD_ID="env-thread-1" \ +@test "codex record: stores the thread and effective profile path" { + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/codex profile" + mkdir -p "$explicit_home" "$HOME/.codex" + CODEX_HOME="$explicit_home" CODEX_THREAD_ID="env-thread-1" \ bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "env-thread-1" ] - # type is recorded as codex. source "$SKILL_DIR/scripts/lib/role-session.sh" [ "$(agmsg_role_session_get team alice type)" = "codex" ] + expected_home="$(cd "$explicit_home" && pwd -P)" + # Match the recorder's cross-platform path spelling (not raw Git Bash /c/...). + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] + + # Unset CODEX_HOME uses the same default Codex uses, recorded as an absolute + # path rather than leaving a later reader to infer it from its own HOME. + env -u CODEX_HOME CODEX_THREAD_ID="env-thread-2" \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" + [ "$(recorded_uuid team alice)" = "env-thread-2" ] + expected_home="$(cd "$HOME/.codex" && pwd -P)" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: falls back to the unique matching-cwd rollout when env is unset" { - local proj; proj="$(mktemp -d)" + local proj explicit_home expected_home; proj="$(mktemp -d)" + explicit_home="$TEST_SKILL_DIR/profile-B" + mkdir -p "$explicit_home" + # A matching rollout in the default profile must not outrank the selected + # profile's own unique matching rollout. + CODEX_SESSIONS="$HOME/.codex/sessions" + make_rollout "wrong-profile-uuid" "$proj" + CODEX_SESSIONS="$explicit_home/sessions" make_rollout "fallback-uuid" "$proj" - ( unset CODEX_THREAD_ID; bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" ) + CODEX_HOME="$explicit_home" env -u CODEX_THREAD_ID \ + bash "$TYPES/codex/codex-record-session.sh" team alice "$proj" [ "$(recorded_uuid team alice)" = "fallback-uuid" ] + source "$SKILL_DIR/scripts/lib/role-session.sh" + expected_home="$(cd "$explicit_home" && pwd -P)" + # shellcheck disable=SC1090 + source "$SCRIPTS/lib/resolve-project.sh" + expected_home="$(agmsg_normalize_project_path "$expected_home")" + [ "$(agmsg_role_session_get team alice codex_home)" = "$expected_home" ] } @test "codex record: records NOTHING when two recent rollouts share the cwd (ambiguous)" { From 78c3fd4f59c67ffef2416d0a3a3406b8f06cb119 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 12:55:18 -0700 Subject: [PATCH 05/15] feat: one agmsg command (npm entry dispatches to the runtime, install places a launcher) (#1515) ## Summary One `agmsg` command for 1.6.0. - `bin/agmsg.js` (the npm entry) runs `install` itself, as before, and hands every other verb to the installed bash runtime by absolute path, preserving arguments, stdio and the exit code. When no runtime is installed it prints how to install one. The old bootstrapper's guidance table is removed; compatibility with it is not kept. - `scripts/agmsg` is the runtime entry. In 1.6.0 only `daemon` is public; reserved verbs print that they are not available in this version; anything else exits 2 with the runtime location. - `scripts/lib/agmsg-launcher.sh` places a marked launcher in a fixed candidate directory (`~/.local/bin`; `~/bin` first on Windows Git Bash). It never edits shell rc files; when the directory is not on PATH it prints the one line to add. An existing entry is judged without following links: valid or broken symlinks, directories, foreign files, edited launchers and another install's launcher are left untouched. - `install.sh` places the launcher inside the install operation lock phase, on fresh install and `--update`, and marks `scripts/agmsg` and `scripts/daemon/agmsgd` executable. - `uninstall.sh` removes the launcher only when its marker, embedded install path and contents all match this install. Known limit: when an npm entry from 1.5.1 or earlier is still first on PATH, `agmsg daemon` reaches the old installer and is rejected; install prints a one-line note when it finds one, and `npm i -g agmsg` updates it. ## Test plan - [x] `tests/test_agmsg_command.bats` (new) and `tests/test_bin_agmsg.bats` (rewritten) pass locally; the launcher directory is redirected with `AGMSG_BIN_DIR`, never the real `~/.local/bin`. - [x] Launcher placement and removal, the older-npm-entry note (including an `npx` entry first on PATH), and Windows bash selection are covered by the library and entry tests above. --- .github/enforced-assertions-baseline | 2 +- bin/agmsg.js | 269 +++++++++++++-------------- install.sh | 10 + scripts/agmsg | 56 ++++++ scripts/lib/agmsg-launcher.sh | 203 ++++++++++++++++++++ tests/test_agmsg_command.bats | 164 ++++++++++++++++ tests/test_bin_agmsg.bats | 173 ++++++----------- uninstall.sh | 13 ++ 8 files changed, 631 insertions(+), 259 deletions(-) create mode 100755 scripts/agmsg create mode 100644 scripts/lib/agmsg-launcher.sh create mode 100644 tests/test_agmsg_command.bats diff --git a/.github/enforced-assertions-baseline b/.github/enforced-assertions-baseline index 7e1aa3219..282d20871 100644 --- a/.github/enforced-assertions-baseline +++ b/.github/enforced-assertions-baseline @@ -1 +1 @@ -621 +612 diff --git a/bin/agmsg.js b/bin/agmsg.js index 3237283f5..7f5d0dfb3 100755 --- a/bin/agmsg.js +++ b/bin/agmsg.js @@ -1,28 +1,22 @@ #!/usr/bin/env node -// agmsg npm bootstrapper. +// agmsg npm entry. // -// This package does NOT contain the agmsg implementation. It exists to -// reserve the "agmsg" name on npm and to give users a convenient -// `npx agmsg install` entry point that defers to the canonical shell -// installer maintained at https://github.com/fujibee/agmsg. +// This package does NOT contain the agmsg implementation. It is the single +// `agmsg` command's Node entry point, and it does two things only: // -// All real installation, configuration, and runtime logic lives in the -// canonical setup.sh. This bootstrapper fetches that script to a tempfile -// and exec's it directly — equivalent to the README's +// - `agmsg install` fetches the canonical setup.sh for this package's +// version and runs it (equivalent to the README's +// `bash <(curl -fsSL .../setup.sh)`; process substitution keeps the tty as +// stdin, see agmsg #98). +// - every other command is handed, unchanged, to the bash runtime that an +// install put on disk (scripts/agmsg). Nothing is reimplemented here. // -// bash <(curl -fsSL https://raw.githubusercontent.com/fujibee/agmsg/main/setup.sh) -// -// form, which is process-substitution and preserves the user's tty as -// stdin. We deliberately do NOT pipe the curl output into bash: piping -// makes the installer's stdin the wrapper script stream, and install.sh's -// interactive command-name prompt would `read -r` the next line of -// setup.sh as the command name. See agmsg #98. -// -// Subcommands: -// install Fetch and run the canonical setup.sh (default if no args). -// --help Print this message and exit 0. -// --version Print the bootstrapper version and exit 0. +// Usage: +// install [options] Fetch and run the canonical setup.sh. +// help, --help Print usage. +// --version Print this package's and the installed runtime's version. +// Run the installed runtime's command with the same args. const { spawnSync } = require('child_process'); const fs = require('fs'); @@ -56,46 +50,11 @@ function readVersion() { } } -// `agmsg ` is the single most common wrong guess about this project, -// and it is a guess the documentation taught: a sweep of docs/design and -// docs/spec found 34 backticked commands written as `agmsg send …`, -// `agmsg key show …`, `agmsg team list …`. There is no such CLI. This package -// installs agmsg; the commands are scripts inside the install. -// -// Saying only "unknown argument" leaves the person exactly where they were. -// It has to name what to type instead. -// -// The verb→script map is a HINT, and the two halves of that are tested -// differently (review P1): -// -// NOT promised: that it is complete. This package does not ship scripts/, -// so it cannot enumerate them. A verb missing from here falls through to -// the general form, which stays correct. -// PROMISED: that every entry present is real. A renamed or deleted script -// would otherwise make this print a specific path that does not exist — -// WORSE than the general form, not merely less precise. The first version -// of this comment claimed only precision could degrade; that was wrong, -// and it was wrong for exactly the entries most likely to rot. -// -// test_bin_agmsg.bats pins every value against the repo's scripts/ — the -// list is asserted non-empty, not at a fixed size, so adding a verb here -// costs nothing while renaming a script fails the suite. -// -// That pin is about THIS repo. It cannot speak for the tree on a user's -// disk, so printNotACommand checks the actual file there before naming it. -const SCRIPT_FOR_VERB = { - send: 'send.sh', history: 'history.sh', inbox: 'inbox.sh', join: 'join.sh', - team: 'team.sh', key: 'key.sh', remote: 'remote.sh', whoami: 'whoami.sh', - leave: 'leave.sh', rename: 'rename.sh', export: 'export.sh', config: 'config.sh', - watch: 'watch.sh', spawn: 'spawn.sh', version: 'version.sh', api: 'api.sh', -}; - -// The default install location. Checked because this package's whole job is -// to install agmsg, so a person who has never run it reaches this branch too -// (review P1) — and for them every path below is a command that fails. -// Advice that assumes the install is advice they cannot follow. -function defaultSkillDir() { - return path.join(os.homedir(), '.agents', 'skills', 'agmsg'); +// The install location. `AGMSG_CMD=` selects an install made with +// `--cmd `; without it the default install is used. A named install that +// is missing is an error -- it never falls back to the default one. +function skillsRoot() { + return path.join(os.homedir(), '.agents', 'skills'); } function exists(p) { @@ -106,82 +65,65 @@ function exists(p) { } } -function printNotACommand(verb, skillDirForTest) { - const dir = skillDirForTest || defaultSkillDir(); - const skill = '~/.agents/skills/agmsg/scripts'; - const script = Object.prototype.hasOwnProperty.call(SCRIPT_FOR_VERB, verb) - ? SCRIPT_FOR_VERB[verb] - : null; - const scriptsDir = path.join(dir, 'scripts'); - - // The contract differs by what is about to be printed, and that is the - // point (review P1): - // - // naming ONE script -> that script file must exist. The repo-side pin - // proves the map matches THIS repo; it says nothing about the tree on - // the user's disk, which can be an old version, a partial update, or a - // broken install. Checking only that scripts/ exists reproduces the - // previous P1 one layer out — a directory is not the file. - // naming the DIRECTORY -> the directory must exist. Nothing more is - // claimed, so nothing more is checked. - const haveScripts = exists(scriptsDir); - const usable = script ? exists(path.join(scriptsDir, script)) : haveScripts; - - const lines = ['agmsg: `agmsg ' + verb + '` is not a command.', '']; - - if (!usable) { - // Three situations that need different next steps, kept apart: never - // installed, installed but without this command, and installed under - // another name. Folding them together leaves someone without a recovery - // step — which is what the first version of this message did. - if (haveScripts) { - lines.push('Your agmsg install does not contain that command. It may be an'); - lines.push('older version — update it:'); - } else { - lines.push('agmsg does not look installed on this machine — this package is'); - lines.push('the installer for it. Install first:'); +// Sets out how to reach the runtime `agmsg` (scripts/agmsg, a bash script that +// install.sh copies into the install). Returns { runtime } on success or +// { error, lines } describing why it cannot be reached. +function resolveRuntime(env, skillsDirForTest) { + const root = skillsDirForTest || skillsRoot(); + const named = env.AGMSG_CMD; + if (named !== undefined && named !== '') { + if (!/^[A-Za-z0-9._-]+$/.test(named) || named === '.' || named === '..') { + return { error: true, lines: ['agmsg: AGMSG_CMD must be a plain install name, got "' + named + '".'] }; } - lines.push(''); - lines.push(' npx agmsg install'); - lines.push(''); - lines.push('After that, ' + (script ? 'that command is:' : 'the commands are:')); - lines.push(''); - lines.push(script ? ' bash ' + skill + '/' + script + ' …' - : ' bash ' + skill + '/.sh …'); - lines.push(''); - lines.push('(Already installed under a different name? Substitute it for'); - lines.push('`agmsg` in that path — nothing is put on your PATH.)'); - } else if (script) { - lines.push('This package only installs agmsg. That one lives in your install:'); - lines.push(''); - lines.push(' bash ' + skill + '/' + script + ' …'); - } else { - lines.push('This package only installs agmsg. The commands live in your install:'); - lines.push(''); - lines.push(' ls ' + skill + '/'); - lines.push(' bash ' + skill + '/.sh …'); + return checkInstall(path.join(root, named), 'AGMSG_CMD=' + named); } + const def = path.join(root, 'agmsg'); + const found = checkInstall(def, 'the default install'); + if (!found.error) return found; + if (exists(path.join(def, 'scripts'))) return found; + // No default install: list others that have a runtime, without picking one. + let others = []; + try { + others = fs.readdirSync(root).filter((n) => exists(path.join(root, n, 'scripts', 'agmsg'))); + } catch (_) { /* no skills directory */ } + if (others.length > 0) { + return { + error: true, + lines: ['agmsg: there is no default install, but these installs have the agmsg command:'] + .concat(others.map((n) => ' ' + n)) + .concat(['Pick one with AGMSG_CMD= agmsg …']) + }; + } + return found; +} - lines.push(''); - // The skill command is the path most people actually want — it is what the - // install sets up, and it needs no paths. - lines.push('Or ask your agent: run the agmsg skill command (/agmsg in Claude Code).'); - lines.push('`npx agmsg --help` covers what THIS package does.'); - console.error(lines.join('\n')); +function checkInstall(dir, label) { + const runtime = path.join(dir, 'scripts', 'agmsg'); + if (exists(runtime)) return { runtime, dir }; + if (exists(path.join(dir, 'scripts'))) { + return { error: true, lines: [ + 'agmsg: ' + label + ' has no agmsg command (an older version). Update it:', + ' agmsg install' + ] }; + } + return { error: true, lines: [ + 'agmsg: ' + label + ' is not installed. Install first:', + ' agmsg install' + ] }; } function printHelp() { process.stdout.write([ - 'agmsg — npm bootstrapper for cross-agent messaging', - '', - 'This package is a thin wrapper. The real installer lives at:', - ' ' + REPO_URL, + 'agmsg — cross-agent messaging', '', 'Usage:', - ' npx agmsg run the canonical setup.sh (same as `agmsg install`)', - ' npx agmsg install run the canonical setup.sh', - ' npx agmsg --help show this message', - ' npx agmsg --version show this bootstrapper\'s version', + ' agmsg install [options] install or update agmsg (runs the canonical setup.sh)', + ' agmsg daemon manage the agmsgd beta daemon (start|stop|status|enable|disable)', + ' agmsg --version show this package and the installed runtime version', + ' agmsg help show this message', + '', + 'Every command other than install is run by the agmsg install on this', + 'machine (AGMSG_CMD= picks an install made with `--cmd `).', '', 'After install, restart your agent (Claude Code / Codex / Gemini CLI /', 'Copilot CLI / Antigravity / OpenCode) and run the agmsg skill command', @@ -193,6 +135,42 @@ function printHelp() { ].join('\n')); } +// Runs the runtime with the arguments exactly as given: an absolute path, no +// shell, inherited stdio, and the runtime's own exit status. +function runRuntime(runtime, args) { + const bash = bashCommand(); + if (!bash) { + console.error('agmsg: Git for Windows bash was not found. Install Git for Windows, then run this again.'); + process.exit(1); + } + const result = spawnSync(bash, [toBashPath(runtime), ...args], { stdio: 'inherit' }); + if (result.error) { + console.error('agmsg: failed to launch bash:', result.error.message); + process.exit(1); + } + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + process.exit(result.status === null ? 1 : result.status); +} + +// On Windows a bare `bash` can be the WSL launcher, so only Git for Windows' +// own bash.exe is used there; when it cannot be found this returns null +// instead of falling back to a bare `bash`. +function bashCommand() { + if (process.platform === 'win32') { + const roots = [process.env.ProgramFiles, process.env.ProgramW6432, process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs')] + .filter(Boolean); + for (const root of roots) { + const gitBash = path.join(root, 'Git', 'bin', 'bash.exe'); + if (exists(gitBash)) return gitBash; + } + return null; + } + return 'bash'; +} + // Normalise a native path to the forward-slash form that bash.exe and // curl.exe accept on Windows. bash is an MSYS2 program: Windows gives it a // raw command-line string rather than a real argv[], and MSYS's own argv @@ -217,6 +195,11 @@ function runInstaller(passthroughArgs) { // correctly here is defense-in-depth and lets future interactive prompts // in setup.sh keep working for real-tty users. const ref = installRef(); + const bash = bashCommand(); + if (!bash) { + console.error('agmsg: Git for Windows bash was not found. Install Git for Windows, then run this again.'); + process.exit(1); + } const setupUrl = RAW_BASE + '/' + ref + '/setup.sh'; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agmsg-bootstrap-')); // os.tmpdir()/path.join() return backslash-separated paths on Windows. @@ -236,7 +219,7 @@ function runInstaller(passthroughArgs) { } // Pin the clone inside setup.sh to the same ref we fetched it from. - const result = spawnSync('bash', [setupPath, ...passthroughArgs], { + const result = spawnSync(bash, [setupPath, ...passthroughArgs], { stdio: 'inherit', env: Object.assign({}, process.env, { AGMSG_REF: ref }) }); @@ -253,21 +236,31 @@ function runInstaller(passthroughArgs) { function main() { const args = process.argv.slice(2); - if (args.length === 0 || args[0] === 'install') { + if (args[0] === 'install') { // Forward anything after `install` (e.g. `agmsg install --cmd m`) to // setup.sh, which passes "$@" through to install.sh. - const passthrough = args[0] === 'install' ? args.slice(1) : args; - runInstaller(passthrough); - } else if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { + runInstaller(args.slice(1)); + } else if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { printHelp(); - process.exit(0); + process.exit(args.length === 0 ? 2 : 0); } else if (args[0] === '--version' || args[0] === '-v') { - process.stdout.write('agmsg bootstrapper ' + readVersion() + '\n'); - process.stdout.write('canonical project: ' + REPO_URL + '\n'); + process.stdout.write('agmsg ' + readVersion() + ' (npm entry)\n'); + const found = resolveRuntime(process.env); + if (found.error) { + process.stdout.write('runtime: not available\n'); + } else { + const bash = bashCommand(); + const v = bash ? spawnSync(bash, [toBashPath(found.runtime), '--version'], { encoding: 'utf8' }) : null; + process.stdout.write('runtime: ' + (v && v.status === 0 ? v.stdout.trim() : 'unreadable') + '\n'); + } process.exit(0); } else { - printNotACommand(args[0]); - process.exit(2); + const found = resolveRuntime(process.env); + if (found.error) { + console.error(found.lines.join('\n')); + process.exit(2); + } + runRuntime(found.runtime, args); } } @@ -275,4 +268,4 @@ if (require.main === module) { main(); } -module.exports = { toBashPath, SCRIPT_FOR_VERB, printNotACommand }; +module.exports = { toBashPath, resolveRuntime, runRuntime }; diff --git a/install.sh b/install.sh index 0ad180bd9..f31b3cd2d 100755 --- a/install.sh +++ b/install.sh @@ -30,6 +30,8 @@ AGENTS_DIR="$HOME/.agents" . "$SCRIPT_DIR/scripts/lib/install-db.sh" # shellcheck disable=SC1091 . "$SCRIPT_DIR/scripts/lib/install-manifest.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/agmsg-launcher.sh" # Type registry — resolve each type's SKILL command template from its manifest # (scripts/drivers/types//template.md) instead of a hardcoded templates/ path. Read-only @@ -1127,6 +1129,8 @@ $_agmsg_running_team" unset -f agmsg_shq fi chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 + # Extensionless entry points the *.sh glob does not reach. + chmod +x "$SKILL_DIR/scripts/agmsg" "$SKILL_DIR/scripts/daemon/agmsgd" 2>/dev/null || true agmsg_install_optional_codex_chmod install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update @@ -1175,6 +1179,7 @@ $_agmsg_running_team" fi install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 + agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 agmsg_install_op_run_phase agmsg_install_write_version || exit 1 echo " + updated scripts, templates, and SKILL.md (version $INSTALLED_VERSION)" echo " ~ DB and team configs preserved" @@ -1280,6 +1285,8 @@ agmsg_install_optional_uninstaller_copy agmsg_install_optional_copy "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" chmod +x "$SKILL_DIR/scripts/"*.sh || exit 1 +# Extensionless entry points the *.sh glob does not reach. +chmod +x "$SKILL_DIR/scripts/agmsg" "$SKILL_DIR/scripts/daemon/agmsgd" 2>/dev/null || true agmsg_install_optional_codex_chmod install_antigravity_tui_shim "$SKILL_DIR/scripts/drivers/types/antigravity/agy-tui.sh" || exit 1 # Re-point an existing Codex monitor shim at the new path on a reinstall over an @@ -1307,6 +1314,9 @@ fi install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 +# The `agmsg` command launcher (never touches shell rc files or PATH). +agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 + # Marker file for uninstall detection and source provenance version are one # protected phase so neither write can occur after a lost-lock boundary. agmsg_install_op_run_phase agmsg_install_touch_marker_and_write_version || exit 1 diff --git a/scripts/agmsg b/scripts/agmsg new file mode 100755 index 000000000..8af17367c --- /dev/null +++ b/scripts/agmsg @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# The runtime `agmsg` command. Deliberately a small dispatcher: it names the +# few verbs that are public in this version and hands each one to the script +# that implements it. It does NOT forward arbitrary verbs to scripts/.sh; +# names such as send / inbox / history are not published here yet. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_usage() { + cat <<'USAGE' +usage: agmsg [args] + +commands: + daemon start|stop|status|enable|disable manage the agmsgd beta daemon + --version print the installed version + help show this message + +Other commands are not available through `agmsg` in this version. +USAGE +} + +case "${1:-}" in + daemon) + shift + exec bash "$SCRIPT_DIR/daemon.sh" "$@" + ;; + --version|-v) + printf 'agmsg %s (install: %s)\n' "$(bash "$SCRIPT_DIR/version.sh")" "$(cd "$SCRIPT_DIR/.." && pwd)" + ;; + help|--help|-h) + _usage + ;; + install) + cat >&2 <<'MSG' +agmsg: this agmsg is already installed; `agmsg install` does not run from here. +To install or update, run one of: + npx agmsg install + bash /install.sh +MSG + exit 2 + ;; + doctor|sync|activate|deactivate|despawn|migrate) + echo "agmsg: '$1' is reserved and is not available in this version yet." >&2 + exit 2 + ;; + "") + _usage >&2 + exit 2 + ;; + *) + echo "agmsg: '$1' is not an agmsg command." >&2 + echo "Scripts for this install live in: $SCRIPT_DIR" >&2 + exit 2 + ;; +esac diff --git a/scripts/lib/agmsg-launcher.sh b/scripts/lib/agmsg-launcher.sh new file mode 100644 index 000000000..d5d81a817 --- /dev/null +++ b/scripts/lib/agmsg-launcher.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# Places (and later removes) the thin `agmsg` launcher that lets a person type +# `agmsg daemon ...` from a terminal. It never edits a shell rc file, never +# follows a symlink, and never overwrites a file it does not own. +# +# The launcher is a marked regular file, not a symlink: a symlink cannot carry +# an ownership marker, and `dirname $0` inside the runtime would then resolve +# to the bin directory instead of the install. + +# Marker prefix. The full marker line is " ". +AGMSG_LAUNCHER_MARKER='# agmsg-launcher-owner:' + +# Sets AGMSG_LAUNCHER_DIR to the directory a launcher goes in. An explicit +# AGMSG_BIN_DIR is used as given (it must be absolute); otherwise the one +# default candidate for this platform. Never searches PATH. +agmsg_launcher_pick_dir() { + AGMSG_LAUNCHER_DIR="" + if [ -n "${AGMSG_BIN_DIR:-}" ]; then + case "$AGMSG_BIN_DIR" in + /*) AGMSG_LAUNCHER_DIR="$AGMSG_BIN_DIR"; return 0 ;; + *) AGMSG_LAUNCHER_REASON="AGMSG_BIN_DIR is not an absolute path"; return 1 ;; + esac + fi + case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) AGMSG_LAUNCHER_DIR="$HOME/bin" ;; + *) AGMSG_LAUNCHER_DIR="$HOME/.local/bin" ;; + esac +} + +# Prints the launcher file content for the install at $1. +agmsg_launcher_render() { + local skill_dir="$1" + printf '#!/usr/bin/env bash\n' + printf '# agmsg launcher -- placed by the agmsg installer; remove it with the uninstaller.\n' + printf '%s %s\n' "$AGMSG_LAUNCHER_MARKER" "$skill_dir" + printf 'exec bash %q "$@"\n' "$skill_dir/scripts/agmsg" +} + +# Sets AGMSG_LAUNCHER_KIND for the target path $1 and install $2 to one of: +# absent | own-same | own-modified | other-install | symlink | directory | +# not-regular | foreign +# The target is judged without following links. +agmsg_launcher_classify() { + local target="$1" skill_dir="$2" marker_line owner + AGMSG_LAUNCHER_KIND="" + AGMSG_LAUNCHER_OWNER="" + if [ -L "$target" ]; then AGMSG_LAUNCHER_KIND=symlink; return 0; fi + if [ ! -e "$target" ]; then AGMSG_LAUNCHER_KIND=absent; return 0; fi + if [ -d "$target" ]; then AGMSG_LAUNCHER_KIND=directory; return 0; fi + if [ ! -f "$target" ]; then AGMSG_LAUNCHER_KIND=not-regular; return 0; fi + marker_line="$(grep -m1 "^$AGMSG_LAUNCHER_MARKER " "$target" 2>/dev/null || true)" + if [ -z "$marker_line" ]; then AGMSG_LAUNCHER_KIND=foreign; return 0; fi + owner="${marker_line#"$AGMSG_LAUNCHER_MARKER "}" + AGMSG_LAUNCHER_OWNER="$owner" + if [ "$owner" != "$skill_dir" ]; then AGMSG_LAUNCHER_KIND=other-install; return 0; fi + if [ "$(agmsg_launcher_render "$skill_dir")" = "$(cat "$target")" ]; then + AGMSG_LAUNCHER_KIND=own-same + else + AGMSG_LAUNCHER_KIND=own-modified + fi +} + +# Places the launcher for the install at $1 and prints a three-part report: +# what was placed, whether this process can see it, and what to check on a +# real terminal. Always returns 0: not placing a launcher is never an install +# failure. +agmsg_launcher_install() { + local skill_dir="$1" target tmp resolved old + AGMSG_LAUNCHER_REASON="" + if ! agmsg_launcher_pick_dir; then + echo " ~ agmsg command: not placed ($AGMSG_LAUNCHER_REASON)" + return 0 + fi + target="$AGMSG_LAUNCHER_DIR/agmsg" + agmsg_launcher_classify "$target" "$skill_dir" + case "$AGMSG_LAUNCHER_KIND" in + own-same) echo " ~ agmsg command: already in place at $target" ;; + absent) + if ! mkdir -p "$AGMSG_LAUNCHER_DIR" 2>/dev/null \ + || ! tmp="$(mktemp "$AGMSG_LAUNCHER_DIR/.agmsg-launcher.XXXXXX" 2>/dev/null)"; then + echo " ~ agmsg command: not placed ($AGMSG_LAUNCHER_DIR is not writable)" + return 0 + fi + if agmsg_launcher_render "$skill_dir" > "$tmp" && chmod +x "$tmp" && mv -n "$tmp" "$target" 2>/dev/null && [ ! -e "$tmp" ]; then + agmsg_launcher_record "$skill_dir" "$target" + echo " + agmsg command: placed $target" + else + rm -f "$tmp" + echo " ~ agmsg command: not placed (could not write $target)" + return 0 + fi + ;; + other-install) + echo " ~ agmsg command: not placed ($target already belongs to the install at $AGMSG_LAUNCHER_OWNER)" + return 0 ;; + own-modified) + echo " ~ agmsg command: not placed ($target was edited; leaving it as it is)" + return 0 ;; + symlink) + echo " ~ agmsg command: not placed ($target is a symlink; agmsg never replaces one)" + return 0 ;; + directory|not-regular|foreign) + echo " ~ agmsg command: not placed ($target already exists and is not an agmsg launcher)" + return 0 ;; + esac + case ":$PATH:" in + *":$AGMSG_LAUNCHER_DIR:"*) echo " visible in this environment: yes ($AGMSG_LAUNCHER_DIR is on PATH)" ;; + *) echo " visible in this environment: no ($AGMSG_LAUNCHER_DIR is not on PATH)" ;; + esac + resolved="$(command -v agmsg 2>/dev/null || true)" + if [ -n "$resolved" ] && [ "$resolved" != "$target" ]; then + echo " note: 'agmsg' currently resolves to $resolved, which comes first on PATH" + fi + # Every PATH entry ahead of the launcher's directory is checked, not just the + # first hit: during `npx agmsg install` a temporary entry sits first and an + # older global one can sit behind it. + if old="$(agmsg_launcher_find_old_npm_entry "$AGMSG_LAUNCHER_DIR")"; then + echo " $old is an older npm agmsg (1.5.1 or earlier) that comes before the launcher on PATH;" + echo " it does not know 'agmsg daemon'. Update it with: npm i -g agmsg@latest" + echo " (or put $AGMSG_LAUNCHER_DIR before it on PATH)" + return 0 + fi + echo " on your own terminal: not checked here; open a new terminal and run: command -v agmsg" + echo " if that prints nothing, add this line to your shell startup file yourself:" + echo " export PATH=\"$AGMSG_LAUNCHER_DIR:\$PATH\"" +} + +# True when the file at $1 is the npm entry from before the single-command +# release (its header names it a bootstrapper). Reads files only. On Unix npm +# links the entry, so reading the path follows to the JS. On Windows npm writes +# a small shell wrapper next to the entry; that wrapper names +# node_modules/agmsg/bin/agmsg.js relative to its own directory, and that file +# is what is read. +agmsg_launcher_is_old_npm_entry() { + local file="$1" js + [ -f "$file" ] || return 1 + head -c 4096 "$file" 2>/dev/null | grep -q 'agmsg npm bootstrapper' && return 0 + if head -c 4096 "$file" 2>/dev/null | grep -q 'node_modules/agmsg/bin/agmsg.js'; then + js="$(dirname "$file")/node_modules/agmsg/bin/agmsg.js" + [ -f "$js" ] && head -c 4096 "$js" 2>/dev/null | grep -q 'agmsg npm bootstrapper' && return 0 + fi + return 1 +} + +# Prints the first older npm agmsg found on PATH ahead of directory $1 (or +# anywhere on PATH when $1 is not on it) and returns 0; returns 1 when none. +agmsg_launcher_find_old_npm_entry() { + local stop="$1" dir rest="$PATH:" + while [ -n "$rest" ]; do + dir="${rest%%:*}" + rest="${rest#*:}" + [ -n "$dir" ] || continue + [ "$dir" = "$stop" ] && return 1 + if agmsg_launcher_is_old_npm_entry "$dir/agmsg"; then + printf '%s\n' "$dir/agmsg" + return 0 + fi + done + return 1 +} + +# Remembers where the launcher went so uninstall can find it under a custom +# AGMSG_BIN_DIR. The record is never trusted alone; uninstall re-checks the file. +agmsg_launcher_record() { + mkdir -p "$1/run" 2>/dev/null || return 0 + printf '%s\n' "$2" > "$1/run/agmsg-launcher.path" 2>/dev/null || true +} + +# Removes the launcher for the install at $1 only if the file carries our +# marker, names this install, and still has exactly the content we wrote. +# Prints one line saying what happened. Returns non-zero only when a launcher +# that is ours could not be removed; the record is then kept. The remove +# command defaults to rm; the uninstaller passes its lock-checked remover in +# AGMSG_LAUNCHER_RM so a removal can never run after the lock was lost. +agmsg_launcher_uninstall() { + local skill_dir="$1" record="" candidate seen="" + local remover="${AGMSG_LAUNCHER_RM:-rm}" + [ -f "$skill_dir/run/agmsg-launcher.path" ] && record="$(head -n1 "$skill_dir/run/agmsg-launcher.path" 2>/dev/null || true)" + agmsg_launcher_pick_dir >/dev/null 2>&1 || true + for candidate in "$record" "${AGMSG_LAUNCHER_DIR:+$AGMSG_LAUNCHER_DIR/agmsg}"; do + [ -n "$candidate" ] || continue + case "$seen" in *"|$candidate|"*) continue ;; esac + seen="$seen|$candidate|" + agmsg_launcher_classify "$candidate" "$skill_dir" + case "$AGMSG_LAUNCHER_KIND" in + own-same) + if ! "$remover" -f "$candidate"; then + echo " ! could not remove agmsg command $candidate" >&2 + return 1 + fi + echo " - removed agmsg command $candidate" + "$remover" -f "$skill_dir/run/agmsg-launcher.path" || return 1 + return 0 ;; + own-modified) + echo " ~ left $candidate in place (it was edited)" ;; + other-install) + echo " ~ left $candidate in place (it belongs to the install at $AGMSG_LAUNCHER_OWNER)" ;; + symlink|directory|not-regular|foreign) + [ "$candidate" = "$record" ] && echo " ~ left $candidate in place (it is not an agmsg launcher)" ;; + esac + done + return 0 +} diff --git a/tests/test_agmsg_command.bats b/tests/test_agmsg_command.bats new file mode 100644 index 000000000..f7ff1144d --- /dev/null +++ b/tests/test_agmsg_command.bats @@ -0,0 +1,164 @@ +#!/usr/bin/env bats + +# The runtime `agmsg` command and the launcher install places for it. Nothing +# here swaps HOME or touches a real bin directory: the launcher goes to +# AGMSG_BIN_DIR under the test's own temp dir, and the install is a copy of +# scripts/ in TEST_SKILL_DIR. + +load test_helper + +setup() { + setup_test_env + AGMSG="$TEST_SKILL_DIR/scripts/agmsg" + chmod +x "$AGMSG" + LIB="$TEST_SKILL_DIR/scripts/lib/agmsg-launcher.sh" + BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + export AGMSG_BIN_DIR="$BIN_DIR" +} + +teardown() { + rm -rf "$TEST_SKILL_DIR" +} + +@test "agmsg: unknown verb exits 2 with the location, reserved verb says not yet, daemon reaches daemon.sh" { + run bash "$AGMSG" storage list + [ "$status" -eq 2 ] + grep -Fq -- "is not an agmsg command" <<<"$output" + grep -Fq -- "$TEST_SKILL_DIR/scripts" <<<"$output" + + run bash "$AGMSG" doctor + [ "$status" -eq 2 ] + grep -Fq -- "reserved" <<<"$output" + + # A published-elsewhere verb is not passed through to its script. + run bash "$AGMSG" send x y z + [ "$status" -eq 2 ] + grep -Fq -- "is not an agmsg command" <<<"$output" + + # daemon reaches daemon.sh with the arguments unchanged (its own usage text). + run bash "$AGMSG" daemon bogus + grep -Fq -- "agmsg daemon start|stop|status|enable|disable" <<<"$output" +} + +@test "launcher: placed into the chosen dir, reaches the runtime, and uninstall removes only that" { + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + grep -Fq -- "placed $BIN_DIR/agmsg" <<<"$output" + grep -Fq -- "visible in this environment: no" <<<"$output" + [ -x "$BIN_DIR/agmsg" ] + + # The launcher, not a symlink, and it lands on the real scripts/agmsg. + [ ! -L "$BIN_DIR/agmsg" ] + run "$BIN_DIR/agmsg" doctor + [ "$status" -eq 2 ] + grep -Fq -- "reserved" <<<"$output" + + # Second run is a no-op. + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "already in place" <<<"$output" + + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "removed agmsg command" <<<"$output" + [ ! -e "$BIN_DIR/agmsg" ] + [ -d "$BIN_DIR" ] +} + +# The four kinds of thing already sitting at the target: nothing is broken, +# nothing is followed, and the message says why. +@test "launcher: a valid symlink, a broken symlink, a directory and a foreign file are all left alone" { + local elsewhere="$BATS_TEST_TMPDIR/elsewhere" + printf 'original\n' > "$elsewhere" + + ln -s "$elsewhere" "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "not placed" <<<"$output" + grep -Fq -- "symlink" <<<"$output" + [ -L "$BIN_DIR/agmsg" ] + [ "$(cat "$elsewhere")" = "original" ] + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ -L "$BIN_DIR/agmsg" ] + + rm -f "$BIN_DIR/agmsg" + ln -s "$BATS_TEST_TMPDIR/does-not-exist" "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "symlink" <<<"$output" + [ -L "$BIN_DIR/agmsg" ] + [ ! -e "$BATS_TEST_TMPDIR/does-not-exist" ] + + rm -f "$BIN_DIR/agmsg" + mkdir "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "not an agmsg launcher" <<<"$output" + [ -d "$BIN_DIR/agmsg" ] + + rmdir "$BIN_DIR/agmsg" + printf '#!/bin/sh\necho mine\n' > "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "not an agmsg launcher" <<<"$output" + [ "$(sed -n 2p "$BIN_DIR/agmsg")" = "echo mine" ] +} + +@test "launcher: another install's launcher is not taken over, an edited one is not removed" { + local other="$BATS_TEST_TMPDIR/other-install" + mkdir -p "$other/scripts" + bash -c 'source "$1"; agmsg_launcher_render "$2" > "$3"' _ "$LIB" "$other" "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "belongs to the install at $other" <<<"$output" + grep -q "$other" "$BIN_DIR/agmsg" + # This install's uninstall must not remove the other install's launcher. + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ -f "$BIN_DIR/agmsg" ] + + rm -f "$BIN_DIR/agmsg" + bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" >/dev/null + printf '# edited\n' >> "$BIN_DIR/agmsg" + run bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "edited" <<<"$output" + [ -f "$BIN_DIR/agmsg" ] +} + +@test "launcher: a relative AGMSG_BIN_DIR is refused and nothing is written" { + cd "$BATS_TEST_TMPDIR" + run env AGMSG_BIN_DIR=relative/bin bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + grep -Fq -- "not an absolute path" <<<"$output" + [ ! -e "$BATS_TEST_TMPDIR/relative" ] +} + +@test "launcher: an older npm agmsg ahead on PATH is named and the update is offered" { + local old="$BATS_TEST_TMPDIR/oldbin" + mkdir -p "$old" + printf '#!/usr/bin/env node\n// agmsg npm bootstrapper.\n' > "$old/agmsg" + chmod +x "$old/agmsg" + # A newer entry sits first on PATH (as during an npx install); the older + # global one behind it must still be found. + local newer="$BATS_TEST_TMPDIR/newerbin" + mkdir -p "$newer" + printf '#!/usr/bin/env node\n// agmsg npm entry.\n' > "$newer/agmsg" + chmod +x "$newer/agmsg" + run env PATH="$newer:$old:$BIN_DIR:$PATH" bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "older npm agmsg" <<<"$output" + grep -Fq -- "npm i -g agmsg@latest" <<<"$output" + # It is only reported; nothing of the old entry is touched. + grep -q 'agmsg npm bootstrapper' "$old/agmsg" +} + +@test "launcher: a failed removal keeps the record and reports failure" { + bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" >/dev/null + [ -f "$TEST_SKILL_DIR/run/agmsg-launcher.path" ] + run env AGMSG_LAUNCHER_RM=false bash -c 'source "$1"; agmsg_launcher_uninstall "$2"' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -ne 0 ] + [ -f "$BIN_DIR/agmsg" ] + [ -f "$TEST_SKILL_DIR/run/agmsg-launcher.path" ] +} + +@test "launcher: an older npm agmsg behind a Windows-style wrapper is recognised through the JS it names" { + local wrap="$BATS_TEST_TMPDIR/npmwrap" + mkdir -p "$wrap/node_modules/agmsg/bin" + printf '// agmsg npm bootstrapper.\n' > "$wrap/node_modules/agmsg/bin/agmsg.js" + printf '#!/bin/sh\nexec node "$basedir/node_modules/agmsg/bin/agmsg.js" "$@"\n' > "$wrap/agmsg" + chmod +x "$wrap/agmsg" + run env PATH="$wrap:$BIN_DIR:$PATH" bash -c 'source "$1"; agmsg_launcher_install "$2"' _ "$LIB" "$TEST_SKILL_DIR" + grep -Fq -- "older npm agmsg" <<<"$output" +} diff --git a/tests/test_bin_agmsg.bats b/tests/test_bin_agmsg.bats index d10c40a1a..0d0558deb 100644 --- a/tests/test_bin_agmsg.bats +++ b/tests/test_bin_agmsg.bats @@ -2,129 +2,62 @@ BIN="$BATS_TEST_DIRNAME/../bin/agmsg.js" -@test "bin/agmsg.js: --version exits successfully" { - run node "$BIN" --version - [ "$status" -eq 0 ] - [[ "$output" =~ "agmsg bootstrapper" ]] -} - -@test "bin/agmsg.js: --help exits successfully" { +@test "bin/agmsg.js: --help lists install and daemon and exits 0" { run node "$BIN" --help [ "$status" -eq 0 ] - [[ "$output" =~ "npm bootstrapper for cross-agent messaging" ]] -} - -# `agmsg ` is the wrong guess the docs taught — a sweep of docs/design -# and docs/spec found 34 backticked commands assuming a CLI that does not -# exist. Fixing the documents does not help the person who types from memory, -# so the refusal has to name the real form. -# -# Asserted on the PATH being present, not on the wording: the value of this -# message is that it tells you what to type instead, and a test that only -# checked for "not a command" would pass on the old text. -@test "bin/agmsg.js: an unknown verb names the script to run instead" { - run node "$BIN" send hello - [ "$status" -eq 2 ] - [[ "$output" =~ "is not a command" ]] - [[ "$output" =~ "scripts/send.sh" ]] -} - -# A verb with no mapping still has to answer "then what do I type", because -# the mapping is a hint that is allowed to be incomplete. `storage` is one the -# docs use and no script implements. -@test "bin/agmsg.js: an unmapped verb still points at the install" { - run node "$BIN" storage list - [ "$status" -eq 2 ] - [[ "$output" =~ "is not a command" ]] - [[ "$output" =~ "scripts/" ]] -} - -# EVERY entry in the map, not the one verb a hand-written test happened to -# pick (review P1). The map is allowed to be incomplete — a new verb -# missing from it costs nothing — but an entry that is PRESENT and stale makes -# the message name a path that does not exist, which is worse than the general -# advice it replaced. Completeness is not pinned; correctness of what is -# listed is. -@test "bin/agmsg.js: every mapped verb names a script that exists" { - run node -e ' - const { SCRIPT_FOR_VERB } = require(process.argv[1]); - const fs = require("fs"), path = require("path"); - const dir = path.join(path.dirname(process.argv[1]), "..", "scripts"); - const missing = Object.entries(SCRIPT_FOR_VERB) - .filter(([, s]) => !fs.existsSync(path.join(dir, s))) - .map(([v, s]) => v + " -> " + s); - if (missing.length) { console.error(missing.join("\n")); process.exit(1); } - console.log("checked " + Object.keys(SCRIPT_FOR_VERB).length); - ' "$BIN" - [ "$status" -eq 0 ] - # Non-empty, not an exact count (review): pinning the number would mean adding - # a legitimate verb to the map fails this test, which makes the map harder - # to extend for no safety gained. What must not pass is a map emptied to {} - # reporting "nothing missing". - [[ "$output" =~ checked\ [1-9] ]] + grep -Fq -- "agmsg install" <<<"$output" + grep -Fq -- "agmsg daemon" <<<"$output" } -# The person this package exists for has NOT installed yet, and they reach -# this same branch (review P1). Telling them to `bash ` is -# telling them to run a command that fails. HOME is redirected to an empty -# directory; nothing here touches the network. -@test "bin/agmsg.js: with nothing installed, it says to install first" { - local fresh="$BATS_TEST_TMPDIR/fresh-home" - mkdir -p "$fresh" - run env HOME="$fresh" node "$BIN" send hello +@test "bin/agmsg.js: no arguments prints usage and exits 2 (it must not start an install)" { + run node "$BIN" [ "$status" -eq 2 ] - [[ "$output" =~ "does not look installed" ]] - [[ "$output" =~ "npx agmsg install" ]] - # Still names the eventual command, so the person knows where they are going. - [[ "$output" =~ "scripts/send.sh" ]] -} - -@test "bin/agmsg.js: with the script present, it names it and says nothing about installing" { - local home="$BATS_TEST_TMPDIR/has-install" - mkdir -p "$home/.agents/skills/agmsg/scripts" - # The FILE, not just the directory. An earlier version of this test created - # an empty scripts/ and accepted advice pointing at a send.sh that was not - # there — the same defect as the map pin, one layer out (review P1). - touch "$home/.agents/skills/agmsg/scripts/send.sh" - run env HOME="$home" node "$BIN" send hello - [ "$status" -eq 2 ] - [[ ! "$output" =~ "does not look installed" ]] - [[ ! "$output" =~ "does not contain that command" ]] - [[ "$output" =~ "scripts/send.sh" ]] -} - -# An install that exists but lacks the command — an old version, or a partial -# update. The repo-side pin cannot see this: it proves the map matches THIS -# repo, not the tree on someone's disk. Distinct from "never installed" -# because the recovery is update, not install. -@test "bin/agmsg.js: an install without that script says update, not install" { - local home="$BATS_TEST_TMPDIR/stale-install" - mkdir -p "$home/.agents/skills/agmsg/scripts" - touch "$home/.agents/skills/agmsg/scripts/history.sh" # some other command - run env HOME="$home" node "$BIN" send hello - [ "$status" -eq 2 ] - [[ "$output" =~ "does not contain that command" ]] - [[ ! "$output" =~ "does not look installed" ]] - [[ "$output" =~ "npx agmsg install" ]] -} - -# An unmapped verb names the DIRECTORY, so the directory is all that is -# checked — the contract matches what is printed. -@test "bin/agmsg.js: an unmapped verb is satisfied by the directory alone" { - local home="$BATS_TEST_TMPDIR/dir-only" - mkdir -p "$home/.agents/skills/agmsg/scripts" - run env HOME="$home" node "$BIN" storage list - [ "$status" -eq 2 ] - [[ ! "$output" =~ "does not look installed" ]] - [[ "$output" =~ "ls " ]] -} - -@test "bin/agmsg.js: toBashPath converts backslashes to forward slashes (#262)" { - run node -e 'const { toBashPath } = require(process.argv[1]); const input = String.raw`C:\Users\me\AppData\Local\Temp\agmsg-bootstrap-abc123\setup.sh`; const expected = "C:/Users/me/AppData/Local/Temp/agmsg-bootstrap-abc123/setup.sh"; if (toBashPath(input) !== expected) process.exit(1);' "$BIN" - [ "$status" -eq 0 ] -} - -@test "bin/agmsg.js: toBashPath is a no-op on POSIX paths" { - run node -e 'const { toBashPath } = require(process.argv[1]); const p = "/tmp/agmsg-bootstrap-abc123/setup.sh"; if (toBashPath(p) !== p) process.exit(1);' "$BIN" - [ "$status" -eq 0 ] + grep -Fq -- "Usage:" <<<"$output" +} + +@test "bin/agmsg.js: the runtime is handed the arguments unchanged and its exit status comes back" { + local rt="$BATS_TEST_TMPDIR/agmsg" + cat > "$rt" <<'RT' +#!/usr/bin/env bash +printf 'argc=%s\n' "$#" +for a in "$@"; do printf '[%s]\n' "$a"; done +exit 7 +RT + run node -e 'require(process.argv[1]).runRuntime(process.argv[2], ["daemon", "two words", "日本語", "it'"'"'s"])' "$BIN" "$rt" + [ "$status" -eq 7 ] + grep -Fq -- "argc=4" <<<"$output" + grep -Fq -- "[two words]" <<<"$output" + grep -Fq -- "[日本語]" <<<"$output" + grep -Fq -- "[it's]" <<<"$output" +} + +# Which install the entry hands off to: the default one, or the one AGMSG_CMD +# names -- and a named install that is missing must never fall back. +@test "bin/agmsg.js: install resolution (default, AGMSG_CMD, no fallback, several candidates)" { + local root="$BATS_TEST_TMPDIR/skills" + mkdir -p "$root/other/scripts" "$root/third/scripts" + touch "$root/other/scripts/agmsg" "$root/third/scripts/agmsg" + + # No default install, two others: list them, choose nothing. + run node -e 'const r=require(process.argv[1]).resolveRuntime({}, process.argv[2]); console.log(JSON.stringify(r))' "$BIN" "$root" + grep -Fq -- '"error":true' <<<"$output" + grep -Fq -- "other" <<<"$output" + grep -Fq -- "third" <<<"$output" + + # Named install: used. + run node -e 'const r=require(process.argv[1]).resolveRuntime({AGMSG_CMD:"other"}, process.argv[2]); console.log(r.runtime)' "$BIN" "$root" + [ "$output" = "$root/other/scripts/agmsg" ] + + # Named install that does not exist: an error, even though a default exists. + mkdir -p "$root/agmsg/scripts"; touch "$root/agmsg/scripts/agmsg" + run node -e 'const r=require(process.argv[1]).resolveRuntime({AGMSG_CMD:"missing"}, process.argv[2]); console.log(JSON.stringify(r))' "$BIN" "$root" + grep -Fq -- '"error":true' <<<"$output" + + # Default install present and no AGMSG_CMD: the default. + run node -e 'const r=require(process.argv[1]).resolveRuntime({}, process.argv[2]); console.log(r.runtime)' "$BIN" "$root" + [ "$output" = "$root/agmsg/scripts/agmsg" ] + + # A name that is not a plain install name is refused. + run node -e 'const r=require(process.argv[1]).resolveRuntime({AGMSG_CMD:"../x"}, process.argv[2]); console.log(JSON.stringify(r))' "$BIN" "$root" + grep -Fq -- '"error":true' <<<"$output" } diff --git a/uninstall.sh b/uninstall.sh index 5ba2e28ba..acfe27234 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -483,6 +483,19 @@ _uninstall_one() { fi done + # --- Remove the agmsg command launcher this install placed --- + # Only a launcher that carries our marker, names this install, and still has + # the content we wrote is removed. Older installs have no launcher library; + # there is then nothing of ours to remove. + local _launcher_lib + for _launcher_lib in "$SKILL_DIR/scripts/lib/agmsg-launcher.sh" "$SCRIPT_DIR/scripts/lib/agmsg-launcher.sh"; do + [ -r "$_launcher_lib" ] || continue + # shellcheck disable=SC1090 + . "$_launcher_lib" + AGMSG_LAUNCHER_RM=_uninstall_checked_rm agmsg_launcher_uninstall "$SKILL_DIR" || return 1 + break + done + # --- Remove the skill directory --- if [ "$KEEP_DATA" = true ]; then echo "" From c8e2ae9588fe12c8726f322c12e90f67f3dbfb16 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 12:55:56 -0700 Subject: [PATCH 06/15] fix(agmsgd): recheck install state before ownership claim (#1514) The entrypoint captures the verified manifest and schema while holding the install-operation lock. Before ownership claim it reacquires the lock, refuses an incomplete operation, and rechecks the manifest generation, install ID, and full scripts tree; it then opens install.db and applies the captured schema while protected. The lock remains held through ownership claim and control-socket readiness, so install.db is never opened outside the lock. A regression test injects the incomplete-operation marker immediately after the initial lock is released and verifies agmsgd exits with status 75 without claiming daemon ownership. Focused validation: the new entrypoint race test, the existing incomplete-operation refusal test, and the real entrypoint start/status/stop test pass; startup unit tests pass 2/2; the enforced-assertions checker passes at the existing baseline. --- scripts/daemon/agmsgd | 168 ++++++++++++++++++------------ scripts/daemon/main.mjs | 57 ++++++---- scripts/daemon/owner.mjs | 9 +- tests/test_agmsgd_entrypoint.bats | 22 ++++ 4 files changed, 166 insertions(+), 90 deletions(-) diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index 9b556e632..9e3df7837 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -50,6 +50,67 @@ function sha256File(installRoot, relPath) { return createHash("sha256").update(readFileSync(join(installRoot, relPath))).digest("hex"); } +function verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath, includeSchema = false }) { + if (existsSync(incompleteOperationPath)) { + fail(75, "an install or uninstall operation is incomplete; recover it before starting agmsgd"); + } + + if (!existsSync(manifestPath)) { + fail(0, "no completion record -- install.sh needs to run again"); + } + const manifestText = readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(manifestText); + + const selfPath = "scripts/daemon/agmsgd"; + const selfEntry = manifest.files.find((f) => f.path === selfPath); + if (!selfEntry) fail(75, `completion record has no entry for ${selfPath}`); + const selfDigest = sha256File(installRoot, selfPath); + if (selfDigest !== selfEntry.digest) { + fail(75, "this file's own digest does not match the completion record"); + } + + if (manifest.bootstrap_version !== BOOTSTRAP_VERSION) { + fail(75, `bootstrap version mismatch: this file is ${BOOTSTRAP_VERSION}, record has ${manifest.bootstrap_version}`); + } + + const installDb = new DatabaseSync(dbPath, { readOnly: true }); + let recordedInstallId; + try { + recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; + } finally { + installDb.close(); + } + if (recordedInstallId !== manifest.install_id) { + fail(75, `install_id mismatch: install.db has ${recordedInstallId ?? "(none)"}, manifest has ${manifest.install_id}`); + } + + if (manifest.digest_algo !== "sha256") { + fail(75, `unsupported digest_algo: ${manifest.digest_algo}`); + } + let onDisk; + try { + onDisk = collectScriptFiles(installRoot); + } catch (error) { + fail(75, `digest verification failed: ${error.message}`); + } + const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const onDiskSet = new Set(onDisk); + const expectedPaths = expected.map((f) => f.path); + const expectedSet = new Set(expectedPaths); + const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); + if (missing.length > 0) fail(75, `digest verification failed: missing file(s): ${missing.join(", ")}`); + const extra = onDisk.filter((p) => !expectedSet.has(p)); + if (extra.length > 0) fail(75, `digest verification failed: unexpected file(s): ${extra.join(", ")}`); + for (const { path, digest } of expected) { + if (sha256File(installRoot, path) !== digest) fail(75, `digest verification failed: digest mismatch: ${path}`); + } + + const schemaText = includeSchema + ? readFileSync(join(dirname(fileURLToPath(import.meta.url)), "schema.sql"), "utf8") + : undefined; + return { manifest, manifestText, schemaText }; +} + async function main() { const [installRoot, desired, opGenStr] = process.argv.slice(2); if (!installRoot || !desired || !opGenStr) { @@ -64,68 +125,10 @@ async function main() { const lockDb = new DatabaseSync(lockPath); lockDb.exec("BEGIN EXCLUSIVE;"); - let manifest; + let verified; let mainModule; try { - if (existsSync(incompleteOperationPath)) { - fail(75, "an install or uninstall operation is incomplete; recover it before starting agmsgd"); - } - - // 1. An install generation exists at all. - if (!existsSync(manifestPath)) { - fail(0, "no completion record -- install.sh needs to run again"); - } - manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - - // 2. This file's own digest is in the record, at its own path. - const selfPath = "scripts/daemon/agmsgd"; - const selfEntry = manifest.files.find((f) => f.path === selfPath); - if (!selfEntry) fail(75, `completion record has no entry for ${selfPath}`); - const selfDigest = sha256File(installRoot, selfPath); - if (selfDigest !== selfEntry.digest) { - fail(75, "this file's own digest does not match the completion record"); - } - - // 3. Bootstrap version matches; the launcher checks its own version too. - if (manifest.bootstrap_version !== BOOTSTRAP_VERSION) { - fail(75, `bootstrap version mismatch: this file is ${BOOTSTRAP_VERSION}, record has ${manifest.bootstrap_version}`); - } - - // 4. install_id: install.db's meta is the source of truth (PR 2); the - // manifest carries only a copy. - const installDb = new DatabaseSync(dbPath, { readOnly: true }); - let recordedInstallId; - try { - recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; - } finally { - installDb.close(); - } - if (recordedInstallId !== manifest.install_id) { - fail(75, `install_id mismatch: install.db has ${recordedInstallId ?? "(none)"}, manifest has ${manifest.install_id}`); - } - - // 5. Full digest verification: every file the manifest lists, no extras, - // no missing files, and no symlinks. - if (manifest.digest_algo !== "sha256") { - fail(75, `unsupported digest_algo: ${manifest.digest_algo}`); - } - let onDisk; - try { - onDisk = collectScriptFiles(installRoot); - } catch (error) { - fail(75, `digest verification failed: ${error.message}`); - } - const expected = [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); - const onDiskSet = new Set(onDisk); - const expectedPaths = expected.map((f) => f.path); - const expectedSet = new Set(expectedPaths); - const missing = expectedPaths.filter((p) => !onDiskSet.has(p)); - if (missing.length > 0) fail(75, `digest verification failed: missing file(s): ${missing.join(", ")}`); - const extra = onDisk.filter((p) => !expectedSet.has(p)); - if (extra.length > 0) fail(75, `digest verification failed: unexpected file(s): ${extra.join(", ")}`); - for (const { path, digest } of expected) { - if (sha256File(installRoot, path) !== digest) fail(75, `digest verification failed: digest mismatch: ${path}`); - } + verified = verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath, includeSchema: true }); // All checks passed -- now, and only now, import the rest of this // component. main.mjs's own module graph (owner/control/lifecycle/ @@ -137,15 +140,48 @@ async function main() { lockDb.close(); } - const db = new DatabaseSync(dbPath); - db.exec(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "schema.sql"), "utf8")); - await mainModule.main(db, { + async function prepareClaim() { + let claimLock; + try { + claimLock = new DatabaseSync(lockPath); + claimLock.exec("BEGIN EXCLUSIVE;"); + } catch (error) { + claimLock?.close(); + fail(75, `cannot recheck install state before daemon ownership claim: ${error.message}`); + } + let handedOff = false; + try { + const current = verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath }); + if (current.manifestText !== verified.manifestText) { + fail(75, "install generation changed before daemon ownership claim"); + } + const db = new DatabaseSync(dbPath); + db.exec(verified.schemaText); + const release = () => { + try { + claimLock.exec("COMMIT;"); + } finally { + claimLock.close(); + } + }; + handedOff = true; + return { db, release }; + } finally { + if (!handedOff) { + claimLock.exec("COMMIT;"); + claimLock.close(); + } + } + } + + await mainModule.main(null, { installRoot, - manifest, - manifestText: readFileSync(manifestPath, "utf8"), + manifest: verified.manifest, + manifestText: verified.manifestText, expectedDesired: desired, expectedOpGen, - version: manifest.version, + version: verified.manifest.version, + prepareClaim, }); } diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 0cf626002..28762f5a7 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -23,26 +23,39 @@ import { classify } from "./status.mjs"; export const POLL_INTERVAL_MS = 5000; // Takes ownership and binds the control socket. Returns -// {ok: true, gen, controlHandle} or {ok: false, reason} -- a refusal from -// takeOwnership OR a bind failure, both reported the same shape so the -// caller (main()) can log+exit either without a separate branch. A bind -// failure additionally reverts daemon_owner back to 'none' (the -// step-2 failure path) before returning. -export async function startup(db, { installRoot, expectedDesired, expectedOpGen, version, handlers }) { - const owned = takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }); - if (!owned.ok) return owned; - - let controlHandle; +// {ok: true, gen, controlHandle, db} or {ok: false, reason, db} -- a +// refusal from takeOwnership OR a bind failure, both reported the same +// shape so the caller (main()) can log+exit either without a separate +// branch. A bind failure additionally reverts daemon_owner back to 'none'. +// When supplied, prepareClaim rechecks the install while holding its lock +// and returns the database plus a release callback; that lock stays held +// through ownership claim and socket readiness. +export async function startup(db, { installRoot, expectedDesired, expectedOpGen, version, handlers, prepareClaim }) { + let releaseInstallLock = () => {}; try { - controlHandle = await createControlServer(owned.socket, handlers); - } catch (error) { - revertToNone(db, owned.gen, "bind_failed"); - logLine(installRoot, `startup: bind failed for gen ${owned.gen}: ${error.message}`); - return { ok: false, reason: `bind failed: ${error.message}` }; + if (prepareClaim) { + const prepared = await prepareClaim(); + db = prepared.db; + releaseInstallLock = prepared.release; + } + + const owned = takeOwnership(db, { installRoot, expectedDesired, expectedOpGen, version }); + if (!owned.ok) return { ...owned, db }; + + let controlHandle; + try { + controlHandle = await createControlServer(owned.socket, handlers); + } catch (error) { + revertToNone(db, owned.gen, "bind_failed"); + logLine(installRoot, `startup: bind failed for gen ${owned.gen}: ${error.message}`); + return { ok: false, reason: `bind failed: ${error.message}`, db }; + } + markReady(db, owned.gen); + logLine(installRoot, `startup: gen ${owned.gen} ready at ${owned.socket}`); + return { ok: true, gen: owned.gen, controlHandle, db }; + } finally { + releaseInstallLock(); } - markReady(db, owned.gen); - logLine(installRoot, `startup: gen ${owned.gen} ready at ${owned.socket}`); - return { ok: true, gen: owned.gen, controlHandle }; } // One poll cycle. Returns @@ -90,7 +103,7 @@ export async function gracefulStop(db, installRoot, gen, controlHandle, channelH // already verified. Exit codes: 0 for a declined // start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 // for a bind failure or any other unexpected error. -export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version }) { +export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version, prepareClaim }) { const channelHooks = []; // beta's Codex-queue channel plugs in here, separately. let controlHandle; let gen; @@ -109,6 +122,11 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe expectedDesired, expectedOpGen, version, + prepareClaim: prepareClaim ? async () => { + const prepared = await prepareClaim(); + db = prepared.db; + return prepared; + } : undefined, handlers: { onStop: async () => { // Fires from inside a control-socket request; the response itself @@ -120,6 +138,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe onStatus: async () => classify({ owner: { gen, state: "ready", version }, intent: {}, alive: true, reachable: true }), }, }); + db = started.db ?? db; if (!started.ok) { logLine(installRoot, `startup declined: ${started.reason}`); db.prepare("INSERT INTO daemon_start_attempts (at, reason, executor_pid) VALUES (?, ?, ?)").run( diff --git a/scripts/daemon/owner.mjs b/scripts/daemon/owner.mjs index 48d4e0235..780e6bb85 100644 --- a/scripts/daemon/owner.mjs +++ b/scripts/daemon/owner.mjs @@ -5,11 +5,10 @@ // never from here. This file only READS daemon_intent, to condition taking // ownership on it still matching what the launcher observed. // -// The install-op lock (run/install-op.lock.db, PR 2) is a SEPARATE -// mechanism the entrypoint holds only while loading code; by the time -// takeOwnership() runs, that lock has already been -// released. daemon_owner's own gen-conditioned CAS is self-contained and -// needs no external lock. +// The install-op lock (run/install-op.lock.db) is separate from the +// daemon_owner CAS. The entrypoint holds it while revalidating the install +// generation and through takeOwnership() plus control-socket readiness, so +// an install cannot begin between validation and this claim. import { join } from "node:path"; import { currentExecutor, isAlive } from "./executor.mjs"; diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index 3216ae390..55d4ef381 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -60,6 +60,18 @@ _write_completion_record() { sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id = '$install_id', node_path = '$(command -v node)'; UPDATE daemon_intent SET desired = 'on', op_gen = 0;" } +_inject_incomplete_marker_after_initial_unlock() { + node - "$TEST_SKILL_DIR/scripts/daemon/agmsgd" <<'NODE' +const { readFileSync, writeFileSync } = require('node:fs'); +const scriptPath = process.argv[2]; +const source = readFileSync(scriptPath, 'utf8'); +const needle = ' let claimLock;\n try {\n'; +if (source.split(needle).length !== 2) throw new Error('claim-lock insertion point must occur exactly once'); +const insert = ' await import("node:fs/promises").then(({ writeFile }) => writeFile(incompleteOperationPath, JSON.stringify({ operation_id: "injected-after-unlock" })));\n'; +writeFileSync(scriptPath, source.replace(needle, insert + needle)); +NODE +} + @test "agmsgd end-to-end: starts, answers status over its real control socket, and stops cleanly on request" { _write_completion_record @@ -124,3 +136,13 @@ _write_completion_record() { [ "$status" -eq 75 ] [[ "$output" == *"operation is incomplete"* ]] } + +@test "agmsgd does not claim ownership when an install starts after bootstrap unlock" { + _inject_incomplete_marker_after_initial_unlock + _write_completion_record + + run node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 + [ "$status" -eq 75 ] + printf '%s\n' "$output" | grep -Fq "operation is incomplete" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT state FROM daemon_owner;")" = "none" ] +} From cc16cae3771488c9e2691805dde2560d83d5823f Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 13:40:06 -0700 Subject: [PATCH 07/15] tests: keep install cancellation children from holding Bats pipes (#1517) Fixes a macOS CI hang in the install cancellation test. Its deliberately blocked copy writer inherited Bats file descriptors 3/4 and standard streams, and the test did not register the lock child with teardown. A process left with those descriptors could keep the test job output pipe open after the assertions finished. The test now disconnects both background installer invocations from Bats descriptors and stdin, closes the fake writer descriptors, and registers the writer and SQLite lock child with teardown. Teardown checks each command before signaling and waits for confirmed exit. Validation: the focused cancellation test passes locally, and a post-run process check found no matching writer, lock, or installer processes. --- tests/test_install.bats | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_install.bats b/tests/test_install.bats index decf4ee65..574a70a6d 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -2415,13 +2415,13 @@ CYG 'test_home="${AGMSG_TEST_INSTALL_LOCK_LOSS_DIR%/cancel-writer}"' \ 'target="$test_home/.agents/skills/agmsg/scripts/"' \ 'dest=""; for arg do dest="$arg"; done' \ - 'if [ "$dest" = "$target" ]; then exec 8>&- 9>&-; printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer-entered"; if [ -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-kill-mode" ]; then printf "%s\\n" "$2" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/stage-source"; while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-release" ]; do sleep 0.02; done; exit 0; fi; while :; do sleep 1; done; fi' \ + 'if [ "$dest" = "$target" ]; then exec 8>&- 9>&- 3>&- 4>&- /dev/null 2>&1; printf "%s\\n" "$$" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer.pid"; touch "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/writer-entered"; if [ -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-kill-mode" ]; then printf "%s\\n" "$2" > "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/stage-source"; while [ ! -e "$AGMSG_TEST_INSTALL_LOCK_LOSS_DIR/hard-release" ]; do sleep 0.02; done; exit 0; fi; while :; do sleep 1; done; fi' \ "exec $cp_q \"\$@\"" > "$bin/cp" chmod +x "$bin/cp" HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ - bash "$REPO_ROOT/install.sh" --update > "$inject_dir/install.out" 2>&1 & + bash "$REPO_ROOT/install.sh" --update "$inject_dir/install.out" 2>&1 3>&- 4>&- & install_pid=$! _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" for ((i = 0; i < 250; i++)); do @@ -2431,7 +2431,9 @@ CYG done [ -e "$inject_dir/writer-entered" ] writer_pid="$(cat "$inject_dir/writer.pid")" + lock_pid="$(cat "$inject_dir/lock.pid")" _agmsg_watch_pid "$writer_pid" "$bin/cp" + _agmsg_watch_pid "$lock_pid" "$SK/run/install-op.lock.db" [ -e "$SK/run/install-op-incomplete.json" ] kill -TERM "$install_pid" @@ -2445,7 +2447,7 @@ CYG rm -f "$inject_dir/writer-entered" "$inject_dir/hard-release" HOME="$FAKE_HOME" CODEX_HOME="$FAKE_HOME/codex" PATH="$bin:$PATH" \ AGMSG_TEST_INSTALL_LOCK_LOSS_DIR="$inject_dir" \ - bash "$REPO_ROOT/install.sh" --update > "$inject_dir/hard-kill-install.out" 2>&1 & + bash "$REPO_ROOT/install.sh" --update "$inject_dir/hard-kill-install.out" 2>&1 3>&- 4>&- & install_pid=$! _agmsg_watch_pid "$install_pid" "$REPO_ROOT/install.sh --update" for ((i = 0; i < 250; i++)); do @@ -2457,6 +2459,7 @@ CYG writer_pid="$(cat "$inject_dir/writer.pid")" lock_pid="$(cat "$inject_dir/lock.pid")" _agmsg_watch_pid "$writer_pid" "$bin/cp" + _agmsg_watch_pid "$lock_pid" "$SK/run/install-op.lock.db" op_id="$(sqlite3 :memory: "SELECT json_extract(readfile('$(rf "$SK/run/install-op-incomplete.json")'), '\$.operation_id');")" kill -KILL "$install_pid" if wait "$install_pid"; then install_rc=0; else install_rc=$?; fi From 96b43b51bf2c8e16cd96955a7fbab17f4f26e2b6 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 15:20:45 -0700 Subject: [PATCH 08/15] Add Codex queue delivery and seat status (#1520) The daemon now tracks Codex seat destinations and reads existing message stores without selecting message bodies. For an addressable seat, it queues one nonce-bearing inbox nudge, records the queued item, and confirms delivery from the queue row or rollout; unreadable observations remain pending and visible in status. Per-seat status reports addressable, unaddressable, blocked, or bridged, including a reason when delivery cannot be established. Queue writes are serialized by CODEX_HOME. Windows queue delivery remains blocked with an explicit status reason until delivery to a live conversation is verified. Local checks: focused Codex queue, daemon main, and status Bats tests; syntax checks for changed modules. --- scripts/daemon.sh | 3 +- scripts/daemon/channels/codex-queue-io.mjs | 347 ++++++++++++ scripts/daemon/channels/codex-queue-store.mjs | 217 ++++++++ scripts/daemon/channels/codex-queue.mjs | 515 ++++++++++++++++++ scripts/daemon/channels/process-group.mjs | 88 +++ scripts/daemon/main.mjs | 46 +- scripts/daemon/status.mjs | 18 +- tests/agmsgd_codex_queue.test.mjs | 422 ++++++++++++++ tests/agmsgd_main.test.mjs | 28 +- tests/test_agmsgd_codex_queue.bats | 6 + tests/test_agmsgd_daemon_sh.bats | 27 +- 11 files changed, 1698 insertions(+), 19 deletions(-) create mode 100644 scripts/daemon/channels/codex-queue-io.mjs create mode 100644 scripts/daemon/channels/codex-queue-store.mjs create mode 100644 scripts/daemon/channels/codex-queue.mjs create mode 100644 scripts/daemon/channels/process-group.mjs create mode 100644 tests/agmsgd_codex_queue.test.mjs create mode 100644 tests/test_agmsgd_codex_queue.bats diff --git a/scripts/daemon.sh b/scripts/daemon.sh index eb6ad7f1b..2c6cc2e70 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -281,7 +281,8 @@ _start_registered_service() { } _start_unregistered_launcher() { - bash "$LAUNCHER" /dev/null 2>&1 3>&- 4>&- & + local stderr_log="${AGMSGD_TEST_LAUNCH_STDERR_LOG:-/dev/null}" + bash "$LAUNCHER" /dev/null 2>"$stderr_log" 3>&- 4>&- & disown 2>/dev/null || true } diff --git a/scripts/daemon/channels/codex-queue-io.mjs b/scripts/daemon/channels/codex-queue-io.mjs new file mode 100644 index 000000000..1a6a231bb --- /dev/null +++ b/scripts/daemon/channels/codex-queue-io.mjs @@ -0,0 +1,347 @@ +// Codex queue I/O for the beta channel. This file knows only Codex's local +// profile format and CLI contract; the daemon loop and its install.db schema +// remain owned by the caller. + +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createReadStream, lstatSync, readdirSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { isAbsolute, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const MAX_OUTPUT_CHARS = 64 * 1024; +export const QUEUE_CONFIRMATION_TTL_MS = 60_000; + +export function newNonce() { + return randomUUID(); +} + +export function inboxNudge(nonce) { + if (typeof nonce !== "string" || !/^[A-Za-z0-9-]{1,80}$/.test(nonce)) { + throw new TypeError("nonce must be a short token"); + } + return `[agmsg:${nonce}] New messages are waiting. Check your agmsg inbox.`; +} + +function appendBounded(current, chunk) { + if (current.length >= MAX_OUTPUT_CHARS) return current; + return current + chunk.toString("utf8").slice(0, MAX_OUTPUT_CHARS - current.length); +} + +function signalProcessGroup(child, signal) { + if (process.platform !== "win32" && Number.isInteger(child.pid) && child.pid > 1) { + try { + process.kill(-child.pid, signal); + return; + } catch (error) { + if (error?.code !== "ESRCH") return; + } + } + try { + child.kill(signal); + } catch { + // The close event remains the authority for whether the child exited. + } +} + +// Runs one queue operation without a shell and waits for close (not just exit) +// so stdout/stderr are fully captured before its result is interpreted. +export async function runCodexQueue({ + executable, + codexHome, + thread, + message, + timeoutMs, + signal, + onChildStart, + spawnProcess = spawn, +}) { + if (typeof executable !== "string" || executable.length === 0) throw new TypeError("missing Codex executable"); + if (typeof codexHome !== "string" || !isAbsolute(codexHome)) throw new TypeError("CODEX_HOME must be absolute"); + if (!UUID.test(thread ?? "")) throw new TypeError("thread must be a Codex UUID"); + if (typeof message !== "string" || !message) throw new TypeError("message must not be empty"); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError("timeoutMs must be a positive integer"); + if (signal?.aborted) return { kind: "cancelled", reason: "daemon_stopping" }; + + const args = ["queue", "--thread", thread, "--message", message]; + let child; + try { + child = spawnProcess(executable, args, { + env: { ...process.env, CODEX_HOME: codexHome }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + detached: process.platform !== "win32", + }); + } catch (error) { + return { kind: "failed", reason: `spawn_failed: ${error.message}` }; + } + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); }); + child.stderr?.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); }); + + const closed = new Promise((resolveClose) => { + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + resolveClose(value); + }; + child.once("error", (error) => settle({ error })); + child.once("close", (code, signal) => settle({ code, signal })); + }); + + let timedOut = false; + let aborted = false; + const killTimers = []; + const onAbort = () => { + aborted = true; + signalProcessGroup(child, "SIGTERM"); + killTimers.push(setTimeout(() => signalProcessGroup(child, "SIGKILL"), 1000)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + const timer = setTimeout(() => { + timedOut = true; + signalProcessGroup(child, "SIGTERM"); + }, timeoutMs); + const hardKill = setTimeout(() => { + if (timedOut) signalProcessGroup(child, "SIGKILL"); + }, timeoutMs + 1000); + let childRecordFailed = false; + if (onChildStart) { + try { + onChildStart(child.pid); + } catch { + childRecordFailed = true; + signalProcessGroup(child, "SIGTERM"); + killTimers.push(setTimeout(() => signalProcessGroup(child, "SIGKILL"), 1000)); + } + } + const result = await closed; + clearTimeout(timer); + clearTimeout(hardKill); + for (const killTimer of killTimers) clearTimeout(killTimer); + signal?.removeEventListener("abort", onAbort); + + if (childRecordFailed) return { kind: "failed", reason: "child_record_failed", stdout, stderr }; + if (aborted) return { kind: "cancelled", reason: "daemon_stopping", stdout, stderr }; + if (timedOut) return { kind: "timeout", stdout, stderr }; + if (result.error) return { kind: "failed", reason: `spawn_failed: ${result.error.message}`, stdout, stderr }; + const combined = `${stdout}\n${stderr}`; + if (result.code !== 0) { + if (combined.includes("code -32600")) return { kind: "archived", reason: "thread_archived" }; + if (combined.includes("code -32603")) return { kind: "no_rollout", reason: "thread_rollout_not_found" }; + return { kind: "failed", reason: `queue_exit_${result.code ?? result.signal ?? "unknown"}` }; + } + + const queued = stdout.match(/^Queued message ([0-9a-f-]{36}) for thread ([0-9a-f-]{36})\.?\s*$/m); + if (!queued || queued[2].toLowerCase() !== thread.toLowerCase() || !UUID.test(queued[1])) { + return { kind: "failed", reason: "queue_output_unrecognized" }; + } + return { kind: "queued", queueItemId: queued[1] }; +} + +// Tri-state by design: an unreadable DB is not an empty queue. A row is +// corroborating evidence only when both its id and thread match. +export function readQueuedItem(codexHome, queueItemId, thread) { + if (!isAbsolute(codexHome ?? "") || !UUID.test(queueItemId ?? "") || !UUID.test(thread ?? "")) { + return { state: "unreadable", reason: "invalid_queue_lookup" }; + } + const dbPath = join(codexHome, "queue_1.sqlite"); + let db; + try { + if (!lstatSync(dbPath).isFile()) return { state: "unreadable", reason: "queue_db_missing" }; + db = new DatabaseSync(dbPath, { readOnly: true, allowExtension: false }); + db.exec("PRAGMA busy_timeout = 1000;"); + const row = db.prepare("SELECT thread_id FROM queued_items WHERE id = ?").get(queueItemId); + if (!row) return { state: "absent" }; + if (row.thread_id !== thread) return { state: "unreadable", reason: "queue_thread_mismatch" }; + return { state: "present" }; + } catch { + return { state: "unreadable", reason: "queue_db_read_failed" }; + } finally { + try { db?.close(); } catch { /* the query result already carries the observation */ } + } +} + +function matchingRollouts(sessionsDir, thread) { + const matches = []; + const walk = (dir, depth) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isSymbolicLink()) throw new Error("symlink_in_sessions"); + if (entry.isDirectory() && depth < 3) walk(path, depth + 1); + else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(`-${thread}.jsonl`)) matches.push(path); + } + }; + walk(sessionsDir, 0); + return matches; +} + +function userText(row) { + if (row?.type === "event_msg" && row.payload?.type === "user_message") { + const value = row.payload.message; + return typeof value === "string" ? { kind: "text", text: value } : { kind: "unreadable" }; + } + if (row?.type === "response_item" && row.payload?.type === "message" && row.payload.role === "user") { + if (!Array.isArray(row.payload.content) || row.payload.content.length === 0) return { kind: "unreadable" }; + const pieces = []; + for (const part of row.payload.content) { + if (typeof part?.text !== "string") return { kind: "unreadable" }; + pieces.push(part.text); + } + return { kind: "text", text: pieces.join("\n") }; + } + return { kind: "other" }; +} + +async function rolloutHasNonce(path, thread, nonce) { + let malformed = false; + let first = true; + let sawUserRow = false; + let sawUnreadableUserRow = false; + const input = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity }); + try { + for await (const line of input) { + if (!line) continue; + let row; + try { + row = JSON.parse(line); + } catch { + malformed = true; + continue; + } + if (first) { + first = false; + if (row?.type !== "session_meta" || row.payload?.id !== thread) return { state: "unreadable", reason: "rollout_thread_mismatch" }; + } + const observation = userText(row); + if (observation.kind === "unreadable") { + sawUnreadableUserRow = true; + } else if (observation.kind === "text") { + sawUserRow = true; + if (observation.text.includes(nonce)) return { state: "present" }; + } + } + } catch { + return { state: "unreadable", reason: "rollout_read_failed" }; + } + if (first) return { state: "unreadable", reason: "rollout_empty" }; + if (malformed) return { state: "unreadable", reason: "rollout_malformed" }; + if (sawUnreadableUserRow) return { state: "unreadable", reason: "rollout_user_row_unrecognized" }; + if (!sawUserRow) return { state: "unreadable", reason: "rollout_user_rows_unrecognized" }; + return { state: "absent" }; +} + +// This observation may not yet be available on unauthenticated runs. Unknown +// is deliberately separate from absent so callers can leave the row pending. +export async function readRolloutNonce(codexHome, thread, nonce) { + if (!isAbsolute(codexHome ?? "") || !UUID.test(thread ?? "") || !/^[A-Za-z0-9-]{1,80}$/.test(nonce ?? "")) { + return { state: "unreadable", reason: "invalid_rollout_lookup" }; + } + const sessionsDir = join(codexHome, "sessions"); + let matches; + try { + let sessionsStat; + try { + sessionsStat = lstatSync(sessionsDir); + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "sessions_dir_missing" }; + throw error; + } + if (sessionsStat.isSymbolicLink() || !sessionsStat.isDirectory()) { + return { state: "unreadable", reason: "sessions_path_not_directory" }; + } + matches = matchingRollouts(sessionsDir, thread); + } catch { + return { state: "unreadable", reason: "sessions_scan_failed" }; + } + if (matches.length === 0) return { state: "unreadable", reason: "thread_rollout_missing" }; + if (matches.length !== 1) return { state: "unreadable", reason: "multiple_thread_rollouts" }; + return rolloutHasNonce(matches[0], thread, nonce); +} + +// A positive observation is enough to confirm. Two readable negative +// observations wait for the retry window, then expire. Any unreadable or +// malformed observation remains pending; callers must surface that state +// instead of treating it as an empty queue or an absent nonce. +export function resolvePendingQueue({ queueObservation, rolloutObservation, ageMs }) { + if (queueObservation?.state === "present") { + return { state: "confirmed", reason: "queue_item_present" }; + } + if (rolloutObservation?.state === "present") { + return { state: "confirmed", reason: "nonce_observed" }; + } + + const knownState = (observation) => + observation?.state === "absent" || observation?.state === "unreadable"; + if (!knownState(queueObservation) || !knownState(rolloutObservation)) { + return { state: "pending", reason: "verification_unreadable" }; + } + if (queueObservation.state === "unreadable" || rolloutObservation.state === "unreadable") { + return { state: "pending", reason: "verification_unreadable" }; + } + if (!Number.isSafeInteger(ageMs) || ageMs < 0) { + return { state: "pending", reason: "pending_age_unreadable" }; + } + if (ageMs >= QUEUE_CONFIRMATION_TTL_MS) { + return { state: "expired", reason: "confirmation_timeout" }; + } + return { state: "pending", reason: "awaiting_confirmation" }; +} + +// Call immediately after the caller has durably stored queueItemId. Check the +// queue first: Codex may consume the item before the rollout can be inspected. +// A present row is already sufficient evidence, so that fast path does not +// depend on the not-yet-measured processed-message serialization. +export async function verifyPendingDelivery({ + codexHome, + queueItemId, + thread, + nonce, + ageMs, + readQueue = readQueuedItem, + readRollout = readRolloutNonce, +}) { + let queueObservation; + try { + queueObservation = readQueue(codexHome, queueItemId, thread); + } catch { + queueObservation = { state: "unreadable", reason: "queue_db_read_failed" }; + } + if (queueObservation?.state === "present") { + return { state: "confirmed", reason: "queue_item_present" }; + } + + let rolloutObservation; + try { + rolloutObservation = await readRollout(codexHome, thread, nonce); + } catch { + rolloutObservation = { state: "unreadable", reason: "rollout_read_failed" }; + } + return resolvePendingQueue({ queueObservation, rolloutObservation, ageMs }); +} + +export function classifyCodexSeat({ roleSession, bridgeState, rolloutState }) { + if (bridgeState === "running") return { state: "bridged", reason: "bridge_running" }; + if (!roleSession || !roleSession.thread || !roleSession.codex_home) { + return { state: "unaddressable", reason: "role_session_missing" }; + } + if (!UUID.test(roleSession.thread)) return { state: "unaddressable", reason: "thread_id_invalid" }; + if (!isAbsolute(roleSession.codex_home)) return { state: "unaddressable", reason: "codex_home_invalid" }; + if (bridgeState !== "stopped") return { state: "blocked", reason: "bridge_state_unknown" }; + if (rolloutState === "valid") return { state: "addressable", reason: "" }; + if (rolloutState === "archived") return { state: "blocked", reason: "thread_archived" }; + if (rolloutState === "no_rollout") return { state: "blocked", reason: "codex_home_mismatch" }; + return { state: "blocked", reason: "thread_observation_failed" }; +} diff --git a/scripts/daemon/channels/codex-queue-store.mjs b/scripts/daemon/channels/codex-queue-store.mjs new file mode 100644 index 000000000..733c58d33 --- /dev/null +++ b/scripts/daemon/channels/codex-queue-store.mjs @@ -0,0 +1,217 @@ +// Read-only view of agmsg's existing message stores plus the daemon's small +// Codex-channel tables. The daemon never initializes or repairs a team store. + +import { DatabaseSync } from "node:sqlite"; +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +export const CODEX_CHANNEL_SCHEMA = ` +CREATE TABLE IF NOT EXISTS beta_codex_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seat TEXT NOT NULL, + codex_home TEXT NOT NULL, + thread TEXT NOT NULL, + up_to TEXT NOT NULL, + nonce TEXT NOT NULL, + queue_item_id TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'confirmed', 'expired')), + children TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS beta_codex_queue_one_pending + ON beta_codex_queue(seat) WHERE state = 'pending'; +CREATE INDEX IF NOT EXISTS beta_codex_queue_latest + ON beta_codex_queue(seat, id DESC); +CREATE TABLE IF NOT EXISTS beta_codex_seat ( + seat TEXT PRIMARY KEY, + thread TEXT, + codex_home TEXT, + state TEXT NOT NULL CHECK (state IN ('addressable', 'unaddressable', 'blocked', 'bridged')), + reason TEXT NOT NULL, + checked_at TEXT NOT NULL +); +`; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function ensureCodexChannelSchema(db) { + db.exec("BEGIN IMMEDIATE;"); + try { + db.exec(CODEX_CHANNEL_SCHEMA); + db.exec("COMMIT;"); + } catch (error) { + try { db.exec("ROLLBACK;"); } catch { /* preserve the schema error */ } + throw error; + } +} +function readJson(path) { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") return { missing: true }; + throw error; + } +} + +function encodeName(value) { + return [...Buffer.from(value, "utf8")].map((byte) => { + const char = String.fromCharCode(byte); + return /^[A-Za-z0-9._-]$/.test(char) ? char : `%${byte.toString(16).padStart(2, "0").toUpperCase()}`; + }).join(""); +} + +export function roleSessionPath(runDir, team, agent) { + return join(runDir, `role-session.${encodeName(team)}__${encodeName(agent)}`); +} + +export function readRoleSession(runDir, team, agent) { + const path = roleSessionPath(runDir, team, agent); + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return { state: "absent" }; + return { state: "unreadable", reason: "role_session_read_failed" }; + } + if (!stat.isFile() || stat.isSymbolicLink()) return { state: "unreadable", reason: "role_session_not_regular_file" }; + try { + const fields = Object.create(null); + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const at = line.indexOf("="); + if (at < 1) continue; + const key = line.slice(0, at); + if (!(key in fields)) fields[key] = line.slice(at + 1); + } + return { + state: "present", + record: { + team: fields.team ?? "", + agent: fields.agent ?? "", + type: fields.type ?? "", + project: fields.project ?? "", + thread: fields.session ?? "", + codex_home: fields.codex_home ?? "", + }, + }; + } catch { + return { state: "unreadable", reason: "role_session_read_failed" }; + } +} + +// Enumerates only roles whose current registration explicitly names Codex. +// A malformed team record is reported, not interpreted as an empty roster. +export function listCodexRegistrations(teamsDir) { + let entries; + try { + entries = readdirSync(teamsDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return { state: "ok", seats: [] }; + return { state: "unreadable", reason: "team_roster_unreadable" }; + } + const seats = []; + for (const entry of entries) { + if (entry.isSymbolicLink()) return { state: "unreadable", reason: "team_roster_symlink" }; + if (!entry.isDirectory()) continue; + const result = readJson(join(teamsDir, entry.name, "config.json")); + if (result.missing) continue; + const config = result; + if (!config || typeof config !== "object" || !config.agents || typeof config.agents !== "object" || Array.isArray(config.agents)) { + return { state: "unreadable", reason: "team_roster_malformed" }; + } + for (const [agent, value] of Object.entries(config.agents)) { + const regs = value && Array.isArray(value.registrations) ? value.registrations : []; + if (regs.some((registration) => registration?.type === "codex")) { + seats.push({ team: entry.name, agent, teamConfig: config }); + } + } + } + return { state: "ok", seats }; +} + +export function readStorageDriver(configPath) { + const result = readJson(configPath); + if (result.missing) return { state: "ok", driver: "sqlite" }; + if (!result || typeof result !== "object" || Array.isArray(result)) { + return { state: "unreadable", reason: "storage_config_malformed" }; + } + const driver = result.storage || "sqlite"; + return typeof driver === "string" && driver.length > 0 + ? { state: "ok", driver } + : { state: "unreadable", reason: "storage_driver_unreadable" }; +} + +export function messageStorePath({ storageDir, team, teamConfig }) { + if (typeof team !== "string" || !team || team.includes("/") || team.includes("\\") || team === "." || team === "..") { + throw new TypeError("invalid_team_name"); + } + const partition = teamConfig?.drivers?.partition || "shared"; + if (partition === "shared") return join(storageDir, "messages.db"); + if (partition === "per-team") return join(storageDir, "teams", team, "messages.db"); + throw new Error("storage_partition_unsupported"); +} + +export function openMessageStore(path) { + if (!isAbsolute(path)) throw new TypeError("message_store_path_not_absolute"); + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("message_store_not_regular_file"); + const db = new DatabaseSync(path, { readOnly: true, allowExtension: false }); + try { + db.exec("PRAGMA busy_timeout = 1000; PRAGMA query_only = ON;"); + // Validate the expected read schema now. An absent table is not an empty inbox. + db.prepare("SELECT seq FROM events LIMIT 0").all(); + db.prepare("SELECT id FROM messages LIMIT 0").all(); + db.prepare("SELECT local_position FROM read_cursors LIMIT 0").all(); + return db; + } catch (error) { + db.close(); + throw error; + } +} + +function cursorValue(raw) { + if (!raw) return { eventSeq: 0, legacyId: 0 }; + try { + const cursor = JSON.parse(raw); + if (Number.isSafeInteger(cursor.eventSeq) && cursor.eventSeq >= 0 && Number.isSafeInteger(cursor.legacyId) && cursor.legacyId >= 0) { + return cursor; + } + } catch { /* malformed cursor must not become a guessed zero */ } + throw new Error("notification_cursor_unreadable"); +} + +// Uses one read transaction so the cursor never advances past a concurrently +// inserted message. Bodies are not selected or returned. +export function readUnreadSnapshot(db, team, agent, encodedCursor = "") { + const cursor = cursorValue(encodedCursor); + db.exec("BEGIN;"); + try { + const tip = db.prepare("SELECT COALESCE((SELECT seq FROM sqlite_sequence WHERE name='events'), 0) AS n").get().n; + const legacyTip = db.prepare("SELECT COALESCE(MAX(id), 0) AS n FROM messages WHERE team = ?").get(team).n; + const eventUnread = db.prepare(` + SELECT 1 AS yes FROM events e + WHERE e.type='message_sent' AND e.team=? AND e.to_agent=? + AND e.seq>? AND e.seq<=? + AND e.seq>COALESCE((SELECT local_position FROM read_cursors WHERE team=? AND agent=?), 0) + AND NOT EXISTS (SELECT 1 FROM events r WHERE r.type='message_read' AND r.team=e.team AND r.agent=? AND r.msg_id=e.id) + LIMIT 1 + `).get(team, agent, cursor.eventSeq, tip, team, agent, agent); + const legacyUnread = db.prepare(` + SELECT 1 AS yes FROM messages m + WHERE m.team=? AND m.to_agent=? AND m.id>? AND m.id<=? AND m.read_at IS NULL + AND NOT EXISTS (SELECT 1 FROM events r WHERE r.type='message_read' AND r.team=m.team AND r.agent=? AND r.msg_id=CAST(m.id AS TEXT)) + AND NOT EXISTS (SELECT 1 FROM events e2 WHERE e2.legacy_id=m.id AND e2.seq>0) + LIMIT 1 + `).get(team, agent, cursor.legacyId, legacyTip, agent); + db.exec("COMMIT;"); + return { + state: "ok", + unread: Boolean(eventUnread || legacyUnread), + upTo: JSON.stringify({ eventSeq: tip, legacyId: legacyTip }), + }; + } catch (error) { + try { db.exec("ROLLBACK;"); } catch { /* keep the observation error */ } + throw error; + } +} diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs new file mode 100644 index 000000000..569fb7c7a --- /dev/null +++ b/scripts/daemon/channels/codex-queue.mjs @@ -0,0 +1,515 @@ +// Polls the existing message stores and nudges registered Codex seats through +// the local Codex queue. It never writes message data or read cursors. + +import { createHash } from "node:crypto"; +import { homedir, hostname } from "node:os"; +import { isAbsolute, join, resolve, sep } from "node:path"; +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { logLine } from "../log.mjs"; +import { withImmediateTransaction } from "../db.mjs"; +import { + listCodexRegistrations, + messageStorePath, + openMessageStore, + readRoleSession, + readStorageDriver, + readUnreadSnapshot, +} from "./codex-queue-store.mjs"; +import { + classifyCodexSeat, + inboxNudge, + newNonce, + readQueuedItem, + readRolloutNonce, + resolvePendingQueue, + runCodexQueue, +} from "./codex-queue-io.mjs"; +import { captureProcessGroup, observeProcessGroup } from "./process-group.mjs"; +import { processStartWitness } from "./process-group.mjs"; + +const QUEUE_TIMEOUT_MS = 10_000; + +function seatKey(team, agent) { + return JSON.stringify([team, agent]); +} + +function sameProject(left, right) { + if (typeof left !== "string" || !left || typeof right !== "string" || !right) return false; + if (!isAbsolute(left) || !isAbsolute(right)) return false; + const normalize = (value) => { + let candidate = value; + if (process.platform === "win32") { + candidate = candidate.replace(/\\/g, "/"); + const gitBash = candidate.match(/^\/([A-Za-z])(?:\/(.*))?$/); + if (gitBash) candidate = `${gitBash[1]}:/${gitBash[2] ?? ""}`; + candidate = candidate.replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`); + } + let result = resolve(candidate).split(sep).join("/"); + if (process.platform === "win32") result = result.toLowerCase(); + return result; + }; + const a = normalize(left); + const b = normalize(right); + return a === b; +} + +function registrationProjects(teamConfig, agent) { + const registrations = teamConfig?.agents?.[agent]?.registrations; + return Array.isArray(registrations) + ? registrations.filter((item) => item?.type === "codex").map((item) => item.project).filter((value) => typeof value === "string" && value) + : []; +} + +function readPending(db, seat) { + return db.prepare(` + SELECT id, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at + FROM beta_codex_queue WHERE seat = ? AND state = 'pending' ORDER BY id DESC LIMIT 1 + `).get(seat); +} + +function pendingSeats(db) { + const rows = db.prepare("SELECT DISTINCT seat FROM beta_codex_queue WHERE state = 'pending'").all(); + const seats = []; + for (const row of rows) { + try { + const pair = JSON.parse(row.seat); + if (!Array.isArray(pair) || pair.length !== 2) continue; + const [team, agent] = pair; + const validSegment = (value) => typeof value === "string" && value.length > 0 && value !== "." && value !== ".." && + !value.startsWith("-") && !/[\\/\u0000-\u001f\u007f]/.test(value); + if (validSegment(team) && validSegment(agent)) seats.push({ team, agent }); + } catch { /* malformed historical key cannot safely identify a seat */ } + } + return seats; +} + +function latestConfirmedCursor(db, seat) { + const row = db.prepare(` + SELECT up_to FROM beta_codex_queue WHERE seat = ? AND state = 'confirmed' + ORDER BY id DESC LIMIT 1 + `).get(seat); + return row?.up_to ?? ""; +} + +function saveSeat(db, { seat, thread = null, codexHome = null, state, reason = "" }, now) { + withImmediateTransaction(db, () => { + db.prepare(` + INSERT INTO beta_codex_seat (seat, thread, codex_home, state, reason, checked_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(seat) DO UPDATE SET thread=COALESCE(excluded.thread, beta_codex_seat.thread), + codex_home=COALESCE(excluded.codex_home, beta_codex_seat.codex_home), + state=excluded.state, reason=excluded.reason, checked_at=excluded.checked_at + `).run(seat, thread, codexHome, state, reason, new Date(now()).toISOString()); + }); +} + +function setQueueState(db, id, state, queueItemId = undefined) { + withImmediateTransaction(db, () => { + if (queueItemId === undefined) { + db.prepare("UPDATE beta_codex_queue SET state = ? WHERE id = ? AND state = 'pending'").run(state, id); + } else { + db.prepare("UPDATE beta_codex_queue SET state = ?, queue_item_id = ? WHERE id = ? AND state = 'pending'") + .run(state, queueItemId, id); + } + }); +} + +function recordChildStart(db, id, pid, captureGroup) { + let group; + try { + group = captureGroup(pid); + } catch { + group = { state: "unreadable", reason: "child_start_witness_unavailable" }; + } + withImmediateTransaction(db, () => { + db.prepare("UPDATE beta_codex_queue SET children = ? WHERE id = ? AND state = 'pending'") + .run(JSON.stringify(group), id); + }); +} + +function recordChildResult(db, id, result) { + withImmediateTransaction(db, () => { + const row = db.prepare("SELECT children FROM beta_codex_queue WHERE id = ? AND state = 'pending'").get(id); + if (!row) return; + let children; + try { children = JSON.parse(row.children); } catch { children = { state: "unreadable", reason: "child_group_record_malformed" }; } + if (children.state === "incomplete" && result.kind === "failed" && result.reason?.startsWith("spawn_failed:")) { + children = { state: "absent", kind: result.kind }; + } else { + children.result = result.kind; + } + db.prepare("UPDATE beta_codex_queue SET children = ? WHERE id = ? AND state = 'pending'") + .run(JSON.stringify(children), id); + }); +} + +function createPending(db, { seat, codexHome, thread, upTo, nonce, expectedOpGen }, now) { + let rowId; + withImmediateTransaction(db, () => { + const intent = db.prepare("SELECT desired, op_gen FROM daemon_intent").get(); + if (intent?.desired !== "on" || intent.op_gen !== expectedOpGen) { + throw new Error("daemon_intent_changed"); + } + const current = db.prepare("SELECT id FROM beta_codex_queue WHERE seat = ? AND state = 'pending'").get(seat); + if (current) throw new Error("seat_already_pending"); + const result = db.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, NULL, 'pending', 'incomplete', ?) + `).run(seat, codexHome, thread, upTo, nonce, new Date(now()).toISOString()); + rowId = Number(result.lastInsertRowid); + }); + return rowId; +} + +function storagePaths(installRoot, env) { + return { + storageDir: env.AGMSG_STORAGE_PATH || join(installRoot, "db"), + configPath: env.AGMSG_CONFIG || join(homedir(), ".agents", "agmsg", "config.json"), + }; +} + +function bridgeState(runDir, team, agent, projects) { + let files; + try { + files = readdirSync(runDir).filter((name) => name.startsWith("codex-bridge.") && name.endsWith(".pid")); + } catch (error) { + if (error?.code === "ENOENT") return "stopped"; + return "unknown"; + } + const identity = `${team}/${agent}`; + for (const pidName of files) { + const pidPath = join(runDir, pidName); + const metaPath = `${pidPath.slice(0, -4)}.meta`; + let pidText; + let metaText; + try { + const pidStat = lstatSync(pidPath); + if (!pidStat.isFile() || pidStat.isSymbolicLink()) return "unknown"; + const metaStat = lstatSync(metaPath); + if (!metaStat.isFile() || metaStat.isSymbolicLink()) return "unknown"; + pidText = readFileSync(pidPath, "utf8").trim(); + metaText = readFileSync(metaPath, "utf8"); + } catch { + return "unknown"; + } + const fields = Object.create(null); + for (const line of metaText.split(/\r?\n/)) { + const at = line.indexOf("="); + if (at > 0 && !(line.slice(0, at) in fields)) fields[line.slice(0, at)] = line.slice(at + 1); + } + if (!fields.identities) return "unknown"; + if (!fields.identities.split(",").includes(identity) || fields.type !== "codex") continue; + if (!/^\d+$/.test(pidText) || fields.pid !== pidText) return "unknown"; + if (!projects.some((project) => sameProject(project, fields.project))) return "unknown"; + // A live PID alone can be a recycled process. Require the bridge's own + // per-PID lease, matching its recorded pair/project set and start token. + const leasePath = join(runDir, `codex-bridge-lease.${pidText}`); + let leaseText; + try { + const leaseStat = lstatSync(leasePath); + if (!leaseStat.isFile() || leaseStat.isSymbolicLink()) return "unknown"; + leaseText = readFileSync(leasePath, "utf8"); + } catch { + return "unknown"; + } + const lease = Object.create(null); + for (const line of leaseText.split(/\r?\n/)) { + const at = line.indexOf("="); + if (at > 0 && !(line.slice(0, at) in lease)) lease[line.slice(0, at)] = line.slice(at + 1); + else if (at > 0) return "unknown"; + } + const leaseKeys = Object.keys(lease).sort().join(","); + if (leaseKeys !== "host,pairs,pid,project,start,startsrc,v") return "unknown"; + const pairHashes = fields.identities.split(",").map((pair) => createHash("sha1").update(pair.replace("/", "\t")).digest("hex")).sort(); + const expectedPairs = createHash("sha1").update(pairHashes.join("\n")).digest("hex"); + const expectedProject = createHash("sha1").update(fields.project).digest("hex"); + if (lease.v !== "1" || lease.pid !== pidText || lease.project !== expectedProject || lease.pairs !== expectedPairs || lease.host !== hostname() || !lease.start) return "unknown"; + const startPrefix = lease.startsrc === "proc" ? "linux" : lease.startsrc === "ps" ? "darwin" : lease.startsrc === "pwsh" ? "windows" : ""; + if (!startPrefix || processStartWitness(Number(pidText)) !== `${startPrefix}:${lease.start}`) return "unknown"; + try { + process.kill(Number(pidText), 0); + return "running"; + } catch (error) { + if (error?.code === "ESRCH") continue; + if (error?.code === "EPERM") return "running"; + return "unknown"; + } + } + return "stopped"; +} + +// This is intentionally fail-closed. A pending reservation left by a daemon +// crash before the child result was recorded cannot safely be retried here. +async function pendingObservation(pending, now, observeGroup) { + let children; + try { children = JSON.parse(pending.children); } catch { return { state: "pending", reason: "child_group_record_unreadable" }; } + if (children.state === "incomplete") return { state: "pending", reason: "queue_child_state_incomplete", childrenUnresolved: true }; + if (children.state === "complete") { + let group; + try { group = observeGroup(children); } catch { group = { state: "unreadable", reason: "child_group_observation_failed" }; } + if (group?.state !== "absent") return { state: "pending", reason: group?.reason ?? "queue_child_still_running", childrenUnresolved: true }; + } else if (children.state !== "absent") { + return { state: "pending", reason: children.reason ?? "child_group_record_unreadable", childrenUnresolved: true }; + } + const createdAt = Date.parse(pending.created_at); + const ageMs = Number.isFinite(createdAt) ? now() - createdAt : NaN; + const queueObservation = pending.queue_item_id + ? readQueuedItem(pending.codex_home, pending.queue_item_id, pending.thread) + : { state: "absent" }; + return readRolloutNonce(pending.codex_home, pending.thread, pending.nonce).then((rolloutObservation) => + resolvePendingQueue({ queueObservation, rolloutObservation, ageMs })); +} + +export function createCodexQueueChannel({ + db, + installRoot, + expectedOpGen, + env = process.env, + executable = "codex", + timeoutMs = QUEUE_TIMEOUT_MS, + now = Date.now, + log = (message) => logLine(installRoot, message), + listSeats = listCodexRegistrations, + readSession = readRoleSession, + readDriver = readStorageDriver, + openStore = openMessageStore, + readSnapshot = readUnreadSnapshot, + queue = runCodexQueue, + captureGroup = captureProcessGroup, + observeGroup = observeProcessGroup, + hostPlatform = process.platform, +}) { + let stopped = false; + let activeController = null; + let activePoll = null; + + async function pollSeat(registration, paths, blockedCodexHomes) { + const { team, agent, teamConfig, registered = true } = registration; + const seat = seatKey(team, agent); + const registeredProjects = registrationProjects(teamConfig, agent); + const bridge = bridgeState(join(installRoot, "run"), team, agent, registeredProjects); + const pending = readPending(db, seat); + if (pending) { + const observation = await pendingObservation(pending, now, observeGroup); + if (observation.childrenUnresolved && pending.codex_home) blockedCodexHomes.add(pending.codex_home); + if (!registered) { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "unaddressable", reason: "seat_registration_missing" }, now); + return; + } + if (hostPlatform === "win32") { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + const reason = observation.state === "pending" + ? `windows_live_delivery_unverified;pending:${observation.reason}` + : "windows_live_delivery_unverified"; + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "blocked", reason }, now); + return; + } + if (observation.state === "confirmed") { + setQueueState(db, pending.id, "confirmed"); + const knownBridge = bridge === "stopped" || bridge === "running"; + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: !knownBridge ? "blocked" : bridge === "running" ? "bridged" : "addressable", reason: !knownBridge ? "bridge_state_unknown" : bridge === "running" ? "bridge_running" : "" }, now); + return; + } else if (observation.state === "expired") { + setQueueState(db, pending.id, "expired"); + } else { + const knownBridge = bridge === "stopped" || bridge === "running"; + saveSeat(db, { + seat, + thread: pending.thread, + codexHome: pending.codex_home, + state: !knownBridge ? "blocked" : bridge === "running" ? "bridged" : "addressable", + reason: !knownBridge ? `bridge_state_unknown;pending:${observation.reason}` : bridge === "running" ? `bridge_running;pending:${observation.reason}` : observation.reason, + }, now); + return; + } + } + if (!registered) return; + if (bridge !== "stopped" && bridge !== "running") { + saveSeat(db, { seat, state: "blocked", reason: "bridge_state_unknown" }, now); + return; + } + if (bridge === "running") { + saveSeat(db, { seat, state: "bridged", reason: "bridge_running" }, now); + return; + } + const session = readSession(join(installRoot, "run"), team, agent); + if (session.state !== "present") { + saveSeat(db, { seat, state: "unaddressable", reason: session.reason ?? "role_session_missing" }, now); + return; + } + const record = session.record; + if (record.team !== team || record.agent !== agent) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_identity_mismatch" }, now); + return; + } + if (record.type !== "codex") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_type_mismatch" }, now); + return; + } + if (hostPlatform === "win32") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "windows_live_delivery_unverified" }, now); + return; + } + let driver; + try { + driver = readDriver(paths.configPath); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `storage_config_unreadable:${error.message}` }, now); + return; + } + if (driver.state !== "ok") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: driver.reason }, now); + return; + } + if (driver.driver !== "sqlite") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "storage_driver_unsupported" }, now); + return; + } + const projects = registeredProjects; + if (projects.length === 0 || !projects.some((project) => sameProject(project, record.project))) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_project_mismatch" }, now); + return; + } + let storePath; + try { + storePath = messageStorePath({ storageDir: paths.storageDir, team, teamConfig }); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `message_store_path_unreadable:${error.message}` }, now); + return; + } + let store; + try { + store = openStore(storePath); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } + + let snapshot; + try { + snapshot = readSnapshot(store, team, agent, latestConfirmedCursor(db, seat)); + } catch (error) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } finally { + try { store.close(); } catch { /* keep the read result */ } + } + + const nonce = newNonce(); + const rollout = await readRolloutNonce(record.codex_home, record.thread, nonce); + const classification = classifyCodexSeat({ roleSession: record, bridgeState: "stopped", rolloutState: rollout.state === "absent" || rollout.state === "present" ? "valid" : "invalid" }); + if (classification.state !== "addressable") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: classification.state, reason: classification.reason }, now); + return; + } + if (blockedCodexHomes.has(record.codex_home)) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable", reason: "shared_codex_home_queue_pending" }, now); + return; + } + if (!snapshot.unread || stopped) { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable" }, now); + return; + } + + let id; + try { + id = createPending(db, { seat, codexHome: record.codex_home, thread: record.thread, upTo: snapshot.upTo, nonce, expectedOpGen }, now); + } catch (error) { + if (error.message !== "daemon_intent_changed" && error.message !== "seat_already_pending") throw error; + return; + } + + activeController = new AbortController(); + let result; + try { + result = await queue({ + executable, + codexHome: record.codex_home, + thread: record.thread, + message: inboxNudge(nonce), + timeoutMs, + signal: activeController.signal, + onChildStart: (pid) => recordChildStart(db, id, pid, captureGroup), + }); + } finally { + activeController = null; + } + recordChildResult(db, id, result); + if (result.kind === "archived" || result.kind === "no_rollout") { + setQueueState(db, id, "expired"); + const reason = result.kind === "archived" ? "thread_archived" : "codex_home_mismatch"; + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason }, now); + return; + } + if (result.kind === "queued") { + setQueueState(db, id, "pending", result.queueItemId); + const firstCheck = await pendingObservation(readPending(db, seat), now, observeGroup); + if (firstCheck.state === "confirmed") { + setQueueState(db, id, "confirmed"); + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable" }, now); + return; + } + if (firstCheck.childrenUnresolved && record.codex_home) blockedCodexHomes.add(record.codex_home); + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "addressable", reason: firstCheck.reason }, now); + return; + } + saveSeat(db, { + seat, + thread: record.thread, + codexHome: record.codex_home, + state: "addressable", + reason: `queue_${result.kind}:${result.reason ?? "unknown"}`, + }, now); + } + + async function pollOnce() { + if (stopped) return; + if (activePoll) return activePoll; + activePoll = (async () => { + const roster = listSeats(join(installRoot, "teams")); + if (roster.state !== "ok") { + log(`channel: Codex roster unavailable (${roster.reason})`); + return; + } + const paths = storagePaths(installRoot, env); + const blockedCodexHomes = new Set(); + const seats = [...roster.seats]; + const registeredKeys = new Set(seats.map(({ team, agent }) => seatKey(team, agent))); + const unsettledSeats = pendingSeats(db); + const pendingKeys = new Set(unsettledSeats.map(({ team, agent }) => seatKey(team, agent))); + for (const { team, agent } of unsettledSeats) { + const key = seatKey(team, agent); + if (!registeredKeys.has(key)) seats.push({ team, agent, teamConfig: null, registered: false }); + } + const knownKeys = new Set([...registeredKeys, ...pendingKeys]); + for (const row of db.prepare("SELECT seat FROM beta_codex_seat").all()) { + if (!knownKeys.has(row.seat)) { + saveSeat(db, { seat: row.seat, state: "unaddressable", reason: "seat_registration_missing" }, now); + } + } + // Sequential processing also serializes codex queue writes that share a + // CODEX_HOME, avoiding Codex's measured concurrent-write loss. + for (const seat of seats) { + if (stopped) break; + try { + await pollSeat(seat, paths, blockedCodexHomes); + } catch (error) { + log(`channel: Codex seat ${seat.team}/${seat.agent} could not be checked (${error.message})`); + } + } + })().finally(() => { activePoll = null; }); + return activePoll; + } + + return { + pollOnce, + async stop() { + stopped = true; + activeController?.abort(); + await activePoll; + }, + }; +} diff --git a/scripts/daemon/channels/process-group.mjs b/scripts/daemon/channels/process-group.mjs new file mode 100644 index 000000000..4ad9d2c6f --- /dev/null +++ b/scripts/daemon/channels/process-group.mjs @@ -0,0 +1,88 @@ +// Conservative evidence for a short-lived command launched in its own group. +// If the platform cannot prove the group is empty or still belongs to the +// recorded leader, callers must treat it as unknown and keep the seat pending. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { platform } from "node:os"; +import { bootId } from "../executor.mjs"; + +export function processStartWitness(pid) { + if (!Number.isSafeInteger(pid) || pid < 2) return null; + const os = platform(); + if (os === "linux") { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const rest = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); + const start = rest[19]; + return /^\d+$/.test(start ?? "") ? `linux:${start}` : null; + } catch { + return null; + } + } + if (os === "darwin") { + try { + const start = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim(); + return start ? `darwin:${start}` : null; + } catch { + return null; + } + } + if (os === "win32") { + for (const executable of ["powershell.exe", "pwsh.exe"]) { + try { + const start = execFileSync(executable, [ + "-NoProfile", "-NonInteractive", "-Command", + `(Get-Process -Id ${pid}).StartTime.Ticks`, + ], { encoding: "utf8", windowsHide: true }).trim(); + if (/^\d+$/.test(start)) return `windows:${start}`; + } catch { + // Try the other supported PowerShell executable. + } + } + } + return null; +} + +export function captureProcessGroup(pgid) { + const witness = processStartWitness(pgid); + if (!witness) return { state: "unreadable", reason: "child_start_witness_unavailable" }; + return { + state: "complete", + kind: platform() === "win32" ? "windows-native-process" : "posix-process-group", + pgid, + boot_id: bootId(), + witness, + }; +} + +export function observeProcessGroup(record) { + if (!record || record.state !== "complete" || !Number.isSafeInteger(record.pgid) || record.pgid < 2 || !record.boot_id || !record.witness) { + return { state: "unreadable", reason: "child_group_record_unreadable" }; + } + if (platform() === "win32") { + if (record.kind !== "windows-native-process") return { state: "unreadable", reason: "child_group_kind_mismatch" }; + try { + process.kill(record.pgid, 0); + } catch (error) { + if (error?.code === "ESRCH") return { state: "absent" }; + if (error?.code !== "EPERM") return { state: "unreadable", reason: "child_process_observation_failed" }; + } + const currentWitness = processStartWitness(record.pgid); + if (!currentWitness) return { state: "unreadable", reason: "child_process_witness_unavailable" }; + if (currentWitness !== record.witness) return { state: "unreadable", reason: "child_process_pid_reused" }; + return { state: "present" }; + } + if (record.kind !== "posix-process-group") return { state: "unreadable", reason: "child_group_kind_mismatch" }; + if (bootId() !== record.boot_id) return { state: "absent" }; + try { + process.kill(-record.pgid, 0); + } catch (error) { + if (error?.code === "ESRCH") return { state: "absent" }; + if (error?.code !== "EPERM") return { state: "unreadable", reason: "child_group_observation_failed" }; + } + const currentWitness = processStartWitness(record.pgid); + if (!currentWitness) return { state: "unreadable", reason: "child_group_leader_unreadable" }; + if (currentWitness !== record.witness) return { state: "unreadable", reason: "child_group_leader_reused" }; + return { state: "present" }; +} diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 28762f5a7..4f1d4b76c 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -8,20 +8,35 @@ // without wiring real signal handlers or a real interval timer, which // main() (the actual CLI entrypoint) only assembles. // -// channelHooks (an array of {stop()}) is beta's plug point for the Codex -// queue channel -- a separate PR/component this file does not own. Empty -// in this PR; gracefulStop() already calls stop() on every registered -// hook, in the correct place in the shutdown order, so that PR only needs -// to register its hook, not restructure this file. +// channelHooks are polled from the daemon's single event loop and stopped +// before the control socket closes. import { takeOwnership, markReady, markStopping, revertToNone, stopNormally } from "./owner.mjs"; import { createControlServer } from "./control.mjs"; import { captureWatchState, watchForDrift } from "./lifecycle.mjs"; import { logLine } from "./log.mjs"; import { classify } from "./status.mjs"; +import { createCodexQueueChannel } from "./channels/codex-queue.mjs"; +import { ensureCodexChannelSchema } from "./channels/codex-queue-store.mjs"; export const POLL_INTERVAL_MS = 5000; +export async function prepareClaimWithCodexSchema(prepareClaim) { + const prepared = await prepareClaim(); + ensureCodexChannelSchema(prepared.db); + return prepared; +} + +export async function pollChannelHooks(channelHooks, log = () => {}) { + for (const hook of channelHooks) { + try { + await hook.pollOnce(); + } catch (error) { + log(`channel: poll failed: ${error.message}`); + } + } +} + // Takes ownership and binds the control socket. Returns // {ok: true, gen, controlHandle, db} or {ok: false, reason, db} -- a // refusal from takeOwnership OR a bind failure, both reported the same @@ -104,7 +119,7 @@ export async function gracefulStop(db, installRoot, gen, controlHandle, channelH // start or a normal/SIGTERM stop, 75 for stepping aside for an update, 1 // for a bind failure or any other unexpected error. export async function main(db, { installRoot, manifest, manifestText, expectedDesired, expectedOpGen, version, prepareClaim }) { - const channelHooks = []; // beta's Codex-queue channel plugs in here, separately. + const channelHooks = []; let controlHandle; let gen; let stopping = false; @@ -123,7 +138,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe expectedOpGen, version, prepareClaim: prepareClaim ? async () => { - const prepared = await prepareClaim(); + const prepared = await prepareClaimWithCodexSchema(prepareClaim); db = prepared.db; return prepared; } : undefined, @@ -151,13 +166,24 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe } gen = started.gen; controlHandle = started.controlHandle; + channelHooks.push(createCodexQueueChannel({ db, installRoot, expectedOpGen })); process.on("SIGTERM", () => doStop("normal")); const watchState = await captureWatchState(installRoot, manifest, manifestText); + let polling = false; const timer = setInterval(async () => { - if (stopping) return; - const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); - if (verdict.action !== "continue") await doStop(verdict.reason); + if (stopping || polling) return; + polling = true; + try { + const verdict = await pollOnce(db, installRoot, { gen, expectedOpGen, watchState }); + if (verdict.action !== "continue") { + await doStop(verdict.reason); + return; + } + await pollChannelHooks(channelHooks, (message) => logLine(installRoot, message)); + } finally { + polling = false; + } }, POLL_INTERVAL_MS); } diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index a11079ffa..775ebf3c5 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -100,11 +100,21 @@ export function readOwnerAndIntentReadOnly(installRoot) { try { const owner = db.prepare("SELECT * FROM daemon_owner").get(); const intent = db.prepare("SELECT * FROM daemon_intent").get(); + let codexSeats; + try { + codexSeats = { + state: "ok", + seats: db.prepare("SELECT seat, thread, codex_home, state, reason, checked_at FROM beta_codex_seat ORDER BY seat").all(), + }; + } catch (error) { + if (!String(error?.message ?? "").includes("no such table")) throw error; + codexSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; + } const alive = owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive }; + return { owner, intent, alive, codexSeats }; } finally { db.close(); } @@ -145,9 +155,9 @@ async function main() { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive; + let owner, intent, alive, codexSeats; try { - ({ owner, intent, alive } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -172,7 +182,7 @@ async function main() { // unbuffered) looked completely fine. Setting exitCode and letting the // event loop drain naturally waits for the flush first. process.stdout.write( - `${JSON.stringify({ ...result, node_sqlite_experimental: true, node_version: process.version })}\n`, + `${JSON.stringify({ ...result, codex_seats: codexSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, ); process.exitCode = result.exitCode; } diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs new file mode 100644 index 000000000..99ceafbec --- /dev/null +++ b/tests/agmsgd_codex_queue.test.mjs @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { + classifyCodexSeat, + inboxNudge, + newNonce, + readQueuedItem, + readRolloutNonce, + resolvePendingQueue, + runCodexQueue, + verifyPendingDelivery, +} from '../scripts/daemon/channels/codex-queue-io.mjs'; +import { + CODEX_CHANNEL_SCHEMA, + ensureCodexChannelSchema, + listCodexRegistrations, + messageStorePath, + readRoleSession, + readStorageDriver, + readUnreadSnapshot, +} from '../scripts/daemon/channels/codex-queue-store.mjs'; +import { createCodexQueueChannel } from '../scripts/daemon/channels/codex-queue.mjs'; +import { openInstallDb } from '../scripts/daemon/db.mjs'; +import { readOwnerAndIntentReadOnly } from '../scripts/daemon/status.mjs'; +import { processStartWitness } from '../scripts/daemon/channels/process-group.mjs'; + +const thread = '01a0ea97-c011-73f3-8470-fd59db15adba'; +const itemId = '01a0ea98-2235-7f02-b477-7c130e602fcd'; +const notYetVisibleItemId = '01a0ea99-3509-7d03-a588-8d241f7130de'; + +function temporaryDirectory(t) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'agmsgd-codex-queue-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function fakeChild({ stdout, stderr = '', code = 0, autoClose = true }) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = 4242; + child.kill = () => true; + if (autoClose) { + setImmediate(() => { + child.stdout.end(stdout); + child.stderr.end(stderr); + child.emit('close', code, null); + }); + } + return child; +} + +test('queue invocation is shell-free, pins CODEX_HOME, and parses only a successful matching receipt', async () => { + const calls = []; + const nonce = newNonce(); + const message = inboxNudge(nonce); + assert.match(message, new RegExp(`^\\[agmsg:${nonce}\\]`)); + + const result = await runCodexQueue({ + executable: '/opt/codex', + codexHome: '/tmp/profile with spaces', + thread, + message, + timeoutMs: 5000, + spawnProcess: (file, args, options) => { + calls.push({ file, args, options }); + return fakeChild({ stdout: `Queued message ${itemId} for thread ${thread}.\n` }); + }, + }); + + assert.deepEqual(result, { kind: 'queued', queueItemId: itemId }); + assert.equal(calls[0].file, '/opt/codex'); + assert.deepEqual(calls[0].args, ['queue', '--thread', thread, '--message', message]); + assert.equal(calls[0].options.env.CODEX_HOME, '/tmp/profile with spaces'); + assert.equal(calls[0].options.detached, process.platform !== 'win32'); + + const archived = await runCodexQueue({ + executable: '/opt/codex', codexHome: '/tmp/profile', thread, message, timeoutMs: 5000, + spawnProcess: () => fakeChild({ stdout: 'failed (code -32600)', code: 1 }), + }); + assert.deepEqual(archived, { kind: 'archived', reason: 'thread_archived' }); + + let timeoutSignal; + const timedOut = await runCodexQueue({ + executable: '/opt/codex', codexHome: '/tmp/profile', thread, message, timeoutMs: 10, + spawnProcess: () => { + const child = fakeChild({ stdout: '', autoClose: false }); + child.pid = 1; + child.kill = (signal) => { + timeoutSignal = signal; + setImmediate(() => child.emit('close', null, signal)); + return true; + }; + return child; + }, + }); + assert.equal(timedOut.kind, 'timeout'); + assert.equal(timeoutSignal, 'SIGTERM'); +}); + +test('queue DB and rollout observations distinguish positive, absent, and unreadable evidence', async (t) => { + const home = temporaryDirectory(t); + const db = new DatabaseSync(path.join(home, 'queue_1.sqlite')); + db.exec('CREATE TABLE queued_items (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, payload_json TEXT NOT NULL)'); + db.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(itemId, thread, '{}'); + db.close(); + + assert.deepEqual(readQueuedItem(home, itemId, thread), { state: 'present' }); + assert.deepEqual(readQueuedItem(home, '01a0ea98-2235-7f02-b477-7c130e602fce', thread), { state: 'absent' }); + assert.equal(readQueuedItem(path.join(home, 'missing'), itemId, thread).state, 'unreadable'); + assert.equal(readQueuedItem(home, itemId, '01a0ea97-c011-73f3-8470-fd59db15adbb').state, 'unreadable'); + + const rolloutDir = path.join(home, 'sessions', '2026', '09', '29'); + mkdirSync(rolloutDir, { recursive: true }); + const rollout = path.join(rolloutDir, `rollout-2026-09-29T00-00-00-${thread}.jsonl`); + const nonce = 'pending-nonce-123'; + writeFileSync(rollout, [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: inboxNudge(nonce) } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: inboxNudge('second-nonce') }] } }), + '', + ].join('\n')); + assert.deepEqual(await readRolloutNonce(home, thread, nonce), { state: 'present' }); + assert.deepEqual(await readRolloutNonce(home, thread, 'second-nonce'), { state: 'present' }); + assert.deepEqual(await readRolloutNonce(home, thread, 'not-yet-seen'), { state: 'absent' }); + assert.deepEqual(await readRolloutNonce(path.join(home, 'missing-profile'), thread, nonce), { state: 'unreadable', reason: 'sessions_dir_missing' }); + + writeFileSync(rollout, [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'future_text_shape', value: inboxNudge(nonce) }] } }), + '', + ].join('\n')); + assert.deepEqual(await readRolloutNonce(home, thread, nonce), { state: 'unreadable', reason: 'rollout_user_row_unrecognized' }); + + writeFileSync(rollout, '{malformed json}\n'); + assert.equal((await readRolloutNonce(home, thread, nonce)).state, 'unreadable'); +}); + +test('pending delivery confirms on either positive witness, expires only on two readable absences, and otherwise stays pending', () => { + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'present' }, + rolloutObservation: { state: 'unreadable', reason: 'rollout_read_failed' }, + ageMs: 100, + }), { state: 'confirmed', reason: 'queue_item_present' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'unreadable', reason: 'queue_db_read_failed' }, + rolloutObservation: { state: 'present' }, + ageMs: 100, + }), { state: 'confirmed', reason: 'nonce_observed' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, rolloutObservation: { state: 'absent' }, ageMs: 59_999, + }), { state: 'pending', reason: 'awaiting_confirmation' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, rolloutObservation: { state: 'absent' }, ageMs: 60_000, + }), { state: 'expired', reason: 'confirmation_timeout' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, + rolloutObservation: { state: 'unreadable', reason: 'thread_rollout_missing' }, + ageMs: 120_000, + }), { state: 'pending', reason: 'verification_unreadable' }); + assert.deepEqual(resolvePendingQueue({ + queueObservation: { state: 'absent' }, rolloutObservation: { state: 'absent' }, ageMs: -1, + }), { state: 'pending', reason: 'pending_age_unreadable' }); +}); + +test('immediate post-receipt check confirms a still-queued item before rollout inspection', async () => { + const events = ['queue_item_id_persisted']; + const result = await verifyPendingDelivery({ + codexHome: '/tmp/profile', queueItemId: itemId, thread, nonce: 'pending-nonce', ageMs: 5, + readQueue: () => { + events.push('queue_read'); + return { state: 'present' }; + }, + readRollout: async () => { + events.push('rollout_read'); + return { state: 'unreadable', reason: 'rollout_not_ready' }; + }, + }); + assert.deepEqual(result, { state: 'confirmed', reason: 'queue_item_present' }); + assert.deepEqual(events, ['queue_item_id_persisted', 'queue_read']); + + const consumedEarly = await verifyPendingDelivery({ + codexHome: '/tmp/profile', queueItemId: itemId, thread, nonce: 'pending-nonce', ageMs: 60_000, + readQueue: () => ({ state: 'absent' }), + readRollout: async () => ({ state: 'unreadable', reason: 'rollout_user_row_unrecognized' }), + }); + assert.deepEqual(consumedEarly, { state: 'pending', reason: 'verification_unreadable' }); +}); + +test('seat classification never treats an uncertain destination or bridge as addressable', () => { + const record = { thread, codex_home: '/tmp/profile' }; + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'valid' }), { state: 'addressable', reason: '' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'running' }), { state: 'bridged', reason: 'bridge_running' }); + assert.deepEqual(classifyCodexSeat({ roleSession: null }), { state: 'unaddressable', reason: 'role_session_missing' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'archived' }), { state: 'blocked', reason: 'thread_archived' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped', rolloutState: 'no_rollout' }), { state: 'blocked', reason: 'codex_home_mismatch' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'unknown' }), { state: 'blocked', reason: 'bridge_state_unknown' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record }), { state: 'blocked', reason: 'bridge_state_unknown' }); + assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped' }), { state: 'blocked', reason: 'thread_observation_failed' }); +}); + +test('store discovery is fail-closed and unread snapshots advance both event and legacy cursors', async (t) => { + const root = temporaryDirectory(t); + const teamsDir = path.join(root, 'teams'); + const runDir = path.join(root, 'run'); + const storageDir = path.join(root, 'store'); + mkdirSync(path.join(teamsDir, 'alpha'), { recursive: true }); + mkdirSync(runDir, { recursive: true }); + mkdirSync(storageDir, { recursive: true }); + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ + agents: { + alice: { registrations: [{ type: 'codex', project: root }] }, + bob: { registrations: [{ type: 'claude-code', project: root }] }, + }, + })); + writeFileSync(path.join(runDir, 'role-session.alpha__alice'), `team=alpha\nagent=alice\ntype=codex\nsession=${thread}\ncodex_home=/tmp/codex-profile\n`); + writeFileSync(path.join(root, 'config.json'), JSON.stringify({ storage: 'jsonl' })); + + assert.deepEqual(listCodexRegistrations(teamsDir).seats.map(({ team, agent }) => `${team}:${agent}`), ['alpha:alice']); + assert.deepEqual(readRoleSession(runDir, 'alpha', 'alice').record, { + team: 'alpha', agent: 'alice', type: 'codex', project: '', thread, codex_home: '/tmp/codex-profile', + }); + assert.deepEqual(readStorageDriver(path.join(root, 'config.json')), { state: 'ok', driver: 'jsonl' }); + assert.equal(messageStorePath({ storageDir, team: 'alpha', teamConfig: {} }), path.join(storageDir, 'messages.db')); + assert.equal(messageStorePath({ storageDir, team: 'alpha', teamConfig: { drivers: { partition: 'per-team' } } }), path.join(storageDir, 'teams', 'alpha', 'messages.db')); + + const installDb = new DatabaseSync(path.join(root, 'install.db')); + ensureCodexChannelSchema(installDb); + assert.equal(installDb.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name LIKE 'beta_codex_%'").get().n, 2); + assert.match(CODEX_CHANNEL_SCHEMA, /beta_codex_queue_one_pending/); + installDb.close(); + + const msgDb = new DatabaseSync(path.join(storageDir, 'messages.db')); + msgDb.exec(` + CREATE TABLE events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, id TEXT, team TEXT, from_agent TEXT, to_agent TEXT, body TEXT, msg_id TEXT, agent TEXT, at TEXT, legacy_id INTEGER); + CREATE TABLE messages (id INTEGER PRIMARY KEY, team TEXT, to_agent TEXT, read_at TEXT, created_at TEXT, body TEXT); + CREATE TABLE read_cursors (team TEXT, agent TEXT, local_position INTEGER, PRIMARY KEY(team, agent)); + `); + msgDb.prepare('INSERT INTO events VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('message_sent', 'event-1', 'alpha', 'sender', 'alice', 'secret body', null, null, '2026-09-29T00:00:00Z', null); + msgDb.prepare('INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)').run(1, 'alpha', 'alice', null, '2026-09-29T00:00:00Z', 'legacy body'); + msgDb.close(); + + const readDb = new DatabaseSync(path.join(storageDir, 'messages.db'), { readOnly: true }); + const first = readUnreadSnapshot(readDb, 'alpha', 'alice'); + assert.equal(first.state, 'ok'); + assert.equal(first.unread, true); + assert.equal(first.upTo, JSON.stringify({ eventSeq: 1, legacyId: 1 })); + assert.equal(readUnreadSnapshot(readDb, 'alpha', 'alice', first.upTo).unread, false); + readDb.close(); +}); + +test('Codex channel queues one unread snapshot and confirms it before advancing that seat', async (t) => { + const root = temporaryDirectory(t); + const teamsDir = path.join(root, 'teams'); + const runDir = path.join(root, 'run'); + const storageDir = path.join(root, 'store'); + const codexHome = path.join(root, 'codex'); + const sessionDir = path.join(codexHome, 'sessions', '2026', '09', '29'); + mkdirSync(path.join(teamsDir, 'alpha'), { recursive: true }); + mkdirSync(runDir, { recursive: true }); + mkdirSync(storageDir, { recursive: true }); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ + agents: { alice: { registrations: [{ type: 'codex', project: root }] } }, + })); + writeFileSync(path.join(runDir, 'role-session.alpha__alice'), `team=alpha\nagent=alice\ntype=codex\nproject=${root}\nsession=${thread}\ncodex_home=${codexHome}\n`); + writeFileSync(path.join(sessionDir, `rollout-2026-09-29T00-00-00-${thread}.jsonl`), [ + JSON.stringify({ type: 'session_meta', payload: { id: thread } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'an earlier prompt' } }), + '', + ].join('\n')); + + const messageDb = new DatabaseSync(path.join(storageDir, 'messages.db')); + messageDb.exec(` + CREATE TABLE events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, id TEXT, team TEXT, from_agent TEXT, to_agent TEXT, body TEXT, msg_id TEXT, agent TEXT, at TEXT, legacy_id INTEGER); + CREATE TABLE messages (id INTEGER PRIMARY KEY, team TEXT, to_agent TEXT, read_at TEXT, created_at TEXT, body TEXT); + CREATE TABLE read_cursors (team TEXT, agent TEXT, local_position INTEGER, PRIMARY KEY(team, agent)); + `); + messageDb.prepare('INSERT INTO events VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('message_sent', 'event-1', 'alpha', 'sender', 'alice', 'do not include this in the nudge', null, null, '2026-09-29T00:00:00Z', null); + messageDb.close(); + + const queueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); + queueDb.exec('CREATE TABLE queued_items (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, payload_json TEXT NOT NULL)'); + queueDb.close(); + + const installDb = openInstallDb(path.join(runDir, 'install.db')); + ensureCodexChannelSchema(installDb); + installDb.exec("UPDATE daemon_intent SET desired='on', op_gen=4"); + const queued = []; + let childGroupState = 'present'; + const channel = createCodexQueueChannel({ + db: installDb, + installRoot: root, + expectedOpGen: 4, + env: { AGMSG_STORAGE_PATH: storageDir }, + readDriver: () => ({ state: 'ok', driver: 'sqlite' }), + captureGroup: (pid) => ({ state: 'complete', pgid: pid, boot_id: 'test-boot', witness: 'test-start' }), + observeGroup: () => ({ state: childGroupState }), + queue: async (request) => { + queued.push(request); + request.onChildStart(4242); + const db = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); + db.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(itemId, thread, '{}'); + db.close(); + return { kind: 'queued', queueItemId: itemId }; + }, + }); + + await channel.pollOnce(); + assert.equal(queued.length, 1); + assert.equal(queued[0].thread, thread); + assert.match(queued[0].message, /^\[agmsg:[A-Za-z0-9-]+\] New messages are waiting\./); + assert.doesNotMatch(queued[0].message, /do not include this/); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + await channel.pollOnce(); + assert.equal(queued.length, 1, 'a still-running child group must not be retried on the next poll'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + childGroupState = 'absent'; + await channel.pollOnce(); + assert.equal(queued.length, 1, 'the queued snapshot is not retried after the child exits'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'addressable'); + const status = readOwnerAndIntentReadOnly(root); + assert.equal(status.codexSeats.state, 'ok'); + assert.equal(status.codexSeats.seats[0].state, 'addressable'); + + await channel.pollOnce(); + assert.equal(queued.length, 1, 'the confirmed cursor prevents another nudge for the same unread snapshot'); + await channel.stop(); + + const windowsChannel = createCodexQueueChannel({ + db: installDb, + installRoot: root, + expectedOpGen: 4, + env: { AGMSG_STORAGE_PATH: storageDir }, + hostPlatform: 'win32', + queue: async () => { throw new Error('Windows queue must stay closed until live delivery is verified'); }, + }); + installDb.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(JSON.stringify(['alpha', 'alice']), codexHome, thread, JSON.stringify({ eventSeq: 1, legacyId: 0 }), 'windows-pending-nonce', notYetVisibleItemId, JSON.stringify({ state: 'absent' }), new Date().toISOString()); + await windowsChannel.pollOnce(); + const windowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(windowsSeat.state, 'blocked'); + assert.equal(windowsSeat.reason, 'windows_live_delivery_unverified;pending:awaiting_confirmation'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + const windowsQueueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); + windowsQueueDb.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(notYetVisibleItemId, thread, '{}'); + windowsQueueDb.close(); + await windowsChannel.pollOnce(); + const confirmedWindowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(confirmedWindowsSeat.state, 'blocked'); + assert.equal(confirmedWindowsSeat.reason, 'windows_live_delivery_unverified'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + await windowsChannel.stop(); + + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ agents: {} })); + const missingSeatChannel = createCodexQueueChannel({ db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir } }); + await missingSeatChannel.pollOnce(); + const missingSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(missingSeat.state, 'unaddressable'); + assert.equal(missingSeat.reason, 'seat_registration_missing'); + await missingSeatChannel.stop(); + + installDb.prepare(` + INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(JSON.stringify(['alpha', 'alice']), codexHome, thread, JSON.stringify({ eventSeq: 1, legacyId: 0 }), 'retired-seat-nonce', itemId, JSON.stringify({ state: 'absent' }), new Date().toISOString()); + const retiredChannel = createCodexQueueChannel({ db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir } }); + await retiredChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + const retiredSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); + assert.equal(retiredSeat.state, 'unaddressable'); + assert.equal(retiredSeat.reason, 'seat_registration_missing'); + await retiredChannel.stop(); + + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ + agents: { alice: { registrations: [{ type: 'codex', project: root }] } }, + })); + const bridgePid = String(process.pid); + const bridgeBase = path.join(runDir, 'codex-bridge.alpha.alice'); + const bridgeWitness = processStartWitness(process.pid); + assert.ok(bridgeWitness, 'the test process has a readable start witness'); + const witnessSeparator = bridgeWitness.indexOf(':'); + const witnessPlatform = bridgeWitness.slice(0, witnessSeparator); + const witnessToken = bridgeWitness.slice(witnessSeparator + 1); + const bridgePairHash = createHash('sha1').update('alpha\talice').digest('hex'); + const bridgePairsHash = createHash('sha1').update(bridgePairHash).digest('hex'); + const bridgeProjectHash = createHash('sha1').update(root).digest('hex'); + const startsrc = { linux: 'proc', darwin: 'ps', windows: 'pwsh' }[witnessPlatform]; + writeFileSync(`${bridgeBase}.pid`, `${bridgePid}\n`); + writeFileSync(`${bridgeBase}.meta`, `pid=${bridgePid}\nproject=${root}\nidentities=alpha/alice\ntype=codex\n`); + writeFileSync(path.join(runDir, `codex-bridge-lease.${bridgePid}`), [ + 'v=1', `project=${bridgeProjectHash}`, `pairs=${bridgePairsHash}`, `host=${os.hostname()}`, + `pid=${bridgePid}`, `start=${witnessToken}`, `startsrc=${startsrc}`, '', + ].join('\n')); + const bridgedChannel = createCodexQueueChannel({ + db: installDb, + installRoot: root, + expectedOpGen: 4, + env: { AGMSG_STORAGE_PATH: storageDir }, + queue: async () => { throw new Error('a verified live bridge retains delivery ownership'); }, + }); + await bridgedChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'bridged'); + await bridgedChannel.stop(); + unlinkSync(`${bridgeBase}.pid`); + unlinkSync(`${bridgeBase}.meta`); + unlinkSync(path.join(runDir, `codex-bridge-lease.${bridgePid}`)); + installDb.close(); +}); diff --git a/tests/agmsgd_main.test.mjs b/tests/agmsgd_main.test.mjs index e0e00627d..48dddd407 100644 --- a/tests/agmsgd_main.test.mjs +++ b/tests/agmsgd_main.test.mjs @@ -5,7 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import { openInstallDb } from "../scripts/daemon/db.mjs"; import { readOwner } from "../scripts/daemon/owner.mjs"; -import { gracefulStop, pollOnce, startup } from "../scripts/daemon/main.mjs"; +import { gracefulStop, pollChannelHooks, pollOnce, prepareClaimWithCodexSchema, startup } from "../scripts/daemon/main.mjs"; import { captureWatchState } from "../scripts/daemon/lifecycle.mjs"; import { createHash } from "node:crypto"; @@ -152,3 +152,29 @@ test("gracefulStop: a channel hook that throws is logged but does not stop the r rmSync(root, { recursive: true, force: true }); } }); + +test("channel setup is inside the prepared install lock and channel polls stay sequential", async () => { + const { root } = makeInstall(); + try { + const db = openInstallDb(join(root, "run", "install.db")); + let released = false; + const prepared = await prepareClaimWithCodexSchema(async () => ({ + db, + release: () => { released = true; }, + })); + assert.equal(released, false, "schema creation must finish before the caller releases the install lock"); + assert.doesNotThrow(() => db.prepare("SELECT state FROM beta_codex_seat").all()); + prepared.release(); + assert.equal(released, true); + + const order = []; + await pollChannelHooks([ + { pollOnce: async () => { order.push("first:start"); await Promise.resolve(); order.push("first:end"); } }, + { pollOnce: async () => { order.push("second"); } }, + ]); + assert.deepEqual(order, ["first:start", "first:end", "second"]); + db.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/test_agmsgd_codex_queue.bats b/tests/test_agmsgd_codex_queue.bats new file mode 100644 index 000000000..7520649da --- /dev/null +++ b/tests/test_agmsgd_codex_queue.bats @@ -0,0 +1,6 @@ +#!/usr/bin/env bats + +@test "agmsgd Codex queue support tests" { + run node --test "$BATS_TEST_DIRNAME/agmsgd_codex_queue.test.mjs" + [ "$status" -eq 0 ] +} diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 879b54f6e..2c4e2b67e 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -5,6 +5,8 @@ load test_helper setup() { setup_test_env DAEMON="$SCRIPTS/daemon.sh" + AGMSGD_TEST_LAUNCH_STDERR_LOG="$TEST_SKILL_DIR/run/agmsgd.stderr.log" + export AGMSGD_TEST_LAUNCH_STDERR_LOG chmod +x "$SCRIPTS/daemon/agmsgd" "$SCRIPTS/daemon/agmsgd-launch.sh" } @@ -140,12 +142,31 @@ _run_with_deadline() { rm -f "$outfile" "$exitfile" } +_assert_start_result() { + local expected="$1" + if [ "$status" -ne 0 ] || [[ "$output" != *"$expected"* ]]; then + printf '\n--- run/agmsgd.log ---\n' >&2 + if [ -f "$TEST_SKILL_DIR/run/agmsgd.log" ]; then + tail -n 80 "$TEST_SKILL_DIR/run/agmsgd.log" >&2 + else + printf '(not present)\n' >&2 + fi + printf '\n--- run/agmsgd.stderr.log ---\n' >&2 + if [ -f "$AGMSGD_TEST_LAUNCH_STDERR_LOG" ]; then + tail -n 80 "$AGMSGD_TEST_LAUNCH_STDERR_LOG" >&2 + else + printf '(not present)\n' >&2 + fi + fi + [ "$status" -eq 0 ] + [[ "$output" == *"$expected"* ]] +} + @test "daemon.sh start: a real run against the real entrypoint becomes ready" { _seed_install_db _write_completion_record _run_with_deadline 15 bash "$DAEMON" start - [ "$status" -eq 0 ] - printf '%s\n' "$output" | grep -Fq 'running' + _assert_start_result 'running' bash "$DAEMON" stop >/dev/null 2>&1 || true } @@ -179,7 +200,7 @@ _run_with_deadline() { local iteration for iteration in 1 2 3; do _run_with_deadline 15 bash "$DAEMON" start - [ "$status" -eq 0 ] + _assert_start_result 'running' _run_with_deadline 10 bash "$DAEMON" status [ "$status" -eq 0 ] From 20b71f8c97bfd14ea92c364aea43691a36174704 Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 20:50:07 -0700 Subject: [PATCH 09/15] Add daemon beta switching and recovery guidance (#1523) When the daemon beta is enabled, new Codex launches now bypass the bridge app-server and dispatcher. Existing bridge sessions continue until restarted. Ordinary script operations warn when the enabled daemon is unavailable, with recovery commands and an install-wide ten-minute warning limit; daemon status and doctor also report the failure without requiring Node for the fallback check. Start and enable show existing bridge sessions and missing destination records. Disable distinguishes sessions that already have a bridge from sessions that need restarting, preserves unread messages, and warns about unsupported JSONL storage. README and design notes describe the optional 1.6.0 beta, macOS/Linux support, Windows delivery remaining unsupported pending measurement, and the installed command path to use when agmsg is absent from PATH. Known limitation: concurrent operations spanning a ten-minute slot boundary can emit two warnings less than ten minutes apart; cleanup preserves current and newer claims. Validation: `bats tests/test_agmsgd_switch.bats tests/test_agmsgd_status.bats` passed all 17 checks, including enabled/off/missing/corrupt records, direct wrapper bypass, concurrent warning suppression, a rolling ten-minute interval, past-slot-only cleanup, helper-free non-daemon operations, suppression in the watcher, hooks, one-shot polling, and their child operations, skipping repeated health queries, Node-unavailable status and doctor, executor boot evidence, and disable inventory. Shellcheck at warning severity passed with the existing SC1091 and SC2034 exclusions; `git diff --check` passed. Fixtures retain HOME and use disposable scripts, data, and CODEX_HOME. No live user service or live Codex queue was exercised. The isolated entrypoint fixture exercises a real daemon control socket. CI follow-up: watcher and hook entrypoints export notice suppression before starting child operations, ordinary operations skip loading the daemon helper when install.db is absent, and a recent notice skips the health query. The entrypoint readiness fixture now gives its concurrent SQLite read a bounded five-second busy timeout. macOS CI timing logs showed watch continuing through test 37 at job timeout, rather than hanging: the new test file changed shard assignment, placing the 604-second Jev fixture and the active watch suite together while the weighting table still estimated watch at its historical quarantined two seconds. The table now records Jev at 604 seconds (run 36657411860), watch at 215 seconds (an unchanged 40-test run on local macOS at 96b43b51), and the new daemon switching fixture at 10 seconds (16 checks in 9.74s on local macOS). This targeted weighting update will be replaced by the rebuilt main timing table when main is brought into the integration branch. Follow-up validation: entrypoint 3/3 PASS, CI sharding 20/20 PASS, and unchanged watch 40/40 PASS on macOS. Initial sequential timing was 215.18s at 96b43b51 versus 197.90s at 4c49efe1 (39/40; the fixed-three-second DB-health fixture failed with empty stdout). Its subsequent full run passed 40/40 in 338.71s while the sharding tests ran concurrently, so that rerun is not a like-for-like timing comparison. The job-timeout test 38 also passed alone. The shard balance and coverage checks pass; weighting estimates are not a claim of a completed CI run. Targets `integration/agmsgd-beta`, based on `96b43b51`. --- .github/scripts/bats-file-seconds.tsv | 38 ++- README.md | 19 ++ docs/design/agmsgd-architecture.md | 2 + docs/design/agmsgd-rfc.ja.md | 2 + docs/design/agmsgd-rfc.md | 2 + scripts/check-inbox.sh | 2 + scripts/daemon.sh | 52 +++- scripts/daemon/status.mjs | 25 +- scripts/doctor.sh | 16 ++ scripts/drivers/types/codex/codex-monitor.sh | 15 ++ scripts/drivers/types/codex/codex-shim.sh | 19 ++ scripts/drivers/types/codex/watch-once.sh | 2 + scripts/lib/daemon-seats.sh | 80 ++++++ scripts/lib/daemon-state.sh | 119 +++++++++ scripts/lib/storage.sh | 16 ++ scripts/session-end.sh | 2 + scripts/session-start.sh | 2 + scripts/watch.sh | 2 + tests/agmsgd_status.test.mjs | 11 + tests/test_agmsgd_entrypoint.bats | 3 +- tests/test_agmsgd_switch.bats | 262 +++++++++++++++++++ 21 files changed, 655 insertions(+), 36 deletions(-) create mode 100644 scripts/lib/daemon-seats.sh create mode 100644 scripts/lib/daemon-state.sh create mode 100644 tests/test_agmsgd_switch.bats diff --git a/.github/scripts/bats-file-seconds.tsv b/.github/scripts/bats-file-seconds.tsv index 5dbeebd6a..f055e461c 100644 --- a/.github/scripts/bats-file-seconds.tsv +++ b/.github/scripts/bats-file-seconds.tsv @@ -21,16 +21,10 @@ # is two real perf fixes, not broad drift: test_remote_engine_start_refusal.bats # 660s->43s and test_remote_status_liveness.bats 346s->80s are both #1252 # (shortened the two slowest tests' production-readiness wait). A third -# file, test_watch.bats, measured 317s->2s in this same run -- that IS a -# real 2s today (the file is quarantined as a whole for #1262, setup() -# skips every test unconditionally, see that row's own comment), not a -# stale or misleading number: this table's job is to weight the partition -# by what a file actually costs the CURRENT run, and 2s is what it costs -# while quarantined. (A pre-quarantine 317s was pinned here briefly and -# reverted -- it made the on-paper balance look nicer but actively -# unbalanced the real schedule, handing ~315s of work to a shard that -# would not actually spend it; review finding, see the row's own comment -# for the reasoning #1318 needs when it un-quarantines the file.) Every +# file, test_watch.bats, measured 317s->2s in that run because the whole +# file was quarantined for #1262. Its setup skipped every test, so 2s was +# the actual cost while quarantined. The targeted refresh below restores +# its active cost after the quarantine was removed. Every # other file moved by single-digit seconds either way. So this window's # imbalance was not organic, even drift -- it was two real fixes plus one # quarantine, and neither is the kind of thing that keeps happening on a @@ -49,9 +43,20 @@ # run's shard skew (as happened here) triggers a refresh. Refresh by # re-running this extraction against a green run's bats-timings artifacts. # +# Targeted refresh, 2026-09-29: test_ext_tool_jev_handle.bats completed in +# 604s in macOS job 109704655665, run 36657411860, head 4c49efe1. +# test_watch.bats is no longer quarantined: its unchanged 40-test suite +# completed in 215.18s locally on macOS at integration head 96b43b51. +# test_agmsgd_switch.bats completed its 16 checks in 9.74s locally on macOS +# after the internal-entrypoint suppression fix; rounded to 10 seconds. +# Those two costs replace the missing-file estimate and historical 2s row. +# Adding a test file changes the greedy partition, so stale costs can move +# both expensive suites into a shard that exceeds the 30-minute job budget. +# # Columns: basenameseconds. Sorted by basename for a legible diff. test_actas_integration.bats 54 test_actas_lock.bats 34 +test_agmsgd_switch.bats 10 test_antigravity_resume.bats 1 test_antigravity_transport.bats 20 test_api.bats 63 @@ -86,6 +91,7 @@ test_endpoint_table_node.bats 1 test_enforced_assertions.bats 1 test_engine_inherited_fds.bats 21 test_export.bats 30 +test_ext_tool_jev_handle.bats 604 test_harness_self_name_off.bats 1 test_hash.bats 7 test_herdr_cli_routing.bats 2 @@ -167,16 +173,8 @@ test_transcript_exists.bats 2 test_type_registry.bats 35 test_unguarded_env_reads.bats 2 test_wait_helpers.bats 41 -# 2s is real, not stale -- the whole file is quarantined for #1262 -# (setup() skips every test unconditionally, tracked by #1318), so this is -# what it actually costs THIS run, which is what this table exists to -# reflect (a pinned pre-quarantine value was tried and reverted: it gave a -# nicer-looking on-paper balance but actively unbalanced the real schedule, -# handing 315s of imaginary work to whichever shard it landed in while that -# shard really finished ~300s early). #1318 must re-measure and update this -# row when it un-quarantines the file -- until then, reuse 2, not a stale -# pre-quarantine number. -test_watch.bats 2 +# Re-measured after the whole-file quarantine was removed; see refresh above. +test_watch.bats 215 test_watch_install_changed.bats 34 test_watch_once.bats 53 test_watch_process_count.bats 18 diff --git a/README.md b/README.md index ca29849ce..062dead97 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ Git Bash PATH). There is no PowerShell reimplementation. Set-Alias bash 'C:\Program Files\Git\bin\bash.exe' ``` +### agmsgd beta (1.6.0) + +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS and Linux are supported. Windows delivery is unsupported until it has been measured on a real Windows session. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. + +Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: + +```bash +agmsg daemon enable +agmsg daemon status +``` + +If `agmsg` is not found, use `/scripts/agmsg daemon enable` (and the same path for `status`, `start`, `stop`, or `disable`); `` is the installation directory printed by the installer. Follow its PATH instructions to make `agmsg` available in your shell. + +While enabled, new Codex launches through the shim use plain Codex: no bridge app-server or dispatcher is started. Sessions that already have a bridge keep using it until restarted; agmsgd skips those sessions. `enable` and `start` list the remaining bridge sessions and missing destination records. Restart Codex and run `$agmsg actas ` to record a missing destination. Existing remote sync processes remain separate, so this beta does not yet reduce all background work to one process. + +If agmsgd stops while enabled, the next ordinary agmsg script operation warns at most once every ten minutes per install. Codex startup also warns, and `daemon status` and `scripts/doctor.sh` report the failure with exit code 1. Run `agmsg daemon start` to recover. For a missing or outdated Node executable, install Node and run `agmsg daemon enable` again. Unread messages are preserved. + +To return to bridge notices, run `agmsg daemon disable`, then restart the Codex sessions listed as having no bridge (for example, `codex resume`, followed by `$agmsg actas `). They receive no new notices until restarted. Existing bridge sessions need no restart, and subsequent Codex launches get a bridge. Notices already accepted by the Codex queue are not cancelled and may appear once more. `stop` stops the daemon without removing its resident service; `disable` also unregisters that service. + ## First run Open your project in your agent (Claude Code, Codex, Gemini CLI, etc.) and run: diff --git a/docs/design/agmsgd-architecture.md b/docs/design/agmsgd-architecture.md index 3d41ff4ff..72cc850b1 100644 --- a/docs/design/agmsgd-architecture.md +++ b/docs/design/agmsgd-architecture.md @@ -2,6 +2,8 @@ This is the architecture design for agmsgd: the approved direction in [the RFC](agmsgd-rfc.md), worked down to how each part behaves. It describes the current design only; the reasoning behind each choice lives in the discussion and in the ADRs that will follow. Implementation has not started. The technical design (where the source lives, how it is built and shipped, the main components) comes next. +**1.6.0 beta scope:** the optional, default-off daemon queues inbox notices for Codex sessions against the existing SQLite store, before the 2.0.0 migration. Existing bridges and remote sync processes remain separate; a live bridge continues until its Codex session restarts. `agmsg daemon enable` changes new Codex launches, while `agmsg daemon disable` lists sessions that need restarting to restore bridge notices. If `agmsg` is not found, use `/scripts/agmsg daemon enable|disable`. macOS and Linux are supported. Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. The beta does not implement the 2.0.0 storage or delivery changes described below. + Status: 2026-09-28. [日本語版](agmsgd-architecture.ja.md) ## 1. Overview diff --git a/docs/design/agmsgd-rfc.ja.md b/docs/design/agmsgd-rfc.ja.md index bfad5092e..9813eb4d8 100644 --- a/docs/design/agmsgd-rfc.ja.md +++ b/docs/design/agmsgd-rfc.ja.md @@ -44,6 +44,8 @@ flowchart LR この順で出す: +**1.6.0 beta note:** agmsgd is optional and off by default before the 2.0.0 store migration. It only queues inbox notices for Codex sessions against the existing SQLite store. Existing Codex bridges and the remote sync engine remain separate processes; bridge sessions continue until restarted. Use `agmsg daemon enable` for new Codex launches, or `agmsg daemon disable` followed by restarting sessions that have no bridge. If `agmsg` is not found, use `/scripts/agmsg daemon enable|disable`. macOS and Linux are supported; Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. The beta does not bring forward the 2.0.0 data migration. + 1. **1.3.1** — まだ知らないメッセージの項目を読み飛ばせるようにする(出荷済み)。 2. **古い版が脇によけたものの読み直し** — 上げたあと、以前は理解できなかった保存済みの原文を読み直す(出荷済み)。 3. **新しいメッセージすべてに、共通の id・件名・要約を付ける**(1.x)。 diff --git a/docs/design/agmsgd-rfc.md b/docs/design/agmsgd-rfc.md index dadaec91c..ae3029fab 100644 --- a/docs/design/agmsgd-rfc.md +++ b/docs/design/agmsgd-rfc.md @@ -44,6 +44,8 @@ The trigger is concrete bugs. Several problems users actually hit came from mana It ships in this order: +The 1.6.0 agmsgd beta is an optional, default-off exception before the 2.0.0 store migration: it only queues inbox notices for Codex sessions against the existing SQLite store. Existing Codex bridges and the remote sync engine remain available as separate processes; bridge sessions continue until restarted. `agmsg daemon enable` switches new Codex launches to daemon notices, and `agmsg daemon disable` requires restarting sessions that have no bridge. If `agmsg` is not found, use `/scripts/agmsg daemon enable` or `/scripts/agmsg daemon disable`. The beta supports macOS and Linux; Windows delivery remains unsupported pending measurement, and JSONL storage is unsupported. It does not bring forward the 2.0.0 data migration. + 1. **1.3.1** — clients skip message fields they do not know yet (released). 2. **Re-reading what an older client set aside** — after an upgrade, the client re-reads the stored originals it could not understand before (released). 3. **A shared id, subject and summary on every new message** (1.x). diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index 5aba3caa2..cca13a198 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Hooks and their child operations must not run user recovery notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # shellcheck disable=SC1091 source "$(cd "$(dirname "$0")" && pwd)/lib/compat.sh" diff --git a/scripts/daemon.sh b/scripts/daemon.sh index 2c6cc2e70..180a76280 100644 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -1,10 +1,8 @@ #!/usr/bin/env bash # agmsgd's CLI ("agmsg daemon start|stop|status|enable|disable"). -# Written to take the subcommand as its own first argument so -# the future `agmsg` dispatcher (a separate PR/design, decided 2026-09-29) -# can wrap this file directly without restructuring it. Every message this -# file prints therefore already says `agmsg daemon ...`, the form users -# will actually type once that dispatcher exists. +# Takes the subcommand as its first argument so the `agmsg` dispatcher +# wraps this file directly. User-facing instructions use `agmsg daemon ...` +# and give the installed dispatcher's absolute path when PATH is not set. # # No Node is required for `status` on the fast path: it reads # install.db directly with sqlite3 first, and only shells out to Node @@ -17,6 +15,16 @@ SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" INSTALL_DB="$SKILL_DIR/run/install.db" LOCK_DB="$SKILL_DIR/run/install-op.lock.db" LAUNCHER="$SCRIPT_DIR/daemon/agmsgd-launch.sh" +# shellcheck source=lib/daemon-state.sh +source "$SCRIPT_DIR/lib/daemon-state.sh" +# shellcheck source=lib/storage.sh +source "$SCRIPT_DIR/lib/storage.sh" +# shellcheck source=lib/daemon-seats.sh +source "$SCRIPT_DIR/lib/daemon-seats.sh" + +_path_hint() { + printf 'If agmsg is not found, use "%s/scripts/agmsg" daemon %s.\n' "$SKILL_DIR" "$1" +} # Overridable so tests never register anything into the REAL resident # manager (the real gui launchd domain, the real systemd --user, the real @@ -392,6 +400,8 @@ cmd_start() { state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;")" if [ "$state" = "ready" ] && node "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" >/dev/null 2>&1; then echo "agmsg daemon start: already running" + agmsg_daemon_seat_report start + _path_hint status return 0 fi @@ -408,8 +418,12 @@ cmd_start() { fi if _wait_for_ready "$new_op_gen"; then echo "agmsg daemon start: running" + agmsg_daemon_seat_report start + _path_hint status else echo "agmsg daemon start: did not become ready in time -- check 'agmsg daemon status'" >&2 + agmsg_daemon_warn_if_stopped always + _path_hint status >&2 return 1 fi } @@ -465,11 +479,23 @@ cmd_stop() { cmd_status() { _require_install_db + agmsg_daemon_read_state + local health_rc=0 + if [ "${AGMSGD_DESIRED:-unknown}" = on ] && [ "${AGMSGD_HEALTH:-unknown}" != ready ]; then + agmsg_daemon_recovery_text + health_rc=1 + elif [ "${AGMSGD_HEALTH:-unknown}" = unknown ]; then + echo 'agmsg daemon status: install record could not be read; health is unknown' + health_rc=1 + fi local node_path node_path="$(_resolve_node node 2>/dev/null || true)" if [ -n "$node_path" ]; then - "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" - return $? + local status_rc=0 + "$node_path" "$SCRIPT_DIR/daemon/status.mjs" "$SKILL_DIR" || status_rc=$? + agmsg_daemon_seat_report status + [ "$health_rc" -eq 0 ] || return 1 + return "$status_rc" fi # K14 fallback: no usable Node at all -- a minimal, honest read straight # from install.db rather than the fuller status.mjs decision text. @@ -477,6 +503,9 @@ cmd_status() { state="$(sqlite3 "$INSTALL_DB" "SELECT state FROM daemon_owner;" 2>/dev/null || echo "unknown")" desired="$(sqlite3 "$INSTALL_DB" "SELECT desired FROM daemon_intent;" 2>/dev/null || echo "unknown")" echo "agmsg daemon status: state=$state intent=$desired (no usable Node -- a fuller check needs 'agmsg daemon enable')" + agmsg_daemon_seat_report status + _path_hint enable + return "$health_rc" } cmd_enable() { @@ -484,6 +513,8 @@ cmd_enable() { local node_path node_path="$(_resolve_node node)" || { echo "agmsg daemon enable: no usable Node found (need >= 22.13.0 with node:sqlite). Install one and try again." >&2 + echo 'Install Node from https://nodejs.org/en/download, then run agmsg daemon enable.' >&2 + _path_hint enable >&2 return 1 } local node_version @@ -517,8 +548,11 @@ cmd_disable() { echo "agmsg daemon disable: service was unregistered but agmsgd has not confirmed stopping -- check 'agmsg daemon status'" >&2 return 1 fi - echo "agmsg daemon disable: agmsgd turned off. Codex messages will go through the existing bridge again." - echo "agmsg daemon disable: any Codex session started while agmsgd was in use needs to be restarted to get its bridge back." + echo 'agmsg daemon disable: agmsgd turned off. Existing bridges continue to deliver notices; new Codex launches get a bridge.' + echo 'Codex sessions without a bridge must be restarted. Until then they receive no new notices; unread messages are preserved. Restart with codex resume, then enter $agmsg actas in that Codex session.' + echo 'Notices already accepted by the Codex queue are not cancelled and may appear once more.' + agmsg_daemon_seat_report disable + _path_hint disable } case "${1:-}" in diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index 775ebf3c5..9562bdcdc 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -29,11 +29,23 @@ import { PROTOCOL_VERSION } from "./control.mjs"; const STARTING_STOPPING_GRACE_MS = 30_000; // The pure decision. `now` is injectable for tests. -export function classify({ owner, intent, alive, reachable }, now = Date.now()) { +export function classify({ owner, intent, alive, reachable, lastAttempt }, now = Date.now()) { const desired = intent?.desired ?? null; + if (desired === "on" && !(owner.state === "ready" && alive === true && reachable)) { + const reason = lastAttempt?.reason ?? owner.last_end_reason ?? + (owner.state === "ready" ? "executor or control socket unavailable" : owner.state); + const detail = owner.last_end_reason === "bind_failed" ? "failed to start" : + owner.last_end_reason === "stepped_aside_for_update" ? "stopped for an update; the new version has not started yet" : "stopped"; + return { + text: `agmsgd is ${detail} (intent is on; reason: ${reason}${owner.state === "ready" && !reachable ? "; does not answer on its control socket" : ""}). Run agmsg daemon start (recommended), or agmsg daemon disable and restart Codex. Unread messages are preserved.`, + exitCode: 1, + gen: owner.gen, + }; + } + if (owner.state === "ready") { - if (reachable) { + if (reachable && alive === true) { return { text: `agmsgd is running (gen ${owner.gen}, version ${owner.version ?? "unknown"})`, exitCode: 0, @@ -100,6 +112,7 @@ export function readOwnerAndIntentReadOnly(installRoot) { try { const owner = db.prepare("SELECT * FROM daemon_owner").get(); const intent = db.prepare("SELECT * FROM daemon_intent").get(); + const lastAttempt = db.prepare("SELECT at, reason FROM daemon_start_attempts WHERE at > ? ORDER BY at DESC, rowid DESC LIMIT 1").get(owner.last_end_at ?? ""); let codexSeats; try { codexSeats = { @@ -114,7 +127,7 @@ export function readOwnerAndIntentReadOnly(installRoot) { owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive, codexSeats }; + return { owner, intent, alive, codexSeats, lastAttempt }; } finally { db.close(); } @@ -155,9 +168,9 @@ async function main() { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive, codexSeats; + let owner, intent, alive, codexSeats, lastAttempt; try { - ({ owner, intent, alive, codexSeats } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -166,7 +179,7 @@ async function main() { process.exit(1); } const reachable = owner.state === "ready" && owner.socket ? await probeSocket(owner.socket) : false; - const result = classify({ owner, intent, alive, reachable }); + const result = classify({ owner, intent, alive, reachable, lastAttempt }); // The node:sqlite experimental-feature warning is surfaced here, always // -- not hidden, not treated as a failure. diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 9a5bfa313..371f5895b 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -682,6 +682,22 @@ if [ -n "$GLOBAL_WATCH_LINE" ]; then _warn "watcher pidfile present but process not running, installation-wide (see the 'watch processes' line above)" fi fi +# Daemon health is install-wide, including when Node is unavailable. Read +# without starting, repairing or registering anything. +if [ -f "$RUN_DIR/install.db" ]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/lib/daemon-state.sh" + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/lib/daemon-seats.sh" + agmsg_daemon_read_state + if [ "${AGMSGD_DESIRED:-unknown}" = on ] && [ "${AGMSGD_HEALTH:-unknown}" != ready ]; then + _warn "$(_redact_text "$(agmsg_daemon_recovery_text)" "$SKILL_DIR")" + elif [ "${AGMSGD_HEALTH:-unknown}" = unknown ]; then + _warn 'agmsgd: install record could not be read; daemon health is unknown' + fi + REPORT_BLOCKS="${REPORT_BLOCKS}agmsgd beta: intent=${AGMSGD_DESIRED:-unknown} health=${AGMSGD_HEALTH:-unknown}"$'\n' + REPORT_BLOCKS="${REPORT_BLOCKS}$(agmsg_daemon_seat_report status)"$'\n\n' +fi WARN_COUNT="$(printf '%s\n' "$WARNINGS" | grep -c . || true)" echo "$TEAM_COUNT team(s), $TOTAL_PAIR_COUNT registration(s), $WARN_COUNT warning(s)" diff --git a/scripts/drivers/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh index a24f2e3e5..4ebc4c6a4 100755 --- a/scripts/drivers/types/codex/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -103,6 +103,21 @@ esac PROJECT="$(cd "$PROJECT" && pwd)" +# Direct monitor-wrapper users follow the same intent rule as shim users. +# This path must run before creating the seat or any long-lived process. +# shellcheck source=../../../lib/daemon-state.sh +source "$SCRIPT_DIR/../../../lib/daemon-state.sh" +agmsg_daemon_read_state +if [ "${AGMSGD_DESIRED:-unknown}" = on ]; then + echo 'agmsgd handles Codex notices; starting plain Codex without a bridge.' >&2 + agmsg_daemon_warn_if_stopped always + cd "$PROJECT" + case "$CODEX_COMMAND" in + codex) exec "$REAL_CODEX" ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; + resume) exec "$REAL_CODEX" resume ${CODEX_ARGS[@]+"${CODEX_ARGS[@]}"} ;; + esac +fi + # Fail-open: never let a broken bridge block codex. If the agmsg app-server can't # be brought up — e.g. a codex release changes the app-server interface and the # launch/port detection fails — hand off to a plain codex session (no --remote diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index ea4d4dbbf..d33d4b72d 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -159,6 +159,25 @@ fi project="$(project_from_args "$@")" command_name="$(first_non_option "$@" || true)" +# agmsgd owns newly launched seats while intent is on, even when the daemon +# is down. Do not start any app-server, dispatcher or bridge as a fallback. +daemon_state_lib="$SCRIPT_DIR/../../../lib/daemon-state.sh" +if [ -f "$daemon_state_lib" ]; then + # shellcheck disable=SC1090 + source "$daemon_state_lib" + agmsg_daemon_read_state + if [ "${AGMSGD_DESIRED:-unknown}" = on ]; then + case "$command_name" in + app-server|exec|e|login|logout|mcp|plugin|remote-control|completion|update|doctor|cloud|exec-server|features|debug|apply|a|review|sandbox|help|--help|-h|version|--version|-V) ;; + *) + echo 'agmsgd handles Codex notices; starting plain Codex without a bridge.' >&2 + agmsg_daemon_warn_if_stopped always + ;; + esac + exec "$real_codex" "$@" + fi +fi + if ! is_monitor_project "$project"; then exec "$real_codex" "$@" fi diff --git a/scripts/drivers/types/codex/watch-once.sh b/scripts/drivers/types/codex/watch-once.sh index adea69b7a..6c798e235 100755 --- a/scripts/drivers/types/codex/watch-once.sh +++ b/scripts/drivers/types/codex/watch-once.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Internal polling and its child operations must not run user notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # One-shot pending-message oracle for the Codex app-server bridge. # diff --git a/scripts/lib/daemon-seats.sh b/scripts/lib/daemon-seats.sh new file mode 100644 index 000000000..2c5c39303 --- /dev/null +++ b/scripts/lib/daemon-seats.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# User-facing transition inventory. Reads current registrations and bridge +# evidence rather than trusting the daemon's last poll after it has stopped. +[ -n "${_AGMSG_DAEMON_SEATS_SH:-}" ] && return 0 +_AGMSG_DAEMON_SEATS_SH=1 +# shellcheck disable=SC1091 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/instance-id.sh" +# shellcheck disable=SC1091 +source "$SKILL_DIR/scripts/lib/role-session.sh" + +agmsg_daemon_seat_report() { + local action="${1:-status}" cfg cfg_sql roster team agent project pid meta_type meta_project meta_pid + local bridge record_status cached key driver record_type record_home label + driver="$(agmsg_storage_driver)" || driver=unknown + for cfg in "$SKILL_DIR/teams/"*/config.json; do + [ -f "$cfg" ] || continue + cfg_sql="$(agmsg_sql_readfile_path "$cfg")" + roster="$(sqlite3 -separator $'\t' :memory: " + WITH cfg(j) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)) + SELECT DISTINCT json_extract(cfg.j,'\$.name'), a.key, json_extract(r.value,'\$.project') + FROM cfg, json_each(cfg.j,'\$.agents') a, json_each(a.value,'\$.registrations') r + WHERE json_extract(r.value,'\$.type')='codex'; + " 2>/dev/null)" || { echo 'Codex seats: registration inventory could not be read'; continue; } + while IFS=$'\t' read -r team agent project; do + [ -n "$team" ] && [ -n "$agent" ] || continue + [ -z "${FILTER_TEAM:-}" ] || [ "$FILTER_TEAM" = "$team" ] || continue + [ -z "${FILTER_PROJECT:-}" ] || [ "$FILTER_PROJECT" = "$project" ] || continue + [ -z "${FILTER_TYPE:-}" ] || [ "$FILTER_TYPE" = codex ] || continue + if ! agmsg_validate_team_name "$team" >/dev/null 2>&1 || ! agmsg_validate_agent_name "$agent" >/dev/null 2>&1; then continue; fi + bridge=stopped + if [ -e "$SKILL_DIR/run/codex-bridge.$team.$agent.pid" ]; then + pid="$(cat "$SKILL_DIR/run/codex-bridge.$team.$agent.pid" 2>/dev/null)" || pid="" + meta_pid=""; meta_type=""; meta_project="" + if [ -r "$SKILL_DIR/run/codex-bridge.$team.$agent.meta" ]; then + local field value + while IFS='=' read -r field value; do + case "$field" in pid) meta_pid="$value" ;; type) meta_type="$value" ;; project) meta_project="$value" ;; esac + done < "$SKILL_DIR/run/codex-bridge.$team.$agent.meta" + fi + case "$pid" in ''|*[!0-9]*) bridge=unknown ;; *) + if [ "$meta_pid" != "$pid" ] || [ "$meta_type" != codex ] || [ "$meta_project" != "$project" ]; then + bridge=unknown + elif _agmsg_pid_alive "$pid"; then + bridge=running + fi ;; + esac + fi + agmsg_role_session_load "$team" "$agent" 2>/dev/null || true + record_type="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" type)" + record_home="$(_agmsg_role_session_field "$_AGMSG_ROLE_SESSION_PATH" codex_home)" + record_status='destination recorded' + if [ "$record_type" != codex ] || [ -z "${AGMSG_ROLE_SESSION_UUID:-}" ] || [ -z "$record_home" ]; then + record_status='no destination record; restart Codex and run actas again to record it' + fi + key="$(printf '%s' "$team" | sed "s/'/''/g")" + local agent_sql + agent_sql="$(printf '%s' "$agent" | sed "s/'/''/g")" + local thread_sql + thread_sql="$(printf '%s' "${AGMSG_ROLE_SESSION_UUID:-}" | sed "s/'/''/g")" + cached="$(sqlite3 -readonly -cmd '.timeout 100' "$SKILL_DIR/run/install.db" "SELECT reason FROM beta_codex_seat WHERE seat=json_array('$key','$agent_sql') AND thread='$thread_sql';" 2>/dev/null)" || cached="" + case "$cached" in + thread_archived*) record_status='conversation archived; resume an active conversation and run actas again' ;; + codex_home_mismatch*) record_status='incorrect CODEX_HOME record; run actas from the correct Codex profile' ;; + esac + label="Codex $team/$agent" + if [ "${REDACTED:-0}" = 1 ]; then label='Codex seat (redacted)'; cached=""; fi + case "$action:$bridge" in + disable:running) echo "$label: already using a bridge; no restart needed" ;; + disable:stopped) echo "$label: no bridge attached; restart Codex to restore notices ($record_status)" ;; + disable:unknown) echo "$label: bridge state unknown; inspect delivery.sh status codex before restarting ($record_status)" ;; + *:running) echo "$label: still using a bridge; restart Codex to move to agmsgd" ;; + *:unknown) echo "$label: bridge state unknown ($record_status)" ;; + *) echo "$label: $record_status${cached:+; last channel observation: $cached}" ;; + esac + if [ "$driver" != sqlite ]; then + echo "$label: agmsgd beta does not support $driver storage (including JSONL). Keep using the bridge; disable agmsgd before restarting this seat." + fi + done <<< "$roster" + done +} diff --git a/scripts/lib/daemon-state.sh b/scripts/lib/daemon-state.sh new file mode 100644 index 000000000..6b1edb500 --- /dev/null +++ b/scripts/lib/daemon-state.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Read-only daemon health, shared by ordinary operations, the shim and doctor. +# Resolve from this install, never from the message-storage override. +[ -n "${_AGMSG_DAEMON_STATE_SH:-}" ] && return 0 +_AGMSG_DAEMON_STATE_SH=1 +_AGMSG_DAEMON_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +agmsg_daemon_read_state() { + AGMSGD_DESIRED=unknown; AGMSGD_HEALTH=unknown; AGMSGD_REASON="install record unavailable" + local row state pid boot recorded_start current_boot process_start started_epoch + [ -f "$_AGMSG_DAEMON_ROOT/run/install.db" ] || return 0 + # One sqlite invocation, read-only, bounded when another writer is busy. + row="$(sqlite3 -readonly -cmd '.timeout 100' -separator $'\t' "$_AGMSG_DAEMON_ROOT/run/install.db" " + SELECT COALESCE(i.desired,'unset'), o.state, COALESCE(o.executor_pid,0), + COALESCE(o.executor_boot_id,'-'), COALESCE(o.executor_started_at,'-'), + replace(replace(replace(COALESCE( + (SELECT reason FROM daemon_start_attempts WHERE at > COALESCE(o.last_end_at,'') ORDER BY at DESC, rowid DESC LIMIT 1), + o.last_end_reason, 'executor unavailable'),char(9),' '),char(10),' '),char(13),' ') + FROM daemon_owner o CROSS JOIN daemon_intent i; + " 2>/dev/null)" || return 0 + [ -n "$row" ] || return 0 + IFS=$'\t' read -r AGMSGD_DESIRED state pid boot recorded_start AGMSGD_REASON <<< "$row" + AGMSGD_HEALTH=stopped + [ "$state" = ready ] || { AGMSGD_REASON="$state: $AGMSGD_REASON"; return 0; } + case "$pid" in ''|*[!0-9]*|0|1) return 0 ;; esac + case "$(uname -s)" in + Darwin) + current_boot="$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" + process_start="$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null)" || process_start="" + started_epoch="$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$process_start" +%s 2>/dev/null)" || started_epoch="" + ;; + Linux) + current_boot="$(sed -n 's/^btime //p' /proc/stat 2>/dev/null)" + local proc_stat ticks hz + proc_stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || proc_stat="" + ticks="$(printf '%s' "${proc_stat##*) }" | awk '{print $20}')" + hz="$(getconf CLK_TCK 2>/dev/null)" || hz="" + started_epoch="" + case "$ticks:$hz:$current_boot" in *[!0-9:]*|:*|*::*|*:) ;; *) + [ "$hz" -gt 0 ] && started_epoch=$((current_boot + ticks / hz)) ;; + esac + ;; + *) AGMSGD_REASON="platform liveness check unavailable"; return 0 ;; + esac + if [ -z "$current_boot" ] || [ "$current_boot" != "$boot" ] || [ -z "$started_epoch" ]; then + AGMSGD_REASON="executor missing or boot/start evidence does not match ($AGMSGD_REASON)" + return 0 + fi + # The owner records its claim time. A process born after that claim is a + # reused PID, never evidence that the recorded executor is still running. + local claim_epoch + case "$recorded_start" in + ????-??-??T??:??:??*) + case "$(uname -s)" in + Darwin) claim_epoch="$(TZ=UTC date -j -f '%Y-%m-%dT%H:%M:%S' "${recorded_start:0:19}" +%s 2>/dev/null)" || claim_epoch="" ;; + Linux) claim_epoch="$(date -u -d "$recorded_start" +%s 2>/dev/null)" || claim_epoch="" ;; + esac ;; + *) claim_epoch="" ;; + esac + if [ -z "$claim_epoch" ] || [ "$started_epoch" -gt "$claim_epoch" ]; then + AGMSGD_REASON="executor start evidence does not match ($AGMSGD_REASON)" + return 0 + fi + local process_state + process_state="$(ps -p "$pid" -o stat= 2>/dev/null)" || process_state="" + case "$process_state" in ''|*Z*|*T*) AGMSGD_REASON="executor exited or suspended ($AGMSGD_REASON)"; return 0 ;; esac + AGMSGD_HEALTH=ready + AGMSGD_REASON="" +} + +agmsg_daemon_recovery_text() { + printf 'agmsgd (Codex notices) is stopped while enabled (reason: %s). Run agmsg daemon start (recommended), or agmsg daemon disable and restart your Codex sessions. Unread messages are preserved.' "$AGMSGD_REASON" + case "$AGMSGD_REASON" in *Node*|*node*) + printf ' Install Node >= 22.13.0 from https://nodejs.org/en/download, then run agmsg daemon enable.' ;; + esac + printf ' If agmsg is not found, use "%s/scripts/agmsg" daemon start (replace start with disable or enable as needed).\n' "$_AGMSG_DAEMON_ROOT" +} + +agmsg_daemon_warn_if_stopped() { + # Keep health state local without forking a shell that inherits caller FDs. + local AGMSGD_DESIRED AGMSGD_HEALTH AGMSGD_REASON + local now last=0 marker slot + marker="$_AGMSG_DAEMON_ROOT/run/agmsgd-warning-at" + if [ "${1:-}" != always ]; then + now="$(date +%s)" || return 0 + [ ! -f "$marker" ] || IFS= read -r last < "$marker" || last=0 + case "$last" in ''|*[!0-9]*) last=0 ;; esac + # Already warned: skip SQLite and process liveness checks entirely. + [ "$((now - last))" -ge 600 ] || return 0 + fi + agmsg_daemon_read_state + [ "$AGMSGD_DESIRED" = on ] && [ "$AGMSGD_HEALTH" != ready ] || return 0 + if [ "${1:-}" = always ]; then + agmsg_daemon_recovery_text >&2 + return 0 + fi + # Atomic claim per time window; reread the rolling timestamp after claiming. + # Concurrent claims across a boundary can still emit twice (beta limitation). + # A killed claimant can suppress at most this window, never all future ones. + slot="$_AGMSG_DAEMON_ROOT/run/agmsgd-warning-slot.$((now / 600))" + mkdir "$slot" 2>/dev/null || return 0 + [ ! -f "$marker" ] || IFS= read -r last < "$marker" || last=0 + case "$last" in ''|*[!0-9]*) last=0 ;; esac + if [ "$((now - last))" -ge 600 ]; then + printf '%s\n' "$now" > "$slot/at" || return 0 + mv "$slot/at" "$marker" || return 0 + agmsg_daemon_recovery_text >&2 + fi + # The claimed window remains as an empty directory. Only past windows are + # removed; none of them can grant a fresh claim in the current window. + local old old_window + for old in "$_AGMSG_DAEMON_ROOT/run/"agmsgd-warning-slot.*; do + [ -d "$old" ] || continue + old_window="${old##*.}" + case "$old_window" in ''|*[!0-9]*) continue ;; esac + [ "$old_window" -lt "$((now / 600))" ] && rmdir "$old" 2>/dev/null || true + done + return 0 +} diff --git a/scripts/lib/storage.sh b/scripts/lib/storage.sh index 1f5c215d1..ba675579b 100644 --- a/scripts/lib/storage.sh +++ b/scripts/lib/storage.sh @@ -30,6 +30,22 @@ [ -n "${_AGMSG_STORAGE_SH:-}" ] && return 0 _AGMSG_STORAGE_SH=1 +# Ordinary operations on a non-daemon install pay only a file-existence check. +# Watchers suppress this entry check in themselves and all child operations. +_agmsg_entry_dir="${0%/*}" +[ "$_agmsg_entry_dir" != "$0" ] || _agmsg_entry_dir=. +if [ "${AGMSG_DAEMON_NOTICE_SKIP:-0}" != 1 ] && [ -f "$_agmsg_entry_dir/../run/install.db" ]; then + case "${0##*/}" in daemon.sh|doctor.sh|delivery.sh|watch.sh|session-start.sh|session-end.sh|check-inbox.sh) ;; *) + if [ -f "$_agmsg_entry_dir/lib/daemon-state.sh" ]; then + # shellcheck disable=SC1091 + source "$_agmsg_entry_dir/lib/daemon-state.sh" + agmsg_daemon_warn_if_stopped || true + fi + ;; + esac +fi +unset _agmsg_entry_dir + # agmsg_db_path turns the team selector into a path segment, so it cannot do its # job without the shared name validator. Sourced here rather than left to each # caller: watch.sh already reached the store without validate.sh in scope, and a diff --git a/scripts/session-end.sh b/scripts/session-end.sh index 44220b524..81d6d2624 100755 --- a/scripts/session-end.sh +++ b/scripts/session-end.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Hooks and their child operations must not run user recovery notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # shellcheck disable=SC1091 source "$(cd "$(dirname "$0")" && pwd)/lib/compat.sh" diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 07e53896f..9a8ac894f 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +# Hooks and their child operations must not run user recovery notices. +export AGMSG_DAEMON_NOTICE_SKIP=1 # shellcheck disable=SC1091 source "$(cd "$(dirname "$0")" && pwd)/lib/compat.sh" diff --git a/scripts/watch.sh b/scripts/watch.sh index e9426ed6b..3fea047c5 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -49,6 +49,8 @@ ACTIVE_NAME="${4:-}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# Polling and its child operations must never run the user-operation notice. +export AGMSG_DAEMON_NOTICE_SKIP=1 source "$SCRIPT_DIR/lib/storage.sh" agmsg_storage_load # Warm agmsg_storage_dir's own process-lifetime cache as a PLAIN STATEMENT, diff --git a/tests/agmsgd_status.test.mjs b/tests/agmsgd_status.test.mjs index 9c5edd195..8e40a03ce 100644 --- a/tests/agmsgd_status.test.mjs +++ b/tests/agmsgd_status.test.mjs @@ -4,6 +4,17 @@ import { classify } from "../scripts/daemon/status.mjs"; const baseOwner = { gen: 1, version: "1.6.0", socket: "/tmp/x.sock" }; +test("enabled intent never reports a never-started or dead executor as healthy", () => { + for (const owner of [{ ...baseOwner, gen: 0, state: "none" }, { ...baseOwner, state: "ready" }]) { + const result = classify({ owner, intent: { desired: "on" }, alive: false, reachable: true, + lastAttempt: { reason: "restart limit reached" } }); + assert.equal(result.exitCode, 1); + assert.match(result.text, /restart limit reached/); + assert.match(result.text, /agmsg daemon start/); + assert.match(result.text, /agmsg daemon disable/); + } +}); + test("ready + reachable -> running, exit 0", () => { const r = classify({ owner: { ...baseOwner, state: "ready" }, intent: { desired: "on" }, alive: true, reachable: true }); assert.equal(r.exitCode, 0); diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index 55d4ef381..d000c534e 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -80,7 +80,8 @@ NODE local socket="" waited=0 while [ -z "$socket" ]; do - socket="$(sqlite3 "$TEST_SKILL_DIR/run/install.db" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null)" + # Startup updates owner state concurrently; wait for its short DB write. + socket="$(sqlite3 -cmd '.timeout 5000' "$TEST_SKILL_DIR/run/install.db" "SELECT socket FROM daemon_owner WHERE state = 'ready';" 2>/dev/null)" [ -n "$socket" ] && break waited=$((waited + 1)) if [ "$waited" -ge 100 ]; then diff --git a/tests/test_agmsgd_switch.bats b/tests/test_agmsgd_switch.bats new file mode 100644 index 000000000..79eb9d3fb --- /dev/null +++ b/tests/test_agmsgd_switch.bats @@ -0,0 +1,262 @@ +#!/usr/bin/env bats +# Isolated scripts and data, without replacing HOME or touching real services. +assert_contains() { + case "$output" in *"$1"*) return 0 ;; *) printf 'Missing: %s\nOutput: %s\n' "$1" "$output" >&2; return 1 ;; esac +} +assert_lacks() { + case "$output" in *"$1"*) printf 'Unexpected: %s\n' "$1" >&2; return 1 ;; *) return 0 ;; esac +} +refute() { + if "$@"; then return 1; fi + return 0 +} +setup() { + export TEST_SKILL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agmsgd-switch.XXXXXX")" + TEST_SKILL_DIR="$(cd "$TEST_SKILL_DIR" && pwd -P)" + cp -R "$BATS_TEST_DIRNAME/../scripts" "$TEST_SKILL_DIR/scripts" + export SCRIPTS="$TEST_SKILL_DIR/scripts" + mkdir -p "$TEST_SKILL_DIR/run" "$TEST_SKILL_DIR/teams/demo" "$TEST_SKILL_DIR/db" "$TEST_SKILL_DIR/codex-profile" + export AGMSG_CONFIG="$TEST_SKILL_DIR/config.json" + export AGMSG_STORAGE_PATH="$TEST_SKILL_DIR/db" + export CODEX_HOME="$TEST_SKILL_DIR/codex-profile" + export AGMSG_SELF_NAME=off + unset TMUX TMUX_PANE HERDR_ENV HERDR_PANE_ID HERDR_SOCKET_PATH + unset AGMSG_CODEX_SEAT_KEY AGMSG_CODEX_BRIDGE_APP_SERVER AGMSG_CODEX_SHIM_DISABLE AGMSG_CODEX_BRIDGE + unset AGMSG_DAEMON_NOTICE_SKIP + sqlite3 "$TEST_SKILL_DIR/run/install.db" < "$SCRIPTS/daemon/schema.sql" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE meta SET install_id='switch-test'; UPDATE daemon_intent SET desired='on';" + printf '{"install_id":"switch-test","gen":1}\n' > "$TEST_SKILL_DIR/run/install-manifest.json" + printf '{"name":"demo","agents":{"alice":{"registrations":[{"type":"codex","project":"%s"}]},"bob":{"registrations":[{"type":"codex","project":"%s"}]}}}\n' "$TEST_SKILL_DIR" "$TEST_SKILL_DIR" > "$TEST_SKILL_DIR/teams/demo/config.json" + export CALL_LOG="$TEST_SKILL_DIR/calls" + export AGMSG_REAL_CODEX="$TEST_SKILL_DIR/real-codex" + export AGMSG_CODEX_MONITOR_CMD="$TEST_SKILL_DIR/monitor" + printf '#!/usr/bin/env bash\nprintf "plain %%s\\n" "$*" >> "$CALL_LOG"\n' > "$AGMSG_REAL_CODEX" + printf '#!/usr/bin/env bash\nprintf "monitor %%s\\n" "$*" >> "$CALL_LOG"\n' > "$AGMSG_CODEX_MONITOR_CMD" + chmod +x "$AGMSG_REAL_CODEX" "$AGMSG_CODEX_MONITOR_CMD" + # Monitor mode lets missing/unreadable DB retain the existing launch route. + mkdir -p "$TEST_SKILL_DIR/project/.codex" + printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"%s/session-start.sh"}]}]}}\n' "$SCRIPTS" > "$TEST_SKILL_DIR/project/.codex/hooks.json" +} + +@test "enabled shim passes arguments unchanged and launches no monitor even when daemon is stopped" { + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + [ "$status" -eq 0 ] + assert_contains 'without a bridge' + assert_contains 'agmsg daemon start' + assert_contains "$SCRIPTS/agmsg" + grep -Fq "plain -C $TEST_SKILL_DIR/project resume --last" "$CALL_LOG" + refute grep -q '^monitor' "$CALL_LOG" +} + +@test "off, missing and unreadable install records retain the existing monitor route" { + for condition in off missing unreadable; do + case "$condition" in + off) sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_intent SET desired='off';" ;; + missing) mv "$TEST_SKILL_DIR/run/install.db" "$TEST_SKILL_DIR/run/saved.db" ;; + unreadable) printf 'invalid sqlite data\n' > "$TEST_SKILL_DIR/run/install.db" ;; + esac + : > "$CALL_LOG" + run bash "$SCRIPTS/drivers/types/codex/codex-shim.sh" -C "$TEST_SKILL_DIR/project" resume --last + [ "$status" -eq 0 ] + grep -q '^monitor' "$CALL_LOG" + assert_lacks 'agmsgd handles' + done +} + +@test "direct monitor wrapper also bypasses app-server and dispatcher while enabled" { + run bash "$SCRIPTS/drivers/types/codex/codex-monitor.sh" --project "$TEST_SKILL_DIR" --codex-command resume -- --last + [ "$status" -eq 0 ] + grep -Fq 'plain resume --last' "$CALL_LOG" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = on ] + local files + files="$(printf '%s\n' "$TEST_SKILL_DIR/run/"*)" + [ "${files#*codex-seat}" = "$files" ] + [ "${files#*codex-bridge-request}" = "$files" ] +} + +@test "ordinary operations warn once per install without Node and keep stdout clean" { + mkdir -p "$TEST_SKILL_DIR/no-node" + printf '#!/usr/bin/env bash\necho unexpected-node >> "$CALL_LOG"\nexit 1\n' > "$TEST_SKILL_DIR/no-node/node" + chmod +x "$TEST_SKILL_DIR/no-node/node" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "INSERT INTO daemon_start_attempts VALUES ('2099-01-01','no usable Node recorded',0);" + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_contains 'Node >= 22.13.0' + assert_contains 'agmsg daemon enable' + assert_contains alice + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_lacks 'stopped while enabled' + [ -f "$TEST_SKILL_DIR/run/agmsgd-warning-at" ] + [ ! -f "$CALL_LOG" ] +} + +@test "warning interval is rolling across clock windows and expires after ten minutes" { + mkdir -p "$TEST_SKILL_DIR/clock" + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$TEST_NOW"\n' > "$TEST_SKILL_DIR/clock/date" + chmod +x "$TEST_SKILL_DIR/clock/date" + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1199 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_contains 'stopped while enabled' + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1201 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_lacks 'stopped while enabled' + PATH="$TEST_SKILL_DIR/clock:$PATH" TEST_NOW=1799 run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + assert_contains 'stopped while enabled' +} + +@test "storage skips daemon helper loading without an install record" { + mv "$TEST_SKILL_DIR/run/install.db" "$TEST_SKILL_DIR/run/saved.db" + printf '\nprintf "helper loaded\\n" >> "$CALL_LOG"\n' >> "$SCRIPTS/lib/daemon-state.sh" + run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_contains alice + [ ! -f "$CALL_LOG" ] +} + +@test "watch suppresses daemon helper loading in itself and child operations" { + printf '\nprintf "helper loaded\\n" >> "$CALL_LOG"\n' >> "$SCRIPTS/lib/daemon-state.sh" + mv "$SCRIPTS/identities.sh" "$SCRIPTS/identities-real.sh" + cat > "$SCRIPTS/identities.sh" <<'WRAPPER' +#!/usr/bin/env bash +printf 'skip=%s\n' "${AGMSG_DAEMON_NOTICE_SKIP:-0}" >> "$CALL_LOG" +exec bash "$SCRIPTS/identities-real.sh" "$@" +WRAPPER + chmod +x "$SCRIPTS/identities.sh" + AGMSG_WATCH_INTERVAL=0.1 run bash "$SCRIPTS/watch.sh" notice-test "$TEST_SKILL_DIR/unjoined" claude-code + [ "$status" -eq 0 ] + assert_lacks 'stopped while enabled' + [ "$(cat "$CALL_LOG")" = skip=1 ] + [ ! -f "$TEST_SKILL_DIR/run/agmsgd-warning-at" ] +} + +@test "hook and one-shot polling children inherit daemon notice suppression" { + printf '\nprintf "helper loaded\\n" >> "$CALL_LOG"\n' >> "$SCRIPTS/lib/daemon-state.sh" + mv "$SCRIPTS/identities.sh" "$SCRIPTS/identities-real.sh" + cat > "$SCRIPTS/identities.sh" <<'WRAPPER' +#!/usr/bin/env bash +printf 'skip=%s\n' "${AGMSG_DAEMON_NOTICE_SKIP:-0}" >> "$CALL_LOG" +exec bash "$SCRIPTS/identities-real.sh" "$@" +WRAPPER + chmod +x "$SCRIPTS/identities.sh" + local entry expected + for entry in check-inbox.sh session-start.sh drivers/types/codex/watch-once.sh; do + : > "$CALL_LOG" + expected=0 + case "$entry" in + *watch-once.sh) + expected=2 + run bash "$SCRIPTS/$entry" "$TEST_SKILL_DIR" codex --timeout 0 ;; + session-start.sh) + # No registration: exercise identity resolution, then stop before hooks. + run bash "$SCRIPTS/$entry" codex "$TEST_SKILL_DIR/unjoined" > "$CALL_LOG"; }\n' >> "$SCRIPTS/lib/daemon-state.sh" + run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_lacks 'stopped while enabled' + [ ! -f "$CALL_LOG" ] +} + +@test "warning cleanup removes only past slots and preserves current and newer claims" { + mkdir -p "$TEST_SKILL_DIR/clock" "$TEST_SKILL_DIR/run/agmsgd-warning-slot.0" "$TEST_SKILL_DIR/run/agmsgd-warning-slot.2" "$TEST_SKILL_DIR/run/agmsgd-warning-slot.unknown" + printf '#!/usr/bin/env bash\nprintf "1199\\n"\n' > "$TEST_SKILL_DIR/clock/date" + chmod +x "$TEST_SKILL_DIR/clock/date" + PATH="$TEST_SKILL_DIR/clock:$PATH" run bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex + [ "$status" -eq 0 ] + assert_contains 'stopped while enabled' + [ ! -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.0" ] + [ -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.1" ] + [ -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.2" ] + [ -d "$TEST_SKILL_DIR/run/agmsgd-warning-slot.unknown" ] +} + +@test "status and doctor report enabled failure even without usable Node" { + mkdir -p "$TEST_SKILL_DIR/no-node" + printf '#!/usr/bin/env bash\nexit 1\n' > "$TEST_SKILL_DIR/no-node/node" + chmod +x "$TEST_SKILL_DIR/no-node/node" + sqlite3 "$TEST_SKILL_DIR/run/install.db" "INSERT INTO daemon_start_attempts VALUES ('2099-01-01','no usable Node recorded',0);" + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/daemon.sh" status + [ "$status" -eq 1 ] + assert_contains 'no usable Node recorded' + assert_contains 'agmsg daemon enable' + PATH="$TEST_SKILL_DIR/no-node:$PATH" run bash "$SCRIPTS/doctor.sh" --team demo --type codex --redacted + [ "$status" -eq 1 ] + assert_contains 'Codex notices' + assert_contains 'agmsg daemon start' + assert_lacks demo/alice +} + +@test "concurrent ordinary operations claim only one warning" { + bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex > "$TEST_SKILL_DIR/one.out" 2> "$TEST_SKILL_DIR/one.err" & + local one=$! + bash "$SCRIPTS/identities.sh" "$TEST_SKILL_DIR" codex > "$TEST_SKILL_DIR/two.out" 2> "$TEST_SKILL_DIR/two.err" & + local two=$! + wait "$one" + wait "$two" + [ "$(cat "$TEST_SKILL_DIR/one.err" "$TEST_SKILL_DIR/two.err" | grep -c 'stopped while enabled')" -eq 1 ] + refute grep -q agmsgd "$TEST_SKILL_DIR/one.out" + refute grep -q agmsgd "$TEST_SKILL_DIR/two.out" +} + +@test "status on a never-started enabled install fails with recovery and missing destinations" { + run bash "$SCRIPTS/daemon.sh" status + [ "$status" -eq 1 ] + assert_contains 'agmsg daemon start' + assert_contains 'no destination record' + assert_contains 'demo/alice' +} + +@test "executor evidence recognizes a live ready owner and rejects a changed boot" { + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$status" -eq 0 ] + [ "$output" = ready ] + sqlite3 "$TEST_SKILL_DIR/run/install.db" "UPDATE daemon_owner SET executor_boot_id='previous-boot';" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_read_state; printf "%s\n" "$AGMSGD_HEALTH"' + [ "$output" = stopped ] +} + +@test "all enabled failure causes produce recovery notices without losing messages" { + for reason in 'restart limit reached' 'no usable Node recorded' 'repeated startup failures' crash; do + sqlite3 "$TEST_SKILL_DIR/run/install.db" "DELETE FROM daemon_start_attempts; INSERT INTO daemon_start_attempts VALUES ('2099-01-01','$reason',0);" + run bash -c 'source "$SCRIPTS/lib/daemon-state.sh"; agmsg_daemon_warn_if_stopped always' + [ "$status" -eq 0 ] + assert_contains "$reason" + assert_contains 'agmsg daemon start' + assert_contains 'Unread messages are preserved' + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = on ] + done +} + +@test "disable inventory distinguishes bridge sessions and warns about JSONL" { + printf '{"storage":"jsonl"}\n' > "$AGMSG_CONFIG" + # Match delivery.sh's current pid/metadata liveness contract. + printf '%s\n' "$$" > "$TEST_SKILL_DIR/run/codex-bridge.demo.alice.pid" + printf 'pid=%s\ntype=codex\nproject=%s\n' "$$" "$TEST_SKILL_DIR" > "$TEST_SKILL_DIR/run/codex-bridge.demo.alice.meta" + mkdir -p "$TEST_SKILL_DIR/fake-bin" + printf '#!/usr/bin/env bash\necho Unsupported\n' > "$TEST_SKILL_DIR/fake-bin/uname" + chmod +x "$TEST_SKILL_DIR/fake-bin/uname" + PATH="$TEST_SKILL_DIR/fake-bin:$PATH" run bash "$SCRIPTS/daemon.sh" disable + [ "$status" -eq 0 ] + assert_contains 'demo/alice: already using a bridge; no restart needed' + assert_contains 'demo/bob: no bridge attached; restart Codex' + assert_contains 'JSONL' + assert_contains 'unread messages are preserved' + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT desired FROM daemon_intent;')" = off ] +} From 815eb2ab452ab251e472e6c66beec750a3484ffe Mon Sep 17 00:00:00 2001 From: fujibee Date: Tue, 29 Sep 2026 23:57:09 -0700 Subject: [PATCH 10/15] fix: handle database readers and immediate stops during daemon startup (#1530) * fix: wait for install database readers during daemon startup * fix: allow daemon stop before polling timer initialization --- scripts/daemon/agmsgd | 7 +++-- scripts/daemon/main.mjs | 3 +- tests/test_agmsgd_daemon_sh.bats | 5 ++- tests/test_agmsgd_entrypoint.bats | 51 +++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/scripts/daemon/agmsgd b/scripts/daemon/agmsgd index 9e3df7837..22149f912 100755 --- a/scripts/daemon/agmsgd +++ b/scripts/daemon/agmsgd @@ -74,6 +74,7 @@ function verifyInstallGeneration(installRoot, { incompleteOperationPath, manifes } const installDb = new DatabaseSync(dbPath, { readOnly: true }); + installDb.exec("PRAGMA busy_timeout = 5000;"); let recordedInstallId; try { recordedInstallId = installDb.prepare("SELECT install_id FROM meta").get().install_id; @@ -127,6 +128,7 @@ async function main() { lockDb.exec("BEGIN EXCLUSIVE;"); let verified; let mainModule; + let openInstallDb; try { verified = verifyInstallGeneration(installRoot, { incompleteOperationPath, manifestPath, dbPath, includeSchema: true }); @@ -135,6 +137,7 @@ async function main() { // log/status) is loaded here, still under the lock; release it only // after the component is fully loaded. mainModule = await import("./main.mjs"); + ({ openInstallDb } = await import("./db.mjs")); } finally { lockDb.exec("COMMIT;"); lockDb.close(); @@ -155,8 +158,8 @@ async function main() { if (current.manifestText !== verified.manifestText) { fail(75, "install generation changed before daemon ownership claim"); } - const db = new DatabaseSync(dbPath); - db.exec(verified.schemaText); + // Use the shared bounded wait for CLI readers racing owner updates. + const db = openInstallDb(dbPath); const release = () => { try { claimLock.exec("COMMIT;"); diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 4f1d4b76c..0024c8eed 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -123,6 +123,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe let controlHandle; let gen; let stopping = false; + let timer; async function doStop(reason) { if (stopping) return; @@ -172,7 +173,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe const watchState = await captureWatchState(installRoot, manifest, manifestText); let polling = false; - const timer = setInterval(async () => { + timer = setInterval(async () => { if (stopping || polling) return; polling = true; try { diff --git a/tests/test_agmsgd_daemon_sh.bats b/tests/test_agmsgd_daemon_sh.bats index 2c4e2b67e..1bfcd87aa 100644 --- a/tests/test_agmsgd_daemon_sh.bats +++ b/tests/test_agmsgd_daemon_sh.bats @@ -186,11 +186,10 @@ _assert_start_result() { _seed_install_db _write_completion_record _run_with_deadline 15 bash "$DAEMON" start - [ "$status" -eq 0 ] + _assert_start_result 'running' _run_with_deadline 10 bash "$DAEMON" start - [ "$status" -eq 0 ] - printf '%s\n' "$output" | grep -Fq 'already running' + _assert_start_result 'already running' bash "$DAEMON" stop >/dev/null 2>&1 || true } diff --git a/tests/test_agmsgd_entrypoint.bats b/tests/test_agmsgd_entrypoint.bats index d000c534e..0f1686e39 100644 --- a/tests/test_agmsgd_entrypoint.bats +++ b/tests/test_agmsgd_entrypoint.bats @@ -138,6 +138,57 @@ NODE [[ "$output" == *"operation is incomplete"* ]] } +@test "agmsgd entrypoint waits for a CLI read lock before marking ready" { + # Instrument the copied module before hashing it. Synchronize a separate + # reader after ownership is committed, exactly at the ready-write boundary. + node - "$SCRIPTS/daemon/main.mjs" <<'NODE' +const { readFileSync, writeFileSync } = require('node:fs'); +const path = process.argv[2]; +const source = readFileSync(path, 'utf8'); +const needle = ' markReady(db, owned.gen);'; +if (source.split(needle).length !== 2) throw new Error('ready insertion point must occur exactly once'); +const insert = ` + const { spawn } = await import("node:child_process"); + const reader = spawn(process.execPath, ["--input-type=module", "-e", \` + import { DatabaseSync } from "node:sqlite"; + const db = new DatabaseSync(process.argv[1], { readOnly: true }); + db.exec("BEGIN; SELECT state FROM daemon_owner;"); + console.log("locked"); + setTimeout(() => { db.exec("ROLLBACK"); db.close(); }, 300); + \`, installRoot + "/run/install.db"], { stdio: ["ignore", "pipe", "inherit"] }); + await new Promise((resolve, reject) => { + reader.stdout.once("data", resolve); + reader.once("error", reject); + reader.once("exit", (code) => reject(new Error("reader exited before lock: " + code))); + }); +`; +writeFileSync(path, source.replace(needle, insert + needle)); +NODE + _write_completion_record + local daemon_log="$TEST_SKILL_DIR/run/contention.log" + node "$SCRIPTS/daemon/agmsgd" "$TEST_SKILL_DIR" on 0 > "$daemon_log" 2>&1 & + local daemon_pid=$! state="" waited=0 + while [ "$waited" -lt 100 ]; do + state="$(sqlite3 -cmd '.timeout 5000' "$TEST_SKILL_DIR/run/install.db" 'SELECT state FROM daemon_owner;' 2>/dev/null || true)" + [ "$state" = ready ] && break + kill -0 "$daemon_pid" 2>/dev/null || break + waited=$((waited + 1)) + sleep 0.05 + done + if [ "$state" != ready ]; then + cat "$daemon_log" >&2 + kill "$daemon_pid" 2>/dev/null || true + wait "$daemon_pid" 2>/dev/null || true + false + fi + run node "$SCRIPTS/daemon/status.mjs" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *"running"* ]] + bash "$SCRIPTS/daemon.sh" stop + wait "$daemon_pid" + [ "$(sqlite3 "$TEST_SKILL_DIR/run/install.db" 'SELECT state FROM daemon_owner;')" = none ] +} + @test "agmsgd does not claim ownership when an install starts after bootstrap unlock" { _inject_incomplete_marker_after_initial_unlock _write_completion_record From 2a1aa62fae175ac991f34ba228f4ec56188b7757 Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 1 Oct 2026 14:45:30 -0700 Subject: [PATCH 11/15] test: wait for lock holder acknowledgement in launcher fixture (#1547) The held-lock launcher test could fail before it exercised the launcher: its independent `BEGIN EXCLUSIVE` readiness probe could acquire the lock first, making the holder's own nonblocking `BEGIN` fail with `database is locked`. The holder then waited on its FIFO without owning the lock, while the polling loop eventually reached its unconditional cutoff. The launcher correctly classified the free lock and `.prev` manifest as an incomplete previous update. Wait for an acquisition acknowledgement from the holder itself instead. Enable SQLite bail-on-error so a failed `BEGIN` cannot produce a false acknowledgement, and bound the acknowledgement wait to five seconds. Release the holder before checking the launcher result, and assert exit 75, the update-in-progress diagnostic, and no failed-update attempt record. Production launcher behavior is unchanged. Validation: all eight launcher bats tests pass on macOS, and the held-lock case passes five additional runs. A forced probe-first reproduction produced `Runtime error near line 1: database is locked (5)` while the holder continued without owning the lock. Local verification used a scratch helper copy without HOME reassignment or scratch directory deletion. `git diff --check` passes. --- tests/test_agmsgd_launch.bats | 39 +++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/test_agmsgd_launch.bats b/tests/test_agmsgd_launch.bats index 1cb52918b..26170ca3d 100644 --- a/tests/test_agmsgd_launch.bats +++ b/tests/test_agmsgd_launch.bats @@ -59,27 +59,34 @@ setup_install_db() { # the BEGIN EXCLUSIVE, is what actually holds it across commands, the # way a real in-progress install.sh would. local fifo="$TEST_SKILL_DIR/run/holder.fifo" - mkfifo "$fifo" - sqlite3 "$TEST_SKILL_DIR/run/install-op.lock.db" < "$fifo" & + local ack_fifo="$TEST_SKILL_DIR/run/holder-ack.fifo" + mkfifo "$fifo" "$ack_fifo" + sqlite3 -batch "$TEST_SKILL_DIR/run/install-op.lock.db" < "$fifo" > "$ack_fifo" & local holder_pid=$! exec 8> "$fifo" - echo "BEGIN EXCLUSIVE;" >&8 - # No fixed sleep budget: poll until the lock is actually held (a second, - # independent probe blocks), so this test does not race the holder's - # own startup time. - local waited=0 - while sqlite3 -cmd "PRAGMA busy_timeout=0;" "$TEST_SKILL_DIR/run/install-op.lock.db" "BEGIN EXCLUSIVE; ROLLBACK;" >/dev/null 2>&1; do - waited=$((waited + 1)) - [ "$waited" -lt 100 ] || break - sleep 0.05 - done + exec 9< "$ack_fifo" + # A second BEGIN EXCLUSIVE probe can win the race and make the holder's + # own BEGIN fail. Ask the holder itself to acknowledge acquisition, and + # bail on SQL errors so a failed BEGIN cannot print a false success. + printf '%s\n' '.bail on' 'BEGIN EXCLUSIVE;' '.print LOCKED' >&8 + local acquired="" + if ! IFS= read -r -t 5 acquired <&9 || [ "$acquired" != LOCKED ]; then + exec 8>&- + exec 9<&- + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + echo 'lock holder did not acknowledge acquisition' >&2 + false + fi run bash "$LAUNCH" - [ "$status" -eq 75 ] - - echo "ROLLBACK;" >&8 + printf '%s\n' 'ROLLBACK;' '.quit' >&8 exec 8>&- - wait "$holder_pid" 2>/dev/null || true + exec 9<&- + wait "$holder_pid" + [ "$status" -eq 75 ] + printf '%s\n' "$output" | grep -Fq 'an install/uninstall is in progress' + [ "$(sqlite3 "$SKILLDIR_INSTALL_DB" 'SELECT count(*) FROM daemon_start_attempts;')" -eq 0 ] } @test "agmsgd-launch: a crashed update (.prev present, lock free) -> exits 0, records a start attempt" { From d01c32971ff7b4e1c76bbee52d8eb211968f0d2e Mon Sep 17 00:00:00 2001 From: fujibee Date: Thu, 1 Oct 2026 16:34:37 -0700 Subject: [PATCH 12/15] feat: allow the measured Windows Codex queue release (#1548) ## Summary Permit new Windows queue operations only when a native codex.exe resolves and that executable reports codex-cli 0.157.0. This release has measured evidence that native queue creates no descendants and that its notice reaches a logged-in live conversation. Missing executables, unreadable version output, and every unlisted version remain blocked with a reason. The version probe has a two-second timeout and bounded output. Queue launches use the resolved native path directly, including the measured npm global installation layout, rather than its JavaScript or shell shim. Apply the same gate while reconciling pending Windows operations, preserving confirmation and expiry observations even when a changed or unavailable executable blocks new delivery. Add an anonymized, measured Windows response_item fixture from 2026-10-01, preserving field types and positions, including metadata.client_authored=false and metadata.user_input_order. One regression verifies that the fixture's user input nonce is observed as present. ## Validation - `node --test tests/agmsgd_codex_queue.test.mjs`: 9/9 PASS. - `bats tests/test_agmsgd_codex_queue.bats`: 1/1 PASS (runs the Node support suite). - `git diff --check`: PASS. - Tests cover executable resolution, the measured npm layout, unlisted and unreadable versions, native executable forwarding, pending-to-confirmed Windows delivery, and preventing duplicate nudges for a confirmed snapshot. Validation ran on macOS with disposable profiles and mocked native version/queue processes. It did not launch a live Windows service or modify a user queue. The measured rollout fixture and native path layout were supplied from the existing Windows live-delivery measurement. Targets integration/agmsgd-beta. Merge is handled by the integration coordinator. --- scripts/daemon/channels/codex-queue.mjs | 30 ++++--- .../daemon/channels/windows-codex-queue.mjs | 55 ++++++++++++ tests/agmsgd_codex_queue.test.mjs | 83 ++++++++++++++++++- ...codex-windows-0.157.0-queue-response.jsonl | 1 + 4 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 scripts/daemon/channels/windows-codex-queue.mjs create mode 100644 tests/fixtures/codex-windows-0.157.0-queue-response.jsonl diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index 569fb7c7a..2e47700ea 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -26,6 +26,7 @@ import { } from "./codex-queue-io.mjs"; import { captureProcessGroup, observeProcessGroup } from "./process-group.mjs"; import { processStartWitness } from "./process-group.mjs"; +import { checkWindowsCodexQueueGate } from "./windows-codex-queue.mjs"; const QUEUE_TIMEOUT_MS = 10_000; @@ -278,6 +279,7 @@ export function createCodexQueueChannel({ captureGroup = captureProcessGroup, observeGroup = observeProcessGroup, hostPlatform = process.platform, + windowsGate = checkWindowsCodexQueueGate, }) { let stopped = false; let activeController = null; @@ -299,13 +301,16 @@ export function createCodexQueueChannel({ return; } if (hostPlatform === "win32") { - if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); - else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); - const reason = observation.state === "pending" - ? `windows_live_delivery_unverified;pending:${observation.reason}` - : "windows_live_delivery_unverified"; - saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "blocked", reason }, now); - return; + const gate = windowsGate({ executable, env }); + if (gate.state !== "ok") { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + const reason = observation.state === "pending" + ? `${gate.reason};pending:${observation.reason}` + : gate.reason; + saveSeat(db, { seat, thread: pending.thread, codexHome: pending.codex_home, state: "blocked", reason }, now); + return; + } } if (observation.state === "confirmed") { setQueueState(db, pending.id, "confirmed"); @@ -349,9 +354,14 @@ export function createCodexQueueChannel({ saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "role_session_type_mismatch" }, now); return; } + let queueExecutable = executable; if (hostPlatform === "win32") { - saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: "windows_live_delivery_unverified" }, now); - return; + const gate = windowsGate({ executable, env }); + if (gate.state !== "ok") { + saveSeat(db, { seat, thread: record.thread, codexHome: record.codex_home, state: "blocked", reason: gate.reason }, now); + return; + } + queueExecutable = gate.executable; } let driver; try { @@ -426,7 +436,7 @@ export function createCodexQueueChannel({ let result; try { result = await queue({ - executable, + executable: queueExecutable, codexHome: record.codex_home, thread: record.thread, message: inboxNudge(nonce), diff --git a/scripts/daemon/channels/windows-codex-queue.mjs b/scripts/daemon/channels/windows-codex-queue.mjs new file mode 100644 index 000000000..1e264ba51 --- /dev/null +++ b/scripts/daemon/channels/windows-codex-queue.mjs @@ -0,0 +1,55 @@ +// Each listed release was measured on Windows: native queue spawns no +// children, and its nonce reaches a logged-in live conversation's rollout. +import { execFileSync } from "node:child_process"; +import { statSync } from "node:fs"; +import { basename, isAbsolute, join, resolve } from "node:path"; + +// Native-child measurement: 2026-09-28; live delivery/rollout: 2026-10-01. +const VERIFIED_WINDOWS_QUEUE_VERSIONS = new Set(["0.157.0"]); + +export function resolveWindowsCodexExe(executable, env = process.env, arch = process.arch) { + if (typeof executable !== "string" || !executable) return null; + const pathEntry = Object.entries(env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? ""; + const candidates = []; + if (isAbsolute(executable)) { + candidates.push(executable); + } else if (executable === "codex" || executable.toLowerCase() === "codex.exe") { + for (const dir of pathEntry.split(";").filter(Boolean)) { + candidates.push(join(dir, "codex.exe")); + const target = { x64: "x86_64-pc-windows-msvc", arm64: "aarch64-pc-windows-msvc" }[arch]; + if (target) { + // npm's PATH entry is a JS/shell shim. Resolve its native package + // directly; never queue through that shim and its extra processes. + const packageDir = join(dir, "node_modules", "@openai", "codex", "node_modules", "@openai", `codex-win32-${arch}`); + candidates.push(join(packageDir, "vendor", target, "bin", "codex.exe")); + } + } + } + for (const candidate of candidates) { + if (basename(candidate).toLowerCase() !== "codex.exe") continue; + try { + if (statSync(candidate).isFile()) return resolve(candidate); + } catch { /* A missing candidate is not a resolved native executable. */ } + } + return null; +} + +export function checkWindowsCodexQueueGate({ executable, env = process.env, resolveExe = resolveWindowsCodexExe, probe = execFileSync }) { + const nativeExe = resolveExe(executable, env); + if (!nativeExe) return { state: "blocked", reason: "windows_codex_executable_unresolved" }; + let output; + try { + output = probe(nativeExe, ["--version"], { + env, encoding: "utf8", windowsHide: true, timeout: 2000, maxBuffer: 16 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return { state: "blocked", reason: "windows_codex_version_unreadable" }; + } + const version = /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(output)?.[1]; + if (!version) return { state: "blocked", reason: "windows_codex_version_unreadable" }; + if (!VERIFIED_WINDOWS_QUEUE_VERSIONS.has(version)) { + return { state: "blocked", reason: `windows_codex_version_unverified:${version}` }; + } + return { state: "ok", executable: nativeExe, version }; +} diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 99ceafbec..1add2df27 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; @@ -31,6 +31,7 @@ import { createCodexQueueChannel } from '../scripts/daemon/channels/codex-queue. import { openInstallDb } from '../scripts/daemon/db.mjs'; import { readOwnerAndIntentReadOnly } from '../scripts/daemon/status.mjs'; import { processStartWitness } from '../scripts/daemon/channels/process-group.mjs'; +import { checkWindowsCodexQueueGate, resolveWindowsCodexExe } from '../scripts/daemon/channels/windows-codex-queue.mjs'; const thread = '01a0ea97-c011-73f3-8470-fd59db15adba'; const itemId = '01a0ea98-2235-7f02-b477-7c130e602fcd'; @@ -144,6 +145,16 @@ test('queue DB and rollout observations distinguish positive, absent, and unread assert.equal((await readRolloutNonce(home, thread, nonce)).state, 'unreadable'); }); +test('measured Windows 0.157.0 queued user response proves the rollout nonce is present', async (t) => { + const home = temporaryDirectory(t); + const sessions = path.join(home, 'sessions'); + mkdirSync(sessions); + // Anonymized measured row; field positions and types are preserved. + const row = readFileSync(new URL('./fixtures/codex-windows-0.157.0-queue-response.jsonl', import.meta.url), 'utf8'); + writeFileSync(path.join(sessions, `rollout-fixture-${thread}.jsonl`), JSON.stringify({ type: 'session_meta', payload: { id: thread } }) + '\n' + row); + assert.deepEqual(await readRolloutNonce(home, thread, 'NONCE'), { state: 'present' }); +}); + test('pending delivery confirms on either positive witness, expires only on two readable absences, and otherwise stays pending', () => { assert.deepEqual(resolvePendingQueue({ queueObservation: { state: 'present' }, @@ -207,6 +218,37 @@ test('seat classification never treats an uncertain destination or bridge as add assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped' }), { state: 'blocked', reason: 'thread_observation_failed' }); }); +test('Windows queue requires a native executable and an exactly measured release', (t) => { + const root = temporaryDirectory(t); + const nativeExe = path.join(root, 'codex.exe'); + writeFileSync(nativeExe, 'fixture, never executed'); + const env = { PATH: root }; + assert.equal(resolveWindowsCodexExe('codex', env), nativeExe); + assert.equal(resolveWindowsCodexExe(nativeExe, env), nativeExe); + assert.equal(resolveWindowsCodexExe(path.join(root, 'codex.cmd'), env), null); + assert.equal(resolveWindowsCodexExe('codex', { PATH: path.join(root, 'missing') }), null); + const npmRoot = path.join(root, 'npm'); + const vendor = path.join(npmRoot, 'node_modules', '@openai', 'codex', 'node_modules', '@openai', 'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin'); + mkdirSync(vendor, { recursive: true }); + writeFileSync(path.join(npmRoot, 'codex.cmd'), 'fixture shim, never executed'); + writeFileSync(path.join(vendor, 'codex.exe'), 'fixture native binary, never executed'); + assert.equal(resolveWindowsCodexExe('codex', { Path: npmRoot }, 'x64'), path.join(vendor, 'codex.exe')); + const probe = (file, args, options) => { + assert.equal(file, nativeExe); + assert.deepEqual(args, ['--version']); + assert.equal(options.timeout, 2000); + return 'codex-cli 0.157.0\n'; + }; + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'ok', executable: nativeExe, version: '0.157.0' }); + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env: {} }), { state: 'blocked', reason: 'windows_codex_executable_unresolved' }); + for (const version of ['0.156.0', '0.158.0']) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'blocked', reason: `windows_codex_version_unverified:${version}` }); + } + for (const probe of [() => 'codex-cli 0.157.0-beta\n', () => { throw new Error('timeout'); }]) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'blocked', reason: 'windows_codex_version_unreadable' }); + } +}); + test('store discovery is fail-closed and unread snapshots advance both event and legacy cursors', async (t) => { const root = temporaryDirectory(t); const teamsDir = path.join(root, 'teams'); @@ -344,8 +386,11 @@ test('Codex channel queues one unread snapshot and confirms it before advancing expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir }, hostPlatform: 'win32', + windowsGate: () => ({ state: 'blocked', reason: 'windows_codex_version_unverified:0.158.0' }), queue: async () => { throw new Error('Windows queue must stay closed until live delivery is verified'); }, }); + await windowsChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).reason, 'windows_codex_version_unverified:0.158.0'); installDb.prepare(` INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) @@ -353,7 +398,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const windowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(windowsSeat.state, 'blocked'); - assert.equal(windowsSeat.reason, 'windows_live_delivery_unverified;pending:awaiting_confirmation'); + assert.equal(windowsSeat.reason, 'windows_codex_version_unverified:0.158.0;pending:awaiting_confirmation'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); const windowsQueueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); windowsQueueDb.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(notYetVisibleItemId, thread, '{}'); @@ -361,10 +406,42 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const confirmedWindowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(confirmedWindowsSeat.state, 'blocked'); - assert.equal(confirmedWindowsSeat.reason, 'windows_live_delivery_unverified'); + assert.equal(confirmedWindowsSeat.reason, 'windows_codex_version_unverified:0.158.0'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); await windowsChannel.stop(); + const nextMessageDb = new DatabaseSync(path.join(storageDir, 'messages.db')); + nextMessageDb.prepare('INSERT INTO events VALUES (2, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run('message_sent', 'event-2', 'alpha', 'sender', 'alice', 'next unread message', null, null, '2026-10-01T00:00:00Z', null); + nextMessageDb.close(); + const nativeExe = path.join(root, 'codex.exe'); + writeFileSync(nativeExe, 'native fixture, never executed'); + let windowsQueues = 0; + let windowsChildState = 'present'; + const verifiedWindowsChannel = createCodexQueueChannel({ + db: installDb, installRoot: root, expectedOpGen: 4, hostPlatform: 'win32', + env: { AGMSG_STORAGE_PATH: storageDir, PATH: root }, + windowsGate: (options) => checkWindowsCodexQueueGate({ ...options, probe: () => 'codex-cli 0.157.0\n' }), + captureGroup: (pid) => ({ state: 'complete', kind: 'windows-native-process', pgid: pid, boot_id: 'test-boot', witness: 'test-start' }), + observeGroup: () => ({ state: windowsChildState }), + queue: async (request) => { + windowsQueues += 1; + assert.equal(request.executable, nativeExe, 'queue bypasses the npm shim'); + request.onChildStart(4242); + return { kind: 'queued', queueItemId: itemId }; + }, + }); + await verifiedWindowsChannel.pollOnce(); + assert.equal(windowsQueues, 1); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).state, 'addressable'); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); + windowsChildState = 'absent'; + await verifiedWindowsChannel.pollOnce(); + assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); + await verifiedWindowsChannel.pollOnce(); + assert.equal(windowsQueues, 1, 'a confirmed Windows snapshot is not requeued'); + await verifiedWindowsChannel.stop(); + writeFileSync(path.join(teamsDir, 'alpha', 'config.json'), JSON.stringify({ agents: {} })); const missingSeatChannel = createCodexQueueChannel({ db: installDb, installRoot: root, expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir } }); await missingSeatChannel.pollOnce(); diff --git a/tests/fixtures/codex-windows-0.157.0-queue-response.jsonl b/tests/fixtures/codex-windows-0.157.0-queue-response.jsonl new file mode 100644 index 000000000..7e0a146e0 --- /dev/null +++ b/tests/fixtures/codex-windows-0.157.0-queue-response.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-10-01T22:04:30.973Z","ordinal":21,"type":"response_item","payload":{"type":"message","id":"msg_00000000-0000-0000-0000-000000000001","role":"user","content":[{"type":"input_text","text":"[agmsg:NONCE] New messages are waiting. Check your agmsg inbox."}],"internal_chat_message_metadata_passthrough":{"turn_id":"00000000-0000-0000-0000-000000000002","create_time":1790892270.9732182,"content_item_kinds":["user.text"]}},"metadata":{"client_authored":false,"user_input_order":2,"mcp_attribution":{"status":"none"}}} From 6581135db79def6b69073a86133701a3e56a54e5 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 5 Oct 2026 00:19:22 -0700 Subject: [PATCH 13/15] Allow Windows Codex queue from the measured minimum release (#1573) --- README.md | 2 +- .../daemon/channels/windows-codex-queue.mjs | 16 ++++++----- tests/agmsgd_codex_queue.test.mjs | 27 ++++++++++++++----- ...codex-windows-0.160.0-queue-response.jsonl | 1 + 4 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/codex-windows-0.160.0-queue-response.jsonl diff --git a/README.md b/README.md index 062dead97..19fde30c8 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Git Bash PATH). There is no PowerShell reimplementation. ### agmsgd beta (1.6.0) -The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS and Linux are supported. Windows delivery is unsupported until it has been measured on a real Windows session. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. +The optional agmsgd beta sends Codex sessions a notice to check their inbox through the Codex queue. It is off by default. It reads the existing SQLite message store; it does not migrate data, deliver message bodies, replace other agents' monitors, or take over remote sync. macOS, Linux, and Windows are supported. Windows requires native Codex 0.157.0 or later; earlier versions are blocked with a reason. Native queue execution and delivery to a logged-in live Windows session were measured with Codex 0.157.0 and 0.160.0. Unreadable version or delivery evidence keeps the existing conservative handling. JSONL storage is unsupported by this beta; keep using the bridge for those sessions. Requires Node >= 22.13.0 with `node:sqlite` (the experimental SQLite warning is expected). Install Node from [nodejs.org](https://nodejs.org/en/download). Enable and inspect the beta with: diff --git a/scripts/daemon/channels/windows-codex-queue.mjs b/scripts/daemon/channels/windows-codex-queue.mjs index 1e264ba51..c29543c66 100644 --- a/scripts/daemon/channels/windows-codex-queue.mjs +++ b/scripts/daemon/channels/windows-codex-queue.mjs @@ -1,11 +1,12 @@ -// Each listed release was measured on Windows: native queue spawns no -// children, and its nonce reaches a logged-in live conversation's rollout. +// Windows native queue compatibility has been measured at the minimum +// supported release and at 0.160.0; later stable releases use the same policy. import { execFileSync } from "node:child_process"; import { statSync } from "node:fs"; import { basename, isAbsolute, join, resolve } from "node:path"; -// Native-child measurement: 2026-09-28; live delivery/rollout: 2026-10-01. -const VERIFIED_WINDOWS_QUEUE_VERSIONS = new Set(["0.157.0"]); +// 0.157.0: native-child measurement 2026-09-28, live rollout 2026-10-01. +// 0.160.0: native-child observations and live rollout 2026-10-05. +const MINIMUM_WINDOWS_QUEUE_VERSION = [0, 157, 0]; export function resolveWindowsCodexExe(executable, env = process.env, arch = process.arch) { if (typeof executable !== "string" || !executable) return null; @@ -48,8 +49,11 @@ export function checkWindowsCodexQueueGate({ executable, env = process.env, reso } const version = /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(output)?.[1]; if (!version) return { state: "blocked", reason: "windows_codex_version_unreadable" }; - if (!VERIFIED_WINDOWS_QUEUE_VERSIONS.has(version)) { - return { state: "blocked", reason: `windows_codex_version_unverified:${version}` }; + const parts = version.split(".").map(BigInt); + const minimum = MINIMUM_WINDOWS_QUEUE_VERSION.map(BigInt); + const firstDifference = parts.findIndex((part, index) => part !== minimum[index]); + if (firstDifference !== -1 && parts[firstDifference] < minimum[firstDifference]) { + return { state: "blocked", reason: `windows_codex_version_below_minimum:${version}` }; } return { state: "ok", executable: nativeExe, version }; } diff --git a/tests/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs index 1add2df27..6327b7892 100644 --- a/tests/agmsgd_codex_queue.test.mjs +++ b/tests/agmsgd_codex_queue.test.mjs @@ -155,6 +155,16 @@ test('measured Windows 0.157.0 queued user response proves the rollout nonce is assert.deepEqual(await readRolloutNonce(home, thread, 'NONCE'), { state: 'present' }); }); +test('measured Windows 0.160.0 queued user response proves the rollout nonce is present', async (t) => { + const home = temporaryDirectory(t); + const sessions = path.join(home, 'sessions'); + mkdirSync(sessions); + // Anonymized measured row; field positions and types are preserved. + const row = readFileSync(new URL('./fixtures/codex-windows-0.160.0-queue-response.jsonl', import.meta.url), 'utf8'); + writeFileSync(path.join(sessions, `rollout-fixture-${thread}.jsonl`), JSON.stringify({ type: 'session_meta', payload: { id: thread } }) + '\n' + row); + assert.deepEqual(await readRolloutNonce(home, thread, 'NONCE'), { state: 'present' }); +}); + test('pending delivery confirms on either positive witness, expires only on two readable absences, and otherwise stays pending', () => { assert.deepEqual(resolvePendingQueue({ queueObservation: { state: 'present' }, @@ -218,7 +228,7 @@ test('seat classification never treats an uncertain destination or bridge as add assert.deepEqual(classifyCodexSeat({ roleSession: record, bridgeState: 'stopped' }), { state: 'blocked', reason: 'thread_observation_failed' }); }); -test('Windows queue requires a native executable and an exactly measured release', (t) => { +test('Windows queue requires a native executable and a stable release at or above the measured minimum', (t) => { const root = temporaryDirectory(t); const nativeExe = path.join(root, 'codex.exe'); writeFileSync(nativeExe, 'fixture, never executed'); @@ -241,8 +251,11 @@ test('Windows queue requires a native executable and an exactly measured release }; assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'ok', executable: nativeExe, version: '0.157.0' }); assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env: {} }), { state: 'blocked', reason: 'windows_codex_executable_unresolved' }); - for (const version of ['0.156.0', '0.158.0']) { - assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'blocked', reason: `windows_codex_version_unverified:${version}` }); + for (const version of ['0.0.999', '0.156.999']) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'blocked', reason: `windows_codex_version_below_minimum:${version}` }); + } + for (const version of ['0.157.0', '0.158.0', '0.160.0', '0.1000.0', '1.0.0']) { + assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe: () => `codex-cli ${version}\n` }), { state: 'ok', executable: nativeExe, version }); } for (const probe of [() => 'codex-cli 0.157.0-beta\n', () => { throw new Error('timeout'); }]) { assert.deepEqual(checkWindowsCodexQueueGate({ executable: 'codex', env, probe }), { state: 'blocked', reason: 'windows_codex_version_unreadable' }); @@ -386,11 +399,11 @@ test('Codex channel queues one unread snapshot and confirms it before advancing expectedOpGen: 4, env: { AGMSG_STORAGE_PATH: storageDir }, hostPlatform: 'win32', - windowsGate: () => ({ state: 'blocked', reason: 'windows_codex_version_unverified:0.158.0' }), + windowsGate: () => ({ state: 'blocked', reason: 'windows_codex_version_below_minimum:0.156.0' }), queue: async () => { throw new Error('Windows queue must stay closed until live delivery is verified'); }, }); await windowsChannel.pollOnce(); - assert.equal(installDb.prepare("SELECT reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).reason, 'windows_codex_version_unverified:0.158.0'); + assert.equal(installDb.prepare("SELECT reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])).reason, 'windows_codex_version_below_minimum:0.156.0'); installDb.prepare(` INSERT INTO beta_codex_queue (seat, codex_home, thread, up_to, nonce, queue_item_id, state, children, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?) @@ -398,7 +411,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const windowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(windowsSeat.state, 'blocked'); - assert.equal(windowsSeat.reason, 'windows_codex_version_unverified:0.158.0;pending:awaiting_confirmation'); + assert.equal(windowsSeat.reason, 'windows_codex_version_below_minimum:0.156.0;pending:awaiting_confirmation'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'pending'); const windowsQueueDb = new DatabaseSync(path.join(codexHome, 'queue_1.sqlite')); windowsQueueDb.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run(notYetVisibleItemId, thread, '{}'); @@ -406,7 +419,7 @@ test('Codex channel queues one unread snapshot and confirms it before advancing await windowsChannel.pollOnce(); const confirmedWindowsSeat = installDb.prepare("SELECT state, reason FROM beta_codex_seat WHERE seat = ?").get(JSON.stringify(['alpha', 'alice'])); assert.equal(confirmedWindowsSeat.state, 'blocked'); - assert.equal(confirmedWindowsSeat.reason, 'windows_codex_version_unverified:0.158.0'); + assert.equal(confirmedWindowsSeat.reason, 'windows_codex_version_below_minimum:0.156.0'); assert.equal(installDb.prepare("SELECT state FROM beta_codex_queue WHERE seat = ? ORDER BY id DESC LIMIT 1").get(JSON.stringify(['alpha', 'alice'])).state, 'confirmed'); await windowsChannel.stop(); diff --git a/tests/fixtures/codex-windows-0.160.0-queue-response.jsonl b/tests/fixtures/codex-windows-0.160.0-queue-response.jsonl new file mode 100644 index 000000000..74353b89b --- /dev/null +++ b/tests/fixtures/codex-windows-0.160.0-queue-response.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-10-05T06:43:19.582Z","ordinal":29,"type":"response_item","payload":{"type":"message","id":"msg_00000000-0000-0000-0000-000000000001","role":"user","content":[{"type":"input_text","text":"[agmsg:NONCE] New messages are waiting. Check your agmsg inbox."}],"internal_chat_message_metadata_passthrough":{"turn_id":"00000000-0000-0000-0000-000000000002","create_time":1791182599.5823665,"content_item_kinds":["user.text"]}},"metadata":{"retained_source":{"id":{"message_id":"msg_00000000-0000-0000-0000-000000000001","turn_id":"00000000-0000-0000-0000-000000000002","role":"user"},"revision":"retained_00000000-0000-0000-0000-000000000003","complete":true},"client_authored":false,"user_input_order":4,"mcp_attribution":{"status":"none"}}} From 81433b8f589108c9b2a4e0e780eea473e4108ac3 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 5 Oct 2026 03:48:53 -0700 Subject: [PATCH 14/15] fix: skip shared launcher for custom command installs (#1578) --- install.sh | 4 ++-- scripts/lib/agmsg-launcher.sh | 11 +++++++++-- tests/test_agmsg_command.bats | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index f31b3cd2d..e6e986098 100755 --- a/install.sh +++ b/install.sh @@ -1179,7 +1179,7 @@ $_agmsg_running_team" fi install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 - agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 + agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" "$CMD_NAME" || exit 1 agmsg_install_op_run_phase agmsg_install_write_version || exit 1 echo " + updated scripts, templates, and SKILL.md (version $INSTALLED_VERSION)" echo " ~ DB and team configs preserved" @@ -1315,7 +1315,7 @@ install_windows_helpers || exit 1 agmsg_install_op_phase_end || exit 1 # The `agmsg` command launcher (never touches shell rc files or PATH). -agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" || exit 1 +agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" "$CMD_NAME" || exit 1 # Marker file for uninstall detection and source provenance version are one # protected phase so neither write can occur after a lost-lock boundary. diff --git a/scripts/lib/agmsg-launcher.sh b/scripts/lib/agmsg-launcher.sh index d5d81a817..180690409 100644 --- a/scripts/lib/agmsg-launcher.sh +++ b/scripts/lib/agmsg-launcher.sh @@ -60,13 +60,20 @@ agmsg_launcher_classify() { fi } -# Places the launcher for the install at $1 and prints a three-part report: +# Places the launcher for the install at $1 and command name $2 (default +# agmsg for existing callers). Custom skill names do not claim the shared +# shell command. Prints a three-part report: # what was placed, whether this process can see it, and what to check on a # real terminal. Always returns 0: not placing a launcher is never an install # failure. agmsg_launcher_install() { - local skill_dir="$1" target tmp resolved old + local skill_dir="$1" cmd_name="${2:-agmsg}" target tmp resolved old AGMSG_LAUNCHER_REASON="" + if [ "$cmd_name" != agmsg ]; then + echo " ~ agmsg command: not placed (custom command name: $cmd_name; shared launcher is only for --cmd agmsg)" + printf ' use this installation directly: bash %q daemon status\n' "$skill_dir/scripts/agmsg" + return 0 + fi if ! agmsg_launcher_pick_dir; then echo " ~ agmsg command: not placed ($AGMSG_LAUNCHER_REASON)" return 0 diff --git a/tests/test_agmsg_command.bats b/tests/test_agmsg_command.bats index f7ff1144d..b372e8b37 100644 --- a/tests/test_agmsg_command.bats +++ b/tests/test_agmsg_command.bats @@ -64,6 +64,16 @@ teardown() { [ -d "$BIN_DIR" ] } +@test "launcher: a custom command does not claim an empty shared bin directory" { + run bash -c 'source "$1"; agmsg_launcher_install "$2" agmsg-e2e' _ "$LIB" "$TEST_SKILL_DIR" + [ "$status" -eq 0 ] + grep -Fq -- 'custom command name: agmsg-e2e' <<<"$output" + grep -Fq -- "$TEST_SKILL_DIR/scripts/agmsg" <<<"$output" + [ ! -e "$BIN_DIR/agmsg" ] + [ ! -e "$BIN_DIR/agmsg-e2e" ] + [ ! -e "$TEST_SKILL_DIR/run/agmsg-launcher.path" ] +} + # The four kinds of thing already sitting at the target: nothing is broken, # nothing is followed, and the message says why. @test "launcher: a valid symlink, a broken symlink, a directory and a foreign file are all left alone" { From 5cd0f6de48c9fbeb12baab28d1f5a71420dc4ac1 Mon Sep 17 00:00:00 2001 From: fujibee Date: Mon, 5 Oct 2026 03:51:14 -0700 Subject: [PATCH 15/15] feat(agmsgd): deliver inbox notices to Claude Code seats through the session socket (#1577) agmsgd can now deliver the inbox notice to Claude Code seats, through the session's own cross-session messaging socket (CLAUDE_CODE_MESSAGING_SOCKET), instead of the Monitor watch. macOS and Linux only; Windows seats keep the Monitor watch. - SessionStart records messaging_socket, claude_config_dir and session in the role-session record, only for the pair this session resumes or acts as, and only after the instance id (sid.pid) matches the actas lock owner exactly. The record is rewritten on every start, since --resume binds a new socket and /clear starts a new session id. - SessionStart skips the Monitor directive only when the daemon is ready, the platform is not Windows, and the record was written and read back. In every other case it emits the Monitor directive as before. A resume re-claims the lock through actas_lock_claim, which never takes a live owner's lock. - The daemon checks the socket's owner uid and type before sending one `[agmsg:]` notice line, then confirms delivery only from the session transcript's user line carrying the nonce. A held banner is recorded as held. A missing transcript expires after 60 seconds with its reason; a transient read failure stays pending. - `agmsg daemon status` lists claude_code_seats. --- .../daemon/channels/claude-code-queue-io.mjs | 198 +++++++++++ .../channels/claude-code-queue-store.mjs | 122 +++++++ scripts/daemon/channels/claude-code-queue.mjs | 317 ++++++++++++++++++ scripts/daemon/channels/codex-queue.mjs | 4 +- scripts/daemon/main.mjs | 4 + scripts/daemon/status.mjs | 18 +- .../types/claude-code/_session-start.sh | 120 +++++++ scripts/lib/role-session.sh | 49 +++ tests/agmsgd_claude_code_queue.test.mjs | 81 +++++ tests/test_role_session.bats | 29 ++ ...sion_start_claude_code_native_channel.bats | 195 +++++++++++ 11 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 scripts/daemon/channels/claude-code-queue-io.mjs create mode 100644 scripts/daemon/channels/claude-code-queue-store.mjs create mode 100644 scripts/daemon/channels/claude-code-queue.mjs create mode 100644 scripts/drivers/types/claude-code/_session-start.sh create mode 100644 tests/agmsgd_claude_code_queue.test.mjs create mode 100644 tests/test_session_start_claude_code_native_channel.bats diff --git a/scripts/daemon/channels/claude-code-queue-io.mjs b/scripts/daemon/channels/claude-code-queue-io.mjs new file mode 100644 index 000000000..d7c446b3d --- /dev/null +++ b/scripts/daemon/channels/claude-code-queue-io.mjs @@ -0,0 +1,198 @@ +// Claude Code native-channel I/O: writes one line to a seat's own +// cross-session-messaging socket (https://code.claude.com/docs/en/cross-session-messaging) +// and verifies delivery by reading the seat's own transcript file. Measured +// 2026-10-05, memory/design/2026-10-05-cross-session-messaging-socket-measurement.md +// (and its addendum) and memory/design/2026-09-22-agmsgd-arch-8-delivery-driver.md +// §12/§12.1 — this file's shape follows directly from those measurements, not +// from the (partly inaccurate, per the same memo) public docs alone. +// +// This file knows only the Claude Code peer-socket wire format and transcript +// layout; the daemon loop and its install.db schema remain owned by the caller +// (claude-code-queue.mjs), matching codex-queue-io.mjs's split. + +import { randomUUID } from "node:crypto"; +import { createConnection } from "node:net"; +import { closeSync, lstatSync, openSync, readFileSync, readSync, statSync } from "node:fs"; +import process from "node:process"; +import { isAbsolute } from "node:path"; + +export { newNonce, inboxNudge, QUEUE_CONFIRMATION_TTL_MS } from "./codex-queue-io.mjs"; + +const SEND_TIMEOUT_MS = 5_000; +// Bound the transcript read to its tail: a long-lived seat's jsonl grows +// without limit, and the delivery record this channel looks for is always +// recent (written within the TTL window of a message this same daemon just +// sent). 512 KiB comfortably covers many turns' worth of lines. +const TRANSCRIPT_TAIL_BYTES = 512 * 1024; + +// Measured 2026-10-05: Claude Code encodes a project's cwd into its +// transcript directory name by replacing every "/" with "-" +// (e.g. /home/user/projects/example/sub -> -home-user-projects-example-sub). +// Only this simple case was measured; a cwd containing a literal "-" was not +// tested separately and is not known to collide in practice, so this is not +// claimed exhaustive. +export function encodeClaudeProjectDir(cwd) { + if (typeof cwd !== "string" || !cwd) return ""; + return cwd.replace(/\//g, "-"); +} + +export function resolveTranscriptPath(claudeConfigDir, project, sessionId) { + if (typeof claudeConfigDir !== "string" || !isAbsolute(claudeConfigDir)) return ""; + if (typeof project !== "string" || !isAbsolute(project)) return ""; + if (typeof sessionId !== "string" || !sessionId) return ""; + const encoded = encodeClaudeProjectDir(project); + if (!encoded) return ""; + return `${claudeConfigDir}/projects/${encoded}/${sessionId}.jsonl`; +} + +// Refuses to write unless the path is a Unix domain socket owned by this +// process's own OS user: a socket path read from an untrusted or stale +// record must never be written to on trust alone. A symlinked path is +// refused the same way every other agmsg state-file reader on this codebase +// refuses one. +function checkSocketOwnership(socketPath) { + let stat; + try { + stat = lstatSync(socketPath); + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "messaging_socket_missing" }; + return { state: "unreadable", reason: "messaging_socket_stat_failed" }; + } + if (stat.isSymbolicLink()) return { state: "unreadable", reason: "messaging_socket_symlink" }; + if (!stat.isSocket()) return { state: "unreadable", reason: "messaging_socket_not_a_socket" }; + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { + return { state: "unreadable", reason: "messaging_socket_not_owned_by_self" }; + } + return { state: "ok" }; +} + +// Sends the one-line JSON cross-session-message the socket accepts (measured +// shape). `from` is a fixed, non-socket string on purpose: it must not look +// like a reply address, since this is a one-way nudge, not a peer +// conversation. +export async function sendClaudeCodeMessage({ + socketPath, + nonce, + body, + timeoutMs = SEND_TIMEOUT_MS, + checkOwnership = checkSocketOwnership, + connect = createConnection, +}) { + const ownership = checkOwnership(socketPath); + if (ownership.state !== "ok") return { state: "failed", reason: ownership.reason }; + + const payload = { + msgV: 1, + msg_id: randomUUID(), + type: "user", + message: { + role: "user", + content: `\n${body}\n`, + }, + priority: "next", + from: "agmsgd", + }; + const line = `${JSON.stringify(payload)}\n`; + + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket.destroy(); } catch { /* best effort */ } + resolve(result); + }; + const timer = setTimeout(() => finish({ state: "failed", reason: "send_timeout" }), timeoutMs); + let socket; + try { + socket = connect(socketPath); + } catch { + clearTimeout(timer); + resolve({ state: "failed", reason: "connect_failed" }); + return; + } + socket.on("error", (error) => finish({ state: "failed", reason: `socket_error:${error.code ?? error.message}` })); + socket.on("connect", () => { + socket.end(line, () => finish({ state: "sent", nonce, msgId: payload.msg_id })); + }); + }); +} + +function readTail(path, maxBytes) { + const stat = statSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("not_regular_file"); + if (stat.size <= maxBytes) return readFileSync(path, "utf8"); + const buf = Buffer.alloc(maxBytes); + const fd = openSync(path, "r"); + try { + readSync(fd, buf, 0, maxBytes, stat.size - maxBytes); + } finally { + closeSync(fd); + } + return buf.toString("utf8"); +} + +// Reads the delivery outcome straight from the receiving session's own +// transcript. Measured line shapes (2026-10-05 addendum): +// - delivered: a `type:"user"` line with `origin.body` containing the +// nonce verbatim — the ONLY line this treats as proof of delivery. +// - held/declined: a `type:"system"`, `subtype:"informational"` line whose +// `content` (a possibly-truncated preview) contains the nonce. +// - a `type:"queue-operation"`, `operation:"enqueue"` line fires on mere +// receipt, held or not — deliberately never read as evidence here (G4: +// never mark a seat notified on evidence that could be a hold). +export function observeTranscript(transcriptPath, nonce) { + if (typeof nonce !== "string" || !nonce) return { state: "unreadable", reason: "nonce_missing" }; + let text; + try { + text = readTail(transcriptPath, TRANSCRIPT_TAIL_BYTES); + } catch (error) { + if (error?.code === "ENOENT") return { state: "unreadable", reason: "transcript_missing" }; + return { state: "unreadable", reason: "transcript_read_failed" }; + } + const marker = `[agmsg:${nonce}]`; + let heldSeen = false; + for (const line of text.split("\n")) { + if (!line || !line.includes(marker)) continue; + let row; + try { + row = JSON.parse(line); + } catch { + continue; + } + if (row?.type === "user" && typeof row?.origin?.body === "string" && row.origin.body.includes(marker)) { + return { state: "delivered" }; + } + if (row?.type === "system" && row?.subtype === "informational" && typeof row?.content === "string" && row.content.includes(marker)) { + heldSeen = true; + } + } + if (heldSeen) return { state: "held" }; + return { state: "absent" }; +} + +// Single-observation analogue of codex-queue-io.mjs's resolvePendingQueue: +// this channel has only the transcript to read, not a separate queue-item +// check, so "present" collapses to one case (delivered) instead of two. +export function resolvePendingDelivery({ observation, ageMs, ttlMs }) { + if (observation?.state === "delivered") return { state: "confirmed", reason: "nonce_observed" }; + const ageKnown = Number.isSafeInteger(ageMs) && ageMs >= 0; + if (observation?.state === "unreadable") { + // A transcript that is genuinely absent past the TTL is never coming + // back on its own (the session that would create it is gone, or this + // record's project/session no longer matches a live one) -- expire it + // with a reason that says so, rather than polling it forever. Any OTHER + // unreadable cause (a transient read failure, a malformed line) stays + // pending indefinitely: that one really could resolve on the next read, + // and timing it out would report a seat undeliverable that might not be. + if (observation.reason === "transcript_missing" && ageKnown && ageMs >= ttlMs) { + return { state: "expired", reason: "transcript_missing" }; + } + return { state: "pending", reason: observation.reason ?? "transcript_unreadable" }; + } + if (!ageKnown) return { state: "pending", reason: "pending_age_unreadable" }; + const reason = observation?.state === "held" ? "peer_held" : "awaiting_confirmation"; + if (ageMs >= ttlMs) return { state: "expired", reason: observation?.state === "held" ? "confirmation_timeout:peer_held" : "confirmation_timeout" }; + return { state: "pending", reason }; +} diff --git a/scripts/daemon/channels/claude-code-queue-store.mjs b/scripts/daemon/channels/claude-code-queue-store.mjs new file mode 100644 index 000000000..1e304ae32 --- /dev/null +++ b/scripts/daemon/channels/claude-code-queue-store.mjs @@ -0,0 +1,122 @@ +// Read-only view of agmsg's existing message stores plus the daemon's small +// Claude Code native-channel tables. The daemon never initializes or repairs +// a team store. Mirrors codex-queue-store.mjs's shape; the generic store +// helpers (message store path/open/snapshot, storage driver) are reused +// directly from there rather than duplicated. + +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { roleSessionPath } from "./codex-queue-store.mjs"; + +export const CLAUDE_CODE_CHANNEL_SCHEMA = ` +CREATE TABLE IF NOT EXISTS beta_claude_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seat TEXT NOT NULL, + messaging_socket TEXT NOT NULL, + transcript_path TEXT NOT NULL, + up_to TEXT NOT NULL, + nonce TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'confirmed', 'expired')), + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS beta_claude_queue_one_pending + ON beta_claude_queue(seat) WHERE state = 'pending'; +CREATE INDEX IF NOT EXISTS beta_claude_queue_latest + ON beta_claude_queue(seat, id DESC); +CREATE TABLE IF NOT EXISTS beta_claude_seat ( + seat TEXT PRIMARY KEY, + messaging_socket TEXT, + state TEXT NOT NULL CHECK (state IN ('addressable', 'unaddressable', 'blocked')), + reason TEXT NOT NULL, + checked_at TEXT NOT NULL +); +`; + +export function ensureClaudeCodeChannelSchema(db) { + db.exec("BEGIN IMMEDIATE;"); + try { + db.exec(CLAUDE_CODE_CHANNEL_SCHEMA); + db.exec("COMMIT;"); + } catch (error) { + try { db.exec("ROLLBACK;"); } catch { /* preserve the schema error */ } + throw error; + } +} + +// Same record file role-session.sh's agmsg_role_session_set_messaging writes +// messaging_socket/claude_config_dir into (#339 record, shared with Codex's +// codex_home field in the same file). A record missing either new field is +// reported present with an empty value — the caller treats that exactly like +// Codex's role_session_missing: no destination, not an error. +export function readClaudeRoleSession(runDir, team, agent) { + const path = roleSessionPath(runDir, team, agent); + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return { state: "absent" }; + return { state: "unreadable", reason: "role_session_read_failed" }; + } + if (!stat.isFile() || stat.isSymbolicLink()) return { state: "unreadable", reason: "role_session_not_regular_file" }; + try { + const fields = Object.create(null); + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const at = line.indexOf("="); + if (at < 1) continue; + const key = line.slice(0, at); + if (!(key in fields)) fields[key] = line.slice(at + 1); + } + return { + state: "present", + record: { + team: fields.team ?? "", + agent: fields.agent ?? "", + type: fields.type ?? "", + project: fields.project ?? "", + session: fields.session ?? "", + messaging_socket: fields.messaging_socket ?? "", + claude_config_dir: fields.claude_config_dir ?? "", + }, + }; + } catch { + return { state: "unreadable", reason: "role_session_read_failed" }; + } +} + +// Enumerates only roles whose current registration explicitly names +// claude-code. Mirrors listCodexRegistrations in codex-queue-store.mjs +// exactly, filtered on a different registration type — small enough that +// sharing it would cost more indirection than it saves. +export function listClaudeCodeRegistrations(teamsDir) { + let entries; + try { + entries = readdirSync(teamsDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return { state: "ok", seats: [] }; + return { state: "unreadable", reason: "team_roster_unreadable" }; + } + const seats = []; + for (const entry of entries) { + if (entry.isSymbolicLink()) return { state: "unreadable", reason: "team_roster_symlink" }; + if (!entry.isDirectory()) continue; + let config; + try { + const stat = lstatSync(join(teamsDir, entry.name, "config.json")); + if (!stat.isFile() || stat.isSymbolicLink()) return { state: "unreadable", reason: "team_roster_symlink" }; + config = JSON.parse(readFileSync(join(teamsDir, entry.name, "config.json"), "utf8")); + } catch (error) { + if (error?.code === "ENOENT") continue; + return { state: "unreadable", reason: "team_roster_malformed" }; + } + if (!config || typeof config !== "object" || !config.agents || typeof config.agents !== "object" || Array.isArray(config.agents)) { + return { state: "unreadable", reason: "team_roster_malformed" }; + } + for (const [agent, value] of Object.entries(config.agents)) { + const regs = value && Array.isArray(value.registrations) ? value.registrations : []; + if (regs.some((registration) => registration?.type === "claude-code")) { + seats.push({ team: entry.name, agent, teamConfig: config }); + } + } + } + return { state: "ok", seats }; +} diff --git a/scripts/daemon/channels/claude-code-queue.mjs b/scripts/daemon/channels/claude-code-queue.mjs new file mode 100644 index 000000000..0dc2f6640 --- /dev/null +++ b/scripts/daemon/channels/claude-code-queue.mjs @@ -0,0 +1,317 @@ +// Polls the existing message stores and nudges registered Claude Code seats +// through their own cross-session-messaging socket. It never writes message +// data or read cursors. Mirrors codex-queue.mjs's shape; see +// memory/design/2026-09-22-agmsgd-arch-8-delivery-driver.md §12/§12.1 and +// memory/design/2026-10-05-cross-session-messaging-socket-measurement.md for +// the measurements this channel's gates come from. +// +// Deliberately simpler than the Codex channel: there is no CLI child process +// to spawn and observe, no bridge, and (per plan, 2026-10-05) no Windows +// support yet — a named-pipe + mandatory-auth-line variant is separate work. + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { logLine } from "../log.mjs"; +import { withImmediateTransaction } from "../db.mjs"; +import { + messageStorePath, + openMessageStore, + readStorageDriver, + readUnreadSnapshot, +} from "./codex-queue-store.mjs"; +import { sameProject, seatKey } from "./codex-queue.mjs"; +import { + listClaudeCodeRegistrations, + readClaudeRoleSession, +} from "./claude-code-queue-store.mjs"; +import { + QUEUE_CONFIRMATION_TTL_MS, + inboxNudge, + newNonce, + observeTranscript, + resolveTranscriptPath, + resolvePendingDelivery, + sendClaudeCodeMessage, +} from "./claude-code-queue-io.mjs"; + +const SEND_TIMEOUT_MS = 5_000; + +function registrationProjects(teamConfig, agent) { + const registrations = teamConfig?.agents?.[agent]?.registrations; + return Array.isArray(registrations) + ? registrations.filter((item) => item?.type === "claude-code").map((item) => item.project).filter((value) => typeof value === "string" && value) + : []; +} + +function readPending(db, seat) { + return db.prepare(` + SELECT id, messaging_socket, transcript_path, up_to, nonce, state, created_at + FROM beta_claude_queue WHERE seat = ? AND state = 'pending' ORDER BY id DESC LIMIT 1 + `).get(seat); +} + +function pendingSeats(db) { + const rows = db.prepare("SELECT DISTINCT seat FROM beta_claude_queue WHERE state = 'pending'").all(); + const seats = []; + for (const row of rows) { + try { + const pair = JSON.parse(row.seat); + if (!Array.isArray(pair) || pair.length !== 2) continue; + const [team, agent] = pair; + const validSegment = (value) => typeof value === "string" && value.length > 0 && value !== "." && value !== ".." && + !value.startsWith("-") && !/[\\/\u0000-\u001f\u007f]/.test(value); + if (validSegment(team) && validSegment(agent)) seats.push({ team, agent }); + } catch { /* malformed historical key cannot safely identify a seat */ } + } + return seats; +} + +function latestConfirmedCursor(db, seat) { + const row = db.prepare(` + SELECT up_to FROM beta_claude_queue WHERE seat = ? AND state = 'confirmed' + ORDER BY id DESC LIMIT 1 + `).get(seat); + return row?.up_to ?? ""; +} + +function saveSeat(db, { seat, messagingSocket = null, state, reason = "" }, now) { + withImmediateTransaction(db, () => { + db.prepare(` + INSERT INTO beta_claude_seat (seat, messaging_socket, state, reason, checked_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(seat) DO UPDATE SET messaging_socket=COALESCE(excluded.messaging_socket, beta_claude_seat.messaging_socket), + state=excluded.state, reason=excluded.reason, checked_at=excluded.checked_at + `).run(seat, messagingSocket, state, reason, new Date(now()).toISOString()); + }); +} + +function setQueueState(db, id, state) { + withImmediateTransaction(db, () => { + db.prepare("UPDATE beta_claude_queue SET state = ? WHERE id = ? AND state = 'pending'").run(state, id); + }); +} + +function createPending(db, { seat, messagingSocket, transcriptPath, upTo, nonce, expectedOpGen }, now) { + let rowId; + withImmediateTransaction(db, () => { + const intent = db.prepare("SELECT desired, op_gen FROM daemon_intent").get(); + if (intent?.desired !== "on" || intent.op_gen !== expectedOpGen) { + throw new Error("daemon_intent_changed"); + } + const current = db.prepare("SELECT id FROM beta_claude_queue WHERE seat = ? AND state = 'pending'").get(seat); + if (current) throw new Error("seat_already_pending"); + const result = db.prepare(` + INSERT INTO beta_claude_queue (seat, messaging_socket, transcript_path, up_to, nonce, state, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?) + `).run(seat, messagingSocket, transcriptPath, upTo, nonce, new Date(now()).toISOString()); + rowId = Number(result.lastInsertRowid); + }); + return rowId; +} + +function storagePaths(installRoot, env) { + return { + storageDir: env.AGMSG_STORAGE_PATH || join(installRoot, "db"), + configPath: env.AGMSG_CONFIG || join(homedir(), ".agents", "agmsg", "config.json"), + }; +} + +async function pendingObservation(pending, now, observe) { + const createdAt = Date.parse(pending.created_at); + const ageMs = Number.isFinite(createdAt) ? now() - createdAt : NaN; + const observation = observe(pending.transcript_path, pending.nonce); + return resolvePendingDelivery({ observation, ageMs, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); +} + +export function createClaudeCodeQueueChannel({ + db, + installRoot, + expectedOpGen, + env = process.env, + timeoutMs = SEND_TIMEOUT_MS, + now = Date.now, + log = (message) => logLine(installRoot, message), + listSeats = listClaudeCodeRegistrations, + readSession = readClaudeRoleSession, + readDriver = readStorageDriver, + openStore = openMessageStore, + readSnapshot = readUnreadSnapshot, + send = sendClaudeCodeMessage, + observe = observeTranscript, + hostPlatform = process.platform, +}) { + let stopped = false; + let activePoll = null; + + async function pollSeat(registration, paths) { + const { team, agent, teamConfig, registered = true } = registration; + const seat = seatKey(team, agent); + const pending = readPending(db, seat); + if (pending) { + const observation = await pendingObservation(pending, now, observe); + if (!registered) { + if (observation.state === "confirmed") setQueueState(db, pending.id, "confirmed"); + else if (observation.state === "expired") setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "unaddressable", reason: "seat_registration_missing" }, now); + return; + } + if (observation.state === "confirmed") { + setQueueState(db, pending.id, "confirmed"); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable" }, now); + return; + } + if (observation.state === "expired") { + setQueueState(db, pending.id, "expired"); + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "blocked", reason: observation.reason }, now); + return; + } + // Still pending — includes the "held" case: Held is worth surfacing as + // a status, but it is not a delivery confirmation, so the seat must + // never be marked notified on it alone. + saveSeat(db, { seat, messagingSocket: pending.messaging_socket, state: "addressable", reason: observation.reason }, now); + return; + } + if (!registered) return; + if (hostPlatform === "win32") { + saveSeat(db, { seat, state: "blocked", reason: "windows_not_supported_yet" }, now); + return; + } + const session = readSession(join(installRoot, "run"), team, agent); + if (session.state !== "present") { + saveSeat(db, { seat, state: "unaddressable", reason: session.reason ?? "role_session_missing" }, now); + return; + } + const record = session.record; + if (record.team !== team || record.agent !== agent) { + saveSeat(db, { seat, state: "blocked", reason: "role_session_identity_mismatch" }, now); + return; + } + if (record.type !== "claude-code") { + saveSeat(db, { seat, state: "blocked", reason: "role_session_type_mismatch" }, now); + return; + } + if (!record.messaging_socket || !record.claude_config_dir || !record.session) { + saveSeat(db, { seat, state: "unaddressable", reason: "messaging_destination_missing" }, now); + return; + } + const registeredProjects = registrationProjects(teamConfig, agent); + if (registeredProjects.length === 0 || !registeredProjects.some((project) => sameProject(project, record.project))) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "role_session_project_mismatch" }, now); + return; + } + let driver; + try { + driver = readDriver(paths.configPath); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `storage_config_unreadable:${error.message}` }, now); + return; + } + if (driver.state !== "ok") { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: driver.reason }, now); + return; + } + if (driver.driver !== "sqlite") { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "storage_driver_unsupported" }, now); + return; + } + let storePath; + try { + storePath = messageStorePath({ storageDir: paths.storageDir, team, teamConfig }); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `message_store_path_unreadable:${error.message}` }, now); + return; + } + let store; + try { + store = openStore(storePath); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } + let snapshot; + try { + snapshot = readSnapshot(store, team, agent, latestConfirmedCursor(db, seat)); + } catch (error) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `message_store_unreadable:${error.message}` }, now); + return; + } finally { + try { store.close(); } catch { /* keep the read result */ } + } + if (!snapshot.unread || stopped) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable" }, now); + return; + } + + const transcriptPath = resolveTranscriptPath(record.claude_config_dir, record.project, record.session); + if (!transcriptPath) { + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: "transcript_path_unresolvable" }, now); + return; + } + const nonce = newNonce(); + let id; + try { + id = createPending(db, { seat, messagingSocket: record.messaging_socket, transcriptPath, upTo: snapshot.upTo, nonce, expectedOpGen }, now); + } catch (error) { + if (error.message !== "daemon_intent_changed" && error.message !== "seat_already_pending") throw error; + return; + } + + const result = await send({ socketPath: record.messaging_socket, nonce, body: inboxNudge(nonce), timeoutMs }); + if (result.state !== "sent") { + setQueueState(db, id, "expired"); + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "blocked", reason: `send_failed:${result.reason}` }, now); + return; + } + const firstCheck = await pendingObservation(readPending(db, seat), now, observe); + if (firstCheck.state === "confirmed") { + setQueueState(db, id, "confirmed"); + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable" }, now); + return; + } + saveSeat(db, { seat, messagingSocket: record.messaging_socket, state: "addressable", reason: firstCheck.reason }, now); + } + + async function pollOnce() { + if (stopped) return; + if (activePoll) return activePoll; + activePoll = (async () => { + const roster = listSeats(join(installRoot, "teams")); + if (roster.state !== "ok") { + log(`channel: Claude Code roster unavailable (${roster.reason})`); + return; + } + const paths = storagePaths(installRoot, env); + const seats = [...roster.seats]; + const registeredKeys = new Set(seats.map(({ team, agent }) => seatKey(team, agent))); + const unsettledSeats = pendingSeats(db); + const pendingKeys = new Set(unsettledSeats.map(({ team, agent }) => seatKey(team, agent))); + for (const { team, agent } of unsettledSeats) { + const key = seatKey(team, agent); + if (!registeredKeys.has(key)) seats.push({ team, agent, teamConfig: null, registered: false }); + } + const knownKeys = new Set([...registeredKeys, ...pendingKeys]); + for (const row of db.prepare("SELECT seat FROM beta_claude_seat").all()) { + if (!knownKeys.has(row.seat)) { + saveSeat(db, { seat: row.seat, state: "unaddressable", reason: "seat_registration_missing" }, now); + } + } + for (const seat of seats) { + if (stopped) break; + try { + await pollSeat(seat, paths); + } catch (error) { + log(`channel: Claude Code seat ${seat.team}/${seat.agent} could not be checked (${error.message})`); + } + } + })().finally(() => { activePoll = null; }); + return activePoll; + } + + return { + pollOnce, + async stop() { + stopped = true; + await activePoll; + }, + }; +} diff --git a/scripts/daemon/channels/codex-queue.mjs b/scripts/daemon/channels/codex-queue.mjs index 2e47700ea..47b264d59 100644 --- a/scripts/daemon/channels/codex-queue.mjs +++ b/scripts/daemon/channels/codex-queue.mjs @@ -30,11 +30,11 @@ import { checkWindowsCodexQueueGate } from "./windows-codex-queue.mjs"; const QUEUE_TIMEOUT_MS = 10_000; -function seatKey(team, agent) { +export function seatKey(team, agent) { return JSON.stringify([team, agent]); } -function sameProject(left, right) { +export function sameProject(left, right) { if (typeof left !== "string" || !left || typeof right !== "string" || !right) return false; if (!isAbsolute(left) || !isAbsolute(right)) return false; const normalize = (value) => { diff --git a/scripts/daemon/main.mjs b/scripts/daemon/main.mjs index 0024c8eed..75f8c64e6 100644 --- a/scripts/daemon/main.mjs +++ b/scripts/daemon/main.mjs @@ -18,12 +18,15 @@ import { logLine } from "./log.mjs"; import { classify } from "./status.mjs"; import { createCodexQueueChannel } from "./channels/codex-queue.mjs"; import { ensureCodexChannelSchema } from "./channels/codex-queue-store.mjs"; +import { createClaudeCodeQueueChannel } from "./channels/claude-code-queue.mjs"; +import { ensureClaudeCodeChannelSchema } from "./channels/claude-code-queue-store.mjs"; export const POLL_INTERVAL_MS = 5000; export async function prepareClaimWithCodexSchema(prepareClaim) { const prepared = await prepareClaim(); ensureCodexChannelSchema(prepared.db); + ensureClaudeCodeChannelSchema(prepared.db); return prepared; } @@ -168,6 +171,7 @@ export async function main(db, { installRoot, manifest, manifestText, expectedDe gen = started.gen; controlHandle = started.controlHandle; channelHooks.push(createCodexQueueChannel({ db, installRoot, expectedOpGen })); + channelHooks.push(createClaudeCodeQueueChannel({ db, installRoot, expectedOpGen })); process.on("SIGTERM", () => doStop("normal")); diff --git a/scripts/daemon/status.mjs b/scripts/daemon/status.mjs index 9562bdcdc..ab8b13a3b 100644 --- a/scripts/daemon/status.mjs +++ b/scripts/daemon/status.mjs @@ -123,11 +123,21 @@ export function readOwnerAndIntentReadOnly(installRoot) { if (!String(error?.message ?? "").includes("no such table")) throw error; codexSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; } + let claudeCodeSeats; + try { + claudeCodeSeats = { + state: "ok", + seats: db.prepare("SELECT seat, messaging_socket, state, reason, checked_at FROM beta_claude_seat ORDER BY seat").all(), + }; + } catch (error) { + if (!String(error?.message ?? "").includes("no such table")) throw error; + claudeCodeSeats = { state: "unavailable", reason: "channel_not_initialized", seats: [] }; + } const alive = owner.state === "none" || owner.executor_pid == null ? null : isAlive({ pid: owner.executor_pid, bootId: owner.executor_boot_id }); - return { owner, intent, alive, codexSeats, lastAttempt }; + return { owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt }; } finally { db.close(); } @@ -168,9 +178,9 @@ async function main() { process.stderr.write("usage: status.mjs \n"); process.exit(2); } - let owner, intent, alive, codexSeats, lastAttempt; + let owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt; try { - ({ owner, intent, alive, codexSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); + ({ owner, intent, alive, codexSeats, claudeCodeSeats, lastAttempt } = readOwnerAndIntentReadOnly(installRoot)); } catch (error) { // An input that can be detected as an error is reported at // that entry point, with a nonzero exit -- not a raw stack trace, and @@ -195,7 +205,7 @@ async function main() { // unbuffered) looked completely fine. Setting exitCode and letting the // event loop drain naturally waits for the flush first. process.stdout.write( - `${JSON.stringify({ ...result, codex_seats: codexSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, + `${JSON.stringify({ ...result, codex_seats: codexSeats, claude_code_seats: claudeCodeSeats, node_sqlite_experimental: true, node_version: process.version })}\n`, ); process.exitCode = result.exitCode; } diff --git a/scripts/drivers/types/claude-code/_session-start.sh b/scripts/drivers/types/claude-code/_session-start.sh new file mode 100644 index 000000000..6a8bce213 --- /dev/null +++ b/scripts/drivers/types/claude-code/_session-start.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# claude-code SessionStart plug — record this session's own cross-session- +# messaging destination, and (when agmsgd's native channel is up) skip the +# generic Monitor directive the same way codex's own plug skips it. +# +# Sourced by session-start.sh in its global context (so it sees TYPE, PROJECT, +# RUN_DIR, SKILL_DIR, PAIRS and SESSION_ID). Defines agmsg_session_start, +# overriding session-start.sh's default no-op. +# +# Measured 2026-10-05 (memory/design/2026-10-05-cross-session-messaging-socket- +# measurement.md and its addendum) and ruled on the same day +# (memory/design/2026-09-22-agmsgd-arch-8-delivery-driver.md §12/§12.1): +# - CLAUDE_CODE_MESSAGING_SOCKET changes on every `--resume` (new process, +# new pid-named socket) but not on /clear or /compact, so this must run +# and overwrite on every SessionStart, never cache or trust a stale value. +# - CLAUDE_CONFIG_DIR (falling back to ~/.claude) is the base agmsgd needs +# to find this session's own transcript later; it must NEVER be hardcoded +# to ~/.claude, since this machine's own accounts live elsewhere. + +agmsg_session_start() { + if ! declare -F agmsg_role_session_set_messaging >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/role-session.sh" + fi + + # Same guard shape codex-record-session.sh applies to CODEX_HOME: anything + # that is not an absolute path, or that carries a control character, is + # never published as a delivery destination (a malformed value is worse + # than none -- it would look addressable and silently never deliver). + local raw_socket="${CLAUDE_CODE_MESSAGING_SOCKET:-}" socket="" + case "$raw_socket" in + uds:/*) socket="${raw_socket#uds:}" ;; + /*) socket="$raw_socket" ;; + esac + case "$socket" in *[[:cntrl:]]*) socket="" ;; esac + + local config_dir="${CLAUDE_CONFIG_DIR:-${HOME:+$HOME/.claude}}" + case "$config_dir" in + *[[:cntrl:]]*) config_dir="" ;; + /*) ;; + *) config_dir="" ;; + esac + + # Check every OTHER precondition FIRST, before ever touching a lock + # (#1577 re-review): a session that cannot possibly use the native path + # (daemon not ready, Windows, no usable socket) must claim NOTHING. The + # earlier version claimed first and checked these after, so a session with + # agmsgd disabled or no socket could still silently seize an actas lock it + # was never going to use -- only Monitor's own watch.sh was ever supposed + # to touch that lock in that case. + if [ -z "$socket" ]; then return 0; fi + if ! command -v _agmsg_detect_platform >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/compat.sh" + fi + _agmsg_detect_platform + [ "$_agmsg_platform" != msys ] || return 0 + if ! declare -F agmsg_daemon_read_state >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/daemon-state.sh" + fi + agmsg_daemon_read_state + [ "${AGMSGD_HEALTH:-}" = ready ] || return 0 + + # Claim ONLY the single role this session is actually resuming as -- never + # loop over every pair whose record happens to share this bare sid + # (#1577 re-review: the same conversation can carry session= + # into MORE THAN ONE role's record over its life, e.g. actas alice then + # later actas bob; a resumed session must take back only whichever one + # role it is now, not seize every stale name it ever wore). Delegates to + # agmsg_role_session_match_unique -- the SAME ambiguity rule session- + # start.sh's own narrowing uses elsewhere in this file: a second + # qualifying record makes the whole lookup refuse rather than guess. + if ! command -v agmsg_role_session_match_unique >/dev/null 2>&1 \ + || ! command -v actas_lock_claim >/dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${SKILL_DIR:-}/scripts/lib/actas-lock.sh" + fi + local bare_sid my_instance project_phys match pair_team pair_agent + bare_sid="$(agmsg_instance_bare_sid "${SESSION_ID:-}" 2>/dev/null || true)" + my_instance="$(agmsg_normalize_instance_id "${SESSION_ID:-}" "${TYPE:-}" 2>/dev/null || true)" + [ -n "$bare_sid" ] || return 0 + agmsg_instance_is_composite "$my_instance" 2>/dev/null || return 0 + project_phys="$(agmsg_canonical_path "${PROJECT:-}" 2>/dev/null || printf '%s' "${PROJECT:-}")" + match="$(agmsg_role_session_match_unique "${TYPE:-}" "$project_phys" "$bare_sid" 2>/dev/null)" || return 0 + pair_team="${match%%$'\t'*}" + pair_agent="${match#*$'\t'}" + [ -n "$pair_team" ] && [ -n "$pair_agent" ] || return 0 + + # actas_lock_claim is the write gate -- the SAME primitive watch.sh uses to + # reclaim a role's lock across --resume (session-start.sh's own 4th-arg + # "Role-aware resume" relies on exactly this, via watch.sh, when Monitor + # fires; this plug needs its own call since it may skip Monitor entirely). + # Its "mine" verdict requires an EXACT token match; a mismatched owner + # that is still genuinely alive fails the claim outright, and only a + # mismatched DEAD owner reclaims -- so two live processes that happen to + # share a bare sid (same hazard class as #1568) can never both end up + # "owning" the same pair, while a real --resume (new pid, old pid now + # dead) correctly reclaims. + local claim_result + claim_result="$(actas_lock_claim "$pair_team" "$pair_agent" "$my_instance" 2>/dev/null)" || claim_result="unknown:claim_failed" + [ "$claim_result" = ok ] || return 0 + + # Write, then read back: this is the SAME check the daemon itself applies + # (messaging_socket/claude_config_dir both present and non-empty) before + # it will ever address this seat, so "the record actually took" must be + # verified here too, not assumed from the write call returning. + agmsg_role_session_set_messaging "$pair_team" "$pair_agent" "$socket" "$config_dir" "$bare_sid" + local readback_socket readback_config_dir readback_session + readback_socket="$(agmsg_role_session_get "$pair_team" "$pair_agent" messaging_socket 2>/dev/null || true)" + readback_config_dir="$(agmsg_role_session_get "$pair_team" "$pair_agent" claude_config_dir 2>/dev/null || true)" + readback_session="$(agmsg_role_session_uuid "$pair_team" "$pair_agent" 2>/dev/null || true)" + if [ "$readback_socket" = "$socket" ] && [ -n "$config_dir" ] && \ + [ "$readback_config_dir" = "$config_dir" ] && [ "$readback_session" = "$bare_sid" ]; then + cat < is the this-session's uds: peer address with the prefix +# stripped (empty if invalid/absent -- see _session-start.sh's own guard, +# which mirrors codex_home's). is CLAUDE_CONFIG_DIR, or +# ~/.claude when unset -- the base this session's own Claude Code build +# actually uses, never assumed by this function. +# , when given, replaces the record's own session= line (#1577 +# review): a record's session= is only ever written at actas-claim time, so +# after /clear -- which keeps the same socket/process but hands this session +# a brand-new Claude Code session id -- it would otherwise go stale right as +# this function refreshes the fields that matter for finding its transcript +# (the daemon derives the transcript path from BOTH claude_config_dir and +# this session id together). Empty means "leave session= as it is" (every +# other existing caller passes none). +agmsg_role_session_set_messaging() { # [] + local team="$1" agent="$2" socket="$3" config_dir="$4" bare_sid="${5:-}" + [ -n "$team" ] && [ -n "$agent" ] || return 0 + local path dir tmp line + _agmsg_role_session_path_into "$team" "$agent" + path="$_AGMSG_ROLE_SESSION_PATH" + [ -f "$path" ] || return 0 + dir="$(_actas_lock_dir)" + tmp="$(mktemp "$dir/.role-session.XXXXXX" 2>/dev/null)" || return 0 + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + messaging_socket=*|claude_config_dir=*) ;; + session=*) [ -z "$bare_sid" ] && printf '%s\n' "$line" ;; + *) printf '%s\n' "$line" ;; + esac + done < "$path" + [ -z "$bare_sid" ] || printf 'session=%s\n' "$bare_sid" + [ -z "$socket" ] || printf 'messaging_socket=%s\n' "$socket" + [ -z "$config_dir" ] || printf 'claude_config_dir=%s\n' "$config_dir" + } > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; } + mv -f "$tmp" "$path" 2>/dev/null || rm -f "$tmp" 2>/dev/null + return 0 +} + agmsg_role_session_mark_named() { local team="$1" agent="$2" ref="$3" epoch="${4:-}" project="${5:-}" type="${6:-}" [ -n "$team" ] && [ -n "$agent" ] && [ -n "$ref" ] || return 0 diff --git a/tests/agmsgd_claude_code_queue.test.mjs b/tests/agmsgd_claude_code_queue.test.mjs new file mode 100644 index 000000000..630ee55b2 --- /dev/null +++ b/tests/agmsgd_claude_code_queue.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + observeTranscript, + resolvePendingDelivery, + QUEUE_CONFIRMATION_TTL_MS, +} from '../scripts/daemon/channels/claude-code-queue-io.mjs'; + +function temporaryDirectory(t) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'agmsg-cc-queue-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +// Load-bearing property: a `queue-operation`/`enqueue` line fires on mere +// receipt, Held or not, so it must NEVER by itself cause a seat to be marked +// delivered -- never notify on evidence that could just as well be a hold. +// Only the `type:"user"` line whose `origin.body` carries the nonce counts. +// This reproduces both line shapes measured against a live Claude Code +// transcript in one file and asserts the queue-operation line alone is not +// enough, while the user/origin.body line is. +test('observeTranscript: a queue-operation enqueue line is not delivery evidence; only the user/origin.body line is', (t) => { + const dir = temporaryDirectory(t); + const transcriptPath = path.join(dir, 'session.jsonl'); + const nonce = 'abc123-test-nonce'; + const marker = `[agmsg:${nonce}] New messages are waiting. Check your agmsg inbox.`; + + // Only the enqueue line present (held, or not yet acted on) — must read as + // absent, not delivered. + writeFileSync(transcriptPath, `${JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: `${marker}`, + })}\n`); + let observation = observeTranscript(transcriptPath, nonce); + assert.equal(observation.state, 'absent'); + let resolved = resolvePendingDelivery({ observation, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(resolved.state, 'pending'); + + // The real delivered record appended afterward (as the genuine sequence + // measured: enqueue first, then the delivered user line) — now it must + // read as delivered/confirmed. + writeFileSync(transcriptPath, `${JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: `${marker}`, + })}\n${JSON.stringify({ + type: 'user', + isMeta: true, + message: { role: 'user', content: 'Another Claude session sent a message...' }, + origin: { kind: 'peer', from: 'agmsgd', verifiedPeerPid: 12345, name: 'agmsgd', fromMode: 'bypass', body: marker }, + })}\n`); + observation = observeTranscript(transcriptPath, nonce); + assert.equal(observation.state, 'delivered'); + resolved = resolvePendingDelivery({ observation, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(resolved.state, 'confirmed'); +}); + +// #1577 review: a transcript that is genuinely missing (its session/project +// no longer has a live file to write to) must eventually expire with a +// reason, not poll forever -- but a transcript that merely failed to read +// (a transient fs error) must stay pending indefinitely, since that one +// really could resolve on the next read. Distinguishing these two is the +// property this test pins. +test('resolvePendingDelivery: a missing transcript expires at the TTL; a read failure never does', () => { + const missing = { state: 'unreadable', reason: 'transcript_missing' }; + const readFailed = { state: 'unreadable', reason: 'transcript_read_failed' }; + + const missingBeforeTtl = resolvePendingDelivery({ observation: missing, ageMs: 1000, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(missingBeforeTtl.state, 'pending'); + const missingAfterTtl = resolvePendingDelivery({ observation: missing, ageMs: QUEUE_CONFIRMATION_TTL_MS + 1, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(missingAfterTtl.state, 'expired'); + assert.equal(missingAfterTtl.reason, 'transcript_missing'); + + const readFailedAfterTtl = resolvePendingDelivery({ observation: readFailed, ageMs: QUEUE_CONFIRMATION_TTL_MS + 1, ttlMs: QUEUE_CONFIRMATION_TTL_MS }); + assert.equal(readFailedAfterTtl.state, 'pending'); +}); diff --git a/tests/test_role_session.bats b/tests/test_role_session.bats index 8904b3ab7..5ec8cdfb2 100644 --- a/tests/test_role_session.bats +++ b/tests/test_role_session.bats @@ -274,3 +274,32 @@ fake_session() { [ "$status" -eq 0 ] [[ "$output" == *"OK u=sid-9 p=/p/q r"* ]] } + +# Claude Code native-channel delivery (2026-10-05, arch-8 §12.1): a --resume +# starts a brand-new process bound to a brand-new CLAUDE_CODE_MESSAGING_SOCKET +# (measured in memory/design/2026-10-05-cross-session-messaging-socket- +# measurement.md's addendum), so agmsgd must never cache the socket path -- +# session-start.sh re-derives and overwrites it on every start/resume via +# agmsg_role_session_set_messaging. This is the load-bearing property: the +# SECOND call's socket value wins, and unrelated fields (session, project) +# untouched by that function survive unchanged. +@test "role-session: set_messaging overwrites the socket on each call, as a --resume would require" { + agmsg_role_session_record T alice sid-1 /p/q claude-code + agmsg_role_session_set_messaging T alice /tmp/cc-socks/111.sock /home/x/.claude + [ "$(agmsg_role_session_get T alice messaging_socket)" = /tmp/cc-socks/111.sock ] + [ "$(agmsg_role_session_get T alice claude_config_dir)" = /home/x/.claude ] + + # A --resume: same role, brand-new pid-named socket. + agmsg_role_session_set_messaging T alice /tmp/cc-socks/222.sock /home/x/.claude + [ "$(agmsg_role_session_get T alice messaging_socket)" = /tmp/cc-socks/222.sock ] + # Fields set_messaging does not touch must survive untouched. + [ "$(agmsg_role_session_uuid T alice)" = sid-1 ] + [ "$(agmsg_role_session_get T alice project)" = /p/q ] + + # A /clear: same socket, but Claude Code hands this session a brand-new + # session id -- the 5th argument must replace session= too (#1577 review), + # since the daemon derives the transcript path from it. + agmsg_role_session_set_messaging T alice /tmp/cc-socks/222.sock /home/x/.claude sid-2 + [ "$(agmsg_role_session_uuid T alice)" = sid-2 ] + [ "$(agmsg_role_session_get T alice project)" = /p/q ] +} diff --git a/tests/test_session_start_claude_code_native_channel.bats b/tests/test_session_start_claude_code_native_channel.bats new file mode 100644 index 000000000..333f9cfee --- /dev/null +++ b/tests/test_session_start_claude_code_native_channel.bats @@ -0,0 +1,195 @@ +#!/usr/bin/env bats + +# Claude Code native-channel delivery (2026-10-05, arch-8 §12.1): once +# agmsgd's own executor is verifiably ready, a claude-code seat must stop +# arming the generic Monitor watcher — the daemon delivers to its messaging +# socket directly instead, and a second, redundant receive path serves no +# purpose. But "ready" alone is not enough to skip Monitor (#1577 review): +# the daemon only ever addresses a seat whose role-session record actually +# carries a readable messaging_socket/claude_config_dir, so skipping Monitor +# for a seat that never got that record (never actas-claimed) would leave it +# with no delivery path at all. This file pins both halves of that gate. + +load test_helper + +setup() { + setup_test_env + export AGMSG_PLUGIN_DIRS="" + export SKILL_DIR="$TEST_SKILL_DIR" + export RUN_DIR="$SKILL_DIR/run" + mkdir -p "$RUN_DIR" + export PROJ="/tmp/agmsg-session-start-native-channel-proj" + bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null +} + +teardown() { teardown_test_env; } + +_run_session_start() { + env AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/$1.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< "{\"session_id\":\"$1\"}" +} + +_mark_daemon_ready() { + sqlite3 "$RUN_DIR/install.db" < "$SCRIPTS/daemon/schema.sql" + local boot + case "$(uname -s)" in + Darwin) boot="$(sysctl -n kern.boottime | sed -n 's/.*sec = \([0-9]*\),.*/\1/p')" ;; + Linux) boot="$(sed -n 's/^btime //p' /proc/stat)" ;; + *) skip 'POSIX beta executor evidence' ;; + esac + sqlite3 "$RUN_DIR/install.db" "UPDATE daemon_intent SET desired='on'; + UPDATE daemon_owner SET state='ready', executor_pid=$$, executor_boot_id='$boot', executor_started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now');" +} + +# #1577 re-review repro: a conversation that actas'd alice, then +# LATER actas'd bob, leaves BOTH role-session records carrying the same +# session=shared (actas-claim.sh always writes the CURRENT session's bare +# sid into whichever record it claims). A later caller with that same bare +# sid is genuinely resuming only ONE of those roles -- claiming the other's +# lock too would silently seize an actas exclusivity lock nobody asked for, +# with no watcher ever subscribed to use it. Neither role is ever claimed +# here on purpose: the daemon isn't ready (the precondition-first ordering +# this test also pins -- #1577 re-review point 2), so the plug must return +# before ever touching a lock at all, for either pair. +@test "session-start: two roles sharing a stale bare sid are never both claimed (#1577 re-review repro)" { + bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_record team alice shared "$PROJ" claude-code + agmsg_role_session_record team bob shared "$PROJ" claude-code + + run _run_session_start "shared" + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" + + if ! command -v actas_lock_path >/dev/null 2>&1; then source "$SCRIPTS/lib/actas-lock.sh"; fi + bob_lock="$(actas_lock_path team bob)" + [ ! -e "$bob_lock" ] + alice_lock="$(actas_lock_path team alice)" + [ ! -e "$alice_lock" ] +} + +@test "session-start: agmsgd ready AND an actas-claimed role-session record -> no Monitor directive" { + _mark_daemon_ready + # The SAME live pid on both calls, explicit -- the plug's ownership check + # (#1577 re-review) is an exact match on the full composite ".", + # so this positive case must prove the lock and the record really are + # composite-identified, not rely on whatever agmsg_agent_pid's real + # ancestry walk happens to resolve (or degrade to bare) in this + # environment, which left this case passing or failing by accident. + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-daemon-ready" >/dev/null + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-daemon-ready.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-daemon-ready"}' + [ "$status" -eq 0 ] + refute grep -q "AGMSG monitor mode" <<<"$output" + refute grep -q "invoke the Monitor tool" <<<"$output" + grep -qF "agmsgd is running and will deliver" <<<"$output" +} + +# actas_lock_claim is the write gate (not a raw read+compare): its own +# liveness check is what lets a genuine --resume (new pid, the OLD pid now +# dead) correctly reclaim the lock and keep delivering natively, while still +# refusing a same-bare-sid collision from a pid that is still alive (the +# test above). Claim first under a pid nothing on this machine will ever be, +# simulating the pre-resume process having already exited; the resumed +# session's own live pid must still be able to take over. +@test "session-start: a genuine --resume (dead old pid, same bare sid) reclaims the lock and still skips Monitor" { + _mark_daemon_ready + env AGMSG_AGENT_PID=999999999 bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "sid-resumed" >/dev/null + run env AGMSG_AGENT_PID=$$ AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/sid-resumed.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"sid-resumed"}' + [ "$status" -eq 0 ] + refute grep -q "AGMSG monitor mode" <<<"$output" + grep -qF "agmsgd is running and will deliver" <<<"$output" +} + +@test "session-start: agmsgd not running -> the usual Monitor directive still fires" { + run _run_session_start "sid-no-daemon" + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" +} + +# The counterexample #1577 review required: daemon ready is NOT enough by +# itself. A seat that was only joined (never actas-claimed, so it has no +# role-session record at all, let alone a messaging_socket field) must keep +# its only delivery path -- Monitor -- rather than be silenced on the +# assumption the daemon can reach it. +@test "session-start: agmsgd ready but NO role-session record -> Monitor directive still fires" { + _mark_daemon_ready + run _run_session_start "sid-ready-no-record" + [ "$status" -eq 0 ] + grep -qF "AGMSG monitor mode" <<<"$output" + refute grep -q "agmsgd is running and will deliver" <<<"$output" +} + +# #1577 re-review: comparing only the BARE session id let one process +# overwrite another's record whenever the two happened to share the same +# underlying sid under different pids (two live --resume/--continue +# processes of the same conversation -- same hazard class as #1568). bob +# holds the actas lock as the composite "shared."; the caller's +# own SESSION_ID is the SAME bare "shared" but a DIFFERENT live pid, so the +# caller's own composite id differs from bob's lock owner even though their +# bare sids are identical. Only an exact composite match may write. +@test "session-start: a different live pid with the SAME bare session id never overwrites that seat's record" { + _mark_daemon_ready + bash "$SCRIPTS/join.sh" team bob claude-code "$PROJ" >/dev/null + env AGMSG_AGENT_PID=$$ bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code bob "shared" >/dev/null + source "$SCRIPTS/lib/role-session.sh" + agmsg_role_session_set_messaging team bob /tmp/cc-socks/bob-original.sock /home/bob/.claude shared + + # A second, DIFFERENT live pid (this bats test's own subshell, genuinely + # alive and distinct from $$) claims the SAME bare sid for a different + # agent -- the scenario this test exists to catch. + ( env AGMSG_AGENT_PID=$BASHPID bash "$SCRIPTS/join.sh" team alice claude-code "$PROJ" >/dev/null + env AGMSG_AGENT_PID=$BASHPID bash "$SCRIPTS/actas-claim.sh" "$PROJ" claude-code alice "shared" >/dev/null + env AGMSG_AGENT_PID=$BASHPID AGMSG_RESOLVE_PROJECT=0 CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/shared.$BASHPID.sock" \ + bash "$SCRIPTS/session-start.sh" claude-code "$PROJ" <<< '{"session_id":"shared"}' ) >/dev/null + + [ "$(agmsg_role_session_get team bob messaging_socket)" = /tmp/cc-socks/bob-original.sock ] + [ "$(agmsg_role_session_get team bob claude_config_dir)" = /home/bob/.claude ] + [ "$(agmsg_role_session_uuid team bob)" = shared ] +} + +# #1577 review: agmsgd's own Claude Code channel does not send to a Windows +# seat at all yet (named pipe + mandatory auth line is separate work), so the +# plug must never skip Monitor there even with a daemon that is otherwise +# ready and a role-session record that otherwise round-trips cleanly. Faking +# `uname -s` for the whole session-start.sh run (as the full integration tests +# above do for ready/not-ready) is unusable here: compat.sh's OWN platform +# branches change unrelated behavior under a faked Windows uname too, which +# would make this pass or fail for a confounded reason rather than the +# platform check this test exists to pin. Isolate it instead: stub every +# collaborator agmsg_session_start calls so the only real logic under test is +# its own final gate, and set _agmsg_platform directly (compat.sh's own memo +# variable, read, never recomputed, once non-empty). +@test "session-start plug: the Monitor-skip gate itself never fires on Windows, in isolation" { + run bash -c ' + set -uo pipefail + SKILL_DIR="'"$TEST_SKILL_DIR"'" + PROJECT="/tmp/p1" + TYPE="claude-code" + SESSION_ID="sid-1" + PAIRS="T alice" + CLAUDE_CODE_MESSAGING_SOCKET="uds:/tmp/cc-socks/1.sock" + CLAUDE_CONFIG_DIR="/home/x/.claude" + # Stubs: every collaborator reports "this pair is fully addressable" -- + # the one thing NOT stubbed is _agmsg_platform/_agmsg_detect_platform and + # agmsg_daemon_read_state, which together are what this test is pinning. + agmsg_role_session_set_messaging() { :; } + agmsg_role_session_get() { printf "%s" "$3" | grep -q socket && echo "/tmp/cc-socks/1.sock" || echo "/home/x/.claude"; } + agmsg_role_session_uuid() { echo "sid-1"; } + actas_lock_claim() { echo "ok"; } + agmsg_instance_bare_sid() { printf "%s" "$1"; } + agmsg_normalize_instance_id() { echo "sid-1.4242"; } + agmsg_instance_is_composite() { case "$1" in *.*) return 0 ;; *) return 1 ;; esac; } + agmsg_daemon_read_state() { AGMSGD_HEALTH=ready; } + _agmsg_platform=msys + _agmsg_detect_platform() { :; } # already set -- compat.sh itself would also no-op here + + SKILL_DIR="$SKILL_DIR" . "'"$TEST_SKILL_DIR"'/scripts/drivers/types/claude-code/_session-start.sh" + agmsg_session_start + echo "FELL THROUGH (correct: Windows must not skip Monitor)" + ' + [ "$status" -eq 0 ] + grep -qF "FELL THROUGH" <<<"$output" + refute grep -q "agmsgd is running and will deliver" <<<"$output" +}