diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e066fa8..e36b33e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,7 @@ tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, and bootstrap relay tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, completion follow-up counters/caps, dry-run preview, and .env-presence activation tests tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, read-only remediation suppression, and spawn/brief isolation tests +tests/fm-oauth-token.test.sh # durable CLAUDE_CODE_OAUTH_TOKEN: helper setenv/export/print/check, secure-source resolution and error paths, plist template shape, installer render/load/idempotency/uninstall, and no-launchctl refusal tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166) and clean no-mistakes/direct-PR/local-only brief generation tests tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption diff --git a/README.md b/README.md index d6d31cd0..ebe0d010 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/orca-backend.md](docs/orca-backend.md) - setup guide for the experimental Orca backend, plus its lifecycle notes and known gaps. - [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified Claude Code Stop-hook mechanism, scoping, and known gaps. +- [docs/oauth-token.md](docs/oauth-token.md) - make `CLAUDE_CODE_OAUTH_TOKEN` survive reboots on macOS so firstmate-launched agents keep working after a restart. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - firstmate's full operating manual for the orchestrator agent. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. diff --git a/bin/fm-oauth-token-install.sh b/bin/fm-oauth-token-install.sh new file mode 100755 index 00000000..683ccdbd --- /dev/null +++ b/bin/fm-oauth-token-install.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Install, uninstall, or inspect the com.firstmate.oauth-token LaunchAgent that +# makes CLAUDE_CODE_OAUTH_TOKEN survive reboots on macOS by re-exporting it into +# the user's launchd session domain at every login. +# +# It renders the committed template at bin/launchd/com.firstmate.oauth-token.plist +# (which holds NO token, only a reference to bin/fm-oauth-token-load.sh) into +# ~/Library/LaunchAgents/ with the absolute path to this repo's bin/ and a +# per-user log directory substituted, loads it with launchctl, then runs the +# helper once so the token is set immediately without requiring a re-login. +# Re-running --install is idempotent: it unloads any prior copy first, rewrites +# the plist, and reloads it. +# +# The token itself is NEVER read, printed, or committed here; the helper reads it +# from the operator's secure source at run time (see fm-oauth-token-load.sh). +# +# Override paths for testing: +# FM_USER_LAUNCHAGENTS_DIR default ~/Library/LaunchAgents +# FM_OAUTH_TOKEN_LOG_DIR default ~/Library/Logs/firstmate +# FM_LAUNCHCTL default `launchctl`; the binary name used for every +# launchctl call, so the no-launchctl refusal path can +# be exercised without removing /usr/bin from PATH +# Usage: fm-oauth-token-install.sh [--install|--uninstall|--status|--help] +set -u + +LABEL='com.firstmate.oauth-token' +PLIST_NAME="$LABEL.plist" +LAUNCHCTL="${FM_LAUNCHCTL:-launchctl}" + +usage() { + sed -n '2,/^# Usage:/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_BIN="$SCRIPT_DIR" +TEMPLATE="$FM_ROOT/bin/launchd/$PLIST_NAME" +USER_AGENTS_DIR="${FM_USER_LAUNCHAGENTS_DIR:-$HOME/Library/LaunchAgents}" +LOG_DIR="${FM_OAUTH_TOKEN_LOG_DIR:-$HOME/Library/Logs/firstmate}" +TARGET="$USER_AGENTS_DIR/$PLIST_NAME" + +require_launchctl() { + command -v "$LAUNCHCTL" >/dev/null 2>&1 || { echo "error: launchctl not found (looked for $LAUNCHCTL); this installer is macOS-only" >&2; return 1; } +} + +# render_plist: substitute @@FM_BIN@@ and @@FM_LOG_DIR@@ in the template and +# print the result. bash global substitution is used so path characters such as +# '&' are treated literally (no regex replacement semantics). +render_plist() { + [ -f "$TEMPLATE" ] || { echo "error: missing plist template $TEMPLATE" >&2; return 1; } + local content + content=$(cat -- "$TEMPLATE") || return 1 + content=${content//@@FM_BIN@@/$FM_BIN} + content=${content//@@FM_LOG_DIR@@/$LOG_DIR} + printf '%s\n' "$content" +} + +do_install() { + require_launchctl || return 1 + [ -f "$TEMPLATE" ] || { echo "error: missing plist template $TEMPLATE" >&2; return 1; } + [ -x "$FM_BIN/fm-oauth-token-load.sh" ] || { echo "error: helper $FM_BIN/fm-oauth-token-load.sh missing or not executable" >&2; return 1; } + mkdir -p "$USER_AGENTS_DIR" "$LOG_DIR" || { echo "error: could not create $USER_AGENTS_DIR or $LOG_DIR" >&2; return 1; } + # unload any prior copy so a stale registration does not linger. + "$LAUNCHCTL" unload "$TARGET" 2>/dev/null || true + render_plist > "$TARGET" || { echo "error: could not render plist to $TARGET" >&2; return 1; } + if "$LAUNCHCTL" load -w "$TARGET" 2>/dev/null; then + echo "oauth-token: installed and loaded $TARGET" + else + echo "oauth-token: wrote $TARGET but launchctl load failed; run '$LAUNCHCTL load -w $TARGET' manually" >&2 + return 1 + fi + # set the token now so it is present without a re-login; a failure here is a + # source problem, not an install problem, so warn but do not undo the install. + "$FM_BIN/fm-oauth-token-load.sh" --setenv \ + || echo "oauth-token: helper --setenv failed on first run; check $LOG_DIR/oauth-token.err.log and your secure source" >&2 + echo "oauth-token: next login will re-export CLAUDE_CODE_OAUTH_TOKEN automatically" + echo "oauth-token: rotate by updating your secure source and re-running --install, or reboot" +} + +do_uninstall() { + require_launchctl || return 1 + local removed=0 + if [ -f "$TARGET" ]; then + "$LAUNCHCTL" unload -w "$TARGET" 2>/dev/null || true + rm -f "$TARGET" && removed=1 + fi + # best-effort: clear any stale registration left behind by a manually deleted + # plist. `launchctl remove` takes the job label, not a path. + "$LAUNCHCTL" remove "$LABEL" 2>/dev/null || true + "$LAUNCHCTL" unsetenv CLAUDE_CODE_OAUTH_TOKEN 2>/dev/null || true + if [ "$removed" = 1 ]; then + echo "oauth-token: removed $TARGET and unset CLAUDE_CODE_OAUTH_TOKEN from the launchd user domain" + else + echo "oauth-token: nothing to remove; $TARGET was not present" + fi +} + +do_status() { + require_launchctl || return 1 + echo "template: $TEMPLATE" + echo "installed: $([ -f "$TARGET" ] && echo "$TARGET" || echo '(not installed)')" + if [ -f "$TARGET" ]; then + if grep -q '@@FM_BIN@@\|@@FM_LOG_DIR@@' "$TARGET"; then + echo "render: WARN - $TARGET still contains unsubstituted placeholders" + else + echo "render: ok" + fi + fi + if "$LAUNCHCTL" getenv CLAUDE_CODE_OAUTH_TOKEN 2>/dev/null | grep -q .; then + echo "launchd: CLAUDE_CODE_OAUTH_TOKEN present in user domain" + else + echo "launchd: CLAUDE_CODE_OAUTH_TOKEN absent from user domain" + fi + if "$FM_BIN/fm-oauth-token-load.sh" --print >/dev/null 2>&1; then + echo "source: resolvable" + else + echo "source: not resolvable (populate your secure source or set config/oauth-token-source)" + fi +} + +case "${1:---install}" in + --install) do_install ;; + --uninstall) do_uninstall ;; + --status) do_status ;; + -h|--help) usage; exit 0 ;; + *) echo "usage: fm-oauth-token-install.sh [--install|--uninstall|--status|--help]" >&2; exit 2 ;; +esac diff --git a/bin/fm-oauth-token-load.sh b/bin/fm-oauth-token-load.sh new file mode 100755 index 00000000..a2125da7 --- /dev/null +++ b/bin/fm-oauth-token-load.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Load the Claude Code OAuth token (CLAUDE_CODE_OAUTH_TOKEN) from a secure +# source and export it into the user's launchd session domain so the processes +# firstmate spawns (terminal shells, tmux servers, crewmate agents) inherit it +# across reboots without a manual re-export. +# +# This is the helper the com.firstmate.oauth-token LaunchAgent runs at login +# (see bin/fm-oauth-token-install.sh and docs/oauth-token.md). It never logs, +# echoes, or commits the token value in its default mode; only the controlled +# --print/--export modes emit it, for operator use during rotation or manual +# refresh. +# +# Secure source resolution (first match wins): +# 1. FM_OAUTH_TOKEN_FILE env path to a file holding just the token +# 2. config/oauth-token-source gitignored local file; first non-comment line +# is either a path to a token file, "cmd:" +# whose stdout is the token, or "op://" +# resolved with `op read` (1Password CLI) +# 3. ~/.config/firstmate/claude-code-oauth-token +# default gitignored token file (0600) +# +# Modes: +# --setenv Read the token and call `launchctl setenv CLAUDE_CODE_OAUTH_TOKEN` +# (default; what the LaunchAgent runs). Best-effort +# `tmux set-environment -g` when a tmux server is reachable, so a +# running tmux picks up a rotated token without a restart. +# --export Print `export CLAUDE_CODE_OAUTH_TOKEN=` for sourcing. +# --print Print just the token value (for piping into other tooling). +# --check Exit 0 if the token is present in the launchd user domain, 1 +# otherwise; prints a one-line status. Does not read the source. +# --help Show this help. +# +# Security: the token is a secret. It is read into a shell variable and passed +# to launchctl setenv. `launchctl setenv` exposes the value in the process list +# briefly; this is inherent to launchctl and the standard login-time approach. +# The helper never writes the token to any file, log, or committed artifact, and +# error messages never include the value. +# +# Override: +# FM_LAUNCHCTL default `launchctl`; the binary name used for every launchctl +# call, so tests can exercise the no-launchctl path. +# Usage: fm-oauth-token-load.sh [--setenv|--export|--print|--check|--help] +set -u + +LABEL='CLAUDE_CODE_OAUTH_TOKEN' +LAUNCHCTL="${FM_LAUNCHCTL:-launchctl}" + +usage() { + sed -n '2,/^# Usage:/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" + +# resolve_source: echo the source descriptor per the resolution order above. +# Echoes a literal path, "cmd:", or "op://". +resolve_source() { + if [ -n "${FM_OAUTH_TOKEN_FILE:-}" ]; then + printf '%s\n' "$FM_OAUTH_TOKEN_FILE" + return 0 + fi + local src_file="$CONFIG/oauth-token-source" + if [ -f "$src_file" ]; then + local line + line=$(grep -v '^[[:space:]]*#' "$src_file" 2>/dev/null | grep -v '^[[:space:]]*$' | head -n 1 || true) + if [ -n "$line" ]; then + printf '%s\n' "$line" + return 0 + fi + fi + printf '%s\n' "${HOME}/.config/firstmate/claude-code-oauth-token" +} + +# read_token: echo the token value (no trailing newline) from the resolved +# source. Errors go to stderr and never include the value. Returns non-zero on +# any failure. +read_token() { + local src value perms + src=$(resolve_source) || return 1 + case "$src" in + cmd:*) + local cmd=${src#cmd:} + value=$(sh -c "$cmd" 2>/dev/null) || { echo "error: oauth-token-source cmd failed" >&2; return 1; } + ;; + op://*) + command -v op >/dev/null 2>&1 || { echo "error: oauth-token-source uses op:// but the op CLI is not on PATH" >&2; return 1; } + value=$(op read "$src" 2>/dev/null) || { echo "error: op read failed for the configured reference" >&2; return 1; } + ;; + *) + if [ ! -f "$src" ]; then + echo "error: no token file at $src; create it (chmod 600) or set config/oauth-token-source" >&2 + return 1 + fi + value=$(cat -- "$src") || { echo "error: could not read token file $src" >&2; return 1; } + if [ -O "$src" ]; then + perms=$(stat -f '%Lp' "$src" 2>/dev/null || stat -c '%a' "$src" 2>/dev/null || true) + case "$perms" in + 600|"") ;; + *) echo "warn: token file $src is mode ${perms:-?}, expected 600; tighten with chmod 600 \"$src\"" >&2 ;; + esac + fi + ;; + esac + # strip leading and trailing whitespace (spaces, tabs, newlines) + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + if [ -z "$value" ]; then + echo "error: resolved token is empty" >&2 + return 1 + fi + printf '%s' "$value" +} + +run_setenv() { + local token + token=$(read_token) || return 1 + command -v "$LAUNCHCTL" >/dev/null 2>&1 || { echo "error: launchctl not found (looked for $LAUNCHCTL); this helper is macOS-only" >&2; return 1; } + "$LAUNCHCTL" setenv "$LABEL" "$token" || { echo "error: launchctl setenv failed" >&2; return 1; } + # Best-effort: push into a running tmux server so a rotated token reaches new + # tmux windows without restarting tmux. Ignore all tmux failures silently. + if command -v tmux >/dev/null 2>&1 && tmux info >/dev/null 2>&1; then + tmux set-environment -g "$LABEL" "$token" 2>/dev/null || true + fi + echo "oauth-token: set $LABEL in the launchd user domain" +} + +run_export() { + local token escaped + token=$(read_token) || return 1 + escaped=${token//\'/\'\\\'\'} + printf "export %s='%s'\n" "$LABEL" "$escaped" +} + +run_print() { + local token + token=$(read_token) || return 1 + printf '%s\n' "$token" +} + +run_check() { + command -v "$LAUNCHCTL" >/dev/null 2>&1 || { echo "error: launchctl not found (looked for $LAUNCHCTL); this helper is macOS-only" >&2; return 1; } + if "$LAUNCHCTL" getenv "$LABEL" 2>/dev/null | grep -q .; then + echo "$LABEL: present in the launchd user domain" + return 0 + fi + echo "$LABEL: absent from the launchd user domain" + return 1 +} + +case "${1:---setenv}" in + --setenv) run_setenv ;; + --export) run_export ;; + --print) run_print ;; + --check) run_check ;; + -h|--help) usage; exit 0 ;; + *) echo "usage: fm-oauth-token-load.sh [--setenv|--export|--print|--check|--help]" >&2; exit 2 ;; +esac diff --git a/bin/launchd/com.firstmate.oauth-token.plist b/bin/launchd/com.firstmate.oauth-token.plist new file mode 100644 index 00000000..90a04ed8 --- /dev/null +++ b/bin/launchd/com.firstmate.oauth-token.plist @@ -0,0 +1,33 @@ + + + + + + Label + com.firstmate.oauth-token + ProgramArguments + + @@FM_BIN@@/fm-oauth-token-load.sh + --setenv + + RunAtLoad + + KeepAlive + + StandardOutPath + @@FM_LOG_DIR@@/oauth-token.out.log + StandardErrorPath + @@FM_LOG_DIR@@/oauth-token.err.log + + diff --git a/docs/configuration.md b/docs/configuration.md index 516475f8..05999ca6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -209,6 +209,15 @@ In dry-run, `fm-x-dismiss.sh` records `{request_id, endpoint:"dismiss"}` to the The live answer and follow-up bodies intentionally stay the same shape, including optional `image`; the relay distinguishes them by endpoint, and dismiss stays `{request_id}`. These paths need `jq` to build the JSON payload, but they run before token and network checks, so they need neither `FMX_PAIRING_TOKEN` nor `curl`. +## OAuth token durability (macOS) + +firstmate-launched agents rely on `CLAUDE_CODE_OAUTH_TOKEN` being present in the environment. +A token set only in the current login shell is lost on reboot, so agents fail until it is manually re-exported. +The durable mechanism is a per-user LaunchAgent that runs `launchctl setenv CLAUDE_CODE_OAUTH_TOKEN ` at every login, reading the token from a gitignored secure source (a `0600` file, a `config/oauth-token-source` `cmd:`/`op://` reference, or `FM_OAUTH_TOKEN_FILE`). +The committed plist template references the helper by placeholder and never holds the token. +Install it with `bin/fm-oauth-token-install.sh --install`; rotate by updating the secure source and re-running `--install` or `bin/fm-oauth-token-load.sh --setenv`. +Full setup, rotation, security notes, and the LaunchAgent-versus-env-passthrough design choice are in [`docs/oauth-token.md`](oauth-token.md). + ## Environment variables Runtime tuning via environment variables (defaults shown): @@ -265,6 +274,11 @@ GROK_HOME= # optional Grok config home for firstmate's global grok FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line once FM_SEND_SLEEP=0.4 # seconds between fm-send submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful text submit; 0 disables +# oauth-token durability (bin/fm-oauth-token-load.sh / fm-oauth-token-install.sh; docs/oauth-token.md) +FM_OAUTH_TOKEN_FILE= # highest-precedence secure token source; overrides config/oauth-token-source and the default file for one invocation +FM_LAUNCHCTL=launchctl # launchctl binary name used for every launchctl call; lets tests exercise the no-launchctl refusal path +FM_USER_LAUNCHAGENTS_DIR= # LaunchAgents dir the installer renders into; defaults to ~/Library/LaunchAgents +FM_OAUTH_TOKEN_LOG_DIR= # LaunchAgent stdout/stderr log dir; defaults to ~/Library/Logs/firstmate # sub-supervisor (bin/fm-supervise-daemon.sh); presence-gated via /afk FM_SUPERVISOR_BACKEND= # optional supervisor pane backend override; tmux/herdr only, otherwise detects $TMUX_PANE then HERDR_ENV/HERDR_PANE_ID before tmux fallback FM_SUPERVISOR_TARGET= # optional supervisor pane target override; tmux target or herdr :, otherwise auto-detected diff --git a/docs/oauth-token.md b/docs/oauth-token.md new file mode 100644 index 00000000..f958a772 --- /dev/null +++ b/docs/oauth-token.md @@ -0,0 +1,147 @@ +# Durable `CLAUDE_CODE_OAUTH_TOKEN` (macOS) + +firstmate launches crewmate agents that rely on `CLAUDE_CODE_OAUTH_TOKEN` being present in their environment. +A token set only in the current login shell is lost on reboot, so agents fail until it is manually re-exported. +This page documents the durable mechanism that re-establishes the token at every login, with no manual step and no committed secret. + +## What the mechanism is + +A per-user LaunchAgent (`com.firstmate.oauth-token`) runs at login and calls `launchctl setenv CLAUDE_CODE_OAUTH_TOKEN ` to push the token into the user's launchd session domain. +From there it is inherited by every GUI-launched process: Terminal/iTerm, the shells inside them, any tmux server started from those shells, and the crewmate agents firstmate spawns inside tmux. +After a reboot, the agent runs once at login and the token is back before any terminal opens. + +The token value is never committed. +The committed plist template at [`bin/launchd/com.firstmate.oauth-token.plist`](../bin/launchd/com.firstmate.oauth-token.plist) only references the helper script; it carries placeholders, not a secret. +The helper, [`bin/fm-oauth-token-load.sh`](../bin/fm-oauth-token-load.sh), reads the token from a secure source at run time. +The installer, [`bin/fm-oauth-token-install.sh`](../bin/fm-oauth-token-install.sh), renders the template into `~/Library/LaunchAgents/` and loads it. + +## Why a LaunchAgent, not env passthrough in a daemon plist + +Two approaches were considered: + +1. A login-time `launchctl setenv` driven by a per-user LaunchAgent (chosen). +2. Passing the token through as an `EnvironmentVariables` entry in a daemon/agent plist. + +The LaunchAgent is the cleaner fit because firstmate is not a launchd-managed daemon. +It runs interactively in a terminal, usually inside tmux, so there is no existing firstmate plist to inject `EnvironmentVariables` into. +Creating a firstmate LaunchAgent purely to host one env var would re-architect how firstmate launches (foreground interactive versus background daemon) for a single variable, and a static `EnvironmentVariables` entry would either bake the secret into the plist or shell out to a lookup at load time, which is the LaunchAgent approach with extra steps. + +`launchctl setenv` at login matches the actual process tree: GUI terminal inherits the launchd session env, the login shell inherits the terminal, a tmux server started from that shell inherits the shell, and crewmate windows inherit the tmux server. +It is one focused, idempotent mechanism that does not change how firstmate runs. + +## Setup + +1. Put the token in a secure source the helper can read. + The default source is a gitignored file at `~/.config/firstmate/claude-code-oauth-token` containing just the token, with mode `0600`: + + ```sh + mkdir -p ~/.config/firstmate + printf '%s' 'YOUR_TOKEN_VALUE' > ~/.config/firstmate/claude-code-oauth-token + chmod 600 ~/.config/firstmate/claude-code-oauth-token + ``` + + Never commit this file. + It lives under your home directory, outside this repo, and the helper only reads it. + +2. Install and load the LaunchAgent from this repo: + + ```sh + bin/fm-oauth-token-install.sh --install + ``` + + This renders the plist into `~/Library/LaunchAgents/com.firstmate.oauth-token.plist`, loads it, and runs the helper once so the token is set immediately without a re-login. + +3. Verify the token is in the launchd user domain: + + ```sh + bin/fm-oauth-token-load.sh --check + ``` + + Print `CLAUDE_CODE_OAUTH_TOKEN: present in the launchd user domain` means new shells and tmux servers will inherit it. + To confirm a fresh shell actually sees it, open a new terminal and run `printenv CLAUDE_CODE_OAUTH_TOKEN`. + +After a reboot or a fresh login, the LaunchAgent re-runs at login and re-exports the token automatically. + +## Secure source alternatives + +If you do not want a token file on disk, point the helper at your existing secret store with a gitignored `config/oauth-token-source` file under your firstmate home. +Its first non-comment line is one of: + +- A literal path to a token file (e.g. `/Users/you/.secure/claude-token`). +- `cmd:` whose stdout is the token, run via `sh -c`. +- `op://<1Password item reference>` resolved with `op read` (requires the 1Password CLI). + +Examples: + +```sh +# 1Password: op://Vault/Item/field +echo 'op://Private/Claude Code/token' > config/oauth-token-source + +# any command that prints the token +echo 'cmd:op read "op://Private/Claude Code/token"' > config/oauth-token-source +``` + +`config/oauth-token-source` is gitignored; never commit it. +You can also override the source for a single invocation with `FM_OAUTH_TOKEN_FILE=`. + +## Rotation + +To rotate the token: + +1. Update the value in your secure source (overwrite the token file, or update the 1Password item). +2. Re-run the installer, which reloads the agent and runs the helper once: + + ```sh + bin/fm-oauth-token-install.sh --install + ``` + + Or, without touching the plist, just push the new value into the current session: + + ```sh + bin/fm-oauth-token-load.sh --setenv + ``` + + The helper also best-effort pushes the token into a running tmux server with `tmux set-environment -g`, so new tmux windows pick up the rotated value without restarting tmux. + Existing windows do not retroactively inherit it; open a new window or restart tmux to refresh them. + +3. Reboot (or log out and back in) to confirm the new value survives a fresh login via the LaunchAgent. + +## Uninstall + +```sh +bin/fm-oauth-token-install.sh --uninstall +``` + +This unloads the agent, removes the plist from `~/Library/LaunchAgents/`, and `launchctl unsetenv`s the token from the user domain. +The secure source (token file or secret store) is left untouched. + +## Status + +```sh +bin/fm-oauth-token-install.sh --status +``` + +Reports the template path, whether the plist is installed and rendered, whether the token is present in the launchd user domain, and whether the secure source is resolvable. + +## Security notes + +- The token value is never committed, logged, or echoed by the helper in its default `--setenv` mode. +- `launchctl setenv VAR value` exposes the value in the process list briefly while the call runs. + This is inherent to `launchctl` and the standard login-time approach; the helper calls it once at login and never otherwise prints the value. +- The `--print` and `--export` modes do emit the token to stdout by design, for operator use during manual refresh or sourcing. + Never run them in a context where stdout is captured into a committed file or shared log. +- The committed plist template contains only placeholders and a reference to the helper, never a token. +- A token file with permissions looser than `0600` produces a stderr warning but still resolves, so a one-time setup mistake does not silently break agents. + +## Troubleshooting + +- **New shell does not see the token.** Run `bin/fm-oauth-token-load.sh --check`; if absent, re-run `--install` or `--setenv`. If present in the domain but missing from the shell, the shell was started before the agent ran; open a fresh terminal. +- **tmux windows still see the old token after rotation.** The helper pushes the new value to the tmux server's global environment, but existing windows do not retroactively inherit it. Open a new tmux window, or restart the tmux server after rotating. +- **SSH login shells do not see the token.** `launchctl setenv` populates the GUI/aqua session domain, which non-GUI SSH login shells do not inherit. For SSH coverage, also source the token in your shell rc from the same secure source, e.g. `eval "$(bin/fm-oauth-token-load.sh --export)"` in `~/.zshrc` / `~/.bashrc` (guarded by the source existing). +- **`launchctl load` is deprecated.** On newer macOS the installer may emit a deprecation warning; it is harmless and the load still succeeds. The unload/load commands remain functional across current macOS versions. +- **First agent run failed after install.** The installer loads the agent first, then runs the helper once. If the helper fails (e.g. the secure source is not populated yet), the agent is still installed and will succeed at the next login once the source is ready. Check `~/Library/Logs/firstmate/oauth-token.err.log`. + +## Integration with firstmate conventions + +This mechanism follows the repo's `bin/` conventions: plain bash helpers with usage headers, a committed plist template under `bin/launchd/`, behavior tests at `tests/fm-oauth-token.test.sh`, and no change to firstmate's session-start or supervision machinery. +It is a one-time OS-level setup step, not something bootstrap manages; the captain installs it once per Mac. diff --git a/docs/scripts.md b/docs/scripts.md index b8aa8aad..47bcb64e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -56,3 +56,5 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-x-dismiss.sh` | Dismiss or dry-run preview a skipped X mention without replying by sending `{request_id}` to the relay's `connector/dismiss` endpoint | | `fm-x-link.sh` | Link a spawned task to its originating X mention by recording `x_request=`, `x_request_ts=`, and a follow-up counter `x_followups=` in `state/.meta`; paired `--carry-count --carry-ts ` preserves the original counter and timestamp when re-linking onto a successor task | | `fm-x-followup.sh` | Detect, post, and manage up to three completion follow-ups (within a 7-day window) for an X-linked task, forwarding optional `--image `, incrementing the counter on a non-final success, clearing the link on `--final`/cap/window/relay-rejection, and retrying only on a generic post failure | +| `fm-oauth-token-load.sh` | Read `CLAUDE_CODE_OAUTH_TOKEN` from a secure source (a `0600` file, `config/oauth-token-source` `cmd:`/`op://` reference, or `FM_OAUTH_TOKEN_FILE`) and push it into the launchd user domain with `launchctl setenv`, plus best-effort `tmux set-environment -g`; modes `--setenv` (default, run by the LaunchAgent), `--export`, `--print`, and `--check`; never logs or commits the token | +| `fm-oauth-token-install.sh` | Render `bin/launchd/com.firstmate.oauth-token.plist` (no token, helper path substituted) into `~/Library/LaunchAgents/`, load it with `launchctl`, and run the helper once so `CLAUDE_CODE_OAUTH_TOKEN` survives reboots; `--install` (idempotent), `--uninstall`, and `--status`; `FM_LAUNCHCTL`, `FM_USER_LAUNCHAGENTS_DIR`, and `FM_OAUTH_TOKEN_LOG_DIR` overrides for tests | diff --git a/tests/fm-oauth-token.test.sh b/tests/fm-oauth-token.test.sh new file mode 100755 index 00000000..070e5860 --- /dev/null +++ b/tests/fm-oauth-token.test.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash +# Behavior tests for the durable CLAUDE_CODE_OAUTH_TOKEN mechanism: +# bin/fm-oauth-token-load.sh (the login-time helper) and +# bin/fm-oauth-token-install.sh (the LaunchAgent installer), plus the committed +# plist template at bin/launchd/com.firstmate.oauth-token.plist. +# +# All tests are hermetic: launchctl and tmux are stubbed through a fakebin on +# PATH so no real launchd domain or tmux server is touched, and HOME / +# FM_CONFIG_OVERRIDE / FM_USER_LAUNCHAGENTS_DIR / FM_OAUTH_TOKEN_LOG_DIR point at +# temp dirs so no real captain file (in particular the real token) is read or +# written. The fake token values used here are inert strings, never a real +# credential. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-oauth-token) +HOME="$TMP_ROOT/home" +mkdir -p "$HOME" +export HOME + +FAKE_TOKEN='fake-token-AAAA1234' + +# make_fakebin: echo a fakebin dir with stubbed launchctl and tmux that record +# their calls into capture files under $1 and obey FM_FAKE_TOKEN_VALUE for +# `launchctl getenv`. Optional second arg "no-tmux" makes the tmux stub report +# no running server (tmux info exits 1). +make_fakebin() { + local dir=$1 no_tmux=${2:-} fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/launchctl" <> "\$cap" + printf '\\n' >> "\$cap" + exit 0 + ;; + getenv) + # FM_FAKE_TOKEN_VALUE controls presence; print it on stdout (the helper + # pipes launchctl getenv output into grep -q .) and exit 0 only when set. + if [ -n "\${FM_FAKE_TOKEN_VALUE:-}" ]; then + printf '%s\\n' "\${FM_FAKE_TOKEN_VALUE:-}" + exit 0 + fi + exit 1 + ;; + load) + printf '%s|' "\$@" >> "\$cap" + printf '\\n' >> "\$cap" + exit 0 + ;; + unload) + printf '%s|' "\$@" >> "\$cap" + printf '\\n' >> "\$cap" + exit 0 + ;; + remove) + printf '%s|' "\$@" >> "\$cap" + printf '\\n' >> "\$cap" + exit 0 + ;; + unsetenv) + printf '%s|' "\$@" >> "\$cap" + printf '\\n' >> "\$cap" + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/launchctl" + if [ "$no_tmux" = "no-tmux" ]; then + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +case "$1" in + info) exit 1 ;; # no running tmux server + *) exit 0 ;; +esac +SH + else + cat > "$fakebin/tmux" <> "$dir/tmux.calls" + printf '\\n' >> "$dir/tmux.calls" + exit 0 + ;; +esac +exit 0 +SH + fi + chmod +x "$fakebin/tmux" + printf '%s\n' "$fakebin" +} + +# token_file : echo path to a temp token file containing . +token_file() { + local f + f="$TMP_ROOT/token-$(printf '%s' "$1" | cksum | cut -d' ' -f1)" + printf '%s' "$1" > "$f" + chmod 600 "$f" + printf '%s\n' "$f" +} + +run_load() { + FM_OAUTH_TOKEN_FILE="${FM_OAUTH_TOKEN_FILE:-}" \ + FM_CONFIG_OVERRIDE="${FM_CONFIG_OVERRIDE:-$TMP_ROOT/config}" \ + PATH="$1:$PATH" \ + "$ROOT/bin/fm-oauth-token-load.sh" "${@:2}" +} + +# --- helper: --setenv calls launchctl and tmux, never prints the value ------- + +test_setenv_calls_launchctl_and_tmux() { + local fakebin tf out cap + fakebin=$(make_fakebin "$TMP_ROOT/fb1") + tf=$(token_file "$FAKE_TOKEN") + out=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --setenv 2>&1) + assert_contains "$out" "oauth-token: set CLAUDE_CODE_OAUTH_TOKEN in the launchd user domain" \ + "--setenv did not report success" + assert_not_contains "$out" "$FAKE_TOKEN" \ + "--setenv leaked the token value to stdout/stderr" + cap=$(cat "$TMP_ROOT/fb1/launchctl.calls") + assert_contains "$cap" "setenv|CLAUDE_CODE_OAUTH_TOKEN|$FAKE_TOKEN|" \ + "launchctl setenv was not called with the label and token value" + assert_contains "$(cat "$TMP_ROOT/fb1/tmux.calls")" "set-environment|-g|CLAUDE_CODE_OAUTH_TOKEN|$FAKE_TOKEN|" \ + "tmux set-environment -g was not called with the token when a server was reachable" + pass "fm-oauth-token-load --setenv: calls launchctl setenv and tmux set-environment, never prints the token" +} + +test_setenv_skips_tmux_when_no_server() { + local fakebin tf out + fakebin=$(make_fakebin "$TMP_ROOT/fb2" no-tmux) + tf=$(token_file "$FAKE_TOKEN") + out=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --setenv 2>&1) + assert_contains "$out" "oauth-token: set CLAUDE_CODE_OAUTH_TOKEN in the launchd user domain" \ + "--setenv failed despite no tmux server" + assert_contains "$(cat "$TMP_ROOT/fb2/launchctl.calls")" "setenv|CLAUDE_CODE_OAUTH_TOKEN|" \ + "launchctl setenv was skipped when tmux was unavailable" + [ ! -f "$TMP_ROOT/fb2/tmux.calls" ] || fail "tmux set-environment was called despite no server" + pass "fm-oauth-token-load --setenv: still sets launchctl when no tmux server is reachable, and skips tmux" +} + +# --- helper: --export and --print -------------------------------------------- + +test_export_quotes_value() { + local fakebin tf out + fakebin=$(make_fakebin "$TMP_ROOT/fb3") + tf=$(token_file "$FAKE_TOKEN") + out=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --export 2>/dev/null) + [ "$out" = "export CLAUDE_CODE_OAUTH_TOKEN='$FAKE_TOKEN'" ] \ + || fail "--export did not produce the expected single-quoted export line: '$out'" + # a value containing a single quote must be escaped with the '\'' idiom + tf=$(token_file "a'b") + out=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --export 2>/dev/null) + [ "$out" = "export CLAUDE_CODE_OAUTH_TOKEN='a'\''b'" ] \ + || fail "--export did not escape an embedded single quote: '$out'" + pass "fm-oauth-token-load --export: emits a shell-safe export line and escapes embedded single quotes" +} + +test_print_value() { + local fakebin tf out + fakebin=$(make_fakebin "$TMP_ROOT/fb4") + tf=$(token_file "$FAKE_TOKEN") + out=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --print 2>/dev/null) + [ "$out" = "$FAKE_TOKEN" ] || fail "--print did not print exactly the token value: '$out'" + pass "fm-oauth-token-load --print: prints the resolved token value" +} + +# --- helper: source resolution and error paths ------------------------------- + +test_missing_source_errors_without_leaking() { + local fakebin out rc + fakebin=$(make_fakebin "$TMP_ROOT/fb5") + out=$(FM_OAUTH_TOKEN_FILE='' run_load "$fakebin" --print 2>&1); rc=$? + [ "$rc" -ne 0 ] || fail "--print with no resolvable source exited 0" + assert_contains "$out" "no token file" \ + "--print did not explain the missing token file" + assert_not_contains "$out" "$FAKE_TOKEN" \ + "--print leaked a token while reporting a missing source" + pass "fm-oauth-token-load: a missing secure source errors clearly and never leaks a token" +} + +test_cmd_source_runs_command() { + local fakebin cfg out + fakebin=$(make_fakebin "$TMP_ROOT/fb6") + cfg="$TMP_ROOT/config" + mkdir -p "$cfg" + printf 'cmd:printf %%s cmd-token-XYZ\n' > "$cfg/oauth-token-source" + out=$(FM_OAUTH_TOKEN_FILE='' FM_CONFIG_OVERRIDE="$cfg" run_load "$fakebin" --print 2>/dev/null) + [ "$out" = "cmd-token-XYZ" ] || fail "cmd: source did not resolve to the command's stdout: '$out'" + pass "fm-oauth-token-load: config/oauth-token-source cmd: prefix runs the command and uses its stdout" +} + +test_op_source_uses_op_cli() { + local fakebin cfg out + fakebin=$(make_fakebin "$TMP_ROOT/fb7") + local refcap="$TMP_ROOT/fb7/op.ref" + # op stub captures the reference it received and only succeeds for a full + # op:// reference, so a scheme-stripped ref (the old bug) would fail. + cat > "$fakebin/op" < "$refcap" + case "\$2" in + op://*) printf '%s' 'op-token-789' ;; + *) exit 1 ;; + esac + ;; + *) exit 0 ;; +esac +SH + chmod +x "$fakebin/op" + cfg="$TMP_ROOT/config" + mkdir -p "$cfg" + printf 'op://Private/firstmate/claude/token\n' > "$cfg/oauth-token-source" + out=$(FM_OAUTH_TOKEN_FILE='' FM_CONFIG_OVERRIDE="$cfg" run_load "$fakebin" --print 2>/dev/null) + [ "$out" = "op-token-789" ] || fail "op:// source did not resolve via the op CLI: '$out'" + [ "$(cat "$refcap")" = "op://Private/firstmate/claude/token" ] \ + || fail "op read received a mangled reference: '$(cat "$refcap")'" + pass "fm-oauth-token-load: config/oauth-token-source op:// reference resolves via op read with the full reference intact" +} + +test_loose_perms_warn_but_proceed() { + local fakebin tf out err + fakebin=$(make_fakebin "$TMP_ROOT/fb8") + tf="$TMP_ROOT/loose-token" + printf '%s' "$FAKE_TOKEN" > "$tf" + chmod 644 "$tf" + err=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --print 2>&1 1>/dev/null) + assert_contains "$err" "warn: token file" "loose perms did not produce a warning" + assert_contains "$err" "expected 600" "loose-perms warning did not name the expected mode" + out=$(FM_OAUTH_TOKEN_FILE="$tf" run_load "$fakebin" --print 2>/dev/null) + [ "$out" = "$FAKE_TOKEN" ] || fail "loose perms prevented the token from being read" + pass "fm-oauth-token-load: a world/loose-perm token file warns but still resolves the token" +} + +# --- helper: --check reads the launchd domain, not the source ---------------- + +test_check_present_and_absent() { + local fakebin out rc + fakebin=$(make_fakebin "$TMP_ROOT/fb9") + # FM_FAKE_TOKEN_VALUE must be exported so the stubbed launchctl subprocess + # (a child of the helper) actually sees it. + export FM_FAKE_TOKEN_VALUE='whatever' + out=$(run_load "$fakebin" --check 2>/dev/null); rc=$? + [ "$rc" -eq 0 ] || fail "--check exited non-zero when the var was present" + assert_contains "$out" "present in the launchd user domain" "--check present wording missing" + FM_FAKE_TOKEN_VALUE='' + out=$(run_load "$fakebin" --check 2>/dev/null); rc=$? + [ "$rc" -ne 0 ] || fail "--check exited 0 when the var was absent" + assert_contains "$out" "absent from the launchd user domain" "--check absent wording missing" + unset FM_FAKE_TOKEN_VALUE + pass "fm-oauth-token-load --check: reports presence/absence from the launchd domain" +} + +# --- plist template: placeholders, no token ---------------------------------- + +test_template_shape() { + local t="$ROOT/bin/launchd/com.firstmate.oauth-token.plist" + assert_present "$t" "plist template missing" + assert_grep "@@FM_BIN@@" "$t" "template missing the @@FM_BIN@@ placeholder" + assert_grep "@@FM_LOG_DIR@@" "$t" "template missing the @@FM_LOG_DIR@@ placeholder" + assert_grep "fm-oauth-token-load.sh" "$t" "template does not reference the helper" + assert_grep "--setenv" "$t" "template does not pass --setenv to the helper" + assert_grep "" "$t" "template is not set to run at load (RunAtLoad true)" + # a path-shaped secret like an sk- token must never be committed in the template + assert_no_grep "sk-" "$t" "template contains an sk- shaped literal" + pass "plist template: references the helper via placeholders, runs --setenv at load, and carries no token" +} + +# --- installer: render, load, idempotent, uninstall -------------------------- + +# run_install : invoke the installer with the fakebin on PATH +# and the per-test FM_USER_LAUNCHAGENTS_DIR / FM_OAUTH_TOKEN_LOG_DIR already in +# the environment (set by each caller). +run_install() { + PATH="$1:$PATH" \ + "$ROOT/bin/fm-oauth-token-install.sh" "${@:2}" +} + +test_install_renders_and_loads() { + local fakebin tf agents_dir target out cap + fakebin=$(make_fakebin "$TMP_ROOT/fb10") + tf=$(token_file "$FAKE_TOKEN") + agents_dir="$TMP_ROOT/LaunchAgents" + target="$agents_dir/com.firstmate.oauth-token.plist" + out=$(FM_OAUTH_TOKEN_FILE="$tf" \ + FM_USER_LAUNCHAGENTS_DIR="$agents_dir" \ + FM_OAUTH_TOKEN_LOG_DIR="$TMP_ROOT/logs" \ + run_install "$fakebin" --install 2>&1) + assert_contains "$out" "installed and loaded" "install did not report loaded" + assert_present "$target" "install did not write the plist to the agents dir" + assert_no_grep "@@FM_BIN@@" "$target" "rendered plist still has an unsubstituted @@FM_BIN@@" + assert_no_grep "@@FM_LOG_DIR@@" "$target" "rendered plist still has an unsubstituted @@FM_LOG_DIR@@" + assert_grep "$ROOT/bin/fm-oauth-token-load.sh" "$target" "rendered plist does not reference the real helper path" + assert_grep "oauth-token.err.log" "$target" "rendered plist does not reference the per-user log path" + cap=$(cat "$TMP_ROOT/fb10/launchctl.calls") + assert_contains "$cap" "load|-w|$target|" "launchctl load -w was not called with the rendered plist" + assert_not_contains "$out" "$FAKE_TOKEN" "install leaked the token value" + pass "fm-oauth-token-install --install: renders the template, loads it, and never emits the token" +} + +test_install_idempotent() { + local fakebin tf agents_dir target a b + fakebin=$(make_fakebin "$TMP_ROOT/fb11") + tf=$(token_file "$FAKE_TOKEN") + agents_dir="$TMP_ROOT/LaunchAgents-idem" + target="$agents_dir/com.firstmate.oauth-token.plist" + FM_OAUTH_TOKEN_FILE="$tf" \ + FM_USER_LAUNCHAGENTS_DIR="$agents_dir" \ + FM_OAUTH_TOKEN_LOG_DIR="$TMP_ROOT/logs-idem" \ + run_install "$fakebin" --install >/dev/null 2>&1 + a=$(cat "$target") + # second install should unload the prior copy first, then rewrite and reload + : > "$TMP_ROOT/fb11/launchctl.calls" + FM_OAUTH_TOKEN_FILE="$tf" \ + FM_USER_LAUNCHAGENTS_DIR="$agents_dir" \ + FM_OAUTH_TOKEN_LOG_DIR="$TMP_ROOT/logs-idem" \ + run_install "$fakebin" --install >/dev/null 2>&1 + b=$(cat "$target") + [ "$a" = "$b" ] || fail "second install produced a different plist (not idempotent)" + assert_contains "$(cat "$TMP_ROOT/fb11/launchctl.calls")" "unload|" \ + "second install did not unload the prior copy before reloading" + pass "fm-oauth-token-install --install: is idempotent (unload-then-reload, byte-identical plist)" +} + +test_uninstall_removes_plist_and_unsetenv() { + local fakebin tf agents_dir target out cap + fakebin=$(make_fakebin "$TMP_ROOT/fb12") + tf=$(token_file "$FAKE_TOKEN") + agents_dir="$TMP_ROOT/LaunchAgents-un" + target="$agents_dir/com.firstmate.oauth-token.plist" + FM_OAUTH_TOKEN_FILE="$tf" \ + FM_USER_LAUNCHAGENTS_DIR="$agents_dir" \ + FM_OAUTH_TOKEN_LOG_DIR="$TMP_ROOT/logs-un" \ + run_install "$fakebin" --install >/dev/null 2>&1 + out=$(FM_OAUTH_TOKEN_FILE="$tf" \ + FM_USER_LAUNCHAGENTS_DIR="$agents_dir" \ + FM_OAUTH_TOKEN_LOG_DIR="$TMP_ROOT/logs-un" \ + run_install "$fakebin" --uninstall 2>&1) + assert_absent "$target" "uninstall did not remove the plist" + assert_contains "$out" "removed" "uninstall did not report removal" + cap=$(cat "$TMP_ROOT/fb12/launchctl.calls") + assert_contains "$cap" "unload|-w|$target|" "uninstall did not unload the agent" + assert_contains "$cap" "unsetenv|CLAUDE_CODE_OAUTH_TOKEN" "uninstall did not unsetenv the token" + pass "fm-oauth-token-install --uninstall: unloads, removes the plist, and unsetenvs the token" +} + +test_install_refuses_without_launchctl() { + local out rc empty + # Pointing FM_LAUNCHCTL at a name that is not on PATH exercises the + # require_launchctl refusal without removing /usr/bin (which also holds + # dirname/cat/grep) from PATH - so the script still starts and resolves its + # own dir. Works identically on macOS and Linux CI. + empty=$(fm_fakebin "$TMP_ROOT/no-launch") + out=$(FM_USER_LAUNCHAGENTS_DIR="$TMP_ROOT/LaunchAgents-nolaunch" \ + FM_OAUTH_TOKEN_LOG_DIR="$TMP_ROOT/logs-nolaunch" \ + FM_OAUTH_TOKEN_FILE="$(token_file "$FAKE_TOKEN")" \ + FM_LAUNCHCTL='launchctl-not-on-path' \ + PATH="$empty:/usr/bin:/bin" \ + bash "$ROOT/bin/fm-oauth-token-install.sh" --install 2>&1); rc=$? + [ "$rc" -ne 0 ] || fail "install exited 0 without launchctl on PATH" + assert_contains "$out" "launchctl not found" "install did not explain the missing launchctl" + pass "fm-oauth-token-install: refuses with a clear error when launchctl is unavailable" +} + +test_setenv_calls_launchctl_and_tmux +test_setenv_skips_tmux_when_no_server +test_export_quotes_value +test_print_value +test_missing_source_errors_without_leaking +test_cmd_source_runs_command +test_op_source_uses_op_cli +test_loose_perms_warn_but_proceed +test_check_present_and_absent +test_template_shape +test_install_renders_and_loads +test_install_idempotent +test_uninstall_removes_plist_and_unsetenv +test_install_refuses_without_launchctl