instar 1.3.94 → 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 +15 -1
- package/dist/commands/machine.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/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.95.md +39 -0
- package/upgrades/side-effects/burn-attribution-wiring.md +103 -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,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.
|