@skrr-ai/cli 0.1.58 → 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 (36) hide show
  1. package/dist/base-command.d.ts +0 -16
  2. package/dist/base-command.js +65 -11
  3. package/dist/commands/logout.d.ts +0 -46
  4. package/dist/commands/logout.js +21 -3
  5. package/dist/lib/agentic-stream.d.ts +42 -0
  6. package/dist/lib/agentic-stream.js +103 -14
  7. package/dist/lib/api-fetch.d.ts +11 -0
  8. package/dist/lib/api-fetch.js +98 -1
  9. package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
  10. package/dist/lib/daemonBrokerRefusal.js +87 -2
  11. package/dist/lib/dedicated-guest.d.ts +13 -0
  12. package/dist/lib/dedicated-guest.js +22 -0
  13. package/dist/lib/dedicated-machines.js +17 -1
  14. package/dist/lib/dedicated-service.d.ts +1 -13
  15. package/dist/lib/dedicated-service.js +7 -19
  16. package/dist/lib/login.js +10 -1
  17. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +5 -3
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -1
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +21 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
  33. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  34. package/dist/node_modules/@skrr-ai/data-provider/index.js +4391 -4363
  35. package/oclif.manifest.json +33201 -33201
  36. package/package.json +1 -1
@@ -20,22 +20,6 @@ type OclifFlagOutput = {
20
20
  type OclifArgOutput = {
21
21
  [name: string]: unknown;
22
22
  };
23
- /**
24
- * Base class for every CLI command.
25
- *
26
- * Responsibilities:
27
- * - Run the one-shot auth-out-of-config migration (Phase H).
28
- * - Parse global flags (`--bare`, `--token`) from argv once and thread
29
- * them through the credential resolver and the HTTP adapter.
30
- * - Resolve the active credential via the CredentialResolver chain —
31
- * flag → env → helper → keychain → file (Phase H.1). Returns the
32
- * source name for display in `skrr whoami` / `skrr doctor`.
33
- * - Wire the Node HttpAdapter into @skrr-ai/data-provider so
34
- * `dataService.*` calls use our fetch-based transport with bearer
35
- * auth that matches what the resolver surfaced.
36
- * - Provide `requireAuth()` for commands that need a signed-in user,
37
- * and `handleApiError()` for uniform error translation.
38
- */
39
23
  export declare abstract class BaseCommand extends Command {
40
24
  /** Add the owning agent's web detail link without overwriting resource URLs. */
41
25
  protected withAgentContext<T>(agentId: string, value: T): T & {
@@ -16,6 +16,7 @@ exports.isDaemonScopedCredential = isDaemonScopedCredential;
16
16
  exports.describeDaemonScopedCredential = describeDaemonScopedCredential;
17
17
  exports.isTypedServerRefusal = isTypedServerRefusal;
18
18
  exports.isRetryableFailure = isRetryableFailure;
19
+ const node_fs_1 = require("node:fs");
19
20
  const core_1 = require("@oclif/core");
20
21
  Object.defineProperty(exports, "Flags", { enumerable: true, get: function () { return core_1.Flags; } });
21
22
  const option_hint_1 = require("./lib/option-hint");
@@ -51,6 +52,7 @@ const update_check_1 = require("./lib/update-check");
51
52
  const web_url_1 = require("./lib/web-url");
52
53
  const edge_error_page_1 = require("./lib/edge-error-page");
53
54
  const delegated_cli_1 = require("./lib/delegated-cli");
55
+ const dedicated_guest_1 = require("./lib/dedicated-guest");
54
56
  const tasks_1 = require("./lib/tasks");
55
57
  function assigneePayloadNeedsResolution(payload) {
56
58
  const fields = [
@@ -82,6 +84,25 @@ function assigneePayloadNeedsResolution(payload) {
82
84
  * - Provide `requireAuth()` for commands that need a signed-in user,
83
85
  * and `handleApiError()` for uniform error translation.
84
86
  */
87
+ /**
88
+ * On a Dedicated Runtime guest, the description of a daemon that did not
89
+ * answer the hand-off at all — revoked and exited, cycling under systemd, or
90
+ * gone behind a stale descriptor (OSK-12473). Null anywhere else, where "Not
91
+ * signed in. Run `skrr login` first." is the right sentence.
92
+ */
93
+ function describeAbsentGuestDaemon(outcome, bin) {
94
+ const onGuest = outcome.dedicatedGuest === true || (0, dedicated_guest_1.runningOnDedicatedRuntimeGuest)();
95
+ if (!onGuest)
96
+ return null;
97
+ let descriptorPresent = false;
98
+ try {
99
+ descriptorPresent = (0, node_fs_1.existsSync)(daemonBroker_1.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
100
+ }
101
+ catch {
102
+ /* unreadable reads as absent */
103
+ }
104
+ return (0, daemonBrokerRefusal_1.describeGuestDaemonUnavailable)(outcome, { bin, onGuest, descriptorPresent });
105
+ }
85
106
  class BaseCommand extends core_1.Command {
86
107
  /** Add the owning agent's web detail link without overwriting resource URLs. */
87
108
  withAgentContext(agentId, value) {
@@ -375,7 +396,9 @@ class BaseCommand extends core_1.Command {
375
396
  }
376
397
  : null;
377
398
  }
378
- else if ((this.brokerRefusal = (0, daemonBrokerRefusal_1.describeBrokerRefusal)(autoResult.outcome, this.config.bin))) {
399
+ else if ((this.brokerRefusal =
400
+ (0, daemonBrokerRefusal_1.describeBrokerRefusal)(autoResult.outcome, this.config.bin) ??
401
+ describeAbsentGuestDaemon(autoResult.outcome, this.config.bin))) {
379
402
  // Reported by requireAuth(); nothing to print here.
380
403
  }
381
404
  else if (autoResult.outcome.reason === 'rate_limited') {
@@ -1140,19 +1163,45 @@ class BaseCommand extends core_1.Command {
1140
1163
  bin: this.config.bin,
1141
1164
  cliId: this.cliConfig ? (0, cli_id_1.resolveCliId)(this.cliConfig) : undefined,
1142
1165
  });
1143
- this.failWithCliError({
1144
- message: say(presentedCiToken
1145
- ? `Authentication failed: this is an \`osk_ci_*\` token, which only bootstraps a ` +
1166
+ // OSK-12474 — `apiFetch` asked the local daemon for a hand-off after the
1167
+ // refused refresh, and the daemon refused too. Its reason decides the
1168
+ // repair (OSK-12149, OSK-12151), and on a Dedicated guest there is no
1169
+ // login to run, so its remedy replaces the generic "Run login".
1170
+ const brokerRefusal = presentedCiToken || !e.brokerFailure
1171
+ ? null
1172
+ : (0, daemonBrokerRefusal_1.describeBrokerRefusal)(e.brokerFailure, this.config.bin);
1173
+ const renewal = refreshRefusal
1174
+ ? refreshRefusal.message.replace(/\s*Run `[^`]+` to sign in again\.$/, '')
1175
+ : "This CLI's own sign-in could not be renewed.";
1176
+ const details = {
1177
+ ...(refreshRefusal && bodyCode ? { accessTokenCode: bodyCode } : {}),
1178
+ ...(brokerRefusal
1179
+ ? { broker: brokerRefusal.broker, suggestion: brokerRefusal.remedy }
1180
+ : {}),
1181
+ };
1182
+ let authMessage;
1183
+ if (presentedCiToken) {
1184
+ authMessage =
1185
+ `Authentication failed: this is an \`osk_ci_*\` token, which only bootstraps a ` +
1146
1186
  `daemon (\`OVERSKY_TOKEN=<token> skrrd start\`). ${this.config.bin} commands do ` +
1147
- `not accept it — sign in with \`${this.config.bin} login\` instead.`
1148
- : refreshRefusal
1149
- ? `Authentication failed. ${refreshRefusal.message}`
1150
- : `Authentication failed. Run \`${this.config.bin} login\` to sign in again.`),
1187
+ `not accept it — sign in with \`${this.config.bin} login\` instead.`;
1188
+ }
1189
+ else if (brokerRefusal) {
1190
+ authMessage = `Authentication failed. ${renewal} ${brokerRefusal.headline}\n→ ${brokerRefusal.remedy}`;
1191
+ }
1192
+ else if (refreshRefusal) {
1193
+ authMessage = `Authentication failed. ${refreshRefusal.message}`;
1194
+ }
1195
+ else {
1196
+ authMessage = `Authentication failed. Run \`${this.config.bin} login\` to sign in again.`;
1197
+ }
1198
+ this.failWithCliError({
1199
+ message: say(authMessage),
1151
1200
  code: refreshRefusal?.code || bodyCode || 'AUTHENTICATION_FAILED',
1152
1201
  status: e.status,
1153
1202
  exit: exitCode,
1154
1203
  retryable: true,
1155
- ...(refreshRefusal && bodyCode ? { details: { accessTokenCode: bodyCode } } : {}),
1204
+ ...(Object.keys(details).length > 0 ? { details } : {}),
1156
1205
  });
1157
1206
  }
1158
1207
  else if ((e.status === 403 || e.status === 504) &&
@@ -1438,9 +1487,14 @@ class BaseCommand extends core_1.Command {
1438
1487
  bin: this.config.bin,
1439
1488
  cliId: this.cliConfig ? (0, cli_id_1.resolveCliId)(this.cliConfig) : undefined,
1440
1489
  });
1441
- const why = refusal
1490
+ const brokerRefusal = e.brokerFailure
1491
+ ? (0, daemonBrokerRefusal_1.describeBrokerRefusal)(e.brokerFailure, this.config.bin)
1492
+ : null;
1493
+ const why = (refusal
1442
1494
  ? refusal.message.replace(/\s*Run `[^`]+` to sign in again\.$/, '')
1443
- : 'The server did not accept this sign-in.';
1495
+ : 'The server did not accept this sign-in.') +
1496
+ // OSK-12474 — the daemon was asked for a hand-off and refused; say why.
1497
+ (brokerRefusal ? ` ${brokerRefusal.headline}` : '');
1444
1498
  this.failWithCliError({
1445
1499
  message: `Authentication failed${statusSuffix}. ${why} Your ${write.summary} was not applied; it ` +
1446
1500
  `was saved locally as ${queuedId} so it is not lost. Sign in with ` +
@@ -5,52 +5,6 @@ export declare function shouldSkipLocalLogout(input: {
5
5
  hasUnreadableEncryptedToken: boolean;
6
6
  allDevices: boolean;
7
7
  }): boolean;
8
- /**
9
- * `skrr logout` / `skrr logout --all-devices`
10
- *
11
- * Default mode (no flags): revokes the current CLI refresh session on the
12
- * server (best-effort) and clears every local backend (Keychain on macOS,
13
- * cli-auth.json fallback, and any legacy token fields in cli-config.json).
14
- * `baseURL` and `cliId` are preserved so the next `skrr login` defaults to
15
- * the same host and reuses the per-machine identity (which becomes a new
16
- * refresh family — server-side revocation invalidated the prior family).
17
- *
18
- * `--all-devices` (L7 fleet-grade logout): in addition to the local
19
- * cleanup, iterate the user's registered daemons and revoke each one
20
- * server-side. This is the threat-model-correct response for a stolen-
21
- * laptop scenario where the user wants every persistent daemon credential
22
- * gone immediately, regardless of which machine they're calling from.
23
- *
24
- * Architectural note: the L7 server-side has a bulk endpoint
25
- * (`POST /api/auth/sessions/revoke-all`) but it requires a user-scope JWT.
26
- * CLI sessions are minted with `scope: 'cli'` (interactive single-machine
27
- * use), so they cannot directly hit the bulk endpoint — that's by design,
28
- * preventing a leaked CLI credential from kicking the user out of every
29
- * other device. We instead iterate `DELETE /api/daemon-resources/:id`
30
- * which CLI scope CAN hit (per-resource ownership check, not scope check).
31
- * N round trips for N daemons; for typical fleet sizes (< 20) this is
32
- * sub-second. Each per-daemon DELETE produces its own audit row keyed by
33
- * `'user_initiated_per_device'`, which preserves forensic granularity.
34
- *
35
- * The CLI's own refresh session is NOT a Daemon resource (it's a
36
- * `scope='cli'` refresh-session row with no Daemon FK), so it's invisible
37
- * to `/api/daemon-resources`. We revoke it separately via
38
- * `revokeCurrentRefreshToken()`.
39
- *
40
- * Ordering rationale for `--all-devices`:
41
- * 1. `runAllDevices` is attempted first (remote sweep).
42
- * 2. If it throws (network error, API unreachable), the error is captured
43
- * and a warning is emitted — but execution CONTINUES to step 3.
44
- * Rationale: a user who runs `logout --all-devices` after a security
45
- * incident must not be left with live tokens on disk just because the
46
- * API was momentarily unreachable. Local cleanup is always the safer
47
- * default; re-running --all-devices later from a connected machine is
48
- * idempotent and cleans up whatever the first attempt missed.
49
- * 3. If the user explicitly declines the confirmation prompt inside
50
- * `runAllDevices`, that path calls `this.exit(1)` which invokes
51
- * `process.exit` — the try/catch is never reached, so local cleanup
52
- * is correctly skipped (user said "abort").
53
- */
54
8
  export default class Logout extends BaseCommand {
55
9
  static description: string;
56
10
  static flags: {
@@ -40,6 +40,7 @@ const config_1 = require("../lib/config");
40
40
  const first_party_harness_managed_1 = require("../lib/first-party-harness-managed");
41
41
  const auth_storage_1 = require("../lib/auth-storage");
42
42
  const daemonBroker_1 = require("../lib/daemonBroker");
43
+ const dedicated_service_1 = require("../lib/dedicated-service");
43
44
  const refresh_1 = require("../lib/refresh");
44
45
  const api_fetch_1 = require("../lib/api-fetch");
45
46
  const cred_envelope_1 = require("../lib/cred-envelope");
@@ -97,6 +98,18 @@ function shouldSkipLocalLogout(input) {
97
98
  * `process.exit` — the try/catch is never reached, so local cleanup
98
99
  * is correctly skipped (user said "abort").
99
100
  */
101
+ /**
102
+ * True only on a Dedicated Runtime guest whose platform hand-off can still
103
+ * serve this CLI. A laptop daemon's island file ALSO advertises
104
+ * `access_token`, so the descriptor alone is not evidence of a platform-managed
105
+ * machine: on a laptop, gating on it alone told a person who had just signed
106
+ * out that "skrr keeps working here through the platform's managed hand-off"
107
+ * (OSK-12467). The guest markers are what make that sentence true.
108
+ */
109
+ function platformManagedHandoff() {
110
+ return (0, dedicated_service_1.runningOnDedicatedRuntimeGuest)() && Boolean((0, daemonBroker_1.findBrokeredHandoffDescriptor)());
111
+ }
112
+ const SIGN_IN_AGAIN = 'To sign in again, run `skrr login`.';
100
113
  class Logout extends base_command_1.BaseCommand {
101
114
  static description = 'Sign out and clear locally-stored tokens. ' +
102
115
  '--all-devices also revokes every registered daemon for this account.';
@@ -134,12 +147,12 @@ class Logout extends base_command_1.BaseCommand {
134
147
  // `access_token`) there IS no local credential to clear — the CLI holds
135
148
  // a per-process token redeemed over loopback and nothing is persisted.
136
149
  // Say that honestly rather than reporting a logout that did not happen.
137
- if ((0, daemonBroker_1.findBrokeredHandoffDescriptor)()) {
150
+ if (platformManagedHandoff()) {
138
151
  this.log("This machine is signed in through the platform's managed hand-off; " +
139
152
  'there is no local credential to clear.');
140
153
  }
141
154
  else {
142
- this.log('Not signed in. Nothing to do.');
155
+ this.log(`Not signed in. Nothing to do. ${SIGN_IN_AGAIN}`);
143
156
  }
144
157
  return;
145
158
  }
@@ -261,10 +274,15 @@ class Logout extends base_command_1.BaseCommand {
261
274
  // OWN (an explicit `skrr login`); the machine's platform-managed access is
262
275
  // untouched and skrr keeps working through it. Say both, so "Signed out."
263
276
  // is not read as "this machine can no longer reach skrr" (OSK-12043).
264
- if ((0, daemonBroker_1.findBrokeredHandoffDescriptor)()) {
277
+ // Anywhere else there is no such hand-off, so say how to sign back in
278
+ // instead (OSK-12467).
279
+ if (platformManagedHandoff()) {
265
280
  this.log('Removed your own sign-in from this machine. ' +
266
281
  "skrr keeps working here through the platform's managed hand-off.");
267
282
  }
283
+ else {
284
+ this.log(SIGN_IN_AGAIN);
285
+ }
268
286
  if (sweptLegacy.length > 0) {
269
287
  this.log(`Also removed ${sweptLegacy.length} credential${sweptLegacy.length === 1 ? '' : 's'} left by the previous product identity:`);
270
288
  for (const service of sweptLegacy)
@@ -93,7 +93,35 @@ export type AgenticStreamOptions = {
93
93
  idleTerminalProbeMs?: number;
94
94
  /** How long to keep trying to attach before reporting a detach. Default 30s. */
95
95
  attachTimeoutMs?: number;
96
+ /**
97
+ * How long a prompt listens to (and throws away) input before it is shown.
98
+ * Default {@link TYPE_AHEAD_DISCARD_MS}. See {@link discardTypeAhead}.
99
+ */
100
+ typeAheadDiscardMs?: number;
96
101
  };
102
+ /**
103
+ * How long input arriving just before a prompt is shown is thrown away.
104
+ *
105
+ * Long enough for bytes already typed into the terminal to be read out of the
106
+ * kernel once stdin starts flowing (one event-loop turn in practice), short
107
+ * enough that nobody waits for it.
108
+ */
109
+ export declare const TYPE_AHEAD_DISCARD_MS = 60;
110
+ /**
111
+ * Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
112
+ *
113
+ * A key pressed while the turn was still streaming sits in the stream's buffer
114
+ * or the terminal's, and the first `readline.question` read it as the answer:
115
+ * an owner typed `y` before any `[y/N]` was visible and approved a Bash command
116
+ * they had never seen. An approval is only consent to what was on the screen,
117
+ * so every prompt now starts from an empty input: bytes the stream already
118
+ * holds are read and dropped, then stdin flows into a discarding listener for
119
+ * `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
120
+ * on for that window because a half-typed line (`y`, no Enter) is invisible to
121
+ * a read in canonical mode and would otherwise surface as soon as readline
122
+ * enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
123
+ */
124
+ export declare function discardTypeAhead(input: NodeJS.ReadableStream, windowMs?: number): Promise<void>;
97
125
  /**
98
126
  * What a permission request would DO, in one line a person can judge.
99
127
  *
@@ -467,7 +495,21 @@ export declare class AgenticStreamClient {
467
495
  * client is still showing it — the question twin of handlePermissionCancel.
468
496
  */
469
497
  private handleQuestionCancel;
498
+ /** Prompts run one at a time: two readlines on one stdin would both read a line. */
499
+ private askQueue;
470
500
  private ask;
501
+ /**
502
+ * One prompt, on a readline that exists only while the prompt is on screen.
503
+ *
504
+ * The interface used to be created at the first prompt and kept until the
505
+ * run closed, and it was created AFTER stdin had been accumulating whatever
506
+ * the person typed while the turn streamed — so the first question answered
507
+ * itself from that type-ahead (OSK-12468). Now the input is emptied first
508
+ * ({@link discardTypeAhead}), only then is the question shown, and the
509
+ * interface is closed with the answer, so nothing typed between two prompts
510
+ * can answer the second one either.
511
+ */
512
+ private askNow;
471
513
  /**
472
514
  * True the first time this (kind, identity) pair is seen; false afterwards.
473
515
  * The identity is the event's OWN — a tool-use id, a message id — never a
@@ -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:
@@ -1151,26 +1212,54 @@ class AgenticStreamClient {
1151
1212
  promptClosed: Boolean(open),
1152
1213
  });
1153
1214
  }
1215
+ /** Prompts run one at a time: two readlines on one stdin would both read a line. */
1216
+ askQueue = Promise.resolve();
1154
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) {
1155
1234
  // A prompt raised after close has nobody to answer it, and re-creating the
1156
1235
  // interface here would resurrect stdin for a run that is already over.
1157
1236
  if (this.closed || signal?.aborted)
1158
- return Promise.resolve('');
1159
- if (!this.readline) {
1160
- this.readline = (0, node_readline_1.createInterface)({
1161
- input: this.options.input ?? process.stdin,
1162
- 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);
1163
1256
  });
1164
1257
  }
1165
- if (!signal) {
1166
- return new Promise((resolve) => this.readline.question(prompt, resolve));
1258
+ finally {
1259
+ if (this.readline === rl)
1260
+ this.readline = null;
1261
+ rl.close();
1167
1262
  }
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
- });
1174
1263
  }
1175
1264
  /**
1176
1265
  * True the first time this (kind, identity) pair is seen; false afterwards.
@@ -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.
@@ -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;