Land Phase 0 launch chain: SSH -> desktop Terminal -> claude -> phone

Phase 0 proven by hand (N=3) across multiple rooms.

- scripts/gui-launch.sh: open a desktop Terminal via osascript so claude runs in
  the GUI session (login Keychain + real TTY), avoiding a long-lived token (D11).
- scripts/launch-claude.sh: name the session `claude -n "<repo> - <topic>"` so
  Remote Control's phone conversation index is readable.
- .env.example: bot credential schema (real .env stays gitignored).
- AGENTS.md / ROADMAP.md: D11, Phase 0 results, Phase 1 carry-overs.
This commit is contained in:
Keysat
2026-06-15 13:58:15 -05:00
parent 326c3d5398
commit b6cc829f53
5 changed files with 136 additions and 7 deletions
+10
View File
@@ -0,0 +1,10 @@
# matrix-bridge bot credentials — COPY to .env (gitignored) and fill in real values.
# Never commit real values. The bot runs on the Spark; the real .env lives next to it there.
MATRIX_HOMESERVER=https://<your-synapse-host>
MATRIX_USER=@<bot>:<your-domain>
MATRIX_DEVICE_ID=matrix-bridge-bot
MATRIX_ACCESS_TOKEN=
# Optional — kept for recovery / re-minting a token. The bot authenticates with the access token,
# not the password (logging in every start would spawn a new device each time).
MATRIX_PASSWORD=
+56 -6
View File
@@ -98,6 +98,14 @@ Condensed from the scoping workshop. Each: the call, why, what it beat.
the bot ever handles sensitive content over untrusted transport. the bot ever handles sensitive content over untrusted transport.
- **D10 — Spark Control manages the bot (Phase 3).** Status on the dashboard + one-click - **D10 — Spark Control manages the bot (Phase 3).** Status on the dashboard + one-click
update/restart, the same SSH-behind-buttons pattern Spark Control uses for the Sparks today. update/restart, the same SSH-behind-buttons pattern Spark Control uses for the Sparks today.
- **D11 — Launch into a desktop Terminal, not a headless token (Phase 0).** The SSH session
can't reach the GUI login Keychain, so a plain `ssh … claude` reports "Not logged in." Rather
than mint a long-lived `claude setup-token`, the launcher (`scripts/gui-launch.sh`) uses
`osascript` to open a Terminal.app window in the **GUI session**, where `claude` inherits the
existing Keychain login and a real TTY. *Beat:* the long-lived OAuth token (Approach A) — works
and is fully unattended, but adds a credential to manage; kept as the documented fallback if the
Mac is ever driven headless (logged out). *Cost:* requires the Mac logged in + a one-time
Terminal Automation grant.
## Sovereignty constraint ## Sovereignty constraint
@@ -128,9 +136,51 @@ once" is not done.
- **Scaffolded 2026-06-15** from a prior scoping package (SPEC/DECISIONS/CLAUDE/KICKOFF), - **Scaffolded 2026-06-15** from a prior scoping package (SPEC/DECISIONS/CLAUDE/KICKOFF),
folded into this AGENTS.md (decisions + placement), `ROADMAP.md` (phases), and the wrapper + folded into this AGENTS.md (decisions + placement), `ROADMAP.md` (phases), and the wrapper +
config skeleton. No bot code yet — by design. config skeleton. No bot code yet — by design.
- **Next: Phase 0 (manual chain validation, N=3)** — Matrix onboarding (Element, Space, first - **Phase 0 — SSH leg proven (2026-06-15).** Mac Remote Login is on. The Spark `spark-32d0`
room, a bot user), write + locally test `scripts/launch-claude.sh`, passwordless SSH from the (user `modelo`) reaches the Mac over `starttunnel`/WireGuard at `10.59.211.5`*not* the
Spark to the Mac, prove the full chain (message → SSH → wrapper → Claude session → phone LAN (the Spark isn't on the Mac's LAN subnet). A dedicated per-machine key
notification I can drive) by hand at least 3 times, and record the first room→repo mapping. (`spark-control@spark-32d0` = `~/.ssh/id_ed25519` on the Spark) is in the Mac's
Bot code starts only after Phase 0 is proven. The original KICKOFF prompt is the step-by-step `authorized_keys`. SSH alias **`mac-bridge`** in the Spark's `~/.ssh/config` selects that key
for Phase 0. (`IdentityFile ~/.ssh/id_ed25519` + `IdentitiesOnly yes`) — required because the pre-existing
`Host * → id_ed25519_shared` rule otherwise shadows the default key. The bot's entire Mac hop
is therefore `ssh mac-bridge '<command>'`. *Phase 1:* bake the dedicated key + an equivalent
alias/config into the bot's Docker image (modelo's `~/.ssh/config` won't exist in the
container).
- **Phase 0 — launch chain proven end-to-end (2026-06-15).** `ssh mac-bridge → gui-launch.sh
→ launch-claude.sh → authenticated claude → phone via Remote Control` works against a real
repo (`premier-gunner`). Chose **Approach B (desktop Terminal)** over a headless token — see
**D11**. Two pieces it took: (1) `~/.local/bin` (where `claude` lives) had to be added to
`~/.zprofile`, because a non-interactive login shell skips `.zshrc`; (2) `scripts/gui-launch.sh`
opens a Terminal.app window via `osascript` so `claude` runs inside the GUI session (login
Keychain + real TTY) — needed a one-time "Allow ssh to control Terminal" Automation grant.
*Known caveats for the bot:* (a) a never-trusted repo stalls at Claude's first-run folder-trust
gate — unattended launches must target already-trusted repos or pass a skip flag; (b) if the
TCC Automation grant ever resets, a launch stalls until someone clicks Allow — the bot should
detect a failed launch and report it back to the room, not hang.
- **Phase 0 — Matrix bot user live (2026-06-15).** Homeserver is the StartOS Synapse exposed on
**clearnet at `https://matrix.gilliam.ai`** (`server_name` = `matrix.gilliam.ai`, Synapse
1.154.0) — *not* the stale `@gilliam:<onion>` account found in Element. Created a dedicated
non-admin bot **`@agent:matrix.gilliam.ai`** (type `bot`) via the Synapse Admin Dashboard
(StartOS "Create Bot User" is appservice-only/greyed out). Minted a long-lived access token
(fixed `device_id` `matrix-bridge-bot`), verified via `whoami`, and stored
homeserver/user/token/device_id (+ password for recovery) in the gitignored **`.env`** (chmod
600). `config.toml` holds homeserver+user; `.env.example` documents the schema. Bot reuses the
stored token — never re-login per start (avoids device churn); no E2EE (D9). *Note:* the
bot↔Synapse hop is now public-internet TLS, which softens D9's "transport already WireGuard-
private" rationale (still TLS to the user's own server, single-user content) — revisit if it matters.
- **Phase 0 — rooms mapped (2026-06-15).** 9 project rooms in `config.toml` (premier-gunner,
recap, recap-relay, spark-control, ten31-transcripts, ten31-signal-engine, keysat, proof-of-work,
ten31-database), each `room_id → /Users/macpro/Projects/<repo>`. `@agent` is **joined to all 9**
(via its token), so the Phase-1 bot will see messages in each. *Manual by-hand launches must keep
message text free of `'`/`"`* — the typed SSH command line breaks on them (PS2 `>` hang); the
Phase-1 bot avoids this via `shlex.quote`.
- **Phase 0 — PROVEN / DONE (2026-06-15).** N=3 by-hand runs succeeded across multiple rooms
(recap, spark-control, premier-gunner): each opened a Terminal in the right repo, started `claude`
on the message, and pushed a drivable session to the phone. The deterministic core holds.
Added session naming: `launch-claude.sh` now runs `claude -n "<repo> - <topic>"` (topic from the
message, overridable via `$MB_SESSION_NAME`) so Remote Control's phone index is readable —
confirmed `-n` drives the phone app's conversation label.
- **Next: Phase 1 — the matrix-nio bot.** Container on the Spark, logged in as `@agent` (token in
`.env`), listening in the 9 mapped rooms; on a message it runs `ssh mac-bridge gui-launch.sh
<repo_dir> <message>` (built with `shlex.quote`) and reports failures back to the room. See
ROADMAP Phase 1 (also: bake key+config into the image, curated `$MB_SESSION_NAME` topic, fail-loud).
+9
View File
@@ -14,6 +14,15 @@ after it.
- matrix-nio bot in a container on the Spark, logged in as a bot Matrix user. - matrix-nio bot in a container on the Spark, logged in as a bot Matrix user.
- One hardcoded room → one repo. Any message in it spawns a session via the Mac wrapper. - One hardcoded room → one repo. Any message in it spawns a session via the Mac wrapper.
- Carry over from Phase 0's proven launch chain (`ssh mac-bridge → gui-launch.sh → launch-claude.sh`):
- **Bake the SSH key + `mac-bridge` config into the container** (modelo's `~/.ssh` won't exist there).
- **Named sessions for the phone app.** Pass `claude -n "<repo> — <topic>"` so the Remote Control
conversation index is readable (project + topic). Bot derives `<topic>` from the message; confirm
whether the app labels off `-n` or `--remote-control <name>`. Plumb a name arg through the wrappers.
- **Quote-safe message passing.** Bot builds the SSH command with `shlex.quote`; `gui-launch.sh`
already isolates the osascript/shell layers via a `%q` temp script — stress-test with hostile text.
- **Fail loud, not silent.** Detect a stalled launch (untrusted-repo trust gate, or a reset Terminal
Automation grant) and report it back into the room instead of hanging.
- **Exit (falsifiable):** 3 consecutive real messages each correctly launch a drivable - **Exit (falsifiable):** 3 consecutive real messages each correctly launch a drivable
session on the phone. session on the phone.
+47
View File
@@ -0,0 +1,47 @@
#!/bin/zsh -l
# gui-launch.sh — matrix-bridge GUI launcher (Approach B: "open a real desktop terminal").
#
# Invoked over SSH by the bot: gui-launch.sh <repo_dir> <prompt...>
#
# Opens a Terminal.app window on the logged-in desktop that runs launch-claude.sh, so
# `claude` runs INSIDE the GUI session: it gets the login Keychain (no long-lived token
# needed) and a real TTY. Claude Code Remote Control then surfaces the session to the phone.
#
# Requires: the Mac logged into its desktop, and a one-time "Allow ssh to control
# Terminal" Automation grant (System Settings > Privacy & Security > Automation).
#
# Layering: gui-launch.sh owns the GUI/session seam; launch-claude.sh owns the
# environment seam (cd + exec claude). Keep that split.
script_dir="${0:A:h}"
inner="$script_dir/launch-claude.sh"
repo_dir="$1"
shift
prompt="$*"
if [[ -z "$repo_dir" || -z "$prompt" ]]; then
print -u2 "usage: gui-launch.sh <repo_dir> <prompt>"
exit 2
fi
# Fail loud on a bad directory — never open a session in the wrong place (guardrail).
if [[ ! -d "$repo_dir" ]]; then
print -u2 "gui-launch: no such repo dir: $repo_dir"
exit 1
fi
# Embed repo+prompt in a throwaway script via %q so user text NEVER crosses the
# AppleScript string layer — only a safe mktemp path does. This sidesteps the
# SSH -> osascript -> shell -> wrapper multi-shell quoting trap the spec warns about.
launch_script="$(mktemp -t mb-launch)"
{
print -r -- '#!/bin/zsh -l'
printf 'exec %q %q %q\n' "$inner" "$repo_dir" "$prompt"
} >| "$launch_script"
chmod +x "$launch_script"
# mktemp paths contain no spaces/quotes, so embedding the path directly is safe.
osascript -e "tell application \"Terminal\" to do script \"$launch_script\"" \
-e 'tell application "Terminal" to activate'
print -- "gui-launch: opened Terminal for $repo_dir"
+14 -1
View File
@@ -21,4 +21,17 @@ fi
# Fail loud on a bad directory — never launch Claude in the wrong place. # Fail loud on a bad directory — never launch Claude in the wrong place.
cd "$repo_dir" || { print -u2 "launch-claude: no such repo dir: $repo_dir"; exit 1; } cd "$repo_dir" || { print -u2 "launch-claude: no such repo dir: $repo_dir"; exit 1; }
exec claude "$prompt" # Name the session "<repo> - <topic>" so it's identifiable in Remote Control's
# conversation index on the phone. Topic defaults to a trimmed slice of the message;
# the Phase-1 bot can override it with a curated topic via $MB_SESSION_NAME.
repo_name="${${repo_dir%/}:t}"
if [[ -n "$MB_SESSION_NAME" ]]; then
session_name="$MB_SESSION_NAME"
else
topic="${prompt//$'\n'/ }" # collapse newlines to keep the name one line
topic="${topic[1,60]}" # cap length for a tidy index entry
[[ ${#prompt} -gt 60 ]] && topic="${topic}"
session_name="${repo_name} - ${topic}"
fi
exec claude -n "$session_name" "$prompt"