Skip to content

feat(bin): add a localhost web page onto the primary first mate - #268

Merged
Amplify-Logic merged 7 commits into
mainfrom
fm/starship-web
Oct 6, 2026
Merged

Amplify-Logic merged 7 commits into
mainfrom
fm/starship-web

Conversation

@Amplify-Logic

@Amplify-Logic Amplify-Logic commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Intent

Natalia too for her side. If she wants. Not sure if she would want to use something this nerdy. We could create a cleaner interface version? Lightweight / reliable but still has the full capabilities but just isn't display in the terminal. Just a localhost web instance. Let's build that now too. I would want to also use this in moments if I want a break from the terminal or this 'block box' style working aesthetic of herdr.

Context: Starship is Firstmate running in a terminal (Claude Code primary inside a herdr/tmux pane). Lars, and soon colleagues such as Luc and Natalia, run their own homes. The ask is a clean web interface on localhost that gives the full first-mate experience without the terminal.

What Changed

  • Adds bin/fm-web.sh (start/stop/status/url/serve, default port 8767) and bin/fm-web.py, a Python standard-library server that binds only to 127.0.0.1. It serves a chat page showing the primary Claude Code session's conversation, built from the end of its transcript. Messages and replies render as Markdown bubbles, tool calls collapse into lines you can expand, compactions show as dividers, and a header shows working, ready or not running. The page follows session restarts, /clear (through the Claude session registry) and compaction without a restart, and a side panel reuses the bridge view's fleet snapshot.
  • The message box sends only through bin/fm-desk-voice.sh send --source web, the same path the desk floater uses. Pasted or dropped PNG, JPEG, GIF and WebP images are saved owner-only under state/desk-voice/shots/ and passed with --image. Under the box, the page shows whether the message was typed into the chat or saved to the mailbox.
  • Requests are guarded by an owner-only token in state/web/token, which the first visit swaps for an HttpOnly, SameSite=Strict cookie. Other guards: a Host check for 127.0.0.1:<port>, CSRF/Origin/fetch-metadata checks on POST, and a strict nonce-based CSP. The request log never records the token and skips successful polls. Also adds docs/web.md, cross-links from the bridge-view, desk-floater, configuration, scripts and home-layout docs, a fork-surface capability entry, and tests/fm-web.test.sh covering parsing, session following, guards, sending, the fleet panel and the launcher lifecycle.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The fix round makes four small, correct changes: bash-mode entries no longer mark the primary busy, successful /api GETs are no longer logged, the test-only conversation subcommand is removed, and only 127.0.0.1: is accepted as Host and Origin. Each change is covered by an endpoint-level test that would fail without it.

Testing

The targeted test suite tests/fm-web.test.sh passed. I then stood up a real Claude Code primary in a disposable lab home on a private tmux socket and drove the page live in headless Chrome over CDP. That covered: - sign-in, with the token swapped for a cookie; - sending from the page into the terminal chat, with Working → Ready and the reply rendered with correct list numbering and bold text; - a terminal bash-mode command leaving the page Ready; - image drop and send, with the file saved owner-only and read by the primary; - following a terminal /clear; - light, dark and narrow layouts with the Fleet toggle; - the Not running state after the primary stopped; - start, status, url and stop through the launcher. Curl attacks on the live server were all refused with 403 (400 for the non-image attachment) and none reached the primary. The request log held no token and no poll lines. The one blocker I hit, a terminal permission prompt, is the documented and accepted limit, and I answered it once in the terminal. Populated fleet buckets were checked only by the unit test's stubbed snapshot, not live, because the lab fleet was empty. Screenshots and transcripts are in the evidence directory. The lab home, Chrome and the temporary driver were removed, and the worktree is clean.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Captain opens the printed sign-in link: the token is swapped for an HttpOnly cookie, the address bar is cleaned, and the primary's conversation shows as chat with tool calls collapsed and the state re… ✅ pass live 01-signed-in.txt, 01-signed-in-light.png, 13-security-guards.txt (303 + HttpOnly SameSite=Strict cookie)
Captain types a message in the page and presses Enter: it is typed into the terminal primary's chat, the page shows Working then Ready, and the Markdown reply renders with numbered items 1/2/3 and bol… ✅ pass live 02-send-and-reply.txt, 02-working.png, 03-reply-light.png, 04-terminal-pane-after-web-send.txt
Captain runs a bash-mode command (! cmd) in the terminal: the page does not get stuck on Working ✅ pass live 05-bash-mode-state.txt, 05-after-bash-mode-ready.png. In this Claude version the ! command also started a short model turn, so the strictly idle case rests on the unit test
Captain drops an image onto the page and sends it: only image types are accepted, the file is saved owner-only in state/desk-voice/shots/, and the primary can open it ✅ pass live 06-image-send.txt, 06-image-attached.png, 08-shots-folder.txt, 09-image-read.txt, 09-image-read-reply.png
Captain runs /clear in the terminal: the page switches to the new session even though .lock-session still names the old id ✅ pass live 11-clear-follow.txt, 11-after-clear.png, 12-after-clear-conversation.png
Adversarial: requests with no or wrong token, a rebound or localhost Host, a wrong port, a POST without CSRF, a foreign or localhost Origin, cross-site fetch metadata, or a non-image attachment are re… ✅ pass live 13-security-guards.txt (403 for every case, 400 for the non-image attachment, LAN connection refused, 0 PWN strings in the pane or state)
Request log records no token and skips successful /api/ polls ✅ pass live 14-web-log.txt
Page follows the system light/dark theme, and in a narrow window the fleet panel sits behind a working Fleet button ✅ pass live 15-layout-theme.txt, 15-light-theme.png, 03-reply-light.png (dark), 16-narrow-fleet-closed.png, 17-narrow-fleet-open.png
When the primary stops, the page reads Not running; the launcher stops the server, status exits 3, and url refuses ✅ pass live 18-offline.txt, 18-primary-stopped.png, launcher-start.txt
Fleet panel shows populated Needs you / Under way / Recently done entries from the bridge-view snapshot ⏸️ untested no The earlier payload did not establish a live result. The disposable lab home had no fleet work, so every bucket showed 'Nothing here' live. Populated buckets were only checked by the unit test 'the fl…

Signed in: conversation, Ready state, fleet panel (light)

Evidence: Terminal pane showing the message the page typed and the reply

Source: Terminal pane showing the message the page typed and the reply

 ▐▛███▛█   Claude Code v2.1.292
▝▜██████▀  Opus 5.5 (1M context) · Claude Team
 ▝▝   ▝▝   ~/.no-mistakes/worktrees/9957e108f4d7/01M49HVXTQMYERRNX9TPQJNMEM
❯ Take the helm for this lab home (run the session start), then reply with one short line.
  Listed 1 directory, ran 2 shell commands
⏺ Captain, I'm at the helm. The fleet is empty and nothing needs your call; the only notices are that three worker tools have updated past the versions they
  were last certified on.
✻ Churned for 18s · done 11:38 PM
❯ Reply without using any tools. Give me three options as a Markdown numbered list with a blank line between each item, and put the word bold in bold.
⏺ Captain, here are three options for what to do next, listed on screen.
  1. Re-certify the three updated worker tools so they're back on verified versions, and use this bold step first.
  2. Set up your project list and preferences, since this home has neither yet.
  3. Leave things as they are until you have work to send.
✻ Brewed for 3s · done 11:40 PM
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯ 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ⚓ Opus 5.5 (1M context)·medium │ 🧠6% ⚡28% │ 🚢0 🧪0 ⏸0 ⚠0 🪦0 📋0 │ 👁 NO-WATCH -- │ $0.43 │ 💤--
  ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents
Evidence: Send transcript (delivery result, state, list markup)

Source: Send transcript (delivery result, state, list markup)

waitFor ok (2013ms): document.getElementById('result').textContent && !/Sending/.test(document.getElementById('result').textContent)
eval delivery result: "Typed into the first mate's chat."
eval input after send: ""
waitFor ok (0ms): document.querySelector('#state .label').textContent==='Working'
shot ~/.no-mistakes/evidence/01M49HVXTQMYERRNX9TPQJNMEM/02-working.png
waitFor ok (4022ms): document.querySelector('#state .label').textContent==='Ready' && document.querySelectorAll('#items .msg.assistant').length>=2
eval last reply: {"html":"<p>Captain, here are three options for what to do next, listed on screen.</p><ol><li>Re-certify the three updated worker tools so they're back on verified versions, and use this <strong>bold</strong> step first.</li></ol><ol start=\"2\"><li>Set up your project list and preferences, since this home has neither yet.</li></ol><ol start=\"3\"><li>Leave things as they are until you have work to send.</li></ol>","ols":[null,"2","3"],"lis":3,"strong":["bold"]}
shot ~/.no-mistakes/evidence/01M49HVXTQMYERRNX9TPQJNMEM/03-reply-light.png
![Page stays Ready after a terminal bash-mode command](https://github.com/user-attachments/assets/8a218f40-44a3-49db-a15a-713cf3c53e20) - Evidence: [Page stays Ready after a terminal bash-mode command](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/05-after-bash-mode-ready.png) ![Image dropped onto the page as a thumbnail](https://github.com/user-attachments/assets/317471e2-af27-4536-bb59-5d7cdd04c419) - Evidence: [Image dropped onto the page as a thumbnail](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/06-image-attached.png) ![Primary reads the sent image and answers 'red'](https://github.com/user-attachments/assets/ee310c1e-78f6-457d-a54a-44ac75372aa6) - Evidence: [Primary reads the sent image and answers 'red'](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/09-image-read-reply.png)
Evidence: Shots folder permissions and saved PNG

Source: Shots folder permissions and saved PNG

total 8
drwx------@ 3 larstolhurst  staff   96 Oct  6 23:41 .
drwxr-xr-x@ 3 larstolhurst  staff   96 Oct  6 23:41 ..
-rw-------@ 1 larstolhurst  staff  123 Oct  6 23:41 20261006T214100251232Z-edc2f569.png
700 /var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T//fm-lab.ZSr8hz/state/desk-voice/shots
600 /var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T//fm-lab.ZSr8hz/state/desk-voice/shots/20261006T214100251232Z-edc2f569.png
/var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T//fm-lab.ZSr8hz/state/desk-voice/shots/20261006T214100251232Z-edc2f569.png: PNG image data, 48 x 48, 8-bit/color RGB, non-interlaced
Evidence: Terminal permission prompt the page cannot answer (documented limit)

Source: Terminal permission prompt the page cannot answer (documented limit)

  /var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T/fm-lab.ZSr8hz/state/desk-voice/shots/20261006T214100251232Z-edc2f569.png
⏺ Captain, I can't tell you the colour. I only got the file path, not the image itself, and you asked me not to use tools, so I can't open it. Paste the image
  into the chat or let me read the file, and I'll answer in one word.
✻ Worked for 2s · done 11:41 PM
❯ Open that screenshot file with your Read tool and tell me its colour in one word.
  Reading /var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T/fm-lab.ZSr8hz/state/desk-voice/shots/20261006T214100251232Z-edc2f569.png
  ⎿  /var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T/fm-lab.ZSr8hz/state/desk-voice/shots/20261006T214100251232Z-edc2f569.png
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 Read outside the working directories
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 Read(/var/folders/2c/9sf1hvpn23q336b08gs1np0c0000gn/T/fm-lab.ZSr8hz/state/desk-voice/shots/20261006T214100251232Z-edc2f569.png)
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 Auto mode and the sandbox read outside the working directories without asking. Yes or No answers for this read; the rest is for later reads. Block: the file
 tools refuse reads outside the working directories in every project. Sandboxed commands are not changed on this machine (the sandbox is off, its filesystem
 rules are relaxed, a managed read-path lock is on, or the working directory name has glob characters). To undo, remove
 permissions.blockReadsOutsideWorkingDirectories from your user settings; file tools follow next session, sandboxed commands at once.
 Allow this read outside the working directories?
 ❯ 1. Yes, and keep allowing any reads outside the working directories
   2. No, and block reads outside the working directories from now on
   3. No, and ask again next time
   4. Yes, but ask again next time
 Esc to cancel · Tab to amend
![Page after a terminal /clear: only the /clear entry](https://github.com/user-attachments/assets/49a747a0-ed12-430a-988e-af023b16b9c3) - Evidence: [Page after a terminal /clear: only the /clear entry](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/11-after-clear.png) ![New conversation after /clear with a page-sent message](https://github.com/user-attachments/assets/847d0b7f-f820-472b-90b5-2585d6eba2c7) - Evidence: [New conversation after /clear with a page-sent message](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/12-after-clear-conversation.png)
Evidence: Live security guard probes (status codes)

Source: Live security guard probes (status codes)

GET / without token or cookie                                          -> 403
GET /api/conversation without cookie                                   -> 403
GET /?token=<wrong>                                                    -> 403
sign-in GET /?token=<real> (headers):
HTTP/1.1 303 See Other
Location: /
Set-Cookie: fm_web_18767=<redacted>; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000
GET /api/conversation with cookie                                      -> 200
GET /api/fleet with cookie                                             -> 200
DNS-rebinding: cookie + Host: evil.example:18767                       -> 403
Host: localhost:18767 (dropped spelling)                               -> 403
Host: 127.0.0.1:9999 (wrong port)                                      -> 403
POST /api/send with cookie but no CSRF header                          -> 403
POST /api/send CSRF ok, Origin: http://evil.example                    -> 403
POST /api/send CSRF ok, Origin: http://localhost:18767                 -> 403
POST /api/send CSRF ok, Sec-Fetch-Site: cross-site                     -> 403
POST /api/send no cookie, CSRF guessed                                 -> 403
POST /api/send with a non-image attachment                             -> 400
listening sockets:
  Python 127.0.0.1:18767
connect via LAN address 192.168.178.103:
  000 (000 = refused)
Evidence: web.log: no token, no successful poll lines

Source: web.log: no token, no successful poll lines

web.log lines:       22
lines containing the token: 0
successful /api/conversation GET lines: 0
--- log content:
06/Oct/2026 23:39:52 - "GET /
06/Oct/2026 23:39:52 - "GET / HTTP/1.1" 200 -
06/Oct/2026 23:39:52 - "GET /favicon.ico HTTP/1.1" 404 -
06/Oct/2026 23:40:06 - "POST /api/send HTTP/1.1" 200 -
06/Oct/2026 23:41:01 - "POST /api/send HTTP/1.1" 200 -
06/Oct/2026 23:41:16 - "POST /api/send HTTP/1.1" 200 -
06/Oct/2026 23:43:22 - "GET / HTTP/1.1" 403 -
06/Oct/2026 23:43:52 - "POST /api/send HTTP/1.1" 200 -
06/Oct/2026 23:44:14 - "GET / HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "GET /api/conversation HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "GET /
06/Oct/2026 23:44:14 - "GET /
06/Oct/2026 23:44:14 - "GET /api/conversation HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "GET /api/conversation HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "GET /api/conversation HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "GET / HTTP/1.1" 200 -
06/Oct/2026 23:44:14 - "POST /api/send HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "POST /api/send HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "POST /api/send HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "POST /api/send HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "POST /api/send HTTP/1.1" 403 -
06/Oct/2026 23:44:14 - "POST /api/send HTTP/1.1" 400 -
![Light theme](https://github.com/user-attachments/assets/113d144f-d174-4240-860e-3f9961a483e1) - Evidence: [Light theme](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/15-light-theme.png) ![Narrow window, Fleet panel closed](https://github.com/user-attachments/assets/cf81b1f4-295a-4f6e-a938-182eb7c4f6d3) - Evidence: [Narrow window, Fleet panel closed](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/16-narrow-fleet-closed.png) ![Narrow window, Fleet panel opened via the Fleet button](https://github.com/user-attachments/assets/6292489f-4d82-44d0-bd46-0df6aefc73f9) - Evidence: [Narrow window, Fleet panel opened via the Fleet button](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/17-narrow-fleet-open.png) ![Not running after the lab primary stopped](https://github.com/user-attachments/assets/d43a7312-4a7a-4c2b-aa5f-ec87fb796056) - Evidence: [Not running after the lab primary stopped](https://github.com/Amplify-Logic/firstmate/blob/18455654221301bac292496f370b11d032aae19d/.no-mistakes/evidence/fm/starship-web/18-primary-stopped.png)
Evidence: Launcher start/status/stop/url transcript (token redacted)

Source: Launcher start/status/stop/url transcript (token redacted)

$ FM_HOME=$LAB bin/fm-web.sh start --port 18767
http://127.0.0.1:18767/?token=<redacted>
$ bin/fm-web.sh status
running on 127.0.0.1:18767 (pid 94423)
$ lsof listen
Python 127.0.0.1:18767
$ bin/fm-web.sh stop
stopped
$ bin/fm-web.sh status
stopped
exit 3
$ bin/fm-web.sh url
fm-web: not running; start it with fm-web.sh start
exit 1

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed ✅
  • ⚠️ bin/fm-web.py:582 - The page shows "Working" after a terminal bash-mode command (! cmd) even though no turn started. Claude Code records ! git status as two plain user entries, &lt;bash-input&gt;…&lt;/bash-input&gt; and &lt;bash-stdout&gt;…&lt;/bash-stdout&gt;, with no origin and no turn_duration or local_command after them (I checked a real primary transcript: turn_duration → bash-input → bash-stdout → metadata only). _classify_user_string turns each one into an event item, and _user then sets self.busy = True (line 582 for string content, line 611 for list content). So if the captain runs a shell command in the terminal while the first mate is idle, the page reads Working until the next model turn ends. This is the same kind of bug as the earlier local_command fix at line 558, where an idle /clear did not read Ready. Fix: set busy only for entries that start a turn. Either leave busy unchanged for the bash-input/bash-stdout/bash-stderr tags, the way SKIPPED_TAGS items already leave it unchanged, or set busy only for human, task-notification, or tool_result entries.
  • ℹ️ bin/fm-web.py:944 - log_message adds a line to state/web/web.log for every request, and nothing ever rotates or trims the file. The page polls /api/conversation every 1.5 s while visible and every 6 s while hidden, and /api/fleet every 30 s. A tab left open therefore adds about 2,400 lines an hour (around 3 MB a day while visible), with no limit. The log exists to record paths without the token, and logging every successful poll adds nothing to that. Fix: skip logging successful /api/conversation polls (or all 2xx GETs under /api/), or cap the file's size.
  • ⚠️ bin/fm-web.py:1395 - Possibly unneeded component: the fm-web.py conversation --home subcommand is used only by tests/fm-web.test.sh. bin/fm-web.sh never calls it and docs/web.md does not mention it. Nothing in the intent needs a second CLI way into the transcript reader, and the tests could read the same data through the authenticated /api/conversation endpoint of a test server they already start. This is the same kind of test-only production surface as the FM_WEB_CLAUDE_DIRS override the user chose to remove in round 1. Recommend removing the subcommand and pointing the parsing tests at the served endpoint, unless the user wants to keep it as a debugging tool.
  • ⚠️ bin/fm-web.py:930 - A narrower form would do: allowed_hosts, and through it allowed_origins, accept both 127.0.0.1:&lt;port&gt; and localhost:&lt;port&gt;. The launcher only ever prints http://127.0.0.1:&lt;port&gt;/?token=…, and the sign-in cookie is set per host, so the localhost spelling is a second accepted host that no documented flow uses. Accepting only 127.0.0.1:&lt;port&gt; would meet the intent and leave one Host/Origin pair to guard. Recommend dropping the localhost spelling, along with its line in docs/web.md's security model and the localhost assertion in tests/fm-web.test.sh, unless the user wants it.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Captain opens the printed sign-in link: the token is swapped for an HttpOnly cookie, the address bar is cleaned, and the primary's conversation shows as chat with tool calls collapsed and the state re… ✅ pass live 01-signed-in.txt, 01-signed-in-light.png, 13-security-guards.txt (303 + HttpOnly SameSite=Strict cookie)
Captain types a message in the page and presses Enter: it is typed into the terminal primary's chat, the page shows Working then Ready, and the Markdown reply renders with numbered items 1/2/3 and bol… ✅ pass live 02-send-and-reply.txt, 02-working.png, 03-reply-light.png, 04-terminal-pane-after-web-send.txt
Captain runs a bash-mode command (! cmd) in the terminal: the page does not get stuck on Working ✅ pass live 05-bash-mode-state.txt, 05-after-bash-mode-ready.png. In this Claude version the ! command also started a short model turn, so the strictly idle case rests on the unit test
Captain drops an image onto the page and sends it: only image types are accepted, the file is saved owner-only in state/desk-voice/shots/, and the primary can open it ✅ pass live 06-image-send.txt, 06-image-attached.png, 08-shots-folder.txt, 09-image-read.txt, 09-image-read-reply.png
Captain runs /clear in the terminal: the page switches to the new session even though .lock-session still names the old id ✅ pass live 11-clear-follow.txt, 11-after-clear.png, 12-after-clear-conversation.png
Adversarial: requests with no or wrong token, a rebound or localhost Host, a wrong port, a POST without CSRF, a foreign or localhost Origin, cross-site fetch metadata, or a non-image attachment are re… ✅ pass live 13-security-guards.txt (403 for every case, 400 for the non-image attachment, LAN connection refused, 0 PWN strings in the pane or state)
Request log records no token and skips successful /api/ polls ✅ pass live 14-web-log.txt
Page follows the system light/dark theme, and in a narrow window the fleet panel sits behind a working Fleet button ✅ pass live 15-layout-theme.txt, 15-light-theme.png, 03-reply-light.png (dark), 16-narrow-fleet-closed.png, 17-narrow-fleet-open.png
When the primary stops, the page reads Not running; the launcher stops the server, status exits 3, and url refuses ✅ pass live 18-offline.txt, 18-primary-stopped.png, launcher-start.txt
Fleet panel shows populated Needs you / Under way / Recently done entries from the bridge-view snapshot ⏸️ untested no The earlier payload did not establish a live result. The disposable lab home had no fleet work, so every bucket showed 'Nothing here' live. Populated buckets were only checked by the unit test 'the fl…
  • bash tests/fm-web.test.sh (targeted suite for this change; all 11 cases passed)
  • bin/fm-lab-home.sh create $LAB + tmux -L fm-lab new-session ... -e FM_HOME=$LAB claude (real Claude primary, private lab socket; took the lab lock on its first turn)
  • FM_HOME=$LAB bin/fm-web.sh start --port 18767, then status, stop, status (exit 3) and url (refused once stopped)
  • Headless Chrome 153 driven over CDP: sign-in with ?token=, then the address, HttpOnly cookie, header state, chat items and fleet panel
  • Typed a message into the page box and pressed Enter; the delivery note, Working/Ready transitions, rendered reply (&lt;ol start&gt; numbering, &lt;strong&gt;) and the terminal pane all matched
  • Ran ! echo bash-mode-probe in the terminal; the page state stayed Ready for 10s
  • Dropped a PNG and a .txt file onto the page; only the PNG became a thumbnail. Sent it, then checked state/desk-voice/shots/ (0700 folder, 0600 file) and had the primary Read the file
  • Ran /clear in the terminal; the page switched to the registry session (.lock-session kept the old id) and a follow-up message landed in the new conversation
  • Ran curl attacks on the live server: no token, wrong token, Host evil.example, Host localhost, wrong port, POST without CSRF, foreign Origin, localhost Origin, Sec-Fetch-Site: cross-site, no cookie, non-image attachment, and a connection over the LAN IP
  • Inspected state/web/web.log for the token and for successful /api/ GET poll lines
  • CDP screenshots of light and dark themes and of the 420px layout with the Fleet toggle
  • tmux -L fm-lab kill-server, then confirmed the page reads Not running
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

Serves the primary Claude session's conversation as a calm chat page on
127.0.0.1, with tool calls collapsed into quiet expandable lines,
compaction dividers, and a working/ready/not-running marker. A message box
with image paste and drop sends only through fm-desk-voice.sh send, and a
side panel reuses the bridge view's fleet glance. A per-home token, cookie,
Host, CSRF and Origin checks guard every request.
…one assertion in tests/fm-web.test.sh: "token is owner-only". The cause is a bug in the test, not in the web server: the token file really is mode 600. **Cause:** the test read the file mode with `stat -f '%Lp' FILE || stat -c '%a' FILE`. On Linux, GNU `stat -f` doesn't fail. It means "filesystem status", so it succeeds and prints a filesystem report. The `stat -c` fallback never ran, and the test compared that report against "600". The CI log shows this: the expected value was the filesystem report ending in "600", and the actual value was "600". **Rule this breaks:** a test that reads file mode bits must pick the stat form for the platform (BSD `stat -f %Lp` on Darwin, GNU `stat -c %a` elsewhere). Trying one and falling back to the other doesn't work. tests/fm-account.test.sh already notes this. I checked every `stat` use in the files this PR adds (tests/fm-web.test.sh, bin/fm-web.sh, bin/fm-web.py). This assertion is the only shell `stat` call, so it is the only place to fix. **Fix:** tests/fm-web.test.sh:288 now picks by platform: `if [ "$(uname)" = Darwin ]; then stat -f '%Lp' …; else stat -c '%a' …; fi`. This is the same pattern as tests/fm-account.test.sh and tests/fm-channel-intake.test.sh. It is a one-line change in the test, and production code is unchanged. **Verification:** `bash tests/fm-web.test.sh` passes all 11 tests on macOS, and `shellcheck -x tests/fm-web.test.sh` is clean. I couldn't run the Linux branch here. It uses the same `stat -c %a` call that tests/fm-account.test.sh already runs in CI
@Amplify-Logic
Amplify-Logic merged commit 8aa9cf8 into main Oct 6, 2026
44 of 45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant