@skrr-ai/cli 0.1.51 → 0.1.53

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 (67) hide show
  1. package/dist/base-command.d.ts +7 -0
  2. package/dist/base-command.js +38 -8
  3. package/dist/commands/agents/chat.js +2 -1
  4. package/dist/commands/agents/create.js +21 -8
  5. package/dist/commands/agents/update.d.ts +55 -0
  6. package/dist/commands/agents/update.js +119 -23
  7. package/dist/commands/commitments/next-tick.js +9 -1
  8. package/dist/commands/context.js +2 -1
  9. package/dist/commands/daemon/config.js +16 -1
  10. package/dist/commands/doctor.js +8 -1
  11. package/dist/commands/login.d.ts +2 -1
  12. package/dist/commands/login.js +26 -7
  13. package/dist/commands/logout.js +3 -0
  14. package/dist/commands/machines/dedicated/show.js +25 -2
  15. package/dist/commands/tasks/approvals/list.d.ts +22 -0
  16. package/dist/commands/tasks/approvals/list.js +80 -3
  17. package/dist/commands/tasks/create.d.ts +5 -0
  18. package/dist/commands/tasks/create.js +28 -7
  19. package/dist/commands/tasks/start.d.ts +14 -0
  20. package/dist/commands/tasks/start.js +31 -1
  21. package/dist/commands/tasks/update.js +30 -3
  22. package/dist/commands/whoami.js +5 -3
  23. package/dist/lib/agent-config.d.ts +15 -0
  24. package/dist/lib/agent-config.js +46 -10
  25. package/dist/lib/agentic-stream.d.ts +21 -1
  26. package/dist/lib/agentic-stream.js +40 -6
  27. package/dist/lib/auth-failure.js +4 -1
  28. package/dist/lib/auth-storage.d.ts +24 -0
  29. package/dist/lib/auth-storage.js +348 -13
  30. package/dist/lib/cli-id.d.ts +22 -0
  31. package/dist/lib/cli-id.js +56 -0
  32. package/dist/lib/cli-identity.d.ts +49 -0
  33. package/dist/lib/cli-identity.js +159 -0
  34. package/dist/lib/commitments.d.ts +4 -0
  35. package/dist/lib/commitments.js +12 -1
  36. package/dist/lib/config.d.ts +17 -0
  37. package/dist/lib/config.js +15 -3
  38. package/dist/lib/cred-envelope.d.ts +12 -1
  39. package/dist/lib/cred-envelope.js +20 -3
  40. package/dist/lib/daemonBroker.d.ts +26 -4
  41. package/dist/lib/daemonBroker.js +82 -10
  42. package/dist/lib/daemonBrokerRefusal.d.ts +51 -0
  43. package/dist/lib/daemonBrokerRefusal.js +127 -0
  44. package/dist/lib/dedicated-machines.d.ts +53 -4
  45. package/dist/lib/dedicated-machines.js +95 -5
  46. package/dist/lib/delegated-cli.js +16 -0
  47. package/dist/lib/first-party-harness-broker.js +6 -4
  48. package/dist/lib/keychain.d.ts +39 -0
  49. package/dist/lib/keychain.js +254 -40
  50. package/dist/lib/label-ref.d.ts +34 -0
  51. package/dist/lib/label-ref.js +72 -1
  52. package/dist/lib/login.js +24 -12
  53. package/dist/lib/oauthLogin.js +2 -8
  54. package/dist/lib/refresh.js +6 -2
  55. package/dist/lib/tasks.js +33 -8
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +13 -1
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +9 -3
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +4 -3
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +13 -1
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +8 -3
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
  64. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  65. package/dist/node_modules/@skrr-ai/data-provider/index.js +3138 -3100
  66. package/oclif.manifest.json +26470 -26449
  67. 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;
@@ -129,7 +288,12 @@ function readFromFile() {
129
288
  // L12 — route the secret string fields through the envelope. Mixed
130
289
  // bundles (e.g., legacy plaintext token + freshly-rotated wrapped
131
290
  // refresh) decrypt independently so partial migrations stay safe.
132
- const tokenResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token);
291
+ // Read the (plaintext) owner first, so a value another config root sealed
292
+ // under its own key fails with a sentence that says so (OSK-12135).
293
+ const owner = parseOwner(parsed.owner);
294
+ const ownerRoot = owner?.configRoot ? path.resolve(owner.configRoot) : undefined;
295
+ const decryptOptions = ownerRoot && ownerRoot !== path.resolve((0, config_1.configRoot)()) ? { foreignOwnerRoot: ownerRoot } : {};
296
+ const tokenResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions);
133
297
  if (tokenResult.plaintext === null) {
134
298
  // Envelope on disk but no DEK to unwrap → treat as missing so the
135
299
  // caller falls through to a clean `skrr login`.
@@ -141,7 +305,7 @@ function readFromFile() {
141
305
  bundle.expiresAt = parsed.expiresAt;
142
306
  }
143
307
  if (typeof parsed.refreshToken === 'string' && parsed.refreshToken.length > 0) {
144
- const refreshResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.refreshToken);
308
+ const refreshResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.refreshToken, decryptOptions);
145
309
  if (refreshResult.plaintext !== null) {
146
310
  bundle.refreshToken = refreshResult.plaintext;
147
311
  if (refreshResult.needsMigration)
@@ -157,6 +321,8 @@ function readFromFile() {
157
321
  if (typeof parsed.serverOrigin === 'string' && parsed.serverOrigin.length > 0) {
158
322
  bundle.serverOrigin = parsed.serverOrigin;
159
323
  }
324
+ if (owner)
325
+ bundle.owner = owner;
160
326
  // Best-effort upgrade: re-write the bundle so legacy plaintext fields
161
327
  // become wrapped on the next read. A failure here doesn't break the
162
328
  // read path — the caller already has the plaintext bundle.
@@ -186,7 +352,7 @@ function readFromFile() {
186
352
  // in-process "lock held" guard (AsyncLocalStorage) — but atomic
187
353
  // rename already prevents corruption, so the escalation target is
188
354
  // IO efficiency only.
189
- if (needsMigration) {
355
+ if (needsMigration && upgradeInPlace) {
190
356
  try {
191
357
  writeToFile(bundle);
192
358
  }
@@ -218,6 +384,9 @@ function writeToFile(bundle) {
218
384
  if (bundle.serverOrigin) {
219
385
  wireBundle.serverOrigin = bundle.serverOrigin;
220
386
  }
387
+ // Rewrites of an existing bundle (the envelope upgrade) keep its recorded
388
+ // owner; everything else is stamped with this config.
389
+ wireBundle.owner = bundle.owner ?? ownerFor(bundle);
221
390
  // tmp + rename so no racing reader ever sees a partial file; chmod 0600
222
391
  // on the final file so the same-UID blast-radius is as narrow as the
223
392
  // file backend can offer.
@@ -251,6 +420,17 @@ function writeToFile(bundle) {
251
420
  */
252
421
  function deleteFileBackend() {
253
422
  const p = authFilePath();
423
+ // This root's own file — unless it holds another config's credential, which
424
+ // is not ours to destroy (an older binary of another config can write here).
425
+ const own = readFileAt(p, false);
426
+ if (!own || judgeOwnership(own, currentOwner(), true).ours)
427
+ unlinkQuietly(p);
428
+ // The pre-override migration source, only when it is positively ours:
429
+ // otherwise a sign-out would leave it to be adopted again on the next read.
430
+ if (legacyFileIsOurs())
431
+ unlinkQuietly(legacyAuthFilePath());
432
+ }
433
+ function unlinkQuietly(p) {
254
434
  try {
255
435
  if (fs.existsSync(p))
256
436
  fs.unlinkSync(p);
@@ -285,11 +465,95 @@ function readFromKeychain() {
285
465
  const serverOrigin = keychain.readServerOrigin();
286
466
  if (serverOrigin)
287
467
  bundle.serverOrigin = serverOrigin;
468
+ const owner = parseOwner(keychain.readOwner());
469
+ if (owner)
470
+ bundle.owner = owner;
471
+ return bundle;
472
+ }
473
+ function bundleFromSlots(slots) {
474
+ const bundle = { token: slots.token.token };
475
+ if (slots.token.expiresAt !== undefined)
476
+ bundle.expiresAt = slots.token.expiresAt;
477
+ if (slots.refresh) {
478
+ bundle.refreshToken = slots.refresh.token;
479
+ if (slots.refresh.expiresAt !== undefined)
480
+ bundle.refreshExpiresAt = slots.refresh.expiresAt;
481
+ }
482
+ if (slots.serverOrigin)
483
+ bundle.serverOrigin = slots.serverOrigin;
484
+ const owner = parseOwner(slots.owner);
485
+ if (owner)
486
+ bundle.owner = owner;
288
487
  return bundle;
289
488
  }
489
+ /**
490
+ * Slots this root may adopt from, in order: the legacy profile-only slot, then
491
+ * the slot the default root displaced this config's credential into.
492
+ */
493
+ function migrationSlots(me) {
494
+ if (keychain.isLegacySlotCurrent())
495
+ return [];
496
+ return [
497
+ { kind: 'legacy' },
498
+ ...(me.cliId ? [{ kind: 'displaced', cliId: me.cliId }] : []),
499
+ ];
500
+ }
501
+ /**
502
+ * This root's own slot, refused when it belongs to another config; else a
503
+ * migration slot that positively belongs to this config.
504
+ *
505
+ * The migration is READ-ONLY, deliberately. Copying the bundle into our slot
506
+ * here would be a write outside the auth lock, and a copy that lands after a
507
+ * concurrent rotation puts a spent refresh token back — which the server treats
508
+ * as theft and answers by revoking the family. The next locked write (a refresh
509
+ * or a login) lands in our own slot, and from then on it wins.
510
+ */
511
+ function readKeychainAssessed() {
512
+ if (!keychain.isAvailable())
513
+ return { bundle: null };
514
+ const me = currentOwner();
515
+ const own = readFromKeychain();
516
+ if (own) {
517
+ const judged = judgeOwnership(own, me, true);
518
+ return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
519
+ }
520
+ for (const slot of migrationSlots(me)) {
521
+ const slots = keychain.readBundleAt(slot);
522
+ if (!slots)
523
+ continue;
524
+ const bundle = bundleFromSlots(slots);
525
+ if (judgeOwnership(bundle, me, false).ours)
526
+ return { bundle };
527
+ }
528
+ return { bundle: null };
529
+ }
530
+ /**
531
+ * Before the default root overwrites its own slot, move a credential another
532
+ * config's older binary wrote there to that config's displaced slot, keyed by
533
+ * the cliId the server bound it to. A login here must not destroy it: once
534
+ * that config runs a current binary it adopts it from there.
535
+ */
536
+ function rescueForeignCredential(me) {
537
+ if (!keychain.isLegacySlotCurrent())
538
+ return;
539
+ const token = keychain.readToken();
540
+ if (!token)
541
+ return;
542
+ const quick = boundCliId(token.token);
543
+ if (quick && quick === me.cliId)
544
+ return; // the common case: it is ours
545
+ const slots = keychain.readBundleAt({ kind: 'legacy' });
546
+ if (!slots)
547
+ return;
548
+ const judged = judgeOwnership(bundleFromSlots(slots), me, true);
549
+ if (judged.ours || !judged.stored.cliId)
550
+ return;
551
+ keychain.copyBundle({ kind: 'legacy' }, { kind: 'displaced', cliId: judged.stored.cliId });
552
+ }
290
553
  function writeToKeychain(bundle) {
291
554
  if (!keychain.isAvailable())
292
555
  return false;
556
+ rescueForeignCredential(currentOwner());
293
557
  const ok = keychain.writeToken(bundle.token, bundle.expiresAt);
294
558
  if (!ok)
295
559
  return false;
@@ -311,11 +575,34 @@ function writeToKeychain(bundle) {
311
575
  // token pair. Origin-aware readers will fail closed until the next login.
312
576
  keychain.deleteServerOrigin();
313
577
  }
578
+ // Written last, so the token it describes is already in place.
579
+ if (!keychain.writeOwner(JSON.stringify(bundle.owner ?? ownerFor(bundle))))
580
+ return false;
314
581
  return true;
315
582
  }
583
+ /** This root's own slot, after a failed partial write. */
316
584
  function deleteKeychainBackend() {
317
585
  keychain.deleteAll();
318
586
  }
587
+ /**
588
+ * Sign-out for the Keychain: this root's own slot unless it holds another
589
+ * config's credential, plus any migration slot that is positively ours —
590
+ * otherwise a sign-out would leave a bundle the next read adopts again.
591
+ */
592
+ function clearKeychainBackend() {
593
+ if (!keychain.isAvailable())
594
+ return;
595
+ const me = currentOwner();
596
+ const own = readFromKeychain();
597
+ if (!own || judgeOwnership(own, me, true).ours)
598
+ keychain.deleteAll();
599
+ for (const slot of migrationSlots(me)) {
600
+ const slots = keychain.readBundleAt(slot);
601
+ if (slots && judgeOwnership(bundleFromSlots(slots), me, false).ours) {
602
+ keychain.deleteAllAt(slot);
603
+ }
604
+ }
605
+ }
319
606
  // ---------------------------------------------------------------------
320
607
  // Public API — backend-selection logic lives here
321
608
  // ---------------------------------------------------------------------
@@ -331,14 +618,21 @@ function deleteKeychainBackend() {
331
618
  * function returns whatever is persisted.
332
619
  */
333
620
  function readFromBackend(expectedServerOrigin) {
334
- const fromKeychain = readFromKeychain();
335
- const keychainResult = assessBundleOrigin(fromKeychain, 'keychain', expectedServerOrigin);
621
+ const fromKeychain = readKeychainAssessed();
622
+ const keychainResult = assessBundleOrigin(fromKeychain.bundle, 'keychain', expectedServerOrigin);
336
623
  if (keychainResult.bundle)
337
624
  return keychainResult;
338
- const fromFile = readFromFile();
339
- const fileResult = assessBundleOrigin(fromFile, 'file', expectedServerOrigin);
625
+ const fromFile = readFileAssessed();
626
+ const fileResult = assessBundleOrigin(fromFile.bundle, 'file', expectedServerOrigin);
340
627
  if (fileResult.bundle)
341
628
  return fileResult;
629
+ // A bundle that belongs to another skrr config outranks every other
630
+ // diagnostic: it is why this config has no usable credential, and it is the
631
+ // one case where "run login" would otherwise look like it came from nowhere.
632
+ if (fromKeychain.foreign)
633
+ return foreignResult('keychain', fromKeychain.foreign);
634
+ if (fromFile.foreign)
635
+ return foreignResult('file', fromFile.foreign);
342
636
  // Prefer the first concrete rejection for diagnostics. A partially-failed
343
637
  // Keychain write may leave an unbound token while a valid file fallback is
344
638
  // absent; surfacing the status lets BaseCommand explain why it ignored it.
@@ -359,6 +653,43 @@ function readFromBackend(expectedServerOrigin) {
359
653
  : {}),
360
654
  };
361
655
  }
656
+ function foreignResult(backend, storedOwner) {
657
+ const me = currentOwner();
658
+ return {
659
+ bundle: null,
660
+ backend,
661
+ originStatus: 'none',
662
+ ownerStatus: 'foreign',
663
+ storedOwner,
664
+ expectedOwner: me,
665
+ };
666
+ }
667
+ /**
668
+ * One sentence naming whose credential was refused and why, for the surfaces
669
+ * that explain an absent sign-in (BaseCommand, `skrr doctor`).
670
+ */
671
+ function describeForeignCredential(result) {
672
+ if (result.ownerStatus !== 'foreign')
673
+ return null;
674
+ const stored = result.storedOwner ?? {};
675
+ const mine = result.expectedOwner ?? {};
676
+ const where = result.backend === 'file' ? 'credential file' : 'Keychain slot';
677
+ const theirs = [
678
+ stored.cliId ? `CLI identity ${stored.cliId}` : null,
679
+ stored.configRoot ? `config root ${stored.configRoot}` : null,
680
+ ]
681
+ .filter(Boolean)
682
+ .join(', ');
683
+ const ours = [
684
+ mine.cliId ? `CLI identity ${mine.cliId}` : null,
685
+ mine.configRoot ? `config root ${mine.configRoot}` : null,
686
+ ]
687
+ .filter(Boolean)
688
+ .join(', ');
689
+ return (`The credential in this profile's ${where} belongs to another skrr configuration on this ` +
690
+ `machine (${theirs || 'unknown owner'}), not this one (${ours || 'unknown'}). It was not ` +
691
+ `used and has not been deleted.`);
692
+ }
362
693
  /**
363
694
  * Persist a token bundle to the preferred backend.
364
695
  *
@@ -412,6 +743,9 @@ function writeToBackend(bundle, options = {}) {
412
743
  }
413
744
  persistedBundle = { ...bundle, serverOrigin: normalized };
414
745
  }
746
+ // Always stamp THIS config as the owner — a bundle read back and re-written
747
+ // (e.g. `pair`) must not carry a record forward from wherever it came from.
748
+ persistedBundle = { ...persistedBundle, owner: ownerFor(persistedBundle) };
415
749
  let backend;
416
750
  if (keychain.isAvailable()) {
417
751
  if (writeToKeychain(persistedBundle)) {
@@ -444,7 +778,7 @@ function writeToBackend(bundle, options = {}) {
444
778
  * auth-failure cleanup). Idempotent.
445
779
  */
446
780
  function clearAllBackends() {
447
- deleteKeychainBackend();
781
+ clearKeychainBackend();
448
782
  deleteFileBackend();
449
783
  }
450
784
  /**
@@ -468,11 +802,12 @@ function clearAllBackends() {
468
802
  */
469
803
  exports.backends = {
470
804
  readKeychainToken(expectedServerOrigin) {
471
- const result = assessBundleOrigin(readFromKeychain(), 'keychain', expectedServerOrigin);
805
+ const read = readKeychainAssessed();
806
+ const result = assessBundleOrigin(read.bundle, 'keychain', expectedServerOrigin);
472
807
  return result.bundle?.token ?? null;
473
808
  },
474
809
  readFileToken(expectedServerOrigin) {
475
- const result = assessBundleOrigin(readFromFile(), 'file', expectedServerOrigin);
810
+ const result = assessBundleOrigin(readFileAssessed().bundle, 'file', expectedServerOrigin);
476
811
  return result.bundle?.token ?? null;
477
812
  },
478
813
  };
@@ -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;