instar 1.3.93 → 1.3.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/machine.d.ts.map +1 -1
- package/dist/commands/machine.js +23 -1
- package/dist/commands/machine.js.map +1 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +16 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/core/MachineIdentity.d.ts +2 -0
- package/dist/core/MachineIdentity.d.ts.map +1 -1
- package/dist/core/MachineIdentity.js +4 -0
- package/dist/core/MachineIdentity.js.map +1 -1
- package/dist/core/MeshUrlAdvertiser.d.ts +50 -0
- package/dist/core/MeshUrlAdvertiser.d.ts.map +1 -0
- package/dist/core/MeshUrlAdvertiser.js +64 -0
- package/dist/core/MeshUrlAdvertiser.js.map +1 -0
- package/dist/core/PairingSessionStore.d.ts +12 -0
- package/dist/core/PairingSessionStore.d.ts.map +1 -0
- package/dist/core/PairingSessionStore.js +54 -0
- package/dist/core/PairingSessionStore.js.map +1 -0
- package/dist/monitoring/AttributionResolver.d.ts +18 -0
- package/dist/monitoring/AttributionResolver.d.ts.map +1 -1
- package/dist/monitoring/AttributionResolver.js +18 -0
- package/dist/monitoring/AttributionResolver.js.map +1 -1
- package/dist/monitoring/BurnDetector.d.ts.map +1 -1
- package/dist/monitoring/BurnDetector.js +17 -1
- package/dist/monitoring/BurnDetector.js.map +1 -1
- package/dist/monitoring/TokenLedger.d.ts +26 -0
- package/dist/monitoring/TokenLedger.d.ts.map +1 -1
- package/dist/monitoring/TokenLedger.js +121 -4
- package/dist/monitoring/TokenLedger.js.map +1 -1
- package/dist/server/machineRoutes.d.ts.map +1 -1
- package/dist/server/machineRoutes.js +64 -9
- package/dist/server/machineRoutes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +3 -3
- package/upgrades/1.3.94.md +38 -0
- package/upgrades/1.3.95.md +39 -0
- package/upgrades/side-effects/burn-attribution-wiring.md +103 -0
- package/upgrades/side-effects/mesh-url-advertisement.md +66 -0
- package/upgrades/side-effects/noninteractive-pool-join.md +85 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Side-Effects Review — wire AttributionResolver into ingest + exempt the pre-attribution sentinel
|
|
2
|
+
|
|
3
|
+
## What changed
|
|
4
|
+
|
|
5
|
+
Completes **Phase 2** of `docs/specs/token-burn-detection-and-self-heal.md`, which
|
|
6
|
+
was specced but never wired. Three source files + their tests:
|
|
7
|
+
|
|
8
|
+
1. **`src/monitoring/AttributionResolver.ts`** — exports a new
|
|
9
|
+
`PRE_ATTRIBUTION_KEY = 'unknown::pre-attribution'` constant: the sentinel
|
|
10
|
+
meaning "the resolver has never run on this event" (the column default and
|
|
11
|
+
the historical hardcoded ingest value). `resolveAttribution()` itself is
|
|
12
|
+
unchanged and never returns this sentinel.
|
|
13
|
+
|
|
14
|
+
2. **`src/monitoring/TokenLedger.ts`**
|
|
15
|
+
- `ingestLine()` now calls `resolveAttribution({sessionId, projectPath: cwd,
|
|
16
|
+
prompt: null, model})` instead of hardcoding `'unknown::pre-attribution'`.
|
|
17
|
+
The Claude-CLI JSONL assistant line carries no user prompt, so this is
|
|
18
|
+
cwd/job/hook/session-level attribution; prompt-shape attribution remains a
|
|
19
|
+
separate follow-up (needs user+assistant line correlation at ingest).
|
|
20
|
+
- New `ledger_meta` key/value table + `getMeta`/`setMeta` helpers.
|
|
21
|
+
- New `backfillAttributionOnce()` — one-shot, idempotent, marker-guarded
|
|
22
|
+
(`attribution-backfill-v1`) re-resolution of legacy sentinel rows, run
|
|
23
|
+
non-fatally from the constructor after `prepareStatements()`.
|
|
24
|
+
|
|
25
|
+
3. **`src/monitoring/BurnDetector.ts`** — `tick()` exempts the
|
|
26
|
+
`PRE_ATTRIBUTION_KEY` sentinel from the **absolute-share** trigger only. A
|
|
27
|
+
bucket at 100% under the sentinel is a coverage gap, not a burn. The
|
|
28
|
+
genuinely-residual `unknown::<sessionId>` key (resolver ran, no match) is
|
|
29
|
+
NOT exempt and still alerts (the spec's "alert on unattributable spend").
|
|
30
|
+
Baseline-divergence is unaffected.
|
|
31
|
+
|
|
32
|
+
## Why
|
|
33
|
+
|
|
34
|
+
The recurring `unknown::pre-attribution consumed 100.0% of 24h spend
|
|
35
|
+
(threshold 25%)` alert was a **fleet-wide false positive**. Because the
|
|
36
|
+
resolver was never wired, every JSONL-sourced event defaulted to the single
|
|
37
|
+
sentinel bucket, whose share was always 100%, so the absolute-share trigger
|
|
38
|
+
fired every hour forever — on every agent with burn-detection enabled (default)
|
|
39
|
+
and any daily spend. Real spend on the reporting agent was ~1.5M tokens/24h
|
|
40
|
+
(the bleed this system targets was ~3B/day).
|
|
41
|
+
|
|
42
|
+
## Behavioral side effects
|
|
43
|
+
|
|
44
|
+
- **BurnDetector no longer emits absolute-share on the sentinel.** Intended.
|
|
45
|
+
This is the false-positive fix. Real burns are unaffected: (a) any resolved
|
|
46
|
+
key (component/job/hook/`unknown::<sid>`) still trips at >25%; (b) the
|
|
47
|
+
sentinel only exists transiently until backfill clears it; (c) a real burner
|
|
48
|
+
sharing the 24h window with sentinel rows still trips (the sentinel is
|
|
49
|
+
skipped, not counted against the burner's share — verified by test
|
|
50
|
+
"does not let the sentinel mask a real burner sharing the window").
|
|
51
|
+
- **Existing attribution distribution changes shape.** Instead of one 100%
|
|
52
|
+
bucket, spend now splits per origin (mostly `unknown::<sessionId>` for the
|
|
53
|
+
dominant Claude-CLI path, plus `user-job:*` / `user-hook:*`). The
|
|
54
|
+
`/tokens/*` summary/sessions/by-project routes are unchanged in schema; only
|
|
55
|
+
the `byAttributionKey` grouping is now meaningful. No route signature changed.
|
|
56
|
+
- **One-time backfill UPDATE on first boot after upgrade.** On the reporting
|
|
57
|
+
agent that's ~384k rows across a few hundred distinct (session, project,
|
|
58
|
+
model) triples — resolved once per triple inside a single transaction, so a
|
|
59
|
+
handful of bulk UPDATEs, not 384k. Marker-guarded → runs exactly once per DB.
|
|
60
|
+
Wrapped in try/catch in the constructor: a backfill failure logs and is
|
|
61
|
+
swallowed (worst case: sentinel rows stay unattributed and thus exempt from
|
|
62
|
+
burn alerts — i.e. degrades to the old-but-safe behavior, never a crash).
|
|
63
|
+
|
|
64
|
+
## Migration parity
|
|
65
|
+
|
|
66
|
+
No `PostUpdateMigrator` change required. The backfill self-heals on the
|
|
67
|
+
constructor's next run, which happens on every server start. Existing agents
|
|
68
|
+
get it on their next restart onto a version carrying this change. The new
|
|
69
|
+
`ledger_meta` table is created idempotently in the SCHEMA list alongside the
|
|
70
|
+
existing tables.
|
|
71
|
+
|
|
72
|
+
## Blast radius / isolation
|
|
73
|
+
|
|
74
|
+
- Scope is the three monitoring files. **No HTTP route, server-boot, or
|
|
75
|
+
CapabilityIndex change** → no agent-awareness/CLAUDE.md-template update owed.
|
|
76
|
+
- Codex token sessions live in a separate table (`codex_token_sessions`) and
|
|
77
|
+
are untouched — burn detection reads only `token_events`.
|
|
78
|
+
- Read-only-observability invariant of the TokenLedger is preserved: the only
|
|
79
|
+
new write is to the ledger's own SQLite DB (the backfill UPDATE + the meta
|
|
80
|
+
marker). No source JSONL file is touched.
|
|
81
|
+
|
|
82
|
+
## Rollback
|
|
83
|
+
|
|
84
|
+
Revert the three `src/monitoring/*` files. Backfilled `attribution_key` values
|
|
85
|
+
are harmless if the revert lands (they're just better labels); the
|
|
86
|
+
`ledger_meta` table and its marker row are inert without the new code. No data
|
|
87
|
+
loss, no schema-incompatible state. (Per-phase revert matches the umbrella
|
|
88
|
+
spec's rollback model.)
|
|
89
|
+
|
|
90
|
+
## Tests
|
|
91
|
+
|
|
92
|
+
- New `tests/unit/burn-attribution-wiring.test.ts` (11 tests): resolver never
|
|
93
|
+
returns the sentinel; ingest now resolves a real key; two sessions split into
|
|
94
|
+
two keys; job-cwd → `user-job:*`; backfill converts legacy sentinel rows on
|
|
95
|
+
reopen + is idempotent; BurnDetector regression — sentinel at 100% emits
|
|
96
|
+
nothing, residual `unknown::<sid>` at 100% still emits, sentinel doesn't mask
|
|
97
|
+
a co-occurring real burner.
|
|
98
|
+
- Updated `tests/unit/token-ledger.test.ts` (#512 schema-order migration test):
|
|
99
|
+
now asserts the migrated legacy row is backfilled to `unknown::sess-old` and
|
|
100
|
+
the sentinel is gone, instead of asserting the sentinel persists.
|
|
101
|
+
- Full sweep green: `vitest run` over burn-detection-phase-1..6 + token-ledger +
|
|
102
|
+
TokenLedger-codex + TokenLedgerPoller-codex + burn-attribution-wiring =
|
|
103
|
+
**149 passed, 0 failed**. `tsc --noEmit` clean.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Side-effects review — Mesh URL advertisement (lastKnownUrl population)
|
|
2
|
+
|
|
3
|
+
## What changed
|
|
4
|
+
|
|
5
|
+
`updateMachineUrl()` (the only writer of `registry.machines[id].lastKnownUrl`)
|
|
6
|
+
had **zero callers** on main. The Multi-Machine Session Pool routes every
|
|
7
|
+
cross-machine RPC — `deliverMessage`, session transfer, lease ops — to a peer by
|
|
8
|
+
reading that peer's `lastKnownUrl`, and filters peers with `!!e.lastKnownUrl`.
|
|
9
|
+
With the field never written, every peer was filtered out and **no session
|
|
10
|
+
could ever be delivered or transferred across machines**. The feature was inert
|
|
11
|
+
across real machines despite green 3-tier tests (which inject mock peer URLs).
|
|
12
|
+
|
|
13
|
+
Found 2026-05-29 bringing up a second real machine (the mini) for the
|
|
14
|
+
session-pool real-hardware proof.
|
|
15
|
+
|
|
16
|
+
This change adds:
|
|
17
|
+
- `src/core/MeshUrlAdvertiser.ts` — `resolveAdvertisedMeshUrl(tunnel, resolvedUrl?)`
|
|
18
|
+
(named → `https://<hostname>`, quick → the runtime-resolved start URL, none →
|
|
19
|
+
null) + `advertiseSelfMeshUrl(recorder, selfId, url)` (idempotent, tolerant of
|
|
20
|
+
a missing self entry).
|
|
21
|
+
- `src/commands/server.ts` — calls the advertiser after `tunnel.start()` on both
|
|
22
|
+
the boot path and the sleep/wake restart path (a quick tunnel gets a new URL
|
|
23
|
+
after a wake, so the previously-advertised URL goes stale).
|
|
24
|
+
- `src/commands/machine.ts` (`joinMesh`) — the joiner records the URL it
|
|
25
|
+
connected through as the awake peer's `lastKnownUrl`, so a standby can route
|
|
26
|
+
back to the awake machine immediately without waiting for a registry sync.
|
|
27
|
+
|
|
28
|
+
## Blast radius
|
|
29
|
+
|
|
30
|
+
- **Dark-feature-only effect.** The populated `lastKnownUrl` is consumed solely
|
|
31
|
+
by the session-pool mesh code, which is gated behind `multiMachine.sessionPool`
|
|
32
|
+
(default `enabled:false`, `stage:'dark'`). On a single-machine agent there are
|
|
33
|
+
no peers, so nothing changes. Populating the field is otherwise inert.
|
|
34
|
+
- **No new config, no new route, no schema change** → no migration needed.
|
|
35
|
+
Existing agents pick up the behavior at boot on the next release. No
|
|
36
|
+
`PostUpdateMigrator` entry required (the code runs at startup unconditionally
|
|
37
|
+
when multi-machine is enabled and a tunnel URL exists).
|
|
38
|
+
- **Idempotent + fail-safe.** `advertiseSelfMeshUrl` writes only when the URL
|
|
39
|
+
changed and swallows the "self entry not present yet" race (returns false).
|
|
40
|
+
A tunnel-disabled machine advertises nothing (correct — it is genuinely
|
|
41
|
+
unreachable cross-machine; peers skip it rather than route to a dead URL).
|
|
42
|
+
- **No secret exposure.** A tunnel URL is already public (the dashboard link).
|
|
43
|
+
Recording it in the registry adds no new exposure.
|
|
44
|
+
|
|
45
|
+
## What this does NOT close (scoped, decision-gated follow-up)
|
|
46
|
+
|
|
47
|
+
The **awake machine learning a git-uncredentialed standby's URL** still needs one
|
|
48
|
+
of: (a) the standby having git push creds for the shared agent repo (so its
|
|
49
|
+
advertised entry syncs via the existing git-backed RegistrySyncDebouncer), or
|
|
50
|
+
(b) a signed mesh `announce-url` RPC so a standby can publish its URL to the
|
|
51
|
+
awake machine over authenticated MeshRpc without git. (b) also requires the
|
|
52
|
+
awake side to persist the joiner's identity, which today only happens via the
|
|
53
|
+
**interactive SAS pairing** confirmation — so closing this fully touches the
|
|
54
|
+
pairing security model. That is a genuine design fork (creds vs. new RPC +
|
|
55
|
+
non-interactive registration), not a silent deferral, and is raised explicitly
|
|
56
|
+
rather than hand-resolved on a production identity.
|
|
57
|
+
|
|
58
|
+
## Tests
|
|
59
|
+
|
|
60
|
+
- `tests/unit/mesh-url-advertiser.test.ts` — resolver (named/quick/disabled/none)
|
|
61
|
+
+ advertiser against a real `MachineIdentityManager` (writes, idempotent,
|
|
62
|
+
updates on change, null no-op, missing-entry tolerance).
|
|
63
|
+
- `tests/unit/mesh-url-advertisement-wiring.test.ts` — wiring-integrity: asserts
|
|
64
|
+
the advertiser is invoked on the boot + sleep/wake tunnel paths and the joiner
|
|
65
|
+
records the connect URL. This is the tier that was missing — it would have
|
|
66
|
+
caught the original "zero callers" gap.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Side-effects review — Non-interactive, code-authenticated pool join (trust model A)
|
|
2
|
+
|
|
3
|
+
## What changed & why
|
|
4
|
+
|
|
5
|
+
Bringing up a second real machine for the Multi-Machine Session Pool surfaced
|
|
6
|
+
that the awake machine never learned about a machine that joined headlessly: the
|
|
7
|
+
existing `pair`/`join` flow is **interactive by design** — `/api/pair` was
|
|
8
|
+
"signal-only" and real registration completed only via a human confirming
|
|
9
|
+
matching SAS visual symbols on both screens. An active-active session pool can't
|
|
10
|
+
require a human to eyeball symbols per machine, so (operator decision: "Proceed
|
|
11
|
+
with A") pairing becomes **code-authenticated and non-interactive**: the
|
|
12
|
+
time-limited, single-use pairing code (carried over the TLS tunnel) is the
|
|
13
|
+
shared secret that authorizes the join; Ed25519-signed mesh RPCs handle trust
|
|
14
|
+
thereafter.
|
|
15
|
+
|
|
16
|
+
Root cause of the gap: `instar pair` created the `PairingSession` into an
|
|
17
|
+
**unused** `_pairingSession` variable and discarded it, so the running server
|
|
18
|
+
had no code to validate against.
|
|
19
|
+
|
|
20
|
+
Changes:
|
|
21
|
+
- **`src/core/PairingSessionStore.ts`** (new): persists the validation-relevant
|
|
22
|
+
fields of the active `PairingSession` to `.instar/machine/pairing-session.json`
|
|
23
|
+
(0600), and loads it. The ephemeral X25519 private key is deliberately NOT
|
|
24
|
+
persisted (it is a `KeyObject`, not JSON-serializable, and unused for code auth).
|
|
25
|
+
- **`src/commands/machine.ts`** (`pairMachine`): persists the session via
|
|
26
|
+
`PairingSessionStore` (10-min window) instead of discarding it; (`joinMesh`):
|
|
27
|
+
sends the joiner's own advertised URL in the pair body when it already has one.
|
|
28
|
+
- **`src/server/machineRoutes.ts`** (`/api/pair`): validates the submitted code
|
|
29
|
+
against the persisted session (`validatePairingCode` — single-use,
|
|
30
|
+
attempt-capped, TTL'd), and on success registers the joiner as **standby**,
|
|
31
|
+
stores its public keys (`storeRemoteIdentity`, so MeshRpc can verify it),
|
|
32
|
+
records its advertised URL, and burns the code. Rejections are audited.
|
|
33
|
+
- **`src/core/MachineIdentity.ts`**: adds a `baseDir` getter so the route can
|
|
34
|
+
co-locate its `PairingSessionStore` with the registry.
|
|
35
|
+
|
|
36
|
+
## Security analysis
|
|
37
|
+
|
|
38
|
+
- **The code IS the auth.** It is a `WORD-WORD-NNNN` secret, single-use
|
|
39
|
+
(`consumed`), attempt-capped (default 3 — failed attempts persist across
|
|
40
|
+
requests so the cap actually throttles brute force), and TTL'd (10 min as set
|
|
41
|
+
by `instar pair`). It travels over the Cloudflare TLS tunnel. Brute force over
|
|
42
|
+
a large code space within 3 attempts is infeasible.
|
|
43
|
+
- **A joiner can only ever become `standby`.** The role is hard-coded in the
|
|
44
|
+
handler; a join request can never claim the awake role or move the lease.
|
|
45
|
+
- **`/api/pair` remains unauthenticated** (it is the bootstrap, before the joiner
|
|
46
|
+
is known) — but it is now *gated*: with no active session it rejects (403)
|
|
47
|
+
rather than the old behavior of accepting any request. Malformed identities are
|
|
48
|
+
rejected (400) before anything is persisted.
|
|
49
|
+
- **What we trade away vs. SAS:** the human visual-symbol MITM check. Mitigated
|
|
50
|
+
by code-over-TLS + short TTL + single-use + attempt cap. This is the operator's
|
|
51
|
+
explicit trust-model choice for automated pool formation.
|
|
52
|
+
|
|
53
|
+
## Blast radius
|
|
54
|
+
|
|
55
|
+
- **Dark-feature-adjacent.** This is the multi-machine pairing path; a
|
|
56
|
+
single-machine agent never calls it. No behavior change unless an operator runs
|
|
57
|
+
`instar pair` + `instar join`.
|
|
58
|
+
- **No new config / schema.** `/api/pair` is server code → existing agents get
|
|
59
|
+
the new handler on update; no `PostUpdateMigrator` entry needed. The
|
|
60
|
+
`pairing-session.json` file is created on demand by `instar pair`.
|
|
61
|
+
- **Backward-compatible join:** `joinMesh` still registers the awake machine from
|
|
62
|
+
the response and records its URL (the #531 fix); the only response change is
|
|
63
|
+
`status: 'paired'` instead of `'pending'`, which `joinMesh` does not branch on.
|
|
64
|
+
|
|
65
|
+
## Not closed here (scoped follow-up)
|
|
66
|
+
|
|
67
|
+
For a **quick-tunnel** standby, its reachable URL is only known after its server
|
|
68
|
+
starts (post-join), so it cannot be sent in the pair body. The awake machine
|
|
69
|
+
therefore still needs a way to learn a quick-tunnel standby's URL — either the
|
|
70
|
+
standby uses a named tunnel (deterministic URL, exchanged at pair time), or a
|
|
71
|
+
dedicated authenticated URL-announce path is added (the standby is now registered
|
|
72
|
+
with verifiable keys, so it can sign such a call). Raised explicitly; the
|
|
73
|
+
heartbeat path is awake→standby (lease arbitration), so it is not a clean carrier.
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
- `tests/unit/pairing-session-store.test.ts` — `PairingSessionStore` roundtrip,
|
|
78
|
+
null-on-absent, no-private-key-persisted, 0600 perms, overwrite, malformed-file
|
|
79
|
+
tolerance.
|
|
80
|
+
- `tests/integration/pool-noninteractive-pairing.test.ts` — valid code registers
|
|
81
|
+
joiner as standby + records URL + stores keys + consumes code; wrong code 403 +
|
|
82
|
+
attempt increment; brute-force lockout; no-session 403; consumed 403; malformed
|
|
83
|
+
identity 400.
|
|
84
|
+
- `tests/integration/machine-routes.test.ts` — updated the legacy signal-only
|
|
85
|
+
assertion to the new session-gated contract.
|