Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions bin/fm-oauth-token-install.sh
Original file line number Diff line number Diff line change
@@ -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
159 changes: 159 additions & 0 deletions bin/fm-oauth-token-load.sh
Original file line number Diff line number Diff line change
@@ -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:<sh>"
# whose stdout is the token, or "op://<reference>"
# 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=<value>` 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:<command>", or "op://<reference>".
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
33 changes: 33 additions & 0 deletions bin/launchd/com.firstmate.oauth-token.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
com.firstmate.oauth-token LaunchAgent template.

This plist is rendered by bin/fm-oauth-token-install.sh, which substitutes the
absolute path to this repo's bin/ for @@FM_BIN@@ and a per-user log directory
for @@FM_LOG_DIR@@ before writing it to ~/Library/LaunchAgents/.

It contains NO secret. Its only job is to run bin/fm-oauth-token-load.sh at
login; the helper reads CLAUDE_CODE_OAUTH_TOKEN from the operator's secure
source at run time (see fm-oauth-token-load.sh and docs/oauth-token.md). Never
put the token value in this file.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.firstmate.oauth-token</string>
<key>ProgramArguments</key>
<array>
<string>@@FM_BIN@@/fm-oauth-token-load.sh</string>
<string>--setenv</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>@@FM_LOG_DIR@@/oauth-token.out.log</string>
<key>StandardErrorPath</key>
<string>@@FM_LOG_DIR@@/oauth-token.err.log</string>
</dict>
</plist>
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>` 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):
Expand Down Expand Up @@ -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 <session>:<pane-id>, otherwise auto-detected
Expand Down
Loading