@skrr-ai/cli 0.1.79 → 0.1.81

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 (114) hide show
  1. package/dist/base-command.d.ts +4 -0
  2. package/dist/base-command.js +40 -4
  3. package/dist/commands/agents/visibility.d.ts +14 -0
  4. package/dist/commands/agents/visibility.js +52 -0
  5. package/dist/commands/create-token.d.ts +0 -1
  6. package/dist/commands/create-token.js +3 -1
  7. package/dist/commands/inbox/index.js +4 -2
  8. package/dist/commands/list-tokens.d.ts +0 -1
  9. package/dist/commands/list-tokens.js +1 -1
  10. package/dist/commands/login.d.ts +8 -3
  11. package/dist/commands/login.js +14 -4
  12. package/dist/commands/machines/dedicated/ssh-proxy.js +1 -0
  13. package/dist/commands/revoke-token.d.ts +0 -1
  14. package/dist/commands/revoke-token.js +1 -1
  15. package/dist/commands/spaces/workflows/events.js +9 -2
  16. package/dist/commands/store/follow.d.ts +1 -0
  17. package/dist/commands/store/follow.js +7 -1
  18. package/dist/commands/store/follows/list.d.ts +11 -0
  19. package/dist/commands/store/follows/list.js +29 -0
  20. package/dist/commands/store/follows/remove.d.ts +16 -0
  21. package/dist/commands/store/follows/remove.js +37 -0
  22. package/dist/commands/store/publishers/profile/draft.d.ts +17 -0
  23. package/dist/commands/store/publishers/profile/draft.js +54 -0
  24. package/dist/commands/store/publishers/profile/handle.d.ts +14 -0
  25. package/dist/commands/store/publishers/profile/handle.js +39 -0
  26. package/dist/commands/store/publishers/profile/hide.d.ts +12 -0
  27. package/dist/commands/store/publishers/profile/hide.js +39 -0
  28. package/dist/commands/store/publishers/profile/preview.d.ts +11 -0
  29. package/dist/commands/store/publishers/profile/preview.js +39 -0
  30. package/dist/commands/store/publishers/profile/publish.d.ts +12 -0
  31. package/dist/commands/store/publishers/profile/publish.js +41 -0
  32. package/dist/commands/store/publishers/profile/restore.d.ts +15 -0
  33. package/dist/commands/store/publishers/profile/restore.js +49 -0
  34. package/dist/commands/store/publishers/profile/revisions.d.ts +11 -0
  35. package/dist/commands/store/publishers/profile/revisions.js +30 -0
  36. package/dist/commands/store/publishers/profile/show.d.ts +11 -0
  37. package/dist/commands/store/publishers/profile/show.js +39 -0
  38. package/dist/commands/store/review.d.ts +26 -0
  39. package/dist/commands/store/review.js +93 -0
  40. package/dist/commands/store/reviews.d.ts +29 -0
  41. package/dist/commands/store/reviews.js +170 -0
  42. package/dist/commands/store/tip.d.ts +20 -0
  43. package/dist/commands/store/tip.js +57 -0
  44. package/dist/commands/tokens/create.d.ts +28 -0
  45. package/dist/commands/tokens/create.js +136 -0
  46. package/dist/commands/tokens/list.d.ts +11 -0
  47. package/dist/commands/tokens/list.js +53 -0
  48. package/dist/commands/tokens/revoke.d.ts +14 -0
  49. package/dist/commands/tokens/revoke.js +42 -0
  50. package/dist/lib/access-tokens.d.ts +59 -0
  51. package/dist/lib/access-tokens.js +50 -0
  52. package/dist/lib/agentic-stream.js +13 -4
  53. package/dist/lib/api-fetch.js +6 -2
  54. package/dist/lib/auth-core-init.d.ts +6 -0
  55. package/dist/lib/auth-core-init.js +28 -6
  56. package/dist/lib/brokered-dpop.d.ts +34 -0
  57. package/dist/lib/brokered-dpop.js +89 -0
  58. package/dist/lib/credential-resolver.d.ts +1 -1
  59. package/dist/lib/credential-resolver.js +8 -2
  60. package/dist/lib/daemonBroker.d.ts +84 -4
  61. package/dist/lib/daemonBroker.js +209 -40
  62. package/dist/lib/daemonBrokerRefusal.d.ts +26 -0
  63. package/dist/lib/daemonBrokerRefusal.js +83 -4
  64. package/dist/lib/daemonRestartWait.d.ts +39 -0
  65. package/dist/lib/daemonRestartWait.js +181 -0
  66. package/dist/lib/dedicated-service.js +10 -5
  67. package/dist/lib/dedicated-ssh.d.ts +9 -0
  68. package/dist/lib/dedicated-ssh.js +235 -63
  69. package/dist/lib/dedicated-terminal.js +6 -3
  70. package/dist/lib/dpop-auth.d.ts +35 -0
  71. package/dist/lib/dpop-auth.js +57 -0
  72. package/dist/lib/login.d.ts +6 -0
  73. package/dist/lib/login.js +13 -4
  74. package/dist/lib/node-adapter.js +6 -2
  75. package/dist/lib/publisher-profile-flags.d.ts +19 -0
  76. package/dist/lib/publisher-profile-flags.js +48 -0
  77. package/dist/lib/refresh.d.ts +1 -1
  78. package/dist/lib/refresh.js +6 -1
  79. package/dist/lib/ssh-ordered-bytes.d.ts +8 -0
  80. package/dist/lib/ssh-ordered-bytes.js +31 -0
  81. package/dist/lib/usd-micros.d.ts +9 -0
  82. package/dist/lib/usd-micros.js +21 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +42 -1
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +35 -3
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialResolver.d.ts +22 -3
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialResolver.js +29 -5
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +49 -1
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +47 -9
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +7 -1
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +7 -1
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +56 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +42 -20
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.d.ts +101 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +179 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -1
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +23 -5
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +42 -1
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +32 -2
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialResolver.d.ts +22 -3
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialResolver.js +27 -4
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +49 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +47 -9
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +7 -1
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +7 -1
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +56 -0
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +39 -20
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.d.ts +101 -0
  108. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +138 -0
  109. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -1
  110. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +5 -1
  111. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  112. package/dist/node_modules/@skrr-ai/data-provider/index.js +3895 -3882
  113. package/oclif.manifest.json +34985 -33643
  114. package/package.json +28 -4
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * daemonBroker.ts — Sky CLI client for the local `oversky` daemon's
3
+ * daemonBroker.ts — skrr CLI client for the local `skrrd` daemon's
4
4
  * cli-handoff endpoint.
5
5
  *
6
6
  * When the daemon is already authenticated for the user on this machine,
@@ -71,7 +71,7 @@ var __importStar = (this && this.__importStar) || (function () {
71
71
  };
72
72
  })();
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
- exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = void 0;
74
+ exports.HOSTED_MACHINE_CLI_HANDOFF_DESCRIPTOR = exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = void 0;
75
75
  exports.normalizeBaseUrl = normalizeBaseUrl;
76
76
  exports.resolveBootstrapPath = resolveBootstrapPath;
77
77
  exports.resolveProfileBootstrapPaths = resolveProfileBootstrapPaths;
@@ -80,6 +80,7 @@ exports.resolveCliHandoffCandidatePaths = resolveCliHandoffCandidatePaths;
80
80
  exports.resolveExactProfileBootstrapPaths = resolveExactProfileBootstrapPaths;
81
81
  exports.findLiveDaemonBootstrap = findLiveDaemonBootstrap;
82
82
  exports.readBootstrap = readBootstrap;
83
+ exports.inspectBootstrap = inspectBootstrap;
83
84
  exports.attemptDaemonBrokerLogin = attemptDaemonBrokerLogin;
84
85
  exports.isBrokeredHandoffToken = isBrokeredHandoffToken;
85
86
  exports.redeemBrokeredHandoffToken = redeemBrokeredHandoffToken;
@@ -95,9 +96,12 @@ const os = __importStar(require("node:os"));
95
96
  const auth_core_1 = require("@skrr-ai/auth-core");
96
97
  const loopback_http_1 = require("@skrr-ai/auth-core/loopback-http");
97
98
  const cli_handoff_wire_1 = require("@skrr-ai/auth-core/cli-handoff-wire");
99
+ const brokered_dpop_1 = require("./brokered-dpop");
98
100
  const auth_storage_1 = require("./auth-storage");
99
101
  const config_1 = require("./config");
100
102
  const cli_identity_1 = require("./cli-identity");
103
+ const daemonRestartWait_1 = require("./daemonRestartWait");
104
+ const keychain_1 = require("./keychain");
101
105
  const REQUEST_TIMEOUT_MS = 20_000;
102
106
  /**
103
107
  * Normalize a base URL for equality comparison. Mirrors the daemon-side
@@ -230,14 +234,25 @@ function resolveBootstrapCandidatePaths(profile = 'default') {
230
234
  * user configures can point the broker somewhere else.
231
235
  */
232
236
  exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-dedicated-runtime/cli-handoff/descriptor.json';
237
+ /**
238
+ * A Hosted Machine's workload hand-off descriptor, published only on a box
239
+ * booted under the identity split (OSK-11981, OSK-12019), where the daemon runs
240
+ * as `oversky-daemon` and its island file is unreadable to the workload. Same
241
+ * format and same broker-only contract as the Dedicated descriptor
242
+ * (`daemon/src/dedicated-cli-handoff.ts`, `workloadCliHandoffTarget`), in a
243
+ * DIFFERENT directory so a Hosted Machine is never mistaken for a Dedicated
244
+ * Runtime guest (`runningOnDedicatedRuntimeGuest` keys on that directory). The
245
+ * parent is created by the box's launcher as root and owned by the daemon.
246
+ */
247
+ exports.HOSTED_MACHINE_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-hosted-machine/cli-handoff/descriptor.json';
233
248
  /** Where the cli-handoff broker looks, in order. */
234
249
  function resolveCliHandoffCandidatePaths(profile = 'default') {
235
250
  const candidates = resolveBootstrapCandidatePaths(profile);
236
- // Any profile: a guest runs one daemon, and the auto-broker resolves the
237
- // default profile regardless of `--profile`, so a narrower condition here would
238
- // only describe behaviour production never reaches.
251
+ // Any profile: a guest runs one daemon holding the one account the guest
252
+ // serves, so every profile on it is that account. On a laptop a named profile
253
+ // reaches only its own island file above (OSK-13257).
239
254
  if (process.platform === 'linux') {
240
- candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
255
+ candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR, exports.HOSTED_MACHINE_CLI_HANDOFF_DESCRIPTOR);
241
256
  }
242
257
  return candidates;
243
258
  }
@@ -286,21 +301,28 @@ function findLiveDaemonBootstrap({ profile = 'default', bootstrapPathOverride, }
286
301
  * file is malformed, the CLI shouldn't try to repair it).
287
302
  */
288
303
  function readBootstrap(filePath) {
304
+ const inspected = inspectBootstrap(filePath);
305
+ return inspected.state === 'ok' ? inspected.bootstrap : null;
306
+ }
307
+ function inspectBootstrap(filePath) {
289
308
  let raw;
290
309
  try {
291
310
  if (!fs.existsSync(filePath))
292
- return null;
311
+ return { state: 'absent' };
293
312
  raw = fs.readFileSync(filePath, 'utf8');
294
313
  }
295
- catch {
296
- return null;
314
+ catch (err) {
315
+ // Removed between the exists check and the read: a daemon shutting down.
316
+ if (err.code === 'ENOENT')
317
+ return { state: 'absent' };
318
+ return { state: 'unreadable', detail: err.message };
297
319
  }
298
320
  let parsedJson;
299
321
  try {
300
322
  parsedJson = JSON.parse(raw);
301
323
  }
302
- catch {
303
- return null;
324
+ catch (err) {
325
+ return { state: 'invalid', detail: `not JSON: ${err.message}` };
304
326
  }
305
327
  // Shared wire parser (`@skrr-ai/auth-core/cli-handoff-wire`) validates the
306
328
  // fields a caller cannot proceed without — host, port, secret — and
@@ -309,13 +331,13 @@ function readBootstrap(filePath) {
309
331
  // port must be a real TCP port, and the secret is a fixed-width key.
310
332
  const parsed = (0, cli_handoff_wire_1.parseCliHandoffDescriptor)(parsedJson);
311
333
  if (!parsed)
312
- return null;
334
+ return { state: 'invalid', detail: 'missing host, port or secret' };
313
335
  if (parsed.host !== '127.0.0.1' ||
314
336
  typeof parsed.port !== 'number' ||
315
337
  parsed.port > 65_535 ||
316
338
  typeof parsed.secret !== 'string' ||
317
339
  Buffer.from(parsed.secret, 'base64').length !== 32) {
318
- return null;
340
+ return { state: 'invalid', detail: 'not a loopback descriptor with a 32-byte secret' };
319
341
  }
320
342
  // PID liveness — ESRCH means the daemon was force-killed and the file
321
343
  // outlived the process. EPERM means alive but not signalable by us,
@@ -327,11 +349,11 @@ function readBootstrap(filePath) {
327
349
  catch (err) {
328
350
  const code = err.code;
329
351
  if (code === 'ESRCH')
330
- return null;
352
+ return { state: 'dead_pid', pid: parsed.pid, bootstrap: parsed };
331
353
  // EPERM / other: keep going.
332
354
  }
333
355
  }
334
- return parsed;
356
+ return { state: 'ok', bootstrap: parsed };
335
357
  }
336
358
  /**
337
359
  * Try the daemon-as-broker handoff. Returns a tagged outcome so callers
@@ -343,12 +365,83 @@ function readBootstrap(filePath) {
343
365
  * PKCE", and `ok: true` means "we're done, persist + return".
344
366
  */
345
367
  async function attemptDaemonBrokerLogin(opts) {
368
+ const first = await attemptDaemonBrokerLoginOnce(opts);
369
+ if (first.ok || !isRestartShapedFailure(first))
370
+ return first;
371
+ // The local service may be restarting: a graceful restart removes the
372
+ // descriptor and republishes it only once the new daemon is listening
373
+ // (measured ~14s), and a crash leaves it naming a dead pid until launchd
374
+ // brings a replacement up. Every `skrr` process in that window used to be
375
+ // told "Not signed in" and succeed on an immediate retry (OSK-13222). Wait —
376
+ // bounded, and only when something says a daemon is coming back — and ask
377
+ // again.
346
378
  const profile = opts.profile ?? 'default';
347
- const candidates = opts.candidatePathsOverride
379
+ const failedFingerprints = new Set();
380
+ if (first.descriptorPort !== undefined || first.descriptorPid !== undefined) {
381
+ failedFingerprints.add(`${first.descriptorPid ?? ''}:${first.descriptorPort ?? ''}`);
382
+ }
383
+ const waited = await (0, daemonRestartWait_1.awaitRestartedDaemon)({
384
+ profile,
385
+ ...(opts.restartWait ?? {}),
386
+ ready: () => {
387
+ const fresh = listUsableBootstraps(opts);
388
+ return fresh.some(({ bootstrap }) => !failedFingerprints.has(`${bootstrap.pid ?? ''}:${bootstrap.port}`));
389
+ },
390
+ });
391
+ if (!waited.waited)
392
+ return first;
393
+ const second = await attemptDaemonBrokerLoginOnce(opts);
394
+ if (second.ok)
395
+ return second;
396
+ return { ...second, restartWaitedMs: waited.waitedMs };
397
+ }
398
+ /**
399
+ * A failure that looks like the local daemon is absent or mid-restart rather
400
+ * than a daemon that answered: no usable descriptor, a refused port, or an
401
+ * uncoded 401 on a secret it rotates when it restarts.
402
+ */
403
+ function isRestartShapedFailure(failure) {
404
+ if (failure.dedicatedGuest)
405
+ return false;
406
+ switch (failure.reason) {
407
+ case 'no_bootstrap':
408
+ case 'bootstrap_parse':
409
+ case 'network':
410
+ return true;
411
+ case 'stale_bootstrap':
412
+ return !failure.code;
413
+ default:
414
+ return false;
415
+ }
416
+ }
417
+ function candidatePathsFor(opts) {
418
+ const profile = opts.profile ?? 'default';
419
+ return opts.candidatePathsOverride
348
420
  ? [...opts.candidatePathsOverride]
349
421
  : opts.bootstrapPathOverride
350
422
  ? [opts.bootstrapPathOverride]
351
423
  : resolveCliHandoffCandidatePaths(profile);
424
+ }
425
+ /** Live descriptors whose serverUrl does not contradict this CLI's baseURL. */
426
+ function listUsableBootstraps(opts) {
427
+ const out = [];
428
+ for (const candidate of candidatePathsFor(opts)) {
429
+ const bootstrap = readBootstrap(candidate);
430
+ if (!bootstrap)
431
+ continue;
432
+ if (typeof bootstrap.serverUrl === 'string' &&
433
+ bootstrap.serverUrl.length > 0 &&
434
+ typeof opts.baseURL === 'string' &&
435
+ opts.baseURL.length > 0 &&
436
+ normalizeBaseUrl(opts.baseURL) !== normalizeBaseUrl(bootstrap.serverUrl)) {
437
+ continue;
438
+ }
439
+ out.push({ bootstrap, sourcePath: candidate });
440
+ }
441
+ return out;
442
+ }
443
+ async function attemptDaemonBrokerLoginOnce(opts) {
444
+ const candidates = candidatePathsFor(opts);
352
445
  // Bound candidates (their serverUrl matches this CLI) in order, then unbound
353
446
  // ones. Every usable candidate is kept, not just the first: see below.
354
447
  // `sourcePath` rides along — the brokered-migration cleanup keys on whether
@@ -357,10 +450,18 @@ async function attemptDaemonBrokerLogin(opts) {
357
450
  const unbound = [];
358
451
  let mismatchedBootstrap = null;
359
452
  let mismatchedPath = null;
453
+ // The first descriptor that EXISTED but could not be used, so a failure can
454
+ // say what was on disk instead of "no daemon" (OSK-13222).
455
+ let unusableDescriptor = null;
360
456
  for (const candidate of candidates) {
361
- const candidateBootstrap = readBootstrap(candidate);
362
- if (!candidateBootstrap)
457
+ const inspection = inspectBootstrap(candidate);
458
+ if (inspection.state !== 'ok') {
459
+ if (inspection.state !== 'absent' && !unusableDescriptor) {
460
+ unusableDescriptor = { path: candidate, inspection };
461
+ }
363
462
  continue;
463
+ }
464
+ const candidateBootstrap = inspection.bootstrap;
364
465
  if (typeof candidateBootstrap.serverUrl === 'string' &&
365
466
  candidateBootstrap.serverUrl.length > 0 &&
366
467
  typeof opts.baseURL === 'string' &&
@@ -392,6 +493,25 @@ async function attemptDaemonBrokerLogin(opts) {
392
493
  ...(mismatchedPath ? { descriptorPath: mismatchedPath } : {}),
393
494
  };
394
495
  }
496
+ if (unusableDescriptor) {
497
+ const { path: descriptorPath, inspection } = unusableDescriptor;
498
+ return {
499
+ ok: false,
500
+ reason: 'no_bootstrap',
501
+ detail: inspection.state === 'dead_pid'
502
+ ? `The daemon hand-off ${descriptorPath} names pid ${inspection.pid}, which has exited`
503
+ : `The daemon hand-off ${descriptorPath} could not be used: ${inspection.detail}`,
504
+ descriptorPath,
505
+ descriptorState: inspection.state,
506
+ ...(inspection.state === 'dead_pid'
507
+ ? {
508
+ descriptorPid: inspection.pid,
509
+ descriptorPort: inspection.bootstrap.port,
510
+ ...(inspection.bootstrap.daemonId ? { daemonId: inspection.bootstrap.daemonId } : {}),
511
+ }
512
+ : {}),
513
+ };
514
+ }
395
515
  return {
396
516
  ok: false,
397
517
  reason: 'no_bootstrap',
@@ -413,6 +533,8 @@ async function attemptDaemonBrokerLogin(opts) {
413
533
  outcome = {
414
534
  ...outcome,
415
535
  descriptorPath: outcome.descriptorPath ?? sourcePath,
536
+ descriptorPort: bootstrap.port,
537
+ ...(typeof bootstrap.pid === 'number' ? { descriptorPid: bootstrap.pid } : {}),
416
538
  ...(bootstrap.daemonId ? { daemonId: bootstrap.daemonId } : {}),
417
539
  ...(sourcePath === exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR
418
540
  ? { dedicatedGuest: true }
@@ -432,18 +554,41 @@ async function brokerThrough(bootstrap, opts, sourcePath) {
432
554
  // either the caller prefers broker mode (auto-broker) or the durable mint is
433
555
  // not offered at all (the Dedicated guest shape — its hand-off-only secret
434
556
  // cannot mint a family, so the token route is the only door).
557
+ //
558
+ // The no-durable-mint policy for the AUTOMATIC path lives here, in the CLI,
559
+ // and not in the daemon (OSK-12018). On a laptop the daemon cannot tell an
560
+ // explicit `skrr login` from an agent-originated implicit sign-in — same
561
+ // uid, same island secret — so a daemon-side refusal of the legacy mint
562
+ // would either break explicit login or be bypassed by anyone who calls it
563
+ // the way login does. It is not a boundary, so it is not attempted. What the
564
+ // CLI CAN guarantee is that its own automatic path never mints and persists
565
+ // a refresh family: when `access_token` is advertised, a failed redeem is
566
+ // the answer, and the daemon's outage is reported with its repair.
567
+ //
568
+ // COMPATIBILITY WINDOW: a v1 descriptor (no `handoffModes`, so no
569
+ // `access_token`) still takes the durable mint on the automatic path below,
570
+ // because that is the only door an older daemon has and refusing it would
571
+ // sign every laptop user out on a CLI upgrade. Remove that fallback for the
572
+ // automatic path once no supported laptop daemon still writes a v1 island
573
+ // descriptor — the v2 island descriptor (`handoffModes` in
574
+ // `daemon/src/local-server.ts`) and its token route shipped together in
575
+ // 1a636216b0, so the condition is "every daemon in use is at or past that
576
+ // release", which is a fleet measurement, not a date.
435
577
  const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
436
- const offersAccessToken = modes.includes('access_token');
578
+ const offersAccessToken = (0, cli_handoff_wire_1.descriptorOffersAccessToken)(bootstrap);
437
579
  const offersRefreshFamily = modes.includes('refresh_family');
438
580
  const preferAccessToken = opts.preferHandoffMode === 'access_token';
439
581
  if (offersAccessToken && (preferAccessToken || !offersRefreshFamily)) {
440
582
  const redeem = await redeemCliHandoffToken(bootstrap, {
441
583
  forceRefresh: false,
442
584
  timeoutMs: opts.timeoutMs,
585
+ // OSK-12020 — ask for a key-bound token whenever the daemon can serve
586
+ // its proofs. Whether it IS bound is read from the answer's tokenType.
587
+ dpop: (0, cli_handoff_wire_1.descriptorOffersDpop)(bootstrap),
443
588
  });
444
589
  if (redeem.ok) {
445
590
  const response = redeem.body;
446
- registerBrokeredHandoff(bootstrap, response.accessToken, sourcePath);
591
+ registerBrokeredHandoff(bootstrap, response.accessToken, sourcePath, response.tokenType === 'DPoP');
447
592
  if (sourcePath === exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR) {
448
593
  // A guest descriptor's secret can ONLY redeem access tokens, so a
449
594
  // stored cli-auth.json on this machine predates broker mode. Retire
@@ -467,20 +612,17 @@ async function brokerThrough(bootstrap, opts, sourcePath) {
467
612
  ...(sourcePath ? { descriptorPath: sourcePath } : {}),
468
613
  };
469
614
  }
470
- // The descriptor ALSO advertises the durable mint (a laptop island during
471
- // transition): any redeem failure — refusal, network, outage — falls
472
- // through to the legacy route below. When refresh_family is NOT offered,
473
- // the redeem outcome is final: the legacy route would only answer
474
- // 403 HANDOFF_BROKER_ONLY anyway.
475
- if (!offersRefreshFamily)
476
- return redeem;
477
- const minted = await mintRefreshFamilyThrough(bootstrap, opts);
478
- // Both doors refused: report the one that says more. The legacy route
479
- // usually carries the server's code itself, but an older daemon's token
480
- // route may be the only one that named it.
481
- if (!minted.ok && !minted.code && redeem.code && redeem.reason !== 'network')
482
- return redeem;
483
- return minted;
615
+ // A redeem failure is final for the automatic path even when the
616
+ // descriptor ALSO offers the durable mint (the laptop island): falling
617
+ // through would silently mint and persist a refresh family nobody asked
618
+ // for, which is exactly what broker mode exists to stop (OSK-12018). The
619
+ // caller reports the outage (`describeBrokerRefusal`) and names
620
+ // `skrr login` as the way to a durable personal sign-in. When
621
+ // refresh_family is NOT offered (a guest) the legacy route would only
622
+ // answer 403 HANDOFF_BROKER_ONLY anyway. (An explicit `skrr login`
623
+ // against a laptop island never enters this branch: it prefers
624
+ // refresh_family, which is offered, and takes the mint below directly.)
625
+ return redeem;
484
626
  }
485
627
  return mintRefreshFamilyThrough(bootstrap, opts);
486
628
  }
@@ -508,7 +650,10 @@ async function redeemCliHandoffToken(bootstrap, opts = {}) {
508
650
  // No cliId — the family is daemon-owned; the caller is not enrolling a
509
651
  // device. forceRefresh bypasses the daemon's cached access token (the
510
652
  // 401-recovery path).
511
- body: JSON.stringify({ forceRefresh: opts.forceRefresh === true }),
653
+ body: JSON.stringify({
654
+ forceRefresh: opts.forceRefresh === true,
655
+ ...(opts.dpop ? { dpop: true } : {}),
656
+ }),
512
657
  signal: controller.signal,
513
658
  });
514
659
  }
@@ -766,8 +911,24 @@ async function mintRefreshFamilyThrough(bootstrap, opts) {
766
911
  * and re-redeems on entry via `maybeAutoBroker`.
767
912
  */
768
913
  let _brokeredHandoff = null;
769
- function registerBrokeredHandoff(descriptor, accessToken, sourcePath) {
914
+ function registerBrokeredHandoff(descriptor, accessToken, sourcePath, dpopBound = false) {
770
915
  _brokeredHandoff = { descriptor, accessToken, ...(sourcePath ? { sourcePath } : {}) };
916
+ // OSK-12020 — a token the daemon reported as `tokenType: 'DPoP'` carries
917
+ // `cnf.jkt`; every request it makes needs a fresh proof from the daemon's
918
+ // key (`brokered-dpop.ts`). Never inferred from having asked: an older
919
+ // server ignores the binding request and the daemon then says `Bearer`.
920
+ if (dpopBound) {
921
+ (0, brokered_dpop_1.registerDpopBinding)(accessToken, () => {
922
+ const current = (sourcePath ? readBootstrap(sourcePath) : null) ?? descriptor;
923
+ if (!current.host || !current.port || !current.secret) {
924
+ throw new Error('hand-off descriptor is missing its loopback address');
925
+ }
926
+ return { host: current.host, port: current.port, secret: current.secret };
927
+ });
928
+ }
929
+ else {
930
+ (0, brokered_dpop_1.clearDpopBinding)();
931
+ }
771
932
  }
772
933
  /**
773
934
  * True when `token` is the access token this process redeemed through the
@@ -790,15 +951,19 @@ async function redeemBrokeredHandoffToken(forceRefresh = true) {
790
951
  if (!state)
791
952
  return null;
792
953
  const descriptor = (state.sourcePath ? readBootstrap(state.sourcePath) : null) ?? state.descriptor;
793
- const result = await redeemCliHandoffToken(descriptor, { forceRefresh });
954
+ const result = await redeemCliHandoffToken(descriptor, {
955
+ forceRefresh,
956
+ dpop: (0, cli_handoff_wire_1.descriptorOffersDpop)(descriptor),
957
+ });
794
958
  if (!result.ok)
795
959
  return null;
796
- _brokeredHandoff = { ...state, descriptor, accessToken: result.body.accessToken };
960
+ registerBrokeredHandoff(descriptor, result.body.accessToken, state.sourcePath, result.body.tokenType === 'DPoP');
797
961
  return result.body;
798
962
  }
799
963
  /** @internal test seam — clears the module-scoped brokered credential. */
800
964
  function __resetBrokeredHandoffForTest() {
801
965
  _brokeredHandoff = null;
966
+ (0, brokered_dpop_1.clearDpopBinding)();
802
967
  }
803
968
  /**
804
969
  * True when a live local descriptor advertises broker-mode access — i.e.
@@ -908,7 +1073,7 @@ function shouldBrokerBeforeStoredCredential(descriptorPath = exports.DEDICATED_R
908
1073
  if (!bootstrap)
909
1074
  return false;
910
1075
  const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
911
- if (!modes.includes('access_token') || modes.includes('refresh_family'))
1076
+ if (!(0, cli_handoff_wire_1.descriptorOffersAccessToken)(bootstrap) || modes.includes('refresh_family'))
912
1077
  return false;
913
1078
  return retirableCliAuthFamily(bootstrap.serverUrl) !== null;
914
1079
  }
@@ -1052,7 +1217,10 @@ const AUTH_SELF_MANAGED_COMMANDS = new Set([
1052
1217
  * 'access_token'`. On a descriptor that advertises broker mode the
1053
1218
  * outcome is `brokered` — an in-memory access token, nothing written;
1054
1219
  * the caller holds it for this process and the next `skrr` re-redeems.
1055
- * On a legacy/v1 descriptor the durable mint is persisted to the
1220
+ * A failed redeem on such a descriptor is returned as the outcome and
1221
+ * never retried as a durable mint (OSK-12018). Only on a legacy/v1
1222
+ * descriptor (no `access_token` advertised — the compatibility window
1223
+ * documented in `brokerThrough`) is the durable mint persisted to the
1056
1224
  * keychain / cli-auth.json exactly as before.
1057
1225
  * - May generate a new `cliId` if the config doesn't have one. The
1058
1226
  * caller MUST persist `updatedConfig` so the next invocation
@@ -1085,6 +1253,7 @@ async function maybeAutoBroker(opts) {
1085
1253
  // family mint remains the fallback for v1 descriptors and the redeem
1086
1254
  // path for explicit `skrr login`.
1087
1255
  preferHandoffMode: 'access_token',
1256
+ profile: opts.profile ?? (0, keychain_1.getActiveProfile)(),
1088
1257
  ...(opts.bootstrapPathOverride ? { bootstrapPathOverride: opts.bootstrapPathOverride } : {}),
1089
1258
  });
1090
1259
  return {
@@ -32,6 +32,12 @@ export interface BrokerRefusalDescription {
32
32
  transient: boolean;
33
33
  /** True when the refusing daemon is a Dedicated Runtime guest's. */
34
34
  dedicatedGuest: boolean;
35
+ /**
36
+ * True when no daemon answered because the local service is down or
37
+ * restarting (OSK-13222). The person is not signed out — their sign-in
38
+ * lives in that service — so the message must not open with "Not signed in".
39
+ */
40
+ daemonUnavailable?: boolean;
35
41
  /** Machine-readable twin for `--json` error details. */
36
42
  broker: {
37
43
  reason: DaemonBrokerFailure['reason'];
@@ -41,6 +47,10 @@ export interface BrokerRefusalDescription {
41
47
  detail?: string;
42
48
  descriptorPath?: string;
43
49
  daemonId?: string;
50
+ descriptorState?: DaemonBrokerFailure['descriptorState'];
51
+ descriptorPid?: number;
52
+ descriptorPort?: number;
53
+ restartWaitedMs?: number;
44
54
  };
45
55
  }
46
56
  /**
@@ -70,3 +80,19 @@ export declare function describeGuestDaemonUnavailable(failure: DaemonBrokerFail
70
80
  onGuest: boolean;
71
81
  descriptorPresent?: boolean;
72
82
  }): BrokerRefusalDescription | null;
83
+ /**
84
+ * What to tell a person on their own computer when NO daemon answered the
85
+ * hand-off, but something on disk says one was there (OSK-13222).
86
+ *
87
+ * The laptop twin of `describeGuestDaemonUnavailable`. A daemon that crashed
88
+ * leaves its descriptor naming a dead pid; one mid-restart has a descriptor
89
+ * whose port refuses, or a secret it already rotated; or the CLI waited for a
90
+ * restart that did not finish. Each of these used to read "Not signed in. Run
91
+ * `skrr login` first." — a claim about the person's account made on evidence
92
+ * about a local process. Their sign-in is held by that service; say the
93
+ * service is down and name what was read.
94
+ *
95
+ * Returns null when there is no evidence of a daemon at all (no descriptor,
96
+ * nothing waited for): "Not signed in" is then the true sentence.
97
+ */
98
+ export declare function describeLocalDaemonUnavailable(failure: DaemonBrokerFailure, bin?: string): BrokerRefusalDescription | null;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isAnsweredBrokerRefusal = isAnsweredBrokerRefusal;
4
4
  exports.describeBrokerRefusal = describeBrokerRefusal;
5
5
  exports.describeGuestDaemonUnavailable = describeGuestDaemonUnavailable;
6
+ exports.describeLocalDaemonUnavailable = describeLocalDaemonUnavailable;
6
7
  /**
7
8
  * True when a daemon ANSWERED the hand-off and refused it — as opposed to no
8
9
  * daemon being there to ask (`no_bootstrap`, a stale descriptor nobody listens
@@ -97,18 +98,18 @@ function describeBrokerRefusal(failure, bin = 'skrr') {
97
98
  }
98
99
  else if (failure.reason === 'ci_token_refused') {
99
100
  remedy =
100
- `The background service is signed in with a CI token, which cannot sign a person in. Use \`${bin} login\`, ` +
101
- `or \`${bin} create-token\` for a CI credential.`;
101
+ `The background service is signed in with a CI token, which cannot sign a person in. Use \`${bin} login\` ` +
102
+ `(\`--device-code\` without a terminal). A \`${bin} create-token\` token would not help: ${bin} commands reject it.`;
102
103
  }
103
104
  else if (transient) {
104
105
  remedy =
105
106
  `This is usually temporary; retry in a minute. \`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side, ` +
106
- `and \`${bin} login\` signs this CLI in without it.`;
107
+ `and \`${bin} login\` gives this CLI a durable sign-in of its own, without the service.`;
107
108
  }
108
109
  else {
109
110
  remedy =
110
111
  `\`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side. ` +
111
- `\`${bin} login\` signs this CLI in without it.`;
112
+ `\`${bin} login\` gives this CLI a durable sign-in of its own, without the service.`;
112
113
  }
113
114
  return {
114
115
  headline: refusal,
@@ -210,3 +211,81 @@ function describeGuestDaemonUnavailable(failure, opts) {
210
211
  },
211
212
  };
212
213
  }
214
+ /**
215
+ * What to tell a person on their own computer when NO daemon answered the
216
+ * hand-off, but something on disk says one was there (OSK-13222).
217
+ *
218
+ * The laptop twin of `describeGuestDaemonUnavailable`. A daemon that crashed
219
+ * leaves its descriptor naming a dead pid; one mid-restart has a descriptor
220
+ * whose port refuses, or a secret it already rotated; or the CLI waited for a
221
+ * restart that did not finish. Each of these used to read "Not signed in. Run
222
+ * `skrr login` first." — a claim about the person's account made on evidence
223
+ * about a local process. Their sign-in is held by that service; say the
224
+ * service is down and name what was read.
225
+ *
226
+ * Returns null when there is no evidence of a daemon at all (no descriptor,
227
+ * nothing waited for): "Not signed in" is then the true sentence.
228
+ */
229
+ function describeLocalDaemonUnavailable(failure, bin = 'skrr') {
230
+ if (failure.dedicatedGuest)
231
+ return null;
232
+ if (isAnsweredBrokerRefusal(failure))
233
+ return null;
234
+ const who = `The skrr background service on this computer${failure.daemonId ? ` (daemon ${failure.daemonId})` : ''}`;
235
+ const where = failure.descriptorPath ? ` (${failure.descriptorPath})` : '';
236
+ const waited = typeof failure.restartWaitedMs === 'number' && failure.restartWaitedMs > 0
237
+ ? ` Waited ${Math.max(1, Math.round(failure.restartWaitedMs / 1000))}s for it to restart.`
238
+ : '';
239
+ let headline = null;
240
+ if (failure.reason === 'network' && failure.descriptorPath) {
241
+ headline =
242
+ `${who} is not answering: its sign-in hand-off${where} names port ` +
243
+ `${failure.descriptorPort ?? '?'} on 127.0.0.1, where nothing is listening.`;
244
+ }
245
+ else if (failure.reason === 'stale_bootstrap') {
246
+ headline = `${who} refused this CLI's hand-off secret${where}, which it rotates when it restarts.`;
247
+ }
248
+ else if (failure.reason === 'bootstrap_parse' ||
249
+ failure.descriptorState === 'unreadable' ||
250
+ failure.descriptorState === 'invalid') {
251
+ headline = `${who} is not available: its sign-in hand-off${where} could not be read${failure.detail ? ` (${failure.detail.replace(/[.]$/, '')})` : ''}.`;
252
+ }
253
+ else if (failure.descriptorState === 'dead_pid') {
254
+ headline =
255
+ `${who} is not running: its sign-in hand-off${where} names pid ` +
256
+ `${failure.descriptorPid ?? '?'}, which has exited (a crash, or a restart in progress).`;
257
+ }
258
+ else if (failure.reason === 'no_bootstrap' && waited) {
259
+ headline = `${who} was restarting and did not publish its sign-in hand-off in time.`;
260
+ }
261
+ if (!headline)
262
+ return null;
263
+ const remedy = 'You are not signed out: your sign-in is held by that service, which normally restarts on its own. ' +
264
+ `Retry in a moment. \`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side, ` +
265
+ `and \`${bin} login\` signs this CLI in without it.`;
266
+ return {
267
+ headline: `${headline}${waited}`,
268
+ remedy,
269
+ transient: true,
270
+ dedicatedGuest: false,
271
+ daemonUnavailable: true,
272
+ broker: {
273
+ reason: failure.reason,
274
+ ...(typeof failure.status === 'number' ? { status: failure.status } : {}),
275
+ ...(failure.code ? { code: failure.code } : {}),
276
+ ...(failure.detail ? { detail: failure.detail } : {}),
277
+ ...(failure.descriptorPath ? { descriptorPath: failure.descriptorPath } : {}),
278
+ ...(failure.daemonId ? { daemonId: failure.daemonId } : {}),
279
+ ...(failure.descriptorState ? { descriptorState: failure.descriptorState } : {}),
280
+ ...(typeof failure.descriptorPid === 'number'
281
+ ? { descriptorPid: failure.descriptorPid }
282
+ : {}),
283
+ ...(typeof failure.descriptorPort === 'number'
284
+ ? { descriptorPort: failure.descriptorPort }
285
+ : {}),
286
+ ...(typeof failure.restartWaitedMs === 'number'
287
+ ? { restartWaitedMs: failure.restartWaitedMs }
288
+ : {}),
289
+ },
290
+ };
291
+ }
@@ -0,0 +1,39 @@
1
+ export type DaemonRestartSignal = 'starting' | 'supervised' | 'none';
2
+ /** A lock acquired within this window belongs to a daemon still starting up. */
3
+ export declare const STARTING_WINDOW_MS = 60000;
4
+ /** Total wait once a starting daemon is seen. Startup was measured at ~14s. */
5
+ export declare const DEFAULT_RESTART_WAIT_MS = 20000;
6
+ /** Wait for a supervisor to start a daemon when none holds the lock yet. */
7
+ export declare const SUPERVISED_GRACE_MS = 4000;
8
+ export declare const DEFAULT_POLL_MS = 250;
9
+ /**
10
+ * Read the restart signal for `profile` from the daemon's own on-disk state.
11
+ * Never throws; anything unreadable reads as absent.
12
+ */
13
+ export declare function probeDaemonRestart(profile: string, opts?: {
14
+ homeDir?: string;
15
+ now?: number;
16
+ platform?: NodeJS.Platform;
17
+ }): DaemonRestartSignal;
18
+ export interface AwaitRestartedDaemonOptions {
19
+ profile: string;
20
+ /** True once a fresh descriptor the caller has not already failed on exists. */
21
+ ready: () => boolean;
22
+ /** Total budget once a starting daemon is seen. `SKRR_DAEMON_RESTART_WAIT_MS` overrides; 0 disables. */
23
+ maxWaitMs?: number;
24
+ supervisedGraceMs?: number;
25
+ pollMs?: number;
26
+ probe?: (profile: string) => DaemonRestartSignal;
27
+ now?: () => number;
28
+ sleep?: (ms: number) => Promise<void>;
29
+ /** Told once when the wait begins. Defaults to one stderr line. */
30
+ notify?: (message: string) => void;
31
+ }
32
+ export interface AwaitRestartedDaemonResult {
33
+ /** True when a restart was plausible and the CLI waited for it. */
34
+ waited: boolean;
35
+ waitedMs: number;
36
+ /** True when a fresh descriptor appeared inside the budget. */
37
+ ready: boolean;
38
+ }
39
+ export declare function awaitRestartedDaemon(opts: AwaitRestartedDaemonOptions): Promise<AwaitRestartedDaemonResult>;