@symbols-cli/cli 0.0.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.
Files changed (41) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +103 -0
  3. package/dist/auth/client.js +531 -0
  4. package/dist/auth/credentials.js +293 -0
  5. package/dist/auth/hosts.js +85 -0
  6. package/dist/auth/loopback.js +108 -0
  7. package/dist/auth/pkce.js +33 -0
  8. package/dist/auth/wire.js +40 -0
  9. package/dist/commands/arm.js +154 -0
  10. package/dist/commands/curl.js +101 -0
  11. package/dist/commands/doctor.js +217 -0
  12. package/dist/commands/login.js +113 -0
  13. package/dist/commands/logout.js +78 -0
  14. package/dist/commands/mcp.js +33 -0
  15. package/dist/commands/project.js +145 -0
  16. package/dist/commands/status.js +78 -0
  17. package/dist/commands/sync.js +94 -0
  18. package/dist/commands/uninstall.js +149 -0
  19. package/dist/commands/up.js +176 -0
  20. package/dist/commands/update.js +120 -0
  21. package/dist/commands/watch.js +155 -0
  22. package/dist/commands/whoami.js +103 -0
  23. package/dist/index.js +147 -0
  24. package/dist/mcp/scopes.js +215 -0
  25. package/dist/mcp/server.js +366 -0
  26. package/dist/mcp/tools.js +646 -0
  27. package/dist/skills/bundle.js +441 -0
  28. package/dist/skills/claude-md.js +135 -0
  29. package/dist/skills/install.js +188 -0
  30. package/dist/skills/settings-merge.js +107 -0
  31. package/dist/sync/api.js +380 -0
  32. package/dist/sync/diff.js +172 -0
  33. package/dist/sync/ledger.js +319 -0
  34. package/dist/sync/paths.js +447 -0
  35. package/dist/sync/protect.js +108 -0
  36. package/dist/sync/reconcile.js +870 -0
  37. package/dist/sync/watcher.js +206 -0
  38. package/dist/util/log.js +58 -0
  39. package/dist/util/platform.js +79 -0
  40. package/dist/util/version.js +24 -0
  41. package/package.json +44 -0
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+
3
+ This software is proprietary and confidential. Unauthorized copying,
4
+ distribution, modification, or use of this software, via any medium, is
5
+ strictly prohibited.
6
+
7
+ No license, express or implied, is granted except by a written agreement
8
+ signed by Symbols LLC.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Symbols CLI
2
+
3
+ Run the Symbols agent on your own machine.
4
+
5
+ ```bash
6
+ npm i -g @symbols-cli/cli # requires Node >= 20
7
+ symbols login
8
+ symbols up
9
+ cd ~/Symbols/<your project>
10
+ claude
11
+ ```
12
+
13
+ ## Why this exists
14
+
15
+ Odin Code runs the agent in a per-user container capped at **1536 MB and 1 vCPU**.
16
+ `claude` alone holds ~675 MB, so a genuinely large task — "make a report for 100
17
+ stocks" — gets OOM-killed. Your laptop has 16–64 GB and 8–16 cores, dedicated to
18
+ one person. Same agent, same skills, same data; no ceiling.
19
+
20
+ Typing is also local. Today every keystroke crosses the network twice
21
+ (browser → websocket → server → `docker exec` → tmux → claude, and back) at a
22
+ measured ~256 ms round trip. Here there is no network in the loop.
23
+
24
+ ## What runs where
25
+
26
+ | | |
27
+ |---|---|
28
+ | The agent (`claude`), your files, your editor | **Your machine** |
29
+ | Market data, backtests, notebook cell execution, LEAN | **Symbols servers** |
30
+
31
+ Market data always comes through the API — no Databento, Unusual Whales or R2
32
+ credentials are ever placed on your machine. Backtests POST and poll; LEAN never
33
+ runs locally.
34
+
35
+ ## Read this before you install
36
+
37
+ **The agent runs as you, in your home directory.** That is the entire point — it is
38
+ what removes the memory ceiling — and it is also the thing to understand before
39
+ installing.
40
+
41
+ Inside a container, a prompt-injection attack (a hostile string in a fetched web
42
+ page, an imported notebook, a forked community strategy) was bounded by the
43
+ sandbox. On your laptop the boundary is different: it is Claude Code's own
44
+ permission model plus your OS's file protections. An agent session that goes wrong
45
+ can reach what you can reach — your SSH keys, your browser profile, your other
46
+ repositories.
47
+
48
+ Symbols' commitment is to add **no attack surface beyond what vanilla Claude Code
49
+ already has**:
50
+
51
+ - Skills are **signed**; an unsigned or wrong-key bundle is refused, and so is a
52
+ rollback to an older version.
53
+ - File sync **never writes server content into config-bearing paths** — not
54
+ `.claude/**`, not `.mcp.json`, not `CLAUDE.md` outside its managed markers, not
55
+ `.git/**`. Those are files a tool *executes*, and a compromised server must not
56
+ be able to place one in your project.
57
+ - `symbols` writes inside `~/Symbols/**` and its own `~/.symbols/` state directory.
58
+ It never touches `~/.claude/`, `~/.claude.json`, or your shell rc.
59
+
60
+ ## Your credential
61
+
62
+ `symbols login` opens your browser. You authenticate with Clerk there; the CLI
63
+ never sees a password.
64
+
65
+ What comes back is a device credential, stored in the **macOS keychain** (or
66
+ `~/.symbols/credentials` at `0600` elsewhere — `symbols whoami` tells you which,
67
+ rather than assuming). It is:
68
+
69
+ - **read-and-notebook scoped.** It cannot place an order. `symbols whoami` prints
70
+ the live scope list so you can check that yourself.
71
+ - **short-lived in use.** Access tokens last 15 minutes and are held in memory
72
+ only.
73
+ - **revocable, durably.** `symbols logout`, or revoke the device from the app. A
74
+ revoked device stops refreshing immediately; an access token already minted
75
+ stays valid until it expires, at most 15 minutes.
76
+
77
+ Placing live orders is deliberately **not** something this credential can do. The
78
+ CLI proposes an order; you approve it in the app. That keeps a human at the point
79
+ where money moves, which a timer on a laptop cannot substitute for.
80
+
81
+ ## Commands
82
+
83
+ | | |
84
+ |---|---|
85
+ | `symbols login` / `logout` / `whoami` | device credential |
86
+ | `symbols up` | create `~/Symbols` and wire the project |
87
+ | `symbols update` | fetch + verify the signed skills bundle |
88
+ | `symbols sync` / `watch` / `status` | file sync |
89
+ | `symbols project new \| ls \| rm` | projects |
90
+ | `symbols curl <path>` | call the API by path, authenticated |
91
+ | `symbols doctor` | check the install; exits non-zero on any problem |
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ bun install
97
+ bun run --cwd apps/cli build
98
+ node --test apps/cli/test/*.test.mjs
99
+
100
+ # Point at a local server (the API origin is otherwise allowlisted)
101
+ SYMBOLS_ALLOW_INSECURE_ORIGIN=1 SYMBOLS_API_URL=http://127.0.0.1:8000 \
102
+ node apps/cli/dist/index.js whoami
103
+ ```
@@ -0,0 +1,531 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // The authed fetch every command goes through.
6
+ //
7
+ // It owns four things so that no caller has to think about them:
8
+ //
9
+ // 1. HOST PINNING (S3). Every request resolves its origin through `apiOrigin()`.
10
+ // Callers pass a PATH, never a URL — the same shape `symbols curl` enforces,
11
+ // for the same reason.
12
+ // 2. ACCESS-TOKEN LIFECYCLE. A 15-minute access token is minted from the
13
+ // refresh credential on demand, cached in memory only, and re-minted once on
14
+ // a 401. It is never written to disk.
15
+ // 3. WRITE-AHEAD ROTATION. This client GENERATES the next refresh token and
16
+ // PERSISTS it before asking the server to commit its hash, so the credential
17
+ // exists here before it can exist there. Recovery after a crash is therefore
18
+ // purely local — try the pending token, fall back to the current one — and
19
+ // the server never has to hand a credential back to whoever asks for one.
20
+ // That last property is not a nicety: an earlier design let the server
21
+ // re-issue on presentation of a superseded token, and it could not tell a
22
+ // crashed client from an attacker holding a copy. It was a session takeover.
23
+ // 4. VERSION SIGNALLING. `X-Symbols-Cli-Version` on every request, and a `426`
24
+ // is surfaced as an actionable upgrade message rather than a raw status.
25
+ //
26
+ // ⚠ A 426-driven auto-upgrade MUST NOT be added here before bundle signing (S1)
27
+ // lands. An unsigned self-update path is a remote-code-execution channel into
28
+ // every user's home directory, which is precisely the inversion the plan calls
29
+ // the thing it got most wrong.
30
+ import { promises as fs } from "node:fs";
31
+ import { join, dirname } from "node:path";
32
+ import { apiOrigin } from "./hosts.js";
33
+ import { randomBytes, createHash } from "node:crypto";
34
+ import { load, save, credentialsPath } from "./credentials.js";
35
+ import { CLI_VERSION } from "../util/version.js";
36
+ import { refreshBody } from "./wire.js";
37
+ export class NotLoggedInError extends Error {
38
+ constructor() {
39
+ super("not signed in — run `symbols login`");
40
+ this.name = "NotLoggedInError";
41
+ }
42
+ }
43
+ export class DeviceRevokedError extends Error {
44
+ constructor() {
45
+ super("this device's access was revoked — run `symbols login` to sign in again");
46
+ this.name = "DeviceRevokedError";
47
+ }
48
+ }
49
+ /** In memory only, for the life of the process. Never persisted. */
50
+ let cached = null;
51
+ /** Re-mint this long before expiry so an in-flight request cannot straddle it. */
52
+ const EXPIRY_SKEW_MS = 60_000;
53
+ /**
54
+ * How long a single credential rotation may take before it is abandoned.
55
+ *
56
+ * Generous on purpose — see the note at the POST. This exists to bound an
57
+ * unbounded wait, not to make the refresh fast.
58
+ */
59
+ const REFRESH_TIMEOUT_MS = 20_000;
60
+ export class ApiError extends Error {
61
+ status;
62
+ path;
63
+ constructor(status, path, message) {
64
+ super(message);
65
+ this.status = status;
66
+ this.path = path;
67
+ this.name = "ApiError";
68
+ }
69
+ }
70
+ /** A path, always — `/api/...`. Passing a URL is a programming error, not config. */
71
+ function urlFor(path) {
72
+ if (!path.startsWith("/")) {
73
+ throw new Error(`api path must start with '/' (got '${path}')`);
74
+ }
75
+ return apiOrigin() + path;
76
+ }
77
+ async function rawFetch(path, init) {
78
+ const headers = new Headers(init.headers);
79
+ headers.set("x-symbols-cli-version", CLI_VERSION);
80
+ headers.set("accept", "application/json");
81
+ return fetch(urlFor(path), { ...init, headers, redirect: "error" });
82
+ }
83
+ /**
84
+ * Exchange the stored refresh token for an access token, rotating the refresh
85
+ * token in the process.
86
+ *
87
+ * Persist-before-use: see the S4 note at the top of this file.
88
+ */
89
+ /** A fresh refresh token for this device. 32 CSPRNG bytes, url-safe. */
90
+ function newRefreshToken(deviceId) {
91
+ return `${deviceId}.${randomBytes(32).toString("base64url")}`;
92
+ }
93
+ function sha256Hex(s) {
94
+ return createHash("sha256").update(s).digest("hex");
95
+ }
96
+ /**
97
+ * Exchange the stored refresh token for an access token, rotating as we go.
98
+ *
99
+ * ## WRITE-AHEAD — the ordering is the security property
100
+ *
101
+ * We GENERATE the next token, PERSIST it as `pendingToken`, and only then ask
102
+ * the server to commit its hash. The credential therefore exists on this disk
103
+ * before it can exist server-side, so no crash window can strand us holding
104
+ * something the server has superseded.
105
+ *
106
+ * That is what lets the server treat any presentation of a superseded token as
107
+ * a replay and revoke on it. Three earlier designs routed recovery through the
108
+ * server instead, which forced `refresh` to hand a credential to whoever
109
+ * presented the previous token — and it cannot tell a crashed client from an
110
+ * attacker holding a copy. The last of those was a full session takeover.
111
+ *
112
+ * Recovery is local and needs no server cooperation:
113
+ * * crashed before the POST -> the server never committed; `pending` is
114
+ * unknown to it, so fall back to `refreshToken`
115
+ * * crashed after the POST -> the server committed; `pending` IS current,
116
+ * so it works and we promote it
117
+ */
118
+ async function mintAccessToken() {
119
+ const cred = await load();
120
+ if (!cred)
121
+ throw new NotLoggedInError();
122
+ // A credential minted against a different origin must not be sent to this one.
123
+ const origin = apiOrigin();
124
+ if (cred.origin !== origin) {
125
+ throw new Error(`stored credential belongs to ${cred.origin}, not ${origin} — run \`symbols login\``);
126
+ }
127
+ // Try the pending token FIRST. If a previous rotation committed server-side
128
+ // but we died before promoting it, this is the live credential.
129
+ if (cred.pendingToken) {
130
+ try {
131
+ const minted = await rotate(cred, cred.pendingToken);
132
+ return minted;
133
+ }
134
+ catch (err) {
135
+ if (err instanceof DeviceRevokedError) {
136
+ // The server does not recognise it, so that rotation never committed.
137
+ // Drop it before falling back, or every future call pays a wasted round
138
+ // trip re-presenting a token that will never be accepted.
139
+ const withoutPending = { ...cred };
140
+ delete withoutPending.pendingToken;
141
+ await save(withoutPending);
142
+ }
143
+ else {
144
+ throw err;
145
+ }
146
+ }
147
+ }
148
+ return rotate(cred, cred.refreshToken);
149
+ }
150
+ /** One rotation attempt, presenting `present` and writing ahead the successor. */
151
+ async function rotate(cred, present) {
152
+ const next = newRefreshToken(cred.deviceId);
153
+ // ⚠ PERSIST BEFORE THE REQUEST. This is the whole scheme.
154
+ //
155
+ // Only `pendingToken` moves. An earlier version also set
156
+ // `refreshToken: present`, which on the RECOVERY path — where `present` is
157
+ // itself an unconfirmed pending token — overwrote the last known-good
158
+ // credential with one the server might never have committed. A crash there
159
+ // lost the original outright.
160
+ await save({ ...cred, pendingToken: next });
161
+ // ⚠ A6 — THE REFRESH NEEDS ITS OWN DEADLINE. It had none, so a stalled
162
+ // rotation ran until the CALLER's budget expired — measured: a tool with a
163
+ // nominal 15 s cap returned after 22074 ms, and the error named the API call
164
+ // rather than the refresh that actually hung.
165
+ //
166
+ // Aborting mid-rotation is safe and is NOT a new failure mode: `save()` above
167
+ // persists `pendingToken` BEFORE this POST, and `mintAccessToken`'s recovery
168
+ // path already handles the crashed-mid-rotation case. An abort is therefore
169
+ // equivalent to a crash, and recovers from local state.
170
+ //
171
+ // Not set aggressively: this is a multi-statement Postgres transaction on the
172
+ // far side, not a cheap read, so a tight deadline would convert a slow
173
+ // rotation into a failed one.
174
+ const res = await rawFetch("/api/auth/cli/refresh", {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json" },
177
+ body: JSON.stringify(refreshBody({ ...cred, refreshToken: present }, sha256Hex(next))),
178
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
179
+ });
180
+ if (res.status === 401 || res.status === 403) {
181
+ throw new DeviceRevokedError();
182
+ }
183
+ if (!res.ok) {
184
+ throw new ApiError(res.status, "/api/auth/cli/refresh", `refresh failed (${res.status})`);
185
+ }
186
+ const body = (await res.json());
187
+ // Committed. Promote: `next` is the live credential and nothing is pending.
188
+ //
189
+ // ⚠ The token just presented is now SUPERSEDED, and the server has it in
190
+ // history — presenting it again is a proven replay and revokes the device. It
191
+ // must not survive anywhere in the stored credential.
192
+ const promoted = { ...cred, refreshToken: next };
193
+ delete promoted.pendingToken;
194
+ await save(promoted);
195
+ const expiresAt = Date.parse(body.expires_at);
196
+ return {
197
+ mintedAt: Date.now(),
198
+ token: body.access_token,
199
+ // A malformed timestamp must not become an infinitely-valid token.
200
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + 15 * 60_000,
201
+ };
202
+ }
203
+ /**
204
+ * Cross-process single-flight around the refresh.
205
+ *
206
+ * Without it, two `symbols` processes (`watch` in one terminal, `status` in
207
+ * another) can present the SAME refresh token at the same instant. One rotates;
208
+ * the other is then holding a token the server has just superseded — and a
209
+ * superseded token is a PROVEN REPLAY, so the server revokes the device. It is
210
+ * right to: it cannot tell our second process from an attacker holding a stolen
211
+ * copy, and guessing in the caller's favour is how an earlier design became a
212
+ * session takeover.
213
+ *
214
+ * ⚠ THAT MAKES THIS LOCK THE ONLY DEFENCE, not an optimisation. The server is
215
+ * deliberately fail-closed, so every collision this client fails to prevent
216
+ * signs the user out. Serialising means exactly one process presents a token;
217
+ * the losers wait, re-read the store, and find the winner's rotated credential
218
+ * already there.
219
+ *
220
+ * The crash-before-persist case a server-side grace used to cover is handled
221
+ * locally instead: the client writes its successor to disk BEFORE the server can
222
+ * commit it, so recovery never needs the server's cooperation. See `rotate`.
223
+ */
224
+ const LOCK_STALE_MS = 30_000;
225
+ function lockPath() {
226
+ return join(dirname(credentialsPathForLock()), "refresh.lock");
227
+ }
228
+ /** The credentials dir, without importing the path helper's whole surface. */
229
+ function credentialsPathForLock() {
230
+ // `credentialsPath()` is the authority for where state lives; the lock sits
231
+ // beside it so a custom SYMBOLS_HOME keeps them together.
232
+ return credentialsPath();
233
+ }
234
+ async function acquireLock() {
235
+ const path = lockPath();
236
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
237
+ try {
238
+ // `wx` is the atomic create-or-fail; two processes cannot both succeed.
239
+ const h = await fs.open(path, "wx", 0o600);
240
+ await h.writeFile(String(process.pid));
241
+ await h.close();
242
+ return true;
243
+ }
244
+ catch (err) {
245
+ if (err.code !== "EEXIST")
246
+ throw err;
247
+ // A crashed process leaves its lock behind. Break it once it is clearly
248
+ // stale — a permanently stuck lock is worse than a rare double-refresh,
249
+ // because it bricks every command rather than costing one rotation.
250
+ try {
251
+ const st = await fs.stat(path);
252
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
253
+ await fs.rm(path, { force: true });
254
+ return acquireLock();
255
+ }
256
+ }
257
+ catch {
258
+ // The holder released it between our open and our stat. Fall through.
259
+ }
260
+ return false;
261
+ }
262
+ }
263
+ async function releaseLock() {
264
+ await fs.rm(lockPath(), { force: true }).catch(() => { });
265
+ }
266
+ /** Refresh under the lock; if another process holds it, wait for its result. */
267
+ async function mintUnderLock() {
268
+ // ⚠ A3 — there used to be a `const before = await load()` here, costing a
269
+ // ~17 ms keychain exec on EVERY refresh. Its only consumer was a comparison
270
+ // whose `continue` was the last statement of the loop body, so taking the
271
+ // branch was indistinguishable from falling through: the whole thing was a
272
+ // no-op with a subprocess attached.
273
+ //
274
+ // The read the write-ahead scheme actually depends on is the one
275
+ // `mintAccessToken` performs UNDER the lock, which is untouched. If you want
276
+ // the original intent back, load AFTER a failed `acquireLock()` — never
277
+ // before the first attempt.
278
+ let before = null;
279
+ for (let attempt = 0; attempt < 40; attempt += 1) {
280
+ if (await acquireLock()) {
281
+ try {
282
+ return await mintAccessToken();
283
+ }
284
+ finally {
285
+ await releaseLock();
286
+ }
287
+ }
288
+ await new Promise((r) => setTimeout(r, 50));
289
+ // The winner writes the rotated credential before releasing, so a changed
290
+ // token means its refresh already succeeded.
291
+ //
292
+ // ⚠ LOOP BACK — DO NOT PRESENT IT HERE. An earlier version returned
293
+ // `mintAccessToken()` directly from this branch, outside the lock. With two
294
+ // processes that is safe, and with three it is not: a third rotation can
295
+ // supersede the token we just observed between this `load()` and the
296
+ // presentation, and the server (correctly fail-closed, since it cannot tell
297
+ // our process from an attacker) revokes the device.
298
+ //
299
+ // It is the same mistake as the fallback below, one level subtler — present
300
+ // a token only under the lock, always. Continuing costs one more 50ms tick
301
+ // and the next iteration acquires the lock immediately, because the winner
302
+ // has released it.
303
+ // Lazily captured on the FIRST contended pass only — by which point we are
304
+ // already paying a 50 ms tick, so the keychain read is free relative to it.
305
+ before ??= await load();
306
+ const now = await load();
307
+ if (now && before && now.refreshToken !== before.refreshToken) {
308
+ continue;
309
+ }
310
+ }
311
+ // ⚠ DO NOT PRESENT A TOKEN HERE.
312
+ //
313
+ // This used to fall through to `mintAccessToken()` on the grounds that "the
314
+ // server's grace covers the collision". That grace no longer exists, and under
315
+ // the current design presenting a token the winning process has already
316
+ // superseded is a PROVEN REPLAY — the server revokes the device. On a flaky
317
+ // link, a collision this code caused would sign the user's own laptop out.
318
+ //
319
+ // The server cannot help: it genuinely cannot tell our second process from an
320
+ // attacker, and fail-closed is the right choice for it. That makes this lock
321
+ // the real defence, so refusing is the correct outcome — a retry costs a
322
+ // second, a spurious revoke costs the session.
323
+ throw new Error("another `symbols` process is refreshing this device's credential. Try again in a moment.");
324
+ }
325
+ /**
326
+ * The mint currently in flight, if any. **A1 — single-flight.**
327
+ *
328
+ * ⚠ WITHOUT THIS, ONE PROCESS RACES ITSELF INTO ITS OWN CROSS-PROCESS LOCK.
329
+ *
330
+ * The MCP SDK dispatches `tools/call` concurrently. Measured on the shipped
331
+ * build: 12 parallel tool calls at session start produced **7** refresh POSTs
332
+ * and **5 hard failures** reading "another `symbols` process is refreshing this
333
+ * device's credential" — with exactly one process running. Every caller missed
334
+ * the cache, every caller called `mintUnderLock`, and the losers exhausted the
335
+ * 40 x 50 ms lock wait and gave up.
336
+ *
337
+ * Memoising the in-flight promise takes that to 1 refresh POST, 12/12 calls
338
+ * succeeding, and the slowest call from 2323 ms to 807 ms. It also cuts six
339
+ * credential rotations, which moves AWAY from the replay/revoke cliff rather
340
+ * than toward it.
341
+ *
342
+ * Two conditions, both load-bearing:
343
+ * * cleared in a `finally`, so a rejected mint is never cached — one
344
+ * transient failure would otherwise poison every later call in the session;
345
+ * * keyed on `force`, so a caller that needs a genuinely fresh token cannot
346
+ * be handed the stale in-flight one.
347
+ *
348
+ * The cross-process file lock is untouched. This stops a process racing ITSELF;
349
+ * that lock still stops two processes racing each other.
350
+ */
351
+ let inFlight = null;
352
+ async function accessToken(force = false) {
353
+ if (!force && cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
354
+ return cached.token;
355
+ }
356
+ if (inFlight && (inFlight.force || !force)) {
357
+ return (await inFlight.promise).token;
358
+ }
359
+ const promise = mintUnderLock();
360
+ inFlight = { force, promise };
361
+ try {
362
+ cached = await promise;
363
+ return cached.token;
364
+ }
365
+ finally {
366
+ // ⚠ ALWAYS, INCLUDING ON REJECTION. A cached rejected promise would make
367
+ // every subsequent call in this process fail with a stale error.
368
+ if (inFlight?.promise === promise)
369
+ inFlight = null;
370
+ }
371
+ }
372
+ /** Drop the cached access token — used by `logout`, and after a 401. */
373
+ export function forgetAccessToken() {
374
+ cached = null;
375
+ retriedGeneration = null;
376
+ }
377
+ /**
378
+ * May a 401 be retried with a freshly minted token?
379
+ *
380
+ * ⚠ ONE DECISION, TWO CALLERS. `request()` and `requestBytes()` each grew their
381
+ * own version and drifted; `requestBytes` ended up retrying unconditionally,
382
+ * which is the bug `request()` had already been fixed for.
383
+ *
384
+ * A forced re-mint is not free: it is a full refresh, so it ROTATES the refresh
385
+ * chain and writes the credential store. Retrying a 401 that the token cannot
386
+ * explain — an unscoped route, a revoked device — buys nothing and costs a
387
+ * rotation.
388
+ *
389
+ * ⚠ A5 — THIS IS PER TOKEN GENERATION, NOT PER 5 SECONDS, AND THAT MATTERS.
390
+ *
391
+ * The previous rule was "retry if the token is older than 5 s". In a one-shot
392
+ * command that is nearly always false, so it worked. In `symbols mcp` the token
393
+ * lives ~14 minutes, so the guard was effectively OFF: every failing tool call
394
+ * ran GET(401) -> POST /refresh -> GET(401). Measured: 6 failing tool calls
395
+ * produced 17 HTTP requests and 6 refreshes.
396
+ *
397
+ * `auth_cli.rs:192` caps refresh at 60/min per IP. So 60 failing tool calls in
398
+ * a minute would 429 the refresh endpoint — after which EVERY tool fails with
399
+ * `refresh failed (429)`. A latency wart on the one-shot path was a
400
+ * self-inflicted session outage on the agent path.
401
+ *
402
+ * One retry per generation preserves the case that is real — the token expired
403
+ * server-side mid-flight — and refuses the loop.
404
+ */
405
+ let retriedGeneration = null;
406
+ export function shouldRetryAfter401(status, anonymous) {
407
+ if (status !== 401 || anonymous || cached === null)
408
+ return false;
409
+ return retriedGeneration !== cached.token;
410
+ }
411
+ /**
412
+ * Mark the token we just force-minted as having spent its retry.
413
+ *
414
+ * ⚠ THIS IS THE HALF I GOT WRONG FIRST, and the test caught it. Marking the
415
+ * OLD token was useless: the retry immediately mints a new one, and the new
416
+ * token did not match the marker, so the very next 401 retried again. Six
417
+ * failing calls still burned six rotations — the exact behaviour A5 exists to
418
+ * stop — and only a behavioural test showed it. A source assertion that the
419
+ * guard "is generation-based" would have passed.
420
+ *
421
+ * Marking the NEW token is what makes a generation finite: it gets one forced
422
+ * re-mint, and a 401 after that is understood to be about something other than
423
+ * staleness. A genuinely new token — minted on natural expiry ~15 min later —
424
+ * starts with its retry unspent.
425
+ */
426
+ function markRetryConsumed() {
427
+ retriedGeneration = cached?.token ?? null;
428
+ }
429
+ export async function request(path, opts = {}) {
430
+ const send = async (bearer) => {
431
+ const headers = { ...opts.headers };
432
+ if (bearer)
433
+ headers["authorization"] = `Bearer ${bearer}`;
434
+ if (opts.body !== undefined)
435
+ headers["content-type"] = "application/json";
436
+ return rawFetch(path, {
437
+ method: opts.method ?? "GET",
438
+ headers,
439
+ ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
440
+ ...(opts.signal ? { signal: opts.signal } : {}),
441
+ });
442
+ };
443
+ let res = await send(opts.anonymous ? null : await accessToken());
444
+ // Exactly one retry, and only on 401. The access token may have been minted
445
+ // just before a server-side rotation; a second 401 is a real refusal and
446
+ // looping on it would hammer the refresh endpoint.
447
+ // ⚠ RETRY ONLY IF THE TOKEN COULD PLAUSIBLY BE THE PROBLEM.
448
+ //
449
+ // A forced re-mint is not free: it is a full refresh, which ROTATES the
450
+ // refresh chain and writes the credential store. An earlier version retried on
451
+ // every 401 unconditionally, so each failing call cost TWO rotations — and a
452
+ // sweep of the 12 MCP tools against a surface that 401s (as it does until
453
+ // P1's extractor sweep reaches a route) burned ~24 rotations, measurably
454
+ // against the 60/min per-IP cap SEC-3 added.
455
+ //
456
+ // If the access token was minted seconds ago it is not stale, so the 401 is
457
+ // about something else — an unscoped route, a revoked device — and re-minting
458
+ // cannot help. Retry only when the token is old enough for expiry or a
459
+ // server-side rotation to be a credible explanation.
460
+ if (shouldRetryAfter401(res.status, opts.anonymous === true)) {
461
+ forgetAccessToken();
462
+ res = await send(await accessToken(true));
463
+ markRetryConsumed();
464
+ }
465
+ if (res.status === 426) {
466
+ const body = await res.text().catch(() => "");
467
+ throw new ApiError(426, path, `this CLI version (${CLI_VERSION}) is no longer supported. ` +
468
+ `Upgrade with \`npm i -g @symbols-cli/cli\`.${body ? ` ${body}` : ""}`);
469
+ }
470
+ const text = await res.text();
471
+ let parsed = null;
472
+ if (text) {
473
+ try {
474
+ parsed = JSON.parse(text);
475
+ }
476
+ catch {
477
+ parsed = text;
478
+ }
479
+ }
480
+ if (!res.ok) {
481
+ const detail = parsed && typeof parsed === "object" && "detail" in parsed
482
+ ? String(parsed.detail)
483
+ : typeof parsed === "string" && parsed
484
+ ? parsed
485
+ : res.statusText;
486
+ throw new ApiError(res.status, path, `${res.status} ${detail}`);
487
+ }
488
+ return { status: res.status, body: parsed };
489
+ }
490
+ /**
491
+ * The same request, returning RAW BYTES.
492
+ *
493
+ * Added by P2 for the skills bundle, which is a gzipped tar: `request` calls
494
+ * `res.text()`, and running a tarball through UTF-8 decoding corrupts it
495
+ * silently — the sha256 would fail with no hint as to why.
496
+ *
497
+ * It goes through `rawFetch` (host pinning) and `accessToken` (the rotation
498
+ * lock) for the same reason every other call does: a second fetch path would be
499
+ * a second place for S3 to be forgotten.
500
+ */
501
+ export async function requestBytes(path, opts = {}) {
502
+ const send = async (bearer) => {
503
+ const headers = { ...opts.headers };
504
+ if (bearer)
505
+ headers["authorization"] = `Bearer ${bearer}`;
506
+ return rawFetch(path, {
507
+ method: opts.method ?? "GET",
508
+ headers,
509
+ ...(opts.signal ? { signal: opts.signal } : {}),
510
+ });
511
+ };
512
+ let res = await send(opts.anonymous ? null : await accessToken());
513
+ // ⚠ A4 — THE SAME HELPER `request()` USES. This path used to retry on EVERY
514
+ // 401 unconditionally, reintroducing the exact bug `request()` documents and
515
+ // guards: one 401 here costs TWO full credential rotations, and both callers
516
+ // (`bundle.ts:424`, `:428`) are on `symbols up` / `symbols update`. Sharing
517
+ // one decision function is what stops the two paths drifting apart again.
518
+ if (shouldRetryAfter401(res.status, opts.anonymous === true)) {
519
+ forgetAccessToken();
520
+ res = await send(await accessToken(true));
521
+ markRetryConsumed();
522
+ }
523
+ if (!res.ok) {
524
+ throw new ApiError(res.status, path, `${res.status} ${res.statusText}`);
525
+ }
526
+ return { status: res.status, bytes: Buffer.from(await res.arrayBuffer()) };
527
+ }
528
+ /** For `login`, which has no credential yet and authenticates with the code. */
529
+ export async function requestAnonymous(path, opts = {}) {
530
+ return request(path, { ...opts, anonymous: true });
531
+ }