@skrr-ai/cli 0.1.48 → 0.1.50

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 (117) hide show
  1. package/README.md +5 -4
  2. package/dist/base-command.js +16 -4
  3. package/dist/commands/agent-worker.d.ts +12 -0
  4. package/dist/commands/agent-worker.js +140 -0
  5. package/dist/commands/agents/create.js +14 -7
  6. package/dist/commands/agents/update.js +11 -5
  7. package/dist/commands/code/index.d.ts +1 -0
  8. package/dist/commands/code/index.js +49 -5
  9. package/dist/commands/code/run.js +5 -1
  10. package/dist/commands/commitments/cycles.d.ts +19 -0
  11. package/dist/commands/commitments/cycles.js +46 -0
  12. package/dist/commands/commitments/effective-policy.js +26 -1
  13. package/dist/commands/commitments/explain.d.ts +17 -0
  14. package/dist/commands/commitments/explain.js +40 -0
  15. package/dist/commands/goals/create.d.ts +1 -0
  16. package/dist/commands/goals/create.js +22 -0
  17. package/dist/commands/logout.js +12 -1
  18. package/dist/commands/machines/dedicated/index.js +1 -1
  19. package/dist/commands/machines/dedicated/sign-in.js +4 -1
  20. package/dist/commands/machines/dedicated/terminal/kill.d.ts +21 -0
  21. package/dist/commands/machines/dedicated/terminal/kill.js +55 -0
  22. package/dist/commands/machines/dedicated/terminal/ls.d.ts +22 -0
  23. package/dist/commands/machines/dedicated/terminal/ls.js +70 -0
  24. package/dist/commands/machines/dedicated/terminal/rename.d.ts +23 -0
  25. package/dist/commands/machines/dedicated/terminal/rename.js +64 -0
  26. package/dist/commands/machines/dedicated/terminal.d.ts +9 -0
  27. package/dist/commands/machines/dedicated/terminal.js +27 -2
  28. package/dist/commands/machines/hosted/connect.js +2 -2
  29. package/dist/commands/machines/hosted/destroy.js +1 -1
  30. package/dist/commands/machines/hosted/exec.js +1 -1
  31. package/dist/commands/machines/hosted/list.js +1 -1
  32. package/dist/commands/machines/hosted/pause.js +1 -1
  33. package/dist/commands/machines/hosted/pull.js +1 -1
  34. package/dist/commands/machines/hosted/resume.js +1 -1
  35. package/dist/commands/machines/hosted/start.js +2 -2
  36. package/dist/commands/machines/hosted/status.js +2 -1
  37. package/dist/commands/tasks/create.js +1 -0
  38. package/dist/commands/tasks/list.js +1 -0
  39. package/dist/commands/tasks/update.js +1 -0
  40. package/dist/lib/agent-config.d.ts +2 -0
  41. package/dist/lib/agent-config.js +3 -1
  42. package/dist/lib/api-fetch.js +19 -0
  43. package/dist/lib/auth-storage.d.ts +14 -0
  44. package/dist/lib/auth-storage.js +14 -0
  45. package/dist/lib/commitments.d.ts +17 -2
  46. package/dist/lib/commitments.js +114 -0
  47. package/dist/lib/daemon-installer.d.ts +6 -5
  48. package/dist/lib/daemon-installer.js +4 -17
  49. package/dist/lib/daemonBroker.d.ts +120 -31
  50. package/dist/lib/daemonBroker.js +313 -20
  51. package/dist/lib/dedicated-lease-command.d.ts +14 -0
  52. package/dist/lib/dedicated-lease-command.js +30 -1
  53. package/dist/lib/dedicated-machines.js +8 -25
  54. package/dist/lib/dedicated-service-command.d.ts +11 -3
  55. package/dist/lib/dedicated-service-command.js +20 -3
  56. package/dist/lib/dedicated-service.d.ts +46 -0
  57. package/dist/lib/dedicated-service.js +85 -7
  58. package/dist/lib/dedicated-terminal.d.ts +21 -0
  59. package/dist/lib/dedicated-terminal.js +104 -9
  60. package/dist/lib/first-party-harness-broker.d.ts +10 -0
  61. package/dist/lib/first-party-harness-broker.js +9 -0
  62. package/dist/lib/first-party-harness-doctor.js +41 -1
  63. package/dist/lib/first-party-harness-managed.d.ts +4 -3
  64. package/dist/lib/first-party-harness-project-trust.d.ts +125 -0
  65. package/dist/lib/first-party-harness-project-trust.js +364 -0
  66. package/dist/lib/first-party-harness.d.ts +9 -2
  67. package/dist/lib/first-party-harness.js +6 -5
  68. package/dist/lib/hosted-machines.d.ts +10 -1
  69. package/dist/lib/hosted-machines.js +36 -2
  70. package/dist/lib/login.js +16 -0
  71. package/dist/lib/machine-spend-cap.d.ts +23 -0
  72. package/dist/lib/machine-spend-cap.js +68 -0
  73. package/dist/lib/node-adapter.js +15 -3
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +90 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +113 -0
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +47 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +69 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.d.ts +109 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +171 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.d.ts +1 -1
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.js +1 -1
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +4 -2
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +8 -3
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +9 -4
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +8 -9
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refreshClassification.d.ts +6 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refreshClassification.js +32 -9
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.d.ts +38 -0
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/releaseManifest.js +66 -1
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/sessionPermissionAuthority.d.ts +81 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/sessionPermissionAuthority.js +87 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +6 -1
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +90 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +105 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +47 -0
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +66 -0
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.d.ts +109 -0
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +164 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.d.ts +1 -1
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.js +1 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +8 -3
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +9 -4
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +8 -9
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refreshClassification.d.ts +6 -0
  108. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refreshClassification.js +31 -9
  109. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.d.ts +38 -0
  110. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/releaseManifest.js +64 -0
  111. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/sessionPermissionAuthority.d.ts +81 -0
  112. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/sessionPermissionAuthority.js +80 -0
  113. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +6 -1
  114. package/dist/node_modules/@skrr-ai/auth-core/package.json +41 -1
  115. package/dist/node_modules/@skrr-ai/data-provider/index.js +3413 -3343
  116. package/oclif.manifest.json +8930 -8466
  117. package/package.json +4 -1
@@ -80,11 +80,18 @@ exports.resolveCliHandoffCandidatePaths = resolveCliHandoffCandidatePaths;
80
80
  exports.findLiveDaemonBootstrap = findLiveDaemonBootstrap;
81
81
  exports.readBootstrap = readBootstrap;
82
82
  exports.attemptDaemonBrokerLogin = attemptDaemonBrokerLogin;
83
+ exports.isBrokeredHandoffToken = isBrokeredHandoffToken;
84
+ exports.redeemBrokeredHandoffToken = redeemBrokeredHandoffToken;
85
+ exports.__resetBrokeredHandoffForTest = __resetBrokeredHandoffForTest;
86
+ exports.findBrokeredHandoffDescriptor = findBrokeredHandoffDescriptor;
87
+ exports.maybeRetireStoredCliAuthFile = maybeRetireStoredCliAuthFile;
83
88
  exports.attemptDaemonBrokerLoginAndPersist = attemptDaemonBrokerLoginAndPersist;
84
89
  exports.maybeAutoBroker = maybeAutoBroker;
85
90
  const fs = __importStar(require("node:fs"));
86
91
  const path = __importStar(require("node:path"));
87
92
  const os = __importStar(require("node:os"));
93
+ const auth_core_1 = require("@skrr-ai/auth-core");
94
+ const cli_handoff_wire_1 = require("@skrr-ai/auth-core/cli-handoff-wire");
88
95
  const auth_storage_1 = require("./auth-storage");
89
96
  const config_1 = require("./config");
90
97
  const device_id_1 = require("./device-id");
@@ -233,19 +240,25 @@ function readBootstrap(filePath) {
233
240
  catch {
234
241
  return null;
235
242
  }
236
- let parsed;
243
+ let parsedJson;
237
244
  try {
238
- parsed = JSON.parse(raw);
245
+ parsedJson = JSON.parse(raw);
239
246
  }
240
247
  catch {
241
248
  return null;
242
249
  }
243
- if (parsed?.host !== '127.0.0.1' ||
244
- typeof parsed?.port !== 'number' ||
245
- !Number.isInteger(parsed.port) ||
246
- parsed.port < 1 ||
250
+ // Shared wire parser (`@skrr-ai/auth-core/cli-handoff-wire`) validates the
251
+ // fields a caller cannot proceed without — host, port, secret — and
252
+ // tolerates unknown fields and future `version` values. The CLI's own
253
+ // checks below stay STRICTER on purpose: the broker is loopback-only, the
254
+ // port must be a real TCP port, and the secret is a fixed-width key.
255
+ const parsed = (0, cli_handoff_wire_1.parseCliHandoffDescriptor)(parsedJson);
256
+ if (!parsed)
257
+ return null;
258
+ if (parsed.host !== '127.0.0.1' ||
259
+ typeof parsed.port !== 'number' ||
247
260
  parsed.port > 65_535 ||
248
- typeof parsed?.secret !== 'string' ||
261
+ typeof parsed.secret !== 'string' ||
249
262
  Buffer.from(parsed.secret, 'base64').length !== 32) {
250
263
  return null;
251
264
  }
@@ -283,6 +296,8 @@ async function attemptDaemonBrokerLogin(opts) {
283
296
  : resolveCliHandoffCandidatePaths(profile);
284
297
  // Bound candidates (their serverUrl matches this CLI) in order, then unbound
285
298
  // ones. Every usable candidate is kept, not just the first: see below.
299
+ // `sourcePath` rides along — the brokered-migration cleanup keys on whether
300
+ // the redeeming descriptor IS the Dedicated guest hand-off file.
286
301
  const usable = [];
287
302
  const unbound = [];
288
303
  let mismatchedBootstrap = null;
@@ -300,10 +315,10 @@ async function attemptDaemonBrokerLogin(opts) {
300
315
  }
301
316
  if (typeof candidateBootstrap.serverUrl !== 'string' ||
302
317
  candidateBootstrap.serverUrl.length === 0) {
303
- unbound.push(candidateBootstrap);
318
+ unbound.push({ bootstrap: candidateBootstrap, sourcePath: candidate });
304
319
  continue;
305
320
  }
306
- usable.push(candidateBootstrap);
321
+ usable.push({ bootstrap: candidateBootstrap, sourcePath: candidate });
307
322
  }
308
323
  usable.push(...unbound);
309
324
  if (usable.length === 0) {
@@ -329,14 +344,153 @@ async function attemptDaemonBrokerLogin(opts) {
329
344
  // in". Only a refused connection falls through: any answer, or a timeout, is a
330
345
  // daemon that is really there, and its outcome stands.
331
346
  let outcome = null;
332
- for (const bootstrap of usable) {
333
- outcome = await brokerThrough(bootstrap, opts);
347
+ for (const { bootstrap, sourcePath } of usable) {
348
+ outcome = await brokerThrough(bootstrap, opts, sourcePath);
334
349
  if (outcome.ok || outcome.reason !== 'network')
335
350
  return outcome;
336
351
  }
337
352
  return outcome;
338
353
  }
339
- async function brokerThrough(bootstrap, opts) {
354
+ async function brokerThrough(bootstrap, opts, sourcePath) {
355
+ // Mode negotiation (descriptor v2 `handoffModes`). A v1 descriptor advertises
356
+ // nothing and reads as `['refresh_family']` — today's behaviour, unchanged.
357
+ //
358
+ // The token route is attempted when the descriptor offers `access_token` AND
359
+ // either the caller prefers broker mode (auto-broker) or the durable mint is
360
+ // not offered at all (the Dedicated guest shape — its hand-off-only secret
361
+ // cannot mint a family, so the token route is the only door).
362
+ const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
363
+ const offersAccessToken = modes.includes('access_token');
364
+ const offersRefreshFamily = modes.includes('refresh_family');
365
+ const preferAccessToken = opts.preferHandoffMode === 'access_token';
366
+ if (offersAccessToken && (preferAccessToken || !offersRefreshFamily)) {
367
+ const redeem = await redeemCliHandoffToken(bootstrap, {
368
+ forceRefresh: false,
369
+ timeoutMs: opts.timeoutMs,
370
+ });
371
+ if (redeem.ok) {
372
+ const response = redeem.body;
373
+ registerBrokeredHandoff(bootstrap, response.accessToken, sourcePath);
374
+ if (sourcePath === exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR) {
375
+ // A guest descriptor's secret can ONLY redeem access tokens, so a
376
+ // stored cli-auth.json on this machine predates broker mode. Retire
377
+ // the old family (best-effort revoke + delete); see the helper for
378
+ // the provenance guard.
379
+ await maybeRetireStoredCliAuthFile({
380
+ descriptorPath: sourcePath,
381
+ descriptorServerUrl: bootstrap.serverUrl ?? response.serverUrl,
382
+ });
383
+ }
384
+ return {
385
+ ok: true,
386
+ brokered: true,
387
+ accessToken: response.accessToken,
388
+ ...(response.accessExpiresAt !== undefined
389
+ ? { accessExpiresAt: response.accessExpiresAt }
390
+ : {}),
391
+ ...((response.serverUrl ?? bootstrap.serverUrl)
392
+ ? { serverUrl: response.serverUrl ?? bootstrap.serverUrl }
393
+ : {}),
394
+ };
395
+ }
396
+ // The descriptor ALSO advertises the durable mint (a laptop island during
397
+ // transition): any redeem failure — refusal, network, outage — falls
398
+ // through to the legacy route below. When refresh_family is NOT offered,
399
+ // the redeem outcome is final: the legacy route would only answer
400
+ // 403 HANDOFF_BROKER_ONLY anyway.
401
+ if (!offersRefreshFamily)
402
+ return redeem;
403
+ }
404
+ return mintRefreshFamilyThrough(bootstrap, opts);
405
+ }
406
+ /**
407
+ * Broker-mode redeem — `POST /v1/auth/cli-handoff-token` with the
408
+ * descriptor's secret as bearer. Returns the parsed token response or a
409
+ * `DaemonBrokerFailure` the caller can return verbatim / fall through from.
410
+ */
411
+ async function redeemCliHandoffToken(bootstrap, opts = {}) {
412
+ const url = `http://${bootstrap.host}:${bootstrap.port}${cli_handoff_wire_1.CLI_HANDOFF_TOKEN_PATH}`;
413
+ const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
414
+ const controller = new AbortController();
415
+ const abortTimer = setTimeout(() => controller.abort(), timeoutMs);
416
+ let response;
417
+ try {
418
+ response = await fetch(url, {
419
+ method: 'POST',
420
+ headers: {
421
+ Authorization: `Bearer ${bootstrap.secret}`,
422
+ 'Content-Type': 'application/json',
423
+ Accept: 'application/json',
424
+ },
425
+ // No cliId — the family is daemon-owned; the caller is not enrolling a
426
+ // device. forceRefresh bypasses the daemon's cached access token (the
427
+ // 401-recovery path).
428
+ body: JSON.stringify({ forceRefresh: opts.forceRefresh === true }),
429
+ signal: controller.signal,
430
+ });
431
+ }
432
+ catch (err) {
433
+ const msg = err instanceof Error ? err.message : String(err);
434
+ if (err.name === 'AbortError') {
435
+ return {
436
+ ok: false,
437
+ reason: 'timeout',
438
+ detail: `Handoff token redeem timed out after ${timeoutMs}ms`,
439
+ };
440
+ }
441
+ return { ok: false, reason: 'network', detail: msg };
442
+ }
443
+ finally {
444
+ clearTimeout(abortTimer);
445
+ }
446
+ let parsedBody = null;
447
+ try {
448
+ parsedBody = (await response.json());
449
+ }
450
+ catch {
451
+ parsedBody = null;
452
+ }
453
+ if (!response.ok) {
454
+ const code = typeof parsedBody?.code === 'string' ? parsedBody.code : undefined;
455
+ const message = typeof parsedBody?.message === 'string'
456
+ ? parsedBody.message
457
+ : `Handoff token route returned HTTP ${response.status}`;
458
+ if (response.status === 401 || response.status === 403) {
459
+ // The daemon refused the descriptor secret outright — the descriptor is
460
+ // stale (secret rotated on daemon restart) or was never valid for this
461
+ // route.
462
+ return {
463
+ ok: false,
464
+ reason: 'stale_bootstrap',
465
+ detail: message,
466
+ status: response.status,
467
+ ...(code ? { code } : {}),
468
+ };
469
+ }
470
+ return {
471
+ ok: false,
472
+ // 503 HANDOFF_TOKEN_UNAVAILABLE lands in server_error with its code
473
+ // preserved — the daemon is alive but cannot mint (no daemon bearer,
474
+ // upstream unreachable, mint cool-down). That is a daemon outage, not a
475
+ // decline, and the code keeps it diagnosable.
476
+ reason: response.status >= 500 ? 'server_error' : 'unknown',
477
+ detail: message,
478
+ status: response.status,
479
+ ...(code ? { code } : {}),
480
+ };
481
+ }
482
+ const body = (0, cli_handoff_wire_1.parseCliHandoffTokenResponse)(parsedBody);
483
+ if (!body) {
484
+ return {
485
+ ok: false,
486
+ reason: 'server_error',
487
+ detail: 'Handoff token route returned 200 but the response carried no access token',
488
+ status: response.status,
489
+ };
490
+ }
491
+ return { ok: true, body };
492
+ }
493
+ async function mintRefreshFamilyThrough(bootstrap, opts) {
340
494
  const url = `http://${bootstrap.host}:${bootstrap.port}/v1/auth/cli-handoff`;
341
495
  const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
342
496
  const controller = new AbortController();
@@ -389,6 +543,20 @@ async function brokerThrough(bootstrap, opts) {
389
543
  if (response.status === 403 && code === 'CI_TOKEN_REFUSED') {
390
544
  return { ok: false, reason: 'ci_token_refused', detail: message, status: 403, code };
391
545
  }
546
+ if (response.status === 403 && code === cli_handoff_wire_1.CLI_HANDOFF_ERROR_BROKER_ONLY) {
547
+ // The daemon refuses to mint a refresh family for this secret — the
548
+ // descriptor offers `access_token`, and broker mode is the only door.
549
+ // Terminal (not a next-candidate fall-through): every descriptor for
550
+ // this daemon answers the same way, and the caller's remedy is the
551
+ // token route or an explicit `skrr login`, not another file.
552
+ return {
553
+ ok: false,
554
+ reason: 'handoff_broker_only',
555
+ detail: message,
556
+ status: 403,
557
+ code,
558
+ };
559
+ }
392
560
  if (response.status === 503 && code === 'DAEMON_NOT_AUTHED') {
393
561
  return { ok: false, reason: 'daemon_not_authed', detail: message, status: 503, code };
394
562
  }
@@ -457,6 +625,116 @@ async function brokerThrough(bootstrap, opts) {
457
625
  ...(typeof parsedBody.familyId === 'string' ? { familyId: parsedBody.familyId } : {}),
458
626
  };
459
627
  }
628
+ // ---------------------------------------------------------------------
629
+ // Brokered (access-token) hand-off — process-local credential state
630
+ // ---------------------------------------------------------------------
631
+ /**
632
+ * The descriptor this process's brokered credential was redeemed from, and
633
+ * the token it answered. Module-scoped on purpose: broker mode persists
634
+ * NOTHING, so the only record of "the credential in use came from a hand-off
635
+ * redeem" lives here, for the 401 re-redeem path in `api-fetch`
636
+ * (`forceRefresh: true`, retry once). Each new `skrr` process starts empty
637
+ * and re-redeems on entry via `maybeAutoBroker`.
638
+ */
639
+ let _brokeredHandoff = null;
640
+ function registerBrokeredHandoff(descriptor, accessToken, sourcePath) {
641
+ _brokeredHandoff = { descriptor, accessToken, ...(sourcePath ? { sourcePath } : {}) };
642
+ }
643
+ /**
644
+ * True when `token` is the access token this process redeemed through the
645
+ * daemon's broker-mode hand-off. `api-fetch` uses this to pick the re-redeem
646
+ * recovery branch instead of the stored-family refresh (which would have
647
+ * nothing to rotate — brokered credentials are never persisted).
648
+ */
649
+ function isBrokeredHandoffToken(token) {
650
+ return Boolean(token && _brokeredHandoff && token === _brokeredHandoff.accessToken);
651
+ }
652
+ /**
653
+ * Re-redeem the brokered hand-off token — the 401-recovery path. The
654
+ * descriptor is re-read from disk when its source path is known, so a secret
655
+ * rotated by a daemon restart resolves to the fresh one. Returns null when
656
+ * this process holds no brokered credential or the redeem fails (the caller
657
+ * then surfaces the original 401).
658
+ */
659
+ async function redeemBrokeredHandoffToken(forceRefresh = true) {
660
+ const state = _brokeredHandoff;
661
+ if (!state)
662
+ return null;
663
+ const descriptor = (state.sourcePath ? readBootstrap(state.sourcePath) : null) ?? state.descriptor;
664
+ const result = await redeemCliHandoffToken(descriptor, { forceRefresh });
665
+ if (!result.ok)
666
+ return null;
667
+ _brokeredHandoff = { ...state, descriptor, accessToken: result.body.accessToken };
668
+ return result.body;
669
+ }
670
+ /** @internal test seam — clears the module-scoped brokered credential. */
671
+ function __resetBrokeredHandoffForTest() {
672
+ _brokeredHandoff = null;
673
+ }
674
+ /**
675
+ * True when a live local descriptor advertises broker-mode access — i.e.
676
+ * this machine's CLI authority is platform-managed and `skrr logout` has no
677
+ * local credential to clear. Read-only: parses candidates, never redeems.
678
+ */
679
+ function findBrokeredHandoffDescriptor(profile = 'default') {
680
+ for (const candidate of resolveCliHandoffCandidatePaths(profile)) {
681
+ const bootstrap = readBootstrap(candidate);
682
+ if (bootstrap && (0, cli_handoff_wire_1.descriptorOffersAccessToken)(bootstrap))
683
+ return bootstrap;
684
+ }
685
+ return null;
686
+ }
687
+ /**
688
+ * Migration cleanup: retire the refresh family a pre-broker-mode CLI
689
+ * generation persisted, now that this machine redeems access tokens from
690
+ * the daemon's in-memory broker.
691
+ *
692
+ * Provenance guard — locally we cannot tell a hand-off-minted family from a
693
+ * user's explicit `skrr login` family by shape (the distinguishing
694
+ * `daemonRefOverride` lives server-side). The safe rule, applied here:
695
+ * revoke + delete ONLY when all of these hold —
696
+ *
697
+ * 1. the descriptor that just redeemed IS the Dedicated Runtime guest
698
+ * hand-off file (`/run/skrr-dedicated-runtime/...`),
699
+ * 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
700
+ * 3. its stored `serverOrigin` matches the descriptor's `serverUrl`.
701
+ *
702
+ * When provenance can't be proven the file is KEPT: an explicit `skrr login`
703
+ * on a guest is rare but real, and a stale extra family is bounded by the
704
+ * server's session cap and expires on its own. Best-effort throughout — a
705
+ * failed revoke still deletes the file (the whole point is that nothing
706
+ * credential-shaped should remain), and any failure leaves the working
707
+ * brokered credential untouched.
708
+ */
709
+ async function maybeRetireStoredCliAuthFile(opts) {
710
+ if (opts.descriptorPath !== exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR)
711
+ return;
712
+ let stored;
713
+ try {
714
+ stored = (0, auth_storage_1.readFromFile)();
715
+ }
716
+ catch {
717
+ return;
718
+ }
719
+ if (!stored?.refreshToken)
720
+ return;
721
+ const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
722
+ const descriptorOrigin = opts.descriptorServerUrl
723
+ ? (0, auth_storage_1.normalizeServerOrigin)(opts.descriptorServerUrl)
724
+ : null;
725
+ if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
726
+ return;
727
+ // The revoke is authenticated by the refresh token itself — presenting it
728
+ // is the proof of ownership. `revokeDaemonRefreshSession` swallows its own
729
+ // network/HTTP failures (best-effort by contract); the delete still runs.
730
+ try {
731
+ await (0, auth_core_1.revokeDaemonRefreshSession)(descriptorOrigin, stored.refreshToken);
732
+ }
733
+ catch {
734
+ /* best-effort — delete proceeds regardless */
735
+ }
736
+ (0, auth_storage_1.deleteFileBackend)();
737
+ }
460
738
  /** Bound the confirm so a slow server cannot stall a login that already worked. */
461
739
  const CONFIRM_TIMEOUT_MS = 10_000;
462
740
  /**
@@ -514,15 +792,22 @@ function expiryToEpochMs(v) {
514
792
  return undefined;
515
793
  }
516
794
  /**
517
- * Convenience wrapper: run the broker call and, on success, persist the
518
- * returned bundle through the canonical Sky CLI auth backend (Keychain
519
- * on macOS, `cli-auth.json` elsewhere). Returns the same outcome shape
520
- * so the caller can branch on `ok`.
795
+ * Convenience wrapper: run the broker call and, on a durable-mint success,
796
+ * persist the returned bundle through the canonical Sky CLI auth backend
797
+ * (Keychain on macOS, `cli-auth.json` elsewhere). A `brokered` outcome is
798
+ * returned WITHOUT persisting — that is the entire point of broker mode.
799
+ * Returns the same outcome shape so the caller can branch on `ok`/`brokered`.
521
800
  */
522
801
  async function attemptDaemonBrokerLoginAndPersist(opts) {
523
802
  const outcome = await attemptDaemonBrokerLogin(opts);
524
803
  if (!outcome.ok)
525
804
  return outcome;
805
+ // Brokered hand-off: a daemon-owned access token. The persistence policy
806
+ // IS the security property here — nothing credential-shaped lands on disk,
807
+ // so writeToBackend is never called and there is no family to confirm.
808
+ // Callers use the token in-memory for this process only.
809
+ if (outcome.brokered)
810
+ return outcome;
526
811
  const serverOrigin = normalizeBaseUrl(opts.baseURL);
527
812
  if (!serverOrigin) {
528
813
  throw new Error('Daemon-broker credential persistence requires a valid CLI baseURL.');
@@ -586,10 +871,12 @@ const AUTH_SELF_MANAGED_COMMANDS = new Set([
586
871
  * - `commandId` is in `AUTH_SELF_MANAGED_COMMANDS`
587
872
  *
588
873
  * Side effects:
589
- * - Calls `attemptDaemonBrokerLoginAndPersist` which writes to the
590
- * keychain / cli-auth.json on success (this is the whole point —
591
- * subsequent commands read the persisted token via the normal
592
- * resolver chain, no broker round-trip per command).
874
+ * - Calls `attemptDaemonBrokerLoginAndPersist` with `preferHandoffMode:
875
+ * 'access_token'`. On a descriptor that advertises broker mode the
876
+ * outcome is `brokered` — an in-memory access token, nothing written;
877
+ * the caller holds it for this process and the next `skrr` re-redeems.
878
+ * On a legacy/v1 descriptor the durable mint is persisted to the
879
+ * keychain / cli-auth.json exactly as before.
593
880
  * - May generate a new `cliId` if the config doesn't have one. The
594
881
  * caller MUST persist `updatedConfig` so the next invocation
595
882
  * presents the same id to the server's refresh rotator.
@@ -618,6 +905,12 @@ async function maybeAutoBroker(opts) {
618
905
  const outcome = await attemptDaemonBrokerLoginAndPersist({
619
906
  cliId,
620
907
  baseURL: opts.cliConfig.baseURL,
908
+ // Auto-broker prefers the brokered access-token mode when the descriptor
909
+ // advertises it: nothing credential-shaped is persisted, and the next
910
+ // `skrr` process simply re-redeems over loopback. The legacy refresh-
911
+ // family mint remains the fallback for v1 descriptors and the redeem
912
+ // path for explicit `skrr login`.
913
+ preferHandoffMode: 'access_token',
621
914
  ...(opts.bootstrapPathOverride ? { bootstrapPathOverride: opts.bootstrapPathOverride } : {}),
622
915
  });
623
916
  return {
@@ -38,10 +38,24 @@ export declare abstract class DedicatedLeaseCommand extends BaseCommand {
38
38
  * machine, a daemon that is not connected, stdin that is not a terminal — so
39
39
  * the caller gets a sentence that says what to do instead of a start that
40
40
  * hangs and times out.
41
+ *
42
+ * Sessions are persistent by default (always-on §6.4): the daemon runs the
43
+ * shell inside tmux, so Ctrl-] and a lost connection only detach and the
44
+ * same command re-attaches the same shell. `--ephemeral` asks for the old
45
+ * bare-PTY semantics, and an image without `persistent_terminal_v1` gets
46
+ * them too — the relay forwards `persistent` as claimed and an old daemon
47
+ * silently ignores it, so the capability is checked here and the degraded
48
+ * start is announced rather than presented as resumable.
41
49
  */
42
50
  protected openDedicatedTerminal(leaseId: string, flags: {
43
51
  workspace?: string;
44
52
  cwd?: string;
53
+ /** Named session to attach-or-create (default 'main'); ignored with --new. */
54
+ name?: string;
55
+ /** A fresh session the machine names (term-N), printed once the ack reports it. */
56
+ new?: boolean;
57
+ /** The pre-persistent semantics: a bare PTY that ends with the connection. */
58
+ ephemeral?: boolean;
45
59
  }, initialCommand?: string): Promise<void>;
46
60
  /**
47
61
  * Run an API call, turning a refusal into the server's own sentence.
@@ -8,6 +8,7 @@ const base_command_1 = require("../base-command");
8
8
  const api_fetch_1 = require("./api-fetch");
9
9
  const dedicated_machines_1 = require("./dedicated-machines");
10
10
  const dedicated_terminal_1 = require("./dedicated-terminal");
11
+ const dedicated_service_1 = require("./dedicated-service");
11
12
  const daemonBroker_1 = require("./daemonBroker");
12
13
  const harnesses_1 = require("./harnesses");
13
14
  const dedicated_wait_1 = require("./dedicated-wait");
@@ -72,6 +73,14 @@ class DedicatedLeaseCommand extends base_command_1.BaseCommand {
72
73
  * machine, a daemon that is not connected, stdin that is not a terminal — so
73
74
  * the caller gets a sentence that says what to do instead of a start that
74
75
  * hangs and times out.
76
+ *
77
+ * Sessions are persistent by default (always-on §6.4): the daemon runs the
78
+ * shell inside tmux, so Ctrl-] and a lost connection only detach and the
79
+ * same command re-attaches the same shell. `--ephemeral` asks for the old
80
+ * bare-PTY semantics, and an image without `persistent_terminal_v1` gets
81
+ * them too — the relay forwards `persistent` as claimed and an old daemon
82
+ * silently ignores it, so the capability is checked here and the degraded
83
+ * start is announced rather than presented as resumable.
75
84
  */
76
85
  async openDedicatedTerminal(leaseId, flags, initialCommand) {
77
86
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -92,7 +101,24 @@ class DedicatedLeaseCommand extends base_command_1.BaseCommand {
92
101
  if (!harness?.daemonId) {
93
102
  this.error(`${leaseId} is ${state}, but its daemon is not connected yet. Wait a moment, or run \`${this.config.bin} machines dedicated health-check ${leaseId}\`.`, { exit: 1 });
94
103
  }
95
- process.stderr.write(`Connected to ${(0, dedicated_machines_1.dedicatedLeaseDisplayName)(lease)} (${leaseId}) as skrr-workload. Ctrl-] closes the terminal.\n`);
104
+ const capabilities = Array.isArray(harness.metadata?.capabilities)
105
+ ? harness.metadata.capabilities.filter((capability) => typeof capability === 'string')
106
+ : [];
107
+ const supportsPersistent = capabilities.includes(dedicated_service_1.DEDICATED_PERSISTENT_TERMINAL_CAPABILITY);
108
+ const persistent = flags.ephemeral !== true && supportsPersistent;
109
+ // `terminal` with no name means "the machine's terminal" — one stable
110
+ // session, attach-or-create, because a CLI has no picker to resume from.
111
+ // `--new` sends no name and the daemon allocates the next `term-N`, the
112
+ // same semantics the web picker's "New terminal" button has.
113
+ const sessionName = persistent && flags.new !== true ? (flags.name ?? 'main') : undefined;
114
+ if (flags.ephemeral !== true && !supportsPersistent) {
115
+ process.stderr.write(`${(0, dedicated_machines_1.dedicatedLeaseDisplayName)(lease)}'s image does not support persistent terminals ` +
116
+ `(${dedicated_service_1.DEDICATED_PERSISTENT_TERMINAL_CAPABILITY}), so this shell ends with the connection. ` +
117
+ `\`${this.config.bin} machines dedicated update-image ${leaseId}\` upgrades the machine.\n`);
118
+ }
119
+ process.stderr.write(persistent
120
+ ? `Connected to ${(0, dedicated_machines_1.dedicatedLeaseDisplayName)(lease)} (${leaseId}) as skrr-workload. Ctrl-] detaches; the session keeps running on the machine.\n`
121
+ : `Connected to ${(0, dedicated_machines_1.dedicatedLeaseDisplayName)(lease)} (${leaseId}) as skrr-workload. Ctrl-] closes the terminal.\n`);
96
122
  let code;
97
123
  try {
98
124
  code = await (0, dedicated_terminal_1.runDedicatedTerminal)({
@@ -102,6 +128,9 @@ class DedicatedLeaseCommand extends base_command_1.BaseCommand {
102
128
  daemonId: harness.daemonId,
103
129
  ...(flags.cwd ? { cwd: flags.cwd } : {}),
104
130
  ...(initialCommand ? { initialCommand } : {}),
131
+ persistent,
132
+ ...(sessionName ? { sessionName } : {}),
133
+ leaseId,
105
134
  input: process.stdin,
106
135
  output: process.stdout,
107
136
  errorOutput: process.stderr,
@@ -50,6 +50,7 @@ exports.formatDedicatedRuntimeApiError = formatDedicatedRuntimeApiError;
50
50
  const auth_core_1 = require("@skrr-ai/auth-core");
51
51
  const data_provider_1 = require("@skrr-ai/data-provider");
52
52
  const api_fetch_1 = require("./api-fetch");
53
+ const machine_spend_cap_1 = require("./machine-spend-cap");
53
54
  const edge_error_page_1 = require("./edge-error-page");
54
55
  const harnesses_1 = require("./harnesses");
55
56
  exports.DEDICATED_LEASE_ACTIONS = [
@@ -1185,7 +1186,7 @@ function dedicatedRuntimeApiErrorDetails(err) {
1185
1186
  */
1186
1187
  function describeRefusalReasons(err, context = {}) {
1187
1188
  const details = dedicatedRuntimeApiErrorDetails(err);
1188
- const spend = describeSpendingCapRefusal(err.body, details, context);
1189
+ const spend = describeSpendingCapRefusal(err.body, context);
1189
1190
  if (spend)
1190
1191
  return spend;
1191
1192
  const sentences = (0, harnesses_1.describeHealthReasonSentences)(details?.reasons, details?.state);
@@ -1197,31 +1198,13 @@ function describeRefusalReasons(err, context = {}) {
1197
1198
  * the cap. Without them "would exceed the monthly cap" left a user guessing by
1198
1199
  * how much, and which cap (OSK-9552).
1199
1200
  */
1200
- function describeSpendingCapRefusal(body, details, context = {}) {
1201
- let code;
1202
- try {
1203
- code = JSON.parse(body || '{}').code;
1204
- }
1205
- catch {
1206
- return '';
1207
- }
1208
- if (code !== 'RUNTIME_SPENDING_LIMIT_EXCEEDED' || !details)
1209
- return '';
1210
- const cents = (key) => {
1211
- const value = details[key];
1212
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
1213
- };
1214
- const limit = cents('limitCents');
1215
- const spent = cents('currentSpendCents');
1216
- const projected = cents('projectedCents');
1217
- const parts = [
1218
- limit !== null ? `Cap ${formatUsdCents(limit)}` : '',
1219
- spent !== null ? `spent this month ${formatUsdCents(spent)}` : '',
1220
- projected !== null ? `this would bring it to ${formatUsdCents(projected)}` : '',
1221
- ].filter(Boolean);
1222
- if (parts.length === 0)
1201
+ function describeSpendingCapRefusal(body, context = {}) {
1202
+ // Shared with the hosted CLI (machine-spend-cap.ts), which printed the bare
1203
+ // sentence until OSK-11946.
1204
+ const figures = (0, machine_spend_cap_1.describeMonthlyCapFigures)(body);
1205
+ if (!figures)
1223
1206
  return '';
1224
- return ` ${parts.join('; ')}. ${raiseCapHint(context)}`;
1207
+ return ` ${figures}. ${raiseCapHint(context)}`;
1225
1208
  }
1226
1209
  /**
1227
1210
  * How to get past a cap refusal, for the command that was refused. A create or
@@ -1,5 +1,5 @@
1
1
  import { DedicatedLeaseCommand } from './dedicated-lease-command';
2
- import { DedicatedServiceChannel, type ServiceVerb } from './dedicated-service';
2
+ import { DedicatedServiceChannel, type ServiceVerb, type TerminalVerb } from './dedicated-service';
3
3
  /**
4
4
  * The flag set `service create`/`apply` and guest `service propose` share —
5
5
  * one definition so the three surfaces cannot drift apart on what a spec
@@ -40,6 +40,12 @@ export declare abstract class DedicatedServiceCommand extends DedicatedLeaseComm
40
40
  protected serviceChannel(): Promise<DedicatedServiceChannel>;
41
41
  /** Emit one verb and return the daemon's `result`, or throw as a CLI error. */
42
42
  protected callService<T = unknown>(verb: ServiceVerb, payload: Record<string, unknown>, timeoutMs?: number): Promise<T | undefined>;
43
+ /**
44
+ * Emit one persistent-terminal verb (`dedicated:terminal:list|kill|rename`)
45
+ * on the same admitted channel the service verbs ride — the relay answers
46
+ * `dedicated:terminal:ack` with the same requestId discipline.
47
+ */
48
+ protected callDedicatedTerminal<T = unknown>(verb: TerminalVerb, payload: Record<string, unknown>, timeoutMs?: number): Promise<T | undefined>;
43
49
  /**
44
50
  * `start|stop|restart|rm` — the shared `dedicated:service:action` round
45
51
  * trip and its printout. `rm` maps to the `remove` action on the wire (the
@@ -54,7 +60,9 @@ export declare abstract class DedicatedServiceCommand extends DedicatedLeaseComm
54
60
  /**
55
61
  * Turn a service-lane failure into the command's exit — the refusal
56
62
  * sentence the relay sent, or the clean capability-missing path the SSH
57
- * lane established for a missing `sshBridge`.
63
+ * lane established for a missing `sshBridge`. `unsupportedCode` is the
64
+ * lane's own code a `--json` error reports; the terminal lane passes its
65
+ * own so a script can tell "no persistent terminals" from "no services".
58
66
  */
59
- protected reportServiceError(err: unknown): never;
67
+ protected reportServiceError(err: unknown, unsupportedCode?: string): never;
60
68
  }
@@ -94,6 +94,21 @@ class DedicatedServiceCommand extends dedicated_lease_command_1.DedicatedLeaseCo
94
94
  this.reportServiceError(err);
95
95
  }
96
96
  }
97
+ /**
98
+ * Emit one persistent-terminal verb (`dedicated:terminal:list|kill|rename`)
99
+ * on the same admitted channel the service verbs ride — the relay answers
100
+ * `dedicated:terminal:ack` with the same requestId discipline.
101
+ */
102
+ async callDedicatedTerminal(verb, payload, timeoutMs) {
103
+ try {
104
+ const channel = await this.serviceChannel();
105
+ const { result } = await channel.callTerminal(verb, payload, timeoutMs);
106
+ return result;
107
+ }
108
+ catch (err) {
109
+ this.reportServiceError(err, 'DEDICATED_RUNTIME_TERMINAL_UNSUPPORTED');
110
+ }
111
+ }
97
112
  /**
98
113
  * `start|stop|restart|rm` — the shared `dedicated:service:action` round
99
114
  * trip and its printout. `rm` maps to the `remove` action on the wire (the
@@ -143,14 +158,16 @@ class DedicatedServiceCommand extends dedicated_lease_command_1.DedicatedLeaseCo
143
158
  /**
144
159
  * Turn a service-lane failure into the command's exit — the refusal
145
160
  * sentence the relay sent, or the clean capability-missing path the SSH
146
- * lane established for a missing `sshBridge`.
161
+ * lane established for a missing `sshBridge`. `unsupportedCode` is the
162
+ * lane's own code a `--json` error reports; the terminal lane passes its
163
+ * own so a script can tell "no persistent terminals" from "no services".
147
164
  */
148
- reportServiceError(err) {
165
+ reportServiceError(err, unsupportedCode = 'DEDICATED_RUNTIME_SERVICES_UNSUPPORTED') {
149
166
  if (err instanceof dedicated_service_1.DedicatedServiceError) {
150
167
  if ((0, dedicated_service_1.isServiceUnsupportedCode)(err.code)) {
151
168
  this.failWithCliError({
152
169
  message: err.message,
153
- code: 'DEDICATED_RUNTIME_SERVICES_UNSUPPORTED',
170
+ code: unsupportedCode,
154
171
  exit: 1,
155
172
  retryable: false,
156
173
  });