@skrr-ai/cli 0.1.80 → 0.1.82

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 (96) hide show
  1. package/dist/base-command.d.ts +20 -0
  2. package/dist/base-command.js +151 -7
  3. package/dist/commands/agents/visibility.d.ts +14 -0
  4. package/dist/commands/agents/visibility.js +52 -0
  5. package/dist/commands/create-token.d.ts +0 -1
  6. package/dist/commands/create-token.js +3 -1
  7. package/dist/commands/followups/checkin.js +1 -1
  8. package/dist/commands/list-tokens.d.ts +0 -1
  9. package/dist/commands/list-tokens.js +1 -1
  10. package/dist/commands/login.js +10 -0
  11. package/dist/commands/logout.js +4 -0
  12. package/dist/commands/machines/dedicated/ssh-proxy.js +1 -0
  13. package/dist/commands/revoke-token.d.ts +0 -1
  14. package/dist/commands/revoke-token.js +1 -1
  15. package/dist/commands/store/follow.d.ts +1 -0
  16. package/dist/commands/store/follow.js +7 -1
  17. package/dist/commands/store/follows/list.d.ts +11 -0
  18. package/dist/commands/store/follows/list.js +29 -0
  19. package/dist/commands/store/follows/remove.d.ts +16 -0
  20. package/dist/commands/store/follows/remove.js +37 -0
  21. package/dist/commands/store/installs.js +6 -1
  22. package/dist/commands/store/publishers/profile/draft.d.ts +17 -0
  23. package/dist/commands/store/publishers/profile/draft.js +54 -0
  24. package/dist/commands/store/publishers/profile/handle.d.ts +14 -0
  25. package/dist/commands/store/publishers/profile/handle.js +39 -0
  26. package/dist/commands/store/publishers/profile/hide.d.ts +12 -0
  27. package/dist/commands/store/publishers/profile/hide.js +39 -0
  28. package/dist/commands/store/publishers/profile/preview.d.ts +11 -0
  29. package/dist/commands/store/publishers/profile/preview.js +39 -0
  30. package/dist/commands/store/publishers/profile/publish.d.ts +12 -0
  31. package/dist/commands/store/publishers/profile/publish.js +41 -0
  32. package/dist/commands/store/publishers/profile/restore.d.ts +15 -0
  33. package/dist/commands/store/publishers/profile/restore.js +49 -0
  34. package/dist/commands/store/publishers/profile/revisions.d.ts +11 -0
  35. package/dist/commands/store/publishers/profile/revisions.js +30 -0
  36. package/dist/commands/store/publishers/profile/show.d.ts +11 -0
  37. package/dist/commands/store/publishers/profile/show.js +39 -0
  38. package/dist/commands/store/review.d.ts +26 -0
  39. package/dist/commands/store/review.js +93 -0
  40. package/dist/commands/store/reviews.d.ts +29 -0
  41. package/dist/commands/store/reviews.js +170 -0
  42. package/dist/commands/store/tip.d.ts +20 -0
  43. package/dist/commands/store/tip.js +57 -0
  44. package/dist/commands/tokens/create.d.ts +28 -0
  45. package/dist/commands/tokens/create.js +136 -0
  46. package/dist/commands/tokens/list.d.ts +11 -0
  47. package/dist/commands/tokens/list.js +53 -0
  48. package/dist/commands/tokens/revoke.d.ts +14 -0
  49. package/dist/commands/tokens/revoke.js +42 -0
  50. package/dist/lib/access-tokens.d.ts +59 -0
  51. package/dist/lib/access-tokens.js +50 -0
  52. package/dist/lib/auth-core-init.d.ts +6 -0
  53. package/dist/lib/auth-core-init.js +28 -6
  54. package/dist/lib/auth-storage.d.ts +5 -0
  55. package/dist/lib/auth-storage.js +8 -0
  56. package/dist/lib/config.d.ts +8 -0
  57. package/dist/lib/credential-resolver.d.ts +1 -1
  58. package/dist/lib/credential-resolver.js +15 -3
  59. package/dist/lib/daemonBroker.d.ts +48 -2
  60. package/dist/lib/daemonBroker.js +193 -15
  61. package/dist/lib/daemonBrokerRefusal.d.ts +14 -0
  62. package/dist/lib/daemonBrokerRefusal.js +18 -1
  63. package/dist/lib/dedicated-ssh.d.ts +9 -0
  64. package/dist/lib/dedicated-ssh.js +232 -63
  65. package/dist/lib/harness-context.d.ts +8 -0
  66. package/dist/lib/harness-context.js +55 -0
  67. package/dist/lib/login.js +4 -1
  68. package/dist/lib/node-adapter.js +2 -2
  69. package/dist/lib/publisher-profile-flags.d.ts +19 -0
  70. package/dist/lib/publisher-profile-flags.js +48 -0
  71. package/dist/lib/refresh.d.ts +1 -1
  72. package/dist/lib/refresh.js +10 -1
  73. package/dist/lib/ssh-ordered-bytes.d.ts +8 -0
  74. package/dist/lib/ssh-ordered-bytes.js +31 -0
  75. package/dist/lib/usd-micros.d.ts +9 -0
  76. package/dist/lib/usd-micros.js +21 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialResolver.d.ts +22 -3
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialResolver.js +29 -5
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +7 -1
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +7 -1
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +9 -5
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +25 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +35 -1
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialResolver.d.ts +22 -3
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialResolver.js +27 -4
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +7 -1
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +7 -1
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +25 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +33 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  94. package/dist/node_modules/@skrr-ai/data-provider/index.js +4302 -4249
  95. package/oclif.manifest.json +41314 -39972
  96. package/package.json +28 -4
@@ -4,6 +4,7 @@ exports.isAnsweredBrokerRefusal = isAnsweredBrokerRefusal;
4
4
  exports.describeBrokerRefusal = describeBrokerRefusal;
5
5
  exports.describeGuestDaemonUnavailable = describeGuestDaemonUnavailable;
6
6
  exports.describeLocalDaemonUnavailable = describeLocalDaemonUnavailable;
7
+ exports.describeBrokerFailure = describeBrokerFailure;
7
8
  /**
8
9
  * True when a daemon ANSWERED the hand-off and refused it — as opposed to no
9
10
  * daemon being there to ask (`no_bootstrap`, a stale descriptor nobody listens
@@ -261,7 +262,7 @@ function describeLocalDaemonUnavailable(failure, bin = 'skrr') {
261
262
  if (!headline)
262
263
  return null;
263
264
  const remedy = 'You are not signed out: your sign-in is held by that service, which normally restarts on its own. ' +
264
- `Retry in a moment. \`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side, ` +
265
+ `Retry in a few seconds, or start it with \`${bin} daemon start\` if it stays down. \`${bin} daemon status\` and \`${bin} daemon logs\` show the service's side, ` +
265
266
  `and \`${bin} login\` signs this CLI in without it.`;
266
267
  return {
267
268
  headline: `${headline}${waited}`,
@@ -289,3 +290,19 @@ function describeLocalDaemonUnavailable(failure, bin = 'skrr') {
289
290
  },
290
291
  };
291
292
  }
293
+ /**
294
+ * The one description of a laptop or guest broker failure for every surface
295
+ * that has no separate guest probe of its own: a daemon that answered and
296
+ * refused (`describeBrokerRefusal`), else a daemon whose descriptor is on disk
297
+ * but which did not answer — down, crashed or mid-restart
298
+ * (`describeLocalDaemonUnavailable`).
299
+ *
300
+ * `requireAuth` composed these two by hand; `skrr login`'s progress line and
301
+ * headless refusal, and the mid-command 401 paths, consulted only the first,
302
+ * so a daemon that was restarting read there as "no daemon at all" — the
303
+ * generic browser refusal, or a bare "Run `skrr login`" (OSK-13099). Returns
304
+ * null only when there is genuinely nothing to say about a daemon.
305
+ */
306
+ function describeBrokerFailure(failure, bin = 'skrr') {
307
+ return describeBrokerRefusal(failure, bin) ?? describeLocalDaemonUnavailable(failure, bin);
308
+ }
@@ -104,6 +104,10 @@ export declare function installSshConfigBlock(home?: string): {
104
104
  changed: boolean;
105
105
  };
106
106
  export interface SshStartAck {
107
+ failed?: boolean;
108
+ closing?: boolean;
109
+ closed?: boolean;
110
+ retryable?: boolean;
107
111
  channelId?: string;
108
112
  ok?: boolean;
109
113
  error?: string;
@@ -113,6 +117,9 @@ export interface SshStartAck {
113
117
  key: string;
114
118
  }[];
115
119
  receiverAckV1?: boolean;
120
+ resumeV2?: boolean;
121
+ epoch?: string;
122
+ generation?: number;
116
123
  }
117
124
  export interface SshProxyOptions {
118
125
  baseURL: string;
@@ -123,6 +130,8 @@ export interface SshProxyOptions {
123
130
  /** Diagnostics; never stdout — ssh reads stdout as protocol bytes. */
124
131
  errorOutput: NodeJS.WritableStream;
125
132
  ttlSeconds?: number;
133
+ getToken?: () => Promise<string>;
134
+ recoveryMs?: number;
126
135
  /** Injected for tests. */
127
136
  connect?: (url: string, opts: Parameters<typeof io>[1]) => Socket;
128
137
  /** Injected for tests. */
@@ -23,6 +23,7 @@ const socket_io_client_1 = require("socket.io-client");
23
23
  const data_provider_1 = require("@skrr-ai/data-provider");
24
24
  const agentic_stream_1 = require("./agentic-stream");
25
25
  const dpop_auth_1 = require("./dpop-auth");
26
+ const ssh_ordered_bytes_1 = require("./ssh-ordered-bytes");
26
27
  /**
27
28
  * Owner SSH to a Dedicated Runtime — SSH protocol over the admitted relay.
28
29
  *
@@ -278,48 +279,103 @@ function describeSshStartRefusal(ack) {
278
279
  */
279
280
  function runSshProxy(options) {
280
281
  const channelId = `ssh-${(0, node_crypto_1.randomUUID)()}`;
282
+ const resumeProof = (0, node_crypto_1.randomBytes)(32).toString('hex');
281
283
  const connect = options.connect ?? socket_io_client_1.io;
282
284
  const { input, output, errorOutput } = options;
283
285
  const identity = ensureSshIdentity();
284
286
  const writeHosts = options.writeKnownHosts ?? writeKnownHosts;
287
+ const recoveryMs = options.recoveryMs ?? 45_000;
288
+ const receiver = new ssh_ordered_bytes_1.SshOrderedBytes();
285
289
  return new Promise((resolve) => {
286
290
  let started = false;
291
+ let startSent = false;
287
292
  let finished = false;
293
+ let online = false;
294
+ let attached = false;
295
+ let resumable = false;
296
+ let generation = null;
297
+ let epoch = (0, node_crypto_1.randomUUID)();
298
+ let recoveringSince = null;
299
+ let inputOffset = 0;
300
+ let attachmentSeq = 0;
301
+ let closing = false;
302
+ let lastKeepaliveAt = 0;
303
+ let pendingInput = null;
304
+ const identityAuth = () => ({ ...(0, data_provider_1.getClientIdentityAuth)(), clientPlatform: 'cli',
305
+ clientOs: process.platform === 'darwin' ? 'macos' : process.platform });
288
306
  const wsOrigin = (0, agentic_stream_1.toWebSocketOrigin)(options.baseURL);
307
+ const handshakeAuth = (token, callback) => {
308
+ // Resolve a fresh proof after token renewal on every reconnect. Never
309
+ // reuse the original handoff token or its single-use DPoP proof.
310
+ const auth = (0, dpop_auth_1.socketAuthWithDpop)({ token, ...identityAuth() }, wsOrigin);
311
+ if (typeof auth === 'function')
312
+ auth(callback);
313
+ else
314
+ callback(auth);
315
+ };
289
316
  const socket = connect(wsOrigin, {
290
317
  path: '/ws/agentic',
291
- // OSK-12020 — a DPoP-bound token proves possession on every handshake.
292
- auth: (0, dpop_auth_1.socketAuthWithDpop)({
293
- token: options.token,
294
- ...(0, data_provider_1.getClientIdentityAuth)(),
295
- clientPlatform: 'cli',
296
- clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
297
- }, wsOrigin),
298
- transports: ['websocket'],
299
- // A reconnect cannot resume a channel whose admission line the daemon
300
- // revoked on socket death — say it ended and let ssh retry fresh.
301
- reconnection: false,
318
+ auth: options.getToken ? (callback) => {
319
+ void options.getToken().then((token) => handshakeAuth(token, callback), () => {
320
+ finish(1, 'Could not renew SSH authentication. Restore skrr login and connect again.');
321
+ });
322
+ } : (0, dpop_auth_1.socketAuthWithDpop)({ token: options.token, ...identityAuth() }, wsOrigin),
323
+ transports: ['websocket'], reconnection: true,
324
+ reconnectionDelay: 1000, reconnectionDelayMax: 2000,
302
325
  });
303
- const startTimer = setTimeout(() => {
304
- finish(1, 'The machine did not answer the SSH channel in time. Its daemon may be busy or offline.');
305
- }, exports.SSH_START_TIMEOUT_MS);
306
- const onInput = (chunk) => {
326
+ const startTimer = setTimeout(() => finish(1, 'The machine did not answer the SSH channel in time. Its daemon may be busy or offline.'), exports.SSH_START_TIMEOUT_MS);
327
+ function sendPendingInput() {
328
+ if (closing || !online || !attached || !pendingInput || finished)
329
+ return;
330
+ socket.emit('dedicated:ssh:data', { channelId, data: pendingInput.bytes.toString('base64'),
331
+ resumeV2: true, epoch, generation, offset: pendingInput.offset });
332
+ }
333
+ function pumpInput() {
334
+ if (closing || !started || !resumable || !online || !attached || pendingInput || finished)
335
+ return;
336
+ // Explicit reads bound retained input even if ssh writes a large buffer.
337
+ const available = input.readableLength;
338
+ if (!available)
339
+ return;
340
+ const bytes = input.read(Math.min(16 * 1024, available));
341
+ if (!bytes?.length)
342
+ return;
343
+ pendingInput = { bytes: Buffer.from(bytes), offset: inputOffset, firstSentAt: Date.now() };
344
+ sendPendingInput();
345
+ }
346
+ const onLegacyInput = (chunk) => {
347
+ if (!online || finished)
348
+ return;
307
349
  const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
308
350
  socket.emit('dedicated:ssh:data', { channelId, data: bytes.toString('base64') });
309
351
  };
310
- function cleanup() {
311
- clearTimeout(startTimer);
312
- input.off('data', onInput);
313
- input.off('end', onInputEnded);
314
- input.off('close', onInputEnded);
315
- output.off('error', onOutputError);
316
- input.pause();
352
+ function sendResume() {
353
+ if (online && started && resumable && !attached && !finished) {
354
+ socket.emit('dedicated:ssh:resume', { leaseId: options.leaseId, channelId, resumeProof, epoch, generation, attachmentSeq });
355
+ }
356
+ }
357
+ function keepalive() {
358
+ if (started && resumable && attached && online && !finished && Date.now() - lastKeepaliveAt >= 10_000) {
359
+ lastKeepaliveAt = Date.now();
360
+ socket.emit('dedicated:ssh:keepalive', { channelId, epoch });
361
+ }
317
362
  }
318
- // The ssh client exiting is visible ONLY as stdin EOF and an EPIPE on
319
- // stdout — sshd cannot tell the ProxyCommand died until its socket sees
320
- // the stop or the transport drops. Listen for both so the channel, and
321
- // the daemon's key admission, end when ssh does instead of at idle
322
- // timeout.
363
+ const ticker = setInterval(() => {
364
+ if (finished)
365
+ return;
366
+ const now = Date.now();
367
+ if ((recoveringSince !== null && now - recoveringSince >= recoveryMs) ||
368
+ (pendingInput && now - pendingInput.firstSentAt >= recoveryMs)) {
369
+ finish(1, 'SSH connection recovery timed out; connect again.');
370
+ return;
371
+ }
372
+ if (!online && started && resumable && socket.active === false)
373
+ socket.connect();
374
+ sendResume();
375
+ sendPendingInput();
376
+ keepalive();
377
+ }, 2000);
378
+ ticker.unref?.();
323
379
  const onInputEnded = () => finish(0);
324
380
  const onOutputError = () => finish(1);
325
381
  input.once('end', onInputEnded);
@@ -329,25 +385,44 @@ function runSshProxy(options) {
329
385
  if (finished)
330
386
  return;
331
387
  finished = true;
332
- cleanup();
388
+ clearTimeout(startTimer);
389
+ clearInterval(ticker);
390
+ input.off('data', onLegacyInput);
391
+ input.off('readable', pumpInput);
392
+ input.off('end', onInputEnded);
393
+ input.off('close', onInputEnded);
394
+ output.off('error', onOutputError);
395
+ input.pause();
396
+ pendingInput = null;
333
397
  if (notice)
334
398
  errorOutput.write(`${notice}\n`);
335
- if (started)
336
- socket.emit('dedicated:ssh:stop', { channelId });
399
+ // Never queue an offline stop that might arrive after another attachment.
400
+ if (started && online)
401
+ socket.emit('dedicated:ssh:stop', { channelId, ...(resumable ? { epoch } : {}) });
337
402
  socket.disconnect();
338
403
  resolve(code);
339
404
  }
340
- socket.on('connect_error', (err) => {
341
- finish(1, `Could not connect to skrr: ${err.message}`);
405
+ socket.on('connect_error', (error) => {
406
+ if (finished || (started && resumable))
407
+ return;
408
+ finish(1, `Could not connect to skrr: ${error.message}`);
342
409
  });
343
410
  socket.on('connect', () => {
344
- socket.emit('dedicated:ssh:start', {
345
- leaseId: options.leaseId,
346
- channelId,
347
- keyType: identity.keyType,
348
- keyBlob: identity.keyBlob,
349
- receiverAckV1: true,
350
- });
411
+ if (finished)
412
+ return;
413
+ online = true;
414
+ if (started) {
415
+ sendResume();
416
+ return;
417
+ }
418
+ if (startSent) {
419
+ finish(1, 'SSH admission was interrupted before confirmation; connect again.');
420
+ return;
421
+ }
422
+ startSent = true;
423
+ socket.emit('dedicated:ssh:start', { leaseId: options.leaseId, channelId,
424
+ keyType: identity.keyType, keyBlob: identity.keyBlob, receiverAckV1: true,
425
+ resumeV2: true, resumeProof, epoch });
351
426
  });
352
427
  socket.on('dedicated:ssh:start:ack', (ack) => {
353
428
  if (!ack || ack.channelId !== channelId || started || finished)
@@ -356,48 +431,142 @@ function runSshProxy(options) {
356
431
  finish(1, describeSshStartRefusal(ack));
357
432
  return;
358
433
  }
434
+ if (ack.resumeV2 === true) {
435
+ if (ack.epoch !== epoch || !Number.isSafeInteger(ack.generation) || Number(ack.generation) < 1 ||
436
+ (generation !== null && generation !== ack.generation)) {
437
+ finish(1, 'Invalid SSH stream admission.');
438
+ return;
439
+ }
440
+ resumable = true;
441
+ generation = ack.generation;
442
+ }
359
443
  started = true;
444
+ attached = true;
360
445
  clearTimeout(startTimer);
361
- // The instance's keys, attested by the channel — write them before the
362
- // first protocol byte flows so ssh's host-key check reads the new file.
363
- // `host` is what ssh asks about (`%h`), so the pin goes under the alias;
364
- // a daemon that reported no keys becomes an honest StrictHostKeyChecking
365
- // refusal, never a silent TOFU.
366
446
  const host = `${options.leaseId}${exports.SSH_HOST_SUFFIX}`;
367
- if (!writeHosts(host, ack.hostKeys)) {
447
+ if (!writeHosts(host, ack.hostKeys))
368
448
  errorOutput.write('[skrr ssh] warning: the machine reported no host keys; the connection will be refused if none are pinned\n');
449
+ if (resumable) {
450
+ input.pause();
451
+ input.on('readable', pumpInput);
452
+ keepalive();
453
+ pumpInput();
454
+ }
455
+ else {
456
+ input.on('data', onLegacyInput);
457
+ input.resume();
458
+ }
459
+ });
460
+ socket.on('dedicated:ssh:resume:ack', (ack) => {
461
+ if (!ack || ack.channelId !== channelId || ack.epoch !== epoch || !started || !resumable || finished || attached)
462
+ return;
463
+ if (!ack.ok) {
464
+ if (ack.retryable === true)
465
+ return;
466
+ finish(1, ack.error || 'The existing SSH channel cannot resume.');
467
+ return;
468
+ }
469
+ if (ack.resumeV2 !== true || ack.generation !== generation) {
470
+ finish(1, 'Invalid SSH resume admission.');
471
+ return;
472
+ }
473
+ if (ack.closed === true) {
474
+ finish(ack.failed === true ? 1 : 0);
475
+ return;
476
+ }
477
+ closing = ack.closing === true;
478
+ attached = true;
479
+ recoveringSince = null;
480
+ lastKeepaliveAt = 0;
481
+ keepalive();
482
+ sendPendingInput();
483
+ pumpInput();
484
+ });
485
+ socket.on('dedicated:ssh:input:ack', (ack) => {
486
+ if (finished || !resumable || !pendingInput || !attached || ack?.channelId !== channelId ||
487
+ ack.epoch !== epoch || ack.generation !== generation || ack.offset !== pendingInput.offset)
488
+ return;
489
+ if (!ack.ok) {
490
+ finish(1, ack.error || 'The SSH input stream was refused.');
491
+ return;
492
+ }
493
+ if (ack.nextOffset !== pendingInput.offset + pendingInput.bytes.length) {
494
+ finish(1, 'Invalid SSH input acknowledgment.');
495
+ return;
369
496
  }
370
- input.resume();
371
- input.on('data', onInput);
497
+ inputOffset = ack.nextOffset;
498
+ pendingInput = null;
499
+ pumpInput();
372
500
  });
373
- socket.on('dedicated:ssh:data', (frame, receipt) => {
374
- if (!frame || frame.channelId !== channelId || finished)
501
+ socket.on('dedicated:ssh:data', async (frame, receipt) => {
502
+ if (!frame || frame.channelId !== channelId || finished || typeof frame.data !== 'string' || !frame.data.length)
375
503
  return;
376
- if (typeof frame.data === 'string' && frame.data.length > 0) {
504
+ try {
377
505
  const bytes = Buffer.from(frame.data, 'base64');
378
- if (typeof receipt === 'function') {
379
- // A frame can precede start-ack (sshd's banner). The owned channel
380
- // already advertised support; confirm only when stdout flushed.
381
- output.write(bytes, (error) => {
382
- receipt({ channelId, ok: !error && !finished });
383
- if (error)
384
- finish(1, 'Could not write SSH output.');
385
- });
506
+ if (frame.resumeV2 === true) {
507
+ if (frame.epoch !== epoch)
508
+ return;
509
+ if (!Number.isSafeInteger(frame.generation) || Number(frame.generation) < 1 ||
510
+ (generation !== null && frame.generation !== generation) || bytes.toString('base64') !== frame.data) {
511
+ throw new Error('invalid SSH output frame');
512
+ }
513
+ generation ??= frame.generation;
514
+ const nextOffset = await receiver.receive(frame.offset, bytes, (value) => new Promise((done, reject) => output.write(value, (error) => error ? reject(error) : done())));
515
+ receipt?.({ channelId, ok: !finished, epoch: frame.epoch, nextOffset });
386
516
  }
387
517
  else {
388
- output.write(bytes);
518
+ if (resumable)
519
+ throw new Error('unsequenced output on resumable SSH stream');
520
+ if (typeof receipt === 'function') {
521
+ output.write(bytes, (error) => {
522
+ receipt({ channelId, ok: !error && !finished });
523
+ if (error)
524
+ finish(1, 'Could not write SSH output.');
525
+ });
526
+ }
527
+ else
528
+ output.write(bytes);
389
529
  }
390
530
  }
531
+ catch {
532
+ receipt?.({ channelId, ok: false, epoch: frame.epoch });
533
+ finish(1, 'Could not preserve the SSH output stream.');
534
+ }
391
535
  });
392
- socket.on('dedicated:ssh:closed', (frame) => {
393
- if (!frame || frame.channelId !== channelId)
536
+ socket.on('dedicated:ssh:closed', (frame, receipt) => {
537
+ if (!frame || frame.channelId !== channelId || (frame.epoch && frame.epoch !== epoch))
394
538
  return;
395
- finish(0, frame.reason ? `[ssh channel closed: ${frame.reason}]` : undefined);
539
+ receipt?.({ channelId, epoch: frame.epoch, ok: true });
540
+ finish(frame.failed === true ? 1 : 0, frame.reason ? `[ssh channel closed: ${frame.reason}]` : undefined);
541
+ });
542
+ function beginRecovery() {
543
+ if (finished || !attached)
544
+ return;
545
+ attached = false;
546
+ input.pause();
547
+ epoch = (0, node_crypto_1.randomUUID)();
548
+ attachmentSeq++;
549
+ if (recoveringSince === null) {
550
+ recoveringSince = Date.now();
551
+ errorOutput.write('[skrr ssh] connection interrupted; restoring the existing SSH stream\n');
552
+ }
553
+ }
554
+ socket.on('dedicated:ssh:lost', (frame) => {
555
+ if (frame?.channelId !== channelId || frame.epoch !== epoch || !resumable || finished)
556
+ return;
557
+ beginRecovery();
558
+ sendResume();
396
559
  });
397
560
  socket.on('disconnect', () => {
398
561
  if (finished)
399
562
  return;
400
- finish(started ? 1 : 1, '[lost the connection to skrr; the SSH channel has ended]');
563
+ online = false;
564
+ input.pause();
565
+ if (!started || !resumable) {
566
+ finish(1, '[lost the connection to skrr; the SSH channel has ended]');
567
+ return;
568
+ }
569
+ beginRecovery();
401
570
  });
402
571
  });
403
572
  }
@@ -0,0 +1,8 @@
1
+ export declare function isHarnessCliContext(env?: Readonly<Record<string, string | undefined>>): boolean;
2
+ /** Thrown by `writeToBackend` when a harness-context process tries to persist. */
3
+ export declare class HarnessCredentialPersistRefused extends Error {
4
+ readonly code = "HARNESS_CONTEXT_PERSIST_REFUSED";
5
+ constructor();
6
+ }
7
+ /** The commands a harness-context process may not run (they own the stored session). */
8
+ export declare const HARNESS_REFUSED_AUTH_COMMANDS: ReadonlySet<string>;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HARNESS_REFUSED_AUTH_COMMANDS = exports.HarnessCredentialPersistRefused = void 0;
4
+ exports.isHarnessCliContext = isHarnessCliContext;
5
+ /**
6
+ * harness-context.ts — is this `skrr` running for an agent the daemon started?
7
+ * (OSK-13254)
8
+ *
9
+ * A person's `skrr` may adopt a CLI refresh session of its own through the
10
+ * local daemon's hand-off, so that signing in survives the daemon restarting
11
+ * (`daemonBroker.ts`, `maybeAutoBroker`). A `skrr` the daemon spawned for an
12
+ * agent or a tool must not: it borrows short-lived tokens from the daemon for
13
+ * each process, and never reads, writes, rotates or revokes the person's
14
+ * stored CLI session.
15
+ *
16
+ * Two daemon-set markers say so, and either is enough:
17
+ *
18
+ * - `OVERSKY_AUTONOMOUS_SESSION=1` — a harness session (`session-wrapper.ts`,
19
+ * `api-local-session-client.ts`). Such a process already never reaches the
20
+ * stored-credential chain (`BaseCommand.init` returns early on it); it is
21
+ * listed here so every guard below answers the same question.
22
+ * - `SKRR_DAEMON_CHILD` — every other agent/tool child: one-shot Bash,
23
+ * background Bash, agent terminals, harness backends
24
+ * (`daemon/src/env-sanitize.ts`, `agent-terminal-env.ts`). It survives the
25
+ * shared sanitizer, so grandchildren keep it.
26
+ *
27
+ * What this is and is not, stated so nobody leans on it harder than it holds:
28
+ * on a laptop the daemon, the agent and the person share one uid, so this is
29
+ * POLICY — a child that deletes the marker can do what the person's shell can.
30
+ * Where a uid boundary exists (Dedicated Runtime, identity-split Hosted
31
+ * Machines) the guest descriptor offers only the access-token mode, so a
32
+ * durable family cannot be minted there whatever the environment says. This
33
+ * is the same scope invariant I1 has today
34
+ * (`docs/architecture/managed-inference-execution-shapes-2026-08-12.md` §15).
35
+ */
36
+ const auth_core_1 = require("@skrr-ai/auth-core");
37
+ function isHarnessCliContext(env = process.env) {
38
+ return env.OVERSKY_AUTONOMOUS_SESSION === '1' || (0, auth_core_1.isDaemonChildEnv)(env);
39
+ }
40
+ /** Thrown by `writeToBackend` when a harness-context process tries to persist. */
41
+ class HarnessCredentialPersistRefused extends Error {
42
+ code = 'HARNESS_CONTEXT_PERSIST_REFUSED';
43
+ constructor() {
44
+ super('Refusing to store a skrr sign-in from a process the skrr background service started for an ' +
45
+ 'agent or tool. skrr here borrows short-lived tokens from the service; sign in from your own terminal.');
46
+ this.name = 'HarnessCredentialPersistRefused';
47
+ }
48
+ }
49
+ exports.HarnessCredentialPersistRefused = HarnessCredentialPersistRefused;
50
+ /** The commands a harness-context process may not run (they own the stored session). */
51
+ exports.HARNESS_REFUSED_AUTH_COMMANDS = new Set([
52
+ 'login',
53
+ 'logout',
54
+ 'pair',
55
+ ]);
package/dist/lib/login.js CHANGED
@@ -202,7 +202,10 @@ async function cliLogin(opts = {}) {
202
202
  // The daemon answered and refused. Say so before whatever comes next —
203
203
  // a browser that opens with no stated cause, or a refusal about terminals
204
204
  // that says nothing about the daemon, both hid the real reason.
205
- brokerRefusal = (0, daemonBrokerRefusal_1.describeBrokerRefusal)(brokerResult);
205
+ // A daemon whose descriptor is on disk but which did not answer (down or
206
+ // mid-restart) is described too: without it a headless `skrr login` during
207
+ // a restart said only that this shell has no terminal (OSK-13099).
208
+ brokerRefusal = (0, daemonBrokerRefusal_1.describeBrokerFailure)(brokerResult);
206
209
  if (brokerRefusal && !(0, ssh_detect_1.isNonInteractive)()) {
207
210
  console.log('');
208
211
  console.log(` Asked the local skrr service for a session first. ${brokerRefusal.headline}`);
@@ -202,7 +202,7 @@ function createNodeAdapter(opts = {}) {
202
202
  return false;
203
203
  // Never refresh a CI token — they're static by construction.
204
204
  const { token, kind } = currentCredential();
205
- if (!token || kind === 'ci')
205
+ if (!token || (0, auth_core_1.isStaticTokenKind)(kind))
206
206
  return false;
207
207
  if (refreshInFlight)
208
208
  return refreshInFlight;
@@ -241,7 +241,7 @@ function createNodeAdapter(opts = {}) {
241
241
  if (process.env.OVERSKY_SKIP_DAEMON_BROKER === '1')
242
242
  return false;
243
243
  const cred = currentCredential();
244
- if (cred.kind === 'ci')
244
+ if ((0, auth_core_1.isStaticTokenKind)(cred.kind))
245
245
  return false;
246
246
  // An operator-supplied `--token` is that operator's explicit
247
247
  // authority: when the server refuses it, the honest answer is the
@@ -0,0 +1,19 @@
1
+ import type { PublisherProfileDraft } from '@skrr-ai/data-provider';
2
+ /**
3
+ * Shared flags for `skrr store publishers profile *`.
4
+ *
5
+ * `--publish-as` names a workspace you publish for; omitted, the console is
6
+ * your personal publisher profile. The server derives the actual principal —
7
+ * this only selects which of your profiles to address.
8
+ */
9
+ export declare const publishAsFlag: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
10
+ export declare const jsonFlag: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
11
+ /** A profile draft is a document; read it from a JSON file, never from argv. */
12
+ export declare function readDraftFile(file: string): PublisherProfileDraft;
13
+ /**
14
+ * The CAS revision a state change applies to. Publish, hide and restore act
15
+ * on whatever the draft is NOW, so reading it immediately before the write is
16
+ * the intent; a caller holding a specific revision passes it and the server
17
+ * refuses a stale one.
18
+ */
19
+ export declare function currentRevision(publishAs?: string, explicit?: number): Promise<number>;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.jsonFlag = exports.publishAsFlag = void 0;
4
+ exports.readDraftFile = readDraftFile;
5
+ exports.currentRevision = currentRevision;
6
+ const node_fs_1 = require("node:fs");
7
+ const core_1 = require("@oclif/core");
8
+ const data_provider_1 = require("@skrr-ai/data-provider");
9
+ /**
10
+ * Shared flags for `skrr store publishers profile *`.
11
+ *
12
+ * `--publish-as` names a workspace you publish for; omitted, the console is
13
+ * your personal publisher profile. The server derives the actual principal —
14
+ * this only selects which of your profiles to address.
15
+ */
16
+ exports.publishAsFlag = core_1.Flags.string({
17
+ description: 'Workspace id to act for (omit for your personal publisher profile)',
18
+ });
19
+ exports.jsonFlag = core_1.Flags.boolean({ description: 'Output as JSON' });
20
+ /** A profile draft is a document; read it from a JSON file, never from argv. */
21
+ function readDraftFile(file) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
25
+ }
26
+ catch (err) {
27
+ throw new Error(`Could not read a JSON draft from ${file}: ${err.message}`);
28
+ }
29
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
30
+ throw new Error(`${file} must contain a JSON object (the profile draft)`);
31
+ }
32
+ const doc = parsed;
33
+ // Accept either the bare draft or the `profile show --json` envelope's draft.
34
+ const nested = doc.profile?.draft ?? doc.draft;
35
+ return (nested && typeof nested === 'object' ? nested : doc);
36
+ }
37
+ /**
38
+ * The CAS revision a state change applies to. Publish, hide and restore act
39
+ * on whatever the draft is NOW, so reading it immediately before the write is
40
+ * the intent; a caller holding a specific revision passes it and the server
41
+ * refuses a stale one.
42
+ */
43
+ async function currentRevision(publishAs, explicit) {
44
+ if (explicit !== undefined)
45
+ return explicit;
46
+ const { profile } = await data_provider_1.dataService.getStorePublisherProfileEditor(publishAs);
47
+ return profile.revision;
48
+ }
@@ -47,7 +47,7 @@ export interface EnsureFreshCliCredentialResult {
47
47
  pair?: TokenPair;
48
48
  /** Stable diagnostic code; never contains token or server response text. */
49
49
  refreshFailureCode?: string;
50
- skippedReason?: 'no_token' | 'ci_token' | 'external_source' | 'env_override' | 'bare_mode' | 'fresh' | 'no_refresh_token' | 'refresh_failed';
50
+ skippedReason?: 'no_token' | 'ci_token' | 'personal_access_token' | 'external_source' | 'env_override' | 'bare_mode' | 'fresh' | 'no_refresh_token' | 'refresh_failed';
51
51
  }
52
52
  /**
53
53
  * Ensure the active CLI credential has enough remaining lifetime for a
@@ -30,6 +30,7 @@ const config_1 = require("./config");
30
30
  const cli_id_1 = require("./cli-id");
31
31
  const credential_resolver_1 = require("./credential-resolver");
32
32
  const device_identity_1 = require("./device-identity");
33
+ const harness_context_1 = require("./harness-context");
33
34
  exports.CLI_REFRESH_EARLY_MS = 5 * 60 * 1000;
34
35
  /**
35
36
  * Stable origin header so the server's benign-grace matcher can identify
@@ -94,7 +95,10 @@ function canMutatePersistedCredential(credential, bareMode) {
94
95
  return false;
95
96
  if (bareMode)
96
97
  return false;
97
- if (credential.kind === 'ci')
98
+ if ((0, auth_core_1.isStaticTokenKind)(credential.kind))
99
+ return false;
100
+ // A borrower never rotates the person's stored family (OSK-13254).
101
+ if ((0, harness_context_1.isHarnessCliContext)())
98
102
  return false;
99
103
  // These sources are operator-owned or generated on demand. Never rotate
100
104
  // them into the local keychain/file store behind the operator's back.
@@ -120,6 +124,11 @@ async function ensureFreshCliCredential(opts = {}) {
120
124
  if (credential.kind === 'ci') {
121
125
  return { credential, refreshed: false, skippedReason: 'ci_token' };
122
126
  }
127
+ // A personal access token is static by construction (OSK-13208): there is
128
+ // no refresh family behind it, so there is nothing to rotate.
129
+ if (credential.kind === 'pat') {
130
+ return { credential, refreshed: false, skippedReason: 'personal_access_token' };
131
+ }
123
132
  if (!canMutatePersistedCredential(credential, Boolean(opts.bareMode))) {
124
133
  return { credential, refreshed: false, skippedReason: 'external_source' };
125
134
  }
@@ -0,0 +1,8 @@
1
+ /** A bounded receiver for a retryable SSH byte stream. One frame may be
2
+ * outstanding. A lost acknowledgment may repeat that exact frame, never its
3
+ * bytes; an overlapping, changed, or skipped frame is a stream error. */
4
+ export declare class SshOrderedBytes {
5
+ nextOffset: number;
6
+ private last?;
7
+ receive(offset: number, bytes: Buffer, write: (bytes: Buffer) => Promise<void>): Promise<number>;
8
+ }