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/.github/scripts/bats-file-seconds.tsv b/.github/scripts/bats-file-seconds.tsv index ae489e290..f8bc08628 100644 --- a/.github/scripts/bats-file-seconds.tsv +++ b/.github/scripts/bats-file-seconds.tsv @@ -163,3 +163,4 @@ test_watch_process_count.bats 17 test_watch_stuck_map.bats 4 test_watch.bats 216 test_where.bats 3 +test_agmsgd_switch.bats 10 diff --git a/README.md b/README.md index 7f0acdf9c..ba55d83c6 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, 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: + +```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/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/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/install.sh b/install.sh index 762c3e7d0..898ff372e 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,18 @@ 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" +# 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 # helpers; safe to source. @@ -131,6 +143,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 @@ -170,8 +183,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) } @@ -235,8 +251,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) @@ -249,8 +268,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 @@ -266,11 +562,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. @@ -298,7 +596,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 @@ -311,6 +611,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) @@ -320,18 +621,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 @@ -383,6 +689,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() { @@ -397,26 +704,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 } @@ -427,7 +744,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 @@ -449,7 +768,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 @@ -458,6 +779,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 @@ -466,8 +788,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/" } @@ -475,6 +800,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) @@ -490,6 +816,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 " @@ -500,6 +827,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 @@ -580,6 +912,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 @@ -651,16 +986,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. @@ -674,24 +1009,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 @@ -700,29 +1035,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 @@ -793,9 +1130,11 @@ $_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 + # 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 # 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 @@ -835,13 +1174,15 @@ $_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_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" # #1572 fixed the leak; this sweeps up whatever an install made before @@ -854,7 +1195,7 @@ $_agmsg_running_team" if [ "${AGMSG_STALE_OUTCOME_REMOVED:-0}" -gt 0 ]; then echo " + removed $AGMSG_STALE_OUTCOME_REMOVED leaked sync outcome file(s) from before #1572" fi - 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;" @@ -865,7 +1206,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 @@ -884,6 +1225,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 @@ -901,10 +1243,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). @@ -927,32 +1272,35 @@ 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 +# 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 # 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 @@ -969,22 +1317,25 @@ 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" +# The `agmsg` command launcher (never touches shell rc files or PATH). +agmsg_install_op_run_phase agmsg_launcher_install "$SKILL_DIR" "$CMD_NAME" || exit 1 -# 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 @@ -995,15 +1346,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 @@ -1013,8 +1365,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 @@ -1025,8 +1377,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 @@ -1037,8 +1389,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 @@ -1052,8 +1404,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 @@ -1061,7 +1413,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/. @@ -1069,7 +1422,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/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/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 new file mode 100644 index 000000000..180a76280 --- /dev/null +++ b/scripts/daemon.sh @@ -0,0 +1,565 @@ +#!/usr/bin/env bash +# agmsgd's CLI ("agmsg daemon start|stop|status|enable|disable"). +# 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 +# (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" +# 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 +# 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() { + local stderr_log="${AGMSGD_TEST_LAUNCH_STDERR_LOG:-/dev/null}" + bash "$LAUNCHER" /dev/null 2>"$stderr_log" 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" + agmsg_daemon_seat_report start + _path_hint status + 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" + 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 +} + +# 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 + 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 + 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. + 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')" + agmsg_daemon_seat_report status + _path_hint enable + return "$health_rc" +} + +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 + echo 'Install Node from https://nodejs.org/en/download, then run agmsg daemon enable.' >&2 + _path_hint enable >&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. 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 + 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..22149f912 --- /dev/null +++ b/scripts/daemon/agmsgd @@ -0,0 +1,191 @@ +#!/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"); +} + +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 }); + installDb.exec("PRAGMA busy_timeout = 5000;"); + 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) { + 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 incompleteOperationPath = join(installRoot, "run", "install-op-incomplete.json"); + 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 verified; + let mainModule; + let openInstallDb; + try { + 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/ + // 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(); + } + + 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"); + } + // Use the shared bounded wait for CLI readers racing owner updates. + const db = openInstallDb(dbPath); + 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: verified.manifest, + manifestText: verified.manifestText, + expectedDesired: desired, + expectedOpGen, + version: verified.manifest.version, + prepareClaim, + }); +} + +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/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-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..47b264d59 --- /dev/null +++ b/scripts/daemon/channels/codex-queue.mjs @@ -0,0 +1,525 @@ +// 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"; +import { checkWindowsCodexQueueGate } from "./windows-codex-queue.mjs"; + +const QUEUE_TIMEOUT_MS = 10_000; + +export function seatKey(team, agent) { + return JSON.stringify([team, agent]); +} + +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) => { + 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, + windowsGate = checkWindowsCodexQueueGate, +}) { + 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") { + 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"); + 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; + } + let queueExecutable = executable; + if (hostPlatform === "win32") { + 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 { + 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: queueExecutable, + 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/channels/windows-codex-queue.mjs b/scripts/daemon/channels/windows-codex-queue.mjs new file mode 100644 index 000000000..c29543c66 --- /dev/null +++ b/scripts/daemon/channels/windows-codex-queue.mjs @@ -0,0 +1,59 @@ +// 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"; + +// 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; + 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" }; + 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/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..75f8c64e6 --- /dev/null +++ b/scripts/daemon/main.mjs @@ -0,0 +1,194 @@ +// 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 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"; +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; +} + +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 +// 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 { + 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(); + } +} + +// 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, prepareClaim }) { + const channelHooks = []; + let controlHandle; + let gen; + let stopping = false; + let timer; + + 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, + prepareClaim: prepareClaim ? async () => { + const prepared = await prepareClaimWithCodexSchema(prepareClaim); + db = prepared.db; + return prepared; + } : undefined, + 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 }), + }, + }); + 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( + new Date().toISOString(), + started.reason, + process.pid, + ); + process.exit(started.reason.startsWith("bind failed") ? 1 : 0); + return; + } + gen = started.gen; + controlHandle = started.controlHandle; + channelHooks.push(createCodexQueueChannel({ db, installRoot, expectedOpGen })); + channelHooks.push(createClaudeCodeQueueChannel({ db, installRoot, expectedOpGen })); + + process.on("SIGTERM", () => doStop("normal")); + + const watchState = await captureWatchState(installRoot, manifest, manifestText); + let polling = false; + timer = setInterval(async () => { + 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/owner.mjs b/scripts/daemon/owner.mjs new file mode 100644 index 000000000..780e6bb85 --- /dev/null +++ b/scripts/daemon/owner.mjs @@ -0,0 +1,129 @@ +// 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) 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"; +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..ab8b13a3b --- /dev/null +++ b/scripts/daemon/status.mjs @@ -0,0 +1,227 @@ +// 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, 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 && alive === true) { + 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 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 = { + 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: [] }; + } + 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, claudeCodeSeats, lastAttempt }; + } 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, codexSeats, claudeCodeSeats, lastAttempt; + try { + ({ 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 + // 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, lastAttempt }); + + // 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, codex_seats: codexSeats, claude_code_seats: claudeCodeSeats, 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/doctor.sh b/scripts/doctor.sh index caecfc60e..4a2eb7b0f 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -1054,6 +1054,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 # Orphaned per-seat run/ records (#1507): like the watcher line above, an # installation-wide fact -- no --project/--type/--team narrows it. Reported and # counted here, never removed (see --fix). 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 <&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-record-session.sh b/scripts/drivers/types/codex/codex-record-session.sh index 045d82cfa..0c473976f 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,34 @@ 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 never published as +# a delivery destination, but it does not prevent the session record either: +# the record is written without the profile, and discovery falls back to +# $HOME/.codex as before. +codex_home="${CODEX_HOME:-${HOME:+$HOME/.codex}}" +case "$codex_home" in + *[[:cntrl:]]*) codex_home="" ;; + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) codex_home="" ;; +esac +if [ -n "$codex_home" ] && [ -d "$codex_home" ]; then + codex_home="$(agmsg_canonical_path "$codex_home")" || 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")" || codex_home="" + case "$codex_home" in + *[[:cntrl:]]*) codex_home="" ;; + /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; + *) codex_home="" ;; + esac +else + codex_home="" +fi + 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 +196,12 @@ 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 + if [ -n "$codex_home" ]; then + sessions_dir="$codex_home/sessions" + else + sessions_dir="${HOME:-}/.codex/sessions" + fi + if [ -n "${sessions_dir%/.codex/sessions}" ] && [ -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. @@ -211,26 +242,6 @@ fi # codex thread ids are already bare UUIDs (no composite pid form), so record # 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. -# Add optional destination metadata without changing thread discovery. Failure -# to resolve a profile never prevents the existing session record or delivery. -codex_home="${CODEX_HOME:-${HOME:+$HOME/.codex}}" -case "$codex_home" in - *[[:cntrl:]]*) codex_home="" ;; - /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; - *) codex_home="" ;; -esac -if [ -n "$codex_home" ] && [ -d "$codex_home" ]; then - codex_home="$(cd -- "$codex_home" 2>/dev/null && pwd -P)" || codex_home="" - codex_home="$(agmsg_normalize_project_path "$codex_home")" || codex_home="" - case "$codex_home" in - *[[:cntrl:]]*) codex_home="" ;; - /* | [A-Za-z]:/* | [A-Za-z]:\\*) ;; - *) codex_home="" ;; - esac -else - codex_home="" -fi - 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 diff --git a/scripts/drivers/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh index db8e83dd3..152418730 100755 --- a/scripts/drivers/types/codex/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -178,6 +178,27 @@ exec_plain_launch() { exec "$real_codex" "$@" } +# 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) + exec "$real_codex" "$@" + ;; + *) + echo 'agmsgd handles Codex notices; starting plain Codex without a bridge.' >&2 + agmsg_daemon_warn_if_stopped always + exec_plain_launch "$@" + ;; + esac + fi +fi + monitor_cmd="${AGMSG_CODEX_MONITOR_CMD:-$SCRIPT_DIR/codex-monitor.sh}" case "$command_name" in diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 600bfb590..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 this Codex thread so a later spawn can resume it, together with optional profile metadata: `~/.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/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/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/scripts/lib/agmsg-launcher.sh b/scripts/lib/agmsg-launcher.sh new file mode 100644 index 000000000..180690409 --- /dev/null +++ b/scripts/lib/agmsg-launcher.sh @@ -0,0 +1,210 @@ +#!/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 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" 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 + 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/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/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..8985c3e96 100644 --- a/scripts/lib/role-session.sh +++ b/scripts/lib/role-session.sh @@ -196,6 +196,55 @@ agmsg_role_session_record() { # $HERDR_SOCKET_PATH, which the server recreates when it starts). A restarted # server changes it, so a mark made against the old server no longer matches # even when the pane reference is reused unchanged. +# Overwrite ONLY messaging_socket/claude_config_dir, leaving every other +# field (session, project, owner, named_ref, ...) untouched — unlike +# agmsg_role_session_record, this never creates a fresh record: SessionStart +# calls this for every pair in PAIRS on every start/resume, and most of those +# pairs already have a record from actas-claim.sh; one that does not is a +# seat that was never claimed, and writing messaging-only fields for it would +# fabricate a role-session record session-start.sh itself never establishes +# (#339 stays actas-claim's job). A record that lacks these fields reads back +# as empty, which the daemon treats exactly like Codex's role_session_missing +# -- never as a fatal read error. +# +# 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/scripts/lib/skill-render.sh b/scripts/lib/skill-render.sh index eb29680d1..4068e8d11 100644 --- a/scripts/lib/skill-render.sh +++ b/scripts/lib/skill-render.sh @@ -118,9 +118,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/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 a80a8a177..8c7bb78e4 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 19a21c374..3162d6fa5 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -64,6 +64,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_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/agmsgd_codex_queue.test.mjs b/tests/agmsgd_codex_queue.test.mjs new file mode 100644 index 000000000..6327b7892 --- /dev/null +++ b/tests/agmsgd_codex_queue.test.mjs @@ -0,0 +1,512 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +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'; +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'; +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'; +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('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('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' }, + 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('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'); + 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.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' }); + } +}); + +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', + 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_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', ?, ?) + `).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_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, '{}'); + 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_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(); + + 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(); + 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_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..48dddd407 --- /dev/null +++ b/tests/agmsgd_main.test.mjs @@ -0,0 +1,180 @@ +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, pollChannelHooks, pollOnce, prepareClaimWithCodexSchema, 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 }); + } +}); + +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/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..8e40a03ce --- /dev/null +++ b/tests/agmsgd_status.test.mjs @@ -0,0 +1,112 @@ +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("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); + 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/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"}}} 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"}}} diff --git a/tests/test_agmsg_command.bats b/tests/test_agmsg_command.bats new file mode 100644 index 000000000..b372e8b37 --- /dev/null +++ b/tests/test_agmsg_command.bats @@ -0,0 +1,174 @@ +#!/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" ] +} + +@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" { + 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_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_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..1bfcd87aa --- /dev/null +++ b/tests/test_agmsgd_daemon_sh.bats @@ -0,0 +1,385 @@ +#!/usr/bin/env bats + +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" +} + +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" +} + +_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 + _assert_start_result '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 + _assert_start_result 'running' + + _run_with_deadline 10 bash "$DAEMON" start + _assert_start_result '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 + _assert_start_result 'running' + + _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..0f1686e39 --- /dev/null +++ b/tests/test_agmsgd_entrypoint.bats @@ -0,0 +1,200 @@ +#!/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;" +} + +_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 + + 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 + # 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 + 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" ] +} + +@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"* ]] +} + +@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 + + 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" ] +} 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..26170ca3d --- /dev/null +++ b/tests/test_agmsgd_launch.bats @@ -0,0 +1,137 @@ +#!/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" + 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" + 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" + printf '%s\n' 'ROLLBACK;' '.quit' >&8 + exec 8>&- + 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" { + 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_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 ] +} 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/tests/test_codex_resume.bats b/tests/test_codex_resume.bats index 017cf5f3d..21754184a 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,14 +70,30 @@ 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: adds the effective profile path without changing the thread" { @@ -119,10 +137,24 @@ recorded_uuid() { } @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)" { diff --git a/tests/test_install.bats b/tests/test_install.bats index ea78b65f0..292c28a22 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 @@ -2089,6 +2182,567 @@ 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>&- 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 3>&- 4>&- & + 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" + _agmsg_watch_pid "$lock_pid" "$SK/run/install-op.lock.db" + [ -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 3>&- 4>&- & + 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" + _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 + [ "$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_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" +} 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..acfe27234 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,26 +477,71 @@ _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 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 "" 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 +554,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 +575,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 +585,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 +748,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%/}"