@vibelord/lanes 1.2.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -24,6 +24,7 @@ measurements; substitute your account, containers and paths in operational comma
|
|
|
24
24
|
| Scroll a lane | mouse wheel / tmux copy-mode as before (history lives in the lane, not in the viewer) |
|
|
25
25
|
| Edit a lane's AGENTS.md, CLAUDE.md or .env.local | `Ctrl-Alt-e`: a floating **Edit** pane runs `lanes edit --focused`, one key picks (`1`–`5`; `q`/`Esc` closes), then `docker exec -it -u coder -w <checkout> <container> ~/.lz/bin/lane-edit <file>` replaces it — the editor runs in the lane's container as uid 1000, the server only checks the paths through `/proc/<init>/root` and copies nothing out ([guide](USERGUIDE.md#quick-edit)). `lanes edit: lane-edit is not installed in <c>` → `lanes install-container <c>`; `lane-edit: no editor on PATH` (exit 127) → the container has none of micro, hx, nvim, vim, vi (Lanes installs none); `container <c> is not running` → nothing to edit until it runs; `no git checkout in or above <dir>` → the lane works outside a checkout. The editor is the device's `lanes attach <device> --editor E` choice |
|
|
26
26
|
| Throttle monitor | its own full-size tab, `Throttle` (next to `Host`); it starts when you first open the tab and restarts itself if it exits. It is no longer an entry in every board's sidebar. |
|
|
27
|
+
| Open what an agent published, or upload from a phone | the Docs page, `https://<server>:8443/lanes-docs/`: phone **More → Docs**, the laptop bar's **Docs**, a ⌘/Ctrl-click on the sidebar's ` ↑ uploads · ▤ docs N` strip, or `d` on a lane for its own link; an agent publishes with `~/.lz/bin/lane publish <file>` or `lanes_publish` (section "Docs service"; the [user guide's Docs](USERGUIDE.md#docs)) |
|
|
27
28
|
| Detach | `Ctrl-Alt-o` then `d` — or just close the window. Nothing on the server stops. |
|
|
28
29
|
|
|
29
30
|
Sidebar glyphs: see the legend in [`docs/DESIGN.md`](DESIGN.md) (agent column `◆ ◇ ▸ ✓ ✗ _ ·`, terminal column
|
|
@@ -194,7 +195,7 @@ lanes keys revoke <device> remove one machine lanes keys adopt [--dry-run]
|
|
|
194
195
|
lanes invite <name> --email ADDR one-time join code (own terminal) lanes invite list | revoke <id>
|
|
195
196
|
lanes enrol list open access requests lanes enrol approve <id> | deny <id> decide one
|
|
196
197
|
lanes offboard <device> --yes key, device, view (own terminal) lanes token revoke <view> sign a browser out
|
|
197
|
-
lanes onboard <login> --container <name> [--workspace <id> --repo <path>] [--write] a user's container, dry run first
|
|
198
|
+
lanes onboard <login> --container <name> [--workspace <id> --repo <path>] [--step kimi-notify|grok-build] [--write] a user's container, dry run first
|
|
198
199
|
systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web lanes-enrold
|
|
199
200
|
lanes update --status the last update and the last check (update/last.json) lanes update --now check and install now
|
|
200
201
|
lanes update --rollback the release before again, the one left marked bad systemctl --user status lanes-update.timer
|
|
@@ -208,10 +209,13 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
208
209
|
outcome; the full output is `~/.local/state/lanes/update/last.log`, the outcome `update/last.json`. Read it with
|
|
209
210
|
`python3 -m json.tool ~/.local/state/lanes/update/last.json`: `result` `ok` (a step with `"warning": true` — `sync`
|
|
210
211
|
exiting 1 for a board with an error, e.g. a removed container — needs the board fixed, not the update: `lanes sync`
|
|
211
|
-
names it
|
|
212
|
+
names it; `enable-docs` with `"outcome": "skipped"` or `"refused"` and its `reason` is the docs page's Mermaid, never
|
|
213
|
+
fatal — see "Mermaid pin" — and the summary line says `mermaid ok`, `mermaid skipped: <reason>` or `mermaid
|
|
214
|
+
refused: <reason>`); `partial` (exit 4) — the release is installed and a step after the switch failed: each step with a
|
|
212
215
|
non-zero `rc` is the one to run by hand (`sync` → `lanes sync`, `restart lanes-statusd` →
|
|
213
216
|
`systemctl --user restart lanes-statusd.service`, `install-container <c>` → `lanes install-container <c>`,
|
|
214
|
-
`reload-views` → `lanes reload-views`, `ensure` → `lanes ensure --if-layout-changed
|
|
217
|
+
`reload-views` → `lanes reload-views`, `ensure` → `lanes ensure --if-layout-changed`, `enable-docs` with `"outcome":
|
|
218
|
+
"failed"` → `sh ~/.local/share/lanes/current/install/enable-docs.sh`); `refused` (exit 3) — nothing changed: the last step names what said no
|
|
215
219
|
(`preflight`: a prerequisite, the user manager or Docker, or no installed release — `no release is installed here`:
|
|
216
220
|
run the full `./install.sh --server` / `npx @vibelord/lanes@<release> --server` first; `commit`: another install
|
|
217
221
|
holds `~/.local/share/lanes/.install.lock`, or a link is no symlink). A run that printed only
|
|
@@ -489,7 +493,7 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
489
493
|
and phone) > the legend ` ⏎ open · / find · x close · ? keys` (phone: ` Swipe to scroll ·
|
|
490
494
|
Tap to open`). The last
|
|
491
495
|
message lasts 3 s (`MESSAGE_S`, measured on the 1 s tick: `Sidebar.expire_message`) or until the next action begins
|
|
492
|
-
(`Sidebar.begin_action`: open, `x`/`×`, `R`, `P`, `a`/`+`, `u`, `z`, `p`, `h`, `H`, `o`, `/`, `U`, `+`, `r`, `?`, `Tab`, a chord) — never just until
|
|
496
|
+
(`Sidebar.begin_action`: open, `x`/`×`, `R`, `P`, `a`/`+`, `u`, `z`, `p`, `h`, `H`, `o`, `i`, `/`, `U`, `+`, `r`, `?`, `Tab`, a chord) — never just until
|
|
493
497
|
a key: a cursor move, typing in a field, `Esc`, a focus report, the theme's OSC 11 answer, a wheel tick or a mouse
|
|
494
498
|
release leaves it (DEF-1822; DEF-1815 before it kept it from the focus report only). `?` shows
|
|
495
499
|
the key sheet (` lanes <current> · view <tree>` first — cockpit v3 V2 — then `sidebar.KEY_SHEET`, 18 lines since R5's
|
|
@@ -928,6 +932,7 @@ only normalizes HOW a terminal reaches its lane:
|
|
|
928
932
|
|
|
929
933
|
## Installing agent hooks (opt-in, per container, dry run first)
|
|
930
934
|
|
|
935
|
+
This section is Claude Code's and Codex's; Kimi Code, Grok Build and grok-cli (1.5.0) are in [Harnesses](#harnesses) below.
|
|
931
936
|
Without hooks the sidebar's agent glyph comes from Claude's session files and Codex's pane title. Hooks make it
|
|
932
937
|
event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a turn event, which writes
|
|
933
938
|
`~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
|
|
@@ -989,6 +994,242 @@ next tool call: after a denied prompt, or an approved call that failed (`Permiss
|
|
|
989
994
|
write nothing). A lane whose agent crashed loses its sidecar at once (dead pid); one
|
|
990
995
|
whose agent is alive but silent keeps the last word for up to an hour.
|
|
991
996
|
|
|
997
|
+
## Harnesses
|
|
998
|
+
|
|
999
|
+
Since 1.5.0 (W12, spec `docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md`) Lanes reads Kimi Code,
|
|
1000
|
+
Grok Build and grok-cli lanes as well as Claude Code and Codex ones; what each status means for the person at the
|
|
1001
|
+
board is the user guide's [Harnesses](USERGUIDE.md#harnesses). This section is the operator's: what to install per
|
|
1002
|
+
container, how statusd decides, and how to check each harness. Claude Code's and Codex's hooks are in "Installing
|
|
1003
|
+
agent hooks" above.
|
|
1004
|
+
|
|
1005
|
+
**Per container, once, in order.** Each `hooks install` prints its
|
|
1006
|
+
diff first and writes only with `--write`; each changes only its own entries; `--write` refuses a container whose home
|
|
1007
|
+
this account cannot write for uid 1000, as for Claude and Codex. Every file is kept as `<file>.bak-<UTC>` before it
|
|
1008
|
+
changes, a symbolic link below the container home refuses the file, and in a TOML file only Lanes' own marked block
|
|
1009
|
+
(`# >>> lanes <name> >>>` … `# <<< lanes <name> <<<`) is ever rewritten: to undo, delete the block.
|
|
1010
|
+
|
|
1011
|
+
1. `lanes install-container <name>` — ships 1.5.0's contract: `lane-signal` (its harness, when no `--harness` or
|
|
1012
|
+
`LANE_HARNESS` names one, read from the parent processes, never assumed `codex`), `lane-mcp` and its editors, and
|
|
1013
|
+
the hook definitions in `~/.lz/hooks/` (`kimi-hooks.toml`, `grok-hooks.json`, `grok-cli-hooks.json`). Unless
|
|
1014
|
+
`--no-mcp`, it also runs `~/.lz/bin/lane mcp install`, which adds the Lanes MCP server for each harness installed
|
|
1015
|
+
there, beside Claude's and Codex's entries (each subsection below names its file).
|
|
1016
|
+
2. The hooks of each harness the container has, each with its dry run first: `lanes hooks install --agent kimi
|
|
1017
|
+
--container <name>` ([Kimi Code](#kimi-code)), `--agent grok` ([Grok Build](#grok-build), after Grok Build is
|
|
1018
|
+
installed) and `--agent grok-cli` ([grok-cli](#grok-cli)).
|
|
1019
|
+
3. Optional, the operator's call per container: `lanes onboard <login> --container <name> --step kimi-notify --write`
|
|
1020
|
+
(Kimi Code's bell) and `--step grok-build --write` (installs Grok Build).
|
|
1021
|
+
4. New agent sessions read the hooks and the MCP entry; a running session keeps what it started with (inferred from
|
|
1022
|
+
how the harnesses read their configuration at start, not measured).
|
|
1023
|
+
|
|
1024
|
+
`lanes onboard` runs step 2 by itself for each harness whose home is in the container (`hooks-kimi`, `hooks-grok`,
|
|
1025
|
+
`hooks-grok-cli`; `skipped` otherwise); `kimi-notify` and `grok-build` run only when named with `--step`
|
|
1026
|
+
(`grok-build` right after the board, before the contract, so the same run writes Grok Build's MCP entry and hooks).
|
|
1027
|
+
|
|
1028
|
+
What each event writes (`agent{}` vocabulary):
|
|
1029
|
+
|
|
1030
|
+
| Harness | Event | status |
|
|
1031
|
+
|---|---|---|
|
|
1032
|
+
| Kimi Code | `UserPromptSubmit`, `TurnStarted`, `PreToolUse`, `PostToolUse`, `PermissionResult` | `working` |
|
|
1033
|
+
| Kimi Code | `PermissionRequest` | `needs_approval` |
|
|
1034
|
+
| Kimi Code | `Stop`, `Interrupt` | `needs_input` |
|
|
1035
|
+
| Kimi Code | `StopFailure` / `SessionStart` / `SessionEnd` | `errored` / `idle` / `shell` |
|
|
1036
|
+
| Grok Build | `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `working` |
|
|
1037
|
+
| Grok Build | `Notification` (`permission_prompt`) | `needs_approval` |
|
|
1038
|
+
| Grok Build | `Stop`, `StopCancelled`, `Notification` (`idle_prompt`) | `needs_input` |
|
|
1039
|
+
| Grok Build | `StopFailure` / `SessionStart` / `SessionEnd` | `errored` / `idle` / `shell` |
|
|
1040
|
+
| grok-cli | `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `working` |
|
|
1041
|
+
| grok-cli | `Stop` / `StopFailure` / `SessionEnd` | `needs_input` / `errored` / `shell` |
|
|
1042
|
+
|
|
1043
|
+
Verified event names (2026-10-15, this host): Kimi Code 2.1.1's `HOOK_EVENT_TYPES` and its strict `[[hooks]]` schema
|
|
1044
|
+
(`event`, `matcher?`, `command`, `timeout?` 1–600) in its binary; grok-cli 1.1.7's hook events, its exact-string
|
|
1045
|
+
matcher, `sh -c` with its own environment, and its MCP server entry (`{id, label, enabled, transport, command, args,
|
|
1046
|
+
env}`, client name `grok-cli-<id>`) in the source its binary carries; Grok Build 1.0.52's events from its source and
|
|
1047
|
+
user guide (`docs/superpowers/specs/evidence/2026-10-15-grok-build-measurement.md` §3). Not yet proven on a live,
|
|
1048
|
+
logged-in session: that each harness fires these hooks in a lane as the source says.
|
|
1049
|
+
|
|
1050
|
+
### The observed harness
|
|
1051
|
+
|
|
1052
|
+
Which harness runs in a lane needs nothing installed: statusd OBSERVES it instead of guessing it from the pane title.
|
|
1053
|
+
It walks the pane's process tree in the container's `/proc` (`/proc/<pid>/task/*/children` and the `comm` of each
|
|
1054
|
+
process, breadth first from the pane's root, at most four levels and 64 processes, no spawn) and publishes
|
|
1055
|
+
`agent.harness` as `claude`, `codex`, `kimi`, `grok` (Grok Build), `grok-cli`, `shell` or `unknown`. The first process
|
|
1056
|
+
whose `comm` is `claude`, `codex`, `kimi`/`kimi-code` or `grok`, or whose executable holds `/claude/versions/`, names
|
|
1057
|
+
it: native Claude Code before 2.1.299 runs with its version as `comm` (`2.1.295`), so its executable names it
|
|
1058
|
+
(DEF-1993; the same rule as `lane-signal`'s and `lane_core`'s). A `grok` is Grok Build when its executable is
|
|
1059
|
+
`…/downloads/grok-linux-<arch>` (`readlink /proc/<pid>/exe`), any other is grok-cli; one whose executable cannot be read
|
|
1060
|
+
is `unknown`. Only shells is `shell`; anything else, a cut tree or an unreadable read is `unknown`. The answer is kept
|
|
1061
|
+
per pane pid (`statusd.HARNESS_CACHE`) and walked again when the pane's own children change or the harness process it
|
|
1062
|
+
found exited or now runs under another `comm`; `shell` and `unknown` are never kept.
|
|
1063
|
+
|
|
1064
|
+
| Harness | Process | Observed without hooks | `source` |
|
|
1065
|
+
|---|---|---|---|
|
|
1066
|
+
| Claude Code | `claude` (or a native file `…/claude/versions/<version>`) | `~/.claude/sessions/<pid>.json` | `claude-session-file` |
|
|
1067
|
+
| Codex | `codex` (under the npm wrapper `node`) | the pane title and the bell | `title` |
|
|
1068
|
+
| Kimi Code | `kimi-code` (or `kimi`) | `~/.kimi-code/sessions/…/state.json` + `agents/main/wire.jsonl` | `kimi-session-file` |
|
|
1069
|
+
| Grok Build | `grok`, exe `$GROK_HOME/downloads/grok-linux-<arch>` | `$GROK_HOME/active_sessions.json` + the session's `events.jsonl` and `summary.json`, else its pane title | `grok-session-file`, `grok-title` |
|
|
1070
|
+
| grok-cli | `grok`, any other exe | nothing: no title, no bell, no pid in its database | — (hooks only) |
|
|
1071
|
+
| any of them, nothing else claiming | — | the process itself: `idle`, `done` with the bell | `process` |
|
|
1072
|
+
|
|
1073
|
+
The order statusd takes them in: a hook sidecar > a Claude session file > a Kimi session file > the Grok Build
|
|
1074
|
+
observer (its session file, else its title) > the Grok Build title on a pane observed as `grok` > the Codex title (only
|
|
1075
|
+
where Codex can be) > the observed harness itself; the window bell turns an idle or finished turn into `done`. When
|
|
1076
|
+
the walk names a harness, that is the published `harness` whatever signal decided the status. Check it:
|
|
1077
|
+
|
|
1078
|
+
```sh
|
|
1079
|
+
lanes doctor | grep ' lanes: ' # lanes: claude 50 · codex 20 · kimi 3 · grok 0 · grok-cli 0 · shell 0
|
|
1080
|
+
jq '[.lanes[].agent.harness // empty] | group_by(.) | map({(.[0]): length}) | add' ~/.local/state/lanes/status.json
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
To see what statusd decided for one lane:
|
|
1084
|
+
|
|
1085
|
+
```bash
|
|
1086
|
+
python3 -c 'import json,sys; a=json.load(open(sys.argv[1]))["lanes"][sys.argv[2]].get("agent"); print(a)' \
|
|
1087
|
+
~/.local/state/lanes/status.json <lane id>
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
`harness` wrong: look at the pane's tree the way statusd does, read-only — with `P=/proc/$(cat
|
|
1091
|
+
~/.local/state/lanes/containers/<name>.pid)/root/proc` and the lane's `pid` from status.json (the pane's root
|
|
1092
|
+
process), `cat $P/<pid>/task/*/children` lists its children, `cat $P/<child>/comm` names each and `readlink
|
|
1093
|
+
$P/<child>/exe` its file. A harness more than four levels below the pane is `unknown`; so is a pane whose tree holds
|
|
1094
|
+
anything but shells and no harness. Before 1.5.0 a Kimi lane read `codex / idle`: the title rule took any title of two
|
|
1095
|
+
or more words for Codex's (DEF-1970).
|
|
1096
|
+
|
|
1097
|
+
### Kimi Code
|
|
1098
|
+
|
|
1099
|
+
**Observed.** statusd reads the newest session in `~/.kimi-code/sessions/wd_*/` whose `state.json` `cwd` equals the
|
|
1100
|
+
kimi process's working directory (`/proc/<pid>/cwd`). Expect `source: process` instead of `kimi-session-file` when two
|
|
1101
|
+
kimi lanes run in the same directory (they cannot be told apart, DEF-1971 — give each its own worktree, or install the
|
|
1102
|
+
hooks: each lane's sidecar outranks the session file), when the session has written nothing for an hour, or when
|
|
1103
|
+
`~/.kimi-code` or a session directory is a symlink (refused, never followed). Kimi rings no bell by default, so its
|
|
1104
|
+
finished turn shows `needs_input`, not `done`, until `kimi-notify` (below).
|
|
1105
|
+
|
|
1106
|
+
**Hooks** (installed when the container has `~/.kimi-code/`): `lanes hooks install --agent kimi --container <name>`,
|
|
1107
|
+
read the diff, then add `--write`. It writes one marker-delimited `hooks` block of `[[hooks]]` into the container's
|
|
1108
|
+
`~/.kimi-code/config.toml` (from `~/.lz/hooks/kimi-hooks.toml`: PreToolUse, PostToolUse, PermissionRequest,
|
|
1109
|
+
PermissionResult, UserPromptSubmit, TurnStarted, Stop, StopFailure, Interrupt, SessionStart, SessionEnd →
|
|
1110
|
+
`~/.lz/bin/lane-signal … --harness kimi`); running it again replaces that block and nothing else, and the rest of the
|
|
1111
|
+
file is never rewritten. Another writer's `[[hooks]]` stay — Throttle's session observer and its other entries
|
|
1112
|
+
(`throttle setup --install-clients`: provider `throttle-kimi`) included, even when its writer put them inside the
|
|
1113
|
+
block: they are moved just below it. A `hooks` that is not `[[hooks]]` tables refuses. Undo: delete the lines between
|
|
1114
|
+
the markers. **The `.kimi-code` no-touch doctrine** (`docs/plans/workspace-session-registry-native-resume.md`,
|
|
1115
|
+
`src/provider/kimi_device.rs`) is about the Kimi CLI's device identity — its `~/.kimi-code/device_id` slot, which no
|
|
1116
|
+
Lanes or Throttle path reads or writes — not about this block, which is ours (spec §3, recorded).
|
|
1117
|
+
|
|
1118
|
+
**Bell, MCP, trace.** `lanes onboard`'s `kimi-notify` step sets `notification_condition = "always"` in
|
|
1119
|
+
`~/.kimi-code/tui.toml`, in place in Kimi's own `[notifications]` table (its old value kept in a comment), so its bell
|
|
1120
|
+
reaches tmux and the observer marks a finished turn `done`. `lane mcp install` writes `mcpServers.lanes` into
|
|
1121
|
+
`~/.kimi-code/mcp.json` (the Claude shape; the environment passes through). Every canonical recipe that sets
|
|
1122
|
+
`ANTHROPIC_CUSTOM_HEADERS` also sets `KIMI_CODE_CUSTOM_HEADERS='x-throttle-trace: lanes/<ws>/<id>'`: Kimi Code
|
|
1123
|
+
blanks the Anthropic variable.
|
|
1124
|
+
|
|
1125
|
+
### Grok Build
|
|
1126
|
+
|
|
1127
|
+
**Where it lives.** Lanes expects Grok Build (xAI's official `grok`, 1.0.52 measured) under
|
|
1128
|
+
**`GROK_HOME=~/.grok-build`**, never the default `~/.grok`: that directory belongs to grok-cli (superagent-ai) in our
|
|
1129
|
+
containers, and Grok Build's installer replaces grok-cli's `~/.grok/bin/grok` with a symlink to its own binary. Every
|
|
1130
|
+
lane recipe exports `GROK_HOME=/home/coder/.grok-build`. Install it with `lanes onboard <login> --container <name>
|
|
1131
|
+
--step grok-build --write`: that runs, in the container as uid 1000, the stable-channel recipe
|
|
1132
|
+
(`https://x.ai/cli/stable`, the `.zst` artifact when `zstd` is there, else the plain one; `downloads/grok-linux-x86_64`,
|
|
1133
|
+
the relative link `bin/grok`, `[cli] installer = "internal"`). It edits no shell rc file and links nothing into
|
|
1134
|
+
`~/.local/bin`. The channel publishes no checksum: the receipt's `warn` row carries the download's size and sha256,
|
|
1135
|
+
marked unverified, for the operator to check (DEF-1997). Both CLIs show `grok` as the pane's command; `readlink
|
|
1136
|
+
/proc/<pid>/exe` tells them apart — `~/.grok-build/downloads/grok-linux-x86_64` is Grok Build, `~/.grok/bin/grok` is
|
|
1137
|
+
grok-cli.
|
|
1138
|
+
|
|
1139
|
+
**Observed** (no hook installed; `libexec/harness_grok.py`, ARCHITECTURE "Grok Build and grok-cli"):
|
|
1140
|
+
|
|
1141
|
+
| Signal | Read from | Gives |
|
|
1142
|
+
|---|---|---|
|
|
1143
|
+
| pid → session | `$GROK_HOME/active_sessions.json` (`session_id`, `pid`, `cwd`, `opened_at`) | the session of the pane's grok |
|
|
1144
|
+
| state | the last 64 KiB of `$GROK_HOME/sessions/<URL-encoded cwd>/<session id>/events.jsonl` | `working`, `needs_approval`, `needs_input` (`done` with the bell), `errored`, `idle` |
|
|
1145
|
+
| name | that session's `summary.json` `generated_title` | the lane's task line |
|
|
1146
|
+
| fallback | the pane title (`⚠ Action Required - …`, a braille spinner, `grok`, `<name> - grok`) | the same words, `source: grok-title` |
|
|
1147
|
+
|
|
1148
|
+
statusd hands the observer every pane its walk found running Grok Build, every `shell` pane (a Grok-shaped title over
|
|
1149
|
+
a tree with no grok left is that harness ended: `grok` / `shell`) and every `unknown` pane whose `grok` could not be
|
|
1150
|
+
told apart by its executable (Grok Build only when `active_sessions.json` names its pid), with one title memory per
|
|
1151
|
+
container kept between cycles (`statusd.GROK_TITLES`): the `⚠ Action Required` prefix blinks while the pane is
|
|
1152
|
+
unfocused, so a busy title right after a prefixed one still reads `needs_approval` (DEF-1982).
|
|
1153
|
+
|
|
1154
|
+
**Hooks:** `lanes hooks install --agent grok --container <name>`, then `--write`. It writes
|
|
1155
|
+
`~/.grok-build/hooks/lanes.json` (the whole file is Lanes'; Claude Code's JSON shape, which Grok Build reads from every
|
|
1156
|
+
`$GROK_HOME/hooks/*.json`: `Notification` as two groups, `permission_prompt` → `needs_approval` and `idle_prompt` →
|
|
1157
|
+
`needs_input`, then `Stop`, `StopFailure`, `StopCancelled`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`,
|
|
1158
|
+
`PreToolUse`, `PostToolUse` → lane-signal `--harness grok`) and two blocks in `~/.grok-build/config.toml`. `hooks` sets
|
|
1159
|
+
`[compat.claude] hooks = false, mcps = false` — without it Grok Build also runs the hooks in `~/.claude/settings.json`,
|
|
1160
|
+
which say `--harness claude`, and registers lane-mcp a second time — and `[ui.notifications] condition = "always",
|
|
1161
|
+
method = "bel", events = [turn_complete, approval_required, task_complete, agent_error]`, so the window bell is
|
|
1162
|
+
reliable inside tmux. `trace` sets `[model."grok-4.6"]` and `[model."grok-4.5"]` `env_http_headers = {
|
|
1163
|
+
"x-throttle-trace" = "LANES_TRACE" }` (quoted ids: a bare `[model.grok-4.6]` is the table `grok-4` → `6`, DEF-1995):
|
|
1164
|
+
Grok Build's `[models] extra_headers` is global, one trace for every lane, so recipes export `LANES_TRACE` instead. A
|
|
1165
|
+
`[compat.claude]` or `[ui.notifications]` table of the person's refuses the install: set the keys there by hand. A
|
|
1166
|
+
model they already configure is left out of `trace` and named. The MCP entry is the `mcp` block `lane mcp install`
|
|
1167
|
+
writes (`[mcp_servers.lanes]` with `command` only: Grok Build starts a stdio server with its own environment plus
|
|
1168
|
+
`GROK_SESSION_ID`, the session a `lanes_signal` report and every hook then name), a file edit rather than `grok mcp
|
|
1169
|
+
add`; `mcps = false` comes with the hooks block.
|
|
1170
|
+
|
|
1171
|
+
What needs hooks: the sustained-idle backstop (`Notification` `idle_prompt`), why a turn stopped (`StopFailure`,
|
|
1172
|
+
`StopCancelled`) and a reliable bell. Not handled: leader mode (`[cli] use_leader = true`; DEF-1983, accepted) — the
|
|
1173
|
+
observer answers `unknown` with a note, and statusd then publishes no agent state for the lane until a hook speaks for
|
|
1174
|
+
it.
|
|
1175
|
+
|
|
1176
|
+
To confirm a Grok Build lane is joined, in its container:
|
|
1177
|
+
|
|
1178
|
+
1. `cat ~/.grok-build/active_sessions.json` — an entry whose `pid` is the pane's grok (`tmux display -p
|
|
1179
|
+
'#{pane_pid}'`, then its child named `grok`). No entry while the lane sits at the welcome screen before a session
|
|
1180
|
+
opens; then only the title speaks.
|
|
1181
|
+
2. `ls ~/.grok-build/sessions/` — the cwd's directory (the cwd URL-encoded, e.g. `%2Fhome%2Fcoder%2Fsrc%2Fbw-tools`)
|
|
1182
|
+
holding a directory named by that `session_id` with `events.jsonl` and `summary.json`.
|
|
1183
|
+
3. `tail -n 3 ~/.grok-build/sessions/<dir>/<session id>/events.jsonl` — the last `type` names the state (`turn_ended`
|
|
1184
|
+
with its `outcome`, a `phase_changed`, a `permission_requested`).
|
|
1185
|
+
|
|
1186
|
+
The observer refuses a `GROK_HOME`, session directory or `events.jsonl` that is a symlink, and ignores a stale entry
|
|
1187
|
+
(dead pid, a pid that no longer runs grok, or one that started after the entry was opened). The file shapes come from
|
|
1188
|
+
Grok Build's source and user guide; no logged-in session has been measured yet, so a first live lane is worth checking
|
|
1189
|
+
against the three steps above (DEF-1981). Do not pipe `grok doctor --json` into `head`: 1.0.52 aborts on the closed
|
|
1190
|
+
pipe and leaves a `core` file in the cwd.
|
|
1191
|
+
|
|
1192
|
+
### grok-cli
|
|
1193
|
+
|
|
1194
|
+
grok-cli has no observable signal at all — no title, no bell, no pid in its database — so a grok-cli lane shows a
|
|
1195
|
+
state only through its hooks, and never `needs_approval`: it has no approval event (DEF-1980). Hooks (installed when
|
|
1196
|
+
the container has `~/.grok/`): `lanes hooks install --agent grok-cli --container <name>`, then `--write`, merges the
|
|
1197
|
+
lane-signal groups into the `hooks` of `~/.grok/user-settings.json` (UserPromptSubmit, PreToolUse, PostToolUse, Stop,
|
|
1198
|
+
StopFailure, SessionEnd; no matchers: grok-cli compares a matcher to the tool name exactly). Only that member changes;
|
|
1199
|
+
the file holds grok-cli's `apiKey`, and a dry run's diff prints any key, token or secret value as `…`. Its MCP entry is
|
|
1200
|
+
the `lanes` item of `mcp.servers` in the same file. grok-cli starts MCP servers with only HOME, LOGNAME, PATH, SHELL,
|
|
1201
|
+
TERM and USER, so `lane-mcp` reads `LANE_ID`, `LANE_WS`, `TMUX` and `TMUX_PANE` from grok-cli's own
|
|
1202
|
+
`/proc/<pid>/environ`, and only when that parent process is a harness (DEF-1992). grok-cli sends no custom headers: its
|
|
1203
|
+
requests carry no trace.
|
|
1204
|
+
|
|
1205
|
+
### Throttle-side notes
|
|
1206
|
+
|
|
1207
|
+
A trace only matters for a harness attached to a Throttle pool; a running lane gets the recipe's trace variables when
|
|
1208
|
+
it is next created.
|
|
1209
|
+
|
|
1210
|
+
**The fleet's Kimi row after the update.** The 1.5.0 pulse reports the kimi pool (`/pools/kimi/ready`, `/metrics`,
|
|
1211
|
+
and `/pools/kimi/status` when tokened), but an update only ships the new `throttle-pulse` file: `lanes
|
|
1212
|
+
install-container` starts nothing while the `pulse` session runs, so the 1.4.0 loop keeps running and the sidebar's
|
|
1213
|
+
`◆ moonshot` row says `no data` (`lanes doctor`: `kimi ?`) until it is restarted (DEF-2003): `docker exec -u 1000
|
|
1214
|
+
<container> tmux -L lanes-pulse kill-session -t pulse`, then `lanes install-container <container>`.
|
|
1215
|
+
|
|
1216
|
+
**Throttle-side items (the Throttle provider lane's; filed, not built in Lanes):**
|
|
1217
|
+
|
|
1218
|
+
- DEF-2000 — Throttle's managed MCP entry for Kimi is written to `~/.kimi/mcp.json` (`src/cli.rs`
|
|
1219
|
+
`stage_additional_gateway_user_entries` and its remove twin), a path Kimi Code 2.1.1 does not read: it reads
|
|
1220
|
+
`~/.kimi-code/mcp.json`.
|
|
1221
|
+
- DEF-2001 — Throttle synthesizes `cc-session/` and `codex-session/` flows for untraced Claude and Codex requests, but
|
|
1222
|
+
no `kimi-session/` flow: Kimi sends no session id on the Anthropic wire. A Kimi lane's requests join their lane only
|
|
1223
|
+
through the `KIMI_CODE_CUSTOM_HEADERS` trace.
|
|
1224
|
+
- Grok: Throttle has no xAI pool, so there is no Grok fleet row and no Grok trace join. The paid-API lane and the
|
|
1225
|
+
subscription pool, and their gates (O-XAI-key, O-XAI-capture, O-XAI-legal, O-XAI-oauth), are the review's
|
|
1226
|
+
(`docs/plans/2026-10-14-xai-grok-pool-review.md`).
|
|
1227
|
+
- **Operator note, not a defect:** Kimi Code on productbox is not attached to Throttle — no `[pools.kimi]` in its
|
|
1228
|
+
configuration, port 8095 closed — so its requests go to Kimi directly and the fleet's kimi row reads `0/0`.
|
|
1229
|
+
Attaching it is a Throttle step (`throttle accounts add <name> --provider kimi --pool kimi`, then `throttle setup
|
|
1230
|
+
--install-clients`; the [`throttle-kimi-provider`](../../skills/throttle-kimi-provider/SKILL.md) skill), outside
|
|
1231
|
+
Lanes.
|
|
1232
|
+
|
|
992
1233
|
## The agents' MCP entries (`lane-mcp`, on by default)
|
|
993
1234
|
|
|
994
1235
|
`lanes install-container <name>` ships `~/.lz/bin/lane-mcp` (with `lane_core.py`) and adds it to both agents in that
|
|
@@ -1018,7 +1259,7 @@ hand.
|
|
|
1018
1259
|
the file. Same exit 1 and reason as `lanes hooks install` (DEF-1727). A symbolic link at `~/.claude.json`,
|
|
1019
1260
|
`~/.codex` or `config.toml` is refused too, also by `lane mcp install`: from the host it would resolve outside
|
|
1020
1261
|
the container. Add the entry to the file it points at by hand.
|
|
1021
|
-
- Inside the container: `~/.lz/bin/lane mcp install [--agent claude|codex|all]` prints the diff, `--write` applies it.
|
|
1262
|
+
- Inside the container: `~/.lz/bin/lane mcp install [--agent claude|codex|kimi|grok|grok-cli|all]` prints the diff, `--write` applies it (`all`, the default: Claude Code, Codex and each of Kimi Code, Grok Build and grok-cli whose home is there; a harness named on its own is written even without its home).
|
|
1022
1263
|
- New agent sessions load the server; a running one keeps its tools. Check from a lane with the agent's own MCP
|
|
1023
1264
|
listing (`/mcp` in Claude Code). The server's log is `~/.lz/s/logs/lane-mcp.log` (one line per request, rotated
|
|
1024
1265
|
at 1 MiB; never a capture's text, a sent text or a note).
|
|
@@ -1086,6 +1327,10 @@ lands where, and how to take it back:
|
|
|
1086
1327
|
lane's task line). The report stands until the agent's next tool call, prompt or permission prompt; the end of
|
|
1087
1328
|
its turn keeps it unless it is `working` (DEF-1801). `cat ~/.lz/state/hooks/<lane id>.json` shows it: `event`
|
|
1088
1329
|
stays `lanes_signal` while it stands.
|
|
1330
|
+
- `lanes_publish` (and `lane publish <file>` in a lane's shell) writes a publish request,
|
|
1331
|
+
`~/.lz/state/publish/<lane id>/<id>.json`, naming a file under `/home/coder`; the docs service takes it within
|
|
1332
|
+
about 5 s and answers in `<id>.receipt.json` beside it ("Docs service", "Publish pick-up"). `lanes_status` shows the
|
|
1333
|
+
newest receipt as `published`. The file is read by the host, never sent by the agent.
|
|
1089
1334
|
- Nothing an agent does ends, interrupts or attaches a lane, uploads a file, runs `lanes sync`, `lanes gc` or
|
|
1090
1335
|
`lanes ensure`, renames a tmux session, or touches a session added with `lanes register` (`registered-sessions.json`
|
|
1091
1336
|
on the host).
|
|
@@ -1121,8 +1366,8 @@ printf '%s\n' \
|
|
|
1121
1366
|
```
|
|
1122
1367
|
|
|
1123
1368
|
It prints two frames, one line each, exit 0 and nothing on stderr. Frame `id` 1 carries `protocolVersion`
|
|
1124
|
-
`2025-06-18` and `serverInfo` `{"name": "lanes", "version": <~/.lz/VERSION>}`. Frame `id` 2 lists the
|
|
1125
|
-
wire order, the same tools `lanes/tests/unit/golden/lane-mcp-tools.json` pins. The list is deterministic: run the
|
|
1369
|
+
`2025-06-18` and `serverInfo` `{"name": "lanes", "version": <~/.lz/VERSION>}`. Frame `id` 2 lists the eleven tools in
|
|
1370
|
+
wire order (`lanes_publish`, W10 W4, last), the same tools `lanes/tests/unit/golden/lane-mcp-tools.json` pins. The list is deterministic: run the
|
|
1126
1371
|
command twice through `sha256sum` and the two digests match. The log gains a `start lane-mcp <version> wire 1` line.
|
|
1127
1372
|
No output at all means the server could not start, for example because `lane_core.py` is missing beside it; run
|
|
1128
1373
|
`lanes install-container <container>` on the host. `test_docs.AgentApi` runs this block exactly as printed here.
|
|
@@ -1203,7 +1448,7 @@ Settings (environment of the installer — `install/services.py` renders them in
|
|
|
1203
1448
|
| Variable | Default | Meaning |
|
|
1204
1449
|
|---|---|---|
|
|
1205
1450
|
| `LANES_THROTTLE_CONTROL` | `http://127.0.0.1:19181` | control base URL, resolved in the poller's network namespace (see above); `none` sends no request at all (the log fold still runs) |
|
|
1206
|
-
| `LANES_THROTTLE_TOKEN_FILE` | unset | file holding
|
|
1451
|
+
| `LANES_THROTTLE_TOKEN_FILE` | unset | file holding the control bearer (the daemon's `remote_key`, or the local client token `lanes throttle adopt-token` copies). `/status` and `/sessions` are guarded control routes: without it they answer `HTTP 401`, loopback or not — the control port has no loopback exemption (DEF-1930). Never written to `economy.json` or the cursor |
|
|
1207
1452
|
| `LANES_THROTTLE_CONTAINER` | unset | the container whose home volume holds the daemon's log; the log dir becomes its `/home/coder/.local/share/throttle` (via `containers.json`) |
|
|
1208
1453
|
| `LANES_THROTTLE_LOG_DIR` | `~/.local/share/throttle` | where `proxy.log` and `proxy.log.<day>` are read (read only); wins over `LANES_THROTTLE_CONTAINER` |
|
|
1209
1454
|
|
|
@@ -1216,6 +1461,157 @@ when it recovers; the reason is never written to `economy.json`. `economy.json`
|
|
|
1216
1461
|
the next poll re-folds today. For a look without the daemon: `LANES_THROTTLE_CONTROL=none lanes economy --once
|
|
1217
1462
|
--dry-run --status-file status.json --sessions-file sessions.json` with saved documents.
|
|
1218
1463
|
|
|
1464
|
+
## Fleet block and Throttle pulse: operator steps (1.4.0)
|
|
1465
|
+
|
|
1466
|
+
1.5.0 adds the Kimi pool to the pulse and the block (`◆ moonshot`): after that update restart the pulse once, as
|
|
1467
|
+
"Harnesses" above says (DEF-2003); everything else below is unchanged.
|
|
1468
|
+
|
|
1469
|
+
The sidebar's fleet block (user guide "Fleet") needs nothing from you to show the host and its containers. Its Throttle
|
|
1470
|
+
rows need the pulse running in the daemon's container, and its money and burn figures need one opt-in. In order (the
|
|
1471
|
+
reference follows: "Throttle pulse" for the container side, "Fleet facts" for what statusd publishes):
|
|
1472
|
+
|
|
1473
|
+
1. **Install 1.4.0.** On an npm-installed server with `lanes-update.timer` on, the publish is the deploy: the update
|
|
1474
|
+
restarts statusd (the new `fleet`/`throttle` keys) and re-runs `lanes install-container` for every container `lanes
|
|
1475
|
+
sync` recorded, and for the daemon's container when sync did not record it, which ships `~/.lz/bin/throttle-pulse`
|
|
1476
|
+
and starts its loop in the daemon's container. Otherwise: `npx @vibelord/lanes@1.4.0 --server --update-only`, or
|
|
1477
|
+
`lanes update --now`.
|
|
1478
|
+
2. **Check the pulse.** `lanes doctor` prints `throttle pulse: 4 s old (container <c>), daemon up, tokened: no — lanes
|
|
1479
|
+
throttle adopt-token <c>`. `none in status.json` means no pulse yet: the daemon's container is not one `lanes sync`
|
|
1480
|
+
recorded (not on a board), or it is not the one Lanes picked (the Throttle tab's container) — put
|
|
1481
|
+
`LANES_THROTTLE_CONTAINER=<c>` into the server's `~/.config/lanes/site.conf` (the one place Lanes reads it for the
|
|
1482
|
+
pulse: `lanes install-container`, `lanes doctor` and every update's re-ship, re-runs and rollbacks included; never
|
|
1483
|
+
the environment) and run `lanes install-container <c>` once. A name there (or a Throttle tab) whose container docker
|
|
1484
|
+
no longer knows, or that has no home volume, is skipped by every update with a warning (`WARN install-container <c>
|
|
1485
|
+
(skipped: docker has no such container)`; the result stays `ok`): correct or remove the line. `stale` (the pulse's
|
|
1486
|
+
`at` more than 90 s old, the bound statusd uses too) means the loop stopped: statusd starts it again within 10
|
|
1487
|
+
minutes ("The re-arm" below, in its journal), or run `lanes install-container <c>`. `daemon down (connection
|
|
1488
|
+
refused)` is the daemon, not the pulse.
|
|
1489
|
+
3. **Opt in to money and burn (optional).** `lanes throttle adopt-token <c>` copies the container's own local Throttle
|
|
1490
|
+
client token into `~/.lz/throttle-token`; the pulse then reads the guarded `/pools/<pool>/status` ("The token"
|
|
1491
|
+
below). Every guarded control route needs that bearer, loopback included (DEF-1930): there is no token-free way to
|
|
1492
|
+
read spend. To opt out, delete the file; the next pulse says `tokened: no`.
|
|
1493
|
+
4. **The dashboard link (only after an authenticated Grafana).** Put `LANES_FLEET_DASHBOARD_URL=https://<host>[:port]
|
|
1494
|
+
[/path]` into the server's `~/.config/lanes/site.conf` once the fleet-management Grafana (`christian-fleet`) is
|
|
1495
|
+
behind authentication — never before: the link would hand every tailnet viewer the URL. No Lanes command writes
|
|
1496
|
+
it. `lanes doctor` prints the URL, `unset`, or `⚠ … ignored — not an https URL`; a query string, a user part or a
|
|
1497
|
+
fragment is refused too.
|
|
1498
|
+
5. **Laptops with a client from before 1.2.0** do not update themselves: their owner runs `npx @vibelord/lanes@1.4.0`
|
|
1499
|
+
once. Newer clients ask `lanes hello --client-version` and install 1.4.0 within 6 h of the server running it.
|
|
1500
|
+
|
|
1501
|
+
The pulse only reads (GET, no redirect) and writes one file; it never changes the daemon. Removing it: stop the loop
|
|
1502
|
+
as the user it runs as (`docker exec -u 1000 <c> tmux -L lanes-pulse kill-server`) and delete
|
|
1503
|
+
`~/.lz/bin/throttle-pulse`, `~/.lz/state/throttle.json` and `~/.lz/throttle-token` in that container; the block's
|
|
1504
|
+
Throttle rows then show the stale mark. The pulse is part of the container contract, so the next update ships it and
|
|
1505
|
+
starts it in the daemon's container again.
|
|
1506
|
+
|
|
1507
|
+
## Throttle pulse
|
|
1508
|
+
|
|
1509
|
+
The Throttle daemon binds its own container's `127.0.0.1` (DEF-1525), so the host cannot ask it anything. The pulse
|
|
1510
|
+
asks from inside: `~/.lz/bin/throttle-pulse` (Python 3 stdlib), shipped to every workspace container by `lanes
|
|
1511
|
+
install-container` with the rest of the contract, runs as a loop **only in the daemon's container** and writes what
|
|
1512
|
+
it learns to `~/.lz/state/throttle.json` there, which `lanes-statusd` reads through `/proc/<init>/root` for the
|
|
1513
|
+
sidebar's fleet block.
|
|
1514
|
+
|
|
1515
|
+
**Which container.** `LANES_THROTTLE_CONTAINER=<name>` in `~/.config/lanes/site.conf`, else the container of the
|
|
1516
|
+
registry's Throttle Monitor tab (`tools--throttle-monitor`, the container that hosts most boards' monitor). `lanes
|
|
1517
|
+
install-container <that container>` starts the loop; for any other container it ships the file and says
|
|
1518
|
+
`throttle pulse: installed, not started here — …`.
|
|
1519
|
+
|
|
1520
|
+
**How it runs.** A tmux session `pulse` on the socket `lanes-pulse`, as uid 1000 with `HOME=/home/coder`:
|
|
1521
|
+
|
|
1522
|
+
```sh
|
|
1523
|
+
docker exec -d -u 1000 -e HOME=/home/coder <container> tmux -L lanes-pulse new -d -s pulse /home/coder/.lz/bin/throttle-pulse --loop
|
|
1524
|
+
```
|
|
1525
|
+
|
|
1526
|
+
`lanes install-container` asks `tmux -L lanes-pulse has-session -t =pulse` first and starts nothing when the session
|
|
1527
|
+
runs (`throttle pulse: already running in …`); a start docker refuses is printed and makes the exit 1, after
|
|
1528
|
+
everything else was installed. To restart it after an update, end the session and install again:
|
|
1529
|
+
`docker exec -u 1000 <container> tmux -L lanes-pulse kill-session -t pulse`, then `lanes install-container
|
|
1530
|
+
<container>`. Its pane holds the journal: `docker exec -u 1000 <container> tmux -L lanes-pulse capture-pane -p -t
|
|
1531
|
+
pulse` (one line each time the reason a source failed changes, `ok` when it recovers; never the token).
|
|
1532
|
+
|
|
1533
|
+
**What it reads.** Every 10 s, GET only, on `http://127.0.0.1:$LANES_THROTTLE_CONTROL_PORT` (19181 when the
|
|
1534
|
+
container's environment does not set it), 3 s timeout, at most 1 MiB per answer, no redirect followed, no proxy:
|
|
1535
|
+
|
|
1536
|
+
| Route | Guarded | Gives |
|
|
1537
|
+
|---|---|---|
|
|
1538
|
+
| `/pools/claude/ready`, `/pools/codex/ready`, `/pools/kimi/ready` | no | configured / usable / exhausted / auth-failed / benched subscriptions, overflow ready (a `503` is a pool that is not ready; its body is read). A daemon with no Kimi account answers the kimi route with nothing configured; one from before its Kimi pool answers `404` (journalled; the other pools are read) |
|
|
1539
|
+
| `/metrics` | no | `throttle_pool_pressure_phase`, `throttle_pool_spill_fraction`, `throttle_overflow_in_flight`, `throttle_upstream_429_total` per pool |
|
|
1540
|
+
| `/pools/claude/status`, `/pools/codex/status`, `/pools/kimi/status` | yes | per pool: the worst measured 7-day and 5-hour utilization over its subscription accounts, the soonest reset; the overflow spend, enforced daily budget, fraction and alert level — for claude and codex only: Kimi has no paid overflow lane, so `overflow.kimi` stays `null` whatever its status renders |
|
|
1541
|
+
|
|
1542
|
+
`cooling` is what is left of `configured` after usable, exhausted, auth-failed, disabled, billing- or
|
|
1543
|
+
organization-blocked and unhealthy accounts (the daemon's buckets overlap; floored at 0).
|
|
1544
|
+
|
|
1545
|
+
**The token (opt-in, never automatic).** The `status` routes are guarded: the daemon's ControlAuth answers `401`
|
|
1546
|
+
to any request without its bearer, loopback included (DEF-1930). Without a token the pulse reads the open routes only
|
|
1547
|
+
and the burn and money fields stay `null`. The operator opts in once per daemon container:
|
|
1548
|
+
|
|
1549
|
+
```sh
|
|
1550
|
+
lanes throttle adopt-token <container>
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
It copies `env.ANTHROPIC_API_KEY` from that container's own `~/.claude/settings.json` — the LOCAL proxy bearer
|
|
1554
|
+
`throttle setup --install-clients` wrote there, which must start `throttle_`; it is never a provider credential — to
|
|
1555
|
+
the container's `~/.lz/throttle-token` (0600, by rename, never through a link) and prints `adopted: …` or why not,
|
|
1556
|
+
never the value. The pulse picks it up on its next cycle and sends it only to the `status` routes. It uses a
|
|
1557
|
+
token file that is not a regular file or is readable by group or other (mode not 0600) as no token, and says so in
|
|
1558
|
+
its journal. To withdraw the opt-in, remove the file: `docker exec -u 1000 <container> rm ~/.lz/throttle-token`. After
|
|
1559
|
+
`throttle setup` changes the client token, adopt it again.
|
|
1560
|
+
|
|
1561
|
+
**How to see it.**
|
|
1562
|
+
|
|
1563
|
+
```sh
|
|
1564
|
+
docker exec -u 1000 <container> cat /home/coder/.lz/state/throttle.json
|
|
1565
|
+
docker exec -u 1000 -e HOME=/home/coder <container> /home/coder/.lz/bin/throttle-pulse --once # one cycle, printed; exit 1 when the daemon did not answer
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
`{"v": 1, "at", "up", "tokened", "pools": {"claude", "codex", "kimi"}, "overflow": {"claude", "codex", "kimi"},
|
|
1569
|
+
"error"}` (the spec §2.1 shape, with W12's kimi pool; DM-LANES-THROTTLE-PULSE). The file is rewritten only when a value changed or 60 s passed, so `at` is a
|
|
1570
|
+
heartbeat: an `at` more than 90 s old means the loop stopped (a healthy pulse is read up to about 84 s after its
|
|
1571
|
+
`at`: the 60 s heartbeat lands on the next 10 s cycle, DEF-1941). `up: false` with `error` (`connection refused`,
|
|
1572
|
+
`timed out`, `HTTP 500`, …) means no open route answered; the pools then keep their last values. Utilizations and
|
|
1573
|
+
fractions are 0–1, rounded to three places; resets are epoch seconds.
|
|
1574
|
+
|
|
1575
|
+
## Fleet facts (`fleet` and `throttle` in status.json, W11)
|
|
1576
|
+
|
|
1577
|
+
statusd publishes two additive blocks in `~/.local/state/lanes/status.json` that the sidebar's fleet block and
|
|
1578
|
+
`lanes doctor` read (they read nothing else for it). No collector, no new service, no new spawn per cycle.
|
|
1579
|
+
|
|
1580
|
+
**`fleet` — the host and its biggest containers.** Every `FLEET_INTERVAL` (10 s) statusd reads, from plain files:
|
|
1581
|
+
the `cpu` line of `/proc/stat`, `MemTotal`/`MemAvailable` from `/proc/meminfo`, `/proc/sys/fs/inotify/max_user_watches`
|
|
1582
|
+
(the ceiling only — watches in use are not counted in the loop), the PSI and load it reads every cycle anyway, and
|
|
1583
|
+
per container recorded in `containers.json` its cgroup `memory.current` and `cpu.stat` `usage_usec`. Rates come from
|
|
1584
|
+
two samples 10 s apart, so right after a statusd restart `system.cpu.utilization` and every `container.cpu.percent`
|
|
1585
|
+
are `null` for one interval. `container.cpu.percent` is of the whole host (`usage_usec` delta ÷ interval ÷ logical
|
|
1586
|
+
cpus × 100): 12 % of a 64-cpu host is about 8 cpus busy. `containers` holds the three biggest by memory. Figures are
|
|
1587
|
+
quantized (1 %, 0.1 GiB, 0.1 load), so `status.json` is rewritten only when one of them visibly moves or at the 15 s
|
|
1588
|
+
heartbeat. On a `critical` host no sample is taken: the last facts stay, `"stale": true`, until the host recovers.
|
|
1589
|
+
|
|
1590
|
+
```sh
|
|
1591
|
+
jq .fleet ~/.local/state/lanes/status.json # the facts; "stale": true = not sampled since the host went critical
|
|
1592
|
+
jq '.fleet.containers' ~/.local/state/lanes/status.json
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
**`throttle` — the Throttle pulse's last document.** The pulse ("Throttle pulse" above) writes
|
|
1596
|
+
`~/.lz/state/throttle.json` in the daemon's container and statusd copies it: one `stat` per cycle through
|
|
1597
|
+
`/proc/<init>/root`, a read only when the file moved, at most 64 KiB, never through a link. statusd names the daemon's
|
|
1598
|
+
container as the pulse section's "Which container" says (the tab's container: `jq '.tools'
|
|
1599
|
+
~/.local/state/lanes/registry.json`) and looks it up again after a `lanes sync` that changed the registry and every 5
|
|
1600
|
+
min (edit site.conf, then wait or `systemctl --user restart lanes-statusd`). No container known = no `throttle` key at
|
|
1601
|
+
all. `"stale": true` means the pulse's `at` is more than 90 s old (a healthy pulse rewrites every 60 s and is read up
|
|
1602
|
+
to about 84 s after its `at`, so 60–90 s is normal; `lanes doctor` and the sidebar use the same 90 s, DEF-1941), the
|
|
1603
|
+
file is absent, or it was refused (a link, a FIFO, more than 64 KiB, no v1 document) — the last values are kept and
|
|
1604
|
+
shown dim.
|
|
1605
|
+
|
|
1606
|
+
**The re-arm (the only spawn this adds).** When the pulse file is older than 90 s (or missing) while the daemon's
|
|
1607
|
+
container runs and the host is not `critical`, statusd — between two cycles, never inside one — runs the start command
|
|
1608
|
+
of "How it runs" above, byte for byte (DEF-1940), at most once per 10 min and bounded to 10 s, and journals it
|
|
1609
|
+
(`journalctl --user -u lanes-statusd | grep 'throttle pulse'`: the file's age, `re-armed (exit N)` and the argv). It
|
|
1610
|
+
starts nothing when `~/.lz/bin/throttle-pulse` is not installed in that container (`lanes install-container <c>` ships
|
|
1611
|
+
it), and it never runs on a `critical` host — a pulse that keeps going quiet on a healthy host is a pulse that
|
|
1612
|
+
crashes: run it by hand in the container (`~/.lz/bin/throttle-pulse` without `--loop`, as "How to see it" above
|
|
1613
|
+
shows) and read its stderr.
|
|
1614
|
+
|
|
1219
1615
|
## Push notifications (opt-in)
|
|
1220
1616
|
|
|
1221
1617
|
`lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent ENTERS needs-you —
|
|
@@ -1298,7 +1694,7 @@ that becomes readable switches the pushes on within one tick and a removed one s
|
|
|
1298
1694
|
| `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret — pick a long random one |
|
|
1299
1695
|
| `token_file` | unset | a file holding an ntfy access token (sent as a bearer). An unreadable file disables sending rather than sending unauthenticated (or with the token it held before). Keep the token in that file (`chmod 600`), never in `notify.conf` |
|
|
1300
1696
|
| `quiet_when_device_active_s` | `120` | presence window above; `0` never suppresses — except for the one person present when their profile sets a `notify.quiet_s` of its own |
|
|
1301
|
-
| `action_url` | unset | a URL template over `{lane}` (the lane id, URL-quoted), e.g. a page of yours that opens it; becomes the push's tap target and an **Open lane** button |
|
|
1697
|
+
| `action_url` | unset | a URL template over `{lane}` (the lane id, URL-quoted), e.g. a page of yours that opens it; becomes the push's tap target and an **Open lane** button. `{doc}` (plan W10 W7) is the docs page of the document a `published` push names, `/lanes-docs/d/<id>` (`https://<server>:8443{doc}` opens it), empty in a needs-you push. The `published` push (plan W10 W4) is sent by the docs service's publish pick-up, not by this unit: same config, same desk-presence rule, `{title: Published, message: <lane name> published a document (<kind>, <size>) + its page, priority 2}`, at most one per lane per minute, never the document's title or name |
|
|
1302
1698
|
|
|
1303
1699
|
Without a valid `url` and `topic` the service only tracks transitions and sends nothing. Turn it on:
|
|
1304
1700
|
|
|
@@ -1374,12 +1770,27 @@ The Studio's design of record is `docs/design/throttle-studio.md` (§1.1 "The la
|
|
|
1374
1770
|
## Uploads (the Cursor `uploads/` folder, without Cursor)
|
|
1375
1771
|
|
|
1376
1772
|
Files land in `<board folder>/uploads/` on the server — the same folder you drop into from Cursor. The folder is
|
|
1377
|
-
created on first use and kept out of commits through the repo-local `.git/info/exclude` when
|
|
1378
|
-
|
|
1773
|
+
created on first use (0755) and kept out of commits through the repo-local `.git/info/exclude` when that file does not
|
|
1774
|
+
name it yet. Afterwards the in-container path is on your clipboard and in a notification. Every upload — these ways
|
|
1775
|
+
and the Docs page's drop area ("Docs service" below) — also becomes a docs-store document with a sha256 receipt (W10
|
|
1776
|
+
W5): one module, `libexec/uploads.py`, lands the file and hands the same bytes to `docs_store.intake` in one pass, so
|
|
1777
|
+
the receipt is of exactly what arrived; the store keeps a copy (never a hard link to the container's file, which an
|
|
1778
|
+
agent could rewrite under an unchanged receipt). `lanes upload` says each receipt on stderr (`# docs: <name> <bytes> B
|
|
1779
|
+
sha256 <hash> <id>`, in the helper's log) and records it for the login that owns the asking device, under the lane its
|
|
1780
|
+
view shows; a device nobody owns gets a `docs.log` line (`upload - - <lane> <bytes> <sha256> no-owner`) and no
|
|
1781
|
+
document; a file over 32 MiB lands without one (`intake … too-large`). stdout and the spool's `<request>.done` are what
|
|
1782
|
+
they always were.
|
|
1783
|
+
|
|
1784
|
+
The host never follows a symbolic link in the container's folder (an absolute link would resolve against the host's
|
|
1785
|
+
root: DEF-1891) and runs no git there (`git check-ignore` runs the repository's `core.fsmonitor`, a command the
|
|
1786
|
+
container chose: DEF-1890). A folder or `uploads/` reached through a link is refused (`lanes: uploads/ in the
|
|
1787
|
+
workspace folder is not a directory (a symbolic link is refused)`): replace the link with the real directory in the
|
|
1788
|
+
container. The tar stream is unpacked member by member: links that leave the upload, `..`, devices and FIFOs refuse
|
|
1789
|
+
the whole upload, as tar's data filter did.
|
|
1379
1790
|
|
|
1380
1791
|
| Way | How |
|
|
1381
1792
|
|---|---|
|
|
1382
|
-
| Upload strip in the sidebar | The one row above the footer rule, ` ↑ uploads · click, then drop (U)
|
|
1793
|
+
| Upload strip in the sidebar | The one row above the footer rule, ` ↑ uploads · click, then drop (U)` — ` ↑ uploads · ▤ docs 3 (U)` once the workspace's lanes have published documents, the whole row then a link to the docs page (plan W10 W7). Click it (or press `U` in the sidebar, `Ctrl-Alt-u` from anywhere in the tab) — it turns into ` ↓ DROP FILES NOW · anywhere · Esc` — then **drop the files anywhere on the window**. The file goes to the board of that tab and its path is typed into the lane, ready for the agent. `Esc` cancels. While it sends the strip says ` ↑ sending 2 files · 12.4 MiB` and repaints only when the helper reports progress (no spinner, no fast tick); the result stays 8 s as ` ✓ name, name` or ` ✗ reason`. |
|
|
1383
1794
|
| Watch folder | `~/Lanes Uploads/` → the board your view shows; `~/Lanes Uploads/<board>/` → that board. Sent files move to `.sent/<date>/`. Point CleanShot's save folder here for instant screenshot upload. |
|
|
1384
1795
|
| Command | `lanes-upload [-w <board>] [--paste] <files…>` · `lanes-upload --clipboard [--paste]` (clipboard image or copied files) |
|
|
1385
1796
|
| macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it (Dock); double-click uploads the clipboard screenshot |
|
|
@@ -1542,6 +1953,10 @@ Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-t
|
|
|
1542
1953
|
- **`lanes web` printed a `127.0.0.1` URL**: the host has no Tailscale name. Connect Tailscale on the host;
|
|
1543
1954
|
there is no public fallback.
|
|
1544
1955
|
|
|
1956
|
+
The Docs page (`/lanes-docs/`: the phone's **More → Docs**, the laptop bar's **Docs**, the sidebar's ` ▤ docs N`) is
|
|
1957
|
+
a service of its own behind the same Serve and Caddy, with its own pinned Mermaid: section "Docs service" (its unit,
|
|
1958
|
+
pin gate, `docs.log`, routes, Mermaid pin, doctor lines and the operator's live checklist).
|
|
1959
|
+
|
|
1545
1960
|
### Tailscale Serve
|
|
1546
1961
|
|
|
1547
1962
|
`lanes web` (not `--print-only`) makes Tailscale Serve forward `https:8443` to `http://127.0.0.1:8083` after it
|
|
@@ -1823,6 +2238,340 @@ signed out in step 3; their views are named, not ended — offboard each by name
|
|
|
1823
2238
|
open with the key keeps running until it ends (step 1 says so). `enrol.log` gets one `offboard` line with the key's
|
|
1824
2239
|
fingerprint and `ok` or `incomplete`.
|
|
1825
2240
|
|
|
2241
|
+
## Docs service (`lanes-docsd`, W10 W3)
|
|
2242
|
+
|
|
2243
|
+
The docs cockpit's web half: what an agent publishes from a lane (W4, "Publish pick-up" below) and what a person
|
|
2244
|
+
uploads (W5: the Docs page's drop area and every `lanes upload`) is opened at
|
|
2245
|
+
`https://<host>.<tailnet>.ts.net:8443/lanes-docs/` from the phone or laptop shell, bound to the person's
|
|
2246
|
+
tailnet login. `lanes-docsd.service` runs `libexec/docsd.py` on **127.0.0.1:8185**; the `lanes-mobile` Caddy forwards
|
|
2247
|
+
`/lanes-docs/*` to it (`handle_path /lanes-docs/*` in `web/Caddyfile`, which strips the prefix and is the FIRST handle of
|
|
2248
|
+
the site, before the Zellij routes) and keeps Serve's `X-Forwarded-For` (`trusted_proxies 127.0.0.0/8`, as on
|
|
2249
|
+
`/enrol/*`). The site-wide Caddy header block is `header @notdocs` (`@notdocs not path /lanes-docs/*`), so a docs
|
|
2250
|
+
response carries docsd's headers only. Every server install enables and restarts it after `lanes-enrold`, then runs
|
|
2251
|
+
`install/enable-docs.sh` for the page's Mermaid ("Mermaid pin" below; never fatal); an update (`--update-only`)
|
|
2252
|
+
restarts it when it runs (`update.TRY_RESTART`), runs `enable-docs.sh` right after, and, like every update, enables
|
|
2253
|
+
nothing — a server set up before the docs service and only updated since has no `lanes-docsd` until one full `--server`
|
|
2254
|
+
install (or `systemctl --user enable --now lanes-docsd.service`); `lanes doctor` says so (DEF-1900, mitigated). In
|
|
2255
|
+
order below: the unit, the pin gate and who sees what, the routes, uploads, encryption, `docs.log`, the publish
|
|
2256
|
+
pick-up and the Caddy reload; then "Mermaid pin", "What `lanes doctor` says" and the operator's live checklist
|
|
2257
|
+
("Operator verification"). `lanes docs push <id>` prints one document's receipt and exits 2 `not configured`: the
|
|
2258
|
+
hook for the data service's publication surface, not built (operator gate 3 of the plan).
|
|
2259
|
+
|
|
2260
|
+
The unit, `lanes-docsd.service` (rendered from `systemd/lanes-docsd.service` by `install/services.py`), runs
|
|
2261
|
+
`python3 -S -E libexec/docsd.py` in `lanes.slice` with `Restart=on-failure` (10 s), `NoNewPrivileges=true`,
|
|
2262
|
+
`UMask=0077`, `MemoryHigh=128M` and `MemoryMax=192M`, and `WantedBy=default.target`; no `ProtectHome` (it writes
|
|
2263
|
+
`~/.local/state/lanes/docs/` and `docs.log`, its publish pick-up reads each workspace container through
|
|
2264
|
+
`/proc/<pid>/root`, as `lanes-statusd` does, and an upload lands in the workspace's `uploads/` through the container's
|
|
2265
|
+
home volume). MEASURED 2026-10-08 on the development host (`/usr/bin/python3`
|
|
2266
|
+
3.12.3 `-S -E`, load about 70; not the ceobox service): idle VmHWM 25 MB, one 32 MiB upload 56 MB, two at once (the
|
|
2267
|
+
most it parses: `UPLOAD_SLOTS`) 89 MB; the security review (2026-10-08) measured 94 MiB for two. A decryption now
|
|
2268
|
+
takes one of the same two slots (it holds up to 32 MiB of plaintext), so at most two 32 MiB buffers are held at once;
|
|
2269
|
+
the composed peak under `MemoryMax` (two buffers, two renders, the render cache, a pick-up survey) is not measured
|
|
2270
|
+
(DEF-1917, mitigated). `LANES_DOCS_PORT` (or `--port`) moves it off 8185 (the Caddyfile route must follow); `--bind`
|
|
2271
|
+
takes a loopback address only.
|
|
2272
|
+
|
|
2273
|
+
```sh
|
|
2274
|
+
systemctl --user status lanes-docsd.service
|
|
2275
|
+
journalctl --user -u lanes-docsd.service -n 30 # "listening on 127.0.0.1:8185; pinned request (…); domains …"
|
|
2276
|
+
tail ~/.local/state/lanes/docs.log # one line per read, upload refusal, publish, intake and denial
|
|
2277
|
+
lanes doctor # the five docs lines below
|
|
2278
|
+
```
|
|
2279
|
+
|
|
2280
|
+
**The pin gate.** docsd answers NOTHING until the operator has run the enrolment service's Serve header check (section
|
|
2281
|
+
"Enrolment service", Operator verification) and pinned its result with `lanes enrol mode request` (it reads
|
|
2282
|
+
`~/.local/state/lanes/enrol-mode` on every request: no restart). Unpinned (`auto`), pinned `invite`, or a pin that
|
|
2283
|
+
cannot be read: every request is `403 not-pinned`, and the start line says `NOT pinned`. `LANES_ENROL_REQUEST_MODE`
|
|
2284
|
+
in enrold's unit is enrold's alone and is not a pin here. After the pin, a request is answered only when ALL hold —
|
|
2285
|
+
else `403` with the word, a short page for a browser:
|
|
2286
|
+
|
|
2287
|
+
| Check | Refusal |
|
|
2288
|
+
|---|---|
|
|
2289
|
+
| it came through Tailscale Serve: its `X-Forwarded-For` (Serve sets it, Caddy keeps it) names a non-loopback address | `not-through-serve` (a probe of 127.0.0.1:8185 or of Caddy from this host) |
|
|
2290
|
+
| Serve's identity header set: `Tailscale-User-Login` and `Tailscale-User-Name` | `no-identity` (a tagged device) |
|
|
2291
|
+
| the login validates as a member login | `invalid-login` |
|
|
2292
|
+
| the pin above | `not-pinned` |
|
|
2293
|
+
| the login owns a valid `members/<device>.json` (casefolded) | `not-a-member` (`lanes keys owner <device> <login>`) |
|
|
2294
|
+
| its domain is in `LANES_DOCS_DOMAINS`, else `LANES_ENROL_DOMAINS` (default `beyondwork.ai`, `*` = any) — set it in this unit's drop-in | `domain-not-allowed` |
|
|
2295
|
+
| its `Sec-Fetch-Site` is `same-origin` (a page, script, stylesheet or image of the Docs pages, the phone/laptop shell's **Docs** link) or `none` (an address typed or bookmarked, a link opened from a terminal or a phone app); or it is a top-level navigation — a GET or HEAD with `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document` — from any site (a link followed in ntfy's web app, a chat, a mail); `POST /up` only `same-origin` or `none` — every route, since the security review of 2026-10-08 | `cross-site` (anything else from another site — `cross-site`, `same-site` (`*.ts.net` is a public suffix, so another tailnet host's page is same-site) — that is not such a navigation: a `fetch()`, an image, a script, a frame, a form posted to `/up`; or no `Sec-Fetch-Site` on a request that is not a navigation: a browser too old to send it, or `curl`) |
|
|
2296
|
+
|
|
2297
|
+
No Zellij cookie is consulted. The `cross-site` check is why a sandboxed artifact (an opaque origin) cannot spend the
|
|
2298
|
+
viewer's login here: its `fetch()` and subresource loads are not navigations. A link to a docs page followed from
|
|
2299
|
+
another website (a web mail, a chat in a browser, ntfy's web app) is a top-level navigation and is served. The
|
|
2300
|
+
artifact's sandbox (`sandbox allow-scripts` only) cannot open a window, submit a form or navigate another browsing
|
|
2301
|
+
context; the one navigation its script can start is of its own page, which replaces it unless the answer is a download
|
|
2302
|
+
the sandbox blocks, so what it can request that way is bounded by the 300-a-minute limit (the HTML standard's rules,
|
|
2303
|
+
not measured in a browser).
|
|
2304
|
+
**Trust boundary:** as for enrold (DEF-1645, open), a process on this host that reaches loopback can send the headers
|
|
2305
|
+
itself and read any member's documents; the pin and the Serve-only check are the controls, and the same `docker ps
|
|
2306
|
+
--filter network=host` audit applies. And a member's device key carries `restrict,pty` but no `command=`
|
|
2307
|
+
(`libexec/keys.py` `OPTIONS`): a member who can SSH in has a shell as the lanes account and can read every store
|
|
2308
|
+
directly, so the per-login separation of the docs service holds between people who only use the web, not against a
|
|
2309
|
+
member with a device key (DEF-1919, open). Never expose 8443 through Tailscale Funnel (Funnel traffic carries no
|
|
2310
|
+
identity headers).
|
|
2311
|
+
|
|
2312
|
+
**Who sees what.** A person sees the documents in their own store (their uploads), the documents whose `for` names
|
|
2313
|
+
them, and a document that names nobody when a member record they own grants its workspace. A record's `workspaces`
|
|
2314
|
+
(the board ids an invite recorded with `lanes invite --workspaces`) grants those boards; a record that lists none —
|
|
2315
|
+
a key added with `lanes keys add|adopt`, a device enrolled by request, an invite minted without `--workspaces` —
|
|
2316
|
+
grants every workspace the server serves (the registry's), because nothing else in Lanes narrows by that list
|
|
2317
|
+
(DEF-1852, closed). The same grant decides the lanes the upload form offers and `POST /up` accepts. A document a
|
|
2318
|
+
person cannot see answers `404`, like one that does not exist.
|
|
2319
|
+
|
|
2320
|
+
| Route (after Caddy strips `/lanes-docs`) | Answer (wire contract v1, DM-LANES-DOCS-WIRE) |
|
|
2321
|
+
|---|---|
|
|
2322
|
+
| `GET /` · `GET /?ws=<ws>&lane=<lane>` | the index page: workspace → lane → newest first, counts, the upload form; the filter (either key optional) must name a workspace/lane the person knows, else `404` |
|
|
2323
|
+
| `GET /d/<id>` | the document page: Markdown rendered (mdlite; over 1 MiB not rendered; from the render cache when its sha256 was rendered before; `503 busy` + `Retry-After` when two renders run and none ends within 1 s), an HTML/SVG artifact linked (never embedded), a file's facts and download |
|
|
2324
|
+
| `GET /d/<id>/raw` | the bytes, `Content-Disposition: attachment; filename="<name>"`, `X-Lanes-Sha256` |
|
|
2325
|
+
| `GET /d/<id>/artifact` | kind html/svg only: the bytes as a top-level document under `Content-Security-Policy: sandbox allow-scripts` |
|
|
2326
|
+
| `GET /receipt/<id>.json` | `receipt.json` (`{v, id, sha256, bytes, url, at}`) |
|
|
2327
|
+
| `GET /docs.css`, `GET /docs.js` | `web/docs.css` / `web/docs.js` of the running tree (W6), else `404` |
|
|
2328
|
+
| `GET /upload.css`, `GET /upload.js` | `web/upload.css` / `web/upload.js` (W5, the index's drop area), else `404` |
|
|
2329
|
+
| `POST /up` | multipart form: `lane` (+ optional `ws`), files; each lands in the workspace's `uploads/` AND the store, both or neither; `200 {receipts: [{id, name, sha256, bytes, url, at, landed}]}` or a page; `429` (30 per login per 10 min) + `Retry-After`, `503 busy` (two big buffers at once — uploads and decryptions —, one per login, or the store lock), `415`, `411`, `413` (32 MiB), `408 request-timeout` (the body did not arrive within 30 s + its length at 64 KiB/s), `400 bad-request\|no-file\|no-lane\|unknown-lane\|unknown-workspace`, `503 registry-unavailable`, `503 uploads-unavailable` (the container's folder: home volume unknown, a link on the way), `500 encrypt-failed` |
|
|
2330
|
+
| every route but `POST /up` | `429 rate-limited` + `Retry-After` past 300 requests per login per minute (one `docs.log` line per minute) |
|
|
2331
|
+
|
|
2332
|
+
**Limits** (per `lanes-docsd` process; security review 2026-10-08 — each pinned by a test in `tests/unit/test_docsd.py`
|
|
2333
|
+
or `test_docs_store.py`, values in `libexec/docsd.py` / `docs_store.py`):
|
|
2334
|
+
|
|
2335
|
+
| Limit | Value | Past it |
|
|
2336
|
+
|---|---|---|
|
|
2337
|
+
| requests per login, every route but `POST /up` (`VIEWS` / `VIEW_WINDOW`) | 300 per 60 s, a fixed window | `429 rate-limited` + `Retry-After`; one `view … rate-limited` line per window |
|
|
2338
|
+
| Markdown renders at once (`RENDER_SLOTS`, `RENDER_WAIT_S`) | 2; a view waits 1 s for one | `503 busy` + `Retry-After: 5` |
|
|
2339
|
+
| render cache (`RENDER_CACHE_BYTES`, `RENDER_CACHE_ENTRY`) | 8 MiB in all, by the document's sha256, least recently used out; a render over 2 MiB is not kept | a second view of a cached document renders nothing and needs no slot |
|
|
2340
|
+
| big buffers at once (`UPLOAD_SLOTS`), shared by uploads and decryptions | 2, at most 1 per login; a decryption also one at a time and waits 5 s for a slot | `503 busy` + `Retry-After: 5` |
|
|
2341
|
+
| uploads per login (`UPLOADS` / `UPLOAD_WINDOW`) | 30 per 10 min, counted at the start | `429 rate-limited` |
|
|
2342
|
+
| upload body deadline (`BODY_GRACE_S`, `BODY_MIN_RATE`) | 30 s + length / 64 KiB/s (32 MiB: 542 s) for the whole body; each read still times out after 30 s without data | `408 request-timeout`, connection closed, the slot free again |
|
|
2343
|
+
| refused body read away (`DRAIN_LIMIT`, `LINGER_S`) | 64 MiB before the answer, then 5 s | the client may see a reset |
|
|
2344
|
+
| pick-up survey (`MAX_ENTRIES`) | 4,096 directory entries per container per cycle (its publish directory and lane directories together) | the container is refused for the cycle: nothing taken; the journal says `pick-up <container>: refused: more than 4096 entries …` once an hour (`OVER_CAP_SAY_S`) |
|
|
2345
|
+
| pick-up per container (`MAX_PICKUPS`, `PUBLISHES`) | 16 per cycle, 30 per 10 min | the rest wait; past 30 refused `rate-limited` |
|
|
2346
|
+
| `docs.log` refusal lines (`DENIED_REPEAT_S`, `MAX_DENIED_SOURCES`, `MAX_DENIED_LOGIN`) | one `denied` line per source (the tailnet address Serve forwarded, else the peer) and word, then one `denied-repeat` line per minute with the count; 1,024 sources remembered; a login logged at most 64 characters | — |
|
|
2347
|
+
| `docs.log` size (`docs_store.LOG_ROTATE_BYTES`) | 8 MiB, then `docs.log.1` (one generation) | the older generation is replaced |
|
|
2348
|
+
|
|
2349
|
+
Every response has `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and a
|
|
2350
|
+
CSP: pages `default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src
|
|
2351
|
+
'self'; form-action 'self'; base-uri 'none'` (`connect-src` for the drop area's own `POST up`), an artifact exactly
|
|
2352
|
+
`sandbox allow-scripts`, everything else `default-src 'none'; sandbox`.
|
|
2353
|
+
Links and assets in a page are relative (`docs.css`, `d/<id>`), so the pages work under the stripped prefix; open the
|
|
2354
|
+
index as `/lanes-docs/` with the slash (`/lanes-docs` alone is the page route a device of that name would have, which
|
|
2355
|
+
is why `lanes-docs`, `lanes-assets` and `enrol` are not device names). An upload goes through `uploads.receive` (W5):
|
|
2356
|
+
it lands in the lane's workspace's `uploads/` (`<folder>/uploads/<name>`, `-1` on a collision, exactly as `lanes
|
|
2357
|
+
upload` lands one) and `docs_store.intake` (`source` `web`, kind by extension, `for` and `by` the uploader) takes the
|
|
2358
|
+
same bytes into the person's store; the receipt's `landed` is the in-container path to tell the agent. The drop area is
|
|
2359
|
+
a plain form that works without a script; `web/upload.js` (loaded by the index only) adds the drop onto the whole area,
|
|
2360
|
+
a size check before sending, progress and the receipt list.
|
|
2361
|
+
The multipart body is split by docsd's own bytes scan, each part's headers parsed by `email.parser`: parsing a 32 MiB
|
|
2362
|
+
body whole with `email.parser` MEASURED about 430 MB peak RSS and 3.8 s, the scan 0.05 s and no copy.
|
|
2363
|
+
|
|
2364
|
+
**A refused upload is answered, not reset (DEF-1854, closed by W5).** A browser sends its whole body before it reads
|
|
2365
|
+
the answer, and a server that answers and closes while the body is still arriving resets the connection under its own
|
|
2366
|
+
answer. docsd now reads a refused body away up to its `Content-Length` — at most 64 MiB (`DRAIN_LIMIT`, twice the cap) —
|
|
2367
|
+
before it answers (with `Connection: close`); past that it answers, shuts its write side and reads the rest for at
|
|
2368
|
+
most 5 s (`LINGER_S`). MEASURED 2026-10-08 against a real `python3 -S -E docsd.py` (development host, load ~15–20 on
|
|
2369
|
+
64 cores), three concurrent 32 MiB uploads then a 48 MiB and a 100 MiB one: before (the W3 tree) 200, 200 and a
|
|
2370
|
+
`BrokenPipeError`, and both oversize uploads `BrokenPipeError`; after, three runs, 200, 200, `503 busy` and both `413`
|
|
2371
|
+
read by the client. Over a slow link a body past 64 MiB can still be reset; upload.js refuses a set over 32 MiB before
|
|
2372
|
+
it sends.
|
|
2373
|
+
|
|
2374
|
+
**Encryption at rest (`age`, W5; off by default).** `LANES_DOCS_AGE_RECIPIENT=<age recipient>` in lanes-docsd's
|
|
2375
|
+
drop-in makes the docs store keep each NEW document as `age -r <recipient>` output (`<name>.age`, meta.json `enc:
|
|
2376
|
+
{alg, file, bytes, recipient}`); the file in the workspace's `uploads/` stays plain (agents read it), and receipts stay
|
|
2377
|
+
the sha256 of the plaintext, computed before encryption (age output is randomised, so a hash of it would match nothing
|
|
2378
|
+
anyone holds). `lanes upload` runs over SSH, outside the unit, and reads the same drop-in files, so both paths follow
|
|
2379
|
+
one switch. For docsd to open encrypted documents, give it an identity file with `LANES_DOCS_AGE_IDENTITY=<path>`: raw
|
|
2380
|
+
downloads and artifacts are then decrypted on the fly (at most 32 MiB, one at a time) and checked against the receipt;
|
|
2381
|
+
without it they answer `409 encrypted` (a page that sends the person to `uploads/`) and a document page shows its facts
|
|
2382
|
+
only. Documents stored before the switch stay plain. Every server install refuses the setting where no `age` binary is
|
|
2383
|
+
on PATH (`host-install.sh` `check_docs_age` → `python3 libexec/docs_store.py check-age`, exit 1 with the reason; an
|
|
2384
|
+
update records it `refused`, nothing changed); install `age` first.
|
|
2385
|
+
|
|
2386
|
+
```sh
|
|
2387
|
+
systemctl --user edit lanes-docsd.service # [Service]
|
|
2388
|
+
# Environment=LANES_DOCS_AGE_RECIPIENT=age1...
|
|
2389
|
+
# Environment=LANES_DOCS_AGE_IDENTITY=/home/<you>/.config/lanes/docs-age.key
|
|
2390
|
+
python3 ~/.local/share/lanes/current/libexec/docs_store.py check-age && systemctl --user restart lanes-docsd.service
|
|
2391
|
+
```
|
|
2392
|
+
|
|
2393
|
+
**docs.log** (`~/.local/state/lanes/docs.log`, 0600, append-only, DM-LANES-DOCS-LOG): `<at> <login> <event> <id> <lane>
|
|
2394
|
+
<bytes> <sha256> <outcome>` — docsd adds `denied` (outcome = the refusal word; the first refusal of a source and word,
|
|
2395
|
+
the login cut to 64 characters) and `denied-repeat` (one a minute while that source keeps being refused: the `bytes`
|
|
2396
|
+
field is how many refusals the line folds, the login the latest), `view`/`raw`/`artifact`/`receipt` (`ok`,
|
|
2397
|
+
`not-found`, `encrypted`, `busy`, and `view … rate-limited` once a window), `upload` (a refusal before any file
|
|
2398
|
+
reaches intake, `request-timeout` included) and `publish` (one per request the pick-up takes: `ok` or the refusal
|
|
2399
|
+
reason — the precise word, also where the receipt says `not-delivered`); each stored file is intake's own `intake`
|
|
2400
|
+
line; `lanes upload` adds `upload … no-owner`/`no-lane`/`too-many` lines with a file's size and sha256 when it lands
|
|
2401
|
+
without a document. Never content, a title or a file name. At 8 MiB the file is renamed `docs.log.1` (replacing the
|
|
2402
|
+
one before) and a new one begun, by whichever writer finds it full first (an exclusive `flock`; the others append to
|
|
2403
|
+
the new file), so the two files never hold much more than 16 MiB. Before the security review (2026-10-08) refusals
|
|
2404
|
+
from any tailnet device appended 269 B each without bound (DEF-1913).
|
|
2405
|
+
|
|
2406
|
+
**Publish pick-up (W10 W4).** `lane publish <file>` and the agents' `lanes_publish` write a request in the container,
|
|
2407
|
+
`~/.lz/state/publish/<lane id>/<id>.json` (`{v: 1, path, title, kind, claimed_sha256, mime, bytes, for}`;
|
|
2408
|
+
DM-LANES-PUBLISH-REQUEST). A thread of `lanes-docsd` takes them every 5 s — for each container `containers.json` names
|
|
2409
|
+
whose init pid is alive (a pid whose `/proc/<pid>/root` is the host's own `/` is skipped, and so is one whose
|
|
2410
|
+
`/home/coder` is not the home volume `containers.json` records, when this account can see that volume), at most 16 per
|
|
2411
|
+
container per cycle and 30 per container per 10 minutes (more are refused `rate-limited`). It first surveys the
|
|
2412
|
+
container's publish directory and lane directories, at most 4,096 entries in all; a container that holds more is
|
|
2413
|
+
refused for the cycle (Limits above). It renames the request to `.<id>.taking` (a directory planted at that name is
|
|
2414
|
+
removed when empty, else moved aside to `.<name>.<random>.planted`; a request it still cannot rename is left alone, the
|
|
2415
|
+
next one is taken and the journal says why once), reads it, resolves the file inside
|
|
2416
|
+
the container to a regular file under `/home/coder` (never through a link, never a FIFO), streams it into the store and
|
|
2417
|
+
compares the sha256. The answer is written back beside the request as `<id>.receipt.json` (0644; DM-LANES-PUBLISH-RECEIPT):
|
|
2418
|
+
|
|
2419
|
+
```json
|
|
2420
|
+
{"v": 1, "id": "<document id>", "request": "<id>", "sha256": "…", "bytes": 1234, "url": "/lanes-docs/d/<document id>", "at": "2026-10-08T12:00:00Z", "outcome": "ok"}
|
|
2421
|
+
{"v": 1, "id": null, "request": "<id>", "sha256": null, "bytes": null, "url": null, "at": "…", "outcome": "refused", "reason": "outside-home"}
|
|
2422
|
+
```
|
|
2423
|
+
|
|
2424
|
+
then the claim is removed and the lane keeps its 16 newest receipts (a directory planted at the receipt's name is
|
|
2425
|
+
removed or moved aside first, so the answer is always written). A refused receipt carries the host's sha256 and size
|
|
2426
|
+
when it read the file (`sha256-mismatch`, `too-large`). Refusal reasons (the receipt's word):
|
|
2427
|
+
|
|
2428
|
+
| Reason | Meaning |
|
|
2429
|
+
|---|---|
|
|
2430
|
+
| `unreadable-request` | the request is not a regular JSON object file of at most 16 KiB (a link, a FIFO, a directory, not JSON) |
|
|
2431
|
+
| `bad-version` · `bad-request` · `bad-kind` · `bad-title` | `v` is not 1; a field of the wrong type (`path` not absolute, `claimed_sha256` not 64 hex, `bytes` not a count); `kind` not `md`/`html`/`svg`/`file`; a title that is not one printable line of at most 200 characters |
|
|
2432
|
+
| `too-large` | the claimed or the real size is over 32 MiB |
|
|
2433
|
+
| `unknown-lane` | the registry places no lane of that id in this container |
|
|
2434
|
+
| `rate-limited` | over 30 requests from this container in 10 minutes |
|
|
2435
|
+
| `not-delivered` | `for` names a login that owns no member record, or — no `for`, nobody opened the lane — no member record grants its workspace. One word for both (security review 2026-10-08, DEF-1915): the two words told a container whether a login it named is a member. `docs.log`'s `publish` line keeps the precise one, `for-not-a-member` or `no-recipient`; a `for` that names a member still answers `ok` |
|
|
2436
|
+
| `outside-home` | the path, every link resolved inside the container, is not under `/home/coder` |
|
|
2437
|
+
| `not-a-file` | missing, a directory, a FIFO or device, or a link appeared on the way after it was resolved |
|
|
2438
|
+
| `sha256-mismatch` | the file's bytes are not the ones the request hashed (it changed): nothing is stored |
|
|
2439
|
+
| `invalid` | the store refused it (a name or login it cannot hold) |
|
|
2440
|
+
| `error` | anything else went wrong after the claim (the journal names it): the request is answered and removed, never dropped silently |
|
|
2441
|
+
|
|
2442
|
+
Who it is for: `for` (stored in that member's store, for them alone); else the member whose profile last opened the lane
|
|
2443
|
+
(`users/<login>.json` `last_opened`); else every member whose record grants the lane's workspace (`for` empty). A busy
|
|
2444
|
+
store leaves the request for the next cycle, and without a `registry.json` nothing is taken (the requests wait). Each stored document sends one `published` push through notify (above;
|
|
2445
|
+
at most one per lane per minute). From inside a container the latest answer is `lanes_status`'s `published`, or:
|
|
2446
|
+
|
|
2447
|
+
```sh
|
|
2448
|
+
ls -t ~/.lz/state/publish/<lane id>/ | head; cat ~/.lz/state/publish/<lane id>/<id>.receipt.json
|
|
2449
|
+
```
|
|
2450
|
+
|
|
2451
|
+
A crash between the claim and the receipt leaves `.<id>.taking` without a receipt; publish again.
|
|
2452
|
+
|
|
2453
|
+
**Caddy picks up the route** as for `/enrol/*` (section "Enrolment service"): a server install restarts a running
|
|
2454
|
+
`lanes-mobile`; after editing the Caddyfile in place, `systemctl --user restart lanes-mobile.service`. If
|
|
2455
|
+
`/lanes-docs/` answers with the Zellij page, `lanes-mobile` runs an older Caddyfile. Settings go in a drop-in:
|
|
2456
|
+
|
|
2457
|
+
```sh
|
|
2458
|
+
systemctl --user edit lanes-docsd.service # [Service] Environment=LANES_DOCS_DOMAINS=beyondwork.ai,example.com
|
|
2459
|
+
systemctl --user restart lanes-docsd.service
|
|
2460
|
+
```
|
|
2461
|
+
|
|
2462
|
+
### Mermaid pin
|
|
2463
|
+
|
|
2464
|
+
The docs page (plan W10, `docs/superpowers/plans/2026-10-08-lanes-docs-cockpit.md`) draws ```` ```mermaid ```` fences
|
|
2465
|
+
with Mermaid 11.17.2, pinned in `holders.lock` as an opt-in line: the npm tarball's sha256 and the sha256 of its member
|
|
2466
|
+
`package/dist/mermaid.min.js`, the classic-script build (3 572 661 B). The server installer's pinned-binaries loop
|
|
2467
|
+
skips it; `install/enable-docs.sh` installs it. As the workspace account, `sh
|
|
2468
|
+
~/.local/share/lanes/current/install/enable-docs.sh` downloads the tarball into a
|
|
2469
|
+
temporary directory, refuses unless both digests match, and installs `~/.local/share/lanes/vendor/mermaid-11.17.2.min.js`
|
|
2470
|
+
(0644; `$LANES_SHARE/vendor`) and the link `mermaid.min.js`; run again, it finds the pinned digest and downloads
|
|
2471
|
+
nothing. Exit status: 0 installed or present, 1 a prerequisite or the download failed, 2 usage, 3 refused (no valid
|
|
2472
|
+
pin, a digest mismatch, the member missing or not a regular file) — a refusal installs nothing.
|
|
2473
|
+
|
|
2474
|
+
**The server install runs it (plan W8, LNS-109), never fatally.** The full `./install.sh --server` (or `npx
|
|
2475
|
+
@vibelord/lanes@<release> --server`) runs it right after `lanes-docsd`'s enable and restart, shows its own line, goes
|
|
2476
|
+
on whatever it exits, and ends its summary with one line: `Mermaid for the docs page: ok`, `… skipped (<its last
|
|
2477
|
+
line>) — diagrams show as source until sh <tree>/install/enable-docs.sh succeeds` (exit 1: a prerequisite or the
|
|
2478
|
+
network), `… refused (<its last line>) — nothing was installed; see RUNBOOK "Mermaid pin"` (exit 3), or `… failed (exit
|
|
2479
|
+
N: …)`; `--no-start` does not run it and says so. **An update (`--update-only`)** runs it as the post-commit step
|
|
2480
|
+
`enable-docs`, right after `try-restart lanes-docsd` (`libexec/update.py` `DOCS_STEP`; a release rolled back to that
|
|
2481
|
+
has no script gets no step): exit 0 is `ok`; exit 1, and a download stopped after the step's 300 s (`rc` 124), are
|
|
2482
|
+
recorded `"warning": true, "outcome": "skipped"`, exit 3 `"warning": true, "outcome": "refused"`, each with `reason`
|
|
2483
|
+
(the script's last line) — warnings, so the update stays `ok` and `lanes update` does not re-run the post-commit for
|
|
2484
|
+
them; any other status is a failed step (`"outcome": "failed"`, `partial`). The update's summary line names it:
|
|
2485
|
+
`…; every step ran; mermaid skipped: enable-docs: download failed: https://… — log …`. A re-run (every update, every
|
|
2486
|
+
install) finds the pinned file and downloads nothing. To install by hand after a skip, run the script; `lanes doctor`
|
|
2487
|
+
says which state the pin is in ("What `lanes doctor` says" below). Caddy serves the directory at `/lanes-assets/vendor/`
|
|
2488
|
+
(`lanes-mobile.service`; a new Caddyfile takes `systemctl --user try-restart lanes-mobile.service`, which the server
|
|
2489
|
+
installer does) as `immutable`, so `web/docs.js` names the versioned file and carries the member digest as its SRI
|
|
2490
|
+
(DEF-1870). Without the file, or on an integrity mismatch, every fence shows its source as a plain `<pre>`. Check:
|
|
2491
|
+
`curl -sI http://127.0.0.1:8083/lanes-assets/vendor/mermaid-11.17.2.min.js` answers 200 with `cache-control: public,
|
|
2492
|
+
max-age=31536000, immutable` (step 3 of "Operator verification" below; DEF-1871 stays open until that is seen on a
|
|
2493
|
+
server).
|
|
2494
|
+
|
|
2495
|
+
The pin needs no `'unsafe-eval'` (MEASURED 2026-10-08): the member has no `new Function(`; its four
|
|
2496
|
+
`Function("return this")` (lodash's global lookup) sit behind `self`, which every page has, and its one `(0,eval)`
|
|
2497
|
+
(chevrotain) follows a `return`. In headless Chromium 153 under `default-src 'none'; script-src 'self'; style-src
|
|
2498
|
+
'self' 'unsafe-inline'`, docs.js drew a flowchart and a sequence diagram with no violation, while a control page's
|
|
2499
|
+
`eval` was refused and reported. Mermaid's SVG needs `style-src 'unsafe-inline'` (without it: unstyled, 88 violations).
|
|
2500
|
+
|
|
2501
|
+
To bump the pin (12.x — npm's latest, 12.1.0 on 2026-10-08 — is a separate decision): download the tarball once
|
|
2502
|
+
(`curl -fsSLO https://registry.npmjs.org/mermaid/-/mermaid-<v>.tgz`) and compare its sha512 with `npm view
|
|
2503
|
+
mermaid@<v> dist.integrity`; take `sha256sum mermaid-<v>.tgz` and `tar -xzOf mermaid-<v>.tgz
|
|
2504
|
+
package/dist/mermaid.min.js | sha256sum`; check that the member is still a classic script (no `import`/`export`, ends
|
|
2505
|
+
by assigning `globalThis["mermaid"]`) and repeat the `'unsafe-eval'` check above. Then change, in one commit, the
|
|
2506
|
+
`mermaid` line of `holders.lock` (and its comment) and `SRC`/`SRI` in `web/docs.js` — the SRI is `sha256-` plus the
|
|
2507
|
+
base64 of the raw member digest: `python3 -c 'import base64,sys; print("sha256-" +
|
|
2508
|
+
base64.b64encode(bytes.fromhex(sys.argv[1])).decode())' <member-sha256>`. `test_docs_assets` and
|
|
2509
|
+
`tests/web/docs.test.mjs` are red while the lock and docs.js disagree. The install or update of that release runs
|
|
2510
|
+
`enable-docs.sh`, which downloads the new pin (a skip says why: run it again by hand); older `vendor/mermaid-*.min.js`
|
|
2511
|
+
files stay (harmless; remove them by hand).
|
|
2512
|
+
|
|
2513
|
+
### What `lanes doctor` says
|
|
2514
|
+
|
|
2515
|
+
Five lines (plan W8, LNS-109), after the browser/phone lines and before the release lines, read-only (one bounded
|
|
2516
|
+
`systemctl --user show`, one loopback connect that sends nothing — docsd answers nothing and writes no docs.log line
|
|
2517
|
+
for it — and file reads):
|
|
2518
|
+
|
|
2519
|
+
| Line | Marks |
|
|
2520
|
+
|---|---|
|
|
2521
|
+
| `lanes-docsd.service active, answering on 127.0.0.1:8185` | `✓` enabled, active and the port (its drop-in's `LANES_DOCS_PORT`, else 8185) accepts a connection; `✗` `enabled but failed` (or any state but active), `active, but 127.0.0.1:8185 does not answer`, a `LANES_DOCS_PORT` that is no port, or systemctl silent — each with `journalctl --user -u lanes-docsd.service; systemctl --user restart lanes-docsd.service`; `⚠` `not enabled, so there is no docs page` (the full server install enables it; `--update-only` never does) or active but not enabled (a reboot loses it) |
|
|
2522
|
+
| `docs: pinned — lanes-docsd answers members through Tailscale Serve` | `✓` `enrol-mode` pins `request`, read as docsd reads it on every request; `⚠ docs: refuses until the Serve header check is pinned (unpinned: auto \| pinned invite only \| <why the pin cannot be read>)` with the pointer to "Enrolment service" (Operator verification) and this section — an operator gate, never a `✗` |
|
|
2523
|
+
| `docs store: N documents in M lanes (counts.json), <size> under ~/.local/state/lanes/docs; docs.log <size>` | information: the documents and lanes `docs/counts.json` counts, the bytes of the regular files under `docs/` (never through a link), docs.log's size (`no docs.log yet`) |
|
|
2524
|
+
| `docs: Mermaid 11.17.2 pinned (~/.local/share/lanes/vendor/mermaid-11.17.2.min.js, sha256 581ed7d74bd9…)` | `✓` a regular file whose sha256 is `holders.lock`'s member digest; `⚠ … not installed …, so diagrams show as source — run sh …/install/enable-docs.sh`; `✗ … refused — … is not the pinned build (sha256 …, holders.lock …)` or `… is not a regular file` (a link or a FIFO, never followed or waited on) — browsers refuse it by its integrity; the script replaces it from a verified download; `✗ docs: Mermaid not checked — …/holders.lock: …` without a valid opt-in pin |
|
|
2525
|
+
| `docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; age at /usr/bin/age)` | information while the switch is off (and whether an `age` is on PATH); `✓ … on — new store copies are age output (age at …); lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY` (or `no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted`); `✗` set without an `age` binary (every upload and publish fails `encrypt-failed`) or a value that is no recipient. The recipient is never printed. The `age` looked for is the one on doctor's PATH, which may not be the unit's |
|
|
2526
|
+
|
|
2527
|
+
### Operator verification (docs plan gate 1, not yet run on ceobox)
|
|
2528
|
+
|
|
2529
|
+
Unit-proven only so far: `tests/unit/test_docsd.py` runs a real docsd on a loopback port, `test_install.DocsRoute`
|
|
2530
|
+
models the shipped Caddyfile's routes and headers in front of it, `test_docs_assets.VendorRoute` models Caddy's route
|
|
2531
|
+
sort for the Mermaid route, and `tests/web/docs.test.mjs` and `tests/web/upload.test.mjs` drive the page scripts
|
|
2532
|
+
against fakes. Nothing below has been run on a server, a phone or a real browser. After the server install of the
|
|
2533
|
+
first release with the docs cockpit (1.3.0), run it once, in order, and record the date, the devices and each step's
|
|
2534
|
+
result here:
|
|
2535
|
+
|
|
2536
|
+
1. **Install.** On the server, the full install: `npx @vibelord/lanes@1.3.0 --server` — it enables and restarts
|
|
2537
|
+
`lanes-docsd`, restarts `lanes-mobile` on the new Caddyfile and runs `enable-docs.sh`; its last lines say
|
|
2538
|
+
`Mermaid for the docs page: ok`. Then `lanes install-container <name>` for each container whose agents should
|
|
2539
|
+
publish (the `lane publish` verb and the `lanes_publish` tool; new agent sessions load the tool).
|
|
2540
|
+
2. **Pin.** From another tailnet device signed in as yourself, run the Serve header check (section "Enrolment
|
|
2541
|
+
service", Operator verification); on the server, `lanes enrol mode request`. `lanes doctor` then shows
|
|
2542
|
+
`✓ lanes-docsd.service active, answering on 127.0.0.1:8185`, `✓ docs: pinned …` and
|
|
2543
|
+
`✓ docs: Mermaid 11.17.2 pinned …`.
|
|
2544
|
+
3. **The Mermaid route (DEF-1871).** On the server, `curl -sI
|
|
2545
|
+
http://127.0.0.1:8083/lanes-assets/vendor/mermaid-11.17.2.min.js` answers `200` with `cache-control: public,
|
|
2546
|
+
max-age=31536000, immutable`. Seen once on the server, that closes DEF-1871 (the route proven on a running Caddy,
|
|
2547
|
+
not only on a model of its sort).
|
|
2548
|
+
4. **Publish.** In a lane of a container from step 1, `~/.lz/bin/lane publish README.md` (a README with a
|
|
2549
|
+
```` ```mermaid ```` fence; add a small flowchart if it has none). The JSON says `"picked_up_by": "lanes-docsd"`;
|
|
2550
|
+
within about 5 s `cat ~/.lz/state/publish/<lane id>/<id>.receipt.json` says `"outcome": "ok"` with a `url`.
|
|
2551
|
+
5. **Sidebar.** Within a few seconds that workspace's upload strip reads ` ↑ uploads · ▤ docs 1 (U)`. With the
|
|
2552
|
+
cursor on the lane, `d` shows `https://<name>:8443/lanes-docs/?ws=<workspace>&lane=<lane id>` in the footer; a
|
|
2553
|
+
⌘-click or Ctrl-click on it (or on the strip) opens the page.
|
|
2554
|
+
6. **Phone.** **More → Docs** opens the index in a new tab, signed in as you; the README opens rendered and its
|
|
2555
|
+
```` ```mermaid ```` fence is drawn as a diagram, not shown as source.
|
|
2556
|
+
7. **Artifact sandbox (DEF-1853).** Publish an HTML file whose script tries to reach this site and writes what it
|
|
2557
|
+
got into its own page: `fetch('/lanes-docs/')`, `fetch('/')`, `document.cookie`, and a WebSocket to the same
|
|
2558
|
+
server's `/ws/` path that the terminal page itself opens (the browser's network panel on a view shows it). **Open
|
|
2559
|
+
the HTML artifact**: the script runs (the page shows its writing), every one of those attempts fails or comes back
|
|
2560
|
+
empty, and no lane receives a keystroke. Record the browser and what each attempt answered: that is DEF-1853's
|
|
2561
|
+
measurement.
|
|
2562
|
+
8. **Upload.** On the Docs page pick a lane, drop a file on the dashed area, **Upload**: the receipt lists the name,
|
|
2563
|
+
size, sha256 and `/home/coder/<repo>/uploads/<name>`; in that lane's shell, `sha256sum
|
|
2564
|
+
/home/coder/<repo>/uploads/<name>` prints the receipt's sha256. The sidebar's count goes up by one.
|
|
2565
|
+
9. **Not a member.** From a tailnet login that owns no member record, `https://<name>:8443/lanes-docs/` answers 403
|
|
2566
|
+
`not-a-member`; on the server, `curl -s http://127.0.0.1:8185/` answers `{"error": "not-through-serve"}`.
|
|
2567
|
+
10. **Forged header: the known DEF-1645 case.** A process in the host's network namespace can send Serve's headers
|
|
2568
|
+
itself: list such containers with `docker ps --filter network=host` and record the answer. From one (or from the
|
|
2569
|
+
host as the workspace account), `curl -s -H 'Tailscale-User-Login: <a member login>' -H 'Tailscale-User-Name: x'
|
|
2570
|
+
-H 'X-Forwarded-For: 100.64.0.9' -H 'Sec-Fetch-Site: none' http://127.0.0.1:8083/lanes-docs/` is answered as that
|
|
2571
|
+
member. That is expected and stays DEF-1645 (open): the pin gate and the Serve-only check are the controls against
|
|
2572
|
+
every other client, not against a process on this host. If another account or a host-network container exists,
|
|
2573
|
+
weigh `lanes enrol mode invite` (which also stops the Docs page) against it.
|
|
2574
|
+
|
|
1826
2575
|
## Safety rules baked into the code (do not remove)
|
|
1827
2576
|
|
|
1828
2577
|
- tmux is always called with `-N` (`attach-session`, `new-session` and `start-server` on a missing socket start
|