@skrr-ai/cli 0.1.51 → 0.1.52

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 (53) hide show
  1. package/dist/base-command.d.ts +7 -0
  2. package/dist/base-command.js +38 -8
  3. package/dist/commands/agents/create.js +8 -4
  4. package/dist/commands/agents/update.d.ts +23 -0
  5. package/dist/commands/agents/update.js +54 -9
  6. package/dist/commands/commitments/next-tick.js +9 -1
  7. package/dist/commands/context.js +2 -1
  8. package/dist/commands/daemon/config.js +16 -1
  9. package/dist/commands/doctor.js +8 -1
  10. package/dist/commands/login.d.ts +2 -1
  11. package/dist/commands/login.js +26 -7
  12. package/dist/commands/logout.js +3 -0
  13. package/dist/commands/machines/dedicated/show.js +25 -2
  14. package/dist/commands/tasks/create.d.ts +5 -0
  15. package/dist/commands/tasks/create.js +26 -6
  16. package/dist/commands/tasks/start.d.ts +14 -0
  17. package/dist/commands/tasks/start.js +31 -1
  18. package/dist/commands/tasks/update.js +8 -1
  19. package/dist/commands/whoami.js +5 -3
  20. package/dist/lib/agent-config.d.ts +2 -0
  21. package/dist/lib/agent-config.js +14 -10
  22. package/dist/lib/agentic-stream.d.ts +20 -0
  23. package/dist/lib/agentic-stream.js +39 -5
  24. package/dist/lib/auth-failure.js +4 -1
  25. package/dist/lib/auth-storage.d.ts +24 -0
  26. package/dist/lib/auth-storage.js +342 -11
  27. package/dist/lib/cli-id.d.ts +22 -0
  28. package/dist/lib/cli-id.js +56 -0
  29. package/dist/lib/cli-identity.d.ts +49 -0
  30. package/dist/lib/cli-identity.js +159 -0
  31. package/dist/lib/commitments.d.ts +4 -0
  32. package/dist/lib/commitments.js +12 -1
  33. package/dist/lib/config.d.ts +17 -0
  34. package/dist/lib/config.js +15 -3
  35. package/dist/lib/daemonBroker.d.ts +26 -4
  36. package/dist/lib/daemonBroker.js +82 -10
  37. package/dist/lib/daemonBrokerRefusal.d.ts +51 -0
  38. package/dist/lib/daemonBrokerRefusal.js +127 -0
  39. package/dist/lib/dedicated-machines.d.ts +33 -2
  40. package/dist/lib/dedicated-machines.js +50 -1
  41. package/dist/lib/delegated-cli.js +16 -0
  42. package/dist/lib/first-party-harness-broker.js +6 -4
  43. package/dist/lib/keychain.d.ts +39 -0
  44. package/dist/lib/keychain.js +228 -39
  45. package/dist/lib/label-ref.d.ts +34 -0
  46. package/dist/lib/label-ref.js +68 -1
  47. package/dist/lib/login.js +24 -12
  48. package/dist/lib/oauthLogin.js +2 -8
  49. package/dist/lib/refresh.js +6 -2
  50. package/dist/lib/tasks.js +33 -8
  51. package/dist/node_modules/@skrr-ai/data-provider/index.js +3138 -3100
  52. package/oclif.manifest.json +36364 -36355
  53. package/package.json +1 -1
@@ -37,6 +37,7 @@ exports.backends = void 0;
37
37
  exports.readFromFile = readFromFile;
38
38
  exports.deleteFileBackend = deleteFileBackend;
39
39
  exports.readFromBackend = readFromBackend;
40
+ exports.describeForeignCredential = describeForeignCredential;
40
41
  exports.writeToBackend = writeToBackend;
41
42
  exports.clearAllBackends = clearAllBackends;
42
43
  exports.normalizeServerOrigin = normalizeServerOrigin;
@@ -71,6 +72,21 @@ exports.__authFilePathForTest = __authFilePathForTest;
71
72
  * interactive) and delegates the keychain/file slots to `readFromBackend()`
72
73
  * here. We own token persistence; the resolver owns precedence.
73
74
  *
75
+ * Every bundle is also bound to the CONFIG that owns it (OSK-12135). The
76
+ * login Keychain is shared by every skrr setup of this macOS user, while the
77
+ * CLI identity (`cliId`) lives in the config root, so another setup with the
78
+ * same profile name could write this profile's slot and the next refresh
79
+ * presented a token bound to its `cliId`. Now:
80
+ *
81
+ * - a bundle carries its owner `{configRoot, cliId}` (Keychain `cli-owner`
82
+ * slot, or `owner` in the file), and the server-bound identity is also read
83
+ * from the access token's `did` claim, which an older writer cannot omit;
84
+ * - a read REFUSES a bundle whose owner is another root or another cliId,
85
+ * with the owner named (`ownerStatus: 'foreign'`), and never deletes it;
86
+ * - storage is namespaced by config root (see `keychain.ts`; the file lives
87
+ * under the config root), and a slot another root may own is only ever a
88
+ * read-side migration source, adopted on POSITIVE evidence of ownership.
89
+ *
74
90
  * Cross-process coordination: every write path should run inside
75
91
  * `withAuthLock()` from @skrr-ai/auth-core so the daemon, Electron main,
76
92
  * and a sibling `skrr` can't race us on a rotation. Callers are responsible
@@ -84,17 +100,127 @@ const auth_core_1 = require("@skrr-ai/auth-core");
84
100
  const keychain = __importStar(require("./keychain"));
85
101
  const cred_envelope_1 = require("./cred-envelope");
86
102
  const config_1 = require("./config");
103
+ const cli_id_1 = require("./cli-id");
87
104
  /** Filename for the non-darwin file backend. Parallel to the daemon's
88
105
  * `auth.json`, deliberately distinct so the two binaries never clobber
89
106
  * each other's tokens (CLI holds scope=cli, daemon holds scope=daemon). */
90
107
  const AUTH_FILENAME = 'cli-auth.json';
91
- /** Config root shared with the daemon (so auth.lock coordinates). */
108
+ /**
109
+ * The config root — the same one `cli-config.json` (and so `cliId`) lives in.
110
+ *
111
+ * This was `$HOME/.skrr` regardless of `SKRR_CONFIG_DIR`, so two configs that
112
+ * shared a HOME shared one credential file while each held its own `cliId`.
113
+ * Unchanged for anyone who has not relocated the root.
114
+ */
92
115
  function authDir() {
93
- return path.join(os.homedir(), '.skrr');
116
+ return (0, config_1.configRoot)();
94
117
  }
95
118
  function authFilePath() {
96
119
  return path.join(authDir(), AUTH_FILENAME);
97
120
  }
121
+ /**
122
+ * Where the file lived before it followed the config root. Only differs from
123
+ * `authFilePath()` under a root override, and is then a migration SOURCE: read
124
+ * only when its bundle positively belongs to this config, never written.
125
+ */
126
+ function legacyAuthFilePath() {
127
+ return path.join(os.homedir(), '.skrr', AUTH_FILENAME);
128
+ }
129
+ // ---------------------------------------------------------------------
130
+ // Ownership
131
+ // ---------------------------------------------------------------------
132
+ /** Who this process is: its resolved config root and the cliId it presents. */
133
+ function currentOwner() {
134
+ let cliId;
135
+ try {
136
+ // The id this profile PRESENTS — its own when it was issued one (OSK-12156).
137
+ const value = (0, cli_id_1.resolveCliId)((0, config_1.loadConfig)());
138
+ if (value)
139
+ cliId = value;
140
+ }
141
+ catch {
142
+ // A corrupt config cannot tell us who we are; ownership then rests on the
143
+ // config root alone, which is still enough to refuse another root's bundle.
144
+ }
145
+ return { configRoot: path.resolve((0, config_1.configRoot)()), ...(cliId ? { cliId } : {}) };
146
+ }
147
+ /**
148
+ * The CLI identity the SERVER bound this credential to. CLI access tokens carry
149
+ * it as `did` (the refresh row's `daemonId`), so this answers for bundles written
150
+ * by any version — including an older binary that never stored an owner record.
151
+ *
152
+ * Unverified decode, which `jwtUtils` permits for routing and not for trust.
153
+ * This is routing: it picks which LOCAL slot this config may present from. The
154
+ * server still verifies the token and refuses a rotation against another cliId;
155
+ * reading the claim first only stops us presenting a credential that cannot be
156
+ * ours, and anyone able to forge the slot's contents can already write them.
157
+ */
158
+ function boundCliId(token) {
159
+ const payload = (0, auth_core_1.decodeJwtPayload)(token);
160
+ return payload && typeof payload.did === 'string' && payload.did.length > 0
161
+ ? payload.did
162
+ : undefined;
163
+ }
164
+ function parseOwner(raw) {
165
+ let value = raw;
166
+ if (typeof value === 'string') {
167
+ try {
168
+ value = JSON.parse(value);
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ }
174
+ if (!value || typeof value !== 'object')
175
+ return undefined;
176
+ const { configRoot: root, cliId } = value;
177
+ const owner = {};
178
+ if (typeof root === 'string' && root.length > 0)
179
+ owner.configRoot = root;
180
+ if (typeof cliId === 'string' && cliId.length > 0)
181
+ owner.cliId = cliId;
182
+ return owner.configRoot || owner.cliId ? owner : undefined;
183
+ }
184
+ function ownerFor(bundle) {
185
+ const me = currentOwner();
186
+ const cliId = boundCliId(bundle.token) ?? me.cliId;
187
+ return { configRoot: me.configRoot, ...(cliId ? { cliId } : {}) };
188
+ }
189
+ /**
190
+ * Does this bundle belong to this config?
191
+ *
192
+ * `ownSlot` is whether the bundle came from the slot this root owns. There,
193
+ * the bundle is ours unless there is evidence it is not. From a slot another
194
+ * root may own (a migration source), it is ours only on POSITIVE evidence —
195
+ * a matching cliId or root — or, per the legacy rule, when it names nobody and
196
+ * this is the account's default root.
197
+ *
198
+ * The token's `did` outranks the stored owner record: an older binary writes
199
+ * the token but not `cli-owner`, so a record that disagrees with the token it
200
+ * sits beside is stale and says nothing about the token.
201
+ */
202
+ function judgeOwnership(bundle, me, ownSlot) {
203
+ const did = boundCliId(bundle.token);
204
+ const record = bundle.owner;
205
+ const recordValid = record && (!did || !record.cliId || record.cliId === did);
206
+ const storedRoot = recordValid && record.configRoot ? path.resolve(record.configRoot) : undefined;
207
+ const storedCliId = did ?? (recordValid ? record.cliId : undefined);
208
+ const stored = {
209
+ ...(storedRoot ? { configRoot: storedRoot } : {}),
210
+ ...(storedCliId ? { cliId: storedCliId } : {}),
211
+ };
212
+ if (storedRoot && storedRoot !== me.configRoot)
213
+ return { ours: false, stored };
214
+ if (storedCliId && me.cliId && storedCliId !== me.cliId)
215
+ return { ours: false, stored };
216
+ if (ownSlot)
217
+ return { ours: true, stored };
218
+ const positive = (storedCliId !== undefined && storedCliId === me.cliId) || storedRoot === me.configRoot;
219
+ if (positive)
220
+ return { ours: true, stored };
221
+ const namesNobody = !storedCliId && !storedRoot;
222
+ return { ours: namesNobody && keychain.isDefaultConfigRoot(me.configRoot), stored };
223
+ }
98
224
  // ---------------------------------------------------------------------
99
225
  // File backend
100
226
  // ---------------------------------------------------------------------
@@ -106,7 +232,40 @@ function authFilePath() {
106
232
  * retiring it. Everything else should keep using `readFromBackend()`.
107
233
  */
108
234
  function readFromFile() {
109
- const p = authFilePath();
235
+ return readFileAssessed().bundle;
236
+ }
237
+ /**
238
+ * This root's file, refused when it belongs to another config; else, under a
239
+ * root override, the pre-override `$HOME/.skrr` file when it is positively ours.
240
+ */
241
+ function readFileAssessed() {
242
+ const me = currentOwner();
243
+ const own = readFileAt(authFilePath(), true);
244
+ if (own) {
245
+ const judged = judgeOwnership(own, me, true);
246
+ return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
247
+ }
248
+ const legacy = legacyAuthFilePath();
249
+ if (path.resolve(legacy) === path.resolve(authFilePath()))
250
+ return { bundle: null };
251
+ const adopted = readFileAt(legacy, false);
252
+ if (adopted && judgeOwnership(adopted, me, false).ours)
253
+ return { bundle: adopted };
254
+ return { bundle: null };
255
+ }
256
+ /** Is the pre-override file present AND positively this config's? */
257
+ function legacyFileIsOurs() {
258
+ const legacy = legacyAuthFilePath();
259
+ if (path.resolve(legacy) === path.resolve(authFilePath()))
260
+ return false;
261
+ const bundle = readFileAt(legacy, false);
262
+ return Boolean(bundle && judgeOwnership(bundle, currentOwner(), false).ours);
263
+ }
264
+ /**
265
+ * Parse one credential file. `upgradeInPlace` lets the envelope migration
266
+ * rewrite it — true only for this root's own file, never a migration source.
267
+ */
268
+ function readFileAt(p, upgradeInPlace) {
110
269
  if (!fs.existsSync(p))
111
270
  return null;
112
271
  let raw;
@@ -157,6 +316,9 @@ function readFromFile() {
157
316
  if (typeof parsed.serverOrigin === 'string' && parsed.serverOrigin.length > 0) {
158
317
  bundle.serverOrigin = parsed.serverOrigin;
159
318
  }
319
+ const owner = parseOwner(parsed.owner);
320
+ if (owner)
321
+ bundle.owner = owner;
160
322
  // Best-effort upgrade: re-write the bundle so legacy plaintext fields
161
323
  // become wrapped on the next read. A failure here doesn't break the
162
324
  // read path — the caller already has the plaintext bundle.
@@ -186,7 +348,7 @@ function readFromFile() {
186
348
  // in-process "lock held" guard (AsyncLocalStorage) — but atomic
187
349
  // rename already prevents corruption, so the escalation target is
188
350
  // IO efficiency only.
189
- if (needsMigration) {
351
+ if (needsMigration && upgradeInPlace) {
190
352
  try {
191
353
  writeToFile(bundle);
192
354
  }
@@ -218,6 +380,9 @@ function writeToFile(bundle) {
218
380
  if (bundle.serverOrigin) {
219
381
  wireBundle.serverOrigin = bundle.serverOrigin;
220
382
  }
383
+ // Rewrites of an existing bundle (the envelope upgrade) keep its recorded
384
+ // owner; everything else is stamped with this config.
385
+ wireBundle.owner = bundle.owner ?? ownerFor(bundle);
221
386
  // tmp + rename so no racing reader ever sees a partial file; chmod 0600
222
387
  // on the final file so the same-UID blast-radius is as narrow as the
223
388
  // file backend can offer.
@@ -251,6 +416,17 @@ function writeToFile(bundle) {
251
416
  */
252
417
  function deleteFileBackend() {
253
418
  const p = authFilePath();
419
+ // This root's own file — unless it holds another config's credential, which
420
+ // is not ours to destroy (an older binary of another config can write here).
421
+ const own = readFileAt(p, false);
422
+ if (!own || judgeOwnership(own, currentOwner(), true).ours)
423
+ unlinkQuietly(p);
424
+ // The pre-override migration source, only when it is positively ours:
425
+ // otherwise a sign-out would leave it to be adopted again on the next read.
426
+ if (legacyFileIsOurs())
427
+ unlinkQuietly(legacyAuthFilePath());
428
+ }
429
+ function unlinkQuietly(p) {
254
430
  try {
255
431
  if (fs.existsSync(p))
256
432
  fs.unlinkSync(p);
@@ -285,11 +461,95 @@ function readFromKeychain() {
285
461
  const serverOrigin = keychain.readServerOrigin();
286
462
  if (serverOrigin)
287
463
  bundle.serverOrigin = serverOrigin;
464
+ const owner = parseOwner(keychain.readOwner());
465
+ if (owner)
466
+ bundle.owner = owner;
467
+ return bundle;
468
+ }
469
+ function bundleFromSlots(slots) {
470
+ const bundle = { token: slots.token.token };
471
+ if (slots.token.expiresAt !== undefined)
472
+ bundle.expiresAt = slots.token.expiresAt;
473
+ if (slots.refresh) {
474
+ bundle.refreshToken = slots.refresh.token;
475
+ if (slots.refresh.expiresAt !== undefined)
476
+ bundle.refreshExpiresAt = slots.refresh.expiresAt;
477
+ }
478
+ if (slots.serverOrigin)
479
+ bundle.serverOrigin = slots.serverOrigin;
480
+ const owner = parseOwner(slots.owner);
481
+ if (owner)
482
+ bundle.owner = owner;
288
483
  return bundle;
289
484
  }
485
+ /**
486
+ * Slots this root may adopt from, in order: the legacy profile-only slot, then
487
+ * the slot the default root displaced this config's credential into.
488
+ */
489
+ function migrationSlots(me) {
490
+ if (keychain.isLegacySlotCurrent())
491
+ return [];
492
+ return [
493
+ { kind: 'legacy' },
494
+ ...(me.cliId ? [{ kind: 'displaced', cliId: me.cliId }] : []),
495
+ ];
496
+ }
497
+ /**
498
+ * This root's own slot, refused when it belongs to another config; else a
499
+ * migration slot that positively belongs to this config.
500
+ *
501
+ * The migration is READ-ONLY, deliberately. Copying the bundle into our slot
502
+ * here would be a write outside the auth lock, and a copy that lands after a
503
+ * concurrent rotation puts a spent refresh token back — which the server treats
504
+ * as theft and answers by revoking the family. The next locked write (a refresh
505
+ * or a login) lands in our own slot, and from then on it wins.
506
+ */
507
+ function readKeychainAssessed() {
508
+ if (!keychain.isAvailable())
509
+ return { bundle: null };
510
+ const me = currentOwner();
511
+ const own = readFromKeychain();
512
+ if (own) {
513
+ const judged = judgeOwnership(own, me, true);
514
+ return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
515
+ }
516
+ for (const slot of migrationSlots(me)) {
517
+ const slots = keychain.readBundleAt(slot);
518
+ if (!slots)
519
+ continue;
520
+ const bundle = bundleFromSlots(slots);
521
+ if (judgeOwnership(bundle, me, false).ours)
522
+ return { bundle };
523
+ }
524
+ return { bundle: null };
525
+ }
526
+ /**
527
+ * Before the default root overwrites its own slot, move a credential another
528
+ * config's older binary wrote there to that config's displaced slot, keyed by
529
+ * the cliId the server bound it to. A login here must not destroy it: once
530
+ * that config runs a current binary it adopts it from there.
531
+ */
532
+ function rescueForeignCredential(me) {
533
+ if (!keychain.isLegacySlotCurrent())
534
+ return;
535
+ const token = keychain.readToken();
536
+ if (!token)
537
+ return;
538
+ const quick = boundCliId(token.token);
539
+ if (quick && quick === me.cliId)
540
+ return; // the common case: it is ours
541
+ const slots = keychain.readBundleAt({ kind: 'legacy' });
542
+ if (!slots)
543
+ return;
544
+ const judged = judgeOwnership(bundleFromSlots(slots), me, true);
545
+ if (judged.ours || !judged.stored.cliId)
546
+ return;
547
+ keychain.copyBundle({ kind: 'legacy' }, { kind: 'displaced', cliId: judged.stored.cliId });
548
+ }
290
549
  function writeToKeychain(bundle) {
291
550
  if (!keychain.isAvailable())
292
551
  return false;
552
+ rescueForeignCredential(currentOwner());
293
553
  const ok = keychain.writeToken(bundle.token, bundle.expiresAt);
294
554
  if (!ok)
295
555
  return false;
@@ -311,11 +571,34 @@ function writeToKeychain(bundle) {
311
571
  // token pair. Origin-aware readers will fail closed until the next login.
312
572
  keychain.deleteServerOrigin();
313
573
  }
574
+ // Written last, so the token it describes is already in place.
575
+ if (!keychain.writeOwner(JSON.stringify(bundle.owner ?? ownerFor(bundle))))
576
+ return false;
314
577
  return true;
315
578
  }
579
+ /** This root's own slot, after a failed partial write. */
316
580
  function deleteKeychainBackend() {
317
581
  keychain.deleteAll();
318
582
  }
583
+ /**
584
+ * Sign-out for the Keychain: this root's own slot unless it holds another
585
+ * config's credential, plus any migration slot that is positively ours —
586
+ * otherwise a sign-out would leave a bundle the next read adopts again.
587
+ */
588
+ function clearKeychainBackend() {
589
+ if (!keychain.isAvailable())
590
+ return;
591
+ const me = currentOwner();
592
+ const own = readFromKeychain();
593
+ if (!own || judgeOwnership(own, me, true).ours)
594
+ keychain.deleteAll();
595
+ for (const slot of migrationSlots(me)) {
596
+ const slots = keychain.readBundleAt(slot);
597
+ if (slots && judgeOwnership(bundleFromSlots(slots), me, false).ours) {
598
+ keychain.deleteAllAt(slot);
599
+ }
600
+ }
601
+ }
319
602
  // ---------------------------------------------------------------------
320
603
  // Public API — backend-selection logic lives here
321
604
  // ---------------------------------------------------------------------
@@ -331,14 +614,21 @@ function deleteKeychainBackend() {
331
614
  * function returns whatever is persisted.
332
615
  */
333
616
  function readFromBackend(expectedServerOrigin) {
334
- const fromKeychain = readFromKeychain();
335
- const keychainResult = assessBundleOrigin(fromKeychain, 'keychain', expectedServerOrigin);
617
+ const fromKeychain = readKeychainAssessed();
618
+ const keychainResult = assessBundleOrigin(fromKeychain.bundle, 'keychain', expectedServerOrigin);
336
619
  if (keychainResult.bundle)
337
620
  return keychainResult;
338
- const fromFile = readFromFile();
339
- const fileResult = assessBundleOrigin(fromFile, 'file', expectedServerOrigin);
621
+ const fromFile = readFileAssessed();
622
+ const fileResult = assessBundleOrigin(fromFile.bundle, 'file', expectedServerOrigin);
340
623
  if (fileResult.bundle)
341
624
  return fileResult;
625
+ // A bundle that belongs to another skrr config outranks every other
626
+ // diagnostic: it is why this config has no usable credential, and it is the
627
+ // one case where "run login" would otherwise look like it came from nowhere.
628
+ if (fromKeychain.foreign)
629
+ return foreignResult('keychain', fromKeychain.foreign);
630
+ if (fromFile.foreign)
631
+ return foreignResult('file', fromFile.foreign);
342
632
  // Prefer the first concrete rejection for diagnostics. A partially-failed
343
633
  // Keychain write may leave an unbound token while a valid file fallback is
344
634
  // absent; surfacing the status lets BaseCommand explain why it ignored it.
@@ -359,6 +649,43 @@ function readFromBackend(expectedServerOrigin) {
359
649
  : {}),
360
650
  };
361
651
  }
652
+ function foreignResult(backend, storedOwner) {
653
+ const me = currentOwner();
654
+ return {
655
+ bundle: null,
656
+ backend,
657
+ originStatus: 'none',
658
+ ownerStatus: 'foreign',
659
+ storedOwner,
660
+ expectedOwner: me,
661
+ };
662
+ }
663
+ /**
664
+ * One sentence naming whose credential was refused and why, for the surfaces
665
+ * that explain an absent sign-in (BaseCommand, `skrr doctor`).
666
+ */
667
+ function describeForeignCredential(result) {
668
+ if (result.ownerStatus !== 'foreign')
669
+ return null;
670
+ const stored = result.storedOwner ?? {};
671
+ const mine = result.expectedOwner ?? {};
672
+ const where = result.backend === 'file' ? 'credential file' : 'Keychain slot';
673
+ const theirs = [
674
+ stored.cliId ? `CLI identity ${stored.cliId}` : null,
675
+ stored.configRoot ? `config root ${stored.configRoot}` : null,
676
+ ]
677
+ .filter(Boolean)
678
+ .join(', ');
679
+ const ours = [
680
+ mine.cliId ? `CLI identity ${mine.cliId}` : null,
681
+ mine.configRoot ? `config root ${mine.configRoot}` : null,
682
+ ]
683
+ .filter(Boolean)
684
+ .join(', ');
685
+ return (`The credential in this profile's ${where} belongs to another skrr configuration on this ` +
686
+ `machine (${theirs || 'unknown owner'}), not this one (${ours || 'unknown'}). It was not ` +
687
+ `used and has not been deleted.`);
688
+ }
362
689
  /**
363
690
  * Persist a token bundle to the preferred backend.
364
691
  *
@@ -412,6 +739,9 @@ function writeToBackend(bundle, options = {}) {
412
739
  }
413
740
  persistedBundle = { ...bundle, serverOrigin: normalized };
414
741
  }
742
+ // Always stamp THIS config as the owner — a bundle read back and re-written
743
+ // (e.g. `pair`) must not carry a record forward from wherever it came from.
744
+ persistedBundle = { ...persistedBundle, owner: ownerFor(persistedBundle) };
415
745
  let backend;
416
746
  if (keychain.isAvailable()) {
417
747
  if (writeToKeychain(persistedBundle)) {
@@ -444,7 +774,7 @@ function writeToBackend(bundle, options = {}) {
444
774
  * auth-failure cleanup). Idempotent.
445
775
  */
446
776
  function clearAllBackends() {
447
- deleteKeychainBackend();
777
+ clearKeychainBackend();
448
778
  deleteFileBackend();
449
779
  }
450
780
  /**
@@ -468,11 +798,12 @@ function clearAllBackends() {
468
798
  */
469
799
  exports.backends = {
470
800
  readKeychainToken(expectedServerOrigin) {
471
- const result = assessBundleOrigin(readFromKeychain(), 'keychain', expectedServerOrigin);
801
+ const read = readKeychainAssessed();
802
+ const result = assessBundleOrigin(read.bundle, 'keychain', expectedServerOrigin);
472
803
  return result.bundle?.token ?? null;
473
804
  },
474
805
  readFileToken(expectedServerOrigin) {
475
- const result = assessBundleOrigin(readFromFile(), 'file', expectedServerOrigin);
806
+ const result = assessBundleOrigin(readFileAssessed().bundle, 'file', expectedServerOrigin);
476
807
  return result.bundle?.token ?? null;
477
808
  },
478
809
  };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The operator's `OVERSKY_CLI_ID` / `SKRR_CLI_ID`, when set. The legacy spelling
3
+ * wins when both are set, as it does for every other overlay variable
4
+ * (`config.ts`, `envOverlayValue`); the config overlay reads THIS function.
5
+ */
6
+ export declare function envCliIdOverride(env?: NodeJS.ProcessEnv): string | undefined;
7
+ /** True when this process runs a named profile rather than the default one. */
8
+ export declare function isNamedProfile(): boolean;
9
+ /**
10
+ * Precedence:
11
+ *
12
+ * 1. `OVERSKY_CLI_ID` / `SKRR_CLI_ID` — the operator said so;
13
+ * 2. `profileCliId` — this named profile was issued its own identity;
14
+ * 3. `cliId` — the machine identity, which the default profile and every
15
+ * profile whose session predates profile identities keep presenting.
16
+ *
17
+ * Never mints: a read must not change who this config is.
18
+ */
19
+ export declare function resolveCliId(cfg: {
20
+ cliId?: string;
21
+ profileCliId?: string;
22
+ }): string | undefined;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.envCliIdOverride = envCliIdOverride;
4
+ exports.isNamedProfile = isNamedProfile;
5
+ exports.resolveCliId = resolveCliId;
6
+ /**
7
+ * cli-id.ts — THE cliId this process presents (OSK-12156).
8
+ *
9
+ * The one resolver for the identity presented to the server at login, at
10
+ * refresh, to the daemon broker, and as the owner a stored credential is judged
11
+ * against (`auth-storage.ts`). It used to be read as `cfg.cliId` at a dozen call
12
+ * sites and regenerated in three, which is what made it impossible to give one
13
+ * profile its own id without some reader still presenting the machine one.
14
+ *
15
+ * Deliberately depends on nothing but the active profile: it is read by
16
+ * `config.ts` and `auth-storage.ts` alike, so it must not import either.
17
+ * Issuing an id is a separate act, owned by `cli-identity.ts`.
18
+ */
19
+ const keychain_1 = require("./keychain");
20
+ /**
21
+ * The operator's `OVERSKY_CLI_ID` / `SKRR_CLI_ID`, when set. The legacy spelling
22
+ * wins when both are set, as it does for every other overlay variable
23
+ * (`config.ts`, `envOverlayValue`); the config overlay reads THIS function.
24
+ */
25
+ function envCliIdOverride(env = process.env) {
26
+ const legacy = env.OVERSKY_CLI_ID?.trim();
27
+ if (legacy)
28
+ return legacy;
29
+ const renamed = env.SKRR_CLI_ID?.trim();
30
+ return renamed ? renamed : undefined;
31
+ }
32
+ /** True when this process runs a named profile rather than the default one. */
33
+ function isNamedProfile() {
34
+ const profile = (0, keychain_1.getActiveProfile)();
35
+ return Boolean(profile) && profile !== keychain_1.DEFAULT_PROFILE;
36
+ }
37
+ /**
38
+ * Precedence:
39
+ *
40
+ * 1. `OVERSKY_CLI_ID` / `SKRR_CLI_ID` — the operator said so;
41
+ * 2. `profileCliId` — this named profile was issued its own identity;
42
+ * 3. `cliId` — the machine identity, which the default profile and every
43
+ * profile whose session predates profile identities keep presenting.
44
+ *
45
+ * Never mints: a read must not change who this config is.
46
+ */
47
+ function resolveCliId(cfg) {
48
+ const env = envCliIdOverride();
49
+ if (env)
50
+ return env;
51
+ const profileScoped = cfg.profileCliId?.trim();
52
+ if (profileScoped)
53
+ return profileScoped;
54
+ const machine = cfg.cliId?.trim();
55
+ return machine ? machine : undefined;
56
+ }
@@ -0,0 +1,49 @@
1
+ import { type CliConfig } from './config';
2
+ /**
3
+ * A named profile's own identity. Deterministic in (host, user, config root,
4
+ * profile), so a profile that loses its config file and signs in again is
5
+ * issued the same id rather than scattering families, and two profiles — or the
6
+ * same profile name under two config roots — never share one. Same `cli_<12hex>`
7
+ * shape as the machine id: the server requires the `cli_` namespace.
8
+ */
9
+ export declare function profileScopedCliId(profile?: string, root?: string): string;
10
+ /**
11
+ * Does this profile hold a credential it would present? Its own backend slot
12
+ * (ownership-checked, any origin) or a pre-migration token still in the config.
13
+ *
14
+ * A read that FAILS answers true: "keep the id you already present" is the
15
+ * outcome that cannot log anyone out, so doubt falls on that side.
16
+ */
17
+ export declare function hasStoredCredential(cfg: CliConfig): boolean;
18
+ export interface LoginCliIdPlan {
19
+ /** The id this login presents. */
20
+ cliId: string;
21
+ /**
22
+ * `env`: the operator's override. `profile`: an id this profile was issued
23
+ * earlier. `issued`: issued now, to a named profile holding no credential.
24
+ * `machine`: the machine id (the default profile, or a profile whose session
25
+ * was minted under it).
26
+ */
27
+ source: 'env' | 'profile' | 'issued' | 'machine';
28
+ /** Present when the plan chose an id that is not yet on disk; persist it. */
29
+ updatedConfig?: CliConfig;
30
+ }
31
+ /**
32
+ * Decide the id a login is about to present. Pure apart from the credential
33
+ * read; the caller persists `updatedConfig` (see `cliIdForLogin`).
34
+ */
35
+ export declare function planLoginCliId(cfg: CliConfig, deps?: {
36
+ hasStoredCredential?: (cfg: CliConfig) => boolean;
37
+ }): LoginCliIdPlan;
38
+ /**
39
+ * The id an interactive login presents, persisted BEFORE the exchange so a
40
+ * retry — or a sibling process — presents the same one.
41
+ */
42
+ export declare function cliIdForLogin(cfg?: CliConfig): string;
43
+ /**
44
+ * Record that this profile now presents `cliId` — a restored (refresh token,
45
+ * cliId) pair. A named profile records it as its own identity; writing the
46
+ * machine field from a named profile would re-identify the default profile and
47
+ * every profile still on the machine id, whose next refresh would be refused.
48
+ */
49
+ export declare function persistPresentedCliId(cfg: CliConfig, cliId: string): void;