@skrr-ai/cli 0.1.57 → 0.1.59

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.d.ts +0 -16
  2. package/dist/base-command.js +65 -11
  3. package/dist/commands/agents/show.js +13 -2
  4. package/dist/commands/harnesses/ping.d.ts +2 -0
  5. package/dist/commands/harnesses/ping.js +10 -1
  6. package/dist/commands/login.js +6 -1
  7. package/dist/commands/logout.d.ts +0 -46
  8. package/dist/commands/logout.js +28 -2
  9. package/dist/commands/pair.js +1 -1
  10. package/dist/lib/agentic-stream.d.ts +54 -0
  11. package/dist/lib/agentic-stream.js +153 -15
  12. package/dist/lib/api-fetch.d.ts +11 -0
  13. package/dist/lib/api-fetch.js +98 -1
  14. package/dist/lib/auth-storage.d.ts +27 -0
  15. package/dist/lib/auth-storage.js +42 -0
  16. package/dist/lib/commitments.js +1 -0
  17. package/dist/lib/daemonBroker.d.ts +5 -1
  18. package/dist/lib/daemonBroker.js +13 -2
  19. package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
  20. package/dist/lib/daemonBrokerRefusal.js +87 -2
  21. package/dist/lib/daemonHandoff.d.ts +1 -1
  22. package/dist/lib/daemonHandoff.js +18 -0
  23. package/dist/lib/dedicated-guest.d.ts +13 -0
  24. package/dist/lib/dedicated-guest.js +22 -0
  25. package/dist/lib/dedicated-machines.js +17 -1
  26. package/dist/lib/dedicated-service.d.ts +1 -13
  27. package/dist/lib/dedicated-service.js +7 -19
  28. package/dist/lib/login.js +12 -3
  29. package/dist/lib/oauthLogin.js +3 -1
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +5 -3
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -1
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +21 -1
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
  46. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  47. package/dist/node_modules/@skrr-ai/data-provider/index.js +4388 -4349
  48. package/oclif.manifest.json +26425 -26425
  49. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
3
+ exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.TYPE_AHEAD_DISCARD_MS = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
4
4
  exports.writeJsonLine = writeJsonLine;
5
+ exports.discardTypeAhead = discardTypeAhead;
5
6
  exports.describePermissionTarget = describePermissionTarget;
6
7
  exports.envelopesText = envelopesText;
7
8
  exports.envelopeKey = envelopeKey;
@@ -54,6 +55,59 @@ exports.NON_INTERACTIVE_QUESTION_DISMISSAL = 'No answer: this turn was started n
54
55
  function writeJsonLine(output, value) {
55
56
  output.write(`${JSON.stringify(value) ?? 'null'}\n`);
56
57
  }
58
+ /**
59
+ * How long input arriving just before a prompt is shown is thrown away.
60
+ *
61
+ * Long enough for bytes already typed into the terminal to be read out of the
62
+ * kernel once stdin starts flowing (one event-loop turn in practice), short
63
+ * enough that nobody waits for it.
64
+ */
65
+ exports.TYPE_AHEAD_DISCARD_MS = 60;
66
+ /**
67
+ * Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
68
+ *
69
+ * A key pressed while the turn was still streaming sits in the stream's buffer
70
+ * or the terminal's, and the first `readline.question` read it as the answer:
71
+ * an owner typed `y` before any `[y/N]` was visible and approved a Bash command
72
+ * they had never seen. An approval is only consent to what was on the screen,
73
+ * so every prompt now starts from an empty input: bytes the stream already
74
+ * holds are read and dropped, then stdin flows into a discarding listener for
75
+ * `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
76
+ * on for that window because a half-typed line (`y`, no Enter) is invisible to
77
+ * a read in canonical mode and would otherwise surface as soon as readline
78
+ * enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
79
+ */
80
+ async function discardTypeAhead(input, windowMs = exports.TYPE_AHEAD_DISCARD_MS) {
81
+ const tty = input;
82
+ const canSetRaw = Boolean(tty.isTTY) && typeof tty.setRawMode === 'function';
83
+ const wasRaw = canSetRaw ? Boolean(tty.isRaw) : false;
84
+ let sawInterrupt = false;
85
+ const drop = (chunk) => {
86
+ const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : '';
87
+ if (text.includes('\u0003'))
88
+ sawInterrupt = true;
89
+ };
90
+ try {
91
+ if (canSetRaw && !wasRaw)
92
+ tty.setRawMode(true);
93
+ const readable = input;
94
+ if (typeof readable.read === 'function') {
95
+ for (let chunk = readable.read(); chunk !== null; chunk = readable.read())
96
+ drop(chunk);
97
+ }
98
+ input.on('data', drop);
99
+ input.resume();
100
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, windowMs)));
101
+ }
102
+ finally {
103
+ input.removeListener('data', drop);
104
+ input.pause();
105
+ if (canSetRaw && !wasRaw)
106
+ tty.setRawMode(false);
107
+ }
108
+ if (sawInterrupt)
109
+ process.kill(process.pid, 'SIGINT');
110
+ }
57
111
  const PERMISSION_DETAIL_MAX = 400;
58
112
  /**
59
113
  * What a permission request would DO, in one line a person can judge.
@@ -266,6 +320,13 @@ function describeToolEnd(toolName, status) {
266
320
  return `[tool failed] ${name}`;
267
321
  case 'cancelled':
268
322
  return `[tool cancelled] ${name}`;
323
+ // OSK-12469 — a call whose approval was refused never ran. It printed
324
+ // `[tool completed]` just before the line saying it was denied; a call
325
+ // whose ask expired, or whose turn a deploy paused, did the same.
326
+ case 'denied':
327
+ return `[tool denied] ${name} (it did not run)`;
328
+ case 'not_run':
329
+ return `[tool not run] ${name}`;
269
330
  case 'unreported':
270
331
  return `[tool result not reported] ${name} (the harness never said how it ended; closed when the session ended)`;
271
332
  default:
@@ -563,6 +624,12 @@ class AgenticStreamClient {
563
624
  * (OSK-12197) instead of leaving `[y/N]` open after the run stopped asking.
564
625
  */
565
626
  openPermissionPrompts = new Map();
627
+ /**
628
+ * Question prompts on the terminal still waiting for an answer, by
629
+ * questionId, so a server `question:cancel` can close the one it names
630
+ * (OSK-12208) the same way `permission:cancel` closes a permission prompt.
631
+ */
632
+ openQuestionPrompts = new Map();
566
633
  /** Text already written to the terminal, for suffix-only rendering. */
567
634
  renderedText = '';
568
635
  idleTimer = null;
@@ -961,6 +1028,11 @@ class AgenticStreamClient {
961
1028
  if (match(event))
962
1029
  void this.handleQuestion(event);
963
1030
  });
1031
+ socket.on('question:cancel', (event) => {
1032
+ if (!match(event))
1033
+ return;
1034
+ this.handleQuestionCancel(event);
1035
+ });
964
1036
  socket.on('error:unauthorized', (event) => {
965
1037
  if (match(event))
966
1038
  this.finish('failed', event);
@@ -1063,9 +1135,20 @@ class AgenticStreamClient {
1063
1135
  const interactive = this.options.input !== undefined || Boolean(process.stdin.isTTY);
1064
1136
  const answers = {};
1065
1137
  if (interactive) {
1138
+ const prompt = new AbortController();
1139
+ if (event.questionId)
1140
+ this.openQuestionPrompts.set(event.questionId, prompt);
1066
1141
  for (const question of event.questions ?? []) {
1067
- answers[question.header ?? question.question] = await this.ask(`${question.header ?? 'Question'}: ${question.question}\n> `);
1142
+ if (prompt.signal.aborted)
1143
+ break;
1144
+ answers[question.header ?? question.question] = await this.ask(`${question.header ?? 'Question'}: ${question.question}\n> `, prompt.signal);
1068
1145
  }
1146
+ if (event.questionId)
1147
+ this.openQuestionPrompts.delete(event.questionId);
1148
+ // Closed by the server (it stopped waiting, or the turn ended): an answer
1149
+ // sent now would be read by nobody, and would look like a person's.
1150
+ if (prompt.signal.aborted)
1151
+ return;
1069
1152
  }
1070
1153
  else {
1071
1154
  answers._dismissed = exports.NON_INTERACTIVE_QUESTION_DISMISSAL;
@@ -1110,26 +1193,73 @@ class AgenticStreamClient {
1110
1193
  : {}),
1111
1194
  });
1112
1195
  }
1196
+ /**
1197
+ * The server closed a question (OSK-12208): nobody answered it in time, or
1198
+ * the turn ended first. Say so, and close the terminal prompt if this
1199
+ * client is still showing it — the question twin of handlePermissionCancel.
1200
+ */
1201
+ handleQuestionCancel(event) {
1202
+ const questionId = typeof event.questionId === 'string' ? event.questionId : undefined;
1203
+ if (!this.firstTimeSeen('question-cancel', questionId))
1204
+ return;
1205
+ const open = questionId ? this.openQuestionPrompts.get(questionId) : undefined;
1206
+ if (questionId)
1207
+ this.openQuestionPrompts.delete(questionId);
1208
+ open?.abort();
1209
+ this.emit({
1210
+ ...event,
1211
+ type: 'question.cancelled',
1212
+ promptClosed: Boolean(open),
1213
+ });
1214
+ }
1215
+ /** Prompts run one at a time: two readlines on one stdin would both read a line. */
1216
+ askQueue = Promise.resolve();
1113
1217
  ask(prompt, signal) {
1218
+ const next = this.askQueue.then(() => this.askNow(prompt, signal));
1219
+ this.askQueue = next.catch(() => undefined);
1220
+ return next;
1221
+ }
1222
+ /**
1223
+ * One prompt, on a readline that exists only while the prompt is on screen.
1224
+ *
1225
+ * The interface used to be created at the first prompt and kept until the
1226
+ * run closed, and it was created AFTER stdin had been accumulating whatever
1227
+ * the person typed while the turn streamed — so the first question answered
1228
+ * itself from that type-ahead (OSK-12468). Now the input is emptied first
1229
+ * ({@link discardTypeAhead}), only then is the question shown, and the
1230
+ * interface is closed with the answer, so nothing typed between two prompts
1231
+ * can answer the second one either.
1232
+ */
1233
+ async askNow(prompt, signal) {
1114
1234
  // A prompt raised after close has nobody to answer it, and re-creating the
1115
1235
  // interface here would resurrect stdin for a run that is already over.
1116
1236
  if (this.closed || signal?.aborted)
1117
- return Promise.resolve('');
1118
- if (!this.readline) {
1119
- this.readline = (0, node_readline_1.createInterface)({
1120
- input: this.options.input ?? process.stdin,
1121
- output: process.stderr,
1237
+ return '';
1238
+ const input = this.options.input ?? process.stdin;
1239
+ await discardTypeAhead(input, this.options.typeAheadDiscardMs ?? exports.TYPE_AHEAD_DISCARD_MS);
1240
+ if (this.closed || signal?.aborted)
1241
+ return '';
1242
+ const rl = (0, node_readline_1.createInterface)({ input, output: process.stderr });
1243
+ this.readline = rl;
1244
+ try {
1245
+ return await new Promise((resolve) => {
1246
+ // Closed under us (the run ended): nobody is left to answer.
1247
+ rl.once('close', () => resolve(''));
1248
+ if (!signal) {
1249
+ rl.question(prompt, resolve);
1250
+ return;
1251
+ }
1252
+ // readline does not call back for a question aborted by its signal, so
1253
+ // the abort settles the prompt itself.
1254
+ signal.addEventListener('abort', () => resolve(''), { once: true });
1255
+ rl.question(prompt, { signal }, resolve);
1122
1256
  });
1123
1257
  }
1124
- if (!signal) {
1125
- return new Promise((resolve) => this.readline.question(prompt, resolve));
1258
+ finally {
1259
+ if (this.readline === rl)
1260
+ this.readline = null;
1261
+ rl.close();
1126
1262
  }
1127
- return new Promise((resolve) => {
1128
- // readline does not call back for a question aborted by its signal, so
1129
- // the abort settles the prompt itself.
1130
- signal.addEventListener('abort', () => resolve(''), { once: true });
1131
- this.readline.question(prompt, { signal }, resolve);
1132
- });
1133
1263
  }
1134
1264
  /**
1135
1265
  * True the first time this (kind, identity) pair is seen; false afterwards.
@@ -1243,6 +1373,14 @@ class AgenticStreamClient {
1243
1373
  output.write(`\n[${event.type}]\n`);
1244
1374
  this.renderedText = this.accumulated;
1245
1375
  }
1376
+ else if (event.type === 'question.cancelled') {
1377
+ // A question the run stopped waiting for (OSK-12208).
1378
+ const why = typeof event.message === 'string' && event.message
1379
+ ? event.message
1380
+ : `closed (${String(event.reason ?? 'cancelled')})`;
1381
+ output.write(`\n[question] ${why}\n`);
1382
+ this.renderedText = this.accumulated;
1383
+ }
1246
1384
  else if (event.type === 'permission.approved' && event.auto === true) {
1247
1385
  // Said out loud, because nobody was asked: the flag approved it, and a
1248
1386
  // reader of this log should see which tools that covered (OSK-10323).
@@ -1,4 +1,5 @@
1
1
  import { type ResolvedCredential } from './credential-resolver';
2
+ import { type DaemonBrokerFailure } from './daemonBroker';
2
3
  export interface ApiFetchOptions {
3
4
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
4
5
  body?: unknown;
@@ -36,6 +37,14 @@ export declare class ApiFetchError extends Error {
36
37
  * attributed by guesswork — see `node-adapter.ts`.
37
38
  */
38
39
  readonly requestId?: string;
40
+ /**
41
+ * Set on a 401 when this CLI's own refresh was refused AND the local
42
+ * daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
43
+ * decides the repair, so the 401 message carries it rather than a bare
44
+ * "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
45
+ * it into words. Absent when the daemon was never asked.
46
+ */
47
+ brokerFailure?: DaemonBrokerFailure;
39
48
  constructor(message: string, status: number, body?: string, code?: string, method?: string, requestId?: string);
40
49
  }
41
50
  /**
@@ -44,6 +53,8 @@ export declare class ApiFetchError extends Error {
44
53
  * `err.status` / `err.code`.
45
54
  */
46
55
  export declare function apiFetch<T>(pathOrUrl: string, opts?: ApiFetchOptions): Promise<T>;
56
+ /** @internal test seam — forget this process's daemon hand-off attempt. */
57
+ export declare function __resetDaemonHandoffFallbackForTest(): void;
47
58
  /**
48
59
  * True when the current process has a persisted refresh-capable session
49
60
  * (`skrr login` has been run and survived). Used by CI-token commands
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiFetchError = void 0;
4
4
  exports.apiFetch = apiFetch;
5
+ exports.__resetDaemonHandoffFallbackForTest = __resetDaemonHandoffFallbackForTest;
5
6
  exports.hasHumanSession = hasHumanSession;
6
7
  /**
7
8
  * api-fetch.ts — authenticated fetch helper for endpoints not yet in
@@ -61,6 +62,14 @@ class ApiFetchError extends Error {
61
62
  * attributed by guesswork — see `node-adapter.ts`.
62
63
  */
63
64
  requestId;
65
+ /**
66
+ * Set on a 401 when this CLI's own refresh was refused AND the local
67
+ * daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
68
+ * decides the repair, so the 401 message carries it rather than a bare
69
+ * "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
70
+ * it into words. Absent when the daemon was never asked.
71
+ */
72
+ brokerFailure;
64
73
  constructor(message, status, body, code, method, requestId) {
65
74
  super(message);
66
75
  this.name = 'ApiFetchError';
@@ -181,6 +190,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
181
190
  });
182
191
  };
183
192
  let res = await fetchWithCredential(cred);
193
+ let brokerFailure = null;
184
194
  if (res.status === 401 && delegatedCli) {
185
195
  // Deliberately terminal on broker failure: an Agent whose daemon cannot
186
196
  // rotate its family must surface the 401, never fall through to
@@ -217,6 +227,25 @@ async function apiFetch(pathOrUrl, opts = {}) {
217
227
  (0, node_adapter_1.setAdapterCredentialOverride)(cred);
218
228
  res = await fetchWithCredential(cred);
219
229
  }
230
+ else if (fresh.skippedReason === 'refresh_failed' ||
231
+ fresh.skippedReason === 'no_refresh_token') {
232
+ // OSK-12474 — this CLI's STORED sign-in could not be renewed (e.g.
233
+ // DEVICE_KEY_MISMATCH after a key split). The node-adapter asks the local
234
+ // daemon for a hand-off at this point; this transport did not, so a
235
+ // healthy daemon was ignored and every apiFetch command demanded
236
+ // `skrr login`. Gated on the stored-credential skip reasons only: a
237
+ // `--token`, OVERSKY_TOKEN, CI token or bare-mode credential is the
238
+ // operator's, and must never be swapped for the daemon's principal.
239
+ const brokered = await daemonHandoffAfterRefusedRefresh();
240
+ if (brokered.credential) {
241
+ cred = brokered.credential;
242
+ (0, node_adapter_1.setAdapterCredentialOverride)(cred);
243
+ res = await fetchWithCredential(cred);
244
+ }
245
+ else {
246
+ brokerFailure = brokered.failure;
247
+ }
248
+ }
220
249
  }
221
250
  if (!res.ok) {
222
251
  let text = '';
@@ -235,7 +264,10 @@ async function apiFetch(pathOrUrl, opts = {}) {
235
264
  catch {
236
265
  /* ignore body read failures */
237
266
  }
238
- throw new ApiFetchError(`HTTP ${res.status} ${res.statusText} — ${method} ${pathOrUrl}`, res.status, text, code, method, res.headers.get('x-request-id') || undefined);
267
+ const error = new ApiFetchError(`HTTP ${res.status} ${res.statusText} — ${method} ${pathOrUrl}`, res.status, text, code, method, res.headers.get('x-request-id') || undefined);
268
+ if (brokerFailure)
269
+ error.brokerFailure = brokerFailure;
270
+ throw error;
239
271
  }
240
272
  // 204/205 carry no body by definition; an empty body has no content-type
241
273
  // worth judging. Neither is an error.
@@ -255,6 +287,71 @@ async function apiFetch(pathOrUrl, opts = {}) {
255
287
  }
256
288
  return (await res.json());
257
289
  }
290
+ /**
291
+ * One daemon hand-off attempt per process (OSK-12474). Memoized, including a
292
+ * failure: a command that makes many requests must not ask the daemon again
293
+ * on every 401, and a hand-off token that is itself refused is renewed by the
294
+ * brokered re-redeem branch above, never by coming back here — so this cannot
295
+ * loop.
296
+ */
297
+ let daemonHandoffFallback = null;
298
+ function daemonHandoffAfterRefusedRefresh() {
299
+ daemonHandoffFallback ??= attemptDaemonHandoffFallback();
300
+ return daemonHandoffFallback;
301
+ }
302
+ async function attemptDaemonHandoffFallback() {
303
+ const cliConfig = (0, config_1.loadConfig)();
304
+ let result;
305
+ try {
306
+ // `maybeAutoBroker` applies the same opt-outs as the node-adapter's
307
+ // recovery: OVERSKY_TOKEN/OVERSKY_REFRESH_TOKEN and
308
+ // OVERSKY_SKIP_DAEMON_BROKER=1 leave it untriggered.
309
+ result = await (0, daemonBroker_1.maybeAutoBroker)({ cliConfig, bareMode: false });
310
+ }
311
+ catch {
312
+ return { credential: null, failure: null };
313
+ }
314
+ const outcome = result.outcome;
315
+ if (!result.triggered || !outcome)
316
+ return { credential: null, failure: null };
317
+ if (!outcome.ok)
318
+ return { credential: null, failure: outcome };
319
+ if (result.updatedConfig) {
320
+ try {
321
+ (0, config_1.saveConfig)(result.updatedConfig);
322
+ }
323
+ catch {
324
+ /* non-fatal: the next process mints a new cliId (see BaseCommand.init) */
325
+ }
326
+ }
327
+ const accessToken = outcome.accessToken;
328
+ // Where a durable mint was written; a brokered hand-off is never stored.
329
+ const storedSource = !outcome.brokered && outcome.storedIn === 'keychain' ? 'keychain' : 'file';
330
+ // Say where the credential now comes from, once. The adapter slot records a
331
+ // brokered token as `env-token` (for refresh semantics), which a reader would
332
+ // take for OVERSKY_TOKEN; the label must name the descriptor (OSK-12185).
333
+ const label = outcome.brokered
334
+ ? (0, credential_resolver_1.describeCredentialSource)('env-token', {
335
+ kind: 'daemon-handoff',
336
+ ...(outcome.descriptorPath ? { descriptorPath: outcome.descriptorPath } : {}),
337
+ })
338
+ : `a new sign-in brokered by the local daemon, saved to ${(0, credential_resolver_1.describeCredentialSource)(storedSource)}`;
339
+ process.stderr.write(`[skrr] This CLI's own sign-in could not be renewed; using ${label} instead.\n`);
340
+ return {
341
+ credential: {
342
+ token: accessToken,
343
+ // A brokered hand-off is held in memory for this process only; the
344
+ // durable mint was written to the backend the broker reported.
345
+ source: outcome.brokered ? 'env-token' : storedSource,
346
+ kind: (0, credential_resolver_1.kindOf)(accessToken),
347
+ },
348
+ failure: null,
349
+ };
350
+ }
351
+ /** @internal test seam — forget this process's daemon hand-off attempt. */
352
+ function __resetDaemonHandoffFallbackForTest() {
353
+ daemonHandoffFallback = null;
354
+ }
258
355
  function trustedAutonomousBaseUrl() {
259
356
  // Only the daemon-owned alias is accepted. OVERSKY_BASE_URL is a normal
260
357
  // user/CLI override and may originate in request-carried provider env.
@@ -11,7 +11,26 @@ export interface AuthBundle {
11
11
  * current config; callers never need to set it.
12
12
  */
13
13
  owner?: CredentialOwner;
14
+ /**
15
+ * Who created the refresh family this bundle carries (OSK-12293). Recorded
16
+ * in the file in plaintext, like `owner`, so it can be read without the key.
17
+ * Absent on everything an older CLI wrote.
18
+ */
19
+ provenance?: CredentialProvenance;
14
20
  }
21
+ /**
22
+ * How a stored refresh family came to exist.
23
+ *
24
+ * - `user_login`: a person signed in on purpose (`skrr login` in any of its
25
+ * flows, `skrr pair`). On a Dedicated guest this is the one stored family
26
+ * the broker-mode retirement must never touch: the person asked for it.
27
+ * - `daemon_broker`: the local daemon minted it through its hand-off.
28
+ *
29
+ * An absent value means an older CLI wrote the file and the family's origin
30
+ * is unknown, which on a brokered guest is the pre-broker leftover the
31
+ * retirement exists for (OSK-12194).
32
+ */
33
+ export type CredentialProvenance = 'user_login' | 'daemon_broker';
15
34
  /** Which skrr config a credential belongs to. */
16
35
  export interface CredentialOwner {
17
36
  /** Resolved config root (`~/.skrr`, or the `SKRR_CONFIG_DIR` override). */
@@ -39,6 +58,13 @@ export interface WriteOptions {
39
58
  serverOrigin?: string;
40
59
  /** Clear a stale needs-reauth latch after a server has issued fresh credentials. */
41
60
  clearReauth?: boolean;
61
+ /**
62
+ * Who created the family being written. A write that names none is a
63
+ * ROTATION of the stored family (a refresh), and keeps the provenance the
64
+ * stored file records for the same server — so a refresh never erases the
65
+ * marker a login wrote, and never invents one an older CLI did not.
66
+ */
67
+ provenance?: CredentialProvenance;
42
68
  }
43
69
  /**
44
70
  * File-backend-only read. Exported for the brokered-hand-off migration
@@ -62,6 +88,7 @@ export declare function readFromFile(): AuthBundle | null;
62
88
  export declare function readRefreshFamilyFromFile(): {
63
89
  refreshToken: string;
64
90
  serverOrigin?: string;
91
+ provenance?: CredentialProvenance;
65
92
  } | null;
66
93
  /**
67
94
  * Delete `~/.skrr/cli-auth.json` only (the keychain is untouched). Exported
@@ -106,6 +106,12 @@ const cli_id_1 = require("./cli-id");
106
106
  * `auth.json`, deliberately distinct so the two binaries never clobber
107
107
  * each other's tokens (CLI holds scope=cli, daemon holds scope=daemon). */
108
108
  const AUTH_FILENAME = 'cli-auth.json';
109
+ const CREDENTIAL_PROVENANCES = ['user_login', 'daemon_broker'];
110
+ function parseProvenance(raw) {
111
+ return CREDENTIAL_PROVENANCES.includes(raw)
112
+ ? raw
113
+ : undefined;
114
+ }
109
115
  /**
110
116
  * The config root — the same one `cli-config.json` (and so `cliId`) lives in.
111
117
  *
@@ -253,8 +259,29 @@ function readRefreshFamilyFromFile() {
253
259
  return {
254
260
  refreshToken: bundle.refreshToken,
255
261
  ...(bundle.serverOrigin ? { serverOrigin: bundle.serverOrigin } : {}),
262
+ ...(bundle.provenance ? { provenance: bundle.provenance } : {}),
256
263
  };
257
264
  }
265
+ /**
266
+ * The provenance this root's `cli-auth.json` records for `serverOrigin`, read
267
+ * from the plaintext field alone — no decrypt, no ownership rewrite. Used by a
268
+ * rotation to carry the marker forward.
269
+ */
270
+ function storedFileProvenance(serverOrigin) {
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(fs.readFileSync(authFilePath(), 'utf-8'));
274
+ }
275
+ catch {
276
+ return undefined;
277
+ }
278
+ const provenance = parseProvenance(parsed?.provenance);
279
+ if (!provenance)
280
+ return undefined;
281
+ const stored = typeof parsed.serverOrigin === 'string' ? normalizeServerOrigin(parsed.serverOrigin) : null;
282
+ const next = serverOrigin ? normalizeServerOrigin(serverOrigin) : null;
283
+ return stored && next && stored === next ? provenance : undefined;
284
+ }
258
285
  /**
259
286
  * This root's file, refused when it belongs to another config; else, under a
260
287
  * root override, the pre-override `$HOME/.skrr` file when it is positively ours.
@@ -349,6 +376,9 @@ function readFileAt(p, upgradeInPlace, opts = {}) {
349
376
  }
350
377
  if (owner)
351
378
  bundle.owner = owner;
379
+ const provenance = parseProvenance(parsed.provenance);
380
+ if (provenance)
381
+ bundle.provenance = provenance;
352
382
  // Best-effort upgrade: re-write the bundle so legacy plaintext fields
353
383
  // become wrapped on the next read. A failure here doesn't break the
354
384
  // read path — the caller already has the plaintext bundle.
@@ -413,6 +443,8 @@ function writeToFile(bundle) {
413
443
  // Rewrites of an existing bundle (the envelope upgrade) keep its recorded
414
444
  // owner; everything else is stamped with this config.
415
445
  wireBundle.owner = bundle.owner ?? ownerFor(bundle);
446
+ if (bundle.provenance)
447
+ wireBundle.provenance = bundle.provenance;
416
448
  // tmp + rename so no racing reader ever sees a partial file; chmod 0600
417
449
  // on the final file so the same-UID blast-radius is as narrow as the
418
450
  // file backend can offer.
@@ -772,6 +804,16 @@ function writeToBackend(bundle, options = {}) {
772
804
  // Always stamp THIS config as the owner — a bundle read back and re-written
773
805
  // (e.g. `pair`) must not carry a record forward from wherever it came from.
774
806
  persistedBundle = { ...persistedBundle, owner: ownerFor(persistedBundle) };
807
+ // Provenance: an explicit one wins (a login or a broker mint creates a new
808
+ // family); otherwise this is a rotation of the stored family and keeps what
809
+ // the file recorded for the same server (OSK-12293).
810
+ const provenance = options.provenance ??
811
+ persistedBundle.provenance ??
812
+ storedFileProvenance(persistedBundle.serverOrigin);
813
+ if (provenance)
814
+ persistedBundle = { ...persistedBundle, provenance };
815
+ else
816
+ delete persistedBundle.provenance;
775
817
  let backend;
776
818
  if (keychain.isAvailable()) {
777
819
  if (writeToKeychain(persistedBundle)) {
@@ -660,6 +660,7 @@ const STALLED_DIAGNOSES = {
660
660
  observation_unreadable: 'the latest check could not read every configured source',
661
661
  execution_budget_exhausted: 'recent planned work was skipped — the execution budget is exhausted',
662
662
  no_advancement: 'no server-recorded advancement exists inside the declared window',
663
+ owner_action_blocked: 'a completed work run is blocked pending owner action',
663
664
  contract_version_mismatch: 'the running contract is older than the authored one; its triggers have not resynced',
664
665
  };
665
666
  /**
@@ -329,7 +329,11 @@ export declare function findBrokeredHandoffDescriptor(profile?: string): LocalBo
329
329
  * 1. the descriptor that just redeemed IS the Dedicated Runtime guest
330
330
  * hand-off file (`/run/skrr-dedicated-runtime/...`),
331
331
  * 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
332
- * 3. its stored `serverOrigin` matches the descriptor's `serverUrl`.
332
+ * 3. its stored `serverOrigin` matches the descriptor's `serverUrl`,
333
+ * 4. it is not marked `provenance: 'user_login'`. Since OSK-12293 every
334
+ * explicit sign-in (`skrr login`, `skrr pair`) records that marker in
335
+ * the file, so a login the person made seconds ago is kept and used;
336
+ * an unmarked file was written by a CLI that predates the marker.
333
337
  *
334
338
  * When provenance can't be proven the file is KEPT: an explicit `skrr login`
335
339
  * on a guest is rare but real, and a stale extra family is bounded by the
@@ -826,7 +826,11 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
826
826
  * 1. the descriptor that just redeemed IS the Dedicated Runtime guest
827
827
  * hand-off file (`/run/skrr-dedicated-runtime/...`),
828
828
  * 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
829
- * 3. its stored `serverOrigin` matches the descriptor's `serverUrl`.
829
+ * 3. its stored `serverOrigin` matches the descriptor's `serverUrl`,
830
+ * 4. it is not marked `provenance: 'user_login'`. Since OSK-12293 every
831
+ * explicit sign-in (`skrr login`, `skrr pair`) records that marker in
832
+ * the file, so a login the person made seconds ago is kept and used;
833
+ * an unmarked file was written by a CLI that predates the marker.
830
834
  *
831
835
  * When provenance can't be proven the file is KEPT: an explicit `skrr login`
832
836
  * on a guest is rare but real, and a stale extra family is bounded by the
@@ -869,6 +873,13 @@ function retirableCliAuthFamily(descriptorServerUrl) {
869
873
  }
870
874
  if (!stored?.refreshToken)
871
875
  return null;
876
+ // A family a person signed in for on purpose is never a pre-broker
877
+ // leftover: `skrr login` / `skrr pair` stamp it, and a refresh carries the
878
+ // stamp forward. Retiring it undid an explicit guest login on the very next
879
+ // command (OSK-12293). Only an UNMARKED file — written by a CLI that
880
+ // predates the marker — or a daemon-minted one is retired.
881
+ if (stored.provenance === 'user_login')
882
+ return null;
872
883
  const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
873
884
  const descriptorOrigin = descriptorServerUrl ? (0, auth_storage_1.normalizeServerOrigin)(descriptorServerUrl) : null;
874
885
  if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
@@ -987,7 +998,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
987
998
  ...(expiryToEpochMs(outcome.refreshExpiresAt) !== undefined
988
999
  ? { refreshExpiresAt: expiryToEpochMs(outcome.refreshExpiresAt) }
989
1000
  : {}),
990
- }, { serverOrigin, clearReauth: true });
1001
+ }, { serverOrigin, clearReauth: true, provenance: 'daemon_broker' });
991
1002
  // Only now: the credential is on disk, so "the predecessor dies iff the
992
1003
  // replacement is in place" holds. A server that predates the split ships no
993
1004
  // `familyId` and has already revoked eagerly — nothing to do there.
@@ -49,3 +49,24 @@ export interface BrokerRefusalDescription {
49
49
  * (`base_url_mismatch`, `rate_limited`).
50
50
  */
51
51
  export declare function describeBrokerRefusal(failure: DaemonBrokerFailure, bin?: string): BrokerRefusalDescription | null;
52
+ /**
53
+ * What to tell a person on a Dedicated Runtime guest when NO daemon answered
54
+ * the hand-off at all (OSK-12473).
55
+ *
56
+ * `describeBrokerRefusal` speaks only for a daemon that answered. On a guest
57
+ * the realistic broken state is a daemon that is absent: its credential was
58
+ * revoked and it exited by design, or systemd is cycling it, or it crashed and
59
+ * left its descriptor behind. The CLI then saw `no_bootstrap` / `network`, and
60
+ * fell to "Not signed in. Run `skrr login` first." — a login nobody performs
61
+ * on a guest, and which would only put a personal credential on a machine the
62
+ * daemon is meant to hold the account for. Say the machine's service is not
63
+ * running and name the owner's repair instead.
64
+ *
65
+ * Returns null off a guest, or for a daemon that answered (that is
66
+ * `describeBrokerRefusal`'s case).
67
+ */
68
+ export declare function describeGuestDaemonUnavailable(failure: DaemonBrokerFailure | null, opts: {
69
+ bin?: string;
70
+ onGuest: boolean;
71
+ descriptorPresent?: boolean;
72
+ }): BrokerRefusalDescription | null;