@skrr-ai/cli 0.1.56 → 0.1.58

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 (36) hide show
  1. package/dist/base-command.d.ts +11 -0
  2. package/dist/base-command.js +80 -6
  3. package/dist/commands/agents/chat.js +5 -3
  4. package/dist/commands/agents/create.js +7 -0
  5. package/dist/commands/agents/show.js +13 -2
  6. package/dist/commands/agents/update.js +7 -0
  7. package/dist/commands/harnesses/ping.d.ts +2 -0
  8. package/dist/commands/harnesses/ping.js +10 -1
  9. package/dist/commands/login.js +20 -1
  10. package/dist/commands/logout.js +8 -0
  11. package/dist/commands/pair.js +1 -1
  12. package/dist/commands/tasks/move.js +11 -0
  13. package/dist/lib/agent-model-check.d.ts +19 -0
  14. package/dist/lib/agent-model-check.js +68 -0
  15. package/dist/lib/agentic-stream.d.ts +24 -0
  16. package/dist/lib/agentic-stream.js +117 -5
  17. package/dist/lib/auth-core-init.d.ts +0 -9
  18. package/dist/lib/auth-core-init.js +9 -0
  19. package/dist/lib/auth-storage.d.ts +42 -0
  20. package/dist/lib/auth-storage.js +75 -7
  21. package/dist/lib/commitments.js +4 -0
  22. package/dist/lib/config.d.ts +4 -0
  23. package/dist/lib/config.js +4 -2
  24. package/dist/lib/daemonBroker.d.ts +23 -1
  25. package/dist/lib/daemonBroker.js +66 -17
  26. package/dist/lib/daemonHandoff.d.ts +1 -1
  27. package/dist/lib/daemonHandoff.js +39 -1
  28. package/dist/lib/internal-id.d.ts +28 -0
  29. package/dist/lib/internal-id.js +41 -0
  30. package/dist/lib/login.js +2 -2
  31. package/dist/lib/oauthLogin.js +3 -1
  32. package/dist/lib/workflow-state-hint.d.ts +10 -0
  33. package/dist/lib/workflow-state-hint.js +27 -0
  34. package/dist/node_modules/@skrr-ai/data-provider/index.js +3292 -3281
  35. package/oclif.manifest.json +29800 -29800
  36. package/package.json +1 -1
@@ -557,6 +557,18 @@ class AgenticStreamClient {
557
557
  toolNamesByCall = new Map();
558
558
  /** `permission-ask` requestId → tool name, to name its resolution (OSK-12113). */
559
559
  toolNamesByPermission = new Map();
560
+ /**
561
+ * Permission prompts on the terminal still waiting for an answer, by
562
+ * toolUseId, so a server `permission:cancel` can close the one it names
563
+ * (OSK-12197) instead of leaving `[y/N]` open after the run stopped asking.
564
+ */
565
+ openPermissionPrompts = new Map();
566
+ /**
567
+ * Question prompts on the terminal still waiting for an answer, by
568
+ * questionId, so a server `question:cancel` can close the one it names
569
+ * (OSK-12208) the same way `permission:cancel` closes a permission prompt.
570
+ */
571
+ openQuestionPrompts = new Map();
560
572
  /** Text already written to the terminal, for suffix-only rendering. */
561
573
  renderedText = '';
562
574
  idleTimer = null;
@@ -946,10 +958,20 @@ class AgenticStreamClient {
946
958
  return;
947
959
  void this.handlePermission(event);
948
960
  });
961
+ socket.on('permission:cancel', (event) => {
962
+ if (!match(event))
963
+ return;
964
+ this.handlePermissionCancel(event);
965
+ });
949
966
  socket.on('question:ask', (event) => {
950
967
  if (match(event))
951
968
  void this.handleQuestion(event);
952
969
  });
970
+ socket.on('question:cancel', (event) => {
971
+ if (!match(event))
972
+ return;
973
+ this.handleQuestionCancel(event);
974
+ });
953
975
  socket.on('error:unauthorized', (event) => {
954
976
  if (match(event))
955
977
  this.finish('failed', event);
@@ -1008,7 +1030,16 @@ class AgenticStreamClient {
1008
1030
  });
1009
1031
  return;
1010
1032
  }
1011
- const answer = await this.ask(`Allow ${describePermission(event)}? [y/N] `);
1033
+ const prompt = new AbortController();
1034
+ if (event.toolUseId)
1035
+ this.openPermissionPrompts.set(event.toolUseId, prompt);
1036
+ const answer = await this.ask(`Allow ${describePermission(event)}? [y/N] `, prompt.signal);
1037
+ if (event.toolUseId)
1038
+ this.openPermissionPrompts.delete(event.toolUseId);
1039
+ // Closed by the server (it stopped waiting, or another surface answered):
1040
+ // there is nothing left to answer, and a late "no" would read as a person's.
1041
+ if (prompt.signal.aborted)
1042
+ return;
1012
1043
  const approved = /^y(es)?$/i.test(answer.trim());
1013
1044
  this.socket.emit(approved ? 'permission:approve' : 'permission:deny', {
1014
1045
  sessionId: this.options.sessionId,
@@ -1043,9 +1074,20 @@ class AgenticStreamClient {
1043
1074
  const interactive = this.options.input !== undefined || Boolean(process.stdin.isTTY);
1044
1075
  const answers = {};
1045
1076
  if (interactive) {
1077
+ const prompt = new AbortController();
1078
+ if (event.questionId)
1079
+ this.openQuestionPrompts.set(event.questionId, prompt);
1046
1080
  for (const question of event.questions ?? []) {
1047
- answers[question.header ?? question.question] = await this.ask(`${question.header ?? 'Question'}: ${question.question}\n> `);
1081
+ if (prompt.signal.aborted)
1082
+ break;
1083
+ answers[question.header ?? question.question] = await this.ask(`${question.header ?? 'Question'}: ${question.question}\n> `, prompt.signal);
1048
1084
  }
1085
+ if (event.questionId)
1086
+ this.openQuestionPrompts.delete(event.questionId);
1087
+ // Closed by the server (it stopped waiting, or the turn ended): an answer
1088
+ // sent now would be read by nobody, and would look like a person's.
1089
+ if (prompt.signal.aborted)
1090
+ return;
1049
1091
  }
1050
1092
  else {
1051
1093
  answers._dismissed = exports.NON_INTERACTIVE_QUESTION_DISMISSAL;
@@ -1064,10 +1106,55 @@ class AgenticStreamClient {
1064
1106
  ...(interactive ? {} : { nonInteractive: true }),
1065
1107
  });
1066
1108
  }
1067
- ask(prompt) {
1109
+ /**
1110
+ * The server closed a permission request (OSK-12197): it expired unanswered,
1111
+ * the pre-execution step gave up, or another surface answered it. Say so,
1112
+ * and close the terminal prompt if this client is still showing it.
1113
+ */
1114
+ handlePermissionCancel(event) {
1115
+ const toolUseId = typeof event.toolUseId === 'string' ? event.toolUseId : undefined;
1116
+ const open = toolUseId ? this.openPermissionPrompts.get(toolUseId) : undefined;
1117
+ // Every answer is echoed to the whole session as `answered` — including
1118
+ // this client's own. Only one that closes a prompt still open HERE is news.
1119
+ if (event.reason === 'answered' && !open)
1120
+ return;
1121
+ if (!this.firstTimeSeen('permission-cancel', toolUseId))
1122
+ return;
1123
+ if (toolUseId)
1124
+ this.openPermissionPrompts.delete(toolUseId);
1125
+ open?.abort();
1126
+ this.emit({
1127
+ ...event,
1128
+ type: 'permission.cancelled',
1129
+ promptClosed: Boolean(open),
1130
+ ...(event.reason === 'answered' && !event.message
1131
+ ? { message: 'answered on another surface' }
1132
+ : {}),
1133
+ });
1134
+ }
1135
+ /**
1136
+ * The server closed a question (OSK-12208): nobody answered it in time, or
1137
+ * the turn ended first. Say so, and close the terminal prompt if this
1138
+ * client is still showing it — the question twin of handlePermissionCancel.
1139
+ */
1140
+ handleQuestionCancel(event) {
1141
+ const questionId = typeof event.questionId === 'string' ? event.questionId : undefined;
1142
+ if (!this.firstTimeSeen('question-cancel', questionId))
1143
+ return;
1144
+ const open = questionId ? this.openQuestionPrompts.get(questionId) : undefined;
1145
+ if (questionId)
1146
+ this.openQuestionPrompts.delete(questionId);
1147
+ open?.abort();
1148
+ this.emit({
1149
+ ...event,
1150
+ type: 'question.cancelled',
1151
+ promptClosed: Boolean(open),
1152
+ });
1153
+ }
1154
+ ask(prompt, signal) {
1068
1155
  // A prompt raised after close has nobody to answer it, and re-creating the
1069
1156
  // interface here would resurrect stdin for a run that is already over.
1070
- if (this.closed)
1157
+ if (this.closed || signal?.aborted)
1071
1158
  return Promise.resolve('');
1072
1159
  if (!this.readline) {
1073
1160
  this.readline = (0, node_readline_1.createInterface)({
@@ -1075,7 +1162,15 @@ class AgenticStreamClient {
1075
1162
  output: process.stderr,
1076
1163
  });
1077
1164
  }
1078
- return new Promise((resolve) => this.readline.question(prompt, resolve));
1165
+ if (!signal) {
1166
+ return new Promise((resolve) => this.readline.question(prompt, resolve));
1167
+ }
1168
+ return new Promise((resolve) => {
1169
+ // readline does not call back for a question aborted by its signal, so
1170
+ // the abort settles the prompt itself.
1171
+ signal.addEventListener('abort', () => resolve(''), { once: true });
1172
+ this.readline.question(prompt, { signal }, resolve);
1173
+ });
1079
1174
  }
1080
1175
  /**
1081
1176
  * True the first time this (kind, identity) pair is seen; false afterwards.
@@ -1176,10 +1271,27 @@ class AgenticStreamClient {
1176
1271
  output.write(`\n[${event.type}] ${describePermission(event)}\n`);
1177
1272
  this.renderedText = this.accumulated;
1178
1273
  }
1274
+ else if (event.type === 'permission.cancelled') {
1275
+ // A request the run stopped waiting for (OSK-12197). Without this line
1276
+ // an expired approval looked exactly like one still being asked.
1277
+ const why = typeof event.message === 'string' && event.message
1278
+ ? event.message
1279
+ : `closed (${String(event.reason ?? 'cancelled')})`;
1280
+ output.write(`\n[permission] ${String(event.toolName ?? 'tool')}: ${why}\n`);
1281
+ this.renderedText = this.accumulated;
1282
+ }
1179
1283
  else if (event.type === 'question.requested') {
1180
1284
  output.write(`\n[${event.type}]\n`);
1181
1285
  this.renderedText = this.accumulated;
1182
1286
  }
1287
+ else if (event.type === 'question.cancelled') {
1288
+ // A question the run stopped waiting for (OSK-12208).
1289
+ const why = typeof event.message === 'string' && event.message
1290
+ ? event.message
1291
+ : `closed (${String(event.reason ?? 'cancelled')})`;
1292
+ output.write(`\n[question] ${why}\n`);
1293
+ this.renderedText = this.accumulated;
1294
+ }
1183
1295
  else if (event.type === 'permission.approved' && event.auto === true) {
1184
1296
  // Said out loud, because nobody was asked: the flag approved it, and a
1185
1297
  // reader of this log should see which tools that covered (OSK-10323).
@@ -36,15 +36,6 @@ export declare function plainCredentialEnvelopeWarning(args: readonly unknown[])
36
36
  * own when a local daemon can hand a credential over.
37
37
  */
38
38
  export declare const CREDENTIAL_KEY_CHANGED_NOTICE: string;
39
- /**
40
- * What the CLI prints for one auth-core warning: a replacement line, nothing
41
- * (`''`), or null for "print it as auth-core wrote it".
42
- *
43
- * Stateful by design — the process that detected a key change has already
44
- * told the person why every old credential is unreadable, so the per-value
45
- * decrypt failures that follow add nothing. A fresh process starts clean, and
46
- * a later genuinely unreadable credential still gets its own OSK-12180 line.
47
- */
48
39
  export declare function createCredentialEnvelopeWarningPresenter(): (args: readonly unknown[]) => string | null;
49
40
  type DedicatedGenerationPreparationOptions = {
50
41
  machineUuid?: string;
@@ -106,6 +106,8 @@ exports.CREDENTIAL_KEY_CHANGED_NOTICE = "note: this machine's credential key cha
106
106
  * decrypt failures that follow add nothing. A fresh process starts clean, and
107
107
  * a later genuinely unreadable credential still gets its own OSK-12180 line.
108
108
  */
109
+ /** auth-core's device-identity warning for a private key it could not open. */
110
+ const DEVICE_KEY_DECRYPT_FAILED = '[deviceIdentity] private_key_decrypt_failed';
109
111
  function createCredentialEnvelopeWarningPresenter() {
110
112
  let keyChanged = false;
111
113
  return (args) => {
@@ -130,6 +132,13 @@ function createCredentialEnvelopeWarningPresenter() {
130
132
  // already said why. A MALFORMED envelope is a different fact and still prints.
131
133
  if (keyChanged && event === 'cred_envelope.read.decrypt_failed')
132
134
  return '';
135
+ // The device private key is one of those values: auth-core's device
136
+ // identity loads it next, cannot open it, and mints a new keypair. It
137
+ // logs that through its own `[deviceIdentity]` prefix, which reached the
138
+ // terminal as a raw `[auth-core warn]` line right under the notice. The
139
+ // notice covers it; without a key change it is a real fault and prints.
140
+ if (keyChanged && message === DEVICE_KEY_DECRYPT_FAILED)
141
+ return '';
133
142
  return plainCredentialEnvelopeWarning(args);
134
143
  };
135
144
  }
@@ -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
@@ -48,6 +74,22 @@ export interface WriteOptions {
48
74
  * retiring it. Everything else should keep using `readFromBackend()`.
49
75
  */
50
76
  export declare function readFromFile(): AuthBundle | null;
77
+ /**
78
+ * The refresh family a stored `cli-auth.json` carries, whether or not it also
79
+ * carries an access token — for the Dedicated guest's retirement of a
80
+ * pre-broker file only (OSK-12194).
81
+ *
82
+ * `readFromFile()` treats a file with an empty `token` as no credential, which
83
+ * is right for signing in and wrong here: the realistic pre-broker shape is
84
+ * `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
85
+ * lapsed, and a reader that returns nothing for it retires nothing. Same
86
+ * ownership rules as `readFromFile()`; never rewrites the file.
87
+ */
88
+ export declare function readRefreshFamilyFromFile(): {
89
+ refreshToken: string;
90
+ serverOrigin?: string;
91
+ provenance?: CredentialProvenance;
92
+ } | null;
51
93
  /**
52
94
  * Delete `~/.skrr/cli-auth.json` only (the keychain is untouched). Exported
53
95
  * for the brokered-hand-off migration cleanup; `skrr logout` continues to
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.backends = void 0;
37
37
  exports.readFromFile = readFromFile;
38
+ exports.readRefreshFamilyFromFile = readRefreshFamilyFromFile;
38
39
  exports.deleteFileBackend = deleteFileBackend;
39
40
  exports.readFromBackend = readFromBackend;
40
41
  exports.describeForeignCredential = describeForeignCredential;
@@ -105,6 +106,12 @@ const cli_id_1 = require("./cli-id");
105
106
  * `auth.json`, deliberately distinct so the two binaries never clobber
106
107
  * each other's tokens (CLI holds scope=cli, daemon holds scope=daemon). */
107
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
+ }
108
115
  /**
109
116
  * The config root — the same one `cli-config.json` (and so `cliId`) lives in.
110
117
  *
@@ -234,13 +241,54 @@ function judgeOwnership(bundle, me, ownSlot) {
234
241
  function readFromFile() {
235
242
  return readFileAssessed().bundle;
236
243
  }
244
+ /**
245
+ * The refresh family a stored `cli-auth.json` carries, whether or not it also
246
+ * carries an access token — for the Dedicated guest's retirement of a
247
+ * pre-broker file only (OSK-12194).
248
+ *
249
+ * `readFromFile()` treats a file with an empty `token` as no credential, which
250
+ * is right for signing in and wrong here: the realistic pre-broker shape is
251
+ * `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
252
+ * lapsed, and a reader that returns nothing for it retires nothing. Same
253
+ * ownership rules as `readFromFile()`; never rewrites the file.
254
+ */
255
+ function readRefreshFamilyFromFile() {
256
+ const bundle = readFileAssessed({ requireAccessToken: false }).bundle;
257
+ if (!bundle?.refreshToken)
258
+ return null;
259
+ return {
260
+ refreshToken: bundle.refreshToken,
261
+ ...(bundle.serverOrigin ? { serverOrigin: bundle.serverOrigin } : {}),
262
+ ...(bundle.provenance ? { provenance: bundle.provenance } : {}),
263
+ };
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
+ }
237
285
  /**
238
286
  * This root's file, refused when it belongs to another config; else, under a
239
287
  * root override, the pre-override `$HOME/.skrr` file when it is positively ours.
240
288
  */
241
- function readFileAssessed() {
289
+ function readFileAssessed(opts = {}) {
242
290
  const me = currentOwner();
243
- const own = readFileAt(authFilePath(), true);
291
+ const own = readFileAt(authFilePath(), true, opts);
244
292
  if (own) {
245
293
  const judged = judgeOwnership(own, me, true);
246
294
  return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
@@ -248,7 +296,7 @@ function readFileAssessed() {
248
296
  const legacy = legacyAuthFilePath();
249
297
  if (path.resolve(legacy) === path.resolve(authFilePath()))
250
298
  return { bundle: null };
251
- const adopted = readFileAt(legacy, false);
299
+ const adopted = readFileAt(legacy, false, opts);
252
300
  if (adopted && judgeOwnership(adopted, me, false).ours)
253
301
  return { bundle: adopted };
254
302
  return { bundle: null };
@@ -265,7 +313,7 @@ function legacyFileIsOurs() {
265
313
  * Parse one credential file. `upgradeInPlace` lets the envelope migration
266
314
  * rewrite it — true only for this root's own file, never a migration source.
267
315
  */
268
- function readFileAt(p, upgradeInPlace) {
316
+ function readFileAt(p, upgradeInPlace, opts = {}) {
269
317
  if (!fs.existsSync(p))
270
318
  return null;
271
319
  let raw;
@@ -282,7 +330,9 @@ function readFileAt(p, upgradeInPlace) {
282
330
  catch {
283
331
  return null;
284
332
  }
285
- if (typeof parsed.token !== 'string' || parsed.token.length === 0) {
333
+ const requireAccessToken = opts.requireAccessToken !== false;
334
+ const hasAccessToken = typeof parsed.token === 'string' && parsed.token.length > 0;
335
+ if (!hasAccessToken && requireAccessToken) {
286
336
  return null;
287
337
  }
288
338
  // L12 — route the secret string fields through the envelope. Mixed
@@ -293,13 +343,16 @@ function readFileAt(p, upgradeInPlace) {
293
343
  const owner = parseOwner(parsed.owner);
294
344
  const ownerRoot = owner?.configRoot ? path.resolve(owner.configRoot) : undefined;
295
345
  const decryptOptions = ownerRoot && ownerRoot !== path.resolve((0, config_1.configRoot)()) ? { foreignOwnerRoot: ownerRoot } : {};
296
- const tokenResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions);
346
+ const tokenResult = hasAccessToken
347
+ ? (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions)
348
+ : { plaintext: '', needsMigration: false };
297
349
  if (tokenResult.plaintext === null) {
298
350
  // Envelope on disk but no DEK to unwrap → treat as missing so the
299
351
  // caller falls through to a clean `skrr login`.
300
352
  return null;
301
353
  }
302
- let needsMigration = tokenResult.needsMigration;
354
+ // A token-less read is never written back: there is no access token to seal.
355
+ let needsMigration = hasAccessToken && tokenResult.needsMigration;
303
356
  const bundle = { token: tokenResult.plaintext };
304
357
  if (typeof parsed.expiresAt === 'number' && Number.isFinite(parsed.expiresAt)) {
305
358
  bundle.expiresAt = parsed.expiresAt;
@@ -323,6 +376,9 @@ function readFileAt(p, upgradeInPlace) {
323
376
  }
324
377
  if (owner)
325
378
  bundle.owner = owner;
379
+ const provenance = parseProvenance(parsed.provenance);
380
+ if (provenance)
381
+ bundle.provenance = provenance;
326
382
  // Best-effort upgrade: re-write the bundle so legacy plaintext fields
327
383
  // become wrapped on the next read. A failure here doesn't break the
328
384
  // read path — the caller already has the plaintext bundle.
@@ -387,6 +443,8 @@ function writeToFile(bundle) {
387
443
  // Rewrites of an existing bundle (the envelope upgrade) keep its recorded
388
444
  // owner; everything else is stamped with this config.
389
445
  wireBundle.owner = bundle.owner ?? ownerFor(bundle);
446
+ if (bundle.provenance)
447
+ wireBundle.provenance = bundle.provenance;
390
448
  // tmp + rename so no racing reader ever sees a partial file; chmod 0600
391
449
  // on the final file so the same-UID blast-radius is as narrow as the
392
450
  // file backend can offer.
@@ -746,6 +804,16 @@ function writeToBackend(bundle, options = {}) {
746
804
  // Always stamp THIS config as the owner — a bundle read back and re-written
747
805
  // (e.g. `pair`) must not carry a record forward from wherever it came from.
748
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;
749
817
  let backend;
750
818
  if (keychain.isAvailable()) {
751
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
  /**
@@ -1334,6 +1335,9 @@ function formatCommitmentExplanation(explanation) {
1334
1335
  : explanation.verdict === 'quiet'
1335
1336
  ? `Chose to stay quiet at: ${explanation.stoppedAt}`
1336
1337
  : `Stopped at: ${explanation.stoppedAt}`);
1338
+ // What the next Run may do without asking — an Ask-mode Agent can ask
1339
+ // nobody on an unattended Run (OSK-12201). Same lines as show/preflight.
1340
+ lines.push(...permissionLines(explanation.permissions));
1337
1341
  return lines;
1338
1342
  }
1339
1343
  /**
@@ -190,6 +190,10 @@ export declare function isEnvAuthOverride(): boolean;
190
190
  * resolver and every existing install read it.
191
191
  */
192
192
  export declare function configRoot(env?: NodeJS.ProcessEnv): string;
193
+ /** The root this machine uses when nothing relocates it (`~/.skrr`). */
194
+ export declare function defaultConfigRoot(): string;
195
+ /** Is this process on the primary root, however it was reached? */
196
+ export declare function isDefaultConfigRoot(env?: NodeJS.ProcessEnv): boolean;
193
197
  /**
194
198
  * The machine cliId THIS config root mints when it has none stored
195
199
  * (OSK-12179). The primary root keeps the historical (host, user) id; any other
@@ -39,6 +39,8 @@ exports.describeDeployment = describeDeployment;
39
39
  exports.canonicalizeKnownBaseURL = canonicalizeKnownBaseURL;
40
40
  exports.isEnvAuthOverride = isEnvAuthOverride;
41
41
  exports.configRoot = configRoot;
42
+ exports.defaultConfigRoot = defaultConfigRoot;
43
+ exports.isDefaultConfigRoot = isDefaultConfigRoot;
42
44
  exports.machineCliId = machineCliId;
43
45
  exports.priorConfigDir = priorConfigDir;
44
46
  exports.loadConfig = loadConfig;
@@ -276,8 +278,8 @@ function defaultConfigRoot() {
276
278
  return (0, auth_core_1.resolveConfigRoot)({});
277
279
  }
278
280
  /** Is this process on the primary root, however it was reached? */
279
- function isDefaultConfigRoot() {
280
- return path.resolve(configRoot()) === path.resolve(defaultConfigRoot());
281
+ function isDefaultConfigRoot(env = process.env) {
282
+ return path.resolve(configRoot(env)) === path.resolve(defaultConfigRoot());
281
283
  }
282
284
  /**
283
285
  * The machine cliId THIS config root mints when it has none stored
@@ -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
@@ -342,6 +346,24 @@ export declare function maybeRetireStoredCliAuthFile(opts: {
342
346
  descriptorPath?: string;
343
347
  descriptorServerUrl?: string;
344
348
  }): Promise<void>;
349
+ /**
350
+ * Should this process broker BEFORE it reads the stored credential?
351
+ * (OSK-12194)
352
+ *
353
+ * True only on a brokered Dedicated guest: the guest hand-off descriptor is
354
+ * live and offers the access-token mode and NOT the durable mint (its secret
355
+ * can only redeem, so any `cli-auth.json` here predates broker mode), and that
356
+ * file carries a refresh family for the descriptor's own server — the same
357
+ * provenance guard the retirement applies.
358
+ *
359
+ * Without this, the retirement could never run: it runs after a successful
360
+ * redeem, the auto-broker runs only when the resolver found no token, and the
361
+ * resolver adopts a file that carries one — so a LIVE pre-broker family was
362
+ * used and kept forever, which is exactly the case the cleanup exists for.
363
+ * A laptop has no guest descriptor and is untouched: there `cli-auth.json` is
364
+ * a legitimate login.
365
+ */
366
+ export declare function shouldBrokerBeforeStoredCredential(descriptorPath?: string): boolean;
345
367
  /**
346
368
  * Convenience wrapper: run the broker call and, on a durable-mint success,
347
369
  * persist the returned bundle through the canonical Sky CLI auth backend
@@ -86,6 +86,7 @@ exports.redeemBrokeredHandoffToken = redeemBrokeredHandoffToken;
86
86
  exports.__resetBrokeredHandoffForTest = __resetBrokeredHandoffForTest;
87
87
  exports.findBrokeredHandoffDescriptor = findBrokeredHandoffDescriptor;
88
88
  exports.maybeRetireStoredCliAuthFile = maybeRetireStoredCliAuthFile;
89
+ exports.shouldBrokerBeforeStoredCredential = shouldBrokerBeforeStoredCredential;
89
90
  exports.attemptDaemonBrokerLoginAndPersist = attemptDaemonBrokerLoginAndPersist;
90
91
  exports.maybeAutoBroker = maybeAutoBroker;
91
92
  const fs = __importStar(require("node:fs"));
@@ -825,7 +826,11 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
825
826
  * 1. the descriptor that just redeemed IS the Dedicated Runtime guest
826
827
  * hand-off file (`/run/skrr-dedicated-runtime/...`),
827
828
  * 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
828
- * 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.
829
834
  *
830
835
  * When provenance can't be proven the file is KEPT: an explicit `skrr login`
831
836
  * on a guest is rare but real, and a stale extra family is bounded by the
@@ -837,32 +842,76 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
837
842
  async function maybeRetireStoredCliAuthFile(opts) {
838
843
  if (opts.descriptorPath !== exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR)
839
844
  return;
840
- let stored;
841
- try {
842
- stored = (0, auth_storage_1.readFromFile)();
843
- }
844
- catch {
845
- return;
846
- }
847
- if (!stored?.refreshToken)
848
- return;
849
- const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
850
- const descriptorOrigin = opts.descriptorServerUrl
851
- ? (0, auth_storage_1.normalizeServerOrigin)(opts.descriptorServerUrl)
852
- : null;
853
- if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
845
+ const stale = retirableCliAuthFamily(opts.descriptorServerUrl);
846
+ if (!stale)
854
847
  return;
855
848
  // The revoke is authenticated by the refresh token itself — presenting it
856
849
  // is the proof of ownership. `revokeDaemonRefreshSession` swallows its own
857
850
  // network/HTTP failures (best-effort by contract); the delete still runs.
858
851
  try {
859
- await (0, auth_core_1.revokeDaemonRefreshSession)(descriptorOrigin, stored.refreshToken);
852
+ await (0, auth_core_1.revokeDaemonRefreshSession)(stale.origin, stale.refreshToken);
860
853
  }
861
854
  catch {
862
855
  /* best-effort — delete proceeds regardless */
863
856
  }
864
857
  (0, auth_storage_1.deleteFileBackend)();
865
858
  }
859
+ /**
860
+ * Guards 2 and 3 of the retirement above: the stored file's refresh family,
861
+ * when its origin matches the descriptor's. Read through
862
+ * `readRefreshFamilyFromFile`, which accepts a file whose access token is
863
+ * empty — the realistic pre-broker shape once that token lapsed, and the one
864
+ * `readFromFile` reports as no credential at all (OSK-12194).
865
+ */
866
+ function retirableCliAuthFamily(descriptorServerUrl) {
867
+ let stored;
868
+ try {
869
+ stored = (0, auth_storage_1.readRefreshFamilyFromFile)();
870
+ }
871
+ catch {
872
+ return null;
873
+ }
874
+ if (!stored?.refreshToken)
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;
883
+ const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
884
+ const descriptorOrigin = descriptorServerUrl ? (0, auth_storage_1.normalizeServerOrigin)(descriptorServerUrl) : null;
885
+ if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
886
+ return null;
887
+ return { origin: descriptorOrigin, refreshToken: stored.refreshToken };
888
+ }
889
+ /**
890
+ * Should this process broker BEFORE it reads the stored credential?
891
+ * (OSK-12194)
892
+ *
893
+ * True only on a brokered Dedicated guest: the guest hand-off descriptor is
894
+ * live and offers the access-token mode and NOT the durable mint (its secret
895
+ * can only redeem, so any `cli-auth.json` here predates broker mode), and that
896
+ * file carries a refresh family for the descriptor's own server — the same
897
+ * provenance guard the retirement applies.
898
+ *
899
+ * Without this, the retirement could never run: it runs after a successful
900
+ * redeem, the auto-broker runs only when the resolver found no token, and the
901
+ * resolver adopts a file that carries one — so a LIVE pre-broker family was
902
+ * used and kept forever, which is exactly the case the cleanup exists for.
903
+ * A laptop has no guest descriptor and is untouched: there `cli-auth.json` is
904
+ * a legitimate login.
905
+ */
906
+ function shouldBrokerBeforeStoredCredential(descriptorPath = exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR) {
907
+ const bootstrap = readBootstrap(descriptorPath);
908
+ if (!bootstrap)
909
+ return false;
910
+ const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
911
+ if (!modes.includes('access_token') || modes.includes('refresh_family'))
912
+ return false;
913
+ return retirableCliAuthFamily(bootstrap.serverUrl) !== null;
914
+ }
866
915
  /** Bound the confirm so a slow server cannot stall a login that already worked. */
867
916
  const CONFIRM_TIMEOUT_MS = 10_000;
868
917
  /**
@@ -949,7 +998,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
949
998
  ...(expiryToEpochMs(outcome.refreshExpiresAt) !== undefined
950
999
  ? { refreshExpiresAt: expiryToEpochMs(outcome.refreshExpiresAt) }
951
1000
  : {}),
952
- }, { serverOrigin, clearReauth: true });
1001
+ }, { serverOrigin, clearReauth: true, provenance: 'daemon_broker' });
953
1002
  // Only now: the credential is on disk, so "the predecessor dies iff the
954
1003
  // replacement is in place" holds. A server that predates the split ships no
955
1004
  // `familyId` and has already revoked eagerly — nothing to do there.
@@ -40,7 +40,7 @@ export interface DaemonHandoffOutcome {
40
40
  * The two absent cases are separated because they need different work — one
41
41
  * needs a binary fetched, the other only needs the service registered.
42
42
  */
43
- reason?: 'opted-out' | 'brokered' | 'no-binary' | 'legacy-binary' | 'unsupported-daemon' | 'no-service';
43
+ reason?: 'opted-out' | 'brokered' | 'no-binary' | 'legacy-binary' | 'unsupported-daemon' | 'no-service' | 'foreign-root' | 'mint-refused' | 'platform-managed';
44
44
  }
45
45
  export declare function handOffToLocalDaemon(env?: NodeJS.ProcessEnv, opts?: {
46
46
  loginFlow?: string;