feat(mcp): base-to-base sync, idle park Chromium, multiSelect updates - #19
Open
ARHAEEM wants to merge 246 commits into
Open
feat(mcp): base-to-base sync, idle park Chromium, multiSelect updates#19ARHAEEM wants to merge 246 commits into
ARHAEEM wants to merge 246 commits into
Conversation
Wrap each row in try/catch so assertAirtableId throws isolate to failed instead of aborting the batch. Empty cellValuesByColumnId now reports as failed instead of silently landing in updated with zero API calls made. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ert upload checksum
Wire the three existing client methods as MCP tools in the record-write category. Tool definitions, handlers, category entries, and test count updates (66→69) are all included. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rd-tool docs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (full-only) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces packages/mcp-server/src/sync/snapshot.js — the first module of the M2a schema-plan engine. Exports normalizeSchema() (handles both .tableSchemas/.columns and .tables/.fields shapes, resolves primaryColumnId → primaryFieldId → first-col fallback), isComputedType() (full set of non-writable field types), and snapshotBase() (async helper over AirtableClient.getApplicationData). Pure logic, no mutations. Three node:test assertions pass covering isComputedType flags and both API shape variants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure matchByName() builds tables/fields/choices maps from two snapshots. saveIdmap/loadIdmap/savePlan/saveState persist per-pair state under ~/.airtable-user-mcp/sync/<srcId>__<destId>/ via safeAtomicWriteFileSync. Two node:test suites pass (matching + round-trip I/O). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…f-stable) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…test Extract computedSig() helper (description-free, ID-stable) so updateField only includes changes.typeOptions for computed fields when the formula itself genuinely differs — not when field IDs merely differ across bases. Add two new tests: creation-ordering (scalars < links < computed) and the computed-description-only diff guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…counts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tions for apply Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tions Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ary reconcile Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kip unsupported Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…option Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The verify loop waited up to 3 minutes per target. With one target that was a 3-minute worst case; with eight it became 24 minutes of sleeping whenever Open VSX's index stalled — and this check is warning-only, so it would burn that time without ever failing the release. Keep the full 3-minute budget for the first target, since that is what actually covers Open VSX's indexing latency, then drop to 1 minute each once any target has been confirmed — the rest land moments apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lumn-visibility predicate Follow-up to the Task 10 review (853179e). Three Minor findings, all addressed: F1 — auth._rawApiCall's JSON-branch CSRF injection spread `{...body, _csrf}` unconditionally. A future array/string/number JSON body (none exists today, but the surrounding comment promises the guarantee for "any future" caller) would be silently coerced into an indexed object by the spread. Guarded to plain objects only. F2 — the exact predicate divergence just fixed in client.js was still live in two sync sites reading the same raw columnOrder shape: sync/apply.js's applyViewConfig columns facet (`c.visibility && refOk(...)`) and sync/remap.js's canonicalizeViewConfig (`.filter(co => co.visibility)` / `!co.visibility`). Both treated an absent `visibility` key as hidden, same as the just-fixed client.js bug — a source column returned without the key would never be shown on the destination (apply) and would compare as hidden instead of visible (diff/compare), one module over from the fix already shipped. F3 — extracted the predicate into a new tiny module, `src/column-visibility.js` (`isColumnVisible`), since it's importable cleanly from both client.js (top level) and sync/*.js (one level down) with no circular dependency — client.js doesn't import sync/, and sync/apply.js takes `client` as a parameter rather than importing client.js directly. All four sites (client.js's getView + _showColumnsWithRetry, sync/apply.js's applyViewConfig, sync/remap.js's canonicalizeViewConfig) now share one definition and cannot re-diverge. Checked test/sync/ for any test encoding the old (undefined-means-hidden) behavior before changing the sync predicates: none exists — every existing columnOrder fixture in test-compare-views.test.js and test-remap-view.test.js sets `visibility: true`/`false` explicitly, never omits the key, so no existing assertion needed to be touched (GC2 clean). Regression tests added, each verified to fail without its fix by reverting and re-running: - test-auth-transport.test.js: an array JSON body sent through _rawApiCall comes back as `{"0":"a","1":"b","_csrf":...}` without the guard (F1). - test-apply.test.js: a source columnOrder entry with no `visibility` key never reaches setViewColumns's visibleColumnIds without the fix (F2/apply). - test-remap-view.test.js: a columnOrder entry with no `visibility` key canonicalizes into the `hidden` set instead of `visible` without the fix (F2/remap). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ame (review F1) Review found a content-swap hole: replacing the BYTES of impit-node.darwin-arm64.node with the linux-x64-gnu ELF while keeping the filename passed every assertion cleanly. Only the printed size (6.1 -> 8.2 MiB) hinted at it. Every check up to now inspected LABELS — package name, package.json os/cpu/version, the filename the loader's "main" points at. A binary holding another platform's machine code under the right filename satisfies all of them. The magic number is the first check that reads the bytes themselves. NATIVE_BINARY_MAGICS lives in vsix-targets.mjs and is keyed by the target's own `os`, so it stays one source of truth and a new target cannot be added without a magic mapping (expectedBinaryMagics throws for an unknown os). The values were read from the 16 actual vendored binaries rather than assumed: every win32 package starts 4d5a (PE "MZ"), every linux/alpine one 7f454c46 (ELF), every darwin one cffaedfe (Mach-O 64-bit MH_MAGIC_64). The byte-swapped and universal/fat Mach-O magics are also accepted — we ship no fat binary today (no target uses @ngrok/ngrok-darwin-universal) but one would be legitimate. This closes the swap hole and also catches a file truncated at the front. The verified-binary output now prints each magic so the check is visible in CI logs rather than silent. Unreachable through our own pipeline, since the tarballs are byte-pinned to pnpm-lock.yaml — but the assertion exists to catch a pipeline that has gone wrong, so it should not have a hole. Verified: the exact swap now fails with "starts with 0x7f454c46, which is not a darwin binary"; all 8 real artifacts still pass, now reporting their magic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… count (review F2, F3)
F2 — the previous bound only engaged AFTER a first success, so an index that
never catches up (queued publish, namespace problem) still slept
8 targets x 18 attempts x 10s ~= 24 minutes and emitted eight ::warning::
lines. And once the per-target budget dropped to 1 minute, the message still
claimed "within 3 minutes".
Replaced with a single wall-clock budget shared by all targets and enforced
whether or not anything has been confirmed yet: 180s total, checked against
`date +%s` rather than an attempt counter. Unverified targets are collected
and reported in ONE warning that states the actual elapsed seconds. This is
a best-effort check that never fails the release, so it must not dominate
the job's runtime.
Uses `if [ ... ]` rather than `(( ... ))` so a false arithmetic test cannot
interact badly with `set -e`. Simulated both paths: with nothing ever
indexing, 8 targets finish in one budget (7s against a 6s test budget, not
8x6s) and exit 0; with everything indexed, no warning is emitted.
F3 — `${#ARGS[@]}` reported 16 for 8 builds because each file pushes two
argv elements (`--packagePath` and the path). The published argv was always
correct; only the log line was wrong. Counted separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… stack The top-level `await vendorPlatformPackagesForTarget(...)` surfaced every failure as a raw unhandled rejection. The exit code was right and the message was there, just buried above a stack trace — and these failures (integrity mismatch, unreachable registry, missing lockfile entry) all carry an actionable message that deserves to be the whole output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fix Task 12 (documentation-accuracy pass, no logic changes): - CLAUDE.md's "Keeping tool categories in sync" section overclaimed that LEGACY_CATEGORIES_DEFAULT_ON keeps a custom profile from silently widening to include "a tool" it never opted into. The allowlist is category-granular, not tool-name-granular: a new tool joining an already-legacy category (e.g. a future addition to record-write) still resolves an absent customTools key to enabled for standalone npm/CLI users. tool-config.js's ponytail comment and both CHANGELOGs already scoped this correctly (from eb87f60); CLAUDE.md was the one site that still overclaimed. Narrowed to name the residual gap, who's affected, and the remedy. - Added the missing release note for 455bfda's column-visibility predicate unification: an absent `visibility` key on a source column previously compared as hidden in sync/apply.js and sync/remap.js, matching an explicitly-hidden dest column and reporting false convergence — masking real drift. Documented in both CHANGELOGs as a correctness fix (not a regression): a base pair previously reported converged/identical by `sync_base mode=diff` may now correctly surface column-visibility drift. - Investigated whether any CHANGELOG/comment records autoNumber as a confirmed current bug in sync/apply.js (per owner: create and primary-retype already strip maxUsedAutoNumber, and the diff engine doesn't emit the suspected failing update). Found no such record in the tracked tree — the only matching claim lives on an external PR review thread, handled separately. No edit made. Zero executable-code changes: CHANGELOG.md, CLAUDE.md, and packages/mcp-server/CHANGELOG.md only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ic (18a/18b)
The VSIX assertion checked package name, package.json os/cpu/version, filename
and a 4-byte magic number. Only the magic reads the bytes, and it distinguishes
PE / ELF / Mach-O — the OS and nothing more. The cpu check beside it reads the
vendored package's OWN manifest, never the machine code. So two whole classes of
mis-vendored artifact passed clean, with a green tick:
- x64 <-> ARM64 on the same OS (darwin-x64 bytes in the darwin-arm64 artifact)
- glibc <-> musl (linux-x64-gnu bytes in the alpine-x64 artifact)
Both were reproduced as controls and both passed under the previous check.
Every .node is now hashed and compared against scripts/native-binary-digests.json,
which records the exact SHA-256 of every binary each platform package ships.
record-native-binary-digests.mjs generates it by downloading each tarball and
refusing to hash anything inside until the tarball's SHA-512 byte-matches the
integrity hash pnpm-lock.yaml already recorded — so expected values derive from
the lockfile, never from the artifact under test. The FILE NAMES come from the
pin too, not from the artifact's own "main", closing the same circularity.
A mismatch names the impostor by reverse-lookup across all 16 pins ("these are
the bytes of impit-darwin-x64@0.14.3 — the binary for darwin-x64, not
darwin-arm64"). A missing or stale pin fails packaging and assertion CLOSED with
the regeneration command; it never degrades to the weaker check.
The magic check stays: it proves only the OS, but it turns the common case into
one readable sentence before the digest reports hex.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s (18c) Two ways a verified-once tree could go unverified afterwards. 1. Cache trust. .cache/vsix-platform-packages/<key> was integrity-checked at download and thereafter trusted via a .vendored.json marker — a file living INSIDE the directory it vouches for, so anything able to alter the binaries could equally rewrite the marker. Contents are now re-hashed against the pinned digests on every reuse (plus package.json name/version, plus a check for unpinned .node files); a copy that fails is discarded and re-fetched. The freshly-populated tree is validated the same way before the marker is written, so a bad tarball or a mismatched node_modules copy cannot be vendored either. The marker is now only a cosmetic source label. Verified: flipping one byte of a cached .node, leaving the marker intact, now re-fetches instead of silently vendoring the tampered binary. 2. Symlink escape. assertSafeSymlinks protected the workspace vendoring path but not the registry-tarball path — even though that path unpacks with `tar`, which creates whatever symlinks the archive asks for, and then copies with dereference:true. Both paths now share one guard, extracted to scripts/safe-symlinks.mjs so neither can drift from the other. Verified: an escaping link planted in a cache directory is refused by name and target; the same tree under the previous unguarded cpSync copied the linked file's contents straight into the vendored output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single-host workflow cannot runtime-load eight foreign native bindings. One machine can only require() the binding compiled for itself, so ONLY the host target's binding can receive a genuine runtime smoke; the other seven are verified by exact content — which is the strongest claim available from one host, and the reason the digest pinning in be862c3 matters. Corrected in release.yml, README.md, CLAUDE.md and CHANGELOG.md, plus the script docblocks and the log lines the run actually prints. Also records what the previous check could and could not detect, so "the magic number reads the bytes" is not read as "the magic number verifies the binary". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n (17)
Ownership criterion (b) of `_isOwnedCommandLine()` was a loose two-fragment
substring scan — `.airtable-user-mcp` followed by `.chrome-profile` — with no
executable requirement and no argument matching. ANY process whose command line
merely mentioned the profile path was force-killed with its whole process tree
by `forceStop()` (the dashboard Stop button and `airtable-formula.stopDaemon`):
an editor holding `…/.chrome-profile/Default/Preferences`, a `grep -r`/`rg` over
the config dir, a file manager, a backup job — and `…/.chrome-profile-backup` or
another app's Chrome on a different `--user-data-dir` matched just as readily.
This is the same defect criterion (a) was hardened against one branch above.
Criterion (b) now requires BOTH:
1. the process image (argv[0] basename, `.exe` stripped) is a Chrome-family
binary this project can actually launch — derived from patchright-core's
channel + downloaded-browser registries (chrome/msedge/chromium,
chrome-headless-shell, headless_shell, `Google Chrome for Testing`, the
macOS app-bundle names and their helpers) and from browser-detect.ts
(system chromium/chromium-browser, google-chrome*, microsoft-edge*, brave*);
2. argv carries `--user-data-dir=<configDir>/.chrome-profile` for the EXACT
resolved profile, matched as a whole argv token.
The token match reuses criterion (a)'s `_containsPathToken` boundary check
rather than a second hand-rolled predicate — a duplicated predicate is how (a)
and (b) drifted apart in the first place — so `-backup`/`.old` suffixes,
subdirectories, `/mnt/backup`-prefixed copies and another OS user's identically
named profile are all misses, under the same case-insensitive, `\` vs `/`
tolerant, quote-aware canonicalization. Bare, whole-argument-quoted and
value-quoted spellings are all understood, so a home directory with a space in
it still matches through POSIX `ps` output.
Fails safe exactly as (a) does: a process holding our exact profile that is not
an image we could have launched is left ALIVE and reported via `skippedUnowned`.
The docblock claimed criterion (b) was "already correctly scoped" and
"UNCHANGED from the original" — that claim was false and is why two review
passes accepted it. It is corrected in place.
Tests: daemon-manager 57 → 73 (16 new; every negative in both Windows and POSIX
command-line form). Reverting the predicate fails 11 of them, including the
editor case (3 editors force-killed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… packages (review F1) Check 5 keyed on the expected PACKAGE, so a rogue `.node` added alongside the pinned ones inside a legitimately-expected package was waved through: check 3 iterates the pinned filenames and never sees the extra, and check 5 saw only that the package belonged. A genuine darwin-arm64 artifact with an extra `impit-darwin-arm64/rogue.node` holding darwin-x64 machine code asserted clean (exit 0). Node will load such a file if anything requires it by path. Check 5 now requires each `.node` to be a PINNED FILENAME within an expected package, so an unpinned binary fails by name with what the package is actually allowed to ship. `validateCacheDir` already refused unpinned `.node` files in the vendoring cache, so our own pipeline could not emit one — but the artifact assertion is the last gate before publish and must not be the weaker of the two. Control NC8 added: exit 0 before, exit 1 after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (review F2/F3/F4) F2 — `--check` compared the whole serialized file, so rewording `$comment` reported "does not match the tarballs pnpm-lock.yaml pins": alarming and false. It now compares a canonicalised `packages` object only, and names exactly what differs (per package: version, integrity, per-file digest, added/removed entries) instead of leaving a scavenger hunt across 16 packages. F3 — the regenerate hint named only the raw node command; it now leads with `pnpm record:binary-digests`. Both new scripts added to CLAUDE.md's Commands block, which listed neither. F4 — `findNodeBinaries` treats a symlinked `.node` as an ordinary file, so a tarball could have pointed a pinned filename at a file outside the extraction and had THAT hashed into the pin. Unreachable today (the integrity gate means the tarball is byte-identical to what the registry published), but this routine mints the trust root every later check depends on, so it no longer relies on an upstream guarantee: assertSafeSymlinks now runs over the scratch extraction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two placements, one check, different audiences. release.yml — a step between `pnpm install --frozen-lockfile` and packaging. The first publish is far below it and the version bump/commit/tag lower still, so a failure here ships nothing and tags nothing. verify-binary-pins.yml — a `pull_request` job filtered to `pnpm-lock.yaml` and `scripts/native-binary-digests.json`. A lockfile change is how an impit / @ngrok/ngrok bump arrives, and without this the stale pin surfaces to whoever runs the release rather than to the author who caused it. Its own file because GitHub applies `paths` at the trigger level, and ci.yml's `pull_request` has none. No `pnpm install` — the checker needs only Node builtins, the repo's own scripts, the lockfile and `tar`. Deliberately NOT in `pnpm test`: it needs the network and pulls ~150 MB. Recorded in both files: what this uniquely catches is a pin file that is internally consistent — right version, right integrity — but carries WRONG DIGESTS. The neighbouring failure modes never reach it, being already covered offline: a stale or missing pin fails closed in binaryPin() during vendoring, and a re-published tarball fails the lockfile SHA-512 on download. The poisoned pin file is the residual trust root, and only a fresh re-derivation exposes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not the gate Trigger. Two files can invalidate the pin file WITHOUT editing it: record-native-binary-digests.mjs decides how digests are produced, and vsix-targets.mjs decides which packages are expected at all. Both added alongside pnpm-lock.yaml and the pin file itself, each annotated with what it changes. Semantics. A path-filtered job that does not run reports as SKIPPED, and a merge queue or required-check configuration can render that as green — so a green PR here proves nothing about the pins. The header now says so outright, names the release-workflow step as the actual safety boundary, and calls out the two ways to get this wrong later: promoting this workflow to a required check believing it gates merges (it passes by absence), or deleting the release step believing this job covers it (it cannot). Also records why the usual workarounds were declined — a companion always-succeeds job is a permanently-green decoy, and dropping the filter costs ~150 MB of downloads on every unrelated PR — both of which only make sense if this job were the gate, and it is not. The release step carries the mirror of this note so the dependency reads in both directions. Verified: gate at step 6, packaging at 12, first publish at 13, bump/tag at 17. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…/F1) Review found `_imageName()` more lenient than its docblocks: it took the basename of everything before the first flag-shaped token, so the LAST pre-flag positional decided. With our exact `--user-data-dir=<profile>` also present, these were force-killed — `vim /home/u/chrome …`, `NOTEPAD.EXE C:\x\chrome …`, `notepad.exe "C:\x\Google Chrome" …`, `node /home/u/tools/chrome …`, `python3 /opt/x/chromium …` — while the docblocks asserted "argv[0]'s basename" and "a filename — never a browser name". That over-claiming comment is the same failure mode as the bug this task repaired, so the code is tightened to match the claim rather than the claim relaxed to match the code. `_imageName()` now resolves argv[0] properly: a quoted leading token verbatim; otherwise the pre-flag head with a LEADING run of exec wrappers (env, nohup, xvfb-run, dbus-run-session, setarch, stdbuf) skipped, after which the remainder must plausibly be ONE path — no later token may look like a separate argument (absolute/UNC/drive-rooted/`./`-relative path, quote or flag). That keeps unquoted images containing spaces working (POSIX `ps` output, macOS app bundles, `C:\Program Files\…`), keeps legitimate wrapper forms attributable, and makes a browser-NAMED FILE handed to another program resolve to no image at all. Anything ambiguous resolves to "not attributable": reported, never killed. Also: the flag-cut regex now tolerates a quote before the dashes, so the bare image + whole-argument-quoted flag shape (`chrome.exe "--user-data-dir=…"`) is killed rather than merely reported (report §3/§6 had claimed this already worked; it did not, and it is now pinned by a test). Docs corrected in place, no new claims beyond what the code does: criterion (b)'s conjunct 1 now spells out what "image" means and that an unpinnable argv[0] fails safe; `_hasUserDataDirArg` records the known `=`-boundary leniency (`--foo=--user-data-dir=<profile>` satisfies that conjunct, harmless because the image conjunct still applies); and `CHROME_FAMILY_IMAGES` gains a cross-reference stating that `mcp-server/src/process-tree.js` now differs SUBSTANTIVELY (Windows-only image filter, none on POSIX, bare-dir pgrep fallback) so neither is assumed to mirror the other. Tests: daemon-manager 73 → 77, extension suite 165 → 169. The 4 additions fail against the previous commit's implementation (2 negatives force-killing 3 and 5 processes; 2 positives under-killing), so they are load-bearing in both directions. `process-tree.js` untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(21) findProfileBrowserPids fell back to `pgrep -f -- <profileDir>` — ANY process merely MENTIONING the profile path — whenever the `--user-data-dir=` marker query found nothing, which is the NORMAL case (pgrep exits 1 on no match). Its output went to a filter that dropped only `--type=` helpers, with no executable filter on POSIX at all (win32 already had one), and straight into terminateProcessTree. So on Linux/macOS `vim ~/.airtable-user-mcp/ .chrome-profile/Default/Preferences` was SIGTERMed then SIGKILLed with its children — automatically, via evictProfileSquatters on profile-lock acquisition and killProfileBrowserTree on every browser park/close. Third instance of the same defect class (after _sweepOrphans criteria (a) and (b) in daemon-manager.ts), so fix the class: - POSIX now requires POSITIVE attribution on two independent conjuncts — the process image (argv[0] basename) is a Chrome-family binary this project can actually launch, AND argv carries `--user-data-dir=<our exact dir>` as a whole argv token. CHROME_FAMILY_IMAGES is derived from auth.js's channel use, patchright's channel + download registries, browser-detect.ts and browser-download.ts — not guessed. - Whole-token matching via canon/containsPathToken (reimplemented from daemon-manager.ts with a cross-reference comment; the mcp-server package must not depend on the extension package). <profile>-backup, <profile>.old, <profile>/Default, /mnt/backup<profile> and another OS user's identically-named path all miss. - Bare-directory fallback REMOVED. It is provably incapable of contributing a killable pid: it is a strict superset of the marker query, so everything it adds lacks the flag form and is rejected by conjunct 2. It existed for "Chromium variants that embed the path without the flag form", but no such variant exists — base::CommandLine only understands --switch=value, so a space-separated --user-data-dir does not claim the profile at all. pgrep is now candidate generation only; the filter is the authority (this also closes pgrep's ERE looseness, where `.` in .chrome-profile matched any character). - Reject empty/relative userDataDir before executing anything. - Anything unreadable or unattributable is left ALIVE — under-killing a genuine squatter beats killing a user's editor. killProfileBrowserTree stays unguarded, deliberately: the pid source is now narrow enough that its targets are our own orphaned browsers, both call sites run under the scheduler barrier right after we closed our own context, the acquisition path is already guarded by evictProfileSquatters, and adding the daemon.lock check would silently disable orphan cleanup under AIRTABLE_NO_DAEMON=1. Rationale and revisit-trigger recorded in the docblock. win32 branch byte-for-byte unchanged (GC2) — it already carried the image filter POSIX was missing; its residual substring near-miss is reported as a follow-up, not shipped. Tests: +49 (1038 -> 1087, all green). Negatives verified load-bearing — reverting the source to HEAD fails 28 of the 58 process-tree tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad (19)
auth.js's _rawApiCall called every non-array object "plain" and spread it
with _csrf, which silently destroys Date/Map/Set/RegExp/class instances
(e.g. {...new Date(), _csrf} loses the entire date, shipping {"_csrf":...}).
Replace with a real prototype-based isPlainObject(): only object literals
and Object.create(null) bags (prototype === Object.prototype or null) are
spread; everything else passes through byte-identical via plain
JSON.stringify. Comment corrected to state exactly what is/isn't covered,
including the deliberate cross-realm non-goal (no vm/iframe boundary in
this server). Added coverage for Date, Map, Set, a class instance, and
Object.create(null) as the positive case, asserting on the sent wire body.
…es (17/F3)
Round three of the same defect class: `NEW_ARG_LIKE` only recognised ROOTED
tokens, so a browser-named RELATIVE path handed to another program still
resolved to a Chrome-family image and was force-killed — `vim x/chrome`,
`node tools/chrome`, `python3 opt/x/chromium`, `NOTEPAD.EXE x\chrome`,
`git add sub/msedge`, each with our exact `--user-data-dir=<profile>`.
The root cause is not a missing pattern, it is that no rule over TOKENS can
work: `vim x/chrome` is byte-for-byte the same shape as the legitimate
`/applications/google chrome.app/contents/macos/google chrome`. So the rule is
now structural, and multi-token argv[0] is accepted ONLY when the path itself
proves its extent:
1. quoted argv[0] → the launcher's quoting settles it (and, if it contains a
space, it must be rooted — which also closes the fabricated
`"vim /home/u/chrome"` shape the reviewer flagged as a minor);
2. one token after skipping leading exec wrappers → that IS argv[0];
3. more than one token → only with a ROOTED first token, no argument-shaped
later token, AND the macOS bundle marker `.app/contents/macos/` in the
join. Every Chrome-family image whose real path contains a space is a
macOS bundle executable, so that marker is the whole legitimate set.
Deliberate narrowing, called out in the docblock and pinned by a test: an
UNQUOTED Windows image path containing spaces no longer attributes (reported,
not killed). Keeping it would mean accepting
`C:\Windows\System32\notepad.exe x\chrome.exe --user-data-dir=…` as a browser,
because the two are the same shape; every launcher that matters quotes an
argv[0] with spaces, so the quoted branch still covers Windows.
One residual is stated rather than implied away: a relative argument that
itself carries the bundle marker (`/usr/bin/vim x.app/contents/macos/google
chrome`) attributes. It is the one spelling indistinguishable from a real macOS
invocation, and it still needs our exact profile flag.
Also closes the `=`-boundary leniency instead of documenting it: the shared
`_containsPathToken` gained a `leadingBoundary` parameter, so criterion (a)
keeps `ARGV_BOUNDARY` (a path may follow `=`) while criterion (b) uses
`ARG_START_BOUNDARY` (a flag may not). That rejects `--foo=--user-data-dir=<p>`
and `--user-data-dir=/other=--user-data-dir=<p>` — the latter was a false kill
of a browser running a DIFFERENT profile.
Docblocks rewritten so every claim is one I falsified an attempt against: the
`NEW_ARG_LIKE` doc now says what it does NOT cover, `_imageName` enumerates its
rejections and its residual, and criterion (b) no longer says "can never".
Tests: 77 → 82. Two IMAGE CONJUNCT tests walk the whole space (bare relative,
nested relative, ./ and ../, absolute, rooted-program-plus-relative,
drive-relative, UNC, `\` and `/` mixes, multi-token argv, quoted fabrication,
ambiguous wrapper tails) in both platform forms; a third pins the legitimate
spellings that must still attribute; a fourth pins both nested-flag spellings.
All 4 additions fail against the previous commit (16 wrong kills in total).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…forced vector (22) The "is this process ours, so we may force-kill it" predicate exists in two implementations that cannot import each other (the MCP server is a standalone npm package). It has drifted apart five times; each drift shipped a force-kill of an innocent process, and each fix patched the reported example and missed a neighbouring spelling. This closes the sixth and makes the seventh a test failure instead of a bug report. 22a — port the fixed predicate to process-tree.js. Its `imageName` was still the pre-fix logic: "everything before the first flag, take the basename", with no exec-wrapper allowlist, no rooted/argument-shaped rejection and no empty-on-ambiguity. So on POSIX these were SIGTERM/SIGKILLed with their children whenever they carried our profile flag: vim x/chrome · node /home/u/tools/chrome · vim ./chrome · vim /home/u/chrome · notepad.exe C:\x\chrome Ported line-for-line from daemon-manager.ts (the reference, stabilised in 05b54aa): WRAPPER_IMAGES, ROOTED, NEW_ARG_LIKE, MAC_BUNDLE_MARKER, CHROME_FAMILY_HELPER, the empty-on-ambiguity fail-safe, and ARG_START_BOUNDARY for the flag (which also rejects the nested `--foo=--user-data-dir=<p>` and `--user-data-dir=/other=--user-data-dir=<p>` spellings the old left boundary accepted — the second is a browser on a DIFFERENT profile). 22b — the win32 residual. The CIM query ORed a bare `.Contains($env:AIRTABLE_EVICT_DIR)` alongside the marker, so ANY root chrome.exe/msedge.exe/chromium.exe whose command line merely MENTIONED the profile path was taskkill /T /F'd: a user opening file:///C:/Users/u/.airtable-user-mcp/.chrome-profile/Default/Preferences in their everyday Chrome lost the whole browser and every tab, on this project's primary platform. It was the same redundant widening removed from POSIX in 26cfa0f, and redundant for the same reason (the marker already IS --user-data-dir=<dir>). Removed. PowerShell is now candidate generation only, exactly like pgrep: it emits `<pid>\t<commandLine>` and the shared predicate decides in JS, requiring the exact --user-data-dir=<dir> as a whole argv token. The chrome/msedge/chromium name filter is kept; the marker is canonicalised so the coarse filter is case/separator-insensitive like the predicate. 22c — the systemic fix. packages/shared/test-fixtures/process-attribution-cases.json: 124 command lines with expected verdicts, loaded and asserted by BOTH test-process-tree.test.js and daemon-manager.test.ts. Editing one side and not the other now fails a test on the side that was NOT edited — verified by breaking each side in turn (see the task report). Implementation sharing was considered and rejected: it would need an ESM→CJS build step across a package boundary that must stay independent. 22d — smaller items. * The win32/pgrep marker interpolated the UNTRIMMED userDataDir while the filter used the trimmed one, so a padded path produced a no-match query. * profile-lock's `legacy` path is deleted: `pkill -f <profileDir>` killed every process whose command line matched the profile path as a REGEX, with no image filter, no argv-token matching and no ownership guard. Test-only, but it shipped in the published npm package one boolean from any caller. * terminateProcessTree gains an optional last-instant argv re-read to narrow the pid-recycling window (a recycled pid previously lost its own children to the pgrep -P walk). Deliberately three-valued — 'not-owned' aborts, 'unknown' proceeds — so an unreadable argv behaves exactly as before and no new failure mode is introduced. daemon-manager.ts is unchanged except for one docblock whose three claims about process-tree.js this commit falsifies. Tests: mcp-server 1092 -> 1231, extension 174 -> 300. No version bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (22/F1) F1 — the win32 CIM query was missing `[Console]::OutputEncoding=[Text.Encoding]::UTF8`. The reference sets it and documents why (daemon-manager.ts:_listProcesses). It was immaterial here while only ASCII pids crossed stdout; commit 427a3c7 made the command line itself round-trip through that stdout, so it stopped being immaterial and became a fresh drift from the reference introduced by the commit whose purpose was to end drift. Windows PowerShell 5.1 writes redirected stdout in the console OEM codepage while Node decodes UTF-8. Without the prologue a non-ASCII profile path (C:\Users\Jose\... with an accent) arrives mojibaked, so: 1. findProfileBrowserPids re-checks the mangled string, the --user-data-dir token no longer matches, and a REAL squatter is never evicted — the profile lock stays wedged and the browser cannot launch; and worse, 2. makeOwnershipVerifier's re-read returns a NON-EMPTY mangled string, which is 'not-owned' rather than 'unknown', so it does not merely fail to confirm an eviction — it ACTIVELY ABORTS one the selection pass already approved. Both fail safe (never a false kill) but both are functional breakage on this project's primary platform. Added to BOTH win32 PowerShell call sites. Minor — maxBuffer. Node's default is 1 MB and overflow REJECTS the call, which for an enumeration means "no candidates", i.e. the squatter is silently never evicted. 16 MB via one EXEC_MAX_BUFFER constant, matching the reference, applied to every exec in the module (both win32 CIM calls, `pgrep -f`, `pgrep -P`, `ps`) so the two enumeration paths cannot drift apart on it. Explicit `encoding: 'utf8'` too. The shared fixture pins the PREDICATE and structurally cannot pin the CANDIDATE-GENERATION QUERY — which is exactly where this drift got in. Five new non-fixture tests close that gap: the prologue on both call sites, utf8+maxBuffer on both, an end-to-end non-ASCII round-trip through selection AND verification, and a regression-shape test documenting that a mojibaked re-read reads as 'not-owned' (so removing the prologue is visibly a behaviour change, not a silent one). Verified by deleting the prologue (2 failures) and the maxBuffer (1 failure). process-tree.js + its tests only. Extension untouched. No version bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ker prove something (17/F5-F6)
Three holes, all the same failure mode as the ones before them — a rule fixed at
the position it was reported and left open one step over. Fixed in BOTH
implementations this time, and pinned in the shared vector so neither can drift.
F5 — the `=` boundary was fixed on the LEFT and left on the RIGHT. `=` is legal
inside a path on both platforms, so accepting it as a trailing boundary meant a
longer directory matched a shorter one:
`--user-data-dir=<profile>=2` → a browser on a DIFFERENT directory, killed
`…/dist/mcp/index.mjs=1 daemon start` → a DIFFERENT file, killed (criterion a)
`ARG_BOUNDARY` (`[\s"']`) now governs both sides everywhere; the one asymmetry
left is criterion (a)'s leading side, which accepts `=` because a PATH may
legitimately follow it (`--script=<path>`) — named `PATH_LEAD_BOUNDARY` and
documented at both ends.
F6 — `MAC_BUNDLE_MARKER` was accepted platform-blind and proved nothing on its
own. Five non-browser shapes attributed, three of them Windows, where
`.app/Contents/MacOS` is not an executable layout at all. Two gates now:
- the multi-token branch is refused for a Windows-SPELLED command line
(decided from the raw string before canonicalisation folds `\` into `/`;
spelling, not host platform, so the same command line attributes identically
on every machine — which is what keeps the shared vector host-independent);
- the join must be a bundle whose executable is named after it
(`Google Chrome.app/…/Google Chrome`, `… Helper.app/…/… Helper (GPU)`), so
`x.app/contents/macos/chrome` and `Foo.App\Contents\MacOS\Chrome` are out.
Minor — a quoted argv[0] was trusted whenever it was rooted, so
`"/usr/bin/vim /home/u/chrome"` and `"C:\Program Files\vim\vim.exe C:\x\chrome"`
still presented a browser basename. The `NEW_ARG_LIKE` sub-token rule already
used on the unquoted path now applies inside the quoted token too.
The residual is now POSIX-only and genuinely irreducible — a correctly-named
bundle path handed to another program — and it is PINNED in the fixture as
`residual-posix-named-bundle-arg` (expect `owned`, with the reasoning inline) so
the one known-bad shape cannot drift on either side unnoticed.
Docblocks: the three claims a reviewer falsified by probe are gone. "can never
satisfy it" now states which characters bound each side and why they differ;
"the one shape … IRREDUCIBLE" is now the POSIX-only shape with its reason; "the
guard costs nothing and removes the question" is replaced by what the guard
actually does and does not do.
Shared vector: +16 cases (124 → 140), appended, existing ids and ordering
untouched — 12 negatives for the shapes above, 3 positives guarding the
tightening (Edge bundle, named GPU helper, quoted bundle) and the pinned
residual. Reverting either implementation now fails 10 of them on that side.
extension 315 → 318 local + shared vector; mcp-server 1251 → 1252, both green.
`process-tree.js`'s two PowerShell call sites and the 22/F1 assertions are
untouched — no call site added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e same proof (17/F7-F8) F7 — criterion (a) kept `=` on its leading boundary because "a path may legitimately follow `=`". True in general, false for anything we emit: `_spawnDetached()` and `daemon/launcher.js` both spawn `[serverPath, 'daemon', 'start']`, so our entry is never an `=`-attached value. The widening protected no real invocation and force-killed real tooling that merely referenced our entry as a flag VALUE — `rsync --exclude=<entry>`, `tar --exclude=<entry>`, `rg --file=<entry>`, `node --require=<entry>`, `grep --include=<entry>`, `code --extensions-dir=<entry>`. `=` is now a boundary on neither side, for neither criterion, and `_containsPathToken` has no per-caller knob left to drift on. The second half of the same defect: `_hasDaemonStartShape` was an ordered `indexOf` scan, so `daemonize` satisfied `daemon` and `restart` satisfied `start` — which is why the victims were backup and search tools rather than exotica. It now matches WHOLE argv tokens. That tightening would have silently narrowed REPORTING too (another install's `--daemon start`, an `index.mjs.bak`), so reporting is now its own predicate, `_looksLikeStrayDaemon` — deliberately generous because it never kills, but requiring the `index.mjs` token to be a BARE argv entry, so none of the six victims above are even named in the "left running" notice. Criterion (a) still has NO image conjunct, and the docblock now says why rather than leaving it implicit: (b) needs one because the browser profile is a SHARED path, while (a)'s path is absolute and install-specific; after this fix I could not construct a plausible victim; and the only derivable conjunct (`basename(process.execPath)`) FAILS OPEN for the feature — an unanticipated host binary would leave our own orphan unswept and the profile locked, which is the bug this sweep exists to prevent. F8 — the quoted-argv[0] guard rejected only ROOTED second sub-tokens, so the bare-relative twins of the shapes it did reject sailed through: `"/usr/bin/vim x/chrome"`, `"/usr/bin/git add sub/msedge"`, `"/usr/bin/tar cf a x/google chrome"`, `"C:\Program Files\vim\vim.exe x\chrome.exe"`. Quoting bounds where argv[0] ENDS; it does not make the interior one path, and argv[0] is caller-chosen. A space-containing quoted token now clears the same structural bar as the unquoted branch: POSIX spelling → a correctly-named macOS bundle; Windows spelling → an executable extension only in the FINAL fragment (`WIN_EXEC_EXT`), since a Windows path names one executable and names it last. Shared vector: 140 → 147. The bare-relative quoted twins are pinned alongside the rooted ones (the gap the reviewer noted made the vector read as covering a class it did not), plus a positive guarding the Windows shape browsers actually emit, plus the second residual — `"C:\x\vim x\chrome"`, where WIN_EXEC_EXT has nothing to bite on — as `residual-win-quoted-two-relative-paths`. The existing residual's "POSIX-only" wording is corrected to describe the SPELLING, not the host, in both the fixture and the docblocks. Docblocks: the four falsifiable claims are rewritten to what I probed — "can never satisfy it", "the one shape … IRREDUCIBLE", "only a process naming ITSELF that way" (argv[0] is caller-chosen), and NEW_ARG_LIKE's "closed by ROOTED and bundle-marker" (which branch closes what, and that the regex closes none of it alone). extension 328 green, mcp-server 1259 green, tsc clean. Reverting either implementation fails 8 of the new cases on that side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o-install (24) assertSafeSymlinks unconditionally realpathSync'd the workspace node_modules root, so the advisory verify-binary-pins.yml job — which deliberately skips `pnpm install` — threw ENOENT on a clean checkout and failed PR #19's verify-pins check. Replace the implicit global allowlist with an explicit policy every call site must state: OWN_ROOT_ONLY (foreign tarball/cache trees and the digest generator's scratch extraction — only the tree's own canonical root may be a link target) vs ALLOW_WORKSPACE_NODE_MODULES (installed pnpm packages, where the workspace node_modules root is additionally a legitimate target). A missing node_modules under ALLOW_WORKSPACE_NODE_MODULES is now tolerated — absence just means that allowance doesn't apply, not a crash. Wire the correct policy into all four call sites (vendor-platform-packages.mjs x2, prepare-package-deps.mjs, record-native-binary-digests.mjs) and add scripts/safe-symlinks.test.mjs (node:test, 12 cases) covering both policies, the missing-node_modules case, and genuine escape attempts under each policy that must still be refused. Wired into `pnpm test` via a new `test:scripts` script. Also harden verify-binary-pins.yml: add `permissions: contents: read`, and extend its path triggers to scripts/safe-symlinks.mjs, scripts/vendor-platform-packages.mjs, and package.json so a future change to the check itself re-runs the job. The advisory-not-gate framing in the workflow's header comment is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reproduced force-kill false positives, both the same mistake: treating a
QUOTE as a generic argv boundary.
A. A value NESTED inside another argument satisfied the profile flag, because
the nested value's own quotes are boundaries. All four of these returned
OWNED from the real `isOwnedBrowserCommandLine`, and on win32 the eviction
path would have `taskkill /T /F`ed the user's whole browser:
chrome.exe --user-agent="--user-data-dir=<P>" (a UA string)
chrome.exe --proxy-pac-url="--user-data-dir=<P>" (a PAC URL)
chrome.exe --app="--user-data-dir=<P>" (an app URL)
/opt/google/chrome/chrome --user-agent="--user-data-dir=<P>"
B. `_hasDaemonStartShape` did `.split(/\s+/)` and stripped quotes AFTERWARDS,
so `code.exe "<entry>" "daemon start"` — the user's editor with our bundled
file open — yielded the tokens `daemon` and `start` and shape-matched: the
editor and its process tree force-killed, taking unsaved work and the
extension host running the sweep.
Both are replaced by a real quote-aware parser. `splitArgv`/`_splitArgv` splits
whitespace-separated entries, folds quoted sections into the entry they sit in
and removes their quotes, exactly as CommandLineToArgvW and POSIX shells do.
`argvEntryEnds`/`_argvEntryEnds` then requires the needle to BE an entry, and
returns the following index so criterion (a) can assert what comes next.
- `--user-data-dir=<P>` must be a whole argv entry. Bare, value-quoted and
whole-argument-quoted spellings collapse to one comparison; a nested value is
one entry whose text is `--user-agent=--user-data-dir=<P>` and is not it.
- criterion (a) requires the REAL emitted shape: the bundled entry, then the
separate entries `daemon` and `start`, adjacent — what `_spawnDetached` and
`launcher.js` build (`[serverPath, 'daemon', 'start', …]`). A single entry
reading `daemon start` no longer qualifies. Path and subcommands are now
checked together, so "mentioned here, `daemon`/`start` mentioned elsewhere"
cannot combine into a kill.
- both readings of `'` are tried (quote, and literal). Real argv never quotes
with `'`, and an account named O'Brien puts one inside our own profile path —
reading it as a quote would silently break eviction for that user. A nested
value fails both readings.
- "whitespace-only boundaries" is NOT the fix and is pinned against:
`--user-agent="Mozilla/5.0 --user-data-dir=<P> Safari"` puts the flag
whitespace-bounded inside a quoted value.
- an unquoted space-containing path (what `ps -o args=` emits) still matches,
via a run join restricted to unquoted entries separated by one space.
`ARG_BOUNDARY`/`containsPathToken` are deleted on both sides.
Enumerated rather than guessed, because every previous round fixed the reported
spelling and missed its neighbour: 16 value-taking Chrome switches x 3 quote
styles x 2 path spellings, plus double nesting, escaped quotes, unbalanced
quotes, empty-value forms, the space-separated form, and profile paths
containing an apostrophe or a space. 150 command lines: the pre-fix code
answers 82 of them wrong, all false OWNED; the new code answers 0 wrong.
Shared fixture: cases 147 -> 174 (append-only, ids stable), plus three new
sections — `argvSplits` (17, pins the parser on both sides), `daemonEntryCases`
(14, criterion (a); the server asserts all of them not-owned, pinning the
deliberate site difference) and `neighbourEnumeration`, a committed generator
spec both suites expand into the same 108-line cross-product and assert
independently. That is the harness; it runs in `pnpm test`.
Evidence is PER-IMPLEMENTATION, deliberately. These two files are line-for-line
ports, so their agreeing proves mirror parity and nothing more — they agreed on
the wrong answer here for five rounds. Re-introducing defect A alone into
process-tree.js turns the server suite red (20 failures); re-introducing both
defects into daemon-manager.ts turns the extension suite red (25 failures,
including all three `"daemon start"`-as-one-argument cases).
mcp-server 1259 -> 1323, extension 328 -> 392. No assertion weakened; two
`.length` checks tightened to exact pids. One documented behaviour change:
`bash -c "chrome --user-data-dir=<P>"` is no longer REPORTED (it was never
killed) — under a real parse bash carries no such argument, and the chrome it
spawns is enumerated separately and still killed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fingerprintSchema sorted tables and views before hashing but not fields, on the same expression. Field-listing order isn't a stable API contract (and CLAUDE.md already classifies field order as best-effort, non-drift), so a field-order-only difference between plan-time and apply-time snapshots flipped the fingerprint. The apply() drift guard treats that as a real schema change: it hard-aborts a fresh apply (forcing a wasteful re-plan on an unchanged schema) or, on a resume, emits a false RESUME_DRIFT_BYPASS "someone else changed the dest" warning. Sort the composed id=name=type strings the same way the views line already does — equivalent to sorting by field id (unique, always the first token) since ids can never collide, so order stops mattering while a genuine rename/retype still changes the string content and therefore the hash. Swept src/sync/ for sibling instances of the same pattern (identity hash over an unsorted collection) — none found; every other join/sort site is either already order-independent or deliberately order-sensitive for best-effort order-drift reporting (compare.js). Added regression coverage: same fields in different order -> same fingerprint; add/remove/rename/retype a field -> different fingerprint. Reverted the fix locally to confirm the new test fails without it, then restored it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the base-to-base schema/records sync engine (
sync_baseplan/apply/status/diff/reconcile) and hardens the MCP daemon lifecycle so long sync jobs can run safely on Windows.Sync engine (M1–M3+)
RECORD_ARRAY_UPDATE_DEFERRED)Daemon / auth lifecycle (this workstream tip)
AIRTABLE_BROWSER_IDLE_PARK_MS/browserIdleParkMinutes)runInToolContexton CallTool; extension sendsx-airtable-client-id/daemon/healthuses the sameAirtableAuthas tools (shared scheduler)How to test
/daemon/healthwhile calling a tool →pageBusy.active.tool/clientIdpopulated.appMDjopjzDGoyLHD→ destappWDcNnIgTpZcHBW(or your own pair)sync_base mode=planthenmode=applywithpolicy=overlayRECORD_ARRAY_UPDATE_DEFERREDnode --test test/test-page-scheduler.test.js test/test-auth-idle-park.test.js test/test-process-tree.test.js test/sync/test-array-choice-update.test.js test/test-daemon-server.test.jsLive smoke (2026-07-22)
get_base_schema,sync_base; clientId:vscode-airtable-formulaupdated: 1,arrayDefer: 0Related
d3204b1(idle park/scheduler),ae481e1(pageBusy shared auth + multiSelect updates)Checklist