From 79816da24b691cd2ad26fe1dd2696fcf9a958f7d Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:38:25 +0000 Subject: [PATCH 01/24] kit-46: spec Co-Authored-By: Claude Opus 5.5 --- .../tickets/kit-46-standalone-hub/spec.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md new file mode 100644 index 0000000..b3ea680 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md @@ -0,0 +1,66 @@ +# kit-46-standalone-hub: spec + +A hub that runs on its own (`devteam.mjs hub`), and dashboards that leave the hub to the image when its `ENV` says so. + +Feature `box-latest-hub`, stories BOXHUB-S1 and BOXHUB-S2. Design: home's `project/designs/the-cloud-box-always-runs-the-latest-hub.md` (the `devteam.mjs (kit-46)` part, Contracts, Testing). Risk high: this moves the server that takes the owner's thread presses (P5). + +## Why + +The owner: "When we boot the cloud image, we should always boot the LATEST devteam hub in the image (not a hub served by a subproject)." Today every dashboard races for 127.0.0.1:4320, so on the box whichever project starts first serves the hub from its own kit (observed: v0.50.4 while v0.51.0 was out). This ticket gives the kit a hub that runs alone, from the image's kit, and makes dashboards on this release step aside when the image says it serves the hub. kit-49 wires it into the boot; kit-47 adds the `ENV` lines. + +## What changes (all in `docs/devteam/scripts/devteam.mjs` unless named) + +1. **`devteam.mjs hub [--release vX.Y.Z]`**, a machine-wide command (added to the `machineWide` list beside `machine` and `operator-thread`, so it runs from a folder that is no repo and no project). It runs `startHub`'s server alone: + - logs to stderr and to `~/.devteam/hub-.log`; before each write, a log at or over 1 MB (with the line) is renamed to `hub-.log.1` and started afresh, so the file never passes 1 MB; + - installs the uncaughtException, unhandledRejection and SIGTERM/SIGINT/SIGHUP handlers that `cmdDashboard` installs (one shared helper now, so the two can't drift): a crash logs its stack and exits 1, a signal logs `stopped by ` and exits 0; + - keeps itself alive and retries every `DEVTEAM_HUB_RETRY_MS` (default 30000, as today) whatever holds the port; a holder that isn't a hub is logged once (`hub: port is held by something that isn't a devteam hub; retrying every s`), not at every retry; + - writes nothing under its cwd or any `run/` folder; owner actions (`operator-thread …`) run with its cwd, as `box-thread`'s do. +2. **`/instances.json` gains a top-level `kit`**, computed once when the hub starts: + - `hub --release vX.Y.Z`: `kit` is that release and the list carries `image: true`; + - `hub` without `--release`, and a dashboard's hub: `kitVersion()` where there is a project (`settings.md` exists), else `null`; no `image` field. + - No hub reads `DEVTEAM_KIT_RELEASE`. Only the boot (kit-49) passes `--release`. + - The comment above `startHub` states the list's rule: additive changes keep `hub: 1`, anything else bumps it; and documents `kit` and `image`. +3. **`cmdDashboard` skips `startHub`** when `DEVTEAM_IMAGE_HUB`, read as a number, equals its `HUB_PORT`, and writes exactly one line to `run/dashboard.log`: `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. Unset, empty or another port: the hub is served as before, and no such line. +4. **`machine status`'s hub line** asks `/instances.json` (Host `127.0.0.1:`, `sec-fetch-site: same-origin`, 3 s) and prints exactly one of: + - `machine: hub up, kit (image)` (hub 1, `kit` a string, `image` true) + - `machine: hub up, kit ` (hub 1, `kit` a string) + - `machine: hub up, kit ?` (hub 1, no usable `kit`: an older hub, or `null`) + - `machine: hub up, not a hub` (an answer that isn't `hub: 1` JSON) + - `machine: hub DOWN` (no answer) + followed by the unchanged `; alert: …` suffix. The release is printed only if it matches `^[\w.+-]{1,64}$` (else `?`): it comes off the network and goes to a terminal. +5. **README.md**'s "Many projects, one machine" paragraph: on a box whose image sets `DEVTEAM_IMAGE_HUB`, the image serves the hub and dashboards don't; the gap left is that an older project kit can take the port while the image's hub restarts. `hub` is listed in the help text. + +## Contracts + +- **Provides:** the hub list `hub: 1` with a top-level `kit` (string or `null`) and, only for `hub --release`, `image: true`. Additive. Documented in the comment above `startHub`. +- **Consumes:** the box image `ENV` names `DEVTEAM_IMAGE_HUB` (read here) and `DEVTEAM_KIT_RELEASE` (deliberately never read by a hub), per the design's Contracts section; kit-47 lands the Dockerfile comment. `DEVTEAM_BOX_TOOLS` is not read here. + +## Acceptance checks (the done-whens, restated) + +1. `test/hub-overview.test.mjs`, fixture ports only: `hub --release v9.9.9`, run from a temp folder that is no git repo and no project, answers `GET /instances.json` with `hub: 1`, the fixture instance(s), `kit: 'v9.9.9'`, `image: true`, and leaves no `docs/devteam/run` (no `docs` at all) under its cwd. `hub` without `--release`, with `DEVTEAM_KIT_RELEASE=v9.9.9` in its env, answers `kit: null` and no `image`. A dashboard's hub, with `DEVTEAM_KIT_RELEASE` set, answers `kit` equal to that project's `hello --json` `kit` and no `image`. +2. Same file: with `DEVTEAM_IMAGE_HUB=` a dashboard never listens on the hub port and writes exactly one `hub: left to the image (DEVTEAM_IMAGE_HUB=)` line to its `run/dashboard.log`; with it unset, or `4320` while the hub port is a fixture port, the dashboard serves its hub and writes no such line. +3. Same file: port held by another hub: `hub` stays up, retries every `DEVTEAM_HUB_RETRY_MS`, and takes the port once the holder exits. Held by a non-hub: logged once to `~/.devteam/hub-.log` (test `HOME`), keeps retrying, takes the port once it frees. A log pre-filled to just under 1 MB stays at or under 1 MB after the hub writes. A SIGTERM to the hub leaves `stopped by SIGTERM` in that log. +4. `test/operator-threads.test.mjs`: a standalone hub in box mode (`DEVTEAM_PUBLIC_URL`, `DEVTEAM_GATEWAY_THREAD_KEY`, this file's gateway key) refuses with 403 an `/operator/thread-new` POST without a gateway signature, one reusing a nonce, and one with a foreign Origin; a correctly signed one returns 200 and its line is typed into the fixture operator's pane (session `home`, window 0). +5. `test/machine.test.mjs`: each of the five hub lines above asserted exactly, against in-process fixture servers on the test's hub port, and the alert suffix unchanged. +6. Every existing test in `test/dashboard.test.mjs`, `test/hub-overview.test.mjs`, `test/operator-threads.test.mjs` and `test/machine.test.mjs` passes unchanged, in CI and on the box. +7. README as in 5 above; no file added (see Rulings/notes: the ticket's own records are the exception). +8. Every test added deletes `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE` and `DEVTEAM_BOX_TOOLS` from the env it passes and sets them only where it means to. + +## Edge cases + +- `--release` given empty or not `vX.Y.Z` (e.g. the boot's `"$DEVTEAM_KIT_RELEASE"` on an image without it): the hub logs the problem once and serves with `kit: null` and no `image`. It never exits for it: exiting would put the boot's restart loop into a 1 s crash loop with no hub at all (the design's anti-goal "a box with no hub"). My call. +- `DEVTEAM_IMAGE_HUB` that isn't a number (`"yes"`, `" "`): treated as unset. +- A standalone hub whose holder answers slowly (`unsure`): retried, as today. +- The non-hub log line is written once per run of non-hub holding; if a hub then takes the port and later a non-hub again, logged once more (state flips). Simple and bounded. +- A dashboard's hub keeps today's behaviour exactly: stops trying on a non-hub holder, its retry timer unref'd. +- `machine status` against an `/instances.json` that is huge: read capped at 64 KB, then `not a hub`. + +## Out of scope + +- The boot, the `home:hub` window, the minute loop and `ENV DEVTEAM_IMAGE_HUB` (kit-49); the Dockerfile `ARG`s/`ENV` (kit-47); `hub.html`'s versions line, `/box.json`'s `image`, the gateway's fallback `down` (kit-50); `box-pins` (kit-48). +- operator.md (not in touches). + +## Rulings and notes + +- No rulings or answers in `history` or `messages` at spec time. +- "No file is added" (done-when 7): the ticket's records under `docs/devteam-dev/tickets/kit-46-standalone-hub/` are added files, as the worker prompt directs; I read the done-when as covering everything outside the ticket's records folder, and the review request says so. From c600510f748b9a88fd2df7b1dcbeddc80eea5233 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:39:47 +0000 Subject: [PATCH 02/24] kit-46: plan Co-Authored-By: Claude Opus 5.5 --- .../tickets/kit-46-standalone-hub/plan.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md new file mode 100644 index 0000000..e3fdc4c --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md @@ -0,0 +1,107 @@ +# kit-46-standalone-hub Implementation Plan + +## TL;DR + +- One script (`docs/devteam/scripts/devteam.mjs`), three test files and the README; no new file outside this ticket's records. +- T1: `devteam.mjs hub [--release vX.Y.Z]`, a machine-wide command running `startHub` alone, logging to `~/.devteam/hub-.log` (≤ 1 MB), with crash/signal handlers shared with the dashboard, retrying whatever holds the port. +- T1 also: `/instances.json` gains `kit` (computed once) and, for `--release`, `image: true`. +- T2: a dashboard leaves the hub alone when `DEVTEAM_IMAGE_HUB` equals its hub port, and says so once in `run/dashboard.log`. +- T3: the press checks proven against a box-mode standalone hub (unsigned, replayed, foreign Origin refused; signed reaches the operator's pane). +- T4: `machine status` prints the hub's kit (or `kit ?`, `not a hub`, `DOWN`); T5: README and help. +- Every task test-first; every new test clears `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS`. + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (native; this ticket's tasks share one function, `startHub`, and are built in order in one session). Steps use checkbox (`- [ ]`) syntax. + +**Goal:** a hub that runs on its own from the image's kit, and dashboards that step back from it on a box. + +**Architecture:** `startHub(log, opts)` stays the one hub implementation. A new `cmdHub` calls it with `{ standalone: true, kit, image }`; `cmdDashboard` calls it with `{ kit: kitVersion() }` unless `DEVTEAM_IMAGE_HUB` names its port. `machineStatus` reads the list. + +**Tech Stack:** Node built-ins only (`node:http`, `node:fs`), `node --test`. + +**Spec:** `docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md`. + +## Global Constraints + +- `devteam.mjs` stays one file, Node built-ins, no relative imports; Node 18–24 (CI matrix). +- Fixture ports only (`PORTS + n` of the file's block); never 4320 in a test. +- Every test added deletes `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` from the env it passes, setting them only where it means to. +- Exact strings: `hub: left to the image (DEVTEAM_IMAGE_HUB=)`; `stopped by SIGTERM`; `machine: hub up, kit (image)` / `up, kit ` / `up, kit ?` / `up, not a hub` / `DOWN`. +- Existing tests in dashboard, hub-overview, operator-threads and machine test files pass unchanged. +- Gates: `node /data/home/code/devteam-dev/docs/devteam/scripts/devteam.mjs gate test -- node --test ` per file touched. + +## Review Focus + +1. `--release ""` (the boot on an image without `DEVTEAM_KIT_RELEASE`): the hub must serve, `kit: null`, no `image`, never exit. Test in T1. +2. The standalone hub run from a git checkout (tests) vs a non-git folder (the box): `mainRoot` differs, so owner actions run from the hub's own cwd and the hub writes no `run/`. T1 asserts nothing appears under its cwd; T3's signed press lands in the store under `HOME`, run from that cwd. +3. A process that would exit while retrying (an unref'd timer with no listener): T1's "held by a hub" test asserts the process is still alive after two retry intervals. +4. A non-hub that answers forever: logged once, not per retry: T1 counts lines. +5. `machine status` against a hostile `kit` (control characters, 10 KB): printed as `kit ?`. Test in T4. + +--- + +### Task 1: `devteam.mjs hub`, the `kit` field, the hub's log and handlers + +**Files:** +- Modify: `docs/devteam/scripts/devteam.mjs` (machineWide list ~430; `runOwnerAction` ~11727; `startHub` and its comment ~11779-11965; `cmdDashboard` handlers ~13763-13765 and its `startHub(log)` call ~14619; `COMMANDS` ~19077; HELP "Dashboard and releases") +- Test: `test/hub-overview.test.mjs` + +**Interfaces:** +- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException/unhandledRejection (log `crashed: `, stderr `devteam crashed: …`, exit 1) and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `projectKit()` = `fs.existsSync(path.join(projectDir, 'settings.md')) ? kitVersion() : null`. +- List shape: `{ hub: 1, kit, ...(image ? { image: true } : {}), instances }`. + +- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node /scripts/devteam.mjs hub …`, detached, killed in `t.after`), env built from `process.env` minus the three box vars, `HOME` a temp home, `DEVTEAM_HUB_PORT=PORTS+40`, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=300`: + - `test('hub --release, from a folder that is no repo: the list with the image\'s release, and nothing written there')`: the hub started first (it holds the port), then a bareProject dashboard, whose own hub keeps retrying harmlessly, is listed; `GET /instances.json` → `hub === 1`, `kit === 'v9.9.9'`, `image === true`, instances includes the fixture root; `fs.existsSync(path.join(cwd, 'docs')) === false`. + - `test('hub without --release names no release, whatever DEVTEAM_KIT_RELEASE says; a bad --release serves anyway')`: env `DEVTEAM_KIT_RELEASE=v9.9.9`: `kit === null`, `!('image' in list)`; then `hub --release ''` and `--release nope`: same, and the process still alive after 1 s. + - `test('a dashboard\'s hub names its project\'s kit and no image')`: dashboard with `DEVTEAM_KIT_RELEASE=v9.9.9`: list `kit` equals `hello --json` `kit` of that project, no `image`. + - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1 s it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`: restart hub; after 1.5 s exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. + - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048000 bytes; start hub; until `hub on http://` is in log or `.1`; `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`. +- [ ] **Step 2:** `node --test --test-name-pattern='hub' test/hub-overview.test.mjs` → the new tests FAIL (`unknown command "hub"`). +- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: projectKit(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; on `EADDRINUSE`, standalone: `holder()`, log the non-hub line only when the last holder seen wasn't `other`, then `setTimeout(tryListen, retry)` **not** unref'd; dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: projectKit() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". +- [ ] **Step 4:** the same command → PASS; then the whole file and `test/dashboard.test.mjs` through `gate`. +- [ ] **Step 5:** commit `kit-46 T1: devteam.mjs hub, the list's kit, the hub's log`. + +### Task 2: dashboards leave the hub to the image + +**Files:** Modify `docs/devteam/scripts/devteam.mjs` (`cmdDashboard`'s `listening` handler); Test `test/hub-overview.test.mjs`. + +**Interfaces:** Consumes `startHub(log, opts)`, `projectKit()` from T1. Produces `imageHub()` = `Number(process.env.DEVTEAM_IMAGE_HUB)` when the trimmed value is a non-empty integer string, else `null`. + +- [ ] **Step 1: failing test** `test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port')`: three runs of a bareProject dashboard on hub `PORTS+40`: (a) `DEVTEAM_IMAGE_HUB=String(PORTS+40)`: after the dashboard answers `/api/hello`, wait 1 s; `net.connect(PORTS+40)` fails (nothing listens); `run/dashboard.log` has exactly one line ending `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. (b) unset and (c) `4320`: until `/instances.json` answers `hub: 1`, and the log has no `left to the image`. +- [ ] **Step 2:** run → FAIL on (a). +- [ ] **Step 3:** in the `listening` handler: `imageHub() === HUB_PORT ? log(\`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})\`) : startHub(log, { kit: projectKit() })`, with a one-line comment (the image's own hub serves it; an older kit can still take the port in a restart's gap). +- [ ] **Step 4:** PASS, and T1's tests still pass. +- [ ] **Step 5:** commit `kit-46 T2: dashboards leave the hub to the image`. + +### Task 3: the press checks on a box-mode standalone hub + +**Files:** Test `test/operator-threads.test.mjs` only (no code change expected; a failure here is a bug in T1, fixed there). + +**Interfaces:** Consumes `hub` (T1), this file's `signedPress`, `ask`, `seat`, `machine({ box: true })` env pieces, `GW_PUBLIC`. + +- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`; seat the operator (`seat(M)`, session `home`); spawn `hub` from `nowhere` with `M.env` minus the three box vars, `DEVTEAM_HUB_PORT=M.hub`, `DEVTEAM_PORT_BASE=PORTS+60`; until `/instances.json` answers. `own` headers as in `hubFixture`. Assert 403 for: `thread-new` with `own` only; a signed press re-sent after it was taken (`/used already/`); the signed headers with `origin: 'https://evil.example'`. Assert 200 for a fresh signed `thread-new {text:'standalone?'}`, its `out` second line `sent to the operator's pane`, and `until(() => a.keys().includes('standalone?'))`. `finally`: kill hub and `a.kill()`. +- [ ] **Step 2:** run the file through `gate` → PASS (it guards T1; mutate `ownerPost`'s nonce check in a scratch copy once to see it fail, noted in the task review). +- [ ] **Step 3:** commit `kit-46 T3: the press checks, against the standalone hub`. + +### Task 4: `machine status`'s hub line + +**Files:** Modify `docs/devteam/scripts/devteam.mjs` (`machineStatus` hub line ~13595; a `hubLine(port)` beside `portAnswers`); Test `test/machine.test.mjs`. + +**Interfaces:** Produces `hubLine(port) -> Promise` returning `DOWN`, `up, not a hub`, `up, kit ?`, `up, kit `, `up, kit (image)`; `` shown only if `/^[\w.+-]{1,64}$/`. Uses `http.get` (Host `127.0.0.1:`, `sec-fetch-site: same-origin`, 3 s timeout, body capped at 64 KB → `not a hub`). + +- [ ] **Step 1: failing test** `test('status names the hub\'s kit, or says it is no hub, or down')`: for each fixture served on `process.env.DEVTEAM_HUB_PORT` by an in-process `http.createServer` — `{hub:1,kit:'v1.2.3',image:true,instances:[]}`, `{hub:1,kit:'v1.2.3',instances:[]}`, `{hub:1,instances:[]}`, `{hub:1,kit:'\u001b[31mx',instances:[]}`, `'ok'` — run `machine status --in ` and assert the exact line (`up, kit v1.2.3 (image)`, `up, kit v1.2.3`, `up, kit ?`, `up, kit ?`, `up, not a hub`); with nothing listening, `DOWN`; with an alert set, `up, kit v1.2.3; alert: look`. The env clears the three box vars. +- [ ] **Step 2:** run → FAIL (`up` alone). +- [ ] **Step 3:** implement `hubLine`, use it in `machineStatus`. +- [ ] **Step 4:** the file through `gate` → PASS (the existing DOWN assertions unchanged). +- [ ] **Step 5:** commit `kit-46 T4: machine status names the hub's kit`. + +### Task 5: README + +**Files:** Modify `README.md` ("Many projects, one machine"). + +- [ ] **Step 1:** after "Any running dashboard also serves the hub at http://127.0.0.1:4320", add: on a laptop that is any dashboard; `devteam.mjs hub` serves it alone; on a box whose image sets `DEVTEAM_IMAGE_HUB` the image's own kit serves it and dashboards on this release or later leave the port alone (saying so in `run/dashboard.log`), so the gap left is a project still on an older kit, which can take the port in the moment the image's hub restarts. `hub` appears in HELP (T1). +- [ ] **Step 2:** `git diff --diff-filter=A --name-only origin/main -- . ':!docs/devteam-dev'` is empty. +- [ ] **Step 3:** commit `kit-46 T5: README, the image's hub`. + +## Rulings + +None yet. From 2ce8e946465b237b986e83b2d5cebc5e677cfd92 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:43:33 +0000 Subject: [PATCH 03/24] kit-46: spec review 1 and its fixes Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/spec-1.md | 34 +++++++++++++++++++ .../tickets/kit-46-standalone-hub/spec.md | 25 ++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-1.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-1.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-1.md new file mode 100644 index 0000000..3c4ebd2 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-1.md @@ -0,0 +1,34 @@ +# Spec review, pass 1 + +- Reviewer: spec-reviewer (fresh subagent, claude-opus) +- Reviewed: 79816da24b691cd2ad26fe1dd2696fcf9a958f7d (spec.md) + +I read the spec, `ticket show kit-46-standalone-hub` (the final done_when set, with the `(image)` form), the design's `devteam.mjs (kit-46)` bullets, Contracts, Boot, Failure modes, Testing and Security, and the code at 79816da: the re-exec block (~37-47), `homeAt`/`machineWide`, `mainRoot`, `devteamDir`, `runDir` (~424-470), `runOwnerAction` (~11728), `startHub` (~11794-11910), the operator pane lookup (~13206), `machineStatus`'s hub line, `cmdDashboard`'s handlers (~13763), the role guard at dispatch (~19093), `kitVersion` (~18072), and the hub fixtures in test/operator-threads.test.mjs and test/lib/kit.mjs. + +Every done-when is restated, and the restatements are faithful. The spec follows the design: `--release` comes only from the boot, `DEVTEAM_KIT_RELEASE` is never read, the skip applies only when the variable equals the hub port, and there are five hub lines. Reusing `startHub` whole keeps every press check (own Host, Sec-Fetch-Site plus Origin, the gateway signature, the in-memory nonce, and the child's re-check against the store). I found no security hole in the move itself. The one defect that blocks approval is F1. It is a path-resolution gap: on the box `mainRoot` and `runDir` become the cwd, but in the tests they resolve to the kit's git checkout. So the "writes nothing under run/" check cannot fail as specified. + +Values and refusals: +- P1 harness-agnostic: no harness is involved. Passes. +- P2 nothing project-specific: generic machine command and ENV names. Passes. +- P3 decide and report: the bad-`--release` call is made and recorded ("My call"). Passes. +- P4 owner-only asks: none asked. Passes. +- P5 adversarial for authority: risk high, and check 4 tries an unsigned press, a reused nonce and a foreign Origin. It does not try a foreign Host or Sec-Fetch-Site against the standalone hub (F5). Partly passes. +- P6 keep it simple and name what isn't defended: the design names it, but the spec has no Security line (F10). Minor. +- R1 no self-widening of authority: presses still need the gateway's signature on a box, and nothing new writes owner lines. Passes. +- R2 owner-facing plain words: the new lines are operator- and log-facing and readable. Passes. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Important | done-when 1 ("creates no docs/devteam/run folder under its cwd"); spec "What changes" 1 ("writes nothing under its cwd or any `run/` folder"); quality.md §3 (an untestable claim) | Acceptance check 1; code `homeAt` ~424-430, `mainRoot` ~449, `runDir` ~463 | When the kit runs from a git checkout, as in every test, `scriptCommon = commonOf(here)` wins. `mainRoot` is then the kit's main checkout (on the box, `/data/home/code/devteam-dev-links/devteam`), and `runDir` is that checkout's `docs/devteam/run`, not anything under the temp cwd. On the box the kit at `/opt/devteam/kit` is no git repo, so `mainRoot = repoRoot = cwd` and `runDir = /docs/devteam/run`. That is exactly the folder the done-when protects. As specified, a hub that writes `runDir` (through `dashboardLogger`, `tmuxSession`, `pickDashboardPort` or anything shared) passes the test, and when the tests run on the box it writes into a real kit checkout instead. Specify that the test runs `hub` from a copy of `docs/devteam` in a non-git temp folder, which mirrors `/opt/devteam/kit` so that mainRoot is the cwd, and asserts no `docs` under the cwd. Alternatively, set `DEVTEAM_RUN` to a temp path and assert it never appears. Run check 4's standalone hub from that copy too. | fixed | +| F2 | Minor | spec "What changes" 1 ("owner actions run with its cwd, as `box-thread`'s do"); design kit-46 bullet 1 | `runOwnerAction` ~11730 and `askOperator` ~11834 (`cwd: mainRoot`) | The spec doesn't say whether code changes. Both spawn with `cwd: mainRoot`, which is the cwd only when the kit is non-git and the cwd is no git repo. From a git checkout, or if the hub is started inside some repo, the children run in that repo's root. Say that the standalone hub passes `process.cwd()` explicitly and a dashboard keeps `mainRoot`, and give one assertion that would catch the wrong one. | fixed | +| F3 | Minor | design kit-46 bullet 2 ("none of them may claim the image's release"; `kit` is the running kit's version) | spec "What changes" 2, `hub` without `--release`; code `devteamDir` ~458, `projectDir`, `kitVersion` ~18072 | With a non-git kit, "where there is a project (`settings.md` exists)" resolves through the cwd's repo or `DEVTEAM_HOME`, not the running kit. Image code started from a project folder would report that project's `upstream_base` as its `kit`, and `kitVersion`'s `rel` check returns the bare tag with no `+local`. Define "project" as the running kit's own (`kitDir/project/settings.md`), or make the standalone hub without `--release` always `null`. | fixed | +| F4 | Minor | done-when 4 ("reaches the fixture operator pane") | Acceptance check 4 ("session `home`, window 0") | On a box the pane lookup takes only the window named `operator` (`live.find((p) => p.window === OPERATOR_WINDOW)`, ~13206). A fixture pane that is merely window 0 of `home` is not found, and the press answers "recorded; no operator is running here". That is the existing box test's outcome, and it does not reach a pane. Say window `operator`, and assert the typed line in that pane. | fixed | +| F5 | Minor | strategy P5 (the review tries to break the press checks); ticket notes ("the final reviews try to break the press checks on the standalone hub") | Acceptance check 4 | Only three refusals are listed. The standalone hub also stands on `ownHost` (a foreign Host gets 403, which is the DNS-rebinding guard), on Sec-Fetch-Site (missing or `same-site`), on the JSON content-type, and on refusing a cross-site GET of `/instances.json` and `/operator/threads`. These cases are cheap to add: run the existing `boxroutes` refusal loop against the standalone hub instead of re-listing them. | fixed | +| F6 | Minor | spec "What changes" 1 ("keeps itself alive and retries … whatever holds the port"); design anti-goal "a box with no hub" | `startHub` `once('error')` ~11906 | For a listen error other than EADDRINUSE (EACCES, EADDRNOTAVAIL), the handler logs and returns without retrying. A standalone hub then holds no handle and exits 0, so the boot loop restarts it every second and nothing records a crash. Specify that the standalone hub retries on any listen error, logging once per state, as it does for a non-hub holder. | fixed | +| F7 | Minor | done-when 3 ("logs that once … keeps retrying") | Acceptance check 3 | If the test frees the port after one retry, "logged once" cannot be told apart from "logged at every retry". State that the non-hub holds the port for at least 3 × `DEVTEAM_HUB_RETRY_MS` before the lines are counted, and that exactly one line is asserted. | fixed | +| F8 | Minor | design Boot step 3 (`--release "$DEVTEAM_KIT_RELEASE"`, which is empty on an image without it); anti-goal "a box with no hub" | Edge cases, the first bullet; no acceptance check | "Never exits for a bad `--release`" protects the boot from a crash loop, but no check asserts it, and "not vX.Y.Z" names no pattern. The machine line, by contrast, uses `^[\w.+-]{1,64}$`. Name the exact pattern, decide whether suffixes like `-rc.1` count, and add an assertion that `hub --release ''` serves `kit: null` with no `image` field. | fixed | +| F9 | Suggestion | spec "What changes" 3 ("read as a number") | `cmdDashboard` skip | `Number('0x10E0') === 4320` and `Number('4320.0') === 4320`. Match `/^\d+$/` first, so only a plain port means skip. | fixed | +| F10 | Suggestion | strategy P6 (name what isn't defended); quality.md §3 | spec (no Security section) | State the invariant in one line: the standalone hub is the same `startHub` request handler, with no mode branch in `ownHost`, `ownerPost` or `threadsNeedGateway`. Add the P6 line: a `dev` process can take 4320 first, or unset `DEVTEAM_IMAGE_HUB` for its own dashboard. | fixed | +| F11 | Suggestion | dispatch role guard ~19093 | spec "What changes" 1 | `DEVTEAM_ROLE=operator` lets only `recover`, `machine` and `operator-thread` through, so the operator cannot run `hub`. Say that this is intended (the minute loop restarts the hub), or add `hub` to that list. | fixed | + +Verdict: CHANGES 79816da24b691cd2ad26fe1dd2696fcf9a958f7d diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md index b3ea680..8486095 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/spec.md @@ -13,14 +13,16 @@ The owner: "When we boot the cloud image, we should always boot the LATEST devte 1. **`devteam.mjs hub [--release vX.Y.Z]`**, a machine-wide command (added to the `machineWide` list beside `machine` and `operator-thread`, so it runs from a folder that is no repo and no project). It runs `startHub`'s server alone: - logs to stderr and to `~/.devteam/hub-.log`; before each write, a log at or over 1 MB (with the line) is renamed to `hub-.log.1` and started afresh, so the file never passes 1 MB; - installs the uncaughtException, unhandledRejection and SIGTERM/SIGINT/SIGHUP handlers that `cmdDashboard` installs (one shared helper now, so the two can't drift): a crash logs its stack and exits 1, a signal logs `stopped by ` and exits 0; - - keeps itself alive and retries every `DEVTEAM_HUB_RETRY_MS` (default 30000, as today) whatever holds the port; a holder that isn't a hub is logged once (`hub: port is held by something that isn't a devteam hub; retrying every s`), not at every retry; - - writes nothing under its cwd or any `run/` folder; owner actions (`operator-thread …`) run with its cwd, as `box-thread`'s do. + - keeps itself alive and retries every `DEVTEAM_HUB_RETRY_MS` (default 30000, as today) whatever holds the port, and on any other listen error too (EACCES, EADDRNOTAVAIL): its retry timer is not unref'd, so the process never exits while waiting. A holder that isn't a hub is logged once (`hub: port is held by something that isn't a devteam hub; retrying every s`), and another listen error once per distinct message, never at every retry; + - writes nothing under its cwd or any `run/` folder; owner actions (`operator-thread …`, and the `operator-thread status` it asks) are spawned with `cwd: process.cwd()`, as `box-thread`'s run. A dashboard's hub keeps `cwd: mainRoot`. (On the box the image's kit is no git repo, so `mainRoot` is the cwd anyway; from a git checkout it is that checkout's root, which a standalone hub must not use.) + - `DEVTEAM_ROLE=operator` can't run it: the dispatch's operator guard lets the operator run only `recover`, `machine`, `operator-thread` and reads, and this is not a read. The boot starts the hub with no role. Intended. 2. **`/instances.json` gains a top-level `kit`**, computed once when the hub starts: - `hub --release vX.Y.Z`: `kit` is that release and the list carries `image: true`; - - `hub` without `--release`, and a dashboard's hub: `kitVersion()` where there is a project (`settings.md` exists), else `null`; no `image` field. + - `hub` without `--release`, and a dashboard's hub: `kitVersion()` when the running kit is the project's own (`settings.md` exists in `projectDir` and `kitDir` is inside `mainRoot`), else `null`; no `image` field. So the image's kit started from a project folder, or with `DEVTEAM_HOME`, never names that project's release as its own. + - `--release` is valid only if it matches `^v\d+\.\d+\.\d+$` (the stamp's form in the design; no `-rc` suffixes). - No hub reads `DEVTEAM_KIT_RELEASE`. Only the boot (kit-49) passes `--release`. - The comment above `startHub` states the list's rule: additive changes keep `hub: 1`, anything else bumps it; and documents `kit` and `image`. -3. **`cmdDashboard` skips `startHub`** when `DEVTEAM_IMAGE_HUB`, read as a number, equals its `HUB_PORT`, and writes exactly one line to `run/dashboard.log`: `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. Unset, empty or another port: the hub is served as before, and no such line. +3. **`cmdDashboard` skips `startHub`** when `DEVTEAM_IMAGE_HUB` is a plain port (`^\d+$`, so not `0x10E0` or `4320.0`) equal to its `HUB_PORT`, and writes exactly one line to `run/dashboard.log`: `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. Unset, empty or another port: the hub is served as before, and no such line. 4. **`machine status`'s hub line** asks `/instances.json` (Host `127.0.0.1:`, `sec-fetch-site: same-origin`, 3 s) and prints exactly one of: - `machine: hub up, kit (image)` (hub 1, `kit` a string, `image` true) - `machine: hub up, kit ` (hub 1, `kit` a string) @@ -37,10 +39,12 @@ The owner: "When we boot the cloud image, we should always boot the LATEST devte ## Acceptance checks (the done-whens, restated) -1. `test/hub-overview.test.mjs`, fixture ports only: `hub --release v9.9.9`, run from a temp folder that is no git repo and no project, answers `GET /instances.json` with `hub: 1`, the fixture instance(s), `kit: 'v9.9.9'`, `image: true`, and leaves no `docs/devteam/run` (no `docs` at all) under its cwd. `hub` without `--release`, with `DEVTEAM_KIT_RELEASE=v9.9.9` in its env, answers `kit: null` and no `image`. A dashboard's hub, with `DEVTEAM_KIT_RELEASE` set, answers `kit` equal to that project's `hello --json` `kit` and no `image`. +Every standalone hub a test starts runs from a copy of `docs/devteam` (no `project/`, no `run/`) in a temp folder that is no git repo, as `/opt/devteam/kit` is on the box, so `mainRoot` and `runDir` resolve to the hub's cwd exactly as there; and no test's hub writes into the kit checkout under test. + +1. `test/hub-overview.test.mjs`, fixture ports only: `hub --release v9.9.9`, run from a temp folder that is no git repo and no project, answers `GET /instances.json` with `hub: 1`, the fixture instance(s), `kit: 'v9.9.9'`, `image: true`, and leaves no `docs/devteam/run` (no `docs` at all) under its cwd. `hub` without `--release`, with `DEVTEAM_KIT_RELEASE=v9.9.9` in its env, answers `kit: null` and no `image`; so do `hub --release ''` and `hub --release nope`, each still serving (and alive) a second later. A dashboard's hub, with `DEVTEAM_KIT_RELEASE` set, answers `kit` equal to that project's `hello --json` `kit` and no `image`. 2. Same file: with `DEVTEAM_IMAGE_HUB=` a dashboard never listens on the hub port and writes exactly one `hub: left to the image (DEVTEAM_IMAGE_HUB=)` line to its `run/dashboard.log`; with it unset, or `4320` while the hub port is a fixture port, the dashboard serves its hub and writes no such line. -3. Same file: port held by another hub: `hub` stays up, retries every `DEVTEAM_HUB_RETRY_MS`, and takes the port once the holder exits. Held by a non-hub: logged once to `~/.devteam/hub-.log` (test `HOME`), keeps retrying, takes the port once it frees. A log pre-filled to just under 1 MB stays at or under 1 MB after the hub writes. A SIGTERM to the hub leaves `stopped by SIGTERM` in that log. -4. `test/operator-threads.test.mjs`: a standalone hub in box mode (`DEVTEAM_PUBLIC_URL`, `DEVTEAM_GATEWAY_THREAD_KEY`, this file's gateway key) refuses with 403 an `/operator/thread-new` POST without a gateway signature, one reusing a nonce, and one with a foreign Origin; a correctly signed one returns 200 and its line is typed into the fixture operator's pane (session `home`, window 0). +3. Same file: port held by another hub: `hub` stays up, retries every `DEVTEAM_HUB_RETRY_MS`, and takes the port once the holder exits. Held by a non-hub for at least 4 × `DEVTEAM_HUB_RETRY_MS`: exactly one such line in `~/.devteam/hub-.log` (test `HOME`), the hub still alive, and it takes the port once it frees. A log pre-filled to just under 1 MB stays at or under 1 MB after the hub writes. A SIGTERM to the hub leaves `stopped by SIGTERM` in that log. +4. `test/operator-threads.test.mjs`: a standalone hub in box mode (`DEVTEAM_PUBLIC_URL`, `DEVTEAM_GATEWAY_THREAD_KEY`, this file's gateway key), run from its cwd with `HOME` the fixture's, refuses with 403 an `/operator/thread-new` POST without a gateway signature, one reusing a nonce, and one with a foreign Origin; also (P5: try to break it) a signed press with a foreign Host, with `sec-fetch-site` missing or `same-site`, and with a non-JSON content type, and a cross-site GET of `/instances.json` and of `/operator/threads`. A correctly signed one returns 200, its proof is kept in `~/operator/threads.jsonl` under the fixture `HOME`, and its line is typed into the fixture operator's pane (tmux session `home`, window `operator`, which is where a box's pane lookup looks). 5. `test/machine.test.mjs`: each of the five hub lines above asserted exactly, against in-process fixture servers on the test's hub port, and the alert suffix unchanged. 6. Every existing test in `test/dashboard.test.mjs`, `test/hub-overview.test.mjs`, `test/operator-threads.test.mjs` and `test/machine.test.mjs` passes unchanged, in CI and on the box. 7. README as in 5 above; no file added (see Rulings/notes: the ticket's own records are the exception). @@ -55,6 +59,13 @@ The owner: "When we boot the cloud image, we should always boot the LATEST devte - A dashboard's hub keeps today's behaviour exactly: stops trying on a non-hub holder, its retry timer unref'd. - `machine status` against an `/instances.json` that is huge: read capped at 64 KB, then `not a hub`. +## Security + +- The standalone hub is the same `startHub` request handler, with no branch of its own on any request: its own Host only, `Origin` and `Sec-Fetch-Site` for a press, the gateway signature and the in-memory nonce on a box, and the owner's command checking the proof again. Only listening, retrying, logging and the children's cwd differ. +- It runs from the image's root-owned files, which is stronger than a project's checkout. What it doesn't defend against (the design's Security): a `dev` process that takes port 4320 on purpose while the hub restarts, or that sets `DEVTEAM_IMAGE_HUB` for a dashboard it starts; P6 trusts the box's agents not to. +- `machine status` prints a `kit` from the network only if it matches `^[\w.+-]{1,64}$`. +- The log holds no request body (as the dashboard's log never does). + ## Out of scope - The boot, the `home:hub` window, the minute loop and `ENV DEVTEAM_IMAGE_HUB` (kit-49); the Dockerfile `ARG`s/`ENV` (kit-47); `hub.html`'s versions line, `/box.json`'s `image`, the gateway's fallback `down` (kit-50); `box-pins` (kit-48). From 76be351dd41c466138c8527f2ef557035529c202 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:44:00 +0000 Subject: [PATCH 04/24] kit-46: plan follows the reviewed spec Co-Authored-By: Claude Opus 5.5 --- docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md index e3fdc4c..7a21194 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md @@ -46,17 +46,17 @@ - Test: `test/hub-overview.test.mjs` **Interfaces:** -- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException/unhandledRejection (log `crashed: `, stderr `devteam crashed: …`, exit 1) and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `projectKit()` = `fs.existsSync(path.join(projectDir, 'settings.md')) ? kitVersion() : null`. +- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException/unhandledRejection (log `crashed: `, stderr `devteam crashed: …`, exit 1) and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `projectKit()` = `kitVersion()` when `projectDir/settings.md` exists and `kitDir` is inside `mainRoot`, else `null`. Test helper `imageKit()` (in each test file that starts a standalone hub): copies `kit` (from `lib/kit.mjs`) minus `project/` and `run/` into a fresh non-git temp folder and returns its `scripts/devteam.mjs`, as `/opt/devteam/kit` is laid out. - List shape: `{ hub: 1, kit, ...(image ? { image: true } : {}), instances }`. -- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node /scripts/devteam.mjs hub …`, detached, killed in `t.after`), env built from `process.env` minus the three box vars, `HOME` a temp home, `DEVTEAM_HUB_PORT=PORTS+40`, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=300`: +- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node hub …` with that cwd, detached, killed in `t.after`), env built from `process.env` minus the three box vars, `HOME` a temp home, `DEVTEAM_HUB_PORT=PORTS+40`, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=300`: - `test('hub --release, from a folder that is no repo: the list with the image\'s release, and nothing written there')`: the hub started first (it holds the port), then a bareProject dashboard, whose own hub keeps retrying harmlessly, is listed; `GET /instances.json` → `hub === 1`, `kit === 'v9.9.9'`, `image === true`, instances includes the fixture root; `fs.existsSync(path.join(cwd, 'docs')) === false`. - `test('hub without --release names no release, whatever DEVTEAM_KIT_RELEASE says; a bad --release serves anyway')`: env `DEVTEAM_KIT_RELEASE=v9.9.9`: `kit === null`, `!('image' in list)`; then `hub --release ''` and `--release nope`: same, and the process still alive after 1 s. - `test('a dashboard\'s hub names its project\'s kit and no image')`: dashboard with `DEVTEAM_KIT_RELEASE=v9.9.9`: list `kit` equals `hello --json` `kit` of that project, no `image`. - - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1 s it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`: restart hub; after 1.5 s exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. + - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1.5 s (5 retries) it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`: restart hub; after 1.5 s (≥ 4 × 300 ms) the hub is alive and exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048000 bytes; start hub; until `hub on http://` is in log or `.1`; `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`. - [ ] **Step 2:** `node --test --test-name-pattern='hub' test/hub-overview.test.mjs` → the new tests FAIL (`unknown command "hub"`). -- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: projectKit(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; on `EADDRINUSE`, standalone: `holder()`, log the non-hub line only when the last holder seen wasn't `other`, then `setTimeout(tryListen, retry)` **not** unref'd; dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: projectKit() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". +- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: projectKit(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; standalone, on any listen error: for `EADDRINUSE` ask `holder()` and log the non-hub line only when the last state logged wasn't `other`; for another error log `hub: ` only when it differs from the last; then `setTimeout(tryListen, retry)` **not** unref'd. Dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: projectKit() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". - [ ] **Step 4:** the same command → PASS; then the whole file and `test/dashboard.test.mjs` through `gate`. - [ ] **Step 5:** commit `kit-46 T1: devteam.mjs hub, the list's kit, the hub's log`. @@ -78,7 +78,7 @@ **Interfaces:** Consumes `hub` (T1), this file's `signedPress`, `ask`, `seat`, `machine({ box: true })` env pieces, `GW_PUBLIC`. -- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`; seat the operator (`seat(M)`, session `home`); spawn `hub` from `nowhere` with `M.env` minus the three box vars, `DEVTEAM_HUB_PORT=M.hub`, `DEVTEAM_PORT_BASE=PORTS+60`; until `/instances.json` answers. `own` headers as in `hubFixture`. Assert 403 for: `thread-new` with `own` only; a signed press re-sent after it was taken (`/used already/`); the signed headers with `origin: 'https://evil.example'`. Assert 200 for a fresh signed `thread-new {text:'standalone?'}`, its `out` second line `sent to the operator's pane`, and `until(() => a.keys().includes('standalone?'))`. `finally`: kill hub and `a.kill()`. +- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`; seat the operator (`seat(M)`: session `home`, window `operator`); spawn `node hub` with cwd `nowhere` and `M.env` minus the three box vars, `DEVTEAM_PORT_BASE=PORTS+60`; until `/instances.json` answers `hub: 1`. `own` headers as in `hubFixture`. Each of these is 403 and leaves `M.store()` empty: `thread-new` with `own` only; the signed headers with `origin: 'https://evil.example'`; with `host: 'evil.example'`; with `sec-fetch-site` deleted, and `'same-site'`; with `content-type: 'text/plain'`; GET `/instances.json` and `/operator/threads` with `sec-fetch-site: cross-site`. Then a fresh signed `thread-new {text:'standalone?'}` → 200, `out` second line `sent to the operator's pane`, `M.store()[1].proof.nonce` is its nonce, `until(() => a.keys().includes('standalone?'))`; re-sent → 403 `/used already/`. `finally`: kill hub and `a.kill()`. - [ ] **Step 2:** run the file through `gate` → PASS (it guards T1; mutate `ownerPost`'s nonce check in a scratch copy once to see it fail, noted in the task review). - [ ] **Step 3:** commit `kit-46 T3: the press checks, against the standalone hub`. From 36f1b24bf9dc0216848f70d848a09affeae04185 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:45:32 +0000 Subject: [PATCH 05/24] kit-46: spec review 2 (approve), plan refined Co-Authored-By: Claude Opus 5.5 --- .../tickets/kit-46-standalone-hub/plan.md | 13 ++++++----- .../kit-46-standalone-hub/reviews/spec-2.md | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-2.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md index 7a21194..fdf58e5 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md @@ -46,7 +46,7 @@ - Test: `test/hub-overview.test.mjs` **Interfaces:** -- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException/unhandledRejection (log `crashed: `, stderr `devteam crashed: …`, exit 1) and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `projectKit()` = `kitVersion()` when `projectDir/settings.md` exists and `kitDir` is inside `mainRoot`, else `null`. Test helper `imageKit()` (in each test file that starts a standalone hub): copies `kit` (from `lib/kit.mjs`) minus `project/` and `run/` into a fresh non-git temp folder and returns its `scripts/devteam.mjs`, as `/opt/devteam/kit` is laid out. +- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException/unhandledRejection (log `crashed: `, stderr `devteam crashed: …`, exit 1) and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `projectKit()` = `kitVersion()` when `projectDir/settings.md` exists and `kitDir` is inside `mainRoot`, else `null`: used by the standalone hub only; a dashboard's hub passes `kitVersion()` as it is (spec-2 F2-1). Test helper `imageKit()` (in each test file that starts a standalone hub): copies `kit` (from `lib/kit.mjs`) minus `project/` and `run/` into a fresh non-git temp folder of its own (never under the hub's cwd, spec-2 F2-2) and returns its `scripts/devteam.mjs`, as `/opt/devteam/kit` is laid out. - List shape: `{ hub: 1, kit, ...(image ? { image: true } : {}), instances }`. - [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node hub …` with that cwd, detached, killed in `t.after`), env built from `process.env` minus the three box vars, `HOME` a temp home, `DEVTEAM_HUB_PORT=PORTS+40`, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=300`: @@ -56,7 +56,7 @@ - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1.5 s (5 retries) it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`: restart hub; after 1.5 s (≥ 4 × 300 ms) the hub is alive and exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048000 bytes; start hub; until `hub on http://` is in log or `.1`; `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`. - [ ] **Step 2:** `node --test --test-name-pattern='hub' test/hub-overview.test.mjs` → the new tests FAIL (`unknown command "hub"`). -- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: projectKit(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; standalone, on any listen error: for `EADDRINUSE` ask `holder()` and log the non-hub line only when the last state logged wasn't `other`; for another error log `hub: ` only when it differs from the last; then `setTimeout(tryListen, retry)` **not** unref'd. Dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: projectKit() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". +- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: projectKit(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; standalone, on any listen error: for `EADDRINUSE` ask `holder()` and log the non-hub line only when the last state logged wasn't `other`; for another error log `hub: ` only when it differs from the last; then `setTimeout(tryListen, retry)` **not** unref'd. Dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: kitVersion() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". - [ ] **Step 4:** the same command → PASS; then the whole file and `test/dashboard.test.mjs` through `gate`. - [ ] **Step 5:** commit `kit-46 T1: devteam.mjs hub, the list's kit, the hub's log`. @@ -64,11 +64,11 @@ **Files:** Modify `docs/devteam/scripts/devteam.mjs` (`cmdDashboard`'s `listening` handler); Test `test/hub-overview.test.mjs`. -**Interfaces:** Consumes `startHub(log, opts)`, `projectKit()` from T1. Produces `imageHub()` = `Number(process.env.DEVTEAM_IMAGE_HUB)` when the trimmed value is a non-empty integer string, else `null`. +**Interfaces:** Consumes `startHub(log, opts)` from T1. Produces `imageHub()` = `Number(process.env.DEVTEAM_IMAGE_HUB)` when the value matches `^\d+$` (a plain port: not `0x10E0`, `4320.0` or ` 4320`), else `null`; the T2 test adds `0x10E0`-style value as a fourth run that serves its hub. - [ ] **Step 1: failing test** `test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port')`: three runs of a bareProject dashboard on hub `PORTS+40`: (a) `DEVTEAM_IMAGE_HUB=String(PORTS+40)`: after the dashboard answers `/api/hello`, wait 1 s; `net.connect(PORTS+40)` fails (nothing listens); `run/dashboard.log` has exactly one line ending `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. (b) unset and (c) `4320`: until `/instances.json` answers `hub: 1`, and the log has no `left to the image`. - [ ] **Step 2:** run → FAIL on (a). -- [ ] **Step 3:** in the `listening` handler: `imageHub() === HUB_PORT ? log(\`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})\`) : startHub(log, { kit: projectKit() })`, with a one-line comment (the image's own hub serves it; an older kit can still take the port in a restart's gap). +- [ ] **Step 3:** in the `listening` handler: `imageHub() === HUB_PORT ? log(\`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})\`) : startHub(log, { kit: kitVersion() })`, with a one-line comment (the image's own hub serves it; an older kit can still take the port in a restart's gap). - [ ] **Step 4:** PASS, and T1's tests still pass. - [ ] **Step 5:** commit `kit-46 T2: dashboards leave the hub to the image`. @@ -102,6 +102,7 @@ - [ ] **Step 2:** `git diff --diff-filter=A --name-only origin/main -- . ':!docs/devteam-dev'` is empty. - [ ] **Step 3:** commit `kit-46 T5: README, the image's hub`. -## Rulings +## Rulings and notes -None yet. +- Spec review 2 (APPROVE 2ce8e94), F2-1: the spec's own-kit guard for `kit` applies to the standalone hub only; a dashboard's hub reports `kitVersion()` unguarded, as done-when 1 says. F2-2: the test's kit copy lives outside the hub's cwd. +- No rulings or answers yet. diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-2.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-2.md new file mode 100644 index 0000000..44327bd --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/spec-2.md @@ -0,0 +1,23 @@ +# Spec review, pass 2 + +- Reviewer: spec-confirmer (fresh subagent, claude-opus) +- Reviewed: 2ce8e946465b237b986e83b2d5cebc5e677cfd92 (spec.md, diff since 79816da) + +- F1 confirmed fixed: the new preamble to the acceptance checks runs every standalone hub from a non-git copy of `docs/devteam`. With `scriptCommon` and `cwdCommon` both null, `mainRoot = repoRoot = process.cwd()` (devteam.mjs ~390, ~449), so the check now matches the box. It also forbids writes into the kit checkout. See F2-2 for a wording gap in it. +- F2 confirmed fixed: the owner action and the `operator-thread status` ask spawn with `cwd: process.cwd()`, a dashboard keeps `mainRoot` (today ~11730, ~11834), and the reason is stated. Check 4 now has an assertion that catches a wrong cwd: the proof lands in `~/operator/threads.jsonl` under the fixture `HOME`. +- F3 confirmed fixed for the standalone hub. The image's kit started from a project folder or with `DEVTEAM_HOME` has `kitDir` outside `mainRoot`, so it reports `null`. The guard also changes a dashboard's hub; see F2-1. +- F4 confirmed fixed: the check now uses window `operator`, which matches `OPERATOR_WINDOW` and the box lookup at ~13206. +- F5 confirmed fixed: check 4 adds a foreign Host, `sec-fetch-site` missing or `same-site`, a non-JSON content type, and cross-site GETs of `/instances.json` and `/operator/threads`. +- F6 confirmed fixed: the hub retries on any listen error, logs once per distinct message, and its retry timer is not unref'd. +- F7 confirmed fixed: the non-hub holds the port for at least 4 × `DEVTEAM_HUB_RETRY_MS`, and the check asserts exactly one line and that the hub is still alive. +- F8 confirmed fixed: `--release` must match `^v\d+\.\d+\.\d+$` (no `-rc` suffix), and check 1 asserts that `--release ''` and `--release nope` give `kit: null`, no `image`, and a hub still alive a second later. +- F9 confirmed fixed: `DEVTEAM_IMAGE_HUB` must be a plain port (`^\d+$`). This agrees with the edge case that treats a non-number as unset. +- F10 confirmed fixed: the new Security section states the same-handler invariant and the P6 lines that aren't defended. +- F11 confirmed fixed: the hub is left out on purpose, and the claim checks out. The guard at ~19093 lets the operator run only `recover`, `machine`, `operator-thread` and `OPERATOR_READS` (~19075), and `hub` is none of them. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F2-1 | Minor | done-when 1 ("a dashboard's hub reports kitVersion()"); design kit-46 bullet 2 ("for a dashboard's hub, it is `kitVersion()` (or `null` where there's no project)") | spec "What changes" 2, second bullet | The F3 guard ("`kitDir` is inside `mainRoot`") applies to a dashboard's hub as well as to the standalone `hub`. A dashboard whose running kit sits outside `mainRoot` while `settings.md` exists then reports `null`, but `hello --json` (~18060) still reports `kitVersion()`, which for that layout is the bare tag (`rel` starts `..`, ~18075). One example is a dashboard started with `DEVTEAM_OWN_KIT=1` from a project worktree. The test fixtures copy the kit into the project (test/lib/kit.mjs ~418), so check 1 still passes, but the spec narrows what the done-when says. Either apply the guard only to `hub` without `--release` (a dashboard's hub keeps `kitVersion()`, as written), or say in Rulings that this narrowing is intended. | fixed in plan.md (T1 Interfaces, Rulings and notes): a dashboard's hub keeps kitVersion(); the own-kit guard is the standalone hub's alone | +| F2-2 | Minor | done-when 1 ("creates no docs/devteam/run folder under its cwd"); spec check 1 ("no `docs` at all under its cwd") | Acceptance checks, the new preamble | The preamble says the hub runs "from a copy of `docs/devteam` … in a temp folder" but does not say the copy lives outside the hub's cwd. If a test puts the copy at `/docs/devteam`, then "no `docs` at all under its cwd" fails without any defect, and `devteamDir` (~458) resolves to that copy. Say that the kit copy sits in its own temp folder (like `/opt/devteam/kit`), apart from the cwd the hub runs in. | fixed in plan.md (T1 Interfaces): imageKit() copies into its own temp folder, never under the hub's cwd | + +Verdict: APPROVE 2ce8e946465b237b986e83b2d5cebc5e677cfd92 From 013ae420766462b1a494f2a738400315d2dc44ab Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:50:13 +0000 Subject: [PATCH 06/24] kit-46: plan review 1 and its fixes; box baseline; rulings Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/baseline-box.txt | 26 +++++++++++++++++++ .../tickets/kit-46-standalone-hub/plan.md | 25 ++++++++++-------- .../kit-46-standalone-hub/reviews/plan-1.md | 25 ++++++++++++++++++ 3 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-1.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt b/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt new file mode 100644 index 0000000..88536ca --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt @@ -0,0 +1,26 @@ +Baseline on the cloud box at main 5c2a77b (HEAD 36f1b24 has no code change), 2026-11-14: +node --test test/hub-overview.test.mjs test/machine.test.mjs test/operator-threads.test.mjs +not ok 8 - status says at a glance what a check-in needs, and reads no pane it needn't +not ok 11 - the kit lets a box operator look, and nothing else +not ok 12 - a repo on a kit from before recover comes back from the box's list, the way the box always brought it back +not ok 20 - the store: a thread, its turns, closed and opened again, kept beside the operator in no repo +not ok 21 - a box keeps them in ~/operator, on the disk that outlives a deploy +not ok 22 - who may write what: the owner starts, says and closes; only the operator replies; no session in a fleet reads or writes +not ok 23 - what is kept is checked: a turn's size, a title of one line, a subject of the Overview's own shape +not ok 24 - an owner turn becomes one line in the operator's pane, and only at its prompt +not ok 25 - on a box the operator is window 0 of home: typed into when it is there, never started when it is not +not ok 26 - on a box the owner's verbs take only a press the box's gateway signed, and show says which turns it signed +not ok 27 - a laptop's operator is started once: by the first words or by start, in its own session, with operator.md as its prompt +not ok 28 - start-once holds under a C locale, with the window renamed, and when tmux gives no answer +not ok 29 - settings that can't be read fail the start before its lock is taken +not ok 30 - inside tmux, list, show and reply are the operator's only from its own pane +not ok 31 - turns sent at the same moment each arrive whole: text then Enter, never text, text, Enter, Enter +not ok 33 - the hub takes the owner's turns only from its own page, within its caps, and starts the laptop's operator on the first +not ok 34 - on a box the hub takes a press only with the gateway's signature: a local program with the page's own headers is refused +not ok 36 - a start's note from a kit that did not name its tmux server is never taken for another session's pane +# tests 36 +# pass 16 +# fail 18 +# skipped 2 + +[exited with code 0] diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md index fdf58e5..2f2ad18 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md @@ -28,6 +28,7 @@ - Exact strings: `hub: left to the image (DEVTEAM_IMAGE_HUB=)`; `stopped by SIGTERM`; `machine: hub up, kit (image)` / `up, kit ` / `up, kit ?` / `up, not a hub` / `DOWN`. - Existing tests in dashboard, hub-overview, operator-threads and machine test files pass unchanged. - Gates: `node /data/home/code/devteam-dev/docs/devteam/scripts/devteam.mjs gate test -- node --test ` per file touched. +- Every kit test runs as `env -u DEVTEAM_RUN -u DEVTEAM_HOME -u DEVTEAM_ROLE -u DEVTEAM_TICKET -u DEVTEAM_FEATURE TMUX_TMPDIR=$(mktemp -d) node --test test/.test.mjs` (m-20261114-214804017-supervisor: a test keeping `DEVTEAM_RUN` wrote into home's live log); the gate wraps that command. ## Review Focus @@ -46,17 +47,17 @@ - Test: `test/hub-overview.test.mjs` **Interfaces:** -- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException/unhandledRejection (log `crashed: `, stderr `devteam crashed: …`, exit 1) and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `projectKit()` = `kitVersion()` when `projectDir/settings.md` exists and `kitDir` is inside `mainRoot`, else `null`: used by the standalone hub only; a dashboard's hub passes `kitVersion()` as it is (spec-2 F2-1). Test helper `imageKit()` (in each test file that starts a standalone hub): copies `kit` (from `lib/kit.mjs`) minus `project/` and `run/` into a fresh non-git temp folder of its own (never under the hub's cwd, spec-2 F2-2) and returns its `scripts/devteam.mjs`, as `/opt/devteam/kit` is laid out. +- Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException (log `crashed: `) and unhandledRejection (log `crashed (unhandled rejection): `), both with stderr `devteam crashed: …` and exit 1, the dashboard's wording kept exactly and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `ownKitVersion()` = `kitVersion()` when `projectDir/settings.md` exists and `kitDir` is inside `mainRoot`, else `null`: used by the standalone hub only; a dashboard's hub passes `kitVersion()` as it is (spec-2 F2-1). Test helper `imageKit()` (in each test file that starts a standalone hub): copies `kit` (from `lib/kit.mjs`) minus `project/` and `run/` into a fresh non-git temp folder of its own (never under the hub's cwd, spec-2 F2-2) and returns its `scripts/devteam.mjs`, as `/opt/devteam/kit` is laid out. - List shape: `{ hub: 1, kit, ...(image ? { image: true } : {}), instances }`. -- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node hub …` with that cwd, detached, killed in `t.after`), env built from `process.env` minus the three box vars, `HOME` a temp home, `DEVTEAM_HUB_PORT=PORTS+40`, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=300`: - - `test('hub --release, from a folder that is no repo: the list with the image\'s release, and nothing written there')`: the hub started first (it holds the port), then a bareProject dashboard, whose own hub keeps retrying harmlessly, is listed; `GET /instances.json` → `hub === 1`, `kit === 'v9.9.9'`, `image === true`, instances includes the fixture root; `fs.existsSync(path.join(cwd, 'docs')) === false`. - - `test('hub without --release names no release, whatever DEVTEAM_KIT_RELEASE says; a bad --release serves anyway')`: env `DEVTEAM_KIT_RELEASE=v9.9.9`: `kit === null`, `!('image' in list)`; then `hub --release ''` and `--release nope`: same, and the process still alive after 1 s. +- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node hub …` with that cwd, `detached: true`; `t.after` sends SIGKILL to the process group `-pid` and awaits its `exit`), env built from `process.env` minus the three box vars, `HOME` a fresh temp home per test, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=200`. Each standalone hub in this task gets its own hub port, `PORTS+40`, `+36`, `+37`, `+38`, `+39` in the order below (never reused within the file, so a hub still dying can't answer for the next): + - `test('hub --release, from a folder that is no repo: the list with the image\'s release, and nothing written there')`: the hub started first and awaited until its list answers `kit: 'v9.9.9'`, then a bareProject dashboard, whose own hub keeps retrying harmlessly, is listed; `GET /instances.json` → `hub === 1`, `kit === 'v9.9.9'`, `image === true`, instances includes the fixture root; `fs.existsSync(path.join(cwd, 'docs')) === false`. + - `test('hub without --release names no release, whatever DEVTEAM_KIT_RELEASE says; a bad --release serves anyway')`: env `DEVTEAM_KIT_RELEASE=v9.9.9`: `kit === null`, `!('image' in list)`; then `hub --release ''` and `--release nope`, each on its own port and awaited until its own port answers: same, and the process still alive (`exitCode === null`) 1 s later. - `test('a dashboard\'s hub names its project\'s kit and no image')`: dashboard with `DEVTEAM_KIT_RELEASE=v9.9.9`: list `kit` equals `hello --json` `kit` of that project, no `image`. - - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1.5 s (5 retries) it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`: restart hub; after 1.5 s (≥ 4 × 300 ms) the hub is alive and exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. - - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048000 bytes; start hub; until `hub on http://` is in log or `.1`; `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`. + - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1.5 s it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`, on a new port: start a hub there; until the first `isn't a devteam hub` line is in the log, then wait a further 2 s (≥ 8 retries at 200 ms); the hub is alive and exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. + - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048570 bytes (so the hub's first line can't fit); start hub; until `hub on http://` is in the log; `hub-.log.1` exists with the 1048570 bytes, `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`. - [ ] **Step 2:** `node --test --test-name-pattern='hub' test/hub-overview.test.mjs` → the new tests FAIL (`unknown command "hub"`). -- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: projectKit(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; standalone, on any listen error: for `EADDRINUSE` ask `holder()` and log the non-hub line only when the last state logged wasn't `other`; for another error log `hub: ` only when it differs from the last; then `setTimeout(tryListen, retry)` **not** unref'd. Dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: kitVersion() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". +- [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: ownKitVersion(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; standalone, on any listen error: for `EADDRINUSE` ask `holder()` and log the non-hub line only when the last state logged wasn't `other`; for another error log `hub: ` only when it differs from the last; then `setTimeout(tryListen, retry)` **not** unref'd. Dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: kitVersion() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". - [ ] **Step 4:** the same command → PASS; then the whole file and `test/dashboard.test.mjs` through `gate`. - [ ] **Step 5:** commit `kit-46 T1: devteam.mjs hub, the list's kit, the hub's log`. @@ -66,7 +67,7 @@ **Interfaces:** Consumes `startHub(log, opts)` from T1. Produces `imageHub()` = `Number(process.env.DEVTEAM_IMAGE_HUB)` when the value matches `^\d+$` (a plain port: not `0x10E0`, `4320.0` or ` 4320`), else `null`; the T2 test adds `0x10E0`-style value as a fourth run that serves its hub. -- [ ] **Step 1: failing test** `test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port')`: three runs of a bareProject dashboard on hub `PORTS+40`: (a) `DEVTEAM_IMAGE_HUB=String(PORTS+40)`: after the dashboard answers `/api/hello`, wait 1 s; `net.connect(PORTS+40)` fails (nothing listens); `run/dashboard.log` has exactly one line ending `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. (b) unset and (c) `4320`: until `/instances.json` answers `hub: 1`, and the log has no `left to the image`. +- [ ] **Step 1: failing test** `test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port')`: four runs, each a fresh bareProject (its own `run/dashboard.log`) and its own hub port (`PORTS+26`, `+27`, `+28`, `+29`: below the instance block +41..65 the dashboards pick from), each dashboard stopped (`await d.stop()`) before the next: (a) `DEVTEAM_IMAGE_HUB=String(hubPort)`: after the dashboard answers `/api/hello`, wait 1 s; `net.connect(hubPort)` fails (nothing listens); its `run/dashboard.log` has exactly one line ending `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. (b) unset, (c) `4320`, (d) `0x${hubPort.toString(16)}` (a number equal to its port, but not a plain one): until that hub port's `/instances.json` answers `hub: 1`, and the log has no `left to the image`. - [ ] **Step 2:** run → FAIL on (a). - [ ] **Step 3:** in the `listening` handler: `imageHub() === HUB_PORT ? log(\`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})\`) : startHub(log, { kit: kitVersion() })`, with a one-line comment (the image's own hub serves it; an older kit can still take the port in a restart's gap). - [ ] **Step 4:** PASS, and T1's tests still pass. @@ -78,7 +79,7 @@ **Interfaces:** Consumes `hub` (T1), this file's `signedPress`, `ask`, `seat`, `machine({ box: true })` env pieces, `GW_PUBLIC`. -- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`; seat the operator (`seat(M)`: session `home`, window `operator`); spawn `node hub` with cwd `nowhere` and `M.env` minus the three box vars, `DEVTEAM_PORT_BASE=PORTS+60`; until `/instances.json` answers `hub: 1`. `own` headers as in `hubFixture`. Each of these is 403 and leaves `M.store()` empty: `thread-new` with `own` only; the signed headers with `origin: 'https://evil.example'`; with `host: 'evil.example'`; with `sec-fetch-site` deleted, and `'same-site'`; with `content-type: 'text/plain'`; GET `/instances.json` and `/operator/threads` with `sec-fetch-site: cross-site`. Then a fresh signed `thread-new {text:'standalone?'}` → 200, `out` second line `sent to the operator's pane`, `M.store()[1].proof.nonce` is its nonce, `until(() => a.keys().includes('standalone?'))`; re-sent → 403 `/used already/`. `finally`: kill hub and `a.kill()`. +- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`, with the hub on `PORTS+36` (in this file's block; `machine()`'s own `PORTS+80+n` can pass +99): `env = { ...M.env, DEVTEAM_HUB_PORT: String(PORTS+36) }`, used for the hub and for `M.store()`'s reads alike; seat the operator (`seat(M)`: session `home`, window `operator`); spawn `node hub` with cwd `nowhere` and `M.env` minus the three box vars, `DEVTEAM_PORT_BASE=PORTS+60`; until `/instances.json` answers `hub: 1`. `own` headers as in `hubFixture`. Each of these is 403 and leaves `M.store()` empty: `thread-new` with `own` only; the signed headers with `origin: 'https://evil.example'`; with `host: 'evil.example'`; with `sec-fetch-site` deleted, and `'same-site'`; with `content-type: 'text/plain'`; GET `/instances.json` and `/operator/threads` with `sec-fetch-site: cross-site`. Then a fresh signed `thread-new {text:'standalone?'}` → 200, `out` second line `sent to the operator's pane`, `M.store()[1].proof.nonce` is its nonce, `until(() => a.keys().includes('standalone?'))`; re-sent → 403 `/used already/`. `finally`: kill hub and `a.kill()`. - [ ] **Step 2:** run the file through `gate` → PASS (it guards T1; mutate `ownerPost`'s nonce check in a scratch copy once to see it fail, noted in the task review). - [ ] **Step 3:** commit `kit-46 T3: the press checks, against the standalone hub`. @@ -88,7 +89,7 @@ **Interfaces:** Produces `hubLine(port) -> Promise` returning `DOWN`, `up, not a hub`, `up, kit ?`, `up, kit `, `up, kit (image)`; `` shown only if `/^[\w.+-]{1,64}$/`. Uses `http.get` (Host `127.0.0.1:`, `sec-fetch-site: same-origin`, 3 s timeout, body capped at 64 KB → `not a hub`). -- [ ] **Step 1: failing test** `test('status names the hub\'s kit, or says it is no hub, or down')`: for each fixture served on `process.env.DEVTEAM_HUB_PORT` by an in-process `http.createServer` — `{hub:1,kit:'v1.2.3',image:true,instances:[]}`, `{hub:1,kit:'v1.2.3',instances:[]}`, `{hub:1,instances:[]}`, `{hub:1,kit:'\u001b[31mx',instances:[]}`, `'ok'` — run `machine status --in ` and assert the exact line (`up, kit v1.2.3 (image)`, `up, kit v1.2.3`, `up, kit ?`, `up, kit ?`, `up, not a hub`); with nothing listening, `DOWN`; with an alert set, `up, kit v1.2.3; alert: look`. The env clears the three box vars. +- [ ] **Step 1: failing test** `test('status names the hub\'s kit, or says it is no hub, or down')`: `machine status --in ` is run with the async `execFile` (promisified), never `spawnSync`, since the fixture server lives in the test's own event loop; env `{ ...baseEnv }` with `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` deleted. One in-process `http.createServer` on `process.env.DEVTEAM_HUB_PORT` whose `/instances.json` body the test swaps per case, asserting the exact line: `{hub:1,kit:'v1.2.3',image:true,instances:[]}` → `up, kit v1.2.3 (image)`; `{hub:1,kit:'v1.2.3',instances:[]}` → `up, kit v1.2.3`; `{hub:1,instances:[]}`, `{hub:1,kit:null,instances:[]}`, `{hub:1,kit:'\u001b[31mx',instances:[]}`, `{hub:1,kit:'v'.repeat(10240),instances:[]}` → `up, kit ?`; `'ok'` and a 70 KB JSON body → `up, not a hub`; server closed → `DOWN`; `machine alert -- look` then → `up, kit v1.2.3; alert: look`, cleared with `machine alert -- --clear` in `finally`. - [ ] **Step 2:** run → FAIL (`up` alone). - [ ] **Step 3:** implement `hubLine`, use it in `machineStatus`. - [ ] **Step 4:** the file through `gate` → PASS (the existing DOWN assertions unchanged). @@ -104,5 +105,7 @@ ## Rulings and notes +- Plan review 1 (CHANGES 36f1b24): P1-1..P1-12 fixed in the steps above (async status run, `ownKitVersion` name, a log that must rotate, a port per hub, the hex run, T3's in-block port, retries counted from the first line, a project per T2 run, the rejection wording, T4's extra cases, waits, group kill). - Spec review 2 (APPROVE 2ce8e94), F2-1: the spec's own-kit guard for `kit` applies to the standalone hub only; a dashboard's hub reports `kitVersion()` unguarded, as done-when 1 says. F2-2: the test's kit copy lives outside the hub's cwd. -- No rulings or answers yet. +- m-20261114-214804017-supervisor: run kit tests with the session's devteam env removed (above); don't change the tests for it here. +- m-20261114-214554948-supervisor: spec approved; records in the ticket folder don't count as added files (done-when 7 is checked outside `docs/devteam-dev/`). diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-1.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-1.md new file mode 100644 index 0000000..ef8bb66 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-1.md @@ -0,0 +1,25 @@ +# Plan review, pass 1 + +- Reviewer: plan-reviewer (fresh subagent, claude-opus) +- Reviewed: 36f1b24bf9dc0216848f70d848a09affeae04185 (plan.md) + +- Scope: every task's files are inside the ticket's `touches`. Every done-when has a task and a test. The TL;DR has seven bullets (quality.md §6). The spec-2 Minors F2-1 and F2-2 are resolved as the Rulings say. +- P5: the plan adds no branch to the request handler. The standalone hub changes only listening, retrying, logging and the children's cwd, and T3 covers the attacks spec check 4 lists. I found no security regression in the press path. The findings below are about tests that cannot fail, one name clash and port hygiene. +- Node 18: nothing the plan uses is newer than Node 18 (`http.get`, `AbortSignal.timeout`, `fs.statSync({ throwIfNoEntry })`). + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| P1-1 | Important | done-when 5; quality.md §3 (a test must be able to pass for the right reason) | T4 Step 1 | The fixtures are "served … by an in-process `http.createServer`", but `machine()` in test/machine.test.mjs (line 44) is `spawnSync`. That blocks the test's event loop, so the in-process server never answers and every case prints `DOWN` (after the 3 s timeout). The existing status test avoids this by running its server in a child process (`srv = spawn(... createServer ...)`, ~285). Say which approach the test uses: a child-process server that is handed the body, or an async `execFile` for these runs. | fixed | +| P1-2 | Important | Global Constraints ("one file"); a step that is wrong as written | T1 Interfaces, `projectKit()` | `function projectKit(root, args, { timeout })` already exists (devteam.mjs 12728, used by `machineStatus` ~13612). A second top-level `function projectKit` in an ES module is a duplicate lexical declaration, which is a SyntaxError, so the whole script fails to load. Rename the new helper (for example `ownKitVersion()`). | fixed | +| P1-3 | Important | done-when 3 ("the log never grows past 1 MB, checked at each write (a test writes past it)"); spec check 3 | T1 Step 1, the 1 MB test | The test pre-fills 1048000 bytes. That leaves 576 bytes under 1048576, and the hub's start lines (`hub on http://127.0.0.1:` plus the timestamp, about 70 bytes) plus `stopped by SIGTERM` fit in it. An implementation that never rotates passes, so the test does not write past the limit. Pre-fill to within a few bytes of 1048576 (for example 1048570) so the first line must rotate. Assert that `hub-.log.1` exists, and check the size ≤ 1048576 again after the SIGTERM line. | fixed | +| P1-4 | Important | done-when 1 ("hub without --release reports kit null"); spec check 1 (`--release ''`/`nope` "each still serving") | T1 Step 1, second test | The test starts three hubs one after another on the same `PORTS+40`. If one hub has not exited when the next starts, the next sees a hub holder and retries, and the list comes from the previous hub, which also says `kit: null`. The `''` and `nope` runs then prove nothing about their own process, and "alive after 1 s" holds while it retries. Wait for each hub to exit (and the port to free) before starting the next. Also assert the run's own `hub on http://` line and the `is not vX.Y.Z` line in its log. | fixed | +| P1-5 | Important | spec "What changes" 3 (`^\d+$`, "not `0x10E0` or `4320.0`"); T2 Interfaces | T2 Interfaces vs Step 1 | The Interfaces add "a `0x10E0`-style value as a fourth run", but Step 1 lists only three runs. `0x10E0` is 4320, not the fixture hub port, so that run serves its hub whether or not the regex exists, and the case cannot fail. To test the plain-port rule, use the fixture port in a non-plain form: `` ` ${PORTS + 40}` `` (`Number` accepts it, the regex does not), `${PORTS + 40}.0`, or `0x${(PORTS + 40).toString(16)}`. Assert the hub is served and no `left to the image` line is written. Put the run in Step 1. | fixed | +| P1-6 | Important | test/lib/ports.mjs (a port a test binds must be in the file's block) | T3 Step 1 | `machine()` gives hub `PORTS + 80 + n`, and the file already makes 20 `machine()` calls (the existing last one is already `+100`). A new box machine at the end gets `PORTS + 101`, in the neighbouring block, which another file may be using. T3 runs its own hub, so set `DEVTEAM_HUB_PORT` to a free in-block port, for example `PORTS + 50`. That port is clear of hubFixture's `+30/31..55` only if the base is not inside that range, so choose and state the pair. A box's pane lookup uses session `home` whatever the hub port, so nothing else changes. Name the layout in the test's comment. | fixed | +| P1-7 | Important | done-when 3 ("logs that once … keeps retrying"); spec check 3 ("held by a non-hub for at least 4 × DEVTEAM_HUB_RETRY_MS: exactly one such line") | T1 Step 1, the retry test, holder 2 | The 1.5 s is counted from the spawn. On a slow CI job, Node's start-up and the 19k-line script's load use much of it, so the hub may try only once or twice. A logger that writes at every retry then still shows one line. Wait until the first `isn't a devteam hub` line appears, then wait at least 4 × 300 ms more, then assert exactly one line and that the hub is alive. The spec words it this way. | fixed | +| P1-8 | Minor | done-when 2 ("writes no such line"); a step that is ambiguous | T2 Step 1 | It is not stated whether runs (a), (b) and (c) share one bareProject. If they do, (a)'s line is still in `run/dashboard.log` when (b) and (c) assert "no `left to the image`", and they fail. Also, for (b) and (c), "`/instances.json` answers `hub: 1`" can be answered by a hub left over from an earlier test. Use a fresh project per run, or read only the lines written after each start. Also assert the dashboard's own log has `hub on http://127.0.0.1:`. | fixed | +| P1-9 | Minor | done-when 6 / T1 Step 3 ("Dashboard path unchanged") | T1 Interfaces, `processLog` | The plan says the shared handler logs `crashed: ` for both events. Today the dashboard logs `crashed (unhandled rejection): …` for a rejection (~13764). Keep both strings exactly, so the dashboard's log does not change. | fixed | +| P1-10 | Minor | done-when 5, done-when 8; Review Focus 5; spec Edge cases (64 KB cap) | T4 Step 1 | (a) Review Focus 5 promises a 10 KB hostile `kit` test, and the spec caps the read at 64 KB (`not a hub`). Neither case is in the step. Add `kit: 'x'.repeat(10240)` → `kit ?`, a body over 64 KB → `not a hub`, and `kit: null` → `kit ?`. (b) The alert lives in the file-wide `HOME`, and the existing test asserts `hub DOWN$` exactly (~302). Clear the alert in `finally` (`machine alert -- --clear`). (c) `baseEnv` spreads `process.env`, so say how the three box vars are cleared: pass them as `undefined`, which spawn drops, or delete them from a copy. | fixed | +| P1-11 | Minor | a step that is ambiguous | T1 Step 1, first test | "the hub started first (it holds the port)" needs an explicit wait: wait until `/instances.json` answers `kit: 'v9.9.9'` before starting the dashboard. Otherwise the dashboard's hub can take the port first, and the standalone hub retries behind it for as long as the dashboard runs. The test then fails on `kit`/`image` for a reason unrelated to the code. | fixed | +| P1-12 | Suggestion | test hygiene | T1 helper `startHubCmd` | The helper spawns with `detached` and kills in `t.after`. Make `t.after` wait for the child's `exit`, so the next test's hub on the same `PORTS+40` never finds the old one still holding the port (see P1-4). Kill the group (`process.kill(-pid)`) or drop `detached`, so an owner-action child is not left running. | fixed | + +Verdict: CHANGES 36f1b24bf9dc0216848f70d848a09affeae04185 From 4ba60a40a92588f97881a13907e33ed2d7a598e2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:52:38 +0000 Subject: [PATCH 07/24] kit-46: plan review 2 and its fixes; clean box baseline Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/baseline-box.txt | 11 +++----- .../tickets/kit-46-standalone-hub/plan.md | 12 +++++---- .../kit-46-standalone-hub/reviews/plan-2.md | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-2.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt b/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt index 88536ca..9ddea5d 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/baseline-box.txt @@ -1,8 +1,5 @@ -Baseline on the cloud box at main 5c2a77b (HEAD 36f1b24 has no code change), 2026-11-14: -node --test test/hub-overview.test.mjs test/machine.test.mjs test/operator-threads.test.mjs -not ok 8 - status says at a glance what a check-in needs, and reads no pane it needn't -not ok 11 - the kit lets a box operator look, and nothing else -not ok 12 - a repo on a kit from before recover comes back from the box's list, the way the box always brought it back +Baseline on the cloud box at main 5c2a77b (no code change yet), 2026-11-14, run with the session's devteam env removed (m-20261114-214804017-supervisor): +env -u DEVTEAM_RUN -u DEVTEAM_HOME -u DEVTEAM_ROLE -u DEVTEAM_TICKET -u DEVTEAM_FEATURE -u DEVTEAM_SUPERVISOR -u TMUX -u TMUX_PANE TMUX_TMPDIR=$(mktemp -d) node --test test/hub-overview.test.mjs test/machine.test.mjs test/operator-threads.test.mjs not ok 20 - the store: a thread, its turns, closed and opened again, kept beside the operator in no repo not ok 21 - a box keeps them in ~/operator, on the disk that outlives a deploy not ok 22 - who may write what: the owner starts, says and closes; only the operator replies; no session in a fleet reads or writes @@ -19,8 +16,8 @@ not ok 33 - the hub takes the owner's turns only from its own page, within its c not ok 34 - on a box the hub takes a press only with the gateway's signature: a local program with the page's own headers is refused not ok 36 - a start's note from a kit that did not name its tmux server is never taken for another session's pane # tests 36 -# pass 16 -# fail 18 +# pass 19 +# fail 15 # skipped 2 [exited with code 0] diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md index 2f2ad18..6a65e98 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md @@ -50,12 +50,12 @@ - Produces: `startHub(log, { kit = null, image = false, standalone = false } = {})`; `processLog(log, name)` installs uncaughtException (log `crashed: `) and unhandledRejection (log `crashed (unhandled rejection): `), both with stderr `devteam crashed: …` and exit 1, the dashboard's wording kept exactly and SIGTERM/SIGINT/SIGHUP (`stopped by `, exit 0); `hubLogger()` returns `(msg) => void` writing ` [] ` to stderr and `~/.devteam/hub-.log`, rotating to `.1` before a write that would pass 1 MiB (1048576); `cmdHub()`; `runOwnerAction(args, { …, cwd = mainRoot })`; `ownKitVersion()` = `kitVersion()` when `projectDir/settings.md` exists and `kitDir` is inside `mainRoot`, else `null`: used by the standalone hub only; a dashboard's hub passes `kitVersion()` as it is (spec-2 F2-1). Test helper `imageKit()` (in each test file that starts a standalone hub): copies `kit` (from `lib/kit.mjs`) minus `project/` and `run/` into a fresh non-git temp folder of its own (never under the hub's cwd, spec-2 F2-2) and returns its `scripts/devteam.mjs`, as `/opt/devteam/kit` is laid out. - List shape: `{ hub: 1, kit, ...(image ? { image: true } : {}), instances }`. -- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node hub …` with that cwd, `detached: true`; `t.after` sends SIGKILL to the process group `-pid` and awaits its `exit`), env built from `process.env` minus the three box vars, `HOME` a fresh temp home per test, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=200`. Each standalone hub in this task gets its own hub port, `PORTS+40`, `+36`, `+37`, `+38`, `+39` in the order below (never reused within the file, so a hub still dying can't answer for the next): +- [ ] **Step 1: failing tests** in `test/hub-overview.test.mjs`, one helper `startHubCmd(cwd, env, args)` (spawn `node hub …` with that cwd, `detached: true`; `t.after` sends SIGKILL to the process group `-pid` and awaits its `exit`, both skipped when the child has already exited), env built from `process.env` minus the three box vars, `HOME` a fresh temp home per test, `DEVTEAM_PORT_BASE=PORTS+41`, `DEVTEAM_HUB_RETRY_MS=200`. Every hub in this task has its own port, never reused within the file, so a hub still dying can't answer for the next: test 1's standalone hub `PORTS+40`; test 2's no-release `+36`, `--release ''` `+37`, `--release nope` `+38`; test 3's dashboard hub `+39`; test 4's holder-is-a-hub run `+30`, holder-is-not run `+31`; test 5's `+32`. (T2 uses `+26..29`; the instance block is `+41..65`.) - `test('hub --release, from a folder that is no repo: the list with the image\'s release, and nothing written there')`: the hub started first and awaited until its list answers `kit: 'v9.9.9'`, then a bareProject dashboard, whose own hub keeps retrying harmlessly, is listed; `GET /instances.json` → `hub === 1`, `kit === 'v9.9.9'`, `image === true`, instances includes the fixture root; `fs.existsSync(path.join(cwd, 'docs')) === false`. - `test('hub without --release names no release, whatever DEVTEAM_KIT_RELEASE says; a bad --release serves anyway')`: env `DEVTEAM_KIT_RELEASE=v9.9.9`: `kit === null`, `!('image' in list)`; then `hub --release ''` and `--release nope`, each on its own port and awaited until its own port answers: same, and the process still alive (`exitCode === null`) 1 s later. - `test('a dashboard\'s hub names its project\'s kit and no image')`: dashboard with `DEVTEAM_KIT_RELEASE=v9.9.9`: list `kit` equals `hello --json` `kit` of that project, no `image`. - `test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port')`: holder 1 = a tiny server answering `{"hub":1,"instances":[]}` on `/instances.json`; start `hub --release v9.9.9`; after 1.5 s it is alive and list kit is not `v9.9.9`; close holder → until list kit `v9.9.9`. Holder 2 = server answering `ok`, on a new port: start a hub there; until the first `isn't a devteam hub` line is in the log, then wait a further 2 s (≥ 8 retries at 200 ms); the hub is alive and exactly one line matching `isn't a devteam hub` in `HOME/.devteam/hub-.log`; close → until kit `v9.9.9`. - - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048570 bytes (so the hub's first line can't fit); start hub; until `hub on http://` is in the log; `hub-.log.1` exists with the 1048570 bytes, `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`. + - `test('the hub\'s log stays under 1 MB and records a SIGTERM')`: pre-write the log with 1048570 bytes (so the hub's first line can't fit); start hub; until `hub on http://` is in the log; `hub-.log.1` exists with the 1048570 bytes, `statSync(log).size <= 1048576`; `kill(SIGTERM)`; until log includes `stopped by SIGTERM`; size still `<= 1048576`. - [ ] **Step 2:** `node --test --test-name-pattern='hub' test/hub-overview.test.mjs` → the new tests FAIL (`unknown command "hub"`). - [ ] **Step 3: implement.** `cmdHub`: `const rel = flagStr('release')`; valid iff `/^v\d+\.\d+\.\d+$/`; invalid but given → log `hub: --release "" is not vX.Y.Z; serving with no release`; `startHub(log, valid ? { kit: rel, image: true, standalone: true } : { kit: ownKitVersion(), standalone: true })`. In `startHub`: list body adds `kit`/`image`; standalone, on any listen error: for `EADDRINUSE` ask `holder()` and log the non-hub line only when the last state logged wasn't `other`; for another error log `hub: ` only when it differs from the last; then `setTimeout(tryListen, retry)` **not** unref'd. Dashboard path unchanged. Owner actions (`runOwnerAction` and `askOperator`'s `execFile`) use `cwd: standalone ? process.cwd() : mainRoot`. `cmdDashboard` uses `processLog(log, 'dashboard')` and `startHub(log, { kit: kitVersion() })`. Add `'hub'` to `machineWide`, `COMMANDS`, HELP. Rewrite the comment above `startHub`: who serves it (a dashboard, or `devteam.mjs hub` alone, which keeps retrying), the list's fields, and "additive changes keep `hub: 1`; anything else bumps it". - [ ] **Step 4:** the same command → PASS; then the whole file and `test/dashboard.test.mjs` through `gate`. @@ -65,7 +65,7 @@ **Files:** Modify `docs/devteam/scripts/devteam.mjs` (`cmdDashboard`'s `listening` handler); Test `test/hub-overview.test.mjs`. -**Interfaces:** Consumes `startHub(log, opts)` from T1. Produces `imageHub()` = `Number(process.env.DEVTEAM_IMAGE_HUB)` when the value matches `^\d+$` (a plain port: not `0x10E0`, `4320.0` or ` 4320`), else `null`; the T2 test adds `0x10E0`-style value as a fourth run that serves its hub. +**Interfaces:** Consumes `startHub(log, opts)` from T1. Produces `imageHub()` = `Number(process.env.DEVTEAM_IMAGE_HUB)` when the value matches `^\d+$` (a plain port: not hex, `4320.0` or ` 4320`), else `null`; Step 1's run (d) uses `0x`, which `Number()` would equal. - [ ] **Step 1: failing test** `test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port')`: four runs, each a fresh bareProject (its own `run/dashboard.log`) and its own hub port (`PORTS+26`, `+27`, `+28`, `+29`: below the instance block +41..65 the dashboards pick from), each dashboard stopped (`await d.stop()`) before the next: (a) `DEVTEAM_IMAGE_HUB=String(hubPort)`: after the dashboard answers `/api/hello`, wait 1 s; `net.connect(hubPort)` fails (nothing listens); its `run/dashboard.log` has exactly one line ending `hub: left to the image (DEVTEAM_IMAGE_HUB=)`. (b) unset, (c) `4320`, (d) `0x${hubPort.toString(16)}` (a number equal to its port, but not a plain one): until that hub port's `/instances.json` answers `hub: 1`, and the log has no `left to the image`. - [ ] **Step 2:** run → FAIL on (a). @@ -79,7 +79,7 @@ **Interfaces:** Consumes `hub` (T1), this file's `signedPress`, `ask`, `seat`, `machine({ box: true })` env pieces, `GW_PUBLIC`. -- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`, with the hub on `PORTS+36` (in this file's block; `machine()`'s own `PORTS+80+n` can pass +99): `env = { ...M.env, DEVTEAM_HUB_PORT: String(PORTS+36) }`, used for the hub and for `M.store()`'s reads alike; seat the operator (`seat(M)`: session `home`, window `operator`); spawn `node hub` with cwd `nowhere` and `M.env` minus the three box vars, `DEVTEAM_PORT_BASE=PORTS+60`; until `/instances.json` answers `hub: 1`. `own` headers as in `hubFixture`. Each of these is 403 and leaves `M.store()` empty: `thread-new` with `own` only; the signed headers with `origin: 'https://evil.example'`; with `host: 'evil.example'`; with `sec-fetch-site` deleted, and `'same-site'`; with `content-type: 'text/plain'`; GET `/instances.json` and `/operator/threads` with `sec-fetch-site: cross-site`. Then a fresh signed `thread-new {text:'standalone?'}` → 200, `out` second line `sent to the operator's pane`, `M.store()[1].proof.nonce` is its nonce, `until(() => a.keys().includes('standalone?'))`; re-sent → 403 `/used already/`. `finally`: kill hub and `a.kill()`. +- [ ] **Step 1: test** `test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator')`, `skip: !hasTmux`: `M = machine({ box: true })`, with the hub on `PORTS+36` (in this file's block; `machine()`'s own `PORTS+80+n` can pass +99): `env = { ...M.env, DEVTEAM_HUB_PORT: String(PORTS+36), DEVTEAM_PORT_BASE: String(PORTS+60) }` minus the three box vars, and the hub is spawned with this `env`, never `M.env`. (`M.store()` reads `M.dir`, `~/operator` under `M.home` on a box, which no port changes; the press's own `operator-thread` child inherits `env`.) seat the operator (`seat(M)`: session `home`, window `operator`); spawn `node hub` with cwd `nowhere` and that `env`; until `/instances.json` answers `hub: 1`. `own` headers as in `hubFixture`. Each of these is 403 and leaves `M.store()` empty: `thread-new` with `own` only; the signed headers with `origin: 'https://evil.example'`; with `host: 'evil.example'`; with `sec-fetch-site` deleted, and `'same-site'`; with `content-type: 'text/plain'`; GET `/instances.json` and `/operator/threads` with `sec-fetch-site: cross-site`. Then a fresh signed `thread-new {text:'standalone?'}` → 200, `out` second line `sent to the operator's pane`, `M.store()[1].proof.nonce` is its nonce, `until(() => a.keys().includes('standalone?'))`; re-sent → 403 `/used already/`. `finally`: kill hub and `a.kill()`. - [ ] **Step 2:** run the file through `gate` → PASS (it guards T1; mutate `ownerPost`'s nonce check in a scratch copy once to see it fail, noted in the task review). - [ ] **Step 3:** commit `kit-46 T3: the press checks, against the standalone hub`. @@ -89,7 +89,7 @@ **Interfaces:** Produces `hubLine(port) -> Promise` returning `DOWN`, `up, not a hub`, `up, kit ?`, `up, kit `, `up, kit (image)`; `` shown only if `/^[\w.+-]{1,64}$/`. Uses `http.get` (Host `127.0.0.1:`, `sec-fetch-site: same-origin`, 3 s timeout, body capped at 64 KB → `not a hub`). -- [ ] **Step 1: failing test** `test('status names the hub\'s kit, or says it is no hub, or down')`: `machine status --in ` is run with the async `execFile` (promisified), never `spawnSync`, since the fixture server lives in the test's own event loop; env `{ ...baseEnv }` with `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` deleted. One in-process `http.createServer` on `process.env.DEVTEAM_HUB_PORT` whose `/instances.json` body the test swaps per case, asserting the exact line: `{hub:1,kit:'v1.2.3',image:true,instances:[]}` → `up, kit v1.2.3 (image)`; `{hub:1,kit:'v1.2.3',instances:[]}` → `up, kit v1.2.3`; `{hub:1,instances:[]}`, `{hub:1,kit:null,instances:[]}`, `{hub:1,kit:'\u001b[31mx',instances:[]}`, `{hub:1,kit:'v'.repeat(10240),instances:[]}` → `up, kit ?`; `'ok'` and a 70 KB JSON body → `up, not a hub`; server closed → `DOWN`; `machine alert -- look` then → `up, kit v1.2.3; alert: look`, cleared with `machine alert -- --clear` in `finally`. +- [ ] **Step 1: failing test** `test('status names the hub\'s kit, or says it is no hub, or down')`: `machine status --in ` is run with the async `execFile` (promisified), never `spawnSync`, since the fixture server lives in the test's own event loop; env `{ ...baseEnv }` with `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` deleted. One in-process `http.createServer` on `process.env.DEVTEAM_HUB_PORT` whose `/instances.json` body the test swaps per case, asserting the exact line: `{hub:1,kit:'v1.2.3',image:true,instances:[]}` → `up, kit v1.2.3 (image)`; `{hub:1,kit:'v1.2.3',instances:[]}` → `up, kit v1.2.3`; `{hub:1,instances:[]}`, `{hub:1,kit:null,instances:[]}`, `{hub:1,kit:'\u001b[31mx',instances:[]}`, `{hub:1,kit:'v'.repeat(10240),instances:[]}` → `up, kit ?`; `'ok'` and a 70 KB JSON body → `up, not a hub`; back to the `v1.2.3` body, `machine alert -- look` → `up, kit v1.2.3; alert: look` (cleared with `machine alert -- --clear` in `finally`); then the server closed → `DOWN`. - [ ] **Step 2:** run → FAIL (`up` alone). - [ ] **Step 3:** implement `hubLine`, use it in `machineStatus`. - [ ] **Step 4:** the file through `gate` → PASS (the existing DOWN assertions unchanged). @@ -105,7 +105,9 @@ ## Rulings and notes +- Plan review 2 (CHANGES 013ae42): P2-1..P2-5 fixed (a port per hub, listed; T3's hub spawned with its own env; T4's alert before the close; T2's hex run named; cleanup skips an exited child), and the size re-checked after SIGTERM. - Plan review 1 (CHANGES 36f1b24): P1-1..P1-12 fixed in the steps above (async status run, `ownKitVersion` name, a log that must rotate, a port per hub, the hex run, T3's in-block port, retries counted from the first line, a project per T2 run, the rejection wording, T4's extra cases, waits, group kill). - Spec review 2 (APPROVE 2ce8e94), F2-1: the spec's own-kit guard for `kit` applies to the standalone hub only; a dashboard's hub reports `kitVersion()` unguarded, as done-when 1 says. F2-2: the test's kit copy lives outside the hub's cwd. +- m-20261114-215021941-supervisor: done-when 6 on the box means no test that passes at main fails at this head there (baseline: `baseline-box.txt`); CI (test.yml, every job) is the full gate; the `onBox()` fix is kit-200-tests-isolated-on-box, not here. - m-20261114-214804017-supervisor: run kit tests with the session's devteam env removed (above); don't change the tests for it here. - m-20261114-214554948-supervisor: spec approved; records in the ticket folder don't count as added files (done-when 7 is checked outside `docs/devteam-dev/`). diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-2.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-2.md new file mode 100644 index 0000000..efffef3 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-2.md @@ -0,0 +1,27 @@ +# Plan review, pass 2 + +- Reviewer: plan-confirmer (fresh subagent, claude-opus) +- Reviewed: 013ae420766462b1a494f2a738400315d2dc44ab (plan.md, diff since 36f1b24) + +- P1-1 fixed: T4 runs `machine status` with a promisified async `execFile`, and the fixture server stays in the test's own event loop. +- P1-2 fixed: the new helper is `ownKitVersion()`. That name does not occur in devteam.mjs, so it cannot clash. +- P1-3 fixed: the log is pre-filled to 1048570 bytes, so the first line must rotate, and `.1` is asserted with those bytes. The plan does not check the size again after the SIGTERM line. Spec check 3 does not ask for that, so this is not blocking. +- P1-4 fixed in substance: each hub has its own port and the test waits on that port, so no earlier hub can answer for a later one. The extra log-line assertions are not added. Spec check 1 does not require them. +- P1-5 fixed: Step 1 run (d) uses `0x`. That value is equal to the hub's own port but is not a plain port, so the test fails if the regex is missing. The Interfaces line is stale (P2-4). +- P1-6 partly fixed: `PORTS+36` is inside the block and outside the hub's own scan (+60..+84). The step still spawns the hub with `M.env` (P2-2). +- P1-7 fixed: the count starts at the first `isn't a devteam hub` line, then waits 2 s (at least 8 retries at 200 ms). A logger that writes on every retry fails the test. +- P1-8 fixed: each run has a fresh bareProject, its own hub port (+26..+29) and a stopped dashboard. The extra `hub on` assertion is not added. It is not needed, because the port is fresh. +- P1-9 fixed: both crash strings are kept exactly. They match devteam.mjs line 13764. +- P1-10 fixed: the 10 KB `kit`, the over-64 KB body and `kit: null` cases are added, the alert is cleared in `finally`, and the three box vars are deleted from a copy of `baseEnv`. The case order is a new issue (P2-3). +- P1-11 fixed: the test waits until the list answers `kit: 'v9.9.9'` before it starts the dashboard. +- P1-12 fixed: the helper sends SIGKILL to the process group and awaits `exit`. The guard is a new issue (P2-5). + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| P2-1 | Important | test/lib/ports.mjs ("a port one test binds should be one no test before it in that file can still be holding"); T1 Step 1's own rule "never reused within the file"; done-when 3 | T1 Step 1, port list | The plan names five ports (`+40, +36, +37, +38, +39`, "in the order below"), but the tests below start seven standalone hubs: test 1 starts one, test 2 starts three (no release, `''`, `nope`), the retry test starts two (holder 1, then holder 2 "on a new port"), and the 1 MB test starts one. Holder 2's hub and the 1 MB test's hub have no port, so the rule P1-4 relies on cannot be followed as written. The helper env no longer sets `DEVTEAM_HUB_PORT`, so the dashboard-hub test's port is unstated too. Name every port. Free in-block candidates below the +41..+65 scan, clear of T2's +26..+29, are +30..+35. | fixed | +| P2-2 | Important | P1-6; test/lib/ports.mjs (a bound port must be in the file's block); a step that contradicts itself | T3 Step 1 | The step first defines `env = { ...M.env, DEVTEAM_HUB_PORT: String(PORTS+36) }` "used for the hub", then says to spawn the hub "with cwd `nowhere` and `M.env` minus the three box vars". `M.env` carries `M.hub` = `PORTS+80+n`. That is the 21st `machine()` here, so +101, the neighbouring block, which is what P1-6 was about. Say the hub spawns with `env` (minus the box vars, plus `DEVTEAM_PORT_BASE=PORTS+60`), and that `own`'s origin and every fetch use `PORTS+36`. Also, `M.store()` is a closure over the box's `home/operator` and reads no env, so drop "and for `M.store()`'s reads". | fixed | +| P2-3 | Minor | a step that is ambiguous; done-when 5 | T4 Step 1, case order | The cases run "server closed → `DOWN`; `machine alert -- look` then → `up, kit v1.2.3; alert: look`". Once the server is closed, the alert case cannot answer `up`. Either run the alert case before the close, or reopen the server with the `{hub:1,kit:'v1.2.3',instances:[]}` body. | fixed | +| P2-4 | Minor | P1-5; a stale interface | T2 Interfaces | The Interfaces still say "the T2 test adds `0x10E0`-style value as a fourth run". Read literally (`0x10E0` = 4320), that is the case P1-5 showed cannot fail. Change it to match Step 1(d): `0x${hubPort.toString(16)}`. | fixed | +| P2-5 | Suggestion | test hygiene (P1-12) | T1 helper `startHubCmd` | The 1 MB test SIGTERMs its hub, so by `t.after` the child has already exited. `process.kill(-pid, 'SIGKILL')` then throws ESRCH, and awaiting a later `exit` event never resolves, which hangs the test. Skip the kill and the wait when `exitCode !== null \|\| signalCode !== null`, and wrap the kill in try/catch. | fixed | + +Verdict: CHANGES 013ae420766462b1a494f2a738400315d2dc44ab From e4c60cc3f2471b84b801290d4435c3deaf4b9f7a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 21:53:55 +0000 Subject: [PATCH 08/24] kit-46: plan review 3 (approve) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/plan-3.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-3.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-3.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-3.md new file mode 100644 index 0000000..efef1c8 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/plan-3.md @@ -0,0 +1,16 @@ +# Plan review, pass 3 + +- Reviewer: plan-confirmer-3 (fresh subagent, claude-opus) +- Reviewed: 4ba60a40a92588f97881a13907e33ed2d7a598e2 (plan.md, diff since 013ae42) + +- P2-1 fixed: T1 Step 1 now gives all eight hubs a port, each used once in the file: +40; +36, +37, +38; +39 (the dashboard's hub); +30, +31 (holder 1 and holder 2); +32. None of them is +26..29 (T2) or inside the +41..65 scan. The only fixed ports the existing hub-overview tests bind are +60/+61.. and +90, so none of these collides with an earlier test. +- P2-2 fixed: T3 spawns the hub with its own `env` (`M.env` plus `DEVTEAM_HUB_PORT=PORTS+36` and `DEVTEAM_PORT_BASE=PORTS+60`, minus the three box vars), "never `M.env`". Its scan is +60..84, inside the block. The three box vars are `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE` and `DEVTEAM_BOX_TOOLS` (plan line 11), so the gateway vars, which make this a box, stay in. The new note on `M.store()` is correct: `machine()` builds `dir` as `home/operator` on a box and never reads a port for it. In this file, `hubFixture` binds only +30 and +31 (one dashboard), so +36 is free. P1-6 is now fully fixed. +- P2-3 fixed: T4 goes back to the `v1.2.3` body before the alert case and closes the server only after it, so `DOWN` is the last case. +- P2-4 fixed: the T2 Interfaces line now names the run (d) value, `0x`, as Step 1 does. +- P2-5 fixed: the cleanup skips both the kill and the wait when the child has already exited. The try/catch is not added. The plan does not need it for the case the finding raised, the 1 MB test's hub that already took SIGTERM. +- The diff adds two more things, and neither is a defect. The 1 MB test checks the size again after SIGTERM, which tightens spec check 3. The new Rulings line records a supervisor message, which this review takes as given. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| + +Verdict: APPROVE 4ba60a40a92588f97881a13907e33ed2d7a598e2 From 55b51e700a0fb08e0a25efe88466fce6b95e9977 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:09:45 +0000 Subject: [PATCH 09/24] kit-46 T1: devteam.mjs hub, the list's kit, the hub's log Co-Authored-By: Claude Opus 5.5 --- .../tickets/kit-46-standalone-hub/plan.md | 1 + docs/devteam/scripts/devteam.mjs | 83 ++++++++++-- test/hub-overview.test.mjs | 128 +++++++++++++++++- 3 files changed, 196 insertions(+), 16 deletions(-) diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md index 6a65e98..f96bf3d 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/plan.md @@ -105,6 +105,7 @@ ## Rulings and notes +- m-20261114-215435068-supervisor: plan approved. kit-46 depends on kit-201-tests-isolated-on-box, which lands first: merge main after it before the merge request. T3 keeps the check that an unsigned press from the hub's own page (`own` headers only) is refused. - Plan review 2 (CHANGES 013ae42): P2-1..P2-5 fixed (a port per hub, listed; T3's hub spawned with its own env; T4's alert before the close; T2's hex run named; cleanup skips an exited child), and the size re-checked after SIGTERM. - Plan review 1 (CHANGES 36f1b24): P1-1..P1-12 fixed in the steps above (async status run, `ownKitVersion` name, a log that must rotate, a port per hub, the hex run, T3's in-block port, retries counted from the first line, a project per T2 run, the rejection wording, T4's extra cases, waits, group kill). - Spec review 2 (APPROVE 2ce8e94), F2-1: the spec's own-kit guard for `kit` applies to the standalone hub only; a dashboard's hub reports `kitVersion()` unguarded, as done-when 1 says. F2-2: the test's kit copy lives outside the hub's cwd. diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 4b49f63..b4d5090 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -427,7 +427,7 @@ const homeAt = (() => { const root = common ? path.dirname(common) : null; const proj = root && path.join(root, 'docs', 'devteam', 'project'); // the machine's commands look after every project from anywhere, a folder that is no project included - const machineWide = ['machine', 'operator-thread'].includes(command) || (command === 'recover' && flags.machine); + const machineWide = ['machine', 'operator-thread', 'hub'].includes(command) || (command === 'recover' && flags.machine); if (proj && command && !machineWide && !['help', '--help', 'init'].includes(command) && !process.env.DEVTEAM_PROJECT && !flagStr('project') && !fs.existsSync(path.join(proj, 'settings.md')) && !(KIT_VIEWS.includes(command) && kitsOwnRepoAt(root))) die(`no ${path.relative(root, proj).split(path.sep).join('/')}/settings.md here (${root}): run init to adopt devteam in this repo, or run home's kit with DEVTEAM_HOME=`); return { common, proj: null }; } @@ -11725,9 +11725,9 @@ async function pickDashboardPort({ skip = [] } = {}) { // as soon as the child says it is done (its reply pipe, fd 3), before it types into a pane; what it // says on stderr after that goes to the log. A child that exits without replying (refused, or an // older script) is answered from its exit. -function runOwnerAction(args, { env, timeout = 0, input = null, log = () => {} } = {}) { +function runOwnerAction(args, { env, timeout = 0, input = null, log = () => {}, cwd = mainRoot } = {}) { return new Promise((resolve) => { - const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], { cwd: mainRoot, env: { ...env, DEVTEAM_REPLY_FD: '3' }, stdio: ['pipe', 'pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], { cwd, env: { ...env, DEVTEAM_REPLY_FD: '3' }, stdio: ['pipe', 'pipe', 'pipe', 'pipe'] }); let so = ''; let se = ''; let rp = ''; @@ -11763,7 +11763,9 @@ function ownerActionEnv(channel) { for (const k of ['TMUX_PANE', 'DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE']) delete env[k]; return env; } -// The hub: every dashboard tries to serve 127.0.0.1:HUB_PORT, and the one that gets it lists every +// The hub: every dashboard tries to serve 127.0.0.1:HUB_PORT (unless the box's image serves it: +// DEVTEAM_IMAGE_HUB, cmdDashboard), and so does `devteam.mjs hub` alone (cmdHub), which the box's boot +// starts from the image's kit. The one that gets it lists every // instance in the block by asking each port /api/hello: a tab per instance, with that instance's // own dashboard in a sandboxed frame on its own address. No proxy, its own Host only, a page that // fetches only from itself and frames only the block. It does nothing in any project: the page's @@ -11774,7 +11776,13 @@ function ownerActionEnv(channel) { // as a dashboard takes an owner action, each run as the owner's own command, which types the turn // into the operator's pane. The store is beside the operator, not in this process, so the hub still // moves when its dashboard stops: the others retry. Something that isn't a devteam hub on the -// port: logged once, and this dashboard stops trying. +// port: logged once, and a dashboard stops trying; the hub alone (standalone) keeps retrying whatever +// holds the port, and never exits for it. +// The list, /instances.json: { hub: 1, kit, image?, instances }. kit is the release serving the hub, +// fixed when it starts: hub --release's, with image: true (only the box's boot passes it, so no other +// hub can claim the image's release); else the running kit's own version (kitVersion()), or null where +// it has no project. No hub reads DEVTEAM_KIT_RELEASE: the box's ENV reaches every process there. +// The rule for the list: an additive change keeps hub: 1; anything else bumps it. const HUB_BODY_MAX = 20000; // The hub's owner actions, each the arguments of the owner's command or null: checked here before // anything is spawned, and by the command again. The owner's words always go after --, so words @@ -11791,8 +11799,12 @@ const HUB_ACTIONS = { 'thread-reopen': (b) => (OPERATOR_THREAD_ID.test(b.id ?? '') && (b.text === undefined || b.text === '' || hubTurnOk(b.text)) ? ['operator-thread', 'reopen', b.id, ...(b.text?.trim() ? ['--', b.text.trim()] : [])] : null), start: () => ['operator-thread', 'start'], }; -function startHub(log) { +function startHub(log, { kit = null, image = false, standalone = false } = {}) { const page = path.join(kitDir, 'dashboard', 'hub.html'); + // the hub alone runs its owner actions where it was started, as box-thread's run: on a box that is + // where the image's kit is no checkout; a dashboard's run in its project + const actionCwd = standalone ? process.cwd() : mainRoot; + const listHead = { hub: 1, kit, ...(image ? { image: true } : {}) }; const retry = Number(process.env.DEVTEAM_HUB_RETRY_MS ?? 30000); const ownHost = (h) => [`127.0.0.1:${HUB_PORT}`, `localhost:${HUB_PORT}`].includes(h ?? ''); // One scan at a time, its answer kept for 2 s: however often pages ask, the block is probed at @@ -11831,7 +11843,7 @@ function startHub(log) { let op = { at: 0, state: { state: 'unknown', can_start: false, box: onBox() } }; let asking = null; const askOperator = () => (asking ??= new Promise((resolve) => { - execFile(process.execPath, [fileURLToPath(import.meta.url), 'operator-thread', 'status', '--json'], { cwd: mainRoot, env: ownerEnv, timeout: 20000, maxBuffer: 65536 }, (err, stdout) => { + execFile(process.execPath, [fileURLToPath(import.meta.url), 'operator-thread', 'status', '--json'], { cwd: actionCwd, env: ownerEnv, timeout: 20000, maxBuffer: 65536 }, (err, stdout) => { try { const j = JSON.parse(stdout); if (!err && isObj(j) && typeof j.state === 'string') op = { at: Date.now(), state: { state: j.state, can_start: j.can_start === true, box: j.box === true, session: String(j.session ?? '') } }; } catch {} resolve(); }); @@ -11875,13 +11887,14 @@ function startHub(log) { try { const parsed = JSON.parse(raw.toString('utf8') || '{}'); args = isObj(parsed) ? act(parsed) : null; } catch { args = null; } if (args && args.some((a) => typeof a !== 'string' || a.includes('\0'))) args = null; if (!args) return send(400, 'application/json', JSON.stringify({ error: 'missing or invalid fields' })); - const r = await runOwnerAction(args, { env: proof ? { ...ownerEnv, DEVTEAM_THREAD_PROOF: 'stdin' } : ownerEnv, timeout: 120000, log, input: proof ? JSON.stringify(proof) : null }); + const r = await runOwnerAction(args, { env: proof ? { ...ownerEnv, DEVTEAM_THREAD_PROOF: 'stdin' } : ownerEnv, timeout: 120000, log, input: proof ? JSON.stringify(proof) : null, cwd: actionCwd }); // what the operator is at is asked afresh before the next read answers: a press may have // started it, and a read served from before the press would offer Start again op.at = 0; send(r.ok ? 200 : 400, 'application/json', JSON.stringify(r.ok ? { ok: true, out: r.stdout.trim() } : { error: r.stderr.split('\n').filter((l) => l && !l.startsWith('devteam: warning:')).join(' ').replace(/^devteam: /, '') || r.stdout.trim() || 'failed' })); }); }; + let held = null; const tryListen = () => { const hub = http.createServer(async (req, res) => { const send = (code, type, body, extra = {}) => { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...extra }); res.end(body); }; @@ -11898,7 +11911,7 @@ function startHub(log) { // anywhere, a link in a README included. const site = req.headers['sec-fetch-site']; if (req.url === '/instances.json' && site && !['same-origin', 'none'].includes(site)) return send(403, 'text/plain; charset=utf-8', 'the list is for the hub\'s own page'); - if (req.url === '/instances.json') return send(200, 'application/json', JSON.stringify({ hub: 1, instances: await scan() })); + if (req.url === '/instances.json') return send(200, 'application/json', JSON.stringify({ ...listHead, instances: await scan() })); // The operator's threads, for the Overview's dock: a GET carries no Origin, so it is refused // when the browser says another site's page asks (as a dashboard's /api/threads is). if (req.url === '/operator/threads') { @@ -11914,6 +11927,14 @@ function startHub(log) { return send(404, 'text/plain; charset=utf-8', 'not found'); }); hub.once('error', async (e) => { + if (standalone) { + // said once for each state, never at each retry; the timer keeps this process alive + const now = e.code === 'EADDRINUSE' ? await holder() : `error ${e.message}`; + if (now !== held && now === 'other') log(`hub: port ${HUB_PORT} is held by something that isn't a devteam hub; retrying every ${retry / 1000} s`); + else if (now !== held && now.startsWith('error ')) log(`hub: ${e.message}; retrying every ${retry / 1000} s`); + held = now; + return void setTimeout(tryListen, retry); + } if (e.code !== 'EADDRINUSE') return log(`hub: ${e.message}`); if ((await holder()) !== 'other') setTimeout(tryListen, retry).unref(); else log(`hub: port ${HUB_PORT} is held by something that isn't a devteam hub; this dashboard won't serve the hub`); @@ -13754,15 +13775,48 @@ function cmdAccountsTick() { fs.renameSync(tmp, ACCOUNTS_FILE); } +// Why a server stopped, in its own log: a crash with its stack, or the signal that ended it. +function processLog(log, name) { + process.on('uncaughtException', (e) => { log(`crashed: ${e?.stack ?? e}`); process.stderr.write(`devteam ${name} crashed: ${e?.stack ?? e}\n`); process.exit(1); }); + process.on('unhandledRejection', (e) => { log(`crashed (unhandled rejection): ${e?.stack ?? e}`); process.stderr.write(`devteam ${name} crashed: ${e?.stack ?? e}\n`); process.exit(1); }); + for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, () => { log(`stopped by ${sig}`); process.exit(0); }); +} +// The hub alone has no run/ of its own: its log is the machine's, named for the hub port, and never +// passes 1 MB (checked at each write, the full one moved to .1). Also on stderr, for its window. +function hubLogger() { + const file = path.join(os.homedir(), '.devteam', `hub-${HUB_PORT}.log`); + return (msg) => { + const line = `${nowIso()} [${process.pid}] ${msg}\n`; + process.stderr.write(line); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + if ((fs.statSync(file, { throwIfNoEntry: false })?.size ?? 0) + Buffer.byteLength(line) > 1024 * 1024) fs.renameSync(file, `${file}.1`); + fs.appendFileSync(file, line); + } catch {} + }; +} +// The running kit's version, when it is the project's own kit; null for a kit with no project (the +// image's), or one run against a project that isn't its checkout's (DEVTEAM_HOME, another repo's cwd). +const ownKitVersion = () => (fs.existsSync(path.join(projectDir, 'settings.md')) && !path.relative(mainRoot, kitDir).startsWith('..') ? kitVersion() : null); +// hub [--release vX.Y.Z]: the machine's hub and nothing else, the way the box's boot runs it from the +// image's kit (--release is the image's, and only the boot passes it). It stays up whatever holds the +// port, retrying, so a restart loop around it never spins; a bad --release is said and served without. +function cmdHub() { + const log = hubLogger(); + processLog(log, 'hub'); + const rel = flagStr('release') ?? (flags.release === true ? '' : undefined); + const ok = rel !== undefined && /^v\d+\.\d+\.\d+$/.test(rel); + if (rel !== undefined && !ok) log(`hub: --release ${JSON.stringify(rel)} is not vX.Y.Z; serving with no release`); + startHub(log, ok ? { kit: rel, image: true, standalone: true } : { kit: ownKitVersion(), standalone: true }); +} + async function cmdDashboard() { const picked = await pickDashboardPort(); let port = picked.port; const host = flagStr('host') ?? '127.0.0.1'; const log = dashboardLogger(); if (picked.warn) { log(picked.warn); process.stderr.write(`devteam dashboard: ${picked.warn}\n`); } - process.on('uncaughtException', (e) => { log(`crashed: ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard crashed: ${e?.stack ?? e}\n`); process.exit(1); }); - process.on('unhandledRejection', (e) => { log(`crashed (unhandled rejection): ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard crashed: ${e?.stack ?? e}\n`); process.exit(1); }); - for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, () => { log(`stopped by ${sig}`); process.exit(0); }); + processLog(log, 'dashboard'); const page = path.join(kitDir, 'dashboard', 'index.html'); // The page's policy, at / and at /view/: its frames are its own pages and, where a preview's lens // is shown, exactly the origins that serve it (stageFrames, previewStage); only it and the hub frame it. @@ -14616,7 +14670,7 @@ async function cmdDashboard() { log(`server error: ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard: ${e.message}\n`); process.exit(1); }); keepAlive(server); - server.once('listening', () => { log(`started on http://${host}:${port} (node ${process.version})`); out(`devteam dashboard on http://${host}:${port}`); startHub(log); setInterval(accountsTick, accountsEveryMs()).unref(); }); + server.once('listening', () => { log(`started on http://${host}:${port} (node ${process.version})`); out(`devteam dashboard on http://${host}:${port}`); startHub(log, { kit: kitVersion() }); setInterval(accountsTick, accountsEveryMs()).unref(); }); server.listen(port, host); } @@ -19065,6 +19119,7 @@ The log Dashboard and releases dashboard [--port p] [--host h] | dashboard-data | notes [] [--repo ] + hub [--release vX.Y.Z] the machine's hub alone, on 127.0.0.1:4320, retrying while anything holds it; logs to ~/.devteam/hub-.log (the box's boot runs it from the image's kit with the image's release) `; // What the box's operator may run: reads only (a test runs each and diffs the log and git status). A @@ -19084,7 +19139,7 @@ const COMMANDS = { goal: cmdGoal, retro: cmdRetro, chore: cmdChore, log: cmdLog, record: cmdRecord, peek: cmdPeek, sensor: cmdSensor, 'log-check': cmdLogCheck, remediate: cmdRemediate, 'accounts-tick': cmdAccountsTick, 'supervisor-session': cmdSupervisorSession, recover: cmdRecover, machine: cmdMachine, 'operator-thread': cmdOperatorThread, dispose: cmdDispose, 'log-sync': cmdLogSync, history: cmdHistory, drain: cmdDrain, undrain: cmdUndrain, fleet: cmdFleet, handoff: cmdHandoff, wait: cmdWait, - dashboard: cmdDashboard, 'dashboard-data': cmdDashboardData, notes: cmdNotes, + dashboard: cmdDashboard, 'dashboard-data': cmdDashboardData, notes: cmdNotes, hub: cmdHub, }; if (!command || command === 'help' || command === '--help') out(HELP); else if (!COMMANDS[command]) die(`unknown command "${command}"\n${HELP}`); diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 8dbcf53..c21d967 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -6,8 +6,8 @@ import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; -import { execFileSync, spawnSync } from 'node:child_process'; -import { PORTS, bareProject, fleetFixture, json, line, startDashboard, until } from './lib/kit.mjs'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { PORTS, bareProject, fleetFixture, json, kit, line, startDashboard, until } from './lib/kit.mjs'; import { canDrive, openChrome } from './lib/chrome.mjs'; const SUP = { DEVTEAM_ROLE: 'supervisor' }; @@ -218,3 +218,127 @@ test('the Overview in Chrome: Park by keyboard, Park all and Resume all through await p.ev(`[...document.querySelectorAll('iframe')].find((f) => f.title === 'beta').contentWindow.postMessage(${ask('aaaaaaaa05')}, 'http://127.0.0.1:${P + 1}')`); await until(() => mode(B) === 'park', 'beta parked by its parent, the hub'); }); + +// ----- the hub on its own (devteam.mjs hub), as the box's boot starts it from the image's kit ---- +// The image's kit, laid out as the box has it at /opt/devteam/kit: a copy in a folder that is no git +// repo, so the hub's checkout is its cwd, exactly as there. A box's ENV reaches every process on it, +// these tests included: each hub and dashboard here gets the three variables only where it means to. +const BOX_VARS = ['DEVTEAM_IMAGE_HUB', 'DEVTEAM_KIT_RELEASE', 'DEVTEAM_BOX_TOOLS']; +function imageKit(t) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-image-kit-')); + t.after(() => fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + fs.cpSync(kit, d, { recursive: true, filter: (p) => !/[\\/](project|run)([\\/]|$)/.test(p.slice(kit.length)) }); + return path.join(d, 'scripts', 'devteam.mjs'); +} +// A machine of its own: a home, a folder to run in, and the env (hub port as given). +function hubMachine(t, name, hubPort, extra = {}) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), `devteam-${name}-`)); + t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + const home = path.join(base, 'home'); + const cwd = path.join(base, 'cwd'); + fs.mkdirSync(home); + fs.mkdirSync(cwd); + const env = { ...process.env, HOME: home, DEVTEAM_HUB_PORT: String(hubPort), DEVTEAM_PORT_BASE: String(PORTS + 41), DEVTEAM_HUB_RETRY_MS: '200', CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + for (const k of [...BOX_VARS, 'DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; + Object.assign(env, extra); + return { base, home, cwd, env, log: path.join(home, '.devteam', `hub-${hubPort}.log`) }; +} +// `devteam.mjs hub`, in a process group of its own, ended (and waited for) after the test. +function startHubCmd(t, script, cwd, env, args = []) { + const p = spawn(process.execPath, [script, 'hub', ...args], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + let o = ''; + p.stdout.on('data', (b) => { o += b; }); + p.stderr.on('data', (b) => { o += b; }); + const alive = () => p.exitCode === null && p.signalCode === null; + t.after(() => (alive() ? new Promise((r) => { p.once('exit', r); try { process.kill(-p.pid, 'SIGKILL'); } catch { r(); } }) : undefined)); + return { p, alive, out: () => o }; +} +const hubList = async (port) => { try { const r = await fetch(`http://127.0.0.1:${port}/instances.json`); return r.status === 200 ? await r.json() : null; } catch { return null; } }; +const readOr = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } }; +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); + +test('hub --release, from a folder that is no repo: the list with the image\'s release, and nothing written there', { timeout: 240000 }, async (t) => { + const HUB = PORTS + 40; + const M = hubMachine(t, 'hub-alone', HUB); + const h = startHubCmd(t, imageKit(t), M.cwd, M.env, ['--release', 'v9.9.9']); + await until(async () => (await hubList(HUB))?.kit === 'v9.9.9', `the image's hub (${h.out()})`); + const A = bareProject(M.base, 'alpha'); + execFileSync('sh', ['-c', 'git add -A && git -c user.email=t@t -c user.name=t commit -qm init'], { cwd: A }); + const d = startDashboard(A, M.env); + t.after(() => d.stop()); + const list = await until(async () => { const l = await hubList(HUB); return l?.instances?.some((x) => x.root === fs.realpathSync(A)) ? l : null; }, 'alpha listed by the image\'s hub'); + assert.deepEqual([list.hub, list.kit, list.image], [1, 'v9.9.9', true]); + assert.ok(h.alive(), 'still serving'); + assert.equal(fs.existsSync(path.join(M.cwd, 'docs')), false, 'nothing written under its cwd, no docs/devteam/run'); + assert.deepEqual(fs.readdirSync(M.cwd), []); +}); + +test('hub without --release names no release, whatever DEVTEAM_KIT_RELEASE says; a bad --release serves anyway', { timeout: 120000 }, async (t) => { + const script = imageKit(t); + for (const [port, args] of [[PORTS + 36, []], [PORTS + 37, ['--release', '']], [PORTS + 38, ['--release', 'nope']]]) { + const M = hubMachine(t, 'hub-norel', port, { DEVTEAM_KIT_RELEASE: 'v9.9.9' }); + const h = startHubCmd(t, script, M.cwd, M.env, args); + const list = await until(() => hubList(port), `the hub on ${port} (${args.join(' ')})`); + assert.equal(list.hub, 1); + assert.equal(list.kit, null, `kit null for ${JSON.stringify(args)}`); + assert.equal('image' in list, false, `no image field for ${JSON.stringify(args)}`); + await pause(1000); + assert.ok(h.alive(), `still up a second later (${JSON.stringify(args)}): ${h.out()}`); + } +}); + +test('a dashboard\'s hub names its project\'s kit and no image, whatever DEVTEAM_KIT_RELEASE says', { timeout: 240000 }, async (t) => { + const HUB = PORTS + 39; + const M = hubMachine(t, 'hub-dash-kit', HUB, { DEVTEAM_KIT_RELEASE: 'v9.9.9' }); + const A = bareProject(M.base, 'alpha'); + execFileSync('sh', ['-c', 'git add -A && git -c user.email=t@t -c user.name=t commit -qm init'], { cwd: A }); + const d = startDashboard(A, M.env); + t.after(() => d.stop()); + const list = await until(() => hubList(HUB), "the dashboard's hub"); + const hello = json(spawnSync(process.execPath, [path.join(A, 'docs/devteam/scripts/devteam.mjs'), 'hello', '--json'], { cwd: A, env: M.env, encoding: 'utf8' }).stdout); + assert.equal(typeof list.kit, 'string'); + assert.equal(list.kit, hello.kit); + assert.notEqual(list.kit, 'v9.9.9'); + assert.equal('image' in list, false); +}); + +test('the hub waits out another hub and a non-hub, logging the non-hub once, and takes the port', { timeout: 120000 }, async (t) => { + const script = imageKit(t); + const holder = (port, body) => new Promise((r) => { const s = http.createServer((q, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(body); }); s.listen(port, '127.0.0.1', () => r(s)); }); + // another hub holds it: the image's hub stays up, retrying, and takes it when that one goes + const A = PORTS + 30; + const M1 = hubMachine(t, 'hub-wait', A); + let s = await holder(A, JSON.stringify({ hub: 1, instances: [] })); + const h1 = startHubCmd(t, script, M1.cwd, M1.env, ['--release', 'v9.9.9']); + await pause(1500); + assert.ok(h1.alive(), `still up behind another hub: ${h1.out()}`); + assert.equal((await hubList(A)).kit, undefined, 'the holder answers, not the image\'s hub'); + await new Promise((r) => s.close(r)); + await until(async () => (await hubList(A))?.kit === 'v9.9.9', 'the image\'s hub on the port it waited for'); + // something that isn't a hub: said once, however many retries, and the port taken once it frees + const B = PORTS + 31; + const M2 = hubMachine(t, 'hub-wait-other', B); + s = await holder(B, 'ok'); + const h2 = startHubCmd(t, script, M2.cwd, M2.env, ['--release', 'v9.9.9']); + await until(() => readOr(M2.log).includes("isn't a devteam hub"), `the non-hub logged (${h2.out()})`); + await pause(2000); + assert.ok(h2.alive(), `still up behind a non-hub: ${h2.out()}`); + assert.equal(readOr(M2.log).split('\n').filter((l) => l.includes("isn't a devteam hub")).length, 1, 'once, not at every retry'); + await new Promise((r) => s.close(r)); + await until(async () => (await hubList(B))?.kit === 'v9.9.9', 'the image\'s hub once the non-hub went'); +}); + +test('the hub\'s log stays under 1 MB and records a SIGTERM', { timeout: 120000 }, async (t) => { + const HUB = PORTS + 32; + const M = hubMachine(t, 'hub-log', HUB); + fs.mkdirSync(path.dirname(M.log), { recursive: true }); + fs.writeFileSync(M.log, 'x'.repeat(1048570)); + const h = startHubCmd(t, imageKit(t), M.cwd, M.env, ['--release', 'v9.9.9']); + await until(() => readOr(M.log).includes('hub on http://'), `the hub's start line (${h.out()})`); + assert.equal(fs.statSync(`${M.log}.1`).size, 1048570, 'the full log moved aside'); + assert.ok(fs.statSync(M.log).size <= 1048576); + process.kill(h.p.pid, 'SIGTERM'); + await until(() => readOr(M.log).includes('stopped by SIGTERM'), 'the signal logged'); + assert.ok(fs.statSync(M.log).size <= 1048576); +}); + From 0d9fb7cf1df7e12dd9f655aa6e7bc0c7aafda3dc Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:12:50 +0000 Subject: [PATCH 10/24] kit-46 T2: dashboards leave the hub to the image Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 6 +++++- test/hub-overview.test.mjs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index b4d5090..0121362 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -13810,6 +13810,10 @@ function cmdHub() { startHub(log, ok ? { kit: rel, image: true, standalone: true } : { kit: ownKitVersion(), standalone: true }); } +// The port the box's image serves its own hub on (ENV DEVTEAM_IMAGE_HUB), a plain port or nothing. A +// dashboard whose hub port it names leaves the hub to the image; a project still on an older kit can +// take the port in the second the image's hub restarts, until it adopts this release. +const imageHub = () => (/^\d+$/.test(process.env.DEVTEAM_IMAGE_HUB ?? '') ? Number(process.env.DEVTEAM_IMAGE_HUB) : null); async function cmdDashboard() { const picked = await pickDashboardPort(); let port = picked.port; @@ -14670,7 +14674,7 @@ async function cmdDashboard() { log(`server error: ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard: ${e.message}\n`); process.exit(1); }); keepAlive(server); - server.once('listening', () => { log(`started on http://${host}:${port} (node ${process.version})`); out(`devteam dashboard on http://${host}:${port}`); startHub(log, { kit: kitVersion() }); setInterval(accountsTick, accountsEveryMs()).unref(); }); + server.once('listening', () => { log(`started on http://${host}:${port} (node ${process.version})`); out(`devteam dashboard on http://${host}:${port}`); if (imageHub() === HUB_PORT) log(`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})`); else startHub(log, { kit: kitVersion() }); setInterval(accountsTick, accountsEveryMs()).unref(); }); server.listen(port, host); } diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index c21d967..72d73e7 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -4,6 +4,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import http from 'node:http'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { execFileSync, spawn, spawnSync } from 'node:child_process'; @@ -342,3 +343,33 @@ test('the hub\'s log stays under 1 MB and records a SIGTERM', { timeout: 120000 assert.ok(fs.statSync(M.log).size <= 1048576); }); +test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port', { timeout: 480000 }, async (t) => { + const left = (port) => `hub: left to the image (DEVTEAM_IMAGE_HUB=${port})`; + const listens = (port) => new Promise((r) => { const c = net.connect({ port, host: '127.0.0.1' }); c.once('connect', () => { c.destroy(); r(true); }); c.once('error', () => r(false)); }); + // the box's own case, the image naming this dashboard's hub port: the port left alone, and said once + const run = async (hubPort, value) => { + const M = hubMachine(t, 'hub-image', hubPort, value === undefined ? {} : { DEVTEAM_IMAGE_HUB: value }); + const A = bareProject(M.base, 'alpha'); + execFileSync('sh', ['-c', 'git add -A && git -c user.email=t@t -c user.name=t commit -qm init'], { cwd: A }); + const d = startDashboard(A, M.env); + try { + await until(() => /dashboard on http/.test(d.out()), 'the dashboard'); + return { d, logOf: () => readOr(path.join(A, 'docs/devteam/run/dashboard.log')) }; + } catch (e) { await d.stop(); throw e; } + }; + const a = await run(PORTS + 26, String(PORTS + 26)); + try { + await until(() => a.logOf().includes(left(PORTS + 26)), 'the line saying so'); + await pause(1000); + assert.equal(await listens(PORTS + 26), false, 'nothing listens on the hub port'); + assert.equal(a.logOf().split('\n').filter((l) => l.endsWith(left(PORTS + 26))).length, 1, 'said exactly once'); + } finally { await a.d.stop(); } + // unset, the real box's 4320 while this hub is a fixture's, or a number that isn't a plain port + for (const [port, value] of [[PORTS + 27, undefined], [PORTS + 28, '4320'], [PORTS + 29, `0x${(PORTS + 29).toString(16)}`]]) { + const r = await run(port, value); + try { + await until(async () => (await hubList(port))?.hub === 1, `its hub on ${port} (DEVTEAM_IMAGE_HUB=${value})`); + assert.doesNotMatch(r.logOf(), /left to the image/, `served as before with DEVTEAM_IMAGE_HUB=${value}`); + } finally { await r.d.stop(); } + } +}); From f57715b8e1ad49be80bc2464e7ba0276ccec91db Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:14:17 +0000 Subject: [PATCH 11/24] kit-46 T3: the press checks, against the standalone hub Co-Authored-By: Claude Opus 5.5 --- test/operator-threads.test.mjs | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/operator-threads.test.mjs b/test/operator-threads.test.mjs index f326469..3cf29d5 100644 --- a/test/operator-threads.test.mjs +++ b/test/operator-threads.test.mjs @@ -932,3 +932,52 @@ test('a start\'s note from a kit that did not name its tmux server is never take assert.equal(a.keys(), '', 'nothing typed into another session\'s pane'); } finally { tmuxSh(['kill-session', '-t', `=${other}`]); } }); + +// The hub alone (devteam.mjs hub), as the box's boot runs it: from a copy of the kit in a folder that +// is no git repo, as /opt/devteam/kit is, started in a folder that is no project. It is the same +// server a dashboard's hub is, so it takes a press on exactly the same terms; this tries to break them. +test('on a box the standalone hub takes a press only as the gateway signed it, once, from the hub\'s own page; a signed one reaches the operator', { skip: !hasTmux, timeout: 240000 }, async (t) => { + const M = machine({ box: true }); + const HUB = PORTS + 36; + const env = { ...M.env, DEVTEAM_HUB_PORT: String(HUB), DEVTEAM_PORT_BASE: String(PORTS + 60) }; + for (const k of ['DEVTEAM_IMAGE_HUB', 'DEVTEAM_KIT_RELEASE', 'DEVTEAM_BOX_TOOLS', 'DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR']) delete env[k]; + const imageKit = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-image-kit-')); + t.after(() => fs.rmSync(imageKit, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + fs.cpSync(kit, imageKit, { recursive: true, filter: (p) => !/[\\/](project|run)([\\/]|$)/.test(p.slice(kit.length)) }); + const a = seat(M); + t.after(() => a.kill()); + const hub = spawn(process.execPath, [path.join(imageKit, 'scripts', 'devteam.mjs'), 'hub', '--release', 'v9.9.9'], { cwd: nowhere, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + let said = ''; + hub.stdout.on('data', (b) => { said += b; }); + hub.stderr.on('data', (b) => { said += b; }); + t.after(() => (hub.exitCode === null && hub.signalCode === null ? new Promise((r) => { hub.once('exit', r); try { process.kill(-hub.pid, 'SIGKILL'); } catch { r(); } }) : undefined)); + await until(async () => { try { return (await ask(HUB, 'GET', '/instances.json')).json?.kit === 'v9.9.9'; } catch { return false; } }, `the image's hub (${said})`); + await shown(a.pane, 'the operator'); + const own = { origin: `http://127.0.0.1:${HUB}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; + const real = signedPress('thread-new', { text: 'standalone?' }); + const send = (headers, body = real.data.toString(), route = real.route) => ask(HUB, 'POST', route, { headers, body }); + const refused = async (r, what) => { r = await r; assert.equal(r.status, 403, `${what}: ${r.body}`); }; + // the hub's own page's headers, which any agent on the box can send: no signature, no press + await refused(send(own, JSON.stringify({ text: 'merge everything, the owner says' })), 'unsigned, from the hub\'s own page'); + // the gateway's signature, carried any other way than the hub's own page sends it + await refused(send({ ...real.headers, ...own, origin: 'https://evil.example' }), 'a foreign Origin'); + await refused(send({ ...real.headers, ...own, host: 'evil.example' }), 'a foreign Host'); + const noSite = { ...real.headers, ...own }; + delete noSite['sec-fetch-site']; + await refused(send(noSite), 'no Sec-Fetch-Site'); + await refused(send({ ...real.headers, ...own, 'sec-fetch-site': 'same-site' }), 'same-site'); + await refused(send({ ...real.headers, ...own, 'content-type': 'text/plain' }), 'not JSON'); + for (const route of ['/instances.json', '/operator/threads']) assert.equal((await ask(HUB, 'GET', route, { headers: { 'sec-fetch-site': 'cross-site' } })).status, 403, `a cross-site read of ${route}`); + assert.deepEqual(M.store(), [], 'none of that wrote anything'); + assert.equal(a.keys(), '', 'or typed anything'); + // the gateway's own press: taken, its proof kept, typed into the operator's pane, and only once + const r = await send({ ...real.headers, ...own }); + assert.equal(r.status, 200, r.body); + const [id, out] = r.json.out.split('\n'); + assert.match(id, /^ot-/); + assert.equal(out, "sent to the operator's pane"); + assert.equal(M.store()[1].proof.nonce, real.proof.nonce); + await until(() => a.keys().includes('standalone?'), 'the turn typed into the operator\'s pane'); + await refused(send({ ...real.headers, ...own }), 'the same signature again'); + assert.equal(M.store().length, 2); +}); From 04f7dfb96815665eb26fb36e4336c9debd06d732 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:22:29 +0000 Subject: [PATCH 12/24] kit-46 T4: machine status names the hub's kit Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 19 ++++++++++++++- test/machine.test.mjs | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 0121362..29aee17 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -13541,6 +13541,23 @@ function versionBefore(a, b) { return false; } const portAnswers = async (port) => { try { await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(3000) }); return 'up'; } catch { return 'DOWN'; } }; +// What serves the hub, from its list as the hub's own page asks for it: the release serving it (the +// image's, marked), `kit ?` from a hub too old to say, `not a hub`, or DOWN. The release comes off the +// network and goes to a terminal, so it is printed only in a release's own characters. +const hubLine = (port) => new Promise((resolve) => { + const r = http.get({ host: '127.0.0.1', port, path: '/instances.json', timeout: 3000, headers: { host: `127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin' } }, (res) => { + let b = ''; + res.on('data', (c) => { b += c; if (b.length > 65536) { res.destroy(); resolve('up, not a hub'); } }); + res.on('end', () => { + let j = null; + try { j = JSON.parse(b); } catch {} + if (j?.hub !== 1) return resolve('up, not a hub'); + resolve(typeof j.kit === 'string' && /^[\w.+-]{1,64}$/.test(j.kit) ? `up, kit ${j.kit}${j.image === true ? ' (image)' : ''}` : 'up, kit ?'); + }); + }); + r.on('timeout', () => { r.destroy(); resolve('DOWN'); }); + r.on('error', () => resolve('DOWN')); +}); const gb = (k) => `${Math.round(k / 1048576)}G`; const readNum = (f) => { const t = readText(f)?.trim(); return t && /^\d+$/.test(t) ? Number(t) : null; }; // The machine's memory in bytes, { inUse, max, source }, or null when nothing says (macOS: no cgroup @@ -13613,7 +13630,7 @@ async function machineStatus() { out(acc ? `machine: accounts ${acc.cswap ? 'cswap' : 'no-cswap'} active=${acc.active ?? '?'} exhausted=${acc.exhausted ?? false} no-room=${acc.active_no_room ?? false} needs-login=${(Array.isArray(acc.needs_login) ? acc.needs_login.join(',') : '') || '-'} as of ${acc.at}` : 'machine: accounts ? (no dashboard has recorded any)'); // a hub that doesn't answer and each project's dashboard: nothing else says they went down const alert = readText(path.join(os.homedir(), '.box', 'alert'))?.trim(); - out(`machine: hub ${HUB_PORT} ${await portAnswers(HUB_PORT)}${alert ? `; alert: ${alert}` : ''}`); + out(`machine: hub ${HUB_PORT} ${await hubLine(HUB_PORT)}${alert ? `; alert: ${alert}` : ''}`); const notes = notedIn(); if (flagStr('notes')) { const r = lastReview(notes); out(r === utcDay() ? `machine: daily review done ${r}` : `machine: daily review due (last ${r ?? 'never'})`); } const flagged = []; diff --git a/test/machine.test.mjs b/test/machine.test.mjs index 714aa0c..0fbaf18 100644 --- a/test/machine.test.mjs +++ b/test/machine.test.mjs @@ -5,6 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFile, execFileSync, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { hasTmux, kit, testDir, until, write } from './lib/kit.mjs'; @@ -662,3 +663,44 @@ test('a pane noted moments ago counts as the supervisor starting only if it star assert.match(dry(), new RegExp(`^would resume session ${SID}`), 'and only for a few minutes'); } finally { tmuxSh(['kill-pane', '-t', elsewhere]); tmuxSh(['kill-pane', '-t', atRoot]); } }); + +// The hub line names the release serving the hub, from its list: the image's own, a project kit's, +// one too old to say, something that isn't a hub, or nothing at all. Run without blocking this +// process, whose event loop serves the stand-in hub. +test('status names the hub\'s kit, or says it is no hub, or down', async () => { + const port = Number(process.env.DEVTEAM_HUB_PORT); + const env = { ...baseEnv }; + for (const k of ['DEVTEAM_IMAGE_HUB', 'DEVTEAM_KIT_RELEASE', 'DEVTEAM_BOX_TOOLS']) delete env[k]; + const empty = fs.mkdtempSync(path.join(tmp, 'no-repos-')); + const hubLineOf = () => new Promise((resolve) => execFile(process.execPath, [KIT, 'machine', 'status', '--in', empty], { cwd: nowhere, encoding: 'utf8', env }, (err, stdout) => resolve(stdout.split('\n').find((l) => l.startsWith('machine: hub '))))); + let body = ''; + const srv = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(req.url === '/instances.json' ? body : 'not found'); }); + await new Promise((r) => srv.listen(port, '127.0.0.1', r)); + let open = true; + try { + const cases = [ + [{ hub: 1, kit: 'v1.2.3', image: true, instances: [] }, 'up, kit v1.2.3 (image)'], + [{ hub: 1, kit: 'v1.2.3', instances: [] }, 'up, kit v1.2.3'], + [{ hub: 1, instances: [] }, 'up, kit ?'], + [{ hub: 1, kit: null, instances: [] }, 'up, kit ?'], + [{ hub: 1, kit: '\u001b[31mx', instances: [] }, 'up, kit ?'], + [{ hub: 1, kit: 'v'.repeat(10240), instances: [] }, 'up, kit ?'], + ['ok', 'up, not a hub'], + [{ hub: 1, kit: 'v1.2.3', instances: [], pad: 'x'.repeat(70 * 1024) }, 'up, not a hub'], + ]; + for (const [b, want] of cases) { + body = typeof b === 'string' ? b : JSON.stringify(b); + assert.equal(await hubLineOf(), `machine: hub ${port} ${want}`, body.slice(0, 80)); + } + body = JSON.stringify({ hub: 1, kit: 'v1.2.3', instances: [] }); + machine(['machine', 'alert', '--', 'look']); + assert.equal(await hubLineOf(), `machine: hub ${port} up, kit v1.2.3; alert: look`, 'the alert suffix as it was'); + machine(['machine', 'alert', '--', '--clear']); + await new Promise((r) => srv.close(r)); + open = false; + assert.equal(await hubLineOf(), `machine: hub ${port} DOWN`); + } finally { + machine(['machine', 'alert', '--', '--clear']); + if (open) srv.close(); + } +}); From c54471ae304854cf5ecf61d321b9e9ba9442cd77 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:22:40 +0000 Subject: [PATCH 13/24] kit-46 T5: README, the image's hub Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 427e698..2864c90 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ docs/devteam/ **What matters most.** The supervisor judges each goal's impact against the project's strategy and rates every question it asks (how much rides on the answer, how soon, and which other questions an answer would settle). The kit counts the rest (tickets held up, a default) and orders the Inbox from it, with the next best step on top and the reason on every card; `devteam.mjs next` prints the same. The order is computed on every read and stored nowhere. The owner corrects a goal's impact with their own value, and it stands. -**Many projects, one machine.** Each project has its own tmux session (`devteam--<6 hex of its path>`, saved in `run/tmux-session`) and its own dashboard port: the next free one in 4321–4345, saved in `run/dashboard-port` and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. It opens on its **Overview** tab: a card per project saying whether it is building, moving, fully blocked on your answers (or partly: some tickets wait, the rest can move), stalled, idle or parked, with its kit version (amber when behind the newest one running here), Inbox count, workers, the goal it is on, tickets merged of planned, what needs you, and twelve weeks of commits on main (in every repo the project spans), laid out like GitHub's contribution graph. Each card has **Run**, **Drain** and **Park** for that project's fleet, and the page has **Park all** and **Resume all**; Park asks twice. The hub's server does nothing in any project: for Run, Drain and Park the page asks that project's dashboard, in its frame, and the dashboard does it as a click there would, for its parent page only. The Overview also has the dashboard's **Threads** dock, and there every thread is with the machine's **operator** (`process/operator.md`), the session that looks after the machine and not a repo: start one from the dock, or hold Shift and click a card, its name, a stat, a line that needs you or the headline (**Pin** does the same for a finger), and a numbered pin stays on what it is about. This is the one thing the hub's server keeps and the one thing it does: the threads are in `~/.devteam/operator/threads.jsonl` (on a box, `~/operator/threads.jsonl`), each of your turns is typed into the operator's session as one line marked as yours by way of the hub, and the operator answers with `operator-thread reply`, which the open card shows as text. On a laptop there is no operator until you write to one: your first message, or **Start the operator** in the dock, starts it in a tmux session of its own (`devteam-home`, window `operator`; `tmux attach -t devteam-home`), with this project's `agent_command` (or `DEVTEAM_OPERATOR_COMMAND`), `DEVTEAM_ROLE=operator` and `operator.md` as its prompt, and never a second one. The first time, Claude Code asks whether to trust that folder, and nothing answers that for you: the reply to your first message says so, the dock says the operator is at a dialog, and you answer it in that session. The session is the operator's whatever its window is called, and when tmux doesn't answer the hub starts nothing. A thread is talk with the operator, never an answer to a question: decisions stay in each project's Inbox. Each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub, so whatever is served at the hub's address can steer every fleet on the machine: an owner who finds something else on port 4320 should not open it (a dashboard notes it in `run/dashboard.log`). A dashboard answers only on the address it serves (`127.0.0.1` and `localhost`, or the `--host` it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read, and the hub takes a thread turn only from its own page (the browser's `Origin` and `Sec-Fetch-Site` must both say so). That keeps other sites out, not other programs: on a laptop anything running as you can post to the hub or to a dashboard, or type into a tmux pane, your own fleets' sessions included, so the operator reads a thread line as it reads its terminal and confirms with you anything only you may do. On a box you are never on the machine, and every agent runs as the hub's own user, so the hub takes a thread turn there only when the box's gateway signed it, with a key only the gateway's own user can read; anything sent from the box itself is refused. The operator checks each turn with `box-thread show`, which says which ones the gateway signed, and still confirms with you, in its own window, anything only you may do: an agent there can still type into its terminal. The same goes the other way: any agent there can write a reply as the operator's, so read the operator's replies as a report, as you read an alert. +**Many projects, one machine.** Each project has its own tmux session (`devteam--<6 hex of its path>`, saved in `run/tmux-session`) and its own dashboard port: the next free one in 4321–4345, saved in `run/dashboard-port` and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. On a laptop the hub is whichever dashboard took the port first; `devteam.mjs hub` serves it alone, retrying while anything else holds the port (its log is `~/.devteam/hub-4320.log`). On a box whose image sets `DEVTEAM_IMAGE_HUB`, the image's own kit serves the hub and dashboards don't: a dashboard on this release or later leaves the port alone, and says so in `run/dashboard.log`. The gap left is a project still on an older kit, which can take the port in the moment the image's hub restarts, until that project adopts the release. It opens on its **Overview** tab: a card per project saying whether it is building, moving, fully blocked on your answers (or partly: some tickets wait, the rest can move), stalled, idle or parked, with its kit version (amber when behind the newest one running here), Inbox count, workers, the goal it is on, tickets merged of planned, what needs you, and twelve weeks of commits on main (in every repo the project spans), laid out like GitHub's contribution graph. Each card has **Run**, **Drain** and **Park** for that project's fleet, and the page has **Park all** and **Resume all**; Park asks twice. The hub's server does nothing in any project: for Run, Drain and Park the page asks that project's dashboard, in its frame, and the dashboard does it as a click there would, for its parent page only. The Overview also has the dashboard's **Threads** dock, and there every thread is with the machine's **operator** (`process/operator.md`), the session that looks after the machine and not a repo: start one from the dock, or hold Shift and click a card, its name, a stat, a line that needs you or the headline (**Pin** does the same for a finger), and a numbered pin stays on what it is about. This is the one thing the hub's server keeps and the one thing it does: the threads are in `~/.devteam/operator/threads.jsonl` (on a box, `~/operator/threads.jsonl`), each of your turns is typed into the operator's session as one line marked as yours by way of the hub, and the operator answers with `operator-thread reply`, which the open card shows as text. On a laptop there is no operator until you write to one: your first message, or **Start the operator** in the dock, starts it in a tmux session of its own (`devteam-home`, window `operator`; `tmux attach -t devteam-home`), with this project's `agent_command` (or `DEVTEAM_OPERATOR_COMMAND`), `DEVTEAM_ROLE=operator` and `operator.md` as its prompt, and never a second one. The first time, Claude Code asks whether to trust that folder, and nothing answers that for you: the reply to your first message says so, the dock says the operator is at a dialog, and you answer it in that session. The session is the operator's whatever its window is called, and when tmux doesn't answer the hub starts nothing. A thread is talk with the operator, never an answer to a question: decisions stay in each project's Inbox. Each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub, so whatever is served at the hub's address can steer every fleet on the machine: an owner who finds something else on port 4320 should not open it (a dashboard notes it in `run/dashboard.log`). A dashboard answers only on the address it serves (`127.0.0.1` and `localhost`, or the `--host` it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read, and the hub takes a thread turn only from its own page (the browser's `Origin` and `Sec-Fetch-Site` must both say so). That keeps other sites out, not other programs: on a laptop anything running as you can post to the hub or to a dashboard, or type into a tmux pane, your own fleets' sessions included, so the operator reads a thread line as it reads its terminal and confirms with you anything only you may do. On a box you are never on the machine, and every agent runs as the hub's own user, so the hub takes a thread turn there only when the box's gateway signed it, with a key only the gateway's own user can read; anything sent from the box itself is refused. The operator checks each turn with `box-thread show`, which says which ones the gateway signed, and still confirms with you, in its own window, anything only you may do: an agent there can still type into its terminal. The same goes the other way: any agent there can write a reply as the operator's, so read the operator's replies as a report, as you read an alert. **Artifacts.** Any session can draw an artifact, a page that shows the owner an idea instead of describing it: a UI mock, a flow, a mock docs site, a 3D model. It lives in `project/artifacts//` and lands with the PR it was drawn in; the dashboard's **Library** lists landed ones and live drafts from every worktree beside the link registry, and opens each in a centered viewer or its own tab. An artifact loads its own files and pinned libraries from cdnjs, jsdelivr and unpkg, runs sandboxed with no other network and no reach into the dashboard, and sends nothing back: a decision on one is a question that links it, by name and inline (`[[name]]`). The owner can ask for one ("draw me a mock docs site"), and planners draw them for any goal with UI or a flow. `devteam.mjs artifact new|check`, `artifacts`. On a cloud box the owner can also publish a landed artifact to anyone, at `public.box.example.com///` (below). From e95e26b309d88c1db877c92eda53a1087d397b7a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:22:50 +0000 Subject: [PATCH 14/24] kit-46: task 2 review (approve) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/task-2.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-2.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-2.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-2.md new file mode 100644 index 0000000..80535f3 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-2.md @@ -0,0 +1,22 @@ +# Task 2 review, pass 1 + +- Reviewer: task-reviewer-2 (fresh subagent, claude-opus) +- Reviewed: 0d9fb7cf1df7e12dd9f655aa6e7bc0c7aafda3dc (Task 2, diff since 55b51e7) + +What I checked: + +- Spec "What changes" 3 and acceptance check 2 against `cmdDashboard`'s `listening` handler (devteam.mjs:14677) and `imageHub()` (devteam.mjs:13816). The regex is anchored without the `m` flag, so `4320\n`, ` 4320`, `4320.0`, `0x10E0`, `yes` and empty all give `null`. Only a plain port equal to `HUB_PORT` skips `startHub`, and the skip writes the exact plan string once through the dashboard's logger. `startHub` is self-contained (its state is local to it), so skipping it leaves no other dashboard path without what it needs. +- Plan step 1: four runs on PORTS+26..29. None of them collides with the file's other ports (+30..39) or with the instance block (+41..). Each run gets a fresh bareProject, and each dashboard is stopped before the next run starts. `hubMachine` deletes `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE` and `DEVTEAM_BOX_TOOLS` and sets only the one meant (acceptance 8). +- Ran via kt.sh: the new test passes. All of `test/hub-overview.test.mjs` passes (8 pass, 1 skip, which needs Chrome), so T1's tests still pass (step 4). +- Mutations in a scratch copy, each killed by the new test: + - M1: regex removed, `Number()` alone. Case (d), `0x…`, fails. + - M2: line logged twice. "said exactly once" fails. + - M3: line logged and `startHub` still called. "nothing listens on the hub port" fails. + - M4: any plain port skips. Case (c), `4320`, fails. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Minor | spec "What changes" 3 ("Unset, empty or another port: served as before"); spec Edge cases (`"yes"`, `" "` treated as unset) | test/hub-overview.test.mjs:368 | The empty, non-numeric and padded values (`''`, `yes`, ` `) are not exercised. The code handles them correctly (I checked by reading), and the plan's step 1 lists only (a)-(d), so this is coverage the spec names but the plan didn't ask for. | noted (Minor, no change: `^\d+$` reads them as unset; a test added after this verdict would void it) | +| F2 | Suggestion | plan Task 2 step 1 ("after the dashboard answers `/api/hello`") | test/hub-overview.test.mjs:356 | The test waits on stdout `dashboard on http` and then on the log line, not on `/api/hello`. Both come from the same `listening` handler, so this is equivalent for this property. Noting the deviation from the plan's wording. | noted (equivalent, as the reviewer says) | + +Verdict: APPROVE 0d9fb7cf1df7e12dd9f655aa6e7bc0c7aafda3dc From ffbb787c809dbb502e84bdd4a1196bd32501030e Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:53:42 +0000 Subject: [PATCH 15/24] kit-46: hub line has one 3 s deadline; a slow non-hub is logged once (task reviews 1 and 4) Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 29 ++++++++++++++++++----------- test/hub-overview.test.mjs | 14 ++++++++++++++ test/machine.test.mjs | 9 +++++++++ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 29aee17..cdbb8ab 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -11928,11 +11928,12 @@ function startHub(log, { kit = null, image = false, standalone = false } = {}) { }); hub.once('error', async (e) => { if (standalone) { - // said once for each state, never at each retry; the timer keeps this process alive + // said once for each state, never at each retry (a slow answer, unsure, changes nothing); the + // timer keeps this process alive const now = e.code === 'EADDRINUSE' ? await holder() : `error ${e.message}`; if (now !== held && now === 'other') log(`hub: port ${HUB_PORT} is held by something that isn't a devteam hub; retrying every ${retry / 1000} s`); else if (now !== held && now.startsWith('error ')) log(`hub: ${e.message}; retrying every ${retry / 1000} s`); - held = now; + if (now !== 'unsure') held = now; return void setTimeout(tryListen, retry); } if (e.code !== 'EADDRINUSE') return log(`hub: ${e.message}`); @@ -13542,21 +13543,27 @@ function versionBefore(a, b) { } const portAnswers = async (port) => { try { await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(3000) }); return 'up'; } catch { return 'DOWN'; } }; // What serves the hub, from its list as the hub's own page asks for it: the release serving it (the -// image's, marked), `kit ?` from a hub too old to say, `not a hub`, or DOWN. The release comes off the +// image's, marked), `kit ?` from a hub too old to say, `not a hub`, or DOWN. 3 s in all, however slowly +// the holder sends: something that answered and never finished isn't a hub. The release comes off the // network and goes to a terminal, so it is printed only in a release's own characters. const hubLine = (port) => new Promise((resolve) => { - const r = http.get({ host: '127.0.0.1', port, path: '/instances.json', timeout: 3000, headers: { host: `127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin' } }, (res) => { - let b = ''; - res.on('data', (c) => { b += c; if (b.length > 65536) { res.destroy(); resolve('up, not a hub'); } }); + let answered = false; + const done = (v) => { clearTimeout(timer); r.destroy(); resolve(v); }; + const timer = setTimeout(() => done(answered ? 'up, not a hub' : 'DOWN'), 3000); + const r = http.get({ host: '127.0.0.1', port, path: '/instances.json', headers: { host: `127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin' } }, (res) => { + answered = true; + const chunks = []; + let size = 0; + res.on('data', (c) => { size += c.length; if (size > 65536) done('up, not a hub'); else chunks.push(c); }); res.on('end', () => { let j = null; - try { j = JSON.parse(b); } catch {} - if (j?.hub !== 1) return resolve('up, not a hub'); - resolve(typeof j.kit === 'string' && /^[\w.+-]{1,64}$/.test(j.kit) ? `up, kit ${j.kit}${j.image === true ? ' (image)' : ''}` : 'up, kit ?'); + try { j = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {} + if (j?.hub !== 1) return done('up, not a hub'); + done(typeof j.kit === 'string' && /^[\w.+-]{1,64}$/.test(j.kit) ? `up, kit ${j.kit}${j.image === true ? ' (image)' : ''}` : 'up, kit ?'); }); + res.on('error', () => done('up, not a hub')); }); - r.on('timeout', () => { r.destroy(); resolve('DOWN'); }); - r.on('error', () => resolve('DOWN')); + r.on('error', () => done(answered ? 'up, not a hub' : 'DOWN')); }); const gb = (k) => `${Math.round(k / 1048576)}G`; const readNum = (f) => { const t = readText(f)?.trim(); return t && /^\d+$/.test(t) ? Number(t) : null; }; diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 72d73e7..be16690 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -373,3 +373,17 @@ test('a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names } finally { await r.d.stop(); } } }); + +test('a non-hub that answers slowly now and then is still logged once', { timeout: 120000 }, async (t) => { + const HUB = PORTS + 33; + const M = hubMachine(t, 'hub-slow-other', HUB); + // every other answer comes after the hub's 2 s patience: other, unsure, other, … + let n = 0; + const s = http.createServer((q, res) => { const slow = n++ % 2 === 1; setTimeout(() => { if (!res.destroyed) res.end('ok'); }, slow ? 2500 : 0); }); + await new Promise((r) => s.listen(HUB, '127.0.0.1', r)); + t.after(() => new Promise((r) => { s.closeAllConnections?.(); s.close(r); })); + const h = startHubCmd(t, imageKit(t), M.cwd, M.env, ['--release', 'v9.9.9']); + await until(() => n >= 6, `six asks of the holder (${h.out()})`); + assert.ok(h.alive()); + assert.equal(readOr(M.log).split('\n').filter((l) => l.includes("isn't a devteam hub")).length, 1, 'once, whatever answered slowly in between'); +}); diff --git a/test/machine.test.mjs b/test/machine.test.mjs index 0fbaf18..9d9227a 100644 --- a/test/machine.test.mjs +++ b/test/machine.test.mjs @@ -692,6 +692,15 @@ test('status names the hub\'s kit, or says it is no hub, or down', async () => { body = typeof b === 'string' ? b : JSON.stringify(b); assert.equal(await hubLineOf(), `machine: hub ${port} ${want}`, body.slice(0, 80)); } + // something on the port that answers and never finishes, a byte at a time: status still ends, + // within its 3 s, and says it isn't a hub + const drip = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.write('{'); const i = setInterval(() => res.write(' '), 1000); res.on('close', () => clearInterval(i)); }); + await new Promise((r) => srv.close(r)); + await new Promise((r) => drip.listen(port, '127.0.0.1', r)); + const started = Date.now(); + try { assert.equal(await hubLineOf(), `machine: hub ${port} up, not a hub`); } finally { drip.closeAllConnections?.(); await new Promise((r) => drip.close(r)); } + assert.ok(Date.now() - started < 8000, `status waited ${Date.now() - started} ms on a server that drips`); + await new Promise((r) => srv.listen(port, '127.0.0.1', r)); body = JSON.stringify({ hub: 1, kit: 'v1.2.3', instances: [] }); machine(['machine', 'alert', '--', 'look']); assert.equal(await hubLineOf(), `machine: hub ${port} up, kit v1.2.3; alert: look`, 'the alert suffix as it was'); From bda6057dbe6e301b5be5e6b4a4d28e47d29cb5b9 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:53:42 +0000 Subject: [PATCH 16/24] kit-46: task reviews 1, 3, 4, 5 and dispositions Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/task-1.md | 31 +++++++++++++++++++ .../kit-46-standalone-hub/reviews/task-3.md | 29 +++++++++++++++++ .../kit-46-standalone-hub/reviews/task-4.md | 24 ++++++++++++++ .../kit-46-standalone-hub/reviews/task-5.md | 18 +++++++++++ 4 files changed, 102 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1.md create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-3.md create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4.md create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-5.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1.md new file mode 100644 index 0000000..2997168 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1.md @@ -0,0 +1,31 @@ +# Task 1 review, pass 1 + +- Reviewer: task-reviewer-1 (fresh subagent, claude-opus) +- Reviewed: 55b51e700a0fb08e0a25efe88466fce6b95e9977 (Task 1, diff since e4c60cc) + +Read the whole diff against spec.md (What changes 1-2, Security, Edge cases) and plan Task 1 Steps 1-3. The press checks are unchanged. Nothing in the request handler, `ownerPost` (Origin / Sec-Fetch-Site / JSON content type, `threadsNeedGateway`, gateway proof, `seenNonces`), `ownHost` or the `/instances.json` and `/operator/threads` Sec-Fetch-Site refusals was edited. The only request-path change is the list body (`...listHead`). `threadsNeedGateway()` depends on `onBox()` and the gateway file, not on the project, so a standalone hub run from a folder that is no repo is still boxed on a box. The dashboard's hub is the same as before: its non-standalone `error` branch is byte-identical (gives up on a non-hub holder, retry unref'd), `processLog(log, 'dashboard')` keeps the dashboard's wording exactly, its owner actions keep `cwd: mainRoot`, and the only addition is `kit: kitVersion()` (spec ruling F2-1). The operator guard in the dispatch does not list `hub`, so `DEVTEAM_ROLE=operator` still can't run it (spec 1, last bullet). + +Ran (wrapper `kt.sh`) `test/hub-overview.test.mjs` in the worktree: T1's 5 new tests pass. Test 9 (T2's, at the later commit 0d9fb7c) failed there; that is outside this review. At 55b51e7 itself (`git archive` into a temp dir, devteam env stripped), the 5 T1 tests passed (base: pass 5 fail 0). Mutations, each run on that copy with `--test-name-pattern` for the T1 tests: + +- M1 the non-hub line logged at every retry (`now !== held` dropped): test 7 fails. Killed. +- M2 standalone retry timer `.unref()`'d: test 7 fails. Killed. +- M3 no rotation (threshold raised to 1 GiB): test 8 fails. Killed. +- M4 SIGTERM left out of `processLog`: test 8 fails. Killed. +- M5 standalone `kit` = `kitVersion()` unguarded, not `ownKitVersion()`: test 5 fails. Killed. +- M6 `actionCwd = mainRoot` always: all pass. Survives (F1). +- M7 `'hub'` removed from `machineWide`: all pass. Survives (F2). +- M8 the dashboard's hub claims `image: true`: test 6 fails. Killed. +- M9 the bad `--release` log line removed: all pass. Survives (F3). +- M10 the standalone `error` branch disabled (falls through to the dashboard's): test 7 fails. Killed. + +The press checks against a standalone hub on a box are T3's to prove (plan T3 Step 2 mutates the nonce check). This task's diff doesn't touch them, as checked above. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Minor | spec What changes 1 bullet 4 ("cwd: process.cwd() … from a git checkout it is that checkout's root, which a standalone hub must not use"); quality §3 mutate-to-prove | `startHub` `actionCwd` | M6 survives: no test can tell `process.cwd()` from `mainRoot`. They are also nearly equivalent in fact. A standalone hub's `mainRoot` is derived from the cwd's git common dir (the image's kit has no `scriptCommon`), and the child `operator-thread` resolves its home from that same common dir whichever folder of the repo it starts in. So the spec's stated reason (not using a checkout's root) isn't really achieved by this line. It is harmless and does what the spec literally says. Worth one sentence in the comment, or a T3 assertion run from inside a git repo, if the property matters. | noted (Minor: kept explicit, so the standalone hub never depends on how the child resolves its home) | +| F2 | Minor | spec What changes 1 ("added to the machineWide list … so it runs from a folder that is no repo and no project"); quality §3 | `homeAt` `machineWide` | M7 survives. The die path only fires when the cwd or the script's checkout is a git repo with no `project/settings.md`. Every test runs the image kit from a non-repo cwd, where `proj` is null. A run of `hub` from a cwd that is an unadopted git repo would prove it. | noted (Minor: the image kit is no repo, where machineWide is not reached; the box test of kit-51 runs it end to end) | +| F3 | Minor | spec Edge cases ("`--release` given empty or not vX.Y.Z … the hub logs the problem once") | `cmdHub`; test 'hub without --release …' | M9 survives: the test checks `kit: null` and that the hub is alive, but never checks the `is not vX.Y.Z` log line (once) in `hub-.log`. | noted (Minor) | +| F4 | Minor | plan T1 Step 3 ("log the non-hub line only when the last state logged wasn't `other`"); spec 1 bullet 3 ("never at every retry") | `startHub` standalone `error` branch, `held` | `held` records the last state seen, including unlogged ones (`unsure`, `hub`), not the last one logged. A non-hub holder that sometimes takes over 2 s to answer gives other → unsure → other, and the line is logged again each time. The spec's own edge case only excuses a flip through a real hub. Fix: update `held` only when a line is logged or the state is `hub`, or ignore `unsure`. | fixed (`unsure` no longer resets the state logged; test "a non-hub that answers slowly now and then is still logged once", RED 3 lines → GREEN 1) | +| F5 | Suggestion | spec Contracts ("Documented in the comment above startHub") | comment above `startHub` | At this sha the comment already describes `DEVTEAM_IMAGE_HUB` in cmdDashboard, which only T2 (0d9fb7c) adds. It is accurate once T2 lands, so this is only noted. | noted (the comment is right at the head, T2 landed) | + +Verdict: APPROVE 55b51e700a0fb08e0a25efe88466fce6b95e9977 diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-3.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-3.md new file mode 100644 index 0000000..7ef7fa8 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-3.md @@ -0,0 +1,29 @@ +# Task 3 review, pass 1 + +- Reviewer: task-reviewer-3 (fresh subagent, claude-opus) +- Reviewed: f57715b8e1ad49be80bc2464e7ba0276ccec91db (Task 3, diff since 0d9fb7cf1df7e12dd9f655aa6e7bc0c7aafda3dc) + +Ran in the worktree (kt.sh, "standalone hub takes a press"): every 403 part passes. The 200 fails with "its signature is not the gateway's", because this box's /opt/devteam/gateway.mjs makes the kit read /etc/devteam/thread-key.pub. That matches the known baseline and the Task 3 ruling. + +To check CI, I made a scratch copy (git archive of f57715b). In its devteam.mjs I pointed `BOX_GATEWAY_FILE` and `BOX_THREAD_KEY` at /nonexistent-ci/..., so the copy sees no box files, as on a CI runner. I ran it with the session's devteam env and the box's DEVTEAM_PUBLIC_URL/DEVTEAM_PREVIEWS unset. The test passes there (1.6 s): the signed press gets 200, its proof is in the store, it is typed into the pane, and the replay is refused. The ledger also records CI run 43647835355 green on this test. + +Mutations, each in a fresh copy of that CI-like scratch tree, running this test alone. Every one was killed, and each failure came from the check that was mutated: +- the hub's nonce check removed (plan Step 2): the replay got 400 "that press was taken already" (the owner's command's own check), not 403; +- the Origin test dropped from ownerPost: the foreign Origin got 200; +- the content-type test dropped: text/plain got 200; +- the Sec-Fetch-Site test dropped: the press with no Sec-Fetch-Site got 200; +- `same-site` accepted: same-site got 200; +- `boxed = false` (no signature needed): the unsigned press got 400 from the command, not 403; +- ownHost check removed: the foreign Host got 302; +- the cross-site check on /instances.json removed: 200; +- the cross-site check on /operator/threads removed: 200; +- proof not handed to the owner's command: the signed press got 400. + +Wrong-reason analysis: every signed refusal carries the same valid signature as the final 200, and ownerPost checks the headers before it reads the signature. So in CI each of those 403s can only come from the header check it names. The final 200 also proves that none of them consumed the nonce. On this box the header refusals land at the same checks (they come before the key read). The unsigned 403 can only be boxOnly. The weak spots are the refusals that assert no reason (below). + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Minor | plan T3 Step 1 ("re-sent → 403 `/used already/`"); done-when 4 ("one reusing a nonce") | test/operator-threads.test.mjs, last `refused(...)` ('the same signature again') | The replay asserts only status 403, not the reason the plan names. gatewayProofProblem also returns 403 for "its signature is too old" (OPERATOR_PROOF_MS 120 s). The test's timeout is 240 s and the pane wait comes before the resend, so a slow run could pass this line on staleness and not on nonce reuse. Assert `r.json.error` matches `/used already/`. | noted (Minor: the replay is also asserted by the store staying at 2 lines) | +| F2 | Suggestion | quality.md §3 (a refusal must fail for the named reason) | same test, the header refusals | No refusal checks its error text. The final 200 and the check order make this sound today. Matching `owner actions only from the hub's own page` (and `the hub answers only on its own address` for Host) would keep it sound if the checks were reordered or merged. | noted (Suggestion) | + +Verdict: APPROVE f57715b8e1ad49be80bc2464e7ba0276ccec91db diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4.md new file mode 100644 index 0000000..66e6483 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4.md @@ -0,0 +1,24 @@ +# Task 4 review, pass 1 + +- Reviewer: task-reviewer-3 (fresh subagent, claude-opus) +- Reviewed: 04f7dfb96815665eb26fb36e4336c9debd06d732 (Task 4, diff since f57715b8e1ad49be80bc2464e7ba0276ccec91db) + +I ran `kt.sh test/machine.test.mjs` in the worktree: 17/17 pass, so done-when 5 holds and the existing DOWN/alert assertions are unchanged. The new test asserts all five lines exactly, plus the alert suffix, and deletes the three env vars (done-when 8). + +Mutations, each in a scratch copy of 04f7dfb, running "names the hub's kit" alone. The unmutated copy passes. Every mutation was killed: +- the 64 KB cap removed: the 70 KB body printed `up, kit v1.2.3`; +- the release regex replaced by `true`: the ESC case printed the escape; +- `{1,64}` replaced by `+`: the 10 KB case printed it. + +Probes I ran against the worktree kit's `machine status`, each with an in-process server on a scratch port and a scratch HOME (/data/home/tmp/claude-1000/-data-home-code-devteam-dev--worktrees-kit-46-standalone-hub/28488347-2c65-4742-9f26-ba72c20a41e5/scratchpad/t4/trickle.mjs): +- a server that sends headers and then one byte every 2 s: status took 15.5 s, ended only when the server ended its body at 15 s, and printed `up, kit v1.0.0`; +- headers, then silence: `DOWN` after 3.7 s; +- 60000 × "€" (about 180 KB of bytes) in a valid hub body: parsed, `up, kit v1.0.0`. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| G1 | Important | spec item 4 ("asks `/instances.json` … 3 s"); plan T4 Interfaces ("3 s timeout"); spec Security (a `dev` process may hold 4320) | devteam.mjs `hubLine`, `timeout: 3000` on `http.get` | `timeout` on http.get is a socket idle timeout, not a deadline. Whatever holds the hub port can trickle a byte every < 3 s and keep `machine status` (and box-status, which wraps it) waiting up to 64 KB × 3 s. I observed 15.5 s, limited only by my server. The `portAnswers` it replaces had an overall bound (`AbortSignal.timeout(3000)`). Fix: an overall 3 s timer that destroys the request and resolves, and a test case with a trickling server that asserts the line comes back within a few seconds. | fixed (one 3 s timer over the whole read; test case "a server that drips", RED: hung → GREEN: up, not a hub within 3 s) | +| G2 | Minor | spec Edge cases ("read capped at 64 KB, then `not a hub`") | `hubLine`, `b += c; if (b.length > 65536)` | The cap counts decoded UTF-16 characters, not bytes, so about 180 KB of multibyte text was read and parsed. Decoding each chunk on its own can also split a multibyte character. Count `c.length` on the Buffer chunks and decode once at the end. | fixed (the cap counts bytes, chunks kept as Buffers) | +| G3 | Suggestion | spec item 4 (`DOWN` is "no answer") | `hubLine`, `r.on('timeout')` | A holder that sends response headers and then goes silent is reported `DOWN`, though it did answer. `up, not a hub` would be truer to the spec's list. Settle this when fixing G1. | fixed (answered, then silent: up, not a hub) | + +Verdict: CHANGES 04f7dfb96815665eb26fb36e4336c9debd06d732 diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-5.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-5.md new file mode 100644 index 0000000..0046e23 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-5.md @@ -0,0 +1,18 @@ +# Task 5 review, pass 1 + +- Reviewer: task-reviewer-3 (fresh subagent, claude-opus) +- Reviewed: c54471ae304854cf5ecf61d321b9e9ba9442cd77 (Task 5, diff since 04f7dfb96815665eb26fb36e4336c9debd06d732) + +I read the README diff against spec item 5, done-when 7 and plan T5 Step 1. I checked the README's claims against devteam.mjs at this head: +- `hub` retries whatever holds the port (the standalone branch of the error handler); +- the log is `~/.devteam/hub-.log`; +- a dashboard skips the hub only when `DEVTEAM_IMAGE_HUB` equals its HUB_PORT, and says so in run/dashboard.log; +- the HELP line `hub [--release vX.Y.Z]` exists (T1). + +`git diff --diff-filter=A --name-only origin/main c54471a -- . ':!docs/devteam-dev'` is empty, so done-when 7 holds. The README names the gap (an older project kit takes the port while the image's hub restarts), as the spec asks. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| H1 | Suggestion | spec item 5 | README.md, "Many projects, one machine", second sentence | "Any running dashboard also serves the hub" still stands unqualified, and the box case only corrects it two sentences later. "Any running dashboard can serve the hub" (or moving the box sentence up) would read true on a box. The gap sentence could also say that an older kit holding the port keeps it until its dashboard stops, not only for "the moment" of a restart. That is implied by "until that project adopts the release". | noted (Suggestion: the next sentence qualifies it for the laptop, the one after for the box) | + +Verdict: APPROVE c54471ae304854cf5ecf61d321b9e9ba9442cd77 From 4f348d18bfaa34951ce9d7a694eb71ea8eceb964 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 22:58:17 +0000 Subject: [PATCH 17/24] kit-46: task reviews 1 and 4, pass 2 (approve) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/task-1-2.md | 13 ++++++++++++ .../kit-46-standalone-hub/reviews/task-4-2.md | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1-2.md create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4-2.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1-2.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1-2.md new file mode 100644 index 0000000..f684fd4 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-1-2.md @@ -0,0 +1,13 @@ +# Task 1 review, pass 2 + +- Reviewer: task-confirmer (fresh subagent, claude-opus) +- Reviewed: ffbb787c809dbb502e84bdd4a1196bd32501030e (the fix, diff since bda6057's parent e95e26b) + +- F4 (Minor, `held` records unlogged states): fixed. `if (now !== 'unsure') held = now;` keeps the last logged or settled state across a slow answer, so other → unsure → other logs once, while other → hub → other still logs again (spec Edge cases: a flip through a real hub). An always-unsure holder stays silent and is retried (spec: "retried, as today"). Error lines are untouched: once per distinct message. The dashboard branch is unchanged. + +Ran `kt.sh --test-name-pattern="answers slowly now and then" test/hub-overview.test.mjs` in the worktree: pass. The test's holder alternates 0 ms and 2500 ms answers, and 2500 ms is past `holder()`'s 2 s socket timeout, so every other ask is `unsure`. Its port (PORTS + 33) is used by no other test in the file. `n >= 6` waits until asks 0, 2 and 4 (the fast ones) have answered, and each logs before the next retry starts. Mutation, in a scratch copy of HEAD with the devteam env stripped: `held = now;` restored unconditionally makes the test fail with actual 3, expected 1. Killed. The test can fail for the property it claims. I found no defect in the fix. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| + +Verdict: APPROVE ffbb787c809dbb502e84bdd4a1196bd32501030e diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4-2.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4-2.md new file mode 100644 index 0000000..7e57162 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/task-4-2.md @@ -0,0 +1,21 @@ +# Task 4 review, pass 2 + +- Reviewer: task-confirmer (fresh subagent, claude-opus) +- Reviewed: ffbb787c809dbb502e84bdd4a1196bd32501030e (the fix, diff since bda6057's parent e95e26b) + +- G1 (Important, socket idle timeout is no deadline): fixed. One 3 s `setTimeout` covers the whole read. `done` clears it, destroys the request and resolves. Repeat `done` calls (over the cap, then `end` or `error`) are harmless. `timer` and `r` are both bound before any callback can run. The timer is cleared on every path, so `machine status` exits promptly. +- G2 (Minor, cap counted UTF-16 characters): fixed. The code reads Buffer chunks, counts `c.length` bytes and decodes once with `Buffer.concat`. No test covers it (H2). +- G3 (Suggestion, answered then silent reported DOWN): fixed. `answered` turns the deadline, and a request error after the headers, into `up, not a hub`. No answer, or a refused connection, is still `DOWN`. + +Ran `kt.sh --test-name-pattern="names the hub's kit" test/machine.test.mjs` in the worktree: pass, and the earlier cases are unchanged. Mutations, each in a scratch copy of HEAD, devteam env stripped, under `timeout 90`: +- M1: the deadline removed and the old `timeout: 3000` socket timeout restored. The drip case never ends, and the run was killed at 90 s. Detected only as a hang (H1). +- M2: the deadline always resolves `DOWN`, ignoring `answered`. Fails: actual `DOWN`, expected `up, not a hub`. Killed. +- M3: the cap counts `c.toString().length`. Passes. Survives (H2). +- M4: `res.on('error')` removed. Passes. On Node 18.20.4, destroying the request mid-body emits `aborted` and `close` and no `error`, so the listener is defensive only. This is not a finding. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| H1 | Minor | quality §3 mutate-to-prove (a RED that fails, not hangs) | test/machine.test.mjs "status names the hub's kit…", drip case | Without the deadline, `hubLineOf`'s `execFile` waits forever on the dripping server. The test has no `timeout` option, and `execFile` has no `timeout`. The `< 8000` ms assertion therefore never runs, and a regression hangs the file rather than failing it. Add `timeout: 10000` to that `execFile` (or to the test's options), so the regression fails with a message. | noted (Minor: the drip case still fails, by the run's own timeout, if the deadline goes) | +| H2 | Suggestion | spec Edge cases ("read capped at 64 KB") | `hubLine` byte cap; test cases | G2's byte count has no test: every case is ASCII, where characters and bytes are equal. Counting characters again passes the tests (M3). A case with, say, 30000 × "€" (about 90 KB of bytes, 30 K characters) in a valid hub body, expecting `up, not a hub`, would pin it down. | noted (Suggestion) | + +Verdict: APPROVE ffbb787c809dbb502e84bdd4a1196bd32501030e From d3c5f401d56352a5559a40c253a207005b7f1ac1 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 23:05:27 +0000 Subject: [PATCH 18/24] kit-46: verification at 4f348d1 (redone after the merge with main) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/verification.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md new file mode 100644 index 0000000..fb6f529 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md @@ -0,0 +1,20 @@ +# kit-46-standalone-hub: verification + +At commit `4f348d18bfaa34951ce9d7a694eb71ea8eceb964` (code as at `ffbb787`; everything after it is records). Before the merge of `origin/main` that waits for kit-201 (m-20261114-215435068-supervisor): this file is redone at the merged head. + +Where: **box** is this cloud box, every kit test run as `env -u DEVTEAM_RUN -u DEVTEAM_HOME -u DEVTEAM_ROLE -u DEVTEAM_TICKET -u DEVTEAM_FEATURE -u DEVTEAM_SUPERVISOR -u TMUX -u TMUX_PANE TMUX_TMPDIR=$(mktemp -d) node --test ` (m-20261114-214804017-supervisor), through `devteam.mjs gate test`. **CI** is test.yml run [43650829532](https://github.com/sanctuarycomputer/devteam/actions/runs/43650829532) at `4f348d1`: every job (`kit` × Node 18/20/22/24 × chain/rest) succeeded; the box-image workflow at `4f348d1` succeeded too. + +On this box two groups of existing tests fail at main as at this head, because the box's own files and ENV reach them (`baseline-box.txt`; kit-201 fixes it): 15 in `test/operator-threads.test.mjs` (`onBox()` is true while `/opt/devteam/gateway.mjs` exists, and the kit then reads the real `/etc/devteam/thread-key.pub`), and 8 relay/viewer tests in `test/dashboard.test.mjs` (`DEVTEAM_PUBLIC_URL`/`DEVTEAM_PREVIEWS` from the box ENV). Ruling m-20261114-215021941-supervisor: on the box, done-when 6 means no test that passes at main fails here; CI is the full gate. + +| # | Done-when | Command | Result | +|---|---|---|---| +| 1 | `hub --release v9.9.9` from a non-git, non-project folder: hub 1, the fixture instance, kit v9.9.9, image true, no `docs/devteam/run` under its cwd; `hub` without `--release` (and with `''`, `nope`): kit null, no image, with `DEVTEAM_KIT_RELEASE` set; a dashboard's hub: `kitVersion()`, no image | box: `gate test -- … test/hub-overview.test.mjs`; CI | box: 9 pass, 0 fail, 1 skipped (the Chrome test, no Chrome here); the tests "hub --release, from a folder that is no repo…", "hub without --release names no release…", "a dashboard's hub names its project's kit…" pass. CI: all green; the 9 new tests are `ok` in the Node 18, 20 and 22 rest logs (the Node 24 rest job succeeded, but `gh run view --log` returns no log for it) | +| 2 | `DEVTEAM_IMAGE_HUB` = the fixture hub port: no listener on it, exactly one `hub: left to the image (DEVTEAM_IMAGE_HUB=)`; unset, `4320`, or a hex form: served, no such line | same | "a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port" passes on the box and in CI | +| 3 | Held by a hub: stays up, retries, takes the port; held by a non-hub: logged once to `~/.devteam/hub-.log`, takes it once free; log never past 1 MB; SIGTERM leaves `stopped by SIGTERM` | same | "the hub waits out another hub and a non-hub…", "a non-hub that answers slowly now and then is still logged once", "the hub's log stays under 1 MB and records a SIGTERM" pass on the box and in CI | +| 4 | Box-mode standalone hub: unsigned, replayed, foreign Origin each 403; a signed one reaches the operator's pane | CI; box: `gate test -- … test/operator-threads.test.mjs` | CI: "on a box the standalone hub takes a press only as the gateway signed it…" `ok` (Node 18/20/22 rest logs). Box: every 403 in it passes (unsigned from the hub's own page, foreign Origin, foreign Host, no or same-site Sec-Fetch-Site, non-JSON, cross-site reads), and the signed press gets 403 because the kit reads this box's real gateway key, exactly as the existing box hub test does at main. **Deferred on the box** to after kit-201 (rerun at the merged head) | +| 5 | `machine status` prints each of the five hub lines exactly, alert suffix unchanged | box: `gate test -- … test/machine.test.mjs`; CI | box: 17 pass, 0 fail ("status names the hub's kit, or says it is no hub, or down" covers the five lines, a hostile and a 10 KB `kit`, a 70 KB body, a holder that drips a byte a second, the alert suffix); CI green | +| 6 | Existing tests of dashboard, hub-overview, operator-threads, machine pass unchanged, in CI and on the box | CI; box: the four files through `gate`; main's relay tests from an exported copy (`git archive 5c2a77b`) | CI: every job green. Box: hub-overview and machine all pass; dashboard 23 pass, 8 fail, the same 8 that fail at main 5c2a77b on this box (checked from an exported copy of main); operator-threads 1 pass, 16 fail: the 15 of the baseline and the new box test (row 4). No test that passes at main fails here | +| 7 | README's "Many projects, one machine" says the image serves the hub on a box whose image sets `DEVTEAM_IMAGE_HUB`, and names the gap; no file added | `git diff --diff-filter=A --name-only origin/main -- . ':!docs/devteam-dev'` | empty; README.md has the two sentences (task-5 review APPROVE) | +| 8 | Every added test clears `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` and sets them only where it means to | read: `hubMachine()` in test/hub-overview.test.mjs (deletes `BOX_VARS`, then `extra`), the new test in test/operator-threads.test.mjs (deletes the three), the new test in test/machine.test.mjs (deletes the three) | each new test builds its env that way; the only settings are `DEVTEAM_KIT_RELEASE=v9.9.9` (rows 1) and `DEVTEAM_IMAGE_HUB` (row 2) | + +Reviews so far: spec (1 CHANGES, 2 APPROVE), plan (1, 2 CHANGES, 3 APPROVE), tasks 1–5 (each APPROVE, tasks 1 and 4 again at pass 2 after their fixes). Final reviews (two, risk high) come after the merge with main. From a8c9b2fccf38dd5e9a0e2f4a3f7662986708ff7a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 23:42:30 +0000 Subject: [PATCH 19/24] kit-46: the hub is exempt from kit-201's DEVTEAM_RUN check, as the machine's other commands are Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 4 ++-- test/hub-overview.test.mjs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 9e47264..de49f74 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -477,10 +477,10 @@ const runDir = path.resolve(process.env.DEVTEAM_RUN || path.join(mainRoot, 'docs // DEVTEAM_RUN is home's run folder, given to each session by the kit that starts it. One that isn't // inside the home this kit resolved is a session's, leaked into another repo's kit (a test's fixture, // a sibling): refused by name, never followed into that session's live log. The machine's commands -// run from anywhere and keep no run folder. Not defended (the agents are trusted not to forge): a +// (machine, operator-thread, recover --machine, the hub) run from anywhere and keep no run folder. Not defended (the agents are trusted not to forge): a // DEVTEAM_RUN that names some other folder inside home on purpose. const realPathMade = (p) => { let a = p; const rest = []; while (!fs.existsSync(a) && path.dirname(a) !== a) { rest.unshift(path.basename(a)); a = path.dirname(a); } return path.join(realPath(a), ...rest); }; -if (process.env.DEVTEAM_RUN && command && !['help', '--help', 'machine', 'operator-thread'].includes(command) && !(command === 'recover' && flags.machine)) { +if (process.env.DEVTEAM_RUN && command && !['help', '--help', 'machine', 'operator-thread', 'hub'].includes(command) && !(command === 'recover' && flags.machine)) { const r = realPathMade(runDir), root = realPath(mainRoot); if (r !== realPathMade(path.join(mainRoot, 'docs', 'devteam', 'run')) && r !== root && !r.startsWith(`${root}${path.sep}`)) die(`DEVTEAM_RUN=${runDir} is not inside this kit's home (${mainRoot}): a session's run folder leaked into another repo's kit; unset DEVTEAM_RUN, or run home's kit from home`); } diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 9c34355..fceec0d 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -386,3 +386,17 @@ test('a non-hub that answers slowly now and then is still logged once', { timeou assert.ok(h.alive()); assert.equal(readOr(M.log).split('\n').filter((l) => l.includes("isn't a devteam hub")).length, 1, 'once, whatever answered slowly in between'); }); + +// The hub is the machine's, like machine and operator-thread: a session's DEVTEAM_RUN in its env (a +// worker starting it by hand, say) is never refused there, and nothing is written into that run folder. +test('the hub runs with another home\'s DEVTEAM_RUN in its env, and writes nothing there', { timeout: 120000 }, async (t) => { + const HUB = PORTS + 34; + const elsewhere = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-other-home-')), 'docs', 'devteam', 'run'); + fs.mkdirSync(elsewhere, { recursive: true }); + t.after(() => fs.rmSync(path.dirname(path.dirname(path.dirname(elsewhere))), { recursive: true, force: true })); + const M = hubMachine(t, 'hub-run', HUB, { DEVTEAM_RUN: elsewhere }); + const h = startHubCmd(t, imageKit(t), M.cwd, M.env, ['--release', 'v9.9.9']); + await until(async () => (await hubList(HUB))?.kit === 'v9.9.9' || !h.alive(), 'the hub, or its exit'); + assert.ok(h.alive(), `the hub was refused: ${h.out()}`); + assert.deepEqual(fs.readdirSync(elsewhere), [], 'nothing in that run folder'); +}); From 4513dc5a4e899c8179a90429cca9304cda13809c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 14 Nov 2026 23:50:42 +0000 Subject: [PATCH 20/24] kit-46: verification at a8c9b2f (main merged after kit-201) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/verification.md | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md index fb6f529..f3e56bc 100644 --- a/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md @@ -1,20 +1,31 @@ # kit-46-standalone-hub: verification -At commit `4f348d18bfaa34951ce9d7a694eb71ea8eceb964` (code as at `ffbb787`; everything after it is records). Before the merge of `origin/main` that waits for kit-201 (m-20261114-215435068-supervisor): this file is redone at the merged head. +At commit `a8c9b2fccf38dd5e9a0e2f4a3f7662986708ff7a`: the branch with `origin/main` merged after kit-201 (merge `31b98c8`, m-20261114-234110890-supervisor), and `hub` added to kit-201's `DEVTEAM_RUN` exemption. -Where: **box** is this cloud box, every kit test run as `env -u DEVTEAM_RUN -u DEVTEAM_HOME -u DEVTEAM_ROLE -u DEVTEAM_TICKET -u DEVTEAM_FEATURE -u DEVTEAM_SUPERVISOR -u TMUX -u TMUX_PANE TMUX_TMPDIR=$(mktemp -d) node --test ` (m-20261114-214804017-supervisor), through `devteam.mjs gate test`. **CI** is test.yml run [43650829532](https://github.com/sanctuarycomputer/devteam/actions/runs/43650829532) at `4f348d1`: every job (`kit` × Node 18/20/22/24 × chain/rest) succeeded; the box-image workflow at `4f348d1` succeeded too. +Where: **box** is this cloud box, every kit test run through `devteam.mjs gate test` as `env -u DEVTEAM_RUN -u DEVTEAM_HOME -u DEVTEAM_ROLE -u DEVTEAM_TICKET -u DEVTEAM_FEATURE -u DEVTEAM_SUPERVISOR -u TMUX -u TMUX_PANE TMUX_TMPDIR=$(mktemp -d) node --test test/.test.mjs` (m-20261114-214804017-supervisor). **CI** is test.yml run [43654583319](https://github.com/sanctuarycomputer/devteam/actions/runs/43654583319) at `a8c9b2f`: every job (`kit` × Node 18/20/22/24 × chain/rest) succeeded; box-image run 43654583500 at `a8c9b2f` succeeded. -On this box two groups of existing tests fail at main as at this head, because the box's own files and ENV reach them (`baseline-box.txt`; kit-201 fixes it): 15 in `test/operator-threads.test.mjs` (`onBox()` is true while `/opt/devteam/gateway.mjs` exists, and the kit then reads the real `/etc/devteam/thread-key.pub`), and 8 relay/viewer tests in `test/dashboard.test.mjs` (`DEVTEAM_PUBLIC_URL`/`DEVTEAM_PREVIEWS` from the box ENV). Ruling m-20261114-215021941-supervisor: on the box, done-when 6 means no test that passes at main fails here; CI is the full gate. +Box results for the four files: + +| File | Tests | Pass | Fail | Skipped | +|---|---|---|---|---| +| test/hub-overview.test.mjs | 11 | 10 | 0 | 1 (the Chrome test: no Chrome here) | +| test/machine.test.mjs | 23 | 23 | 0 | 0 | +| test/dashboard.test.mjs | 46 | 31 | 0 | 15 (Chrome and similar skips) | +| test/operator-threads.test.mjs | 19 | 18 | 0 | 1 | + +With kit-201 merged, the box's own files and ENV no longer reach the tests: the 23 failures of `baseline-box.txt` (at main before kit-201) are gone, so done-when 6 now holds on the box as written, not only under ruling m-20261114-215021941-supervisor. | # | Done-when | Command | Result | |---|---|---|---| -| 1 | `hub --release v9.9.9` from a non-git, non-project folder: hub 1, the fixture instance, kit v9.9.9, image true, no `docs/devteam/run` under its cwd; `hub` without `--release` (and with `''`, `nope`): kit null, no image, with `DEVTEAM_KIT_RELEASE` set; a dashboard's hub: `kitVersion()`, no image | box: `gate test -- … test/hub-overview.test.mjs`; CI | box: 9 pass, 0 fail, 1 skipped (the Chrome test, no Chrome here); the tests "hub --release, from a folder that is no repo…", "hub without --release names no release…", "a dashboard's hub names its project's kit…" pass. CI: all green; the 9 new tests are `ok` in the Node 18, 20 and 22 rest logs (the Node 24 rest job succeeded, but `gh run view --log` returns no log for it) | -| 2 | `DEVTEAM_IMAGE_HUB` = the fixture hub port: no listener on it, exactly one `hub: left to the image (DEVTEAM_IMAGE_HUB=)`; unset, `4320`, or a hex form: served, no such line | same | "a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port" passes on the box and in CI | -| 3 | Held by a hub: stays up, retries, takes the port; held by a non-hub: logged once to `~/.devteam/hub-.log`, takes it once free; log never past 1 MB; SIGTERM leaves `stopped by SIGTERM` | same | "the hub waits out another hub and a non-hub…", "a non-hub that answers slowly now and then is still logged once", "the hub's log stays under 1 MB and records a SIGTERM" pass on the box and in CI | -| 4 | Box-mode standalone hub: unsigned, replayed, foreign Origin each 403; a signed one reaches the operator's pane | CI; box: `gate test -- … test/operator-threads.test.mjs` | CI: "on a box the standalone hub takes a press only as the gateway signed it…" `ok` (Node 18/20/22 rest logs). Box: every 403 in it passes (unsigned from the hub's own page, foreign Origin, foreign Host, no or same-site Sec-Fetch-Site, non-JSON, cross-site reads), and the signed press gets 403 because the kit reads this box's real gateway key, exactly as the existing box hub test does at main. **Deferred on the box** to after kit-201 (rerun at the merged head) | -| 5 | `machine status` prints each of the five hub lines exactly, alert suffix unchanged | box: `gate test -- … test/machine.test.mjs`; CI | box: 17 pass, 0 fail ("status names the hub's kit, or says it is no hub, or down" covers the five lines, a hostile and a 10 KB `kit`, a 70 KB body, a holder that drips a byte a second, the alert suffix); CI green | -| 6 | Existing tests of dashboard, hub-overview, operator-threads, machine pass unchanged, in CI and on the box | CI; box: the four files through `gate`; main's relay tests from an exported copy (`git archive 5c2a77b`) | CI: every job green. Box: hub-overview and machine all pass; dashboard 23 pass, 8 fail, the same 8 that fail at main 5c2a77b on this box (checked from an exported copy of main); operator-threads 1 pass, 16 fail: the 15 of the baseline and the new box test (row 4). No test that passes at main fails here | -| 7 | README's "Many projects, one machine" says the image serves the hub on a box whose image sets `DEVTEAM_IMAGE_HUB`, and names the gap; no file added | `git diff --diff-filter=A --name-only origin/main -- . ':!docs/devteam-dev'` | empty; README.md has the two sentences (task-5 review APPROVE) | -| 8 | Every added test clears `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` and sets them only where it means to | read: `hubMachine()` in test/hub-overview.test.mjs (deletes `BOX_VARS`, then `extra`), the new test in test/operator-threads.test.mjs (deletes the three), the new test in test/machine.test.mjs (deletes the three) | each new test builds its env that way; the only settings are `DEVTEAM_KIT_RELEASE=v9.9.9` (rows 1) and `DEVTEAM_IMAGE_HUB` (row 2) | - -Reviews so far: spec (1 CHANGES, 2 APPROVE), plan (1, 2 CHANGES, 3 APPROVE), tasks 1–5 (each APPROVE, tasks 1 and 4 again at pass 2 after their fixes). Final reviews (two, risk high) come after the merge with main. +| 1 | `hub --release v9.9.9` from a non-git, non-project folder: hub 1, the fixture instance, kit v9.9.9, image true, no `docs/devteam/run` under its cwd; `hub` without `--release` (and `''`, `nope`): kit null, no image, with `DEVTEAM_KIT_RELEASE` set; a dashboard's hub: `kitVersion()`, no image | box and CI, hub-overview | pass: "hub --release, from a folder that is no repo…", "hub without --release names no release…", "a dashboard's hub names its project's kit…" | +| 2 | `DEVTEAM_IMAGE_HUB` = the fixture hub port: no listener there, exactly one `hub: left to the image (DEVTEAM_IMAGE_HUB=)`; unset, `4320` or a hex form: served, no such line | box and CI, hub-overview | pass: "a dashboard leaves the hub to the image only when DEVTEAM_IMAGE_HUB names its own hub port" | +| 3 | Held by a hub: stays up, retries, takes the port; held by a non-hub: logged once to `~/.devteam/hub-.log`, takes it once free; log never past 1 MB; SIGTERM leaves `stopped by SIGTERM` | box and CI, hub-overview | pass: "the hub waits out another hub and a non-hub…", "a non-hub that answers slowly now and then is still logged once", "the hub's log stays under 1 MB and records a SIGTERM" | +| 4 | Box-mode standalone hub: unsigned, replayed, foreign Origin each 403; a signed one reaches the operator's pane | box and CI, operator-threads | pass: "on a box the standalone hub takes a press only as the gateway signed it…" (`ok 19` on the box); it also refuses a foreign Host, missing or same-site Sec-Fetch-Site, non-JSON, and cross-site reads | +| 5 | `machine status` prints each of the five hub lines exactly, alert suffix unchanged | box and CI, machine | pass: "status names the hub's kit, or says it is no hub, or down" (also a hostile and a 10 KB `kit`, a 70 KB body, a holder that drips, the alert suffix) | +| 6 | Existing tests of dashboard, hub-overview, operator-threads, machine pass unchanged, in CI and on the box | box: the table above; CI | every test passes on the box (0 failures in all four files) and in every CI job | +| 7 | README's "Many projects, one machine" names the image's hub on a box whose image sets `DEVTEAM_IMAGE_HUB`, and the gap; no file added | `git diff --diff-filter=A --name-only origin/main -- . ':!docs/devteam-dev'` | empty; the README has the two sentences (task-5 review APPROVE) | +| 8 | Every added test clears `DEVTEAM_IMAGE_HUB`, `DEVTEAM_KIT_RELEASE`, `DEVTEAM_BOX_TOOLS` and sets them only where it means to | read: `hubMachine()` in test/hub-overview.test.mjs (deletes `BOX_VARS`, then applies `extra`), the new tests in test/operator-threads.test.mjs and test/machine.test.mjs (each deletes the three) | the only settings are `DEVTEAM_KIT_RELEASE=v9.9.9` (row 1) and `DEVTEAM_IMAGE_HUB` (row 2) | + +Also at this head (supervisor, m-20261114-234110890): kit-201's `DEVTEAM_RUN` check exempts `hub` as it does the machine's other commands; "the hub runs with another home's DEVTEAM_RUN in its env, and writes nothing there" was refused before the change, and passes on the box and in CI. + +Reviews: spec (1 CHANGES, 2 APPROVE), plan (1 and 2 CHANGES, 3 APPROVE), tasks 1–5 (each APPROVE; tasks 1 and 4 again at pass 2 after their fixes). The two final reviews (risk high) are requested from other sessions. From f2186275cdc879fc55c21554ca40af8011262d68 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 15 Nov 2026 00:12:28 +0000 Subject: [PATCH 21/24] kit-46: a dashboard's hub names its kit only when the kit is the project's own (final-1b F1) Co-Authored-By: Claude Opus 5.5 --- docs/devteam/scripts/devteam.mjs | 6 +++--- test/hub-overview.test.mjs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index de49f74..33fe6e2 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -11805,8 +11805,8 @@ function ownerActionEnv(channel) { // holds the port, and never exits for it. // The list, /instances.json: { hub: 1, kit, image?, instances }. kit is the release serving the hub, // fixed when it starts: hub --release's, with image: true (only the box's boot passes it, so no other -// hub can claim the image's release); else the running kit's own version (kitVersion()), or null where -// it has no project. No hub reads DEVTEAM_KIT_RELEASE: the box's ENV reaches every process there. +// hub can claim the image's release); else the running kit's own version (kitVersion()) when it is its +// project's own checkout, or null (no project, or a kit run against someone else's: ownKitVersion). No hub reads DEVTEAM_KIT_RELEASE: the box's ENV reaches every process there. // The rule for the list: an additive change keeps hub: 1; anything else bumps it. const HUB_BODY_MAX = 20000; // The hub's owner actions, each the arguments of the owner's command or null: checked here before @@ -14738,7 +14738,7 @@ async function cmdDashboard() { log(`server error: ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard: ${e.message}\n`); process.exit(1); }); keepAlive(server); - server.once('listening', () => { log(`started on http://${host}:${port} (node ${process.version})`); out(`devteam dashboard on http://${host}:${port}`); if (imageHub() === HUB_PORT) log(`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})`); else startHub(log, { kit: kitVersion() }); setInterval(accountsTick, accountsEveryMs()).unref(); }); + server.once('listening', () => { log(`started on http://${host}:${port} (node ${process.version})`); out(`devteam dashboard on http://${host}:${port}`); if (imageHub() === HUB_PORT) log(`hub: left to the image (DEVTEAM_IMAGE_HUB=${HUB_PORT})`); else startHub(log, { kit: ownKitVersion() }); setInterval(accountsTick, accountsEveryMs()).unref(); }); server.listen(port, host); } diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index fceec0d..97c3343 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -400,3 +400,20 @@ test('the hub runs with another home\'s DEVTEAM_RUN in its env, and writes nothi assert.ok(h.alive(), `the hub was refused: ${h.out()}`); assert.deepEqual(fs.readdirSync(elsewhere), [], 'nothing in that run folder'); }); + +// A dashboard run against a project by a kit that isn't that project's own checkout (the image's, or +// another copy) never names the project's release as the hub's. +test('a dashboard\'s hub run by a kit outside its project names no release', { timeout: 240000 }, async (t) => { + const HUB = PORTS + 35; + const M = hubMachine(t, 'hub-dash-foreign', HUB); + const A = bareProject(M.base, 'alpha'); + execFileSync('sh', ['-c', 'git add -A && git -c user.email=t@t -c user.name=t commit -qm init'], { cwd: A }); + const p = spawn(process.execPath, [imageKit(t), 'dashboard'], { cwd: A, env: M.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + let o = ''; + p.stdout.on('data', (b) => { o += b; }); + p.stderr.on('data', (b) => { o += b; }); + t.after(() => (p.exitCode === null && p.signalCode === null ? new Promise((r) => { p.once('exit', r); try { process.kill(-p.pid, 'SIGKILL'); } catch { r(); } }) : undefined)); + const list = await until(() => hubList(HUB), `the foreign kit's dashboard hub (${o})`); + assert.equal(list.kit, null); + assert.equal('image' in list, false); +}); From efd8ea9e63b7b5fc91ee218ad3124d0abd47cfc2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 15 Nov 2026 00:24:04 +0000 Subject: [PATCH 22/24] kit-46: final reviews 1 (approve) and 1b (changes, F1 fixed), dispositions Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/final-1.md | 40 +++++++++++++++++++ .../kit-46-standalone-hub/reviews/final-1b.md | 21 ++++++++++ 2 files changed, 61 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1.md create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1b.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1.md new file mode 100644 index 0000000..6646dcb --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1.md @@ -0,0 +1,40 @@ +# Final review, pass 1 (track A): kit-46-standalone-hub (kit PR #238) + +- Reviewer: final-a (fresh subagent of the supervisor, claude-opus) +- Reviewed: 4513dc5a4e899c8179a90429cca9304cda13809c + +| Id | Severity | Rule cited | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Minor | spec "What changes" 1 (owner actions spawned with `cwd: process.cwd()`) | devteam.mjs `startHub` `actionCwd`; `cmdOperatorThread` guard via `logWriter()` | A standalone hub started from a cwd inside a git checkout on a `plan/*` or `ticket/*` branch refuses every press and every `operator-thread status`. `logWriter()` reads the cwd's branch, so the child dies with "not from a plan-foo session" (I reproduced this with `operator-thread list` from a `plan/foo` repo). It fails closed and the spec asks for exactly this cwd. On the box the boot's cwd is kit-49's to choose. Worth one sentence in kit-49's boot, or in the help line. | deferred (Minor) | +| F2 | Minor | quality.md §6 (documents match the decision) | spec.md "What changes" 2, second bullet | The spec still says a dashboard's hub uses the own-kit guard. The plan's ruling (spec-2 F2-1) keeps a plain `kitVersion()` for a dashboard, and the code follows the plan. spec.md was never updated to match. | moot: f218627 gives a dashboard's hub the own-kit guard, as the spec says | +| F3 | Minor | none (hygiene) | test/hub-overview.test.mjs "the hub waits out another hub…" | When the test fails (mutant M7), the stand-in holder server `s` is never closed, so `node --test` hung for 18 minutes after the assertion failed and had to be killed. The holder servers should be closed in `t.after`. | deferred (Minor) | +| F4 | Suggestion | none | `startHub` `retry` | In standalone mode, `DEVTEAM_HUB_RETRY_MS=0` or a value that isn't a number gives a retry loop with no delay and no unref. The value comes only from the environment. | deferred (Suggestion) | + +## What I checked and how + +- Read quality.md §3, the playbook's open-PR step 2, the spec, plan, verification and task/spec/plan reviews, the design's kit-46 section, and the full diff `origin/main...4513dc5` outside `docs/devteam-dev`. +- Ran at 4513dc5 in the review worktree, one file at a time, with the required env: hub-overview 11/11 pass, operator-threads 19/19 pass, machine 23/23 pass. No port 4320 bound. Every test port is in the 20000–32699 block. +- Read `startHub` whole at the head. The request handler has no `standalone` branch. `ownHost`, the Origin, Sec-Fetch-Site and JSON content-type checks, `threadsNeedGateway()`, `gatewayProofProblem`, `seenNonces` and the child's own re-check of the proof (`cmdOperatorThread` `owner()`) are unchanged. The diff touches only the list head, the listen-error branch and the children's cwd. +- Box detection and the key: `onBox()`, `threadsNeedGateway()`, `gatewayThreadKey()` (BOX_THREAD_KEY when `/opt/devteam/gateway.mjs` exists), `operatorDir()` (`~/operator`) and the child's nonce store all depend on the env, the image's files and HOME. None depends on the cwd, mainRoot, runDir or projectDir. A hub run from a folder that is no checkout therefore uses the box's real key and nonce store. `DEVTEAM_BOX=off` only takes effect off the account's real HOME, and the boot uses the real HOME. +- Write paths: `operator-thread` (every verb, and `status`) and the hub itself never touch runDir, mainRoot, projectDir or the project log. The hub's log is `~/.devteam/hub-.log`. The `hub` exemption from kit-201's DEVTEAM_RUN guard opens nothing: the only children are `operator-thread`, which was already exempt. `ownerActionEnv` still strips ROLE, TICKET, FEATURE and TMUX_PANE. Under `DEVTEAM_ROLE=operator`, the dispatch guard (line ~19216) still refuses `hub`. +- `--release`: `parseArgs` gives `''` for `--release ""`, `true` for a bare flag (mapped to `''`), and a space-joined string for several values. Without the `m` flag the regex anchors to the whole string, so a trailing newline or an `-rc` suffix is refused. A bad value is logged and served with `kit: null` and no `image`, and the hub never exits for it. +- `/instances.json` gains only `kit` (a release string or null) and `image`, under the existing cross-site refusal. The gateway relays it as data to the owner's signed-in page. `machine status` caps the read at 64 KB and 3 s and prints a release only if it matches `^[\w.+-]{1,64}$`. +- `DEVTEAM_IMAGE_HUB` yield: the dashboard yields only on a plain port equal to its `HUB_PORT`, and once it yields it never retries, so it can't win the port back. In any case, whatever holds the port runs the same `ownerPost` on a kit of this release. The older-kit restart gap is documented and owned by the design. +- Crash and restart: on a port that is held, or any listen error, the standalone hub keeps a non-unref'd timer and never exits. A crash exits 1 after logging. The 1 MB rotation is checked at each write. + +## What I tried to break, and how it held (mutants in a scratch copy, each run against the named test) + +| Mutant | Result | +|---|---| +| M1: the standalone hub skips the gateway signature (`boxed = !standalone && …`) | killed (operator-threads, standalone press test) | +| M2: the standalone hub skips its in-memory nonce check | killed (the replay comes back 400 from the child's store, not 403) | +| M3: the standalone hub skips the Origin check | killed | +| M4: the standalone hub skips the own-Host check | killed | +| M5: the standalone hub skips the list's Sec-Fetch-Site refusal | killed | +| M6: a dashboard yields for any `DEVTEAM_IMAGE_HUB` value | killed (hub-overview) | +| M7: the standalone hub gives up on a non-hub holder | killed (the test failed, then hung on its open holder server: F3) | +| M8: `hub` removed from the DEVTEAM_RUN exemption | killed | + +A manual probe found F1. An unsigned, replayed or foreign-Origin press, a foreign Host, a missing or `same-site` Sec-Fetch-Site, a non-JSON body, and cross-site reads are all refused by the shipped test, and each check is pinned by a mutant above. I found no owner action that the standalone hub serves and the dashboard's hub didn't: `HUB_ACTIONS` is unchanged and the routing is shared. + +Verdict: APPROVE 4513dc5a4e899c8179a90429cca9304cda13809c diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1b.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1b.md new file mode 100644 index 0000000..8dcb5bc --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-1b.md @@ -0,0 +1,21 @@ +# Final review, pass 1 (track b) + +- Reviewer: final-b (fresh subagent of the supervisor, claude-sonnet) +- Reviewed: 4513dc5a4e899c8179a90429cca9304cda13809c + +| Id | Severity | Rule cited | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Important | spec "What changes" 2 (a dashboard's hub: kitVersion() only when the kit is the project's own, else null) | docs/devteam/scripts/devteam.mjs, cmdDashboard `server.once('listening')`: `startHub(log, { kit: kitVersion() })` | The dashboard passes `kitVersion()` unconditionally; only `cmdHub` uses `ownKitVersion()` (settings.md in projectDir and kitDir inside mainRoot). `kitVersion()` returns `settings.upstream_base` whenever kitDir is outside mainRoot, so a dashboard run by another kit (the image's, or home's via DEVTEAM_HOME) against a project, on a hub it wins, announces that project's release as the hub's `kit`. The spec says that case must be `null` ("never names that project's release as its own"). No test covers it (the hub-overview dashboard test uses only the project's own kit). Fix: pass `ownKitVersion()` there too, and add a test with a dashboard run from a copy of the kit outside the project. | fixed in f2186275cdc879fc55c21554ca40af8011262d68 (test "a dashboard's hub run by a kit outside its project names no release") | +| F2 | Minor | spec done-when 6 / verification.md | test/machine.test.mjs "status names the hub's kit, or says it is no hub, or down" | One failure in my first full run of machine.test.mjs (22/23); I did not capture the output. The test alone passed, and three further full runs passed 23/23 (the later two background reruns failed tests 15-23 only because I removed the worktree while they ran, so they say nothing). Probably a timing flake under load (3 s probe, drip case), not reproduced. Worth a look at the 3 s budgets, not a block. | deferred (Minor; not reproduced: machine.test 23/23 at f218627 on the box) | +| F3 | Minor | verification.md | docs/devteam-dev/tickets/kit-46-standalone-hub/verification.md | Says the hub-overview file has 1 skipped (Chrome); here Chrome was present and it ran 11/11. Not a defect, a count that varies by machine. | deferred (Minor; the count varies with Chrome) | + +## What I checked and how + +- Read quality.md section 3, spec.md, verification.md, then the whole diff (`origin/main...4513dc5`, excluding docs/devteam-dev). Only verification.md changed between a8c9b2f (the commit CI and the box ran) and 4513dc5, so those results apply. `gh`: run 43654583319 at a8c9b2f succeeded; PR 238's check rollup at 4513dc5 is all SUCCESS. +- Ran test/hub-overview.test.mjs (11/11 pass) and test/machine.test.mjs (see F2), each alone with the specified env, on Node 22. I did not rerun dashboard.test.mjs or operator-threads.test.mjs; I read the operator-threads additions only through the diff stat and the verification claims. +- Ran the standalone hub by hand in a copy of the kit (no project/, no run/) in a non-git folder, with HOME and a fixture port (43871): a non-hub held the port, the hub stayed up and logged the exact "held by something that isn't a devteam hub; retrying every 0.5 s" line once, took the port when the holder died, `/instances.json` answered `{"hub":1,"kit":"v1.2.3","image":true,...}`, SIGTERM logged `stopped by SIGTERM` and exited, the log is at `$HOME/.devteam/hub-.log`, and the cwd stayed empty. No real port was bound. +- Code reading against each done-when: `hub` is in `machineWide` and in the DEVTEAM_RUN exemption; the `--release` validation (`^v\d+\.\d+\.\d+$`, empty or bad logs once and serves `kit: null`, no `image`); no hub reads DEVTEAM_KIT_RELEASE; `imageHub()` accepts only `^\d+$` and the dashboard logs the exact line once and skips `startHub`, otherwise unchanged (laptop and older image unaffected); the standalone retry timer is not unref'd, and non-hub and other listen errors are logged once per distinct state; the log rotates when size plus line would pass 1 MB; owner actions use `process.cwd()` for the standalone hub and `mainRoot` for a dashboard's; `hubLine` prints the five lines as specified, caps the read at 64 KB, enforces 3 s, and prints `kit` only if it matches `^[\w.+-]{1,64}$`; the alert suffix is untouched. The help text lists `hub`; the README paragraph names the image's hub and the gap. +- No new file outside docs/devteam-dev (`--diff-filter=A` empty); nothing project-specific added (grep); no API newer than Node 18 (`statSync` `throwIfNoEntry`, `AbortSignal.timeout` and `http.get` are all fine; tests use `closeAllConnections?.`). +- Not verified by me: the box-only claims and the Node 18/20/24 CI matrix beyond the green rollup. + +Verdict: CHANGES 4513dc5a4e899c8179a90429cca9304cda13809c From bbed0e9a5c3932d331ac13faf11627ff1ff196ad Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 15 Nov 2026 00:34:19 +0000 Subject: [PATCH 23/24] kit-46: final review 2 (approve efd8ea9) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/final-2.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2.md new file mode 100644 index 0000000..f490514 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2.md @@ -0,0 +1,15 @@ +# kit-46-standalone-hub: final-2 (pass 2, cold confirm) + +- Reviewer: final-2 (fresh subagent of the supervisor, claude-sonnet) +- Reviewed: efd8ea9e63b7b5fc91ee218ad3124d0abd47cfc2 (diff since 4513dc5) + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| (none) | - | - | - | No findings. F1 from final-1b is fixed. | n/a | + +## What I checked + +1. Diff 4513dc5..efd8ea9 (--stat, then full diff outside docs/devteam-dev/): code change is only `startHub(log, { kit: ownKitVersion() })` in cmdDashboard, the startHub comment reworded to match, and one new test in test/hub-overview.test.mjs. The rest is two review records (final-1.md, final-1b.md) under the ticket's reviews folder. +2. ownKitVersion() (line 13864) returns kitVersion() only when `settings.md` exists in projectDir and kitDir is inside mainRoot, else null. This is exactly spec §2 (own kit reports its release, done-when 1; a kit from outside the project, e.g. the image's or via DEVTEAM_HOME, reports null). kitVersion() returns the upstream tag (not null) when kitDir is outside mainRoot, so before the fix a foreign kit would have named a release; the new test runs an imageKit copy (in a temp dir, outside the project) with `dashboard` and asserts `list.kit === null` and no `image`, so it fails without the fix. The own-kit case stays covered by the existing dashboard test (kit equals hello --json kit). I ran the named test in a detached worktree (env scrubbed as instructed): both matching tests passed (2 pass, 0 fail). Worktree removed. +3. The change only alters the string passed as `kit`; startHub's handler, press checks, writes and cwd are untouched, so no new path for presses or writes. +Verdict: APPROVE efd8ea9e63b7b5fc91ee218ad3124d0abd47cfc2 From 51d61a2b89915c81da16ff3733291fce4b5efc70 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sun, 15 Nov 2026 00:34:54 +0000 Subject: [PATCH 24/24] kit-46: final review 2b (track b cold confirm, approve) Co-Authored-By: Claude Opus 5.5 --- .../kit-46-standalone-hub/reviews/final-2b.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2b.md diff --git a/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2b.md b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2b.md new file mode 100644 index 0000000..2f9883b --- /dev/null +++ b/docs/devteam-dev/tickets/kit-46-standalone-hub/reviews/final-2b.md @@ -0,0 +1,15 @@ +# kit-46-standalone-hub: final-2 (pass 2, cold confirm) + +- Reviewer: final-2b (fresh subagent of the supervisor, claude-sonnet; the cold confirm of track b, final-1b F1. Same review as final-2.md, filed under track b by the supervisor, who misnamed it) +- Reviewed: efd8ea9e63b7b5fc91ee218ad3124d0abd47cfc2 (diff since 4513dc5) + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| (none) | - | - | - | No findings. F1 from final-1b is fixed. | n/a | + +## What I checked + +1. Diff 4513dc5..efd8ea9 (--stat, then full diff outside docs/devteam-dev/): code change is only `startHub(log, { kit: ownKitVersion() })` in cmdDashboard, the startHub comment reworded to match, and one new test in test/hub-overview.test.mjs. The rest is two review records (final-1.md, final-1b.md) under the ticket's reviews folder. +2. ownKitVersion() (line 13864) returns kitVersion() only when `settings.md` exists in projectDir and kitDir is inside mainRoot, else null. This is exactly spec §2 (own kit reports its release, done-when 1; a kit from outside the project, e.g. the image's or via DEVTEAM_HOME, reports null). kitVersion() returns the upstream tag (not null) when kitDir is outside mainRoot, so before the fix a foreign kit would have named a release; the new test runs an imageKit copy (in a temp dir, outside the project) with `dashboard` and asserts `list.kit === null` and no `image`, so it fails without the fix. The own-kit case stays covered by the existing dashboard test (kit equals hello --json kit). I ran the named test in a detached worktree (env scrubbed as instructed): both matching tests passed (2 pass, 0 fail). Worktree removed. +3. The change only alters the string passed as `kit`; startHub's handler, press checks, writes and cwd are untouched, so no new path for presses or writes. +Verdict: APPROVE efd8ea9e63b7b5fc91ee218ad3124d0abd47cfc2