@skrr-ai/cli 0.1.78 → 0.1.80

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 (49) hide show
  1. package/dist/base-command.js +9 -2
  2. package/dist/commands/inbox/index.js +4 -2
  3. package/dist/commands/login.d.ts +8 -3
  4. package/dist/commands/login.js +14 -4
  5. package/dist/commands/spaces/workflows/events.js +9 -2
  6. package/dist/lib/agentic-stream.js +13 -4
  7. package/dist/lib/api-fetch.js +6 -2
  8. package/dist/lib/brokered-dpop.d.ts +34 -0
  9. package/dist/lib/brokered-dpop.js +89 -0
  10. package/dist/lib/daemonBroker.d.ts +71 -2
  11. package/dist/lib/daemonBroker.js +203 -36
  12. package/dist/lib/daemonBrokerRefusal.d.ts +26 -0
  13. package/dist/lib/daemonBrokerRefusal.js +83 -4
  14. package/dist/lib/daemonRestartWait.d.ts +39 -0
  15. package/dist/lib/daemonRestartWait.js +181 -0
  16. package/dist/lib/dedicated-lease-command.js +1 -0
  17. package/dist/lib/dedicated-service.js +10 -5
  18. package/dist/lib/dedicated-ssh.js +6 -3
  19. package/dist/lib/dedicated-terminal.d.ts +3 -0
  20. package/dist/lib/dedicated-terminal.js +77 -18
  21. package/dist/lib/dpop-auth.d.ts +35 -0
  22. package/dist/lib/dpop-auth.js +57 -0
  23. package/dist/lib/login.d.ts +6 -0
  24. package/dist/lib/login.js +13 -4
  25. package/dist/lib/node-adapter.js +4 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +42 -1
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +35 -3
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +49 -1
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +47 -9
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +56 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +42 -20
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.d.ts +101 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +179 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +19 -3
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +42 -1
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +32 -2
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +49 -1
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +47 -9
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +56 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +39 -20
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.d.ts +101 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +138 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +4 -0
  46. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  47. package/dist/node_modules/@skrr-ai/data-provider/index.js +3422 -3407
  48. package/oclif.manifest.json +41505 -41505
  49. package/package.json +2 -2
@@ -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,11 @@ 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");
101
104
  const REQUEST_TIMEOUT_MS = 20_000;
102
105
  /**
103
106
  * Normalize a base URL for equality comparison. Mirrors the daemon-side
@@ -230,6 +233,17 @@ function resolveBootstrapCandidatePaths(profile = 'default') {
230
233
  * user configures can point the broker somewhere else.
231
234
  */
232
235
  exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-dedicated-runtime/cli-handoff/descriptor.json';
236
+ /**
237
+ * A Hosted Machine's workload hand-off descriptor, published only on a box
238
+ * booted under the identity split (OSK-11981, OSK-12019), where the daemon runs
239
+ * as `oversky-daemon` and its island file is unreadable to the workload. Same
240
+ * format and same broker-only contract as the Dedicated descriptor
241
+ * (`daemon/src/dedicated-cli-handoff.ts`, `workloadCliHandoffTarget`), in a
242
+ * DIFFERENT directory so a Hosted Machine is never mistaken for a Dedicated
243
+ * Runtime guest (`runningOnDedicatedRuntimeGuest` keys on that directory). The
244
+ * parent is created by the box's launcher as root and owned by the daemon.
245
+ */
246
+ exports.HOSTED_MACHINE_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-hosted-machine/cli-handoff/descriptor.json';
233
247
  /** Where the cli-handoff broker looks, in order. */
234
248
  function resolveCliHandoffCandidatePaths(profile = 'default') {
235
249
  const candidates = resolveBootstrapCandidatePaths(profile);
@@ -237,7 +251,7 @@ function resolveCliHandoffCandidatePaths(profile = 'default') {
237
251
  // default profile regardless of `--profile`, so a narrower condition here would
238
252
  // only describe behaviour production never reaches.
239
253
  if (process.platform === 'linux') {
240
- candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
254
+ candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR, exports.HOSTED_MACHINE_CLI_HANDOFF_DESCRIPTOR);
241
255
  }
242
256
  return candidates;
243
257
  }
@@ -286,21 +300,28 @@ function findLiveDaemonBootstrap({ profile = 'default', bootstrapPathOverride, }
286
300
  * file is malformed, the CLI shouldn't try to repair it).
287
301
  */
288
302
  function readBootstrap(filePath) {
303
+ const inspected = inspectBootstrap(filePath);
304
+ return inspected.state === 'ok' ? inspected.bootstrap : null;
305
+ }
306
+ function inspectBootstrap(filePath) {
289
307
  let raw;
290
308
  try {
291
309
  if (!fs.existsSync(filePath))
292
- return null;
310
+ return { state: 'absent' };
293
311
  raw = fs.readFileSync(filePath, 'utf8');
294
312
  }
295
- catch {
296
- return null;
313
+ catch (err) {
314
+ // Removed between the exists check and the read: a daemon shutting down.
315
+ if (err.code === 'ENOENT')
316
+ return { state: 'absent' };
317
+ return { state: 'unreadable', detail: err.message };
297
318
  }
298
319
  let parsedJson;
299
320
  try {
300
321
  parsedJson = JSON.parse(raw);
301
322
  }
302
- catch {
303
- return null;
323
+ catch (err) {
324
+ return { state: 'invalid', detail: `not JSON: ${err.message}` };
304
325
  }
305
326
  // Shared wire parser (`@skrr-ai/auth-core/cli-handoff-wire`) validates the
306
327
  // fields a caller cannot proceed without — host, port, secret — and
@@ -309,13 +330,13 @@ function readBootstrap(filePath) {
309
330
  // port must be a real TCP port, and the secret is a fixed-width key.
310
331
  const parsed = (0, cli_handoff_wire_1.parseCliHandoffDescriptor)(parsedJson);
311
332
  if (!parsed)
312
- return null;
333
+ return { state: 'invalid', detail: 'missing host, port or secret' };
313
334
  if (parsed.host !== '127.0.0.1' ||
314
335
  typeof parsed.port !== 'number' ||
315
336
  parsed.port > 65_535 ||
316
337
  typeof parsed.secret !== 'string' ||
317
338
  Buffer.from(parsed.secret, 'base64').length !== 32) {
318
- return null;
339
+ return { state: 'invalid', detail: 'not a loopback descriptor with a 32-byte secret' };
319
340
  }
320
341
  // PID liveness — ESRCH means the daemon was force-killed and the file
321
342
  // outlived the process. EPERM means alive but not signalable by us,
@@ -327,11 +348,11 @@ function readBootstrap(filePath) {
327
348
  catch (err) {
328
349
  const code = err.code;
329
350
  if (code === 'ESRCH')
330
- return null;
351
+ return { state: 'dead_pid', pid: parsed.pid, bootstrap: parsed };
331
352
  // EPERM / other: keep going.
332
353
  }
333
354
  }
334
- return parsed;
355
+ return { state: 'ok', bootstrap: parsed };
335
356
  }
336
357
  /**
337
358
  * Try the daemon-as-broker handoff. Returns a tagged outcome so callers
@@ -343,12 +364,83 @@ function readBootstrap(filePath) {
343
364
  * PKCE", and `ok: true` means "we're done, persist + return".
344
365
  */
345
366
  async function attemptDaemonBrokerLogin(opts) {
367
+ const first = await attemptDaemonBrokerLoginOnce(opts);
368
+ if (first.ok || !isRestartShapedFailure(first))
369
+ return first;
370
+ // The local service may be restarting: a graceful restart removes the
371
+ // descriptor and republishes it only once the new daemon is listening
372
+ // (measured ~14s), and a crash leaves it naming a dead pid until launchd
373
+ // brings a replacement up. Every `skrr` process in that window used to be
374
+ // told "Not signed in" and succeed on an immediate retry (OSK-13222). Wait —
375
+ // bounded, and only when something says a daemon is coming back — and ask
376
+ // again.
346
377
  const profile = opts.profile ?? 'default';
347
- const candidates = opts.candidatePathsOverride
378
+ const failedFingerprints = new Set();
379
+ if (first.descriptorPort !== undefined || first.descriptorPid !== undefined) {
380
+ failedFingerprints.add(`${first.descriptorPid ?? ''}:${first.descriptorPort ?? ''}`);
381
+ }
382
+ const waited = await (0, daemonRestartWait_1.awaitRestartedDaemon)({
383
+ profile,
384
+ ...(opts.restartWait ?? {}),
385
+ ready: () => {
386
+ const fresh = listUsableBootstraps(opts);
387
+ return fresh.some(({ bootstrap }) => !failedFingerprints.has(`${bootstrap.pid ?? ''}:${bootstrap.port}`));
388
+ },
389
+ });
390
+ if (!waited.waited)
391
+ return first;
392
+ const second = await attemptDaemonBrokerLoginOnce(opts);
393
+ if (second.ok)
394
+ return second;
395
+ return { ...second, restartWaitedMs: waited.waitedMs };
396
+ }
397
+ /**
398
+ * A failure that looks like the local daemon is absent or mid-restart rather
399
+ * than a daemon that answered: no usable descriptor, a refused port, or an
400
+ * uncoded 401 on a secret it rotates when it restarts.
401
+ */
402
+ function isRestartShapedFailure(failure) {
403
+ if (failure.dedicatedGuest)
404
+ return false;
405
+ switch (failure.reason) {
406
+ case 'no_bootstrap':
407
+ case 'bootstrap_parse':
408
+ case 'network':
409
+ return true;
410
+ case 'stale_bootstrap':
411
+ return !failure.code;
412
+ default:
413
+ return false;
414
+ }
415
+ }
416
+ function candidatePathsFor(opts) {
417
+ const profile = opts.profile ?? 'default';
418
+ return opts.candidatePathsOverride
348
419
  ? [...opts.candidatePathsOverride]
349
420
  : opts.bootstrapPathOverride
350
421
  ? [opts.bootstrapPathOverride]
351
422
  : resolveCliHandoffCandidatePaths(profile);
423
+ }
424
+ /** Live descriptors whose serverUrl does not contradict this CLI's baseURL. */
425
+ function listUsableBootstraps(opts) {
426
+ const out = [];
427
+ for (const candidate of candidatePathsFor(opts)) {
428
+ const bootstrap = readBootstrap(candidate);
429
+ if (!bootstrap)
430
+ continue;
431
+ if (typeof bootstrap.serverUrl === 'string' &&
432
+ bootstrap.serverUrl.length > 0 &&
433
+ typeof opts.baseURL === 'string' &&
434
+ opts.baseURL.length > 0 &&
435
+ normalizeBaseUrl(opts.baseURL) !== normalizeBaseUrl(bootstrap.serverUrl)) {
436
+ continue;
437
+ }
438
+ out.push({ bootstrap, sourcePath: candidate });
439
+ }
440
+ return out;
441
+ }
442
+ async function attemptDaemonBrokerLoginOnce(opts) {
443
+ const candidates = candidatePathsFor(opts);
352
444
  // Bound candidates (their serverUrl matches this CLI) in order, then unbound
353
445
  // ones. Every usable candidate is kept, not just the first: see below.
354
446
  // `sourcePath` rides along — the brokered-migration cleanup keys on whether
@@ -357,10 +449,18 @@ async function attemptDaemonBrokerLogin(opts) {
357
449
  const unbound = [];
358
450
  let mismatchedBootstrap = null;
359
451
  let mismatchedPath = null;
452
+ // The first descriptor that EXISTED but could not be used, so a failure can
453
+ // say what was on disk instead of "no daemon" (OSK-13222).
454
+ let unusableDescriptor = null;
360
455
  for (const candidate of candidates) {
361
- const candidateBootstrap = readBootstrap(candidate);
362
- if (!candidateBootstrap)
456
+ const inspection = inspectBootstrap(candidate);
457
+ if (inspection.state !== 'ok') {
458
+ if (inspection.state !== 'absent' && !unusableDescriptor) {
459
+ unusableDescriptor = { path: candidate, inspection };
460
+ }
363
461
  continue;
462
+ }
463
+ const candidateBootstrap = inspection.bootstrap;
364
464
  if (typeof candidateBootstrap.serverUrl === 'string' &&
365
465
  candidateBootstrap.serverUrl.length > 0 &&
366
466
  typeof opts.baseURL === 'string' &&
@@ -392,6 +492,25 @@ async function attemptDaemonBrokerLogin(opts) {
392
492
  ...(mismatchedPath ? { descriptorPath: mismatchedPath } : {}),
393
493
  };
394
494
  }
495
+ if (unusableDescriptor) {
496
+ const { path: descriptorPath, inspection } = unusableDescriptor;
497
+ return {
498
+ ok: false,
499
+ reason: 'no_bootstrap',
500
+ detail: inspection.state === 'dead_pid'
501
+ ? `The daemon hand-off ${descriptorPath} names pid ${inspection.pid}, which has exited`
502
+ : `The daemon hand-off ${descriptorPath} could not be used: ${inspection.detail}`,
503
+ descriptorPath,
504
+ descriptorState: inspection.state,
505
+ ...(inspection.state === 'dead_pid'
506
+ ? {
507
+ descriptorPid: inspection.pid,
508
+ descriptorPort: inspection.bootstrap.port,
509
+ ...(inspection.bootstrap.daemonId ? { daemonId: inspection.bootstrap.daemonId } : {}),
510
+ }
511
+ : {}),
512
+ };
513
+ }
395
514
  return {
396
515
  ok: false,
397
516
  reason: 'no_bootstrap',
@@ -413,6 +532,8 @@ async function attemptDaemonBrokerLogin(opts) {
413
532
  outcome = {
414
533
  ...outcome,
415
534
  descriptorPath: outcome.descriptorPath ?? sourcePath,
535
+ descriptorPort: bootstrap.port,
536
+ ...(typeof bootstrap.pid === 'number' ? { descriptorPid: bootstrap.pid } : {}),
416
537
  ...(bootstrap.daemonId ? { daemonId: bootstrap.daemonId } : {}),
417
538
  ...(sourcePath === exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR
418
539
  ? { dedicatedGuest: true }
@@ -432,18 +553,41 @@ async function brokerThrough(bootstrap, opts, sourcePath) {
432
553
  // either the caller prefers broker mode (auto-broker) or the durable mint is
433
554
  // not offered at all (the Dedicated guest shape — its hand-off-only secret
434
555
  // cannot mint a family, so the token route is the only door).
556
+ //
557
+ // The no-durable-mint policy for the AUTOMATIC path lives here, in the CLI,
558
+ // and not in the daemon (OSK-12018). On a laptop the daemon cannot tell an
559
+ // explicit `skrr login` from an agent-originated implicit sign-in — same
560
+ // uid, same island secret — so a daemon-side refusal of the legacy mint
561
+ // would either break explicit login or be bypassed by anyone who calls it
562
+ // the way login does. It is not a boundary, so it is not attempted. What the
563
+ // CLI CAN guarantee is that its own automatic path never mints and persists
564
+ // a refresh family: when `access_token` is advertised, a failed redeem is
565
+ // the answer, and the daemon's outage is reported with its repair.
566
+ //
567
+ // COMPATIBILITY WINDOW: a v1 descriptor (no `handoffModes`, so no
568
+ // `access_token`) still takes the durable mint on the automatic path below,
569
+ // because that is the only door an older daemon has and refusing it would
570
+ // sign every laptop user out on a CLI upgrade. Remove that fallback for the
571
+ // automatic path once no supported laptop daemon still writes a v1 island
572
+ // descriptor — the v2 island descriptor (`handoffModes` in
573
+ // `daemon/src/local-server.ts`) and its token route shipped together in
574
+ // 1a636216b0, so the condition is "every daemon in use is at or past that
575
+ // release", which is a fleet measurement, not a date.
435
576
  const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
436
- const offersAccessToken = modes.includes('access_token');
577
+ const offersAccessToken = (0, cli_handoff_wire_1.descriptorOffersAccessToken)(bootstrap);
437
578
  const offersRefreshFamily = modes.includes('refresh_family');
438
579
  const preferAccessToken = opts.preferHandoffMode === 'access_token';
439
580
  if (offersAccessToken && (preferAccessToken || !offersRefreshFamily)) {
440
581
  const redeem = await redeemCliHandoffToken(bootstrap, {
441
582
  forceRefresh: false,
442
583
  timeoutMs: opts.timeoutMs,
584
+ // OSK-12020 — ask for a key-bound token whenever the daemon can serve
585
+ // its proofs. Whether it IS bound is read from the answer's tokenType.
586
+ dpop: (0, cli_handoff_wire_1.descriptorOffersDpop)(bootstrap),
443
587
  });
444
588
  if (redeem.ok) {
445
589
  const response = redeem.body;
446
- registerBrokeredHandoff(bootstrap, response.accessToken, sourcePath);
590
+ registerBrokeredHandoff(bootstrap, response.accessToken, sourcePath, response.tokenType === 'DPoP');
447
591
  if (sourcePath === exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR) {
448
592
  // A guest descriptor's secret can ONLY redeem access tokens, so a
449
593
  // stored cli-auth.json on this machine predates broker mode. Retire
@@ -467,20 +611,17 @@ async function brokerThrough(bootstrap, opts, sourcePath) {
467
611
  ...(sourcePath ? { descriptorPath: sourcePath } : {}),
468
612
  };
469
613
  }
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;
614
+ // A redeem failure is final for the automatic path even when the
615
+ // descriptor ALSO offers the durable mint (the laptop island): falling
616
+ // through would silently mint and persist a refresh family nobody asked
617
+ // for, which is exactly what broker mode exists to stop (OSK-12018). The
618
+ // caller reports the outage (`describeBrokerRefusal`) and names
619
+ // `skrr login` as the way to a durable personal sign-in. When
620
+ // refresh_family is NOT offered (a guest) the legacy route would only
621
+ // answer 403 HANDOFF_BROKER_ONLY anyway. (An explicit `skrr login`
622
+ // against a laptop island never enters this branch: it prefers
623
+ // refresh_family, which is offered, and takes the mint below directly.)
624
+ return redeem;
484
625
  }
485
626
  return mintRefreshFamilyThrough(bootstrap, opts);
486
627
  }
@@ -508,7 +649,10 @@ async function redeemCliHandoffToken(bootstrap, opts = {}) {
508
649
  // No cliId — the family is daemon-owned; the caller is not enrolling a
509
650
  // device. forceRefresh bypasses the daemon's cached access token (the
510
651
  // 401-recovery path).
511
- body: JSON.stringify({ forceRefresh: opts.forceRefresh === true }),
652
+ body: JSON.stringify({
653
+ forceRefresh: opts.forceRefresh === true,
654
+ ...(opts.dpop ? { dpop: true } : {}),
655
+ }),
512
656
  signal: controller.signal,
513
657
  });
514
658
  }
@@ -766,8 +910,24 @@ async function mintRefreshFamilyThrough(bootstrap, opts) {
766
910
  * and re-redeems on entry via `maybeAutoBroker`.
767
911
  */
768
912
  let _brokeredHandoff = null;
769
- function registerBrokeredHandoff(descriptor, accessToken, sourcePath) {
913
+ function registerBrokeredHandoff(descriptor, accessToken, sourcePath, dpopBound = false) {
770
914
  _brokeredHandoff = { descriptor, accessToken, ...(sourcePath ? { sourcePath } : {}) };
915
+ // OSK-12020 — a token the daemon reported as `tokenType: 'DPoP'` carries
916
+ // `cnf.jkt`; every request it makes needs a fresh proof from the daemon's
917
+ // key (`brokered-dpop.ts`). Never inferred from having asked: an older
918
+ // server ignores the binding request and the daemon then says `Bearer`.
919
+ if (dpopBound) {
920
+ (0, brokered_dpop_1.registerDpopBinding)(accessToken, () => {
921
+ const current = (sourcePath ? readBootstrap(sourcePath) : null) ?? descriptor;
922
+ if (!current.host || !current.port || !current.secret) {
923
+ throw new Error('hand-off descriptor is missing its loopback address');
924
+ }
925
+ return { host: current.host, port: current.port, secret: current.secret };
926
+ });
927
+ }
928
+ else {
929
+ (0, brokered_dpop_1.clearDpopBinding)();
930
+ }
771
931
  }
772
932
  /**
773
933
  * True when `token` is the access token this process redeemed through the
@@ -790,15 +950,19 @@ async function redeemBrokeredHandoffToken(forceRefresh = true) {
790
950
  if (!state)
791
951
  return null;
792
952
  const descriptor = (state.sourcePath ? readBootstrap(state.sourcePath) : null) ?? state.descriptor;
793
- const result = await redeemCliHandoffToken(descriptor, { forceRefresh });
953
+ const result = await redeemCliHandoffToken(descriptor, {
954
+ forceRefresh,
955
+ dpop: (0, cli_handoff_wire_1.descriptorOffersDpop)(descriptor),
956
+ });
794
957
  if (!result.ok)
795
958
  return null;
796
- _brokeredHandoff = { ...state, descriptor, accessToken: result.body.accessToken };
959
+ registerBrokeredHandoff(descriptor, result.body.accessToken, state.sourcePath, result.body.tokenType === 'DPoP');
797
960
  return result.body;
798
961
  }
799
962
  /** @internal test seam — clears the module-scoped brokered credential. */
800
963
  function __resetBrokeredHandoffForTest() {
801
964
  _brokeredHandoff = null;
965
+ (0, brokered_dpop_1.clearDpopBinding)();
802
966
  }
803
967
  /**
804
968
  * True when a live local descriptor advertises broker-mode access — i.e.
@@ -908,7 +1072,7 @@ function shouldBrokerBeforeStoredCredential(descriptorPath = exports.DEDICATED_R
908
1072
  if (!bootstrap)
909
1073
  return false;
910
1074
  const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
911
- if (!modes.includes('access_token') || modes.includes('refresh_family'))
1075
+ if (!(0, cli_handoff_wire_1.descriptorOffersAccessToken)(bootstrap) || modes.includes('refresh_family'))
912
1076
  return false;
913
1077
  return retirableCliAuthFamily(bootstrap.serverUrl) !== null;
914
1078
  }
@@ -1052,7 +1216,10 @@ const AUTH_SELF_MANAGED_COMMANDS = new Set([
1052
1216
  * 'access_token'`. On a descriptor that advertises broker mode the
1053
1217
  * outcome is `brokered` — an in-memory access token, nothing written;
1054
1218
  * 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
1219
+ * A failed redeem on such a descriptor is returned as the outcome and
1220
+ * never retried as a durable mint (OSK-12018). Only on a legacy/v1
1221
+ * descriptor (no `access_token` advertised — the compatibility window
1222
+ * documented in `brokerThrough`) is the durable mint persisted to the
1056
1223
  * keychain / cli-auth.json exactly as before.
1057
1224
  * - May generate a new `cliId` if the config doesn't have one. The
1058
1225
  * caller MUST persist `updatedConfig` so the next invocation
@@ -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>;