← Commit history

docs: first-run contract in the Rust plan (arav-rog findings), changelog 2.0.397-2.0.399, ship.sh publishes pkg before release

John Lauer ·47808a08f7 ·1mo ago ·parent 7fece99
5 files changed +202−15
CHANGELOG.md+57
@@ -1,5 +1,62 @@ # Changelog — Puppeteer Bridge +## 2.0.399 — 2026-09-05 — Canonical pup_screen_record_* names (pup-bridge#94); ship.sh publishes the pkg before the release++- pup-bridge#94 (John): the Chrome screen-share recorder's canonical verbs are now+  `pup_screen_record_start/stop/status/list` and `pup_recorder_open/close`; the six `desktop_*`+  names stay as aliases (same handlers, same args) and keep their prefix claims only until ab-core+  serves them natively (adom-bridge#143). Every recorder response carries a `_hint` naming what it is+  (Chrome screen share, WebM, HUD consent) and pointing at ab-core's `desktop_record_window_*` for+  the background no-browser MP4 path. `pup_describe` lists the new names and marks the old as aliases.+- `tools/ship.sh` publishes the skills package BEFORE creating the release: a version row born from a+  release never gets the package tarball attached and `latest` does not move (2.0.398; adom/wiki#188).+- `chrome.js`: when the archive is already on disk the log and readiness show its real size.++## 2.0.398 — 2026-09-05 — The Chrome for Testing unpack could hang forever in-process; pup now unpacks in a supervised child with a stall watchdog++Second finding on arav-rog, after 2.0.397 got pup running there: the archive downloaded to 100 % in+15 s (190,733,480 bytes, valid: Windows tar extracted all 308 files from it), then puppeteer's own+in-process unzip (extract-zip) wrote three files (a manifest, ABOUT, and a 0-byte D3DCompiler_47.dll)+and never wrote another byte. The bridge sat idle (5 s CPU), readiness said "download 100 %" for+seven minutes, nothing errored, nothing could be killed. That is exactly how the half-populated+folder from July was born, the one that then broke every install.++- `installChrome` downloads only (`install({unpack:false})`) and pup unpacks itself: OS `tar` first+  on Windows and macOS (bsdtar reads zip natively), the `src/unpack.js` extract-zip worker as the+  fallback and the Linux path, each in a child process with a watchdog (no bytes written for 90 s,+  or 15 min total, kills it and tries the next method). Unpack lands in `<build>.partial`, the+  executable is verified, then the folder is renamed into place atomically and the archive deleted.+  A failure leaves no folder behind and sets `lastError` / `chrome_unpack_failed`.+- `pup_readiness`: `installPhase: "unpacking"`, `unpackBytes`, `unpackElapsedSec`, `unpackMethod`;+  `active[]` shows a distinct `phase: "unpack"` row with a real percent instead of "download 100 %".+- The cache prune skips a `.partial` folder younger than 30 min (an unpack in progress elsewhere).+- Warmup no longer logs "no installed Chromium on this box" when Chrome and Edge are present.+- Gate: `tests/check_unpack.js` (happy path through the worker, corrupt archive fails clean).++## 2.0.397 — 2026-09-05 — First-run install could never succeed on a box with a half-downloaded Chrome; waits on a human are answers, not timeouts++Measured on arav-rog (fresh install, standard user, ab 2.1.31): `npm install` of pup's source-only zip+completed all 132 packages and then failed in the `puppeteer` package's postinstall, which downloads+Chrome for Testing and refuses when the versioned folder already exists without an executable. A+July download had left exactly that folder (3 files, no chrome.exe), so all 11 installs failed the+same way, ab backed off after 496 spawn attempts, and the calling KiCad thread saw only timeouts.+pup's own repair for that folder never ran because pup never started.++1. `src/.puppeteerrc.cjs` (+ `package.json` `puppeteer.skipDownload`) forbids the postinstall+   download. pup provisions Chrome for Testing itself, after it is up, with progress in pup_readiness.+2. `chrome.js` prunes EVERY cache folder without an executable at warmup and before an install,+   not only the one build it is about to fetch (arav-rog also carried a 196 MB folder named "chrome").+3. pup-bridge#92 (ab BRIDGE_SDK 2.1.30 rule): a verb parked on a human never bare-timeouts.+   Consent toasts (credential reveal, card fill) live in `src/consent.js`: 45 s in-verb, then+   `errorCode: blocked_on_user, stillRunning: true, consentId`; the toast stays up 120 s; re-calling+   the same verb re-attaches and collects the decision exactly once. `pup_assisted_click` answers+   `blocked_on_user` (`reason: user_not_ready`) after 45 s; a `pup_login` submit that lands on a+   one-time-code or captcha wall answers `blocked_on_user` with the wall named. `pup_readiness.active[]`+   rows carry `blockedOnUser`; `pup_describe.progressContract.blockedOnUser` states the contract.+4. pup-bridge#93 / adom-bridge#143: the `desktop_record_*` verbs are labelled as ab-core's capability+   served by pup on its behalf until ab's native recorder lands; skills no longer present desktop+   recording as pup's. The verbs stay until ab-core serves them (ab-core precedence is the transition rule).+ ## 1.9.43 — 2026-07-20 — Why some icons had overlays and others did not, plus stacked session titles  John: "why do you sometimes have overlays on your icons and sometimes not?" It was never a rule, it
docs/PLAN-rust-port.md+31−3
@@ -84,7 +84,7 @@ or npm dependency today; right column is what replaces it. | Native clicks, hover, UI automation (`rawTrustedClick`, `pup_assisted_click`) | `desktop_click`, `desktop_hover`, `desktop_ui_click`, `desktop_ui_watch`, `desktop_ui_events` | Already delegated. | | User idle (`osIdleMs`) | `desktop_user_idle_ms` | Already delegated. | | Toasts, captions, consent buttons | `notify_user`, `notify_dismiss`, `notify_response`, `desktop_caption` | Already delegated. |-| The consent-gated desktop recorder: `desktop_recorder_open/close`, `desktop_record_start/stop/status/list`, `recorder.html`, `detectFFmpeg`, `remuxWebmAddDuration` | **Ask ab for `desktop_record_screen_start` with the consent HUD**, next to its native `desktop_record_window_start` (WGC, mp4, no ffmpeg). pup keeps answering the old verbs until ab ships it, then drops them. | Not a copy of ab code: pup inherited this from ab's originally bundled puppeteer bridge, and ab's recording skill still documents it as ab's feature while pup's verb prefix answers it (verified on winvm 2026-09-05). It captures the screen through a Chrome window (`getDisplayMedia`, MediaRecorder, VP9 webm) and needs ffmpeg on the box to write the duration header, a first-run dependency new users do not have. |+| The Chrome screen-share recorder: `pup_screen_record_*` / `pup_recorder_*` (canonical since 2.0.399, pup-bridge#94), `recorder.html`, `detectFFmpeg`, `remuxWebmAddDuration` | **Stays in pup** as `pup-record` (John, 2026-09-05: both recorders stay, each does what the other cannot: Chrome gives the OS share picker, system audio + mic, WebM, cross-platform; ab-core gives background no-browser MP4 of one window). Only the `desktop_*` alias names and prefix claims leave when ab-core's native `desktop_record_*` lands (adom-bridge#143). | Not a copy of ab code: pup inherited this from ab's originally bundled puppeteer bridge, and ab's recording skill still documents it as ab's feature while pup's verb prefix answers it (verified on winvm 2026-09-05). It captures the screen through a Chrome window (`getDisplayMedia`, MediaRecorder, VP9 webm) and needs ffmpeg on the box to write the duration header, a first-run dependency new users do not have. | | Clipboard image + text write (`copyCaptureToClipboard`, PowerShell STA helper) | **Ask ab for `clipboard_set {image, text}`** | Until then it lives in `pup-host-win`. | | Hydrogen API calls (`hydrogenGet/Post`) | `hd_api` | Already delegated for one route; the rest follow. | | Version probe of ab (`probeAdVersion`) | `bridge_info` | Already delegated. |@@ -102,7 +102,7 @@ size of fusion's `os-win`. 2. `clipboard_set {image?: path|b64, text?}` multi-format write. 3. `desktop_register_app_identity` leaves `IconResource` NULL in HKCU (measured), so callers write it themselves. 4. `desktop_list_windows` rows should carry the stamped `appId`, so callers do not PowerShell `SHGetPropertyStoreForWindow`.-5. `desktop_record_screen_start/stop` with the consent HUD, natively (WGC, mp4), so the recorder ab's own skill describes stops living in pup's Chrome window and stops needing ffmpeg.+5. Native `desktop_record_*` (WGC monitor capture + WASAPI loopback to MP4) in ab-core, adom-bridge#143, so the `desktop_*` names stop routing to pup; pup keeps its Chrome recorder under `pup_screen_record_*`. Plus one question: does `prewarm.assets: ["chrome-for-testing"]` actually download CfT today?  ## 4. The crate map@@ -196,7 +196,7 @@ equivalents. Kyle's mac has a Dock; ab's mac side decides what the Dock shows. | 13040–14950 DASHBOARD_HTML (inline JS app) | `pup-dashboard/assets/dashboard.html` verbatim | 1,900 | | 14950–20080 the 85 verb cases | `pup-verbs`, one module per family | 5,100 | | 20080–21080 HTTP server, routes, OPTIONS, proxy | `adom-pup-bridge` main + `pup-dashboard` | 1,000 |-| 21080–21940 ffmpeg, recorder window, desktop and tab recording | `pup-record`: tab recording for good, the HUD screen recorder until ab ask 5 ships | 860 |+| 21080–21940 ffmpeg, recorder window, screen and tab recording | `pup-record`: tab recording and the Chrome screen-share recorder (`pup_screen_record_*`), both for good | 860 | | `chrome.js` | `pup-browser` | 1,067 | | `credential_vault.js`, `card_vault.js` | `pup-vault` | 621 | | `win32.js` | `pup-host-win` | 180 |@@ -248,6 +248,34 @@ Phase 4 passes. Same bridge name, same page, same manifests; only the zip conten  Total: about five weeks of work to parity, if ab's four asks land in the first two. +## 6b. The first-run contract (John, 2026-09-05: "massively critical")++What broke on arav-rog on 2026-09-05, measured: ab installed pup's source-only zip and ran+`npm install`; the `puppeteer` package's postinstall tried to download Chrome for Testing, found a+Chrome 148 folder from July with 3 files and no exe, and exited 1. Every one of the 11 logged+installs failed the same way, ab backed off after 496 spawn attempts, and the KiCad thread saw only+timeouts. pup's own repair code for exactly that folder never ran, because pup never started. The+Node fix (2.0.397) is a config file that forbids the postinstall download plus a cache prune at+warmup. The Rust port must make this whole class of failure impossible by construction:++1. **No install-time work at all.** The zip is the exe plus data files. ab sha-verifies and extracts.+   There is no npm, no postinstall, no interpreter, nothing that can half-complete.+2. **Chrome for Testing is provisioned by pup, after pup is up and answering.** The download is a+   background task with progress in `pup_readiness` (bytes, rate, ETA), atomic: download to a temp+   file, verify size, unzip into a temp folder, rename into place. A folder without an executable is+   junk by definition and is pruned at every start.+3. **Every wait on something that is not pup's is a structured answer, never a timeout.** A missing+   browser, a download in flight, a firewall dialog, a consent toast, a 2FA wall: each returns+   `stillRunning` or `blocked_on_user` with `blockedBy` and the verb to poll, inside every ab budget+   (45 s in-verb cap). The calling AI is never left guessing whether pup is dead or waiting.+4. **The first open works on a box with nothing.** No Chrome, no Edge, no cache, standard user, no+   admin: pup answers `chrome_for_testing_installing` with progress on the first call and opens the+   window on a later call, background, identity-stamped, within the measured budget.+5. **Proof is a wiped box, every ship.** `tools/ship.sh` gains a gate that bootstraps a throwaway+   Windows VM (or winvm with the cache and bridges-cache wiped) from the zip and runs the first-open+   sequence; the release is refused if that sequence does not pass. This is Phase 1's exit test and+   it never stops running.+ ## 7. Build and ship mechanics  - Cross-compiled from the container with `x86_64-pc-windows-gnu`, the target fusion already uses;
tests/check_consent.jsadded+62
@@ -0,0 +1,62 @@+// Gate: the consent registry (pup-bridge#92, ab SDK blocked_on_user rule). Pure, no ab, no OS.+// Proves: in-verb cap returns blocked_on_user (pending) while the toast keeps collecting;+// a re-call with the same key re-attaches (no second toast); the decision is handed over exactly+// once; a toast timeout retracts the toast (notify_dismiss) and records consent_timeout.+const assert = require('assert');+const { createRegistry, blockedOnUser } = require('../src/consent.js');++const calls = [];+let resolveToast = null;+const abCommand = (verb, args) => {+  calls.push({ verb, args });+  if (verb === 'notify_user') return new Promise((r) => { resolveToast = r; });+  if (verb === 'notify_dismiss') return Promise.resolve({ dismissed: true });+  return Promise.resolve({});+};+const reg = createRegistry({ abCommand, abPayload: (r) => r, inVerbMs: 40, totalSec: 120, pickupMs: 10000 });++(async () => {+  const toast = { idPrefix: 'credreveal', title: 'Reveal your example.com login?', buttons: [{ label: 'Reveal' }, { label: 'Deny' }] };+  // 1. first call: no decision within the in-verb cap -> pending+  const a = await reg.ask({ key: 'pup_credential_reveal|example.com|thread-a', verb: 'pup_credential_reveal', what: 'Waiting for the user', toast });+  assert.strictEqual(a.pending, true, 'first call is pending after the in-verb cap');+  assert.ok(a.consentId.startsWith('credreveal-'), 'consent id carries the prefix');+  assert.strictEqual(calls.filter((c) => c.verb === 'notify_user').length, 1, 'exactly one toast shown');+  assert.strictEqual(calls[0].args.wait, true); assert.strictEqual(calls[0].args.timeoutSec, 120);+  const rows1 = reg.rows();+  assert.strictEqual(rows1.length, 1); assert.strictEqual(rows1[0].phase, 'consent'); assert.strictEqual(rows1[0].blockedOnUser, true);++  // 2. re-call with the same key: re-attaches, still one toast+  const b = await reg.ask({ key: 'pup_credential_reveal|example.com|thread-a', verb: 'pup_credential_reveal', what: 'Waiting for the user', toast });+  assert.strictEqual(b.pending, true); assert.strictEqual(b.consentId, a.consentId, 'same consent id on re-attach'); assert.strictEqual(b.attached, 1);+  assert.strictEqual(calls.filter((c) => c.verb === 'notify_user').length, 1, 'no second toast on re-attach');++  // 3. the user clicks while nobody is attached: the row flips to consent_decided; the next+  //    re-call collects the decision exactly once and the record is gone afterwards+  resolveToast({ action: 'Reveal' });+  await new Promise((r) => setTimeout(r, 5));+  const rows2 = reg.rows();+  assert.strictEqual(rows2[0].phase, 'consent_decided'); assert.strictEqual(rows2[0].blockedOnUser, false);+  const c = await reg.ask({ key: 'pup_credential_reveal|example.com|thread-a', verb: 'pup_credential_reveal', what: 'Waiting for the user', toast });+  assert.strictEqual(c.pending, false); assert.strictEqual(c.decision, 'Reveal'); assert.strictEqual(c.consentId, a.consentId);+  assert.strictEqual(reg.map.size, 0, 'picked-up record removed');+  assert.strictEqual(calls.filter((c2) => c2.verb === 'notify_dismiss').length, 0, 'a clicked toast is not retracted');++  // 4. a different key is a different toast+  const d = await reg.ask({ key: 'pup_credential_reveal|other.com|thread-a', verb: 'pup_credential_reveal', what: 'Waiting for the user', toast });+  assert.strictEqual(d.pending, true); assert.notStrictEqual(d.consentId, a.consentId);+  assert.strictEqual(calls.filter((c2) => c2.verb === 'notify_user').length, 2);++  // 5. toast timeout (ab resolves with no action): retract via notify_dismiss, decision null+  resolveToast({});+  await new Promise((r) => setTimeout(r, 5));+  assert.strictEqual(calls.filter((c2) => c2.verb === 'notify_dismiss').length, 1, 'timed-out toast retracted');+  const e = await reg.ask({ key: 'pup_credential_reveal|other.com|thread-a', verb: 'pup_credential_reveal', what: 'Waiting for the user', toast });+  assert.strictEqual(e.pending, false); assert.strictEqual(e.decision, null, 'timeout hands over null (consent_timeout)');++  // 6. the response shape+  const shape = blockedOnUser({ stillRunning: true, blockedBy: 'click Reveal', _hint: 'wait' });+  assert.deepStrictEqual(Object.keys(shape).sort(), ['_hint', 'blockedBy', 'errorCode', 'status', 'stillRunning', 'success'].sort());+  assert.strictEqual(shape.errorCode, 'blocked_on_user'); assert.strictEqual(shape.success, false); assert.strictEqual(shape.stillRunning, true);+  console.log('check_consent: OK');+})().catch((err) => { console.error('check_consent FAILED:', err && err.stack || err); process.exit(1); });
tests/check_unpack.jsadded+38
@@ -0,0 +1,38 @@+// Gate: the supervised unpack (v2.0.398). Builds a small zip, unpacks it through chrome.js's+// unpackSupervised (child process + watchdog), and checks the bytes land and a bad archive fails+// cleanly (dest removed, error surfaced) instead of hanging.+const assert = require('assert');+const fs = require('fs'); const os = require('os'); const path = require('path');+const { execFileSync } = require('child_process');+const chrome = require('../src/chrome.js');++(async () => {+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pup-unpack-'));+  const src = path.join(tmp, 'src'); fs.mkdirSync(path.join(src, 'chrome-linux64'), { recursive: true });+  fs.writeFileSync(path.join(src, 'chrome-linux64', 'chrome'), Buffer.alloc(200000, 7));+  fs.writeFileSync(path.join(src, 'chrome-linux64', 'ABOUT'), 'about');+  const zip = path.join(tmp, 'a.zip');+  execFileSync('python3', ['-c', `import shutil,sys; shutil.make_archive(sys.argv[1][:-4], 'zip', sys.argv[2])`, zip, src]);+  assert.ok(fs.existsSync(zip), 'test zip built');++  // happy path+  const dest = path.join(tmp, 'linux64-1.2.3.partial');+  let seenBytes = 0, method = null;+  const r = await chrome.unpackSupervised(zip, dest, { onBytes: (b) => { seenBytes = Math.max(seenBytes, b); }, onMethod: (m) => { method = method || m; } });+  assert.ok(['extract-zip', 'tar'].includes(r.method), 'a real method was used: ' + r.method);+  assert.ok(fs.existsSync(path.join(dest, 'chrome-linux64', 'chrome')), 'executable landed');+  assert.strictEqual(fs.statSync(path.join(dest, 'chrome-linux64', 'chrome')).size, 200000);+  assert.ok(chrome.dirBytes(dest) >= 200005, 'dirBytes counts the tree');+  assert.ok(chrome.hasCftExecutable(dest), 'hasCftExecutable sees the linux chrome binary');++  // a corrupt archive fails fast and leaves no folder behind+  const bad = path.join(tmp, 'bad.zip'); fs.writeFileSync(bad, Buffer.from('this is not a zip file at all'));+  const dest2 = path.join(tmp, 'linux64-9.9.9.partial');+  let err = null;+  try { await chrome.unpackSupervised(bad, dest2, {}); } catch (e) { err = e; }+  assert.ok(err, 'corrupt archive rejects');+  assert.ok(!fs.existsSync(dest2), 'failed unpack leaves no partial folder');++  fs.rmSync(tmp, { recursive: true, force: true });+  console.log('check_unpack: OK (' + r.method + ', ' + r.ms + 'ms)');+})().catch((e) => { console.error('check_unpack FAILED:', e && e.stack || e); process.exit(1); });
tools/ship.sh+14−12
@@ -41,6 +41,8 @@ node --check src/chrome.js node --check src/card_vault.js node tests/check_dashboard_script.js >/dev/null node tests/check_card_vault.js >/dev/null+node tests/check_consent.js >/dev/null+node tests/check_unpack.js >/dev/null node tests/check_icon.js >/dev/null echo "   server/chrome/card_vault parse; dashboard script parses; card vault checks; dock favicon matches" @@ -78,6 +80,18 @@ with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z: print(f"   {out}  sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()}  size={os.path.getsize(out)}") PY +echo "== 3a. skills pkg FIRST (a release-born version row never gets the pkg tarball attached: 2.0.398; #66) =="+PUBOUT=$(adom-wiki pkg publish --org adom 2>&1); PUBRC=$?+echo "$PUBOUT" | tail -2+[ $PUBRC -eq 0 ] || { echo "PKG PUBLISH FAILED (exit $PUBRC)"; echo "$PUBOUT" | tail -15; exit 1; }+adom-wiki pkg info adom/pup-bridge 2>/dev/null | python3 -c "+import sys,json+raw=sys.stdin.read()+d,_=json.JSONDecoder().raw_decode(raw[raw.find('{'):])+v=d['versions'][0]+assert v.get('integrity') and v.get('size_bytes'), f\"top row {v['version']} has NO tarball\"+print(f\"   pkg tarball verified on top row {v['version']} ({v['size_bytes']} bytes)\")" || { echo "PKG TARBALL VERIFY FAILED"; exit 1; }+ echo "== 3. release on the product page ($PAGE) ==" adom-wiki release create "$PAGE" "$VER" >/dev/null 2>&1 || true UP=$(adom-wiki release upload "$PAGE" "$VER" "$ARCH/$ZIP" --json 2>&1 | tail -c 4000)@@ -131,18 +145,6 @@ adom-wiki repo push "$PAGE" \     [ "${FORCE_PUSH:-0}" = "1" ] || { echo "   PUSH FAILED (set FORCE_PUSH=1 to retry)"; exit 1; }   } -echo "== 6. skills pkg (keeps the top version row carrying a tarball, #66) =="-PUBOUT=$(adom-wiki pkg publish --org adom 2>&1); PUBRC=$?-echo "$PUBOUT" | tail -2-[ $PUBRC -eq 0 ] || { echo "PKG PUBLISH FAILED (exit $PUBRC)"; echo "$PUBOUT" | tail -15; exit 1; }-adom-wiki pkg info adom/pup-bridge 2>/dev/null | python3 -c "-import sys,json-raw=sys.stdin.read()-d,_=json.JSONDecoder().raw_decode(raw[raw.find('{'):])-v=d['versions'][0]-assert v.get('integrity') and v.get('size_bytes'), f\"top row {v['version']} has NO tarball\"-print(f\"   pkg tarball verified on top row {v['version']} ({v['size_bytes']} bytes)\")" || { echo "PKG TARBALL VERIFY FAILED"; exit 1; }- echo "== 7. DIFF-VERIFY ==" ON_DISK=$(cat src/BRIDGE_VERSION) MAN=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" "$INSIDERS_DIR/$INSIDERS_REL")