@atlaso-labs/opencode 0.1.1 → 0.2.1

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.
@@ -0,0 +1,318 @@
1
+ /** Content-free capture-attempt counters — the TS port of the Python client's
2
+ * capture_stats (atlaso_client/cache.py), one estimator across all hook tools
3
+ * (lab ruling 85a5c41b: semantic parity required, reason labels MAP onto the
4
+ * closed Python vocabulary — the server whitelist never grows).
5
+ *
6
+ * Semantics (byte-identical to Python, non-negotiable):
7
+ * attempts = gate-passed evaluations (reasons `signal` | `substantive`)
8
+ * accepted = server-confirmed "added" ONLY (a "duplicate" result goes to
9
+ * drops.duplicate — replaying one memory must read ~0%, not 100%)
10
+ * per-UTC-day cumulative ints + hours_active/max_hour_attempts (hour spread)
11
+ * accepted <= attempts clamped AT EMIT (a corrupt snapshot fails the tile
12
+ * closed server-side — a well-behaved client never trips it)
13
+ * payload = last <=35 days, additive `capture_stats` field on the batch body
14
+ *
15
+ * Counts only — no content and no content-derived hashes ever enter this file
16
+ * or the payload (enforced by the vocabulary-whitelist test).
17
+ *
18
+ * Cursor-specific hazard handled here: stop + sessionEnd can BOTH fire for one
19
+ * turn (two processes). Gate evaluations dedupe on the turn's idempotency key,
20
+ * and a "duplicate" result for a key we already counted is our own double-fire
21
+ * echo, not a real replay — it is skipped.
22
+ *
23
+ * Cross-process safety: read-modify-write under the shared flock helper; when
24
+ * the lock is unavailable (held:false — e.g. Windows) we still write, and the
25
+ * server's max-merge means a lost race can only UNDER-count, never inflate.
26
+ */
27
+ import {
28
+ closeSync,
29
+ fsyncSync,
30
+ mkdirSync,
31
+ openSync,
32
+ readFileSync,
33
+ renameSync,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import { dirname, join } from "node:path";
37
+ import { createHash, randomUUID } from "node:crypto";
38
+ import { atlasoDir } from "./atlaso";
39
+ import { withToolLock } from "./lock";
40
+
41
+ // ── the closed vocabulary (lab ruling 85a5c41b) ─────────────────────────────
42
+ // Every gate reason a TS connector can produce MUST map to exactly one of the
43
+ // Python labels below. An unmappable reason is a build bug, never a reason to
44
+ // extend the vocabulary (map-completeness test enforces totality).
45
+ export const ATTEMPT_REASONS = ["signal", "substantive"] as const;
46
+ export const REASON_MAP: Record<string, string> = {
47
+ empty: "empty",
48
+ chatter: "chatter",
49
+ too_short: "too_short",
50
+ too_long: "too_long",
51
+ system_turn: "system_turn",
52
+ // Cursor's meta_recall = "the user turn is our own injected recall block" —
53
+ // semantically a system-authored turn, so it maps there.
54
+ meta_recall: "system_turn",
55
+ };
56
+
57
+ const MAX_DAYS = 35;
58
+ // Cursor fires stop AND sessionEnd for one turn seconds apart (two processes).
59
+ // A same-key event inside this window is that echo and must not double-count;
60
+ // beyond it, a repeated key is a REAL replay and counts — exactly like the
61
+ // Python client, which has no double-fire and counts every replay.
62
+ export const DOUBLE_FIRE_WINDOW_S = 120;
63
+
64
+ type DayRow = {
65
+ attempts: number;
66
+ accepted: number;
67
+ drops: Record<string, number>;
68
+ hours: Record<string, number>; // "00".."23" → attempts that hour
69
+ };
70
+ // Result stamps carry the counted VERDICT and the day it landed on, so a
71
+ // same-key echo can be reconciled order-independently (CodeRedTeam: the
72
+ // server may answer the losing hook's "duplicate" BEFORE the winning hook's
73
+ // "added" — the turn was captured, and the counters must say so whichever
74
+ // response is processed first).
75
+ type ResultStamp = { t: number; s: "added" | "duplicate"; d: string };
76
+ type Store = {
77
+ version: 1;
78
+ days: Record<string, DayRow>;
79
+ // content-free turn-key hashes → unix seconds of the last COUNTED event
80
+ gate_seen: Record<string, number>;
81
+ result_seen: Record<string, ResultStamp>;
82
+ last_sent_hash: string | null;
83
+ };
84
+
85
+ export type CaptureStatsDay = {
86
+ day: string;
87
+ attempts: number;
88
+ accepted: number;
89
+ hours_active: number;
90
+ max_hour_attempts: number;
91
+ drops: Record<string, number>;
92
+ };
93
+
94
+ function statsPath(): string {
95
+ return join(atlasoDir(), "capture_stats.json");
96
+ }
97
+ function lockPath(): string {
98
+ return join(atlasoDir(), "capture_stats.lock");
99
+ }
100
+
101
+ export function utcDay(d = new Date()): string {
102
+ return d.toISOString().slice(0, 10);
103
+ }
104
+ function utcHour(d = new Date()): string {
105
+ return d.toISOString().slice(11, 13);
106
+ }
107
+
108
+ function emptyStore(): Store {
109
+ return { version: 1, days: {}, gate_seen: {}, result_seen: {}, last_sent_hash: null };
110
+ }
111
+
112
+ function load(): Store {
113
+ try {
114
+ const raw = JSON.parse(readFileSync(statsPath(), "utf8"));
115
+ if (raw && raw.version === 1 && typeof raw.days === "object") {
116
+ raw.gate_seen = raw.gate_seen ?? {};
117
+ // Tolerate pre-reconciliation stamps (bare unix seconds): keep the
118
+ // timestamp, assume the safe verdict ("added" never triggers an
119
+ // upgrade), and let the window expire them naturally.
120
+ const rs: Record<string, ResultStamp> = {};
121
+ for (const [k, v] of Object.entries(raw.result_seen ?? {})) {
122
+ if (typeof v === "number") rs[k] = { t: v, s: "added", d: "" };
123
+ else if (v && typeof (v as ResultStamp).t === "number") rs[k] = v as ResultStamp;
124
+ }
125
+ raw.result_seen = rs;
126
+ return raw as Store;
127
+ }
128
+ } catch {
129
+ /* missing / malformed → fresh */
130
+ }
131
+ return emptyStore();
132
+ }
133
+
134
+ function save(s: Store): void {
135
+ // prune to the newest MAX_DAYS so the file never grows unbounded
136
+ const days = Object.keys(s.days).sort();
137
+ for (const d of days.slice(0, Math.max(0, days.length - MAX_DAYS))) delete s.days[d];
138
+ // prune stale dedupe stamps relative to the NEWEST stamp (a logical clock —
139
+ // wall-clock pruning would wrongly drop stamps after a clock jump, and breaks
140
+ // deterministic tests that inject historical times)
141
+ {
142
+ const stamps = Object.values(s.gate_seen);
143
+ if (stamps.length) {
144
+ const cutoff = Math.max(...stamps) - 2 * DOUBLE_FIRE_WINDOW_S;
145
+ for (const k of Object.keys(s.gate_seen)) if (s.gate_seen[k] < cutoff) delete s.gate_seen[k];
146
+ }
147
+ }
148
+ {
149
+ const stamps = Object.values(s.result_seen).map((v) => v.t);
150
+ if (stamps.length) {
151
+ const cutoff = Math.max(...stamps) - 2 * DOUBLE_FIRE_WINDOW_S;
152
+ for (const k of Object.keys(s.result_seen)) {
153
+ if (s.result_seen[k].t < cutoff) delete s.result_seen[k];
154
+ }
155
+ }
156
+ }
157
+ const target = statsPath();
158
+ const dir = dirname(target);
159
+ mkdirSync(dir, { recursive: true });
160
+ const tmp = join(dir, `.capture_stats.${process.pid}.${randomUUID()}.tmp`);
161
+ const fd = openSync(tmp, "wx", 0o600);
162
+ try {
163
+ writeFileSync(fd, JSON.stringify(s));
164
+ fsyncSync(fd);
165
+ } finally {
166
+ closeSync(fd);
167
+ }
168
+ renameSync(tmp, target);
169
+ }
170
+
171
+ function day(s: Store, key: string): DayRow {
172
+ let row = s.days[key];
173
+ if (!row) {
174
+ row = { attempts: 0, accepted: 0, drops: {}, hours: {} };
175
+ s.days[key] = row;
176
+ }
177
+ return row;
178
+ }
179
+
180
+ async function mutate(fn: (s: Store) => void): Promise<void> {
181
+ try {
182
+ await withToolLock(lockPath(), async () => {
183
+ const s = load();
184
+ fn(s);
185
+ save(s);
186
+ });
187
+ } catch {
188
+ /* counters are best-effort — never break capture */
189
+ }
190
+ }
191
+
192
+ /** Record one gate evaluation. `turnKey` (the deposit idempotency key) dedupes
193
+ * the stop+sessionEnd double-fire: the same key inside DOUBLE_FIRE_WINDOW_S is
194
+ * an echo and is skipped; beyond the window it counts (real-replay parity with
195
+ * the Python client). */
196
+ export async function recordGate(
197
+ reason: string,
198
+ opts: { turnKey?: string | null; now?: Date } = {},
199
+ ): Promise<void> {
200
+ const now = opts.now ?? new Date();
201
+ const nowS = Math.floor(now.getTime() / 1000);
202
+ const dk = utcDay(now);
203
+ await mutate((s) => {
204
+ if (opts.turnKey) {
205
+ const last = s.gate_seen[opts.turnKey];
206
+ if (last !== undefined && nowS - last < DOUBLE_FIRE_WINDOW_S) return; // echo
207
+ s.gate_seen[opts.turnKey] = nowS;
208
+ }
209
+ const row = day(s, dk);
210
+ if ((ATTEMPT_REASONS as readonly string[]).includes(reason)) {
211
+ row.attempts += 1;
212
+ const h = utcHour(now);
213
+ row.hours[h] = (row.hours[h] ?? 0) + 1;
214
+ } else {
215
+ const mapped = REASON_MAP[reason];
216
+ if (!mapped) return; // unmappable = build bug; never invent a label
217
+ row.drops[mapped] = (row.drops[mapped] ?? 0) + 1;
218
+ }
219
+ });
220
+ }
221
+
222
+ /** Count the server's per-item verdicts: "added" → accepted; "duplicate" → the
223
+ * duplicate bucket — UNLESS the item's key is one we already counted (that is
224
+ * our own double-fire echo, not a real replay).
225
+ *
226
+ * Order-independent reconciliation (CodeRedTeam gate): when stop+sessionEnd
227
+ * race, the server hands one hook "added" and the other "duplicate", and the
228
+ * LOCAL processing order of those two responses is arbitrary. Whichever lands
229
+ * first, one captured turn must read accepted=1, duplicate=0 — so a same-key
230
+ * "added" inside the window UPGRADES an earlier "duplicate" (the pair's truth
231
+ * is "captured"), and everything else is a skip. Only counted verdicts stamp
232
+ * the window: an "error"/"invalid" result must never suppress the retry's
233
+ * real verdict. */
234
+ export async function recordDepositResults(
235
+ results: Array<{ client_id?: string; status?: string }>,
236
+ opts: { now?: Date } = {},
237
+ ): Promise<void> {
238
+ if (!Array.isArray(results) || results.length === 0) return;
239
+ const now = opts.now ?? new Date();
240
+ const nowS = Math.floor(now.getTime() / 1000);
241
+ const dk = utcDay(now);
242
+ await mutate((s) => {
243
+ for (const r of results) {
244
+ const status =
245
+ r?.status === "added" ? "added" : r?.status === "duplicate" ? "duplicate" : null;
246
+ if (!status) continue; // uncounted verdicts never stamp the echo window
247
+ const key = r?.client_id;
248
+ if (key) {
249
+ const prev = s.result_seen[key];
250
+ if (prev && nowS - prev.t < DOUBLE_FIRE_WINDOW_S) {
251
+ if (prev.s === "duplicate" && status === "added") {
252
+ // adverse order: the echo's duplicate landed first — undo it and
253
+ // credit the accept on the same day it was counted.
254
+ const row0 = s.days[prev.d];
255
+ if (row0 && (row0.drops["duplicate"] ?? 0) > 0) {
256
+ row0.drops["duplicate"] -= 1;
257
+ if (row0.drops["duplicate"] === 0) delete row0.drops["duplicate"];
258
+ row0.accepted += 1;
259
+ } else {
260
+ day(s, dk).accepted += 1;
261
+ }
262
+ s.result_seen[key] = { t: prev.t, s: "added", d: prev.d };
263
+ }
264
+ continue; // echo — never double-counts
265
+ }
266
+ s.result_seen[key] = { t: nowS, s: status, d: dk };
267
+ }
268
+ const row = day(s, dk);
269
+ if (status === "added") {
270
+ row.accepted += 1;
271
+ } else {
272
+ row.drops["duplicate"] = (row.drops["duplicate"] ?? 0) + 1;
273
+ }
274
+ }
275
+ });
276
+ }
277
+
278
+ /** The additive batch-body payload: last <=35 UTC days, cumulative, clamped. */
279
+ export function buildCaptureStats(store?: Store): CaptureStatsDay[] {
280
+ const s = store ?? load();
281
+ return Object.keys(s.days)
282
+ .sort()
283
+ .slice(-MAX_DAYS)
284
+ .map((dk) => {
285
+ const row = s.days[dk];
286
+ const hours = Object.values(row.hours ?? {});
287
+ return {
288
+ day: dk,
289
+ attempts: row.attempts,
290
+ accepted: Math.min(row.accepted, row.attempts), // emit invariant
291
+ hours_active: Math.min(24, hours.filter((n) => n > 0).length),
292
+ max_hour_attempts: hours.length ? Math.max(...hours) : 0,
293
+ drops: { ...row.drops },
294
+ };
295
+ })
296
+ .filter((d) => d.attempts > 0 || d.accepted > 0 || Object.keys(d.drops).length > 0);
297
+ }
298
+
299
+ function payloadHash(days: CaptureStatsDay[]): string {
300
+ // hash of COUNTS ONLY — content never enters this module
301
+ return createHash("sha256").update(JSON.stringify(days)).digest("hex");
302
+ }
303
+
304
+ /** Returns the payload when the counters changed since the last confirmed send,
305
+ * else null (skip attaching). Call markStatsSent() after a successful push. */
306
+ export function pendingCaptureStats(): CaptureStatsDay[] | null {
307
+ const s = load();
308
+ const days = buildCaptureStats(s);
309
+ if (days.length === 0) return null;
310
+ return payloadHash(days) === s.last_sent_hash ? null : days;
311
+ }
312
+
313
+ export async function markStatsSent(days: CaptureStatsDay[]): Promise<void> {
314
+ const h = payloadHash(days);
315
+ await mutate((s) => {
316
+ s.last_sent_hash = h;
317
+ });
318
+ }
package/lib/connect.ts CHANGED
@@ -24,7 +24,7 @@ import { spawn } from "node:child_process";
24
24
  import { createHash, randomBytes, randomUUID } from "node:crypto";
25
25
  import {
26
26
  appendFileSync, chmodSync, closeSync, fsyncSync, mkdirSync, openSync,
27
- renameSync, statSync, unlinkSync, writeFileSync, writeSync,
27
+ renameSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync,
28
28
  } from "node:fs";
29
29
  import { createServer, type Server } from "node:http";
30
30
  import { hostname } from "node:os";
@@ -37,6 +37,7 @@ const LOCK_NAME = ".connecting";
37
37
  const LOCK_TTL_MS = 15 * 60 * 1000;
38
38
  const START_TIMEOUT_MS = 15000;
39
39
  const POLL_TIMEOUT_MS = 15000;
40
+ const LOCK_HEARTBEAT_MS = 60 * 1000;
40
41
 
41
42
  export function hasToken(): boolean {
42
43
  return !!loadAuth()?.token;
@@ -50,11 +51,14 @@ function lockPath(): string {
50
51
  * unpredictable temp name (O_EXCL, no symlink follow), fsync the file, atomic
51
52
  * rename, then fsync the directory. Mirrors the Python `connect.save_auth`. */
52
53
  export function writeAuth(
53
- server: string,
54
- token: string,
55
- user_id: string,
56
- device_id: string | null,
54
+ opts: {
55
+ server: string;
56
+ token: string;
57
+ user_id: string;
58
+ device_id: string | null;
59
+ },
57
60
  ): string {
61
+ const { server, token, user_id, device_id } = opts;
58
62
  const dir = atlasoDir();
59
63
  mkdirSync(dir, { recursive: true });
60
64
  try {
@@ -95,7 +99,11 @@ function openBrowser(url: string): void {
95
99
  const plt = process.platform;
96
100
  const cmd = plt === "darwin" ? "open" : plt === "win32" ? "cmd" : "xdg-open";
97
101
  const args = plt === "win32" ? ["/c", "start", "", url] : [url];
98
- spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
102
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
103
+ // spawn failures arrive asynchronously on EventEmitter.error, outside the
104
+ // surrounding try/catch. Swallow them: the URL is also written to connect.log.
105
+ child.once("error", () => {});
106
+ child.unref();
99
107
  } catch {
100
108
  /* best-effort */
101
109
  }
@@ -158,6 +166,18 @@ function waitForCode(server: Server, expectedState: string, timeoutMs: number):
158
166
  /** Run the connect handshake to completion. 0 on success. Releases the lock. */
159
167
  export async function runConnect(): Promise<number> {
160
168
  let server: Server | null = null;
169
+ // Authorization can outlive the stale-lock TTL advertised by the server. Refresh
170
+ // the lock while this process is alive so another sessionStart cannot reclaim it
171
+ // and launch a second browser flow.
172
+ const heartbeat = setInterval(() => {
173
+ try {
174
+ const now = new Date();
175
+ utimesSync(lockPath(), now, now);
176
+ } catch {
177
+ /* manual connect or unwritable lock — best-effort */
178
+ }
179
+ }, LOCK_HEARTBEAT_MS);
180
+ heartbeat.unref();
161
181
  try {
162
182
  const base = (process.env.ATLASO_SERVER || loadAuth()?.server || defaultServer()).replace(/\/+$/, "");
163
183
  const label = (hostname() || "this device").slice(0, 80);
@@ -226,11 +246,17 @@ export async function runConnect(): Promise<number> {
226
246
  }
227
247
  if (t?.status === "approved") {
228
248
  if (!t.token || !t.user_id) return 1;
229
- writeAuth(base, t.token, t.user_id, t.device_id ?? null);
249
+ writeAuth({
250
+ server: base,
251
+ token: t.token,
252
+ user_id: t.user_id,
253
+ device_id: t.device_id ?? null,
254
+ });
230
255
  return 0;
231
256
  }
232
257
  return 1;
233
258
  } finally {
259
+ clearInterval(heartbeat);
234
260
  try {
235
261
  server?.close();
236
262
  } catch {
@@ -292,13 +318,19 @@ export function maybeAutoconnect(tool = "opencode"): boolean {
292
318
  const lock = lockPath();
293
319
  if (!acquireLock(lock)) return false;
294
320
  try {
295
- // lib/ ../src/connect-entry.ts (the detached browser-authorize process)
296
- const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "connect-entry.ts");
297
- spawn("bun", ["run", entry], {
321
+ const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "connect.ts");
322
+ // Reuse the exact Bun executable running this hook. This avoids PATH drift in
323
+ // GUI launches; the override exists for deterministic failure-path tests.
324
+ const bun = process.env.ATLASO_BUN_PATH || process.execPath || "bun";
325
+ const child = spawn(bun, ["run", entry], {
298
326
  stdio: "ignore",
299
327
  detached: true,
300
328
  env: { ...process.env, ATLASO_TOOL: tool },
301
- }).unref();
329
+ });
330
+ // ENOENT and similar failures are emitted asynchronously, not thrown. Release
331
+ // our filesystem lock so the next session can retry immediately.
332
+ child.once("error", () => releaseConnectLock());
333
+ child.unref();
302
334
  return true;
303
335
  } catch {
304
336
  try {
@@ -0,0 +1,148 @@
1
+ /** Per-tool credential resolution — the TS port of the Python client's
2
+ * `_credential.py` resolve()/exchange() state machine.
3
+ *
4
+ * Every Atlaso integration on a machine shares ONE bearer (~/.atlaso/auth.json), so
5
+ * the brain can't tell two tools on a device apart — "remove tool X" can't actually
6
+ * stop X. The fix: each tool trades the shared bearer for its OWN credential at
7
+ * ~/.atlaso/tools/<tool>.json, minted via POST /v1/device/exchange, under a kernel
8
+ * lock only that tool participates in. The brain then keys off the tool's own token.
9
+ *
10
+ * Two invariants are load-bearing (break either and the feature is a lie):
11
+ * 1. NEVER BRICK. Only a VERIFIED verdict from our own server (x-atlaso-response: 1)
12
+ * may take a tool offline. A failed exchange / edge 403 / 5xx / lock-miss is NOT
13
+ * a verdict — keep the shared bearer and retry next run. (On a normal device the
14
+ * shared bearer still works on the data plane; this is the WAF-brick lesson.)
15
+ * 2. TOMBSTONE. A verified `tool_revoked` means the user removed this tool — go
16
+ * local-only and do NOT fall back to the shared bearer, or we'd resurrect it.
17
+ * Only an EXPLICIT reconnect (the browser flow) lifts it, never this automatic path.
18
+ */
19
+ import {
20
+ clearToolAuth, defaultServer, loadAuth, loadToolAuth, saveToolAuth,
21
+ toolLockPath, toolsDir, type Auth,
22
+ } from "./atlaso";
23
+ import { withToolLock } from "./lock";
24
+ import { mkdirSync } from "node:fs";
25
+ import * as state from "./state";
26
+
27
+ const EXCHANGE_TIMEOUT_MS = 8000;
28
+
29
+ /** A credential can't be attributed unless server + user + device all match the
30
+ * shared bearer — a reconnect into a different account must not reuse a stale
31
+ * tool file. A null device id is a valid legacy identity when BOTH files agree. */
32
+ function sameIdentity(a: Auth, b: Auth): boolean {
33
+ return (
34
+ !!a.server && !!a.user_id &&
35
+ a.server === b.server &&
36
+ a.user_id === b.user_id &&
37
+ (a.device_id ?? null) === (b.device_id ?? null)
38
+ );
39
+ }
40
+
41
+ type ExchangeResult =
42
+ | { kind: "minted"; token: string }
43
+ | { kind: "revoked" } // verified 403 tool_revoked — the user removed this tool
44
+ | { kind: "not_entitled" } // verified 409 — free plan, another tool owns the slot
45
+ | { kind: "unverified" }; // network / edge / 5xx / 200-without-token — not a verdict
46
+
47
+ /** Trade the shared bearer for this tool's own token. Only a 200-with-token mints;
48
+ * every unverified outcome returns `unverified` so the caller keeps the shared
49
+ * bearer. Verified 403 tool_revoked / 409 are the only offline-taking verdicts. */
50
+ async function exchange(shared: Auth, tool: string): Promise<ExchangeResult> {
51
+ const ctrl = new AbortController();
52
+ const timer = setTimeout(() => ctrl.abort(), EXCHANGE_TIMEOUT_MS);
53
+ try {
54
+ const res = await fetch(shared.server.replace(/\/+$/, "") + "/v1/device/exchange", {
55
+ method: "POST",
56
+ headers: { Authorization: `Bearer ${shared.token}`, "Content-Type": "application/json" },
57
+ body: JSON.stringify({ tool }),
58
+ signal: ctrl.signal,
59
+ });
60
+ const verified = res.headers?.get("x-atlaso-response") === "1";
61
+ if (res.ok) {
62
+ const data = (await res.json().catch(() => null)) as { token?: unknown } | null;
63
+ if (data && typeof data.token === "string" && data.token) return { kind: "minted", token: data.token };
64
+ return { kind: "unverified" }; // 200 but no token → treat as transient, keep shared
65
+ }
66
+ if (verified) {
67
+ const err = res.headers?.get("x-atlaso-error") || "";
68
+ if (res.status === 403 && err === "tool_revoked") return { kind: "revoked" };
69
+ if (res.status === 409) return { kind: "not_entitled" };
70
+ // A verified 401 means the SHARED bearer itself is dead — not this tool. Fall
71
+ // back so the subsequent data-plane call retires the shared bearer (its job).
72
+ }
73
+ return { kind: "unverified" };
74
+ } catch {
75
+ return { kind: "unverified" };
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Resolve the credential to make cloud calls with for `tool`.
83
+ * - own credential present + same identity → use it (fast path, no network).
84
+ * - else mint one under the kernel lock (POST /v1/device/exchange).
85
+ * - unverified failure / no lock → the SHARED bearer (never-brick).
86
+ * - verified tombstone / not-entitled → null (go local-only, do NOT fall back).
87
+ * Returns null only when the tool must stay offline this run (or we're not connected).
88
+ */
89
+ export async function resolveCredential(tool: string): Promise<Auth | null> {
90
+ const shared = loadAuth();
91
+ if (!tool) return shared ? { ...shared, source: "shared" } : null;
92
+ // Not connected at all → nothing to mint from.
93
+ if (!shared || !shared.token || !shared.server) return null;
94
+
95
+ // Fast path: our own credential, provably ours → use it, no round-trip.
96
+ const own = loadToolAuth(tool);
97
+ if (own && sameIdentity(own, shared)) return { ...own, source: "own", tool };
98
+
99
+ // Mint under the lock. Ensure the dir exists so the lock file can be created.
100
+ try {
101
+ mkdirSync(toolsDir(), { recursive: true });
102
+ } catch {
103
+ /* if we can't make the dir, withToolLock will fail to lock → shared bearer */
104
+ }
105
+
106
+ return await withToolLock(toolLockPath(tool), async (held) => {
107
+ if (!held) {
108
+ // No kernel lock (Windows, or contended past the deadline) → don't mint
109
+ // unlocked; ride the shared bearer this run and retry next time.
110
+ return { ...shared, source: "shared" } as Auth;
111
+ }
112
+ // Re-check under the lock — a peer may have minted while we waited.
113
+ const fresh = loadToolAuth(tool);
114
+ if (fresh && sameIdentity(fresh, shared)) return { ...fresh, source: "own", tool } as Auth;
115
+ if (fresh && !sameIdentity(fresh, shared)) clearToolAuth(tool); // foreign leftover
116
+
117
+ const r = await exchange(shared, tool);
118
+ if (r.kind === "minted") {
119
+ const cred = {
120
+ server: shared.server, token: r.token,
121
+ user_id: shared.user_id, device_id: shared.device_id, tool, version: 1,
122
+ };
123
+ try {
124
+ saveToolAuth(tool, cred);
125
+ } catch {
126
+ /* couldn't persist — still usable this run */
127
+ }
128
+ // A fresh mint must not inherit the previous (dead) credential's suppression.
129
+ state.invalidate();
130
+ return { ...cred, source: "own" } as Auth;
131
+ }
132
+ if (r.kind === "revoked") {
133
+ clearToolAuth(tool);
134
+ // Tombstone: stay down; falling back to the shared bearer would resurrect a
135
+ // tool the user just removed. Only an explicit reconnect lifts this.
136
+ state.setLocalOnly(state.REVOKED, { tool, device_id: shared.device_id ?? null });
137
+ return null;
138
+ }
139
+ if (r.kind === "not_entitled") {
140
+ // Free plan, a different tool owns the single slot → local-only. Don't fall
141
+ // back to the shared bearer (that would let this tool masquerade as entitled).
142
+ state.setLocalOnly(state.NOT_ENTITLED, { tool, device_id: shared.device_id ?? null });
143
+ return null;
144
+ }
145
+ // Unverified → never-brick: keep the shared bearer, retry next run.
146
+ return { ...shared, source: "shared" } as Auth;
147
+ });
148
+ }