@bongos/core 1.19.882 → 1.19.883

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.bongos-core.json +52 -52
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +6 -0
  6. package/docs/copy-inventory.md +24 -23
  7. package/docs/copy-registry.json +34 -25
  8. package/docs/file-map.md +1 -2
  9. package/docs/module-api-changelog.md +3 -1
  10. package/docs/recipes/bongos-cli-release.md +1 -1
  11. package/docs/recipes/managed-settings-remote-control.md +1 -1
  12. package/migrations/core_248_module_enablement_overrides.sql +50 -0
  13. package/modules/dev-box/app/README.md +4 -3
  14. package/modules/hall-ui/public/collab-lib.js +33 -1
  15. package/modules/hall-ui/public/collab.css +33 -4
  16. package/modules/hall-ui/public/collab.html +9 -2
  17. package/modules/hall-ui/public/collab.js +68 -15
  18. package/modules/hall-ui/public/dom-utils.js +23 -1
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/scripts/hall-preview/server.js +38 -1
  22. package/src/bongos/module-overrides.js +117 -0
  23. package/src/bongos/routes/modules.js +56 -23
  24. package/src/module-api.js +1 -1
  25. package/src/modules.js +120 -14
  26. package/src/platform-server.js +47 -5
  27. package/tests/box_sweep_docs.mjs +7 -3
  28. package/tests/collab_page.mjs +136 -1
  29. package/tests/modules.mjs +110 -0
  30. package/tests/modules_route_wiring.mjs +103 -0
  31. package/docs/recipes/builder-box-lifecycle.md +0 -181
  32. package/docs/recipes/devbox-app-release.md +0 -103
  33. package/docs/recipes/devbox-app-windows-test.md +0 -128
  34. package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
@@ -0,0 +1,103 @@
1
+ // tests/modules_route_wiring.mjs — task 1002857.
2
+ //
3
+ // WHY THIS FILE EXISTS. The DB-backed enablement override shipped its first round
4
+ // with `moduleOverrides.listOverrides()` and `moduleOverrides.setOverride()` called
5
+ // in src/bongos/routes/modules.js and NOTHING binding that name — the require was
6
+ // lost to a patch that aborted before writing. Every call to GET /modules and
7
+ // POST /modules/:key/enable would have thrown ReferenceError.
8
+ //
9
+ // tests/modules.mjs did not catch it and could not: it covers the PURE layer —
10
+ // resolveModules, moduleScopeKey, setModuleOverrides — and a resolver cannot know
11
+ // whether the route that calls it can see its collaborators. The gap was the
12
+ // seam, so this file tests the seam: it INVOKES the handlers rather than merely
13
+ // importing the module, because an unbound identifier is a runtime error and
14
+ // importing a file with one is perfectly quiet.
15
+ //
16
+ // Hermetic. No DB, no port, no supertest: the router is built, its handler pulled
17
+ // off the Express stack and driven with a fake req/res. listOverrides() swallows
18
+ // its own connection error and returns [], which is the documented no-database
19
+ // behaviour — so GET must still answer here.
20
+ //
21
+ // Run: node --test tests/modules_route_wiring.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { test } from 'node:test';
25
+ import { createRequire } from 'node:module';
26
+ import { listOverrides, setOverride, refreshOverrides, clearOverride } from '../src/bongos/module-overrides.js';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
30
+
31
+ // The LAST handler on a route is the asyncHandler-wrapped business logic; the
32
+ // earlier layers are auth, which this test deliberately does not exercise.
33
+ function handlerFor(router, method, path) {
34
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
35
+ assert.ok(layer, `no ${method.toUpperCase()} ${path} on the router`);
36
+ const stack = layer.route.stack;
37
+ return stack[stack.length - 1].handle;
38
+ }
39
+
40
+ function fakeRes() {
41
+ const res = {
42
+ statusCode: 200,
43
+ body: undefined,
44
+ json(b) { this.body = b; return this; },
45
+ status(c) { this.statusCode = c; return this; },
46
+ fail(code, opts) { this.body = { error: code, ...opts }; return this; },
47
+ };
48
+ return res;
49
+ }
50
+
51
+ // An error thrown inside the handler must reach us, not be swallowed into a
52
+ // passing test — asyncHandler forwards to next(err), so capture that too.
53
+ async function invoke(handler, req) {
54
+ const res = fakeRes();
55
+ let nextErr = null;
56
+ await handler(req, res, (err) => { nextErr = err; });
57
+ if (nextErr) throw nextErr;
58
+ return res;
59
+ }
60
+
61
+ test('1002857: GET /modules runs — every collaborator it calls is actually bound', async () => {
62
+ const router = buildModulesRouter();
63
+ const res = await invoke(handlerFor(router, 'get', '/modules'), { params: {}, query: {}, body: {} });
64
+ assert.ok(Array.isArray(res.body.modules), 'it answered with a module list');
65
+ // The fields this task added, proving the override collaborator really ran
66
+ // rather than being skipped by a short-circuit somewhere.
67
+ assert.equal(typeof res.body.scope, 'string', 'the scope key is reported');
68
+ assert.ok(res.body.scope.length > 0);
69
+ for (const m of res.body.modules) {
70
+ assert.equal(typeof m.enabled_source, 'string', `${m.key} reports which layer won`);
71
+ assert.ok('override' in m, `${m.key} reports its override row (null when none)`);
72
+ }
73
+ });
74
+
75
+ test('1002857: POST /modules/:key/enable rejects a non-boolean before touching the database', async () => {
76
+ // Drives the toggle handler far enough to prove IT is wired too, without a DB:
77
+ // the body check comes first, so this exercises the handler's own code path and
78
+ // would still have thrown ReferenceError had the require stayed missing... which
79
+ // is why the next assertion matters more than this one.
80
+ const router = buildModulesRouter();
81
+ const res = await invoke(handlerFor(router, 'post', '/modules/:key/enable'), {
82
+ params: { key: 'dev-box' }, query: {}, body: { enabled: 'yes-please' }, builder: { id: 1 },
83
+ });
84
+ assert.equal(res.body.error, 'invalid_body');
85
+ });
86
+
87
+ test('1002857: the route module can see module-overrides — the require the first round lost', () => {
88
+ // The direct form of the blocker, stated so it cannot regress by a different
89
+ // route: the file must BIND the collaborator, and that collaborator must carry
90
+ // the two methods the handlers call. src/modules.js also exports a
91
+ // `moduleOverrides` (a sync getter for the snapshot), which is NOT this module —
92
+ // mistaking one for the other is exactly how the first round read as plausible.
93
+ const src = require('node:fs').readFileSync('src/bongos/routes/modules.js', 'utf8');
94
+ assert.match(src, /require\('\.\.\/module-overrides'\)/, 'the route requires ../module-overrides');
95
+ // Asserted through the NAMED IMPORTS at the top of this file rather than by indexing the
96
+ // module with strings. Two reasons, and the second is why it changed: a missing export now
97
+ // fails at load naming the export, instead of at an assertion; and knip cannot see a string
98
+ // as a usage, so indexing left clearOverride reading as dead code and tripped the ratchet
99
+ // that held this branch out of main for 20h (task 1004052 strand walk).
100
+ for (const [name, fn] of Object.entries({ listOverrides, setOverride, refreshOverrides, clearOverride })) {
101
+ assert.equal(typeof fn, 'function', `module-overrides exports ${name}`);
102
+ }
103
+ });
@@ -1,181 +0,0 @@
1
- # Recipe — per-builder dev box lifecycle (provision · auto-suspend · reclaim)
2
-
3
- > **Task [#598](https://example.com/builders#/task/598) · [ADR 0031](../adr/0031-cloud-dev-environments-for-builders.md) §2/§5/§9.3.** This is the operator guide for the per-builder DigitalOcean dev boxes. The code ships in the repo; **going live is a deliberate, money-spending operator action** (like `provision-staging.sh`) — nothing here runs until you set `DO_API_TOKEN` and pass `--apply`.
4
-
5
- ## The shape
6
-
7
- Each builder gets **one** box (ADR 0031 §2). Its lifecycle is a small state machine tracked in the `builder_boxes` table:
8
-
9
- ```
10
- none --provision--> active
11
- ^ |
12
- wake (manual, or | | idle > BOX_IDLE_MINUTES (auto, hourly)
13
- on next connect) | | park (manual)
14
- | v
15
- parked --- parked > BOX_DORMANT_DAYS ---> destroyed
16
- (auto, daily, WARNS FIRST) snapshot deleted
17
-
18
- park keeps the SNAPSHOT (only the droplet is destroyed, to stop billing),
19
- so it is reversible with `wake`. Dormant reclaim is the ONLY automated
20
- path that destroys a snapshot, and it warns the builder first.
21
- ```
22
-
23
- - **provision** — create + boot a droplet running the [#596](https://example.com/builders#/task/596) devcontainer. **Rank-gated.**
24
- - **idle park** (auto, hourly) — when an `active` box is idle > `BOX_IDLE_MINUTES`, snapshot it and **destroy the droplet only**, keeping the snapshot ([task 1002726](https://example.com/builders#/task/1002726)). Compute billing stops; the box is **reversible** via `wake`. An earlier revision ([task 804](https://example.com/builders#/task/804)) destroyed the snapshot too; that was reverted because it took uncommitted work with it (spike [task 837](https://example.com/builders#/task/837)).
25
- - **park** (the idle sweep, or an Archon by hand) — snapshot the droplet then **destroy the droplet**. A powered-off DO droplet still bills; only a destroyed one stops. The snapshot (~$0.30/mo) is the cheap residual you can `wake` from.
26
- - **wake** (manual, on demand) — recreate the droplet from its snapshot. New IP; the `hostname` is repointed via `BOX_DNS_HOOK`.
27
- - **dormant reclaim** (auto, daily) — destroy the snapshot of a box `parked` > `BOX_DORMANT_DAYS`. This is the **only automated path that destroys a snapshot**, so it **warns the builder first** and defers the reclaim if that warning was never delivered ([task 1002727](https://example.com/builders#/task/1002727)). Its input is whatever the idle sweep parked, plus any manual Archon park.
28
-
29
- ### How "idle" is detected
30
-
31
- The idle sweep **parks** a box when its `last_activity_at` (else `active_since`) is older than `BOX_IDLE_MINUTES` **and** `claude_active` is not true. Both signals are set by **`infra/box-heartbeat.sh`**, which runs **inside the box** every ~5 min and POSTs `/api/gds/box/heartbeat` *only while the box is in use* — an interactive login session, an established SSH connection, an **open browser terminal** (ttyd websocket, [#1161](https://example.com/builders#/task/1161)), or non-trivial CPU load. Silence = the sweep is free to park it.
32
-
33
- > ⚠️ **The idle sweep's correctness depends on the box-side heartbeat reaching the server.** If it isn't pinging — no Bongos token on the box yet, a pre-[#1161](https://example.com/builders#/task/1161) script that can't see a browser terminal, or the cron not installed — `last_activity_at` never advances and the sweep falls back to `active_since`, i.e. it measures **uptime**, not activity, and will **park** a box someone is actively using. Because the idle path keeps the snapshot ([task 1002726](https://example.com/builders#/task/1002726)), that box can be restored with `wake` and no work is lost — it is disruptive, not destructive. The destructive case is the dormant sweep, which warns first ([task 1002727](https://example.com/builders#/task/1002727)).
34
- >
35
- > **Live mitigation (2026-06-16):** `BOX_IDLE_MINUTES` is raised to **120** in `/etc/example/box.env` as a soak margin until [#1161](https://example.com/builders#/task/1161)'s browser-terminal heartbeat is verified on a newcomer box; lower it back toward the 10-min default once that's proven.
36
-
37
- Two sweeps automate the middle (both mirror `stale-claim-release` — dry-run first, idempotent, capped):
38
-
39
- | Sweep | Cadence | What it does | Threshold |
40
- |---|---|---|---|
41
- | `box-idle-suspend` | hourly | **park** `active` boxes with no activity — snapshot kept, droplet destroyed ([task 1002726](https://example.com/builders#/task/1002726)); reversible via `wake` | `BOX_IDLE_MINUTES` (default 10; **currently 120 live**) |
42
- | `box-dormant-reclaim` | daily | destroy the snapshot of long-`parked` boxes — the only automated snapshot delete, and it warns first ([task 1002727](https://example.com/builders#/task/1002727)) | `BOX_DORMANT_DAYS` (14) |
43
-
44
- > ⚠️ **Both sweeps are currently `enabled: false`** in `config/scheduled-routines.json` — retired with the dev-box module (goal 1000091, [task 1003886](https://example.com/builders#/task/1003886)). Neither fires; the CLI below still works by hand.
45
-
46
- ## Why a self-built sweep (not Coder)
47
-
48
- ADR 0031 §9.3, resolved 2026-06-04: Coder needs an always-on control-plane droplet (~$12–24/mo fixed) + ops; a self-built sweep has ≈ $0 fixed cost and rides the existing sweep/timer pattern. See the ADR for the full rationale. (The original sweep snapshotted-and-parked idle boxes; [task 804](https://example.com/builders#/task/804) briefly simplified the idle path to a direct deprovision, and [task 1002726](https://example.com/builders#/task/1002726) reverted it — so the snapshot/wake machinery serves the automatic idle path again, as well as the manual `park`/`wake` commands.)
49
-
50
- ## The CLI — `node scripts/gds/box.js`
51
-
52
- **Dry-run is the default. Nothing touches DigitalOcean or the DB without `--apply`.** Run any command once without `--apply` to see the plan.
53
-
54
- ```bash
55
- node scripts/gds/box.js status # roster + cost (read-only)
56
- node scripts/gds/box.js provision <login|id> # plan a provision
57
- node scripts/gds/box.js provision <login|id> --apply
58
- node scripts/gds/box.js park <login|id> --apply
59
- node scripts/gds/box.js wake <login|id> --apply
60
- node scripts/gds/box.js deprovision <login|id> --apply
61
- node scripts/gds/box.js sweep-idle # plan; add --apply to act
62
- node scripts/gds/box.js sweep-dormant # plan; add --apply to act
63
- node scripts/gds/box.js reconcile-drift # DO⇄DB drift; add --apply to act
64
- ```
65
-
66
- `<builder>` is a GitHub login or a numeric builder id.
67
-
68
- ### Drift reconcile — `reconcile-drift` (task [#1289](https://example.com/builders#/task/1289), [ADR 0071](../adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md))
69
-
70
- The teardown is now **confirm-before-destroyed**: `deprovision` only marks a box `destroyed` AFTER DigitalOcean confirms the droplet is deleted (idempotent + retried). A delete that genuinely fails parks the box at `state='error'` with `droplet_id` preserved and **throws** — it never silently leaves a running droplet behind a `destroyed` row (the old "zombie" bug).
71
-
72
- `reconcile-drift` is the backstop. It lists the real fleet droplets (a live read — needs `DO_API_TOKEN`, present in `box.env`) and reconciles against the Bongos rows **both ways**:
73
-
74
- - **Zombie** — a droplet no live box row claims → force-delete the droplet + its tunnel/DNS.
75
- - **Ghost** — a live row whose `droplet_id` vanished from DO → mark it `destroyed`.
76
-
77
- A droplet younger than 30 min is never killed (it may be a provision in flight). It runs hourly on the control plane via `box-drift-reconcile.timer` (see *Going live* below). Run it by hand (dry-run first) any time you suspect drift:
78
-
79
- ```bash
80
- node scripts/gds/box.js reconcile-drift # show the plan
81
- node scripts/gds/box.js reconcile-drift --apply # repair
82
- ```
83
-
84
- ### Rank gate (ADR 0016 trust boundary)
85
-
86
- `provision` reads the builder's **live DB rank** (the same value `requireRank` reads — uncacheable, server-side) and refuses below `BOX_PROVISION_MIN_RANK`. Default floor is `xenos`: per ADR 0031 §5(b) even an onboarded newcomer gets an org-funded **starter** box. The **scope** differs by rank, not the right to a box:
87
-
88
- - **xenos → `scope='starter'`** — the box is tagged for the scoped surface [#600](https://example.com/builders#/task/600) will enforce.
89
- - **metic / archon → `scope='full'`** — full private-repo access (also [#600](https://example.com/builders#/task/600)).
90
-
91
- Raise the floor (e.g. boxes for Metic+ only) by setting `BOX_PROVISION_MIN_RANK=metic`.
92
-
93
- ## Config (environment)
94
-
95
- Put these in `/etc/example/box.env` (chmod 600 — **secrets never go in the repo**, [ADR 0022](../adr/0022-secrets-policy.md)). The systemd units load it via `EnvironmentFile=`.
96
-
97
- | Var | Default | Meaning |
98
- |---|---|---|
99
- | `DO_API_TOKEN` | — | DigitalOcean API token. **Required for `--apply`**; absent ⇒ live calls throw clearly. |
100
- | `BOX_PROVISION_MIN_RANK` | `xenos` | provisioning rank floor |
101
- | `BOX_REGION` | `nyc3` | droplet region |
102
- | `BOX_SIZE` | `s-2vcpu-4gb` | droplet size (4 GB floor — the devcontainer sets a 4 GB Node heap) |
103
- | `BOX_BASE_IMAGE` | `ubuntu-24-04-x64` | first-provision base image (cloud-init installs Docker + the devcontainer) |
104
- | `BOX_IDLE_MINUTES` | `10` | idle-**park** threshold (minutes). **Currently overridden to `120` live** as a soak margin — see "How idle is detected". |
105
- | `BOX_DORMANT_DAYS` | `14` | dormant-reclaim threshold |
106
- | `BOX_SSH_KEY_IDS` | — | comma-list of DO SSH key ids/fingerprints to inject (so you can SSH in) |
107
- | `BOX_REPO_URL` | — | git URL the cloud-init clones into `/workspace`. The scoped **credential** is [#600](https://example.com/builders#/task/600); no identity is hardcoded. |
108
- | `BOX_DNS_HOOK` | — | optional shell command run on provision/wake with `BOX_HOSTNAME`/`BOX_IP` in env, to repoint the per-builder hostname |
109
- | `BOX_DNS_ZONE` | `dev.example.com` | the per-builder hostname zone (`box-<login>.<zone>`) |
110
- | `BOX_TAG` | `otb-builder-box` | DO tag applied to every box droplet |
111
-
112
- ## Cost (Criterion C1 / cost)
113
-
114
- A box accrues compute cost **only while `active`**. At park/deprovision the CLI computes `active hours × the size's hourly rate` and writes it to both the `box_events` ledger and `cost_log` (category `compute`, builder-attributed) — so the public status dashboard's per-builder split sees real box spend. `box.js status` prints the active monthly run-rate + total spent. The **rebill to example/earnings** (org-funded starter → self-fund) is a separate task, [#602](https://example.com/builders#/task/602).
115
-
116
- ## Going live (one-time operator steps)
117
-
118
- These spend money and are intentionally manual.
119
-
120
- > **Hardware-verified end-to-end (2026-06-06, [#720](https://example.com/builders#/task/720)).** The lane now boots a *usable* box: auto-provision by draining the intent queue (`box.js run-intents` / `box-intent-runner.timer`), wake-on-connect from snapshot via `POST /box/ensure`, and SSH connect with the injected key. **`box-intent-runner.timer` is installed + enabled on the control plane as of [#720](https://example.com/builders#/task/720).** That run also found **[#724](https://example.com/builders#/task/724)**: the cloud-init template carried non-ASCII characters (em-dash, §) that cloud-init's strict YAML loader silently rejected — so *every* box before [#724](https://example.com/builders#/task/724) (including the [#699](https://example.com/builders#/task/699) lifecycle test) booted as a bare Ubuntu image with no devcontainer. **Keep `infra/builder-box-cloud-init.yaml` ASCII-only** (scan with `LC_ALL=C grep -nP '[^\x00-\x7f]' infra/builder-box-cloud-init.yaml`) and assert `cloud-init status` is `done` (not `degraded`) on the box after any provision change. Still deferred to **[#725](https://example.com/builders#/task/725)** (needs `BOX_SOURCE_*` GitHub tokens): the rank-gated source-fetch ([#600](https://example.com/builders#/task/600)) leg, full RC pairing, the fresh-account dry-run, plus the `box-source-fetch.sh` `HOME`-unbound fix. The idle/dormant sweeps are now LIVE — wired on the control plane 2026-06-15 ([#1101](https://example.com/builders#/task/1101)); they depend on the box-heartbeat, which needs the source clone (leg 1).
121
-
122
- > **Source + DNS verified (2026-06-06, [#725](https://example.com/builders#/task/725)).** **Leg 1 ✅** — `BOX_SOURCE_*` is wired on the **web** tier via `/etc/example/web-source.env` (a systemd drop-in `EnvironmentFile`, deliberately separate from `box.env` so the internet-facing web process never holds `DO_API_TOKEN`); `GET /box/source-access` issues a rank-scoped credential and the box clones into `/workspace` with the credential **scrubbed from `.git/config`**. **[#599](https://example.com/builders#/task/599) hostname cutover ✅ DONE** — in `box.env`: `CLOUDFLARE_API_TOKEN` (Zone:DNS:Edit, IP-locked to the droplet) + `BOX_DNS_HOOK=/home/lars/example/infra/box-dns-cloudflare.sh`; `box.js` runs it on every provision/wake so `box-<login>.dev.example.com` tracks the box IP (verified live: a park→wake gave a new IP and DNS auto-updated with no manual step; SSH via the hostname works). **The hook forces `curl -4`** — the control-plane droplet has IPv6 egress that would otherwise bypass an IPv4 token allow-list. Gotcha: a Cloudflare token IP-locked to the droplet's IPv4 returns "Invalid API Token" from `/user/tokens/verify` (and from any IPv6-sourced call) — test against the real op (`GET /zones`), not the verify endpoint, especially for account-scoped `cfat_`-prefixed tokens. Still partial: leg 5 (RC pairing needs a `claude` login on the box) + leg 6 (fresh-account needs a 2nd GitHub account). **DNS reliability hardened ([#1180](https://example.com/builders#/task/1180), 2026-06-16):** the hook now RETRIES every Cloudflare call with backoff — a transient auth blip during the provision call-burst used to leave the A record pinned at the prior (now-dead) IP while reporting a misleading "zone not found"; it now self-heals and, on a genuine failure, surfaces the real CF error code. `runDnsHook` no longer swallows a hook failure (it records a `box_events` `error` row and flags the box DNS-stale instead of logging a clean "✓ active"). The browser terminal is independent of this record (it rides the outbound tunnel). _(Root cause: @kingaonline's Claude Desktop hit "handshake timeout" SSHing to a hostname pinned at a dead IP.)_ **DNS teardown refined ([idea 309](https://example.com/builders#/idea/309) → task [#1234](https://example.com/builders#/task/1234)):** on deprovision `deprovisionOne` **repoints** the A record to a non-routable blackhole sentinel (`192.0.2.1`, TEST-NET-1 / RFC 5737) via `runDnsHookBlackhole` instead of **deleting** it. 1180 originally deleted the record on teardown so it couldn't dangle at a recycled DO IP — but delete-then-recreate across a reap→rebuild opened a window where the name returned `NXDOMAIN`, which resolvers negative-cache for the zone's SOA neg-TTL (~30 min on the Cloudflare `example.com` zone, not builder-editable); a client that queried mid-gap (e.g. an open Claude Desktop) then kept getting `ENOTFOUND` long after the box was back. A record that always resolves (to a guaranteed-dead sentinel) has nothing to negative-cache, yet still can't dangle at a recycled IP; the next provision/wake `PUT`s it straight to the live IP at the 60s record TTL, no gap. (Lowering the neg-TTL — the other lever floated in idea 309 — isn't used: Cloudflare's per-zone SOA neg-TTL isn't builder/API-editable, and the repoint makes it moot.) The hook's `BOX_DNS_DELETE=1` / `--delete` mode is retained for **manual** operator cleanup.
123
-
124
- 1. **Token + config.** Create `/etc/example/box.env` (chmod 600) with at least `DO_API_TOKEN` and `BOX_SSH_KEY_IDS`. Apply migration 067 on the prod DB (`bash scripts/migrate.sh`).
125
- > **`DO_API_TOKEN` must carry `tag:create` scope.** The first 2026-06-04 hardware run failed with `DO POST /droplets … missing the required permission tag:create` (the CLI applies `BOX_TAG` to every box). Use a **Full-Access** token, or a custom token that includes **tag write** alongside droplet + snapshot read/write.
126
- > **Hardware-verified (2026-06-04, [#699](https://example.com/builders#/task/699)):** the full `provision → park → wake → deprovision` cycle has run on real DigitalOcean droplets (`box_events` ledger). Still unverified on hardware: the rank-gated source-fetch ([#600](https://example.com/builders#/task/600)) leg and the live Claude Desktop connect.
127
- 2. **Try one box in dry-run, then live.**
128
- ```bash
129
- node scripts/gds/box.js provision <login> # read the plan
130
- node scripts/gds/box.js provision <login> --apply # create it
131
- node scripts/gds/box.js status
132
- ```
133
- SSH in (DO assigns the IP shown), confirm the devcontainer came up, then `park`/`wake` it once to confirm the snapshot round-trips.
134
- 3. **Wire the box-side heartbeat** (so the idle sweep measures activity, not uptime). On the box, schedule `infra/box-heartbeat.sh` every ~5 min — e.g. `*/5 * * * * /workspace/infra/box-heartbeat.sh >> /tmp/box-heartbeat.log 2>&1` (or hang it off [#599](https://example.com/builders#/task/599)'s box service supervisor). Confirm `GET /api/gds/box/me` shows `last_activity_at` advancing while you're connected.
135
- 4. **Wire the sweeps + the onboarding intent runner** (systemd units ship in `infra/`):
136
- ```bash
137
- sudo cp infra/box-idle-suspend.{service,timer} infra/box-dormant-reclaim.{service,timer} infra/box-intent-runner.{service,timer} infra/box-drift-reconcile.{service,timer} /etc/systemd/system/
138
- sudo systemctl daemon-reload
139
- sudo systemctl enable --now box-idle-suspend.timer box-dormant-reclaim.timer box-intent-runner.timer box-drift-reconcile.timer
140
- systemctl list-timers 'box-*' # confirm next-run times
141
- ```
142
- Tail `journalctl -u box-idle-suspend.service` for a day to confirm boxes park + wake cleanly.
143
- > **`box-intent-runner.timer` ([#701](https://example.com/builders#/task/701)) is the on-ramp's responsiveness knob.** It runs `box.js run-intents --apply` every minute, draining the `box_intents` queue that `POST /api/gds/box/ensure` writes — so a builder who hits "connect" gets their box **auto-provisioned** (no box yet) or **woken** (parked) within ~1 min, with no operator CLI. It reads the SAME `/etc/example/box.env` as the sweeps. Without it installed, `ensure` still queues intents — they just sit pending until someone runs `box.js run-intents --apply` by hand. Confirm with `journalctl -u box-intent-runner.service` after a test `ensure`.
144
- > **`box-drift-reconcile.timer` (task [#1289](https://example.com/builders#/task/1289)/[ADR 0071](../adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md)) is the zombie backstop.** It runs `box.js reconcile-drift --apply` hourly, reading the live DO droplet list and repairing DO⇄DB drift both ways — force-deleting **zombie** droplets (running but no live box row claims them) and marking **ghost** rows destroyed (droplet gone from DO). Reads the SAME `/etc/example/box.env` (needs `DO_API_TOKEN` to enumerate droplets). Without it, the confirm-before-destroyed teardown still prevents *new* zombies, but a pre-existing one (or an out-of-band DO change) only heals on the next manual `reconcile-drift`. Confirm with `journalctl -u box-drift-reconcile.service`.
145
- > **Chromebook / browser terminal ([#760](https://example.com/builders#/task/760)/[ADR 0038](../adr/0038-chromebook-ttyd-cloudflare-tunnel.md), superseding the old `claude rc` pairing).** Research proved `claude rc` cannot run headless, so the box instead serves its OWN browser terminal — **ttyd** (127.0.0.1:7681, per-box basic-auth, drops into `/workspace`) exposed over an outbound-only **Cloudflare Tunnel** — where the builder runs normal interactive `claude`. A browser/Chromebook builder gets the URL + credential from `GET /api/gds/box/terminal` after their box is active — no SSH needed. The terminal/cloudflared units are inlined in cloud-init and start at first boot ([#774](https://example.com/builders#/task/774)). The deleted RC bits (`claude-rc.service`, `box-report-pairing.sh`, `/box/pairing(-link)`) are gone.
146
-
147
- ## Cloudflare token: the edge cookie-strip rule (ADR 0053 / SR#11)
148
-
149
- `provision` applies two zone **rulesets** best-effort: the **cookie-strip Transform Rule** (removes the `Cookie` request header on `term-*`/`sandbox-*`, so the apex session cookie never rides the tunnel onto a box — a host outside the trust boundary) and the **sandbox cache-bypass Cache Rule**. Both need scopes beyond Tunnel + DNS, and a token that lacks them fails **silently** — the box provisions fine and the security control simply isn't there.
150
-
151
- Since task 1002511, `box.js provision` **read-probes** the ruleset phase before spending a cent (dry-run included) and prints a `⚠⚠ SECURITY` block naming the fix. Read the probe honestly: it is a `GET`, so it can only prove the scope is **missing** (401/403). A readable phase means *readable* — edit scope is not proven until the rule PUT actually runs, which is why the summary line after `✓ named tunnel ready` is the authoritative signal.
152
-
153
- **Fix — widen the token** (account owner only):
154
-
155
- > CF dashboard → **My Profile → API Tokens** → edit the token used as `CLOUDFLARE_API_TOKEN` → **Permissions** → add **Zone → Transform Rules → Edit** and **Zone → Cache Rules → Edit** → **Zone Resources → Include → Specific zone → `<your apex zone>`** → Continue → Save. Then re-run `node scripts/gds/box.js provision <login> --apply`.
156
-
157
- **Fallback — apply the rule by hand** (no token change; do it once per zone):
158
-
159
- > CF dashboard → the zone → **Rules → Transform Rules → Modify Request Header** → Create rule. Custom filter expression: `starts_with(http.host, "term-") or starts_with(http.host, "sandbox-")` (the exported `COOKIE_STRIP_EXPRESSION`). Action: **Remove** header `Cookie` (phase `http_request_late_transform`). Deploy. Give it the same description the code uses (`COOKIE_STRIP_RULE_DESC`) so a later widened-token run recognises and replaces it instead of adding a duplicate.
160
-
161
- **Verify:**
162
-
163
- ```bash
164
- node scripts/gds/box.js provision <login> # dry-run — prints the probe verdict, spends nothing
165
- curl -sI https://term-<login>.<apex>/ -H 'Cookie: x=1' # from a box-facing check, confirm the origin sees no Cookie
166
- ```
167
-
168
- > `/user/tokens/verify` is **not** a valid check here — an IP-locked token answers "Invalid API Token" there even when it is valid (see the [#725](https://example.com/builders#/task/725) note above). Always probe the real operation.
169
-
170
- ## Boundaries (other ADR-0031 tasks)
171
-
172
- - **[#596](https://example.com/builders#/task/596)** (done) — the devcontainer (box-as-code) this provisions.
173
- - **[#599](https://example.com/builders#/task/599)** — Claude Desktop managed-settings + the stable-hostname cutover the lifecycle records. (The browser-on-ramp is now the ttyd web terminal over a Cloudflare Tunnel — [#760](https://example.com/builders#/task/760)/[ADR 0038](../adr/0038-chromebook-ttyd-cloudflare-tunnel.md) — not the retired `claude rc` Remote-Control service.)
174
- - **[#600](https://example.com/builders#/task/600)** — rank-gated **source access**: the scoped GitHub credential the box holds, granted/revoked by rank. [#598](https://example.com/builders#/task/598) records `scope`; [#600](https://example.com/builders#/task/600) enforces it.
175
- - **[#602](https://example.com/builders#/task/602)** — cost passthrough (org-funded starter → self-fund via the `costs`/example system). [#598](https://example.com/builders#/task/598) logs the cost; [#602](https://example.com/builders#/task/602) rebills it.
176
-
177
- ## Safety properties
178
-
179
- - **No HTTP provisioning — the web process never touches the DO API.** Lifecycle mutations (provision/park/wake/deprovision) run only on the control-plane CLI; `DO_API_TOKEN` lives only in `/etc/example/box.env`, never the web tier. The API surface is read (`GET /box/me`, `GET /boxes`, `GET /box/terminal`) plus own-scoped writes that **record a request, not an action**: `POST /box/heartbeat` and — [#701](https://example.com/builders#/task/701) — `POST /box/ensure`, which only writes a `box_intents` row. The control-plane `box.js run-intents` is what actually calls DO. A stolen builder token can, at most, queue a provision/wake of **its own** box (rank-gated, one open intent max) — it can never create, destroy, or reach another builder's infrastructure.
180
- - **Dry-run default + token-required-for-live.** You cannot accidentally spend money; a live call without `DO_API_TOKEN` throws rather than silently no-op'ing.
181
- - **Audited.** Every transition writes a `box_events` row (`provision`/`park`/`wake`/`deprovision`/`cost`/`error`) with the actor (`cli:<login>` / `sweep:idle` / `sweep:dormant`).
@@ -1,103 +0,0 @@
1
- # Releasing the Dev Box desktop app
2
-
3
- How a new version of the Dev Box app (`devbox-app/`, ADR 0045) reaches builders.
4
- The chain: CI builds installers → operator publishes them to the PUBLIC
5
- releases repo → a one-line config bump re-points the branded
6
- `/downloads/devbox/*` redirects. Nothing binary ever rides the git deploy.
7
-
8
- ## 0. What's where
9
-
10
- - **Source:** `devbox-app/` in the main (private) repo.
11
- - **CI:** `.github/workflows/build-devbox-app.yml` — builds on every push
12
- touching `devbox-app/**` (mac dmg arm64+x64 on macos-14, NSIS exe x64+arm64 on
13
- windows-latest). **Only `main` keeps the artifacts** (3-day retention); a
14
- `claude/**` run builds both platforms as a CI signal and uploads nothing —
15
- ~500MB per run had filled the shared Actions storage cap and blocked uploads
16
- repo-wide. No publishing from CI either way.
17
- - **Public releases repo:** named in `config/devbox-app.json`
18
- (`releases_repo`). Assets live on GitHub releases there; GitHub's CDN
19
- serves the downloads, $0.
20
- - **Branded URLs:** `https://example.com/downloads/devbox/<target>`
21
- (302 → release asset; targets `mac-arm64`, `mac-x64`, `win-x64`, `win-arm64` + aliases)
22
- and `/downloads/devbox/latest.json` (what the app's update check reads).
23
- Server side: `src/devbox-downloads.js`, mounted in `server.js`.
24
- - **Install-window backdrop ([#912](https://example.com/builders#/task/912), fixed in [#1235](https://example.com/builders#/task/1235)):** the backdrop
25
- art is `build-resources/dmg-background.png` (+ `@2x`), drawn by
26
- `scripts/gen-dmg-backdrop.py`. Committed art — re-run that script + commit if you change
27
- the layout/copy. **The DMG is NOT styled by electron-builder.** electron-builder writes a
28
- `.DS_Store` whose background reference includes a modern `pBBk` bookmark that Finder on
29
- macOS ≥ Sonoma/Sequoia (verified white on 15.7.4) cannot resolve — so its install window
30
- renders solid white even though the image is embedded (this is exactly what shipped broken
31
- in v1.0.5/v1.0.6; it is NOT a Finder cache and NOT an OS limitation — macOS renders DMG
32
- backgrounds fine). Instead, the CI step **"Apply install-window backdrop (dmgbuild)"**
33
- (`scripts/apply-dmg-backdrop.sh` + `scripts/dmgbuild-settings.py`) re-packages the
34
- signed+notarized `.app` out of electron-builder's DMG into the final DMG with
35
- [`dmgbuild`](https://pypi.org/project/dmgbuild/), which writes an alias-only `.DS_Store`
36
- (no `pBBk`) that DOES resolve and render. `dmgbuild` is headless (no Finder), so it runs in
37
- CI; the `.app` is copied verbatim with `ditto`, so notarization is preserved; the DMG is an
38
- unsigned carrier either way. Keep the icon coords + window/iconSize in
39
- `scripts/dmgbuild-settings.py` matched to `electron-builder.yml` and the art's wells
40
- (180,198 / 480,198, 660×400, size 100). **Verify after a mac release:** mount the published
41
- arm64 DMG and confirm the backdrop draws — quick non-Finder check:
42
- `hdiutil attach <dmg> -nobrowse -readonly -mountpoint /tmp/m && python3 -c "d=open('/tmp/m/.DS_Store','rb').read(); print('renders:', b'pBBk' not in d and b'.background' in d)"`
43
- (`pBBk` must be **absent**).
44
-
45
- ## 1. Cut a release
46
-
47
- 1. Bump `devbox-app/package.json` version (e.g. `1.1.0`) and regenerate
48
- icons only if they changed (`npm run icons`).
49
- 2. Land the bump on `main` through the normal Bongos flow, then wait for
50
- `build-devbox-app` to go green **on the `main` run** — that is the run that
51
- carries the installers.
52
- 3. Download the artifacts (they expire after 3 days — re-run the workflow on
53
- `main` via `gh workflow run build-devbox-app.yml --ref main` if they aged out):
54
- ```
55
- gh run download -R example-owner/example -n devbox-app-mac -D /tmp/devbox-rel
56
- gh run download -R example-owner/example -n devbox-app-win -D /tmp/devbox-rel
57
- ```
58
- **These commands only work against a `main` run.** Pointing them at a
59
- `claude/**` run fails with nothing to download — the upload steps are gated to
60
- `refs/heads/main` (see §0). To get an installer out of an unmerged branch,
61
- build it locally instead: `cd modules/dev-box/app && npm run dist:mac` (or
62
- `npm run dist:win` on Windows).
63
- 4. Publish to the public releases repo (tag = `v<version>`, must match what
64
- `src/devbox-downloads.js` composes):
65
- ```
66
- gh release create v1.1.0 -R example-owner/example-devbox \
67
- --title "Dev Box v1.1.0" --notes "…" /tmp/devbox-rel/*.dmg /tmp/devbox-rel/*.exe
68
- ```
69
- 5. Update `config/devbox-app.json`: `version` + the four asset filenames
70
- (they embed the version — `tests/devbox_downloads.mjs` fails the gate on a
71
- half-bumped config).
72
- 6. Ship the config change through the normal Bongos flow. **Bumping `config/devbox-app.json`
73
- `version` is what triggers the in-app update banner in the field ([#924](https://example.com/builders#/task/924))** — running
74
- apps poll `GET /downloads/devbox/version`, compare it to their own `package.json`
75
- version with a proper semver compare (`devbox-app/src/updater.js`), and show
76
- "Update available — v<x>" + a one-click Update button only when the published
77
- version is STRICTLY newer. Apps re-check on open and every 12h, so a running app
78
- surfaces a new release within ~12h (sooner if reopened). The config version must
79
- therefore be ≥ the shipped `package.json` version, or no one is offered the update.
80
- The hall `/settings` download card reads the same endpoint to show "Latest version: vX".
81
-
82
- ## 2. Verify after deploy
83
-
84
- ```
85
- curl -sI https://example.com/downloads/devbox/mac-arm64 | grep -i location
86
- curl -s https://example.com/downloads/devbox/version # { version, assets:{target:url} } — the app's update-check payload
87
- curl -s https://example.com/downloads/devbox/latest.json # { version, assets:{target:{file,url}} } — the downloads page payload
88
- curl -sIL https://example.com/downloads/devbox/win-x64 -o /dev/null -w '%{http_code}\n' # expect 200
89
- ```
90
-
91
- ## Gotchas
92
-
93
- - **The tag and asset filenames must match the config exactly** — the
94
- redirect is `releases/download/v<version>/<asset>`; a renamed asset 404s.
95
- - **Unsigned builds:** until the Apple Developer ID / Windows cert decision
96
- (open blocker), keep the "Open Anyway" / SmartScreen copy in the hall
97
- onboarding + settings pages accurate. When certs land: sign + notarize in
98
- CI, then revisit real auto-update.
99
- - **`devbox-app/build-resources/`** is committed source (the app icon); the
100
- name avoids the root `.gitignore`'s global `build/` rule. Output goes to
101
- `devbox-app/dist/` (ignored).
102
- - **The releases repo is public** — never attach anything but the installers
103
- (no env files, no maps, no internal docs).
@@ -1,128 +0,0 @@
1
- # Hardware-verifying the Dev Box app on Windows (Parallels runbook)
2
-
3
- The app's logic is covered by the stub self-test (`OTB_DEVBOX_AUTOFLOW`, see
4
- `devbox-app/README.md`), but four things only a real Windows session can
5
- prove: the UAC elevation write of the Claude Desktop connection file, the
6
- `icacls` SSH-key lock, native notifications + tray behavior, and the full
7
- switch-on → <redacted> → switch-off loop. This runbook walks a
8
- tester through all of them in ~20 minutes. Written for **Windows 11 in
9
- Parallels on an Apple-silicon Mac** (that's ARM64 Windows — use the ARM
10
- build); everything applies unchanged to a physical PC.
11
-
12
- ## 0. Setup
13
-
14
- - Windows 11 VM up to date; **Claude Desktop for Windows** installed inside
15
- the VM and signed in.
16
- - A GitHub account that's an approved builder (the test account
17
- @largekristensen-svg is ideal — it has lived the broken flow).
18
- - Sign into the builders' hall once in the VM's browser (Edge/Chrome
19
- *inside* Windows, not the Mac side): `https://builders.example.com`.
20
- - Parallels quirks that bite this test:
21
- - Do the whole test **inside the VM** — if the pairing browser tab opens on
22
- the macOS side you're testing the wrong machine's cookies.
23
- - Windows "Do not disturb" / Focus suppresses toast notifications — turn it
24
- off (Settings → System → Notifications) or step 5 can't be judged.
25
- - The VM clock must be right (Parallels usually syncs it) — TLS and the
26
- 10-minute pairing TTL both care.
27
-
28
- ## 1. Download + install (tests: branded redirect, SmartScreen path)
29
-
30
- 1. In the VM browser: `https://example.com/downloads/devbox/win-arm64`
31
- (Parallels = ARM64 Windows; on an Intel PC use `win-x64`).
32
- 2. Expect the browser to warn about an uncommon download — keep it.
33
- 3. Run the installer. **SmartScreen will interpose** (unsigned until blocker
34
- [#18](https://example.com/builders#/task/18)'s Windows half): "Windows protected your PC" → **More info** → **Run
35
- anyway**. ✅ Record that this matches the onboarding copy's wording.
36
- 4. One-click install; the app launches itself and the panel window opens.
37
- ✅ The tray (bottom-right, may be behind the ^ chevron) shows the
38
- container icon.
39
- - ARM64 check: Task Manager → Details → `OTB Dev Box.exe` → the
40
- Architecture column should say **ARM64**, not x64-emulated (column may
41
- need enabling via right-click on the headers).
42
-
43
- ## 2. Pairing sign-in (tests: app-pair flow on Windows)
44
-
45
- 1. Click **Sign in through the hall**. ✅ The default browser opens
46
- `/builders/pair?code=XXXX-XXXX`; the code on the page matches the code in
47
- the panel.
48
- 2. Click **Approve — sign the app in as me**. ✅ Within ~5s the panel flips
49
- to signed-in (the @login shows in the header) with no further input.
50
- 3. ✅ A "Signed in" toast notification appears.
51
- 4. ✅ `%USERPROFILE%\.config\otb\gds-session.json` now exists.
52
-
53
- ## 3. SSH key + Claude Desktop connection file (tests: icacls, UAC write)
54
-
55
- Right after sign-in the app generates/registers the key and asks to install
56
- the Claude Desktop connection file:
57
-
58
- 1. In PowerShell:
59
- ```powershell
60
- icacls $env:USERPROFILE\.ssh\otb_builder
61
- ```
62
- ✅ Exactly one ACE — your user with full control. **No** `BUILTIN\Users`
63
- or `Everyone` lines (the 644-key bug the old flow shipped).
64
- 2. ```powershell
65
- Get-Content $env:USERPROFILE\.claude\settings.json
66
- ```
67
- ✅ Contains an `sshConfigs` entry `name` = "Example Dev Box",
68
- `sshHost` = `root@box-<your-login>.dev.example.com`. Since v1.0.3
69
- (#910) THIS per-user file is what makes the environment appear in the
70
- picker — written with **no elevation**; "✓ Claude Desktop is linked"
71
- refers to it.
72
- 3. ✅ A **UAC prompt** may also appear once — that's the OPTIONAL hardening
73
- layer (the system `managed-settings.json` host allowlist, written to both
74
- `C:\Program Files\ClaudeCode\` and `C:\ProgramData\ClaudeCode\`).
75
- Declining it does NOT break the link — the panel must still show
76
- "✓ Claude Desktop is linked".
77
-
78
- ## 4. The switch (tests: box lifecycle end-to-end)
79
-
80
- 1. Flip the switch ON. ✅ Status walks `Raising thy box…` (a first-ever box
81
- shows the one-time Archon-approval wait instead — the panel says so).
82
- 2. ✅ Within ~3 min: status **ON**, host/size rows fill in, a "Your dev box
83
- is ready" toast fires, the tray icon switches to the filled variant.
84
- 3. ✅ **Open the browser terminal** works (ttyd page, user `otb`, password
85
- from the panel's copy row).
86
- 4. ✅ `Get-Content $env:USERPROFILE\.ssh\known_hosts` contains fresh
87
- `box-<login>.dev.example.com` lines (the app replaces them each
88
- activation — the Desktop ssh2 fix). Since v1.0.2 (#909) the panel shows
89
- this live: "✓ Box identity pinned" (it retries by itself while the box's
90
- SSH service boots; a stuck "not pinned yet" row has a **Retry now**
91
- button). If it never pins, read the app log:
92
- `Get-Content $env:APPDATA\otb-devbox-app\devbox.log -Tail 30`.
93
-
94
- ## 5. Claude Desktop connect (the step the old flow could never reach)
95
-
96
- 1. **Restart Claude Desktop** inside the VM (it reads managed-settings at
97
- launch).
98
- 2. Code tab → Environment picker. ✅ **"Example Dev Box"** is listed.
99
- 3. Select it. ✅ It connects with **no host-verification error** (this is the
100
- known_hosts fix paying off) and lands in `/workspace`.
101
- 4. Ask Claude something trivial (`ls`) to prove the session is live.
102
-
103
- ## 6. Switch off + idle behavior
104
-
105
- 1. Flip the switch OFF. ✅ `Resting thy box…` → OFF within ~2 min; tray icon
106
- back to outline; "Your dev box closed" toast.
107
- 2. (Optional, 15 min) Flip ON, never connect, walk away. ✅ The box
108
- self-reclaims (~10–15 min idle sweep) and the panel flips itself off with
109
- the "closed after sitting idle" notification — the app deliberately sends
110
- no keep-alive.
111
- 3. Quit via tray right-click → Quit. Relaunch. ✅ Still signed in (no
112
- re-pairing), state re-syncs.
113
-
114
- ## Recording results
115
-
116
- File outcomes against the verification task in Bongos. For any failure
117
- grab: the panel state, `%TEMP%`/console output if launched from a terminal
118
- (`& "$env:LOCALAPPDATA\Programs\OTB Dev Box\OTB Dev Box.exe"` shows logs),
119
- the relevant PowerShell output from steps 3–4, and a screenshot. The fastest
120
- diagnosis channel is the box-side state: `GET /api/gds/box/me` via
121
- `node scripts/gds/api.js` from any authed checkout.
122
-
123
- ## Known-unsigned caveats (until blocker #18 lands)
124
-
125
- SmartScreen "Run anyway" (step 1) and the unsigned-publisher UAC dialog
126
- (step 3) are EXPECTED today. When the Windows signing decision lands (Azure
127
- Trusted Signing vs EV cert), re-run steps 1 and 3 — both prompts should then
128
- name the verified publisher, and the SmartScreen interstitial disappears.