@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.
- package/dist/base-command.d.ts +20 -0
- package/dist/base-command.js +151 -7
- package/dist/commands/agents/visibility.d.ts +14 -0
- package/dist/commands/agents/visibility.js +52 -0
- package/dist/commands/create-token.d.ts +0 -1
- package/dist/commands/create-token.js +3 -1
- package/dist/commands/followups/checkin.js +1 -1
- package/dist/commands/list-tokens.d.ts +0 -1
- package/dist/commands/list-tokens.js +1 -1
- package/dist/commands/login.js +10 -0
- package/dist/commands/logout.js +4 -0
- package/dist/commands/machines/dedicated/ssh-proxy.js +1 -0
- package/dist/commands/revoke-token.d.ts +0 -1
- package/dist/commands/revoke-token.js +1 -1
- package/dist/commands/store/follow.d.ts +1 -0
- package/dist/commands/store/follow.js +7 -1
- package/dist/commands/store/follows/list.d.ts +11 -0
- package/dist/commands/store/follows/list.js +29 -0
- package/dist/commands/store/follows/remove.d.ts +16 -0
- package/dist/commands/store/follows/remove.js +37 -0
- package/dist/commands/store/installs.js +6 -1
- package/dist/commands/store/publishers/profile/draft.d.ts +17 -0
- package/dist/commands/store/publishers/profile/draft.js +54 -0
- package/dist/commands/store/publishers/profile/handle.d.ts +14 -0
- package/dist/commands/store/publishers/profile/handle.js +39 -0
- package/dist/commands/store/publishers/profile/hide.d.ts +12 -0
- package/dist/commands/store/publishers/profile/hide.js +39 -0
- package/dist/commands/store/publishers/profile/preview.d.ts +11 -0
- package/dist/commands/store/publishers/profile/preview.js +39 -0
- package/dist/commands/store/publishers/profile/publish.d.ts +12 -0
- package/dist/commands/store/publishers/profile/publish.js +41 -0
- package/dist/commands/store/publishers/profile/restore.d.ts +15 -0
- package/dist/commands/store/publishers/profile/restore.js +49 -0
- package/dist/commands/store/publishers/profile/revisions.d.ts +11 -0
- package/dist/commands/store/publishers/profile/revisions.js +30 -0
- package/dist/commands/store/publishers/profile/show.d.ts +11 -0
- package/dist/commands/store/publishers/profile/show.js +39 -0
- package/dist/commands/store/review.d.ts +26 -0
- package/dist/commands/store/review.js +93 -0
- package/dist/commands/store/reviews.d.ts +29 -0
- package/dist/commands/store/reviews.js +170 -0
- package/dist/commands/store/tip.d.ts +20 -0
- package/dist/commands/store/tip.js +57 -0
- package/dist/commands/tokens/create.d.ts +28 -0
- package/dist/commands/tokens/create.js +136 -0
- package/dist/commands/tokens/list.d.ts +11 -0
- package/dist/commands/tokens/list.js +53 -0
- package/dist/commands/tokens/revoke.d.ts +14 -0
- package/dist/commands/tokens/revoke.js +42 -0
- package/dist/lib/access-tokens.d.ts +59 -0
- package/dist/lib/access-tokens.js +50 -0
- package/dist/lib/auth-core-init.d.ts +6 -0
- package/dist/lib/auth-core-init.js +28 -6
- package/dist/lib/auth-storage.d.ts +5 -0
- package/dist/lib/auth-storage.js +8 -0
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/credential-resolver.d.ts +1 -1
- package/dist/lib/credential-resolver.js +15 -3
- package/dist/lib/daemonBroker.d.ts +48 -2
- package/dist/lib/daemonBroker.js +193 -15
- package/dist/lib/daemonBrokerRefusal.d.ts +14 -0
- package/dist/lib/daemonBrokerRefusal.js +18 -1
- package/dist/lib/dedicated-ssh.d.ts +9 -0
- package/dist/lib/dedicated-ssh.js +232 -63
- package/dist/lib/harness-context.d.ts +8 -0
- package/dist/lib/harness-context.js +55 -0
- package/dist/lib/login.js +4 -1
- package/dist/lib/node-adapter.js +2 -2
- package/dist/lib/publisher-profile-flags.d.ts +19 -0
- package/dist/lib/publisher-profile-flags.js +48 -0
- package/dist/lib/refresh.d.ts +1 -1
- package/dist/lib/refresh.js +10 -1
- package/dist/lib/ssh-ordered-bytes.d.ts +8 -0
- package/dist/lib/ssh-ordered-bytes.js +31 -0
- package/dist/lib/usd-micros.d.ts +9 -0
- package/dist/lib/usd-micros.js +21 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialResolver.d.ts +22 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialResolver.js +29 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +7 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +7 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +9 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +25 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +35 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialResolver.d.ts +22 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialResolver.js +27 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +7 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +7 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +25 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +33 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +4302 -4249
- package/oclif.manifest.json +41314 -39972
- 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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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', (
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
504
|
+
try {
|
|
377
505
|
const bytes = Buffer.from(frame.data, 'base64');
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/dist/lib/node-adapter.js
CHANGED
|
@@ -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 ||
|
|
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
|
|
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
|
+
}
|
package/dist/lib/refresh.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/refresh.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|