Skip to content
Merged
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
95 changes: 95 additions & 0 deletions bin/fm-courier-pickup-service.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# fm-courier-pickup-service.sh - the supervised launcher for bin/fm-courier-pickup.py.
#
# Renders fm-courier-pickup.service, a systemd --user unit that runs
# `fm-courier-pickup.py run` for one home with the explicit FM_HOME and the
# FM_NOTIFY_COURIER=1 opt-in the pickup requires. It is wanted by
# default.target, so an enabled unit starts with the user's manager, which
# starts at boot only when the account lingers (loginctl enable-linger), and
# systemd restarts it 5 s after any failure exit or crash. It runs as the user
# whose manager loads it - install it as the Firstmate account - with
# NoNewPrivileges=yes and no User=, capability or path grant of its own, so the
# pickup reads exactly what that account already can.
#
# The unit names this checkout's absolute paths, resolved when it is rendered,
# so re-run install after moving the checkout or changing FM_HOME rather than
# editing the file. The running pickup keeps its loaded code until the unit is
# restarted. Enabling the unit is the courier activation step, so install never
# runs systemctl; docs/voice-relay.md owns the enable, update and rollback
# runbook.
#
# Usage:
# fm-courier-pickup-service.sh render print the unit on stdout
# fm-courier-pickup-service.sh install write it to
# ${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/fm-courier-pickup.service
# (mode 0644, atomic replace) and print that path
# FM_HOME (default: this checkout) must be an existing absolute directory.
# Paths with characters outside [A-Za-z0-9._/@+_-] are refused rather than escaped for systemd.
# Exit 0 on success, 1 on a refused path or failed write, 2 on usage.
set -eu

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
UNIT=fm-courier-pickup.service

die() {
printf 'fm-courier-pickup-service.sh: %s\n' "$*" >&2
exit 1
}

usage() {
sed -n '/^# Usage:/,/^# Exit/s/^# \{0,1\}//p' "${BASH_SOURCE[0]}"
}

checked_path() {
case "$1" in
/*) ;;
*) die "$2 must be an absolute path: $1" ;;
esac
[[ "$1" =~ ^[A-Za-z0-9._/@+_-]+$ ]] || die "$2 has characters this unit does not escape: $1"
[ -d "$1" ] || die "$2 is not a directory: $1"
}

render() {
local home=${FM_HOME:-$ROOT}
checked_path "$ROOT" 'the Firstmate checkout'
checked_path "$home" FM_HOME
[ -x "$ROOT/bin/fm-courier-pickup.py" ] || die "no executable pickup at $ROOT/bin/fm-courier-pickup.py"
cat <<EOF
# Generated by $ROOT/bin/fm-courier-pickup-service.sh; re-run its install rather than editing.
[Unit]
Description=Firstmate courier iMessage pickup for $home
Documentation=file://$ROOT/docs/voice-relay.md

[Service]
Type=simple
Environment=FM_HOME=$home
Environment=FM_NOTIFY_COURIER=1
WorkingDirectory=$home
ExecStart=$ROOT/bin/fm-courier-pickup.py run
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes

[Install]
WantedBy=default.target
EOF
}

install_unit() {
local dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" unit tmp
unit=$(render) || exit 1
mkdir -p "$dir" || die "cannot create $dir"
tmp=$(mktemp "$dir/.$UNIT.XXXXXX") || die "cannot write in $dir"
if ! printf '%s\n' "$unit" > "$tmp" || ! chmod 0644 "$tmp" || ! mv -f "$tmp" "$dir/$UNIT"; then
rm -f "$tmp"
die "could not install $dir/$UNIT"
fi
printf '%s\n' "$dir/$UNIT"
}

case "${1:-}" in
render) [ "$#" -eq 1 ] || { usage >&2; exit 2; }; render ;;
install) [ "$#" -eq 1 ] || { usage >&2; exit 2; }; install_unit ;;
-h|--help|help) usage ;;
*) usage >&2; exit 2 ;;
esac
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1516,7 +1516,7 @@ families_for_changed_path() {
bin/fm-brain-desk.py|tests/fm-brain-desk-cases.py)
printf '%s\n' __script__:fm-brain-desk.test.sh
;;
bin/fm-courier-pickup.py|tests/fm-courier-pickup-cases.py)
bin/fm-courier-pickup.py|bin/fm-courier-pickup-service.sh|tests/fm-courier-pickup-cases.py)
printf '%s\n' __script__:fm-courier-pickup.test.sh
;;
bin/fm-inbox.sh|bin/fm_inbox_conversation.py|tests/fm-inbox-conversation-cases.py)
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ Opting into this App for CI requires a no-mistakes build with the `github_ci_rea
[`bin/fm-brain-desk.py`](../bin/fm-brain-desk.py) is Firstmate's client for the installed brain rooms: desk questions, the morning-brief `brainctl` verbs, and the journal-input export; its help owns invocation, endpoints, bounds and result validation.
It is off unless `FM_BRAIN_DESK_ENABLED=1`, so callers keep their direct brain path until the rooms are activated, and an enabled but unavailable room refuses rather than falling back to raw brain access.
Phone and text notifications, including their optional courier route, belong to firstmate-voice's `fm-notify`; the wedge alarm reaches it through a `command:` directive ([wedge-alarm.md](wedge-alarm.md#channels)).
For inbound conversations with `FM_NOTIFY_COURIER=1`, use the pickup described in [voice-relay.md](voice-relay.md#conversation-transport); [`bin/fm-courier-pickup.py`](../bin/fm-courier-pickup.py)'s `--help` owns its launch and binding requirements.
For inbound courier conversations, follow the [pickup activation runbook](voice-relay.md#running-the-courier-pickup); [`bin/fm-courier-pickup.py`](../bin/fm-courier-pickup.py)'s `--help` owns its binding requirements.

## Toolchain

Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| ------------------------ | ------------------------------------------------------------------------------------ |
| [`fm-brain-desk.py`](../bin/fm-brain-desk.py) | Ask the opt-in brain desk, run the morning-brief brainctl verbs, and export journal input |
| [`fm-courier-pickup.py`](../bin/fm-courier-pickup.py) | File the captain's courier-spooled iMessages into the conversation and answer through the courier outbox |
| [`fm-courier-pickup-service.sh`](../bin/fm-courier-pickup-service.sh) | Render and install the courier pickup's systemd user unit ([activation runbook](voice-relay.md#running-the-courier-pickup)) |
| `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest |
| `fm-sessionstart-nudge.sh` | Print the native session-start hook nudge when the primary has not already run the digest |
| `fm-sessionstart-run.sh` | Route a native session-open hook to the full digest, a context re-emit, or the nudge |
Expand Down
17 changes: 17 additions & 0 deletions docs/voice-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ Each published portion records its exact content digest, author identity, destin
That is accountability for deliberate speech, not an automated claim that any scan makes arbitrary private content safe to say out loud.
The same transport can carry a text conversation, such as iMessage, when the owner's policy authorizes that destination; each conversation is bound to one destination, so a call's reply is never sent as a text and a text's reply is never spoken, and `bin/fm-inbox.sh conversation --help` owns that policy and binding.

### Running the courier pickup

On a Linux host with systemd, run the pickup as the `fm-courier-pickup.service` user unit that [`bin/fm-courier-pickup-service.sh`](../bin/fm-courier-pickup-service.sh) renders; its header owns what the unit contains.
With the unit enabled and the Firstmate account lingering, the pickup starts at boot and recovers from failures.
The offline regression checks the installed unit's effective settings and startup command, and verifies systemd syntax when `systemd-analyze` is available.
Run the service commands as the Firstmate account; only the lingering setup below requires root.

1. The user manager starts at boot only for a lingering account, so confirm `loginctl show-user "$USER" -p Linger` prints `Linger=yes`; otherwise root runs `loginctl enable-linger <account>` once.
2. Install the unit for this home with `FM_HOME=<absolute home> bin/fm-courier-pickup-service.sh install`, retain the printed unit path for rollback, then run `systemctl --user daemon-reload`.
3. Enabling the unit turns the pickup on, so do it as part of courier activation: `systemctl --user enable --now fm-courier-pickup.service`.
Read its state with `systemctl --user status fm-courier-pickup.service` and its log with `journalctl --user -u fm-courier-pickup.service`.
4. After updating Firstmate, moving the checkout or changing `FM_HOME`, repeat step 2 and run `systemctl --user restart fm-courier-pickup.service`, because a running pickup keeps the code it started with.

To roll back, run `systemctl --user disable --now fm-courier-pickup.service`, delete the unit at the path printed by `install`, and run `systemctl --user daemon-reload`.
The pickup's own state under `state/courier-pickup/` stays, so enabling it again resumes from its cursor without refiling anything.
While it is stopped, spooled messages wait in the courier's spool and the courier's pickup deadline tells the captain.

## Existing audio prototype

Talk to a voice agent that sits in front of the first mate. It answers questions
Expand Down
116 changes: 116 additions & 0 deletions tests/fm-courier-pickup.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,119 @@ cp "$(command -v bash)" "$TMP_ROOT/codex"
"$TMP_ROOT/codex" -c 'PYTHONDONTWRITEBYTECODE=1 python3 "$1" "$2" "$3" "$$"; exit "$?"' fixture \
"$ROOT/tests/fm-courier-pickup-cases.py" "$ROOT" "$TMP_ROOT" || fail 'courier pickup contract'
pass 'courier pickup: one turn per record, refusals, replies, polls, stages and latency'

# The supervised launcher: the rendered systemd --user unit must be valid and
# run this checkout's pickup for the home it names, with the opt-in it needs.
# Boot start and restart-on-failure need a live user manager (VM pass).
SERVICE="$ROOT/bin/fm-courier-pickup-service.sh"
HOME_DIR="$TMP_ROOT/service-home"
CONFIG="$TMP_ROOT/config"
mkdir -p "$HOME_DIR" "$TMP_ROOT/courier-root"
installed=$(FM_HOME="$HOME_DIR" XDG_CONFIG_HOME="$CONFIG" "$SERVICE" install) || fail 'service install'
UNIT="$CONFIG/systemd/user/fm-courier-pickup.service"
assert_equals "$UNIT" "$installed" 'install prints the unit path'
assert_equals "$(FM_HOME="$HOME_DIR" "$SERVICE" render)" "$(cat "$UNIT")" 'install writes the rendered unit'
if command -v systemd-analyze >/dev/null 2>&1; then
verify=$(systemd-analyze verify "$UNIT" 2>&1) || fail "systemd-analyze rejected the unit: $verify"
assert_not_contains "$verify" "$UNIT" 'systemd-analyze warns about the unit'
else
echo 'skip: systemd-analyze not found; unit syntax unverified here'
fi
# Run the unit's own command with its own environment: it must reach the pickup
# with FM_HOME and the opt-in (relocated offline, it then refuses the missing
# binding under that home), not exit 3 or demand an explicit FM_HOME.
python3 - "$UNIT" "$ROOT" "$HOME_DIR" "$TMP_ROOT" <<'PY' || fail 'installed unit launcher contract'
import codecs
import os
from pathlib import Path
import pwd
import shlex
import subprocess
import sys

unit, root, home, temp = sys.argv[1:]
settings = {}
section, pending = '', ''
for line in Path(unit).read_text().splitlines():
line = line.strip()
if not line or line.startswith(('#', ';')):
continue
pending += line
if pending.endswith('\\'):
pending = pending[:-1] + ' '
continue
line, pending = pending, ''
if line.startswith('[') and line.endswith(']'):
section = line[1:-1]
else:
key, value = line.split('=', 1)
settings.setdefault((section, key.strip()), []).append(value.strip())
assert not pending, 'unfinished unit continuation'


def single(key, default=''):
return settings.get(('Service', key), [default])[-1]


def words(value):
lexer = shlex.shlex(value, posix=True)
lexer.whitespace_split = True
lexer.commenters = lexer.escape = ''
return [codecs.decode(item.replace(r'\s', ' '), 'unicode_escape') for item in lexer]


def assignments(section, key):
result = []
for value in settings.get((section, key), []):
if not value:
result.clear()
else:
result.append(words(value))
return result


def capabilities(key, default_inverted):
inverted, names = default_inverted, set()
for index, value in enumerate(settings.get(('Service', key), [])):
negate = value.startswith('~')
members = set(words(value[1:] if negate else value))
if index == 0 or not members:
inverted, names = negate, members
elif negate:
names = names | members if inverted else names - members
else:
names = names - members if inverted else names | members
return inverted, names


command = assignments('Service', 'ExecStart')
unit_env = dict(item.split('=', 1) for line in assignments('Service', 'Environment') for item in line)
assert command == [[str(Path(root) / 'bin/fm-courier-pickup.py'), 'run']], ('ExecStart', command)
assert unit_env == {'FM_HOME': home, 'FM_NOTIFY_COURIER': '1'}, ('Environment', unit_env)
assert single('WorkingDirectory') == home, ('WorkingDirectory', single('WorkingDirectory'))
assert single('Restart') == 'on-failure', ('Restart', single('Restart'))
assert single('NoNewPrivileges').lower() in ('yes', 'true', 'on', '1'), 'NoNewPrivileges must be true'
wanted_by = {item for line in assignments('Install', 'WantedBy') for item in line}
assert wanted_by == {'default.target'}, ('WantedBy', wanted_by)
for key in ('User', 'Group'):
assert not single(key), (key, single(key))
assert capabilities('AmbientCapabilities', False) == (False, set()), 'unit adds ambient capabilities'
assert capabilities('CapabilityBoundingSet', True) == (True, set()), 'unit changes the capability bounding set'
assert not assignments('Service', 'ReadWritePaths'), 'unit grants writable paths'

env = dict(PATH=os.environ['PATH'], HOME=temp, FM_COURIER_ROOT=str(Path(temp) / 'courier-root'),
FM_COURIER_USER=pwd.getpwuid(os.getuid()).pw_name, **unit_env)
result = subprocess.run(command[0], cwd=single('WorkingDirectory'), env=env,
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
assert result.returncode == 1, ('rendered command startup refusal', result)
assert 'fm-courier-pickup: ' in result.stdout, ('rendered command runs the pickup', result.stdout)
assert str(Path(home) / 'state/imessage/binding.json') in result.stdout, ('rendered command passes FM_HOME', result.stdout)
PY
for bad in relative/home "$TMP_ROOT/has space" "$TMP_ROOT/missing"; do
mkdir -p "$TMP_ROOT/has space"
if FM_HOME="$bad" XDG_CONFIG_HOME="$TMP_ROOT/refused" "$SERVICE" install 2>/dev/null; then
fail "install accepted FM_HOME '$bad'"
fi
done
assert_absent "$TMP_ROOT/refused/systemd/user/fm-courier-pickup.service" 'a refused install wrote a unit'
pass 'courier pickup service: valid unit runs this pickup for its home, refuses unsafe paths'
Loading