@skrr-ai/cli 0.1.51 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/base-command.d.ts +7 -0
  2. package/dist/base-command.js +38 -8
  3. package/dist/commands/agents/create.js +8 -4
  4. package/dist/commands/agents/update.d.ts +23 -0
  5. package/dist/commands/agents/update.js +54 -9
  6. package/dist/commands/commitments/next-tick.js +9 -1
  7. package/dist/commands/context.js +2 -1
  8. package/dist/commands/daemon/config.js +16 -1
  9. package/dist/commands/doctor.js +8 -1
  10. package/dist/commands/login.d.ts +2 -1
  11. package/dist/commands/login.js +26 -7
  12. package/dist/commands/logout.js +3 -0
  13. package/dist/commands/machines/dedicated/show.js +25 -2
  14. package/dist/commands/tasks/create.d.ts +5 -0
  15. package/dist/commands/tasks/create.js +26 -6
  16. package/dist/commands/tasks/start.d.ts +14 -0
  17. package/dist/commands/tasks/start.js +31 -1
  18. package/dist/commands/tasks/update.js +8 -1
  19. package/dist/commands/whoami.js +5 -3
  20. package/dist/lib/agent-config.d.ts +2 -0
  21. package/dist/lib/agent-config.js +14 -10
  22. package/dist/lib/agentic-stream.d.ts +20 -0
  23. package/dist/lib/agentic-stream.js +39 -5
  24. package/dist/lib/auth-failure.js +4 -1
  25. package/dist/lib/auth-storage.d.ts +24 -0
  26. package/dist/lib/auth-storage.js +342 -11
  27. package/dist/lib/cli-id.d.ts +22 -0
  28. package/dist/lib/cli-id.js +56 -0
  29. package/dist/lib/cli-identity.d.ts +49 -0
  30. package/dist/lib/cli-identity.js +159 -0
  31. package/dist/lib/commitments.d.ts +4 -0
  32. package/dist/lib/commitments.js +12 -1
  33. package/dist/lib/config.d.ts +17 -0
  34. package/dist/lib/config.js +15 -3
  35. package/dist/lib/daemonBroker.d.ts +26 -4
  36. package/dist/lib/daemonBroker.js +82 -10
  37. package/dist/lib/daemonBrokerRefusal.d.ts +51 -0
  38. package/dist/lib/daemonBrokerRefusal.js +127 -0
  39. package/dist/lib/dedicated-machines.d.ts +33 -2
  40. package/dist/lib/dedicated-machines.js +50 -1
  41. package/dist/lib/delegated-cli.js +16 -0
  42. package/dist/lib/first-party-harness-broker.js +6 -4
  43. package/dist/lib/keychain.d.ts +39 -0
  44. package/dist/lib/keychain.js +228 -39
  45. package/dist/lib/label-ref.d.ts +34 -0
  46. package/dist/lib/label-ref.js +68 -1
  47. package/dist/lib/login.js +24 -12
  48. package/dist/lib/oauthLogin.js +2 -8
  49. package/dist/lib/refresh.js +6 -2
  50. package/dist/lib/tasks.js +33 -8
  51. package/dist/node_modules/@skrr-ai/data-provider/index.js +3138 -3100
  52. package/oclif.manifest.json +36364 -36355
  53. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.profileScopedCliId = profileScopedCliId;
40
+ exports.hasStoredCredential = hasStoredCredential;
41
+ exports.planLoginCliId = planLoginCliId;
42
+ exports.cliIdForLogin = cliIdForLogin;
43
+ exports.persistPresentedCliId = persistPresentedCliId;
44
+ /**
45
+ * cli-identity.ts — which cliId a LOGIN presents, and when a profile gets its own.
46
+ *
47
+ * Reading the identity is `resolveCliId()` in `config.ts` (it has to live there:
48
+ * `auth-storage.ts` judges credential ownership by it, and this module reads
49
+ * auth-storage). This module owns the one decision a read must never make —
50
+ * issuing an identity — and the one write that restores a backed-up pair.
51
+ *
52
+ * Why a profile needs its own (OSK-12156): the server's `cli-handoff/confirm`
53
+ * retires every OTHER live cli family of (user, cliId) once a hand-off lands.
54
+ * That is right for one config replacing its own previous session, and wrong
55
+ * when every profile on the machine presents the same machine-scoped `cliId`:
56
+ * profile B's daemon-brokered login then revoked profile A's session, silently.
57
+ * The server scoping is already per cliId, so the fix is the id, not the route.
58
+ *
59
+ * Why only a profile with NO stored credential: an existing session was minted
60
+ * under the machine id, and the refresh route refuses a rotation that presents
61
+ * another. Re-identifying such a profile would log it out on its next refresh —
62
+ * and a re-login under a new id would leave its old family live (the confirm
63
+ * only sweeps the id it was minted for) in a bucket the server evicts
64
+ * oldest-first. So a profile keeps presenting the id its credential is bound
65
+ * to, and only a profile starting from nothing is issued one. That is also why
66
+ * an upgrade forces no re-login: nothing that already works changes id.
67
+ *
68
+ * The default profile keeps the machine id. It IS the machine file, and it is
69
+ * the identity every pre-profile install already presents.
70
+ */
71
+ const node_crypto_1 = require("node:crypto");
72
+ const node_os_1 = __importDefault(require("node:os"));
73
+ const path = __importStar(require("node:path"));
74
+ const auth_storage_1 = require("./auth-storage");
75
+ const config_1 = require("./config");
76
+ const cli_id_1 = require("./cli-id");
77
+ const device_id_1 = require("./device-id");
78
+ const keychain_1 = require("./keychain");
79
+ /**
80
+ * A named profile's own identity. Deterministic in (host, user, config root,
81
+ * profile), so a profile that loses its config file and signs in again is
82
+ * issued the same id rather than scattering families, and two profiles — or the
83
+ * same profile name under two config roots — never share one. Same `cli_<12hex>`
84
+ * shape as the machine id: the server requires the `cli_` namespace.
85
+ */
86
+ function profileScopedCliId(profile = (0, keychain_1.getActiveProfile)(), root = (0, config_1.configRoot)()) {
87
+ const hostname = node_os_1.default.hostname();
88
+ const username = node_os_1.default.userInfo().username || 'unknown';
89
+ const digest = (0, node_crypto_1.createHash)('sha256')
90
+ .update(`${hostname}|${username}|cli|profile:${profile}|root:${path.resolve(root)}`)
91
+ .digest('hex');
92
+ return `cli_${digest.slice(0, 12)}`;
93
+ }
94
+ /**
95
+ * Does this profile hold a credential it would present? Its own backend slot
96
+ * (ownership-checked, any origin) or a pre-migration token still in the config.
97
+ *
98
+ * A read that FAILS answers true: "keep the id you already present" is the
99
+ * outcome that cannot log anyone out, so doubt falls on that side.
100
+ */
101
+ function hasStoredCredential(cfg) {
102
+ if (cfg.token || cfg.refreshToken)
103
+ return true;
104
+ try {
105
+ const read = (0, auth_storage_1.readFromBackend)();
106
+ return Boolean(read.bundle?.token || read.bundle?.refreshToken);
107
+ }
108
+ catch {
109
+ return true;
110
+ }
111
+ }
112
+ /**
113
+ * Decide the id a login is about to present. Pure apart from the credential
114
+ * read; the caller persists `updatedConfig` (see `cliIdForLogin`).
115
+ */
116
+ function planLoginCliId(cfg, deps = {}) {
117
+ const env = (0, cli_id_1.envCliIdOverride)();
118
+ if (env)
119
+ return { cliId: env, source: 'env' };
120
+ const issued = cfg.profileCliId?.trim();
121
+ if (issued)
122
+ return { cliId: issued, source: 'profile' };
123
+ if ((0, cli_id_1.isNamedProfile)() && !(deps.hasStoredCredential ?? hasStoredCredential)(cfg)) {
124
+ const cliId = profileScopedCliId();
125
+ return { cliId, source: 'issued', updatedConfig: { ...cfg, profileCliId: cliId } };
126
+ }
127
+ const machine = cfg.cliId?.trim();
128
+ if (machine)
129
+ return { cliId: machine, source: 'machine' };
130
+ const cliId = (0, device_id_1.generateCliId)();
131
+ return { cliId, source: 'machine', updatedConfig: { ...cfg, cliId } };
132
+ }
133
+ /**
134
+ * The id an interactive login presents, persisted BEFORE the exchange so a
135
+ * retry — or a sibling process — presents the same one.
136
+ */
137
+ function cliIdForLogin(cfg = (0, config_1.loadConfig)()) {
138
+ const plan = planLoginCliId(cfg);
139
+ if (plan.updatedConfig)
140
+ (0, config_1.saveConfig)(plan.updatedConfig);
141
+ return plan.cliId;
142
+ }
143
+ /**
144
+ * Record that this profile now presents `cliId` — a restored (refresh token,
145
+ * cliId) pair. A named profile records it as its own identity; writing the
146
+ * machine field from a named profile would re-identify the default profile and
147
+ * every profile still on the machine id, whose next refresh would be refused.
148
+ */
149
+ function persistPresentedCliId(cfg, cliId) {
150
+ if ((0, cli_id_1.isNamedProfile)()) {
151
+ const current = cfg.profileCliId?.trim() || cfg.cliId?.trim();
152
+ if (current === cliId)
153
+ return;
154
+ (0, config_1.saveConfig)({ ...cfg, profileCliId: cliId });
155
+ return;
156
+ }
157
+ if (cfg.cliId !== cliId)
158
+ (0, config_1.saveConfig)({ ...cfg, cliId });
159
+ }
@@ -486,6 +486,10 @@ export interface CommitmentNextTickProjection {
486
486
  diagnosisKind?: string;
487
487
  terminalDispositionCensus?: {
488
488
  workFailures?: number;
489
+ /** Failed runs the runtime never started (`classifyRunFailure` machine). */
490
+ machineFailures?: number;
491
+ /** Failed runs whose result was rejected (`classifyRunFailure` contract). */
492
+ contractFailures?: number;
489
493
  budgetRefusals?: number;
490
494
  terminal?: number;
491
495
  };
@@ -655,6 +655,8 @@ function describeEvidenceHealth(health, criteriaCount) {
655
655
  const STALLED_DIAGNOSES = {
656
656
  scheduler_stalled: 'its expected checks are no longer arriving',
657
657
  work_failing: 'every recent run reached the agent and failed while doing the work',
658
+ runtime_failing: 'recent runs never started — the runtime refused them',
659
+ result_rejected: 'every recent run reached the agent, but its result was rejected',
658
660
  observation_unreadable: 'the latest check could not read every configured source',
659
661
  execution_budget_exhausted: 'recent planned work was skipped — the execution budget is exhausted',
660
662
  no_advancement: 'no server-recorded advancement exists inside the declared window',
@@ -674,8 +676,17 @@ function describeStalledHealth(commitment) {
674
676
  const kind = typeof liveness.diagnosisKind === 'string' ? liveness.diagnosisKind : undefined;
675
677
  const clause = (kind && STALLED_DIAGNOSES[kind]) ||
676
678
  'this loop has stopped producing; the reconciler did not record which check failed';
679
+ // What refused and what to change, when the reconciler recorded them
680
+ // (OSK-12137). A machine failure has a specific fix; printing only the
681
+ // verdict would send the owner to read run history for it.
682
+ const code = typeof liveness.diagnosisCode === 'string' && liveness.diagnosisCode
683
+ ? ` (${liveness.diagnosisCode})`
684
+ : '';
677
685
  const since = typeof liveness.unhealthySinceAt === 'string' ? ` (since ${liveness.unhealthySinceAt})` : '';
678
- return `${clause}${since}`;
686
+ const fix = typeof liveness.diagnosisFix === 'string' && liveness.diagnosisFix
687
+ ? ` — ${liveness.diagnosisFix}`
688
+ : '';
689
+ return `${clause}${code}${since}${fix}`;
679
690
  }
680
691
  /** Render an interval back as a compact human string. */
681
692
  function describeIntervalMs(ms) {
@@ -29,6 +29,23 @@ export interface CliConfig {
29
29
  * different daemonId.
30
30
  */
31
31
  cliId?: string;
32
+ /**
33
+ * The CLI identity of ONE named profile (OSK-12156), stored in that profile's
34
+ * own file and never in the machine file.
35
+ *
36
+ * `cliId` above is machine-scoped, so every profile used to present it — and
37
+ * the server's `cli-handoff/confirm` retires every OTHER live family of
38
+ * (user, cliId), so one profile's daemon-brokered login silently ended
39
+ * another's session. This is issued ONLY to a named profile that holds no
40
+ * stored credential at the moment it signs in (`cli-identity.ts`), because
41
+ * an existing session was minted under the machine id and a refresh that
42
+ * presents another is refused: a profile that already has a session keeps
43
+ * presenting the id it was minted under, so an upgrade forces no re-login.
44
+ *
45
+ * Never read this field, or `cliId`, to decide what to PRESENT — call
46
+ * `resolveCliId()` (`cli-id.ts`), the one resolver every caller goes through.
47
+ */
48
+ profileCliId?: string;
32
49
  /**
33
50
  * OSK-3892 — the agent a locally-hosted `skrr code` session is attributed to.
34
51
  *
@@ -53,6 +53,7 @@ const os = __importStar(require("node:os"));
53
53
  const path = __importStar(require("node:path"));
54
54
  const keychain_1 = require("./keychain");
55
55
  const auth_core_1 = require("@skrr-ai/auth-core");
56
+ const cli_id_1 = require("./cli-id");
56
57
  const publicEndpoints_generated_1 = require("./publicEndpoints.generated");
57
58
  // Both from config/public-endpoints.json, the one domain contract, rather than a
58
59
  // fourth hand-copied pair. The CLI previously knew only about dev — there was no
@@ -174,7 +175,8 @@ const ENV_VAR = {
174
175
  serverUrl: 'OVERSKY_SERVER_URL',
175
176
  token: 'OVERSKY_TOKEN',
176
177
  refreshToken: 'OVERSKY_REFRESH_TOKEN',
177
- cliId: 'OVERSKY_CLI_ID',
178
+ // OVERSKY_CLI_ID / SKRR_CLI_ID: `envCliIdOverride` (cli-id.ts), which
179
+ // `resolveCliId` shares, so the overlay and the resolver cannot disagree.
178
180
  };
179
181
  /**
180
182
  * Read one overlay variable under either prefix.
@@ -215,8 +217,8 @@ function overlayEnv(cfg) {
215
217
  const rt = envOverlayValue('refreshToken');
216
218
  if (rt && rt.length > 0)
217
219
  out.refreshToken = rt;
218
- const cid = envOverlayValue('cliId');
219
- if (cid && cid.length > 0)
220
+ const cid = (0, cli_id_1.envCliIdOverride)();
221
+ if (cid)
220
222
  out.cliId = cid;
221
223
  return out;
222
224
  }
@@ -275,6 +277,16 @@ function newConfigDir() {
275
277
  * and rejects a rotation against a different one. Split it per profile and a
276
278
  * second profile does not degrade — it fails to stay logged in.
277
279
  *
280
+ * So `cliId` stays here, and a profile's OWN identity is a different key,
281
+ * `profileCliId`, which is profile-scoped (OSK-12156). Sharing the machine id
282
+ * meant the server's cli-handoff confirm — which retires the other families of
283
+ * (user, cliId) — could not tell two profiles of one user apart, so one
284
+ * profile's daemon-brokered login ended another's session. A named profile that
285
+ * signs in holding no credential is now issued its own id; a profile that
286
+ * already has a session keeps the machine id it was minted under, because a
287
+ * refresh presenting another is refused. `resolveCliId()` (`cli-id.ts`) is the
288
+ * one reader.
289
+ *
278
290
  * `credentials.store` is a machine posture (keychain / file / env-only), and
279
291
  * `migrated` is a breadcrumb about this machine's DISK LAYOUT. Neither is a
280
292
  * property of which deployment you are addressing.
@@ -122,7 +122,7 @@ export interface DaemonBrokeredOutcome {
122
122
  * fall through to PKCE; `ci_token_refused` is the one we surface
123
123
  * because re-driving it via PKCE would not change the outcome.
124
124
  */
125
- export type DaemonBrokerErrorReason = 'no_bootstrap' | 'stale_bootstrap' | 'bootstrap_parse' | 'base_url_mismatch' | 'daemon_not_authed' | 'ci_token_refused' | 'handoff_broker_only' | 'rate_limited' | 'server_error' | 'network' | 'timeout' | 'unknown';
125
+ export type DaemonBrokerErrorReason = 'no_bootstrap' | 'stale_bootstrap' | 'bootstrap_parse' | 'base_url_mismatch' | 'daemon_not_authed' | 'ci_token_refused' | 'handoff_broker_only' | 'rate_limited' | 'upstream_refused' | 'server_error' | 'network' | 'timeout' | 'unknown';
126
126
  export interface DaemonBrokerFailure {
127
127
  ok: false;
128
128
  reason: DaemonBrokerErrorReason;
@@ -132,6 +132,16 @@ export interface DaemonBrokerFailure {
132
132
  status?: number;
133
133
  /** Server-supplied error code when present (e.g. 'CI_TOKEN_REFUSED'). */
134
134
  code?: string;
135
+ /**
136
+ * Who refused, when the daemon answered at all: `daemon` for a refusal the
137
+ * local service made itself (not signed in, broker-only, its own outage),
138
+ * `server` for the skrr server's refusal of the daemon's request, carried
139
+ * through the loopback route (`upstreamCode` on the token route, the
140
+ * verbatim body on the legacy one). The two have different repairs, and a
141
+ * caller that cannot tell them apart can only say "Not signed in"
142
+ * (OSK-12149).
143
+ */
144
+ refusedBy?: 'daemon' | 'server';
135
145
  /**
136
146
  * For `base_url_mismatch`: the daemon's serverUrl, so the caller can
137
147
  * surface a helpful hint like "run `skrr login --base-url <X>` to use
@@ -139,11 +149,23 @@ export interface DaemonBrokerFailure {
139
149
  */
140
150
  daemonServerUrl?: string;
141
151
  /**
142
- * For `base_url_mismatch` found locally: the descriptor file that named
143
- * `daemonServerUrl`, so a message can say what was read rather than assert
144
- * where "the" daemon is logged in (OSK-12117).
152
+ * The descriptor file the outcome came from. For `base_url_mismatch` found
153
+ * locally it is the file that named `daemonServerUrl`, so a message can say
154
+ * what was read rather than assert where "the" daemon is logged in
155
+ * (OSK-12117). For a refusal it is the descriptor whose daemon answered —
156
+ * which is how a caller knows the answer came from a Dedicated Runtime
157
+ * guest's daemon (`DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR`), where nobody
158
+ * signs in and the repair is the machine, not a login.
145
159
  */
146
160
  descriptorPath?: string;
161
+ /** The answering daemon's id, from its descriptor, when it published one. */
162
+ daemonId?: string;
163
+ /**
164
+ * True when the answering descriptor is a Dedicated Runtime guest's
165
+ * (`DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR`): nobody signs in there, so
166
+ * the repair for a refusal is the machine, never a login.
167
+ */
168
+ dedicatedGuest?: boolean;
147
169
  }
148
170
  export type DaemonBrokerOutcome = (DaemonBrokerResult & {
149
171
  ok: true;
@@ -96,7 +96,7 @@ const loopback_http_1 = require("@skrr-ai/auth-core/loopback-http");
96
96
  const cli_handoff_wire_1 = require("@skrr-ai/auth-core/cli-handoff-wire");
97
97
  const auth_storage_1 = require("./auth-storage");
98
98
  const config_1 = require("./config");
99
- const device_id_1 = require("./device-id");
99
+ const cli_identity_1 = require("./cli-identity");
100
100
  const REQUEST_TIMEOUT_MS = 20_000;
101
101
  /**
102
102
  * Normalize a base URL for equality comparison. Mirrors the daemon-side
@@ -120,6 +120,19 @@ function normalizeBaseUrl(u) {
120
120
  return (0, config_1.canonicalizeKnownBaseURL)(trimmed.toLowerCase());
121
121
  }
122
122
  }
123
+ /**
124
+ * Codes the daemon's legacy `/v1/auth/cli-handoff` route writes itself. Every
125
+ * other coded body on that route is the server's, passed through verbatim.
126
+ */
127
+ const DAEMON_OWN_HANDOFF_CODES = new Set([
128
+ cli_handoff_wire_1.CLI_HANDOFF_ERROR_BROKER_ONLY,
129
+ 'BODY_TOO_LARGE',
130
+ 'BODY_PARSE_FAILED',
131
+ 'CLI_ID_INVALID',
132
+ 'BASE_URL_MISMATCH',
133
+ 'DAEMON_NOT_AUTHED',
134
+ 'UPSTREAM_UNREACHABLE',
135
+ ]);
123
136
  /**
124
137
  * Compute the bootstrap file path. Exported for tests so they can assert
125
138
  * the platform fork without monkeypatching `os.platform()`.
@@ -394,6 +407,17 @@ async function attemptDaemonBrokerLogin(opts) {
394
407
  let outcome = null;
395
408
  for (const { bootstrap, sourcePath } of usable) {
396
409
  outcome = await brokerThrough(bootstrap, opts, sourcePath);
410
+ if (!outcome.ok) {
411
+ // Say which daemon answered: a guest's refusal has a different repair.
412
+ outcome = {
413
+ ...outcome,
414
+ descriptorPath: outcome.descriptorPath ?? sourcePath,
415
+ ...(bootstrap.daemonId ? { daemonId: bootstrap.daemonId } : {}),
416
+ ...(sourcePath === exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR
417
+ ? { dedicatedGuest: true }
418
+ : {}),
419
+ };
420
+ }
397
421
  if (outcome.ok || outcome.reason !== 'network')
398
422
  return outcome;
399
423
  }
@@ -448,6 +472,13 @@ async function brokerThrough(bootstrap, opts, sourcePath) {
448
472
  // 403 HANDOFF_BROKER_ONLY anyway.
449
473
  if (!offersRefreshFamily)
450
474
  return redeem;
475
+ const minted = await mintRefreshFamilyThrough(bootstrap, opts);
476
+ // Both doors refused: report the one that says more. The legacy route
477
+ // usually carries the server's code itself, but an older daemon's token
478
+ // route may be the only one that named it.
479
+ if (!minted.ok && !minted.code && redeem.code && redeem.reason !== 'network')
480
+ return redeem;
481
+ return minted;
451
482
  }
452
483
  return mintRefreshFamilyThrough(bootstrap, opts);
453
484
  }
@@ -517,6 +548,28 @@ async function redeemCliHandoffToken(bootstrap, opts = {}) {
517
548
  ...(code ? { code } : {}),
518
549
  };
519
550
  }
551
+ // A daemon that names the SERVER's refusal of its own mint carries
552
+ // `upstreamCode` / `upstreamStatus` beside the 503 (older daemons omit them). Surface the
553
+ // server's code — `DEVICE_PROOF_PIN_MISMATCH` has a specific repair that
554
+ // `HANDOFF_TOKEN_UNAVAILABLE` cannot point at (OSK-12149).
555
+ const upstreamCode = typeof parsedBody?.upstreamCode === 'string'
556
+ ? parsedBody.upstreamCode
557
+ : undefined;
558
+ const upstreamStatus = typeof parsedBody?.upstreamStatus === 'number'
559
+ ? parsedBody.upstreamStatus
560
+ : undefined;
561
+ if (upstreamCode || upstreamStatus !== undefined) {
562
+ return {
563
+ ok: false,
564
+ reason: 'upstream_refused',
565
+ refusedBy: 'server',
566
+ detail: message,
567
+ ...(upstreamStatus !== undefined
568
+ ? { status: upstreamStatus }
569
+ : { status: response.status }),
570
+ ...(upstreamCode ? { code: upstreamCode } : code ? { code } : {}),
571
+ };
572
+ }
520
573
  return {
521
574
  ok: false,
522
575
  // 503 HANDOFF_TOKEN_UNAVAILABLE lands in server_error with its code
@@ -524,6 +577,7 @@ async function redeemCliHandoffToken(bootstrap, opts = {}) {
524
577
  // upstream unreachable, mint cool-down). That is a daemon outage, not a
525
578
  // decline, and the code keeps it diagnosable.
526
579
  reason: response.status >= 500 ? 'server_error' : 'unknown',
580
+ refusedBy: 'daemon',
527
581
  detail: message,
528
582
  status: response.status,
529
583
  ...(code ? { code } : {}),
@@ -610,7 +664,14 @@ async function mintRefreshFamilyThrough(bootstrap, opts) {
610
664
  };
611
665
  }
612
666
  if (response.status === 503 && code === 'DAEMON_NOT_AUTHED') {
613
- return { ok: false, reason: 'daemon_not_authed', detail: message, status: 503, code };
667
+ return {
668
+ ok: false,
669
+ reason: 'daemon_not_authed',
670
+ refusedBy: 'daemon',
671
+ detail: message,
672
+ status: 503,
673
+ code,
674
+ };
614
675
  }
615
676
  // 429 is TRANSIENT and its remedy is time, which makes it the one failure
616
677
  // here that a fall-through cannot substitute for. Left in the `unknown`
@@ -644,12 +705,26 @@ async function mintRefreshFamilyThrough(bootstrap, opts) {
644
705
  ...(daemonServerUrl ? { daemonServerUrl } : {}),
645
706
  };
646
707
  }
708
+ // A coded body the daemon did not write is the server's refusal of the
709
+ // daemon's mint, passed through verbatim (`cliHandoff.ts`). A 4xx one —
710
+ // observed: 401 DEVICE_PROOF_PIN_MISMATCH — is a decision with a named
711
+ // repair, and used to land in `unknown`, where no caller could classify it.
712
+ const refusedBy = code
713
+ ? DAEMON_OWN_HANDOFF_CODES.has(code)
714
+ ? 'daemon'
715
+ : 'server'
716
+ : undefined;
647
717
  return {
648
718
  ok: false,
649
- reason: response.status >= 500 ? 'server_error' : 'unknown',
719
+ reason: response.status >= 500
720
+ ? 'server_error'
721
+ : refusedBy === 'server'
722
+ ? 'upstream_refused'
723
+ : 'unknown',
650
724
  detail: message,
651
725
  status: response.status,
652
726
  ...(code ? { code } : {}),
727
+ ...(refusedBy ? { refusedBy } : {}),
653
728
  };
654
729
  }
655
730
  // Happy path — validate the response shape minimally.
@@ -946,14 +1021,11 @@ async function maybeAutoBroker(opts) {
946
1021
  if (opts.commandId && AUTH_SELF_MANAGED_COMMANDS.has(opts.commandId)) {
947
1022
  return { triggered: false };
948
1023
  }
949
- // Mint a cliId on the fly when missing. Persisted by the caller on
1024
+ // The same decision an interactive login makes (OSK-12156): a named profile
1025
+ // with no stored credential is issued its own id, so a durable hand-off's
1026
+ // confirm cannot retire another profile's session. Persisted by the caller on
950
1027
  // success so the rotation row stays bound across processes.
951
- let cliId = opts.cliConfig.cliId;
952
- let updatedConfig;
953
- if (!cliId) {
954
- cliId = (0, device_id_1.generateCliId)();
955
- updatedConfig = { ...opts.cliConfig, cliId };
956
- }
1028
+ const { cliId, updatedConfig } = (0, cli_identity_1.planLoginCliId)(opts.cliConfig);
957
1029
  const outcome = await attemptDaemonBrokerLoginAndPersist({
958
1030
  cliId,
959
1031
  baseURL: opts.cliConfig.baseURL,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What to tell a person whose local daemon ANSWERED the CLI hand-off and
3
+ * refused it (OSK-12149, OSK-12151).
4
+ *
5
+ * The auto-broker used to be "fail-quiet": every refusal except a rate limit
6
+ * and a base-URL mismatch was dropped, and the command then said only "Not
7
+ * signed in. Run `skrr login` first." That sentence is true and points at the
8
+ * wrong repair whenever the daemon's refusal is specific — observed: the server
9
+ * refused the daemon's own mint with `401 DEVICE_PROOF_PIN_MISMATCH`, which
10
+ * `skrr daemon login` repairs (an interactive daemon login re-pins its device
11
+ * key), and which on a Dedicated Runtime guest no login the tenant can run
12
+ * repairs at all.
13
+ *
14
+ * One function decides the headline and the remedy so the NOT_SIGNED_IN error,
15
+ * `skrr login`'s progress line and its headless refusal cannot drift apart.
16
+ */
17
+ import type { DaemonBrokerFailure } from './daemonBroker';
18
+ /**
19
+ * True when a daemon ANSWERED the hand-off and refused it — as opposed to no
20
+ * daemon being there to ask (`no_bootstrap`, a stale descriptor nobody listens
21
+ * behind). This is the case the CLI must explain rather than reduce to "Not
22
+ * signed in": the person has a daemon, it said something specific, and that
23
+ * decides whether `skrr login` would even help.
24
+ */
25
+ export declare function isAnsweredBrokerRefusal(outcome: DaemonBrokerFailure): boolean;
26
+ export interface BrokerRefusalDescription {
27
+ /** One sentence: who refused and what they said, with status and code. */
28
+ headline: string;
29
+ /** What to do about it — never a login the reader cannot perform. */
30
+ remedy: string;
31
+ /** True when waiting and retrying is the repair. */
32
+ transient: boolean;
33
+ /** True when the refusing daemon is a Dedicated Runtime guest's. */
34
+ dedicatedGuest: boolean;
35
+ /** Machine-readable twin for `--json` error details. */
36
+ broker: {
37
+ reason: DaemonBrokerFailure['reason'];
38
+ refusedBy?: 'daemon' | 'server';
39
+ status?: number;
40
+ code?: string;
41
+ detail?: string;
42
+ descriptorPath?: string;
43
+ daemonId?: string;
44
+ };
45
+ }
46
+ /**
47
+ * Describe a broker failure for a person, or null when there is nothing to
48
+ * say: no daemon was there to ask, or the failure already has its own message
49
+ * (`base_url_mismatch`, `rate_limited`).
50
+ */
51
+ export declare function describeBrokerRefusal(failure: DaemonBrokerFailure, bin?: string): BrokerRefusalDescription | null;
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isAnsweredBrokerRefusal = isAnsweredBrokerRefusal;
4
+ exports.describeBrokerRefusal = describeBrokerRefusal;
5
+ /**
6
+ * True when a daemon ANSWERED the hand-off and refused it — as opposed to no
7
+ * daemon being there to ask (`no_bootstrap`, a stale descriptor nobody listens
8
+ * behind). This is the case the CLI must explain rather than reduce to "Not
9
+ * signed in": the person has a daemon, it said something specific, and that
10
+ * decides whether `skrr login` would even help.
11
+ */
12
+ function isAnsweredBrokerRefusal(outcome) {
13
+ switch (outcome.reason) {
14
+ case 'no_bootstrap':
15
+ case 'bootstrap_parse':
16
+ case 'network':
17
+ return false;
18
+ case 'stale_bootstrap':
19
+ // A bare 401 is the daemon refusing a descriptor secret that rotated;
20
+ // re-reading the file next time heals it. A coded one is a real answer.
21
+ return Boolean(outcome.code);
22
+ default:
23
+ return true;
24
+ }
25
+ }
26
+ /** Codes whose repair is signing the DAEMON in again (laptop only). */
27
+ const DAEMON_RELOGIN_CODES = new Set([
28
+ 'DEVICE_PROOF_PIN_MISMATCH',
29
+ 'SESSION_REVOKED',
30
+ 'PRINCIPAL_UNRESOLVED',
31
+ 'DAEMON_NOT_AUTHED',
32
+ ]);
33
+ /** Codes whose repair is time. */
34
+ const TRANSIENT_CODES = new Set([
35
+ 'DEVICE_PROOF_VERIFY_UNAVAILABLE',
36
+ 'SERVICE_UNAVAILABLE',
37
+ 'HANDOFF_RATE_LIMITED',
38
+ 'RATE_LIMITED',
39
+ 'UPSTREAM_UNREACHABLE',
40
+ ]);
41
+ function statusAndCode(f) {
42
+ const parts = [typeof f.status === 'number' ? `HTTP ${f.status}` : '', f.code ?? ''].filter(Boolean);
43
+ return parts.length > 0 ? ` (${parts.join(' ')})` : '';
44
+ }
45
+ function sentence(detail) {
46
+ const d = (detail ?? '').trim();
47
+ if (!d)
48
+ return '';
49
+ return /[.!?]$/.test(d) ? ` ${d}` : ` ${d}.`;
50
+ }
51
+ /**
52
+ * Describe a broker failure for a person, or null when there is nothing to
53
+ * say: no daemon was there to ask, or the failure already has its own message
54
+ * (`base_url_mismatch`, `rate_limited`).
55
+ */
56
+ function describeBrokerRefusal(failure, bin = 'skrr') {
57
+ if (!isAnsweredBrokerRefusal(failure))
58
+ return null;
59
+ if (failure.reason === 'base_url_mismatch' || failure.reason === 'rate_limited')
60
+ return null;
61
+ const dedicatedGuest = failure.dedicatedGuest === true;
62
+ const code = failure.code ?? '';
63
+ const who = dedicatedGuest
64
+ ? `This Dedicated Runtime's skrr service${failure.daemonId ? ` (daemon ${failure.daemonId})` : ''}`
65
+ : 'The skrr background service on this computer';
66
+ const refusal = failure.reason === 'timeout'
67
+ ? `${who} did not answer the sign-in hand-off in time.`
68
+ : failure.refusedBy === 'server' || failure.reason === 'upstream_refused'
69
+ ? `${who} asked the skrr server for a credential for this CLI, and the server refused${statusAndCode(failure)}.${sentence(failure.detail)}`
70
+ : `${who} could not hand this CLI a credential${statusAndCode(failure)}.${sentence(failure.detail)}`;
71
+ const transient = failure.reason === 'timeout' ||
72
+ TRANSIENT_CODES.has(code) ||
73
+ (failure.reason === 'server_error' && !DAEMON_RELOGIN_CODES.has(code));
74
+ let remedy;
75
+ if (dedicatedGuest) {
76
+ // Nobody signs in on a guest: the daemon's credential comes from the
77
+ // machine's attested bootstrap, and a tenant `skrr login` would only put a
78
+ // personal credential on a shared machine. Name the machine instead.
79
+ remedy = transient
80
+ ? 'This is usually temporary; retry in a minute. Nobody signs in on a Dedicated Runtime — ' +
81
+ `if it persists, check the machine from your own computer with \`${bin} machines dedicated health-check <lease-id>\` ` +
82
+ `(\`${bin} machines dedicated list\` shows the lease id).`
83
+ : 'Nobody signs in on a Dedicated Runtime: its daemon holds the account. From your own computer, run ' +
84
+ `\`${bin} machines dedicated health-check <lease-id>\` (\`${bin} machines dedicated list\` shows the lease id), ` +
85
+ `and \`${bin} machines dedicated restart <lease-id>\` if the hand-off stays refused.`;
86
+ }
87
+ else if (code === 'DEVICE_PROOF_PIN_MISMATCH') {
88
+ remedy =
89
+ `Run \`${bin} daemon login\` to sign the background service in again — an interactive daemon login re-pins its device key — then retry. ` +
90
+ `\`${bin} login\` signs this CLI in on its own instead.`;
91
+ }
92
+ else if (DAEMON_RELOGIN_CODES.has(code) || failure.reason === 'daemon_not_authed') {
93
+ remedy =
94
+ `Run \`${bin} daemon login\` to sign the background service in again, then retry. ` +
95
+ `\`${bin} login\` signs this CLI in on its own instead.`;
96
+ }
97
+ else if (failure.reason === 'ci_token_refused') {
98
+ remedy =
99
+ `The background service is signed in with a CI token, which cannot sign a person in. Use \`${bin} login\`, ` +
100
+ `or \`${bin} create-token\` for a CI credential.`;
101
+ }
102
+ else if (transient) {
103
+ remedy =
104
+ `This is usually temporary; retry in a minute. \`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side, ` +
105
+ `and \`${bin} login\` signs this CLI in without it.`;
106
+ }
107
+ else {
108
+ remedy =
109
+ `\`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side. ` +
110
+ `\`${bin} login\` signs this CLI in without it.`;
111
+ }
112
+ return {
113
+ headline: refusal,
114
+ remedy,
115
+ transient,
116
+ dedicatedGuest,
117
+ broker: {
118
+ reason: failure.reason,
119
+ ...(failure.refusedBy ? { refusedBy: failure.refusedBy } : {}),
120
+ ...(typeof failure.status === 'number' ? { status: failure.status } : {}),
121
+ ...(failure.code ? { code: failure.code } : {}),
122
+ ...(failure.detail ? { detail: failure.detail } : {}),
123
+ ...(failure.descriptorPath ? { descriptorPath: failure.descriptorPath } : {}),
124
+ ...(failure.daemonId ? { daemonId: failure.daemonId } : {}),
125
+ },
126
+ };
127
+ }