@skrr-ai/cli 0.1.79 → 0.1.81
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 +4 -0
- package/dist/base-command.js +40 -4
- 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/inbox/index.js +4 -2
- package/dist/commands/list-tokens.d.ts +0 -1
- package/dist/commands/list-tokens.js +1 -1
- package/dist/commands/login.d.ts +8 -3
- package/dist/commands/login.js +14 -4
- 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/spaces/workflows/events.js +9 -2
- 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/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/agentic-stream.js +13 -4
- package/dist/lib/api-fetch.js +6 -2
- package/dist/lib/auth-core-init.d.ts +6 -0
- package/dist/lib/auth-core-init.js +28 -6
- package/dist/lib/brokered-dpop.d.ts +34 -0
- package/dist/lib/brokered-dpop.js +89 -0
- package/dist/lib/credential-resolver.d.ts +1 -1
- package/dist/lib/credential-resolver.js +8 -2
- package/dist/lib/daemonBroker.d.ts +84 -4
- package/dist/lib/daemonBroker.js +209 -40
- package/dist/lib/daemonBrokerRefusal.d.ts +26 -0
- package/dist/lib/daemonBrokerRefusal.js +83 -4
- package/dist/lib/daemonRestartWait.d.ts +39 -0
- package/dist/lib/daemonRestartWait.js +181 -0
- package/dist/lib/dedicated-service.js +10 -5
- package/dist/lib/dedicated-ssh.d.ts +9 -0
- package/dist/lib/dedicated-ssh.js +235 -63
- package/dist/lib/dedicated-terminal.js +6 -3
- package/dist/lib/dpop-auth.d.ts +35 -0
- package/dist/lib/dpop-auth.js +57 -0
- package/dist/lib/login.d.ts +6 -0
- package/dist/lib/login.js +13 -4
- package/dist/lib/node-adapter.js +6 -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 +6 -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/cliHandoffWire.d.ts +42 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +35 -3
- 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/credentialSession.d.ts +49 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +47 -9
- 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/deviceKey.d.ts +56 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +42 -20
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.d.ts +101 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +179 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +23 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +42 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +32 -2
- 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/credentialSession.d.ts +49 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +47 -9
- 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/deviceKey.d.ts +56 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +39 -20
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.d.ts +101 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +138 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +5 -1
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +3895 -3882
- package/oclif.manifest.json +34985 -33643
- package/package.json +28 -4
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_POLL_MS = exports.SUPERVISED_GRACE_MS = exports.DEFAULT_RESTART_WAIT_MS = exports.STARTING_WINDOW_MS = void 0;
|
|
37
|
+
exports.probeDaemonRestart = probeDaemonRestart;
|
|
38
|
+
exports.awaitRestartedDaemon = awaitRestartedDaemon;
|
|
39
|
+
/**
|
|
40
|
+
* Bounded wait for a local daemon that is restarting (OSK-13222).
|
|
41
|
+
*
|
|
42
|
+
* The CLI's credential on a daemon-brokered machine is redeemed per process
|
|
43
|
+
* over the daemon's loopback hand-off. A daemon restart opens a window in which
|
|
44
|
+
* there is nothing to redeem from:
|
|
45
|
+
*
|
|
46
|
+
* - a GRACEFUL restart removes the hand-off descriptor on shutdown and the
|
|
47
|
+
* replacement publishes a new one only once it is listening — measured at
|
|
48
|
+
* ~14s on a laptop (shutdown 03:11:34 → `listening` 03:11:48), most of it
|
|
49
|
+
* harness detection;
|
|
50
|
+
* - a CRASH leaves the descriptor naming a pid that has exited until the
|
|
51
|
+
* supervisor starts a replacement.
|
|
52
|
+
*
|
|
53
|
+
* Every `skrr` process landing in that window used to report "Not signed in.
|
|
54
|
+
* Run `skrr login` first." and succeed on an immediate retry. This module
|
|
55
|
+
* decides whether a restart is plausibly in progress and, if so, waits for a
|
|
56
|
+
* fresh descriptor — bounded, and never when nothing says a daemon is coming
|
|
57
|
+
* back, so a machine with no daemon pays nothing.
|
|
58
|
+
*
|
|
59
|
+
* The signals, cheapest to strongest:
|
|
60
|
+
*
|
|
61
|
+
* - `starting` — the profile's singleton-lock sidecar names a LIVE pid that
|
|
62
|
+
* acquired the lock recently. That is a daemon between lock and listen;
|
|
63
|
+
* its descriptor is coming. Wait up to the full budget.
|
|
64
|
+
* - `supervised` — no live lock holder, but a launchd plist / systemd user
|
|
65
|
+
* unit for the profile is installed, so a supervisor is expected to bring
|
|
66
|
+
* one up (the ~1.4s between a graceful release and the next acquire, or a
|
|
67
|
+
* crash awaiting respawn). Wait a short grace, extended to the full budget
|
|
68
|
+
* if a holder appears.
|
|
69
|
+
* - `none` — no daemon is coming, or one is running and deliberately not
|
|
70
|
+
* serving hand-offs (its lock is old: auth latched, OSK-12807). Do not wait.
|
|
71
|
+
*/
|
|
72
|
+
const fs = __importStar(require("node:fs"));
|
|
73
|
+
const os = __importStar(require("node:os"));
|
|
74
|
+
const path = __importStar(require("node:path"));
|
|
75
|
+
/** A lock acquired within this window belongs to a daemon still starting up. */
|
|
76
|
+
exports.STARTING_WINDOW_MS = 60_000;
|
|
77
|
+
/** Total wait once a starting daemon is seen. Startup was measured at ~14s. */
|
|
78
|
+
exports.DEFAULT_RESTART_WAIT_MS = 20_000;
|
|
79
|
+
/** Wait for a supervisor to start a daemon when none holds the lock yet. */
|
|
80
|
+
exports.SUPERVISED_GRACE_MS = 4_000;
|
|
81
|
+
exports.DEFAULT_POLL_MS = 250;
|
|
82
|
+
const PROFILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
83
|
+
function pidAlive(pid) {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid, 0);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return err.code === 'EPERM';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the restart signal for `profile` from the daemon's own on-disk state.
|
|
94
|
+
* Never throws; anything unreadable reads as absent.
|
|
95
|
+
*/
|
|
96
|
+
function probeDaemonRestart(profile, opts = {}) {
|
|
97
|
+
if (!PROFILE_RE.test(profile))
|
|
98
|
+
return 'none';
|
|
99
|
+
const home = opts.homeDir ?? os.homedir();
|
|
100
|
+
const now = opts.now ?? Date.now();
|
|
101
|
+
const platform = opts.platform ?? process.platform;
|
|
102
|
+
try {
|
|
103
|
+
const infoPath = path.join(home, '.skrr', 'profiles', profile, 'daemon.lock.info.json');
|
|
104
|
+
const info = JSON.parse(fs.readFileSync(infoPath, 'utf8'));
|
|
105
|
+
if (typeof info.pid === 'number' && info.pid > 0 && pidAlive(info.pid)) {
|
|
106
|
+
const startedAt = typeof info.startedAt === 'string' ? Date.parse(info.startedAt) : NaN;
|
|
107
|
+
// A live holder that started long ago is a running daemon that is not
|
|
108
|
+
// serving hand-offs on purpose — waiting would only add latency.
|
|
109
|
+
if (Number.isFinite(startedAt) && now - startedAt <= exports.STARTING_WINDOW_MS)
|
|
110
|
+
return 'starting';
|
|
111
|
+
return 'none';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* no sidecar, or unreadable: fall through to the supervisor check */
|
|
116
|
+
}
|
|
117
|
+
// Supervisor names mirror daemon/src/profile.ts (`launchdLabelFor`,
|
|
118
|
+
// `systemdUnitFor`); the CLI does not import daemon code.
|
|
119
|
+
const units = [];
|
|
120
|
+
if (platform === 'darwin') {
|
|
121
|
+
units.push(path.join(home, 'Library', 'LaunchAgents', `ai.skrr.daemon.${profile}.plist`));
|
|
122
|
+
}
|
|
123
|
+
else if (platform === 'linux') {
|
|
124
|
+
units.push(path.join(home, '.config', 'systemd', 'user', `skrrd-${profile}.service`));
|
|
125
|
+
}
|
|
126
|
+
for (const unit of units) {
|
|
127
|
+
try {
|
|
128
|
+
if (fs.existsSync(unit))
|
|
129
|
+
return 'supervised';
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* unreadable reads as absent */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return 'none';
|
|
136
|
+
}
|
|
137
|
+
function envBudget() {
|
|
138
|
+
const raw = process.env.SKRR_DAEMON_RESTART_WAIT_MS;
|
|
139
|
+
if (raw === undefined || raw.trim() === '')
|
|
140
|
+
return undefined;
|
|
141
|
+
const n = Number(raw);
|
|
142
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
143
|
+
}
|
|
144
|
+
async function awaitRestartedDaemon(opts) {
|
|
145
|
+
const maxWaitMs = envBudget() ?? opts.maxWaitMs ?? exports.DEFAULT_RESTART_WAIT_MS;
|
|
146
|
+
if (maxWaitMs <= 0)
|
|
147
|
+
return { waited: false, waitedMs: 0, ready: false };
|
|
148
|
+
const probe = opts.probe ?? ((p) => probeDaemonRestart(p));
|
|
149
|
+
const now = opts.now ?? Date.now;
|
|
150
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
151
|
+
const pollMs = opts.pollMs ?? exports.DEFAULT_POLL_MS;
|
|
152
|
+
const grace = Math.min(opts.supervisedGraceMs ?? exports.SUPERVISED_GRACE_MS, maxWaitMs);
|
|
153
|
+
let signal = probe(opts.profile);
|
|
154
|
+
if (signal === 'none')
|
|
155
|
+
return { waited: false, waitedMs: 0, ready: false };
|
|
156
|
+
const start = now();
|
|
157
|
+
let deadline = start + (signal === 'starting' ? maxWaitMs : grace);
|
|
158
|
+
const notify = opts.notify ??
|
|
159
|
+
((message) => {
|
|
160
|
+
process.stderr.write(`[skrr] ${message}\n`);
|
|
161
|
+
});
|
|
162
|
+
notify(`The skrr background service on this computer is restarting; waiting up to ${Math.round(maxWaitMs / 1000)}s for it to come back.`);
|
|
163
|
+
for (;;) {
|
|
164
|
+
if (opts.ready())
|
|
165
|
+
return { waited: true, waitedMs: now() - start, ready: true };
|
|
166
|
+
if (now() >= deadline)
|
|
167
|
+
break;
|
|
168
|
+
await sleep(pollMs);
|
|
169
|
+
signal = probe(opts.profile);
|
|
170
|
+
if (signal === 'starting')
|
|
171
|
+
deadline = Math.max(deadline, start + maxWaitMs);
|
|
172
|
+
else if (signal === 'none') {
|
|
173
|
+
// Nothing is coming back after all — but a descriptor may have landed
|
|
174
|
+
// between the last check and this probe.
|
|
175
|
+
if (opts.ready())
|
|
176
|
+
return { waited: true, waitedMs: now() - start, ready: true };
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { waited: true, waitedMs: now() - start, ready: false };
|
|
181
|
+
}
|
|
@@ -26,6 +26,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
26
26
|
const socket_io_client_1 = require("socket.io-client");
|
|
27
27
|
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
28
28
|
const agentic_stream_1 = require("./agentic-stream");
|
|
29
|
+
const dpop_auth_1 = require("./dpop-auth");
|
|
29
30
|
const format_1 = require("./format");
|
|
30
31
|
/**
|
|
31
32
|
* Declared services on a Dedicated Runtime — the CLI half of
|
|
@@ -411,17 +412,20 @@ function specFromFlags(input) {
|
|
|
411
412
|
}
|
|
412
413
|
return spec;
|
|
413
414
|
}
|
|
414
|
-
function socketOptions(token) {
|
|
415
|
+
function socketOptions(token, wsOrigin) {
|
|
415
416
|
return {
|
|
416
417
|
path: '/ws/agentic',
|
|
417
|
-
|
|
418
|
+
// OSK-12020 — a DPoP-bound token proves possession on every handshake.
|
|
419
|
+
auth: (0, dpop_auth_1.socketAuthWithDpop)({
|
|
418
420
|
token,
|
|
419
421
|
// This proves origin only; the normal CLI token retains full execution authority.
|
|
420
|
-
...(process.env.OVERSKY_SESSION_TOKEN
|
|
422
|
+
...(process.env.OVERSKY_SESSION_TOKEN
|
|
423
|
+
? { executionOriginToken: process.env.OVERSKY_SESSION_TOKEN }
|
|
424
|
+
: {}),
|
|
421
425
|
...(0, data_provider_1.getClientIdentityAuth)(),
|
|
422
426
|
clientPlatform: 'cli',
|
|
423
427
|
clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
|
|
424
|
-
},
|
|
428
|
+
}, wsOrigin),
|
|
425
429
|
transports: ['websocket'],
|
|
426
430
|
reconnection: false,
|
|
427
431
|
};
|
|
@@ -442,7 +446,8 @@ class DedicatedServiceChannel {
|
|
|
442
446
|
constructor(options) {
|
|
443
447
|
this.options = options;
|
|
444
448
|
const connect = options.connect ?? socket_io_client_1.io;
|
|
445
|
-
|
|
449
|
+
const wsOrigin = (0, agentic_stream_1.toWebSocketOrigin)(options.baseURL);
|
|
450
|
+
this.socket = connect(wsOrigin, socketOptions(options.token, wsOrigin));
|
|
446
451
|
// An ack resolves a request only when it arrives on THAT request's own
|
|
447
452
|
// ack event — a requestId echoed back on the wrong family is not an
|
|
448
453
|
// answer, however well-formed it looks.
|
|
@@ -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. */
|
|
@@ -22,6 +22,8 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
22
22
|
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
|
+
const dpop_auth_1 = require("./dpop-auth");
|
|
26
|
+
const ssh_ordered_bytes_1 = require("./ssh-ordered-bytes");
|
|
25
27
|
/**
|
|
26
28
|
* Owner SSH to a Dedicated Runtime — SSH protocol over the admitted relay.
|
|
27
29
|
*
|
|
@@ -277,46 +279,103 @@ function describeSshStartRefusal(ack) {
|
|
|
277
279
|
*/
|
|
278
280
|
function runSshProxy(options) {
|
|
279
281
|
const channelId = `ssh-${(0, node_crypto_1.randomUUID)()}`;
|
|
282
|
+
const resumeProof = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
280
283
|
const connect = options.connect ?? socket_io_client_1.io;
|
|
281
284
|
const { input, output, errorOutput } = options;
|
|
282
285
|
const identity = ensureSshIdentity();
|
|
283
286
|
const writeHosts = options.writeKnownHosts ?? writeKnownHosts;
|
|
287
|
+
const recoveryMs = options.recoveryMs ?? 45_000;
|
|
288
|
+
const receiver = new ssh_ordered_bytes_1.SshOrderedBytes();
|
|
284
289
|
return new Promise((resolve) => {
|
|
285
290
|
let started = false;
|
|
291
|
+
let startSent = false;
|
|
286
292
|
let finished = false;
|
|
287
|
-
|
|
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 });
|
|
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
|
+
};
|
|
316
|
+
const socket = connect(wsOrigin, {
|
|
288
317
|
path: '/ws/agentic',
|
|
289
|
-
auth: {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// A reconnect cannot resume a channel whose admission line the daemon
|
|
297
|
-
// revoked on socket death — say it ended and let ssh retry fresh.
|
|
298
|
-
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,
|
|
299
325
|
});
|
|
300
|
-
const startTimer = setTimeout(() =>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
|
304
349
|
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
305
350
|
socket.emit('dedicated:ssh:data', { channelId, data: bytes.toString('base64') });
|
|
306
351
|
};
|
|
307
|
-
function
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
+
}
|
|
314
362
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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?.();
|
|
320
379
|
const onInputEnded = () => finish(0);
|
|
321
380
|
const onOutputError = () => finish(1);
|
|
322
381
|
input.once('end', onInputEnded);
|
|
@@ -326,25 +385,44 @@ function runSshProxy(options) {
|
|
|
326
385
|
if (finished)
|
|
327
386
|
return;
|
|
328
387
|
finished = true;
|
|
329
|
-
|
|
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;
|
|
330
397
|
if (notice)
|
|
331
398
|
errorOutput.write(`${notice}\n`);
|
|
332
|
-
|
|
333
|
-
|
|
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 } : {}) });
|
|
334
402
|
socket.disconnect();
|
|
335
403
|
resolve(code);
|
|
336
404
|
}
|
|
337
|
-
socket.on('connect_error', (
|
|
338
|
-
|
|
405
|
+
socket.on('connect_error', (error) => {
|
|
406
|
+
if (finished || (started && resumable))
|
|
407
|
+
return;
|
|
408
|
+
finish(1, `Could not connect to skrr: ${error.message}`);
|
|
339
409
|
});
|
|
340
410
|
socket.on('connect', () => {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
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 });
|
|
348
426
|
});
|
|
349
427
|
socket.on('dedicated:ssh:start:ack', (ack) => {
|
|
350
428
|
if (!ack || ack.channelId !== channelId || started || finished)
|
|
@@ -353,48 +431,142 @@ function runSshProxy(options) {
|
|
|
353
431
|
finish(1, describeSshStartRefusal(ack));
|
|
354
432
|
return;
|
|
355
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
|
+
}
|
|
356
443
|
started = true;
|
|
444
|
+
attached = true;
|
|
357
445
|
clearTimeout(startTimer);
|
|
358
|
-
// The instance's keys, attested by the channel — write them before the
|
|
359
|
-
// first protocol byte flows so ssh's host-key check reads the new file.
|
|
360
|
-
// `host` is what ssh asks about (`%h`), so the pin goes under the alias;
|
|
361
|
-
// a daemon that reported no keys becomes an honest StrictHostKeyChecking
|
|
362
|
-
// refusal, never a silent TOFU.
|
|
363
446
|
const host = `${options.leaseId}${exports.SSH_HOST_SUFFIX}`;
|
|
364
|
-
if (!writeHosts(host, ack.hostKeys))
|
|
447
|
+
if (!writeHosts(host, ack.hostKeys))
|
|
365
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;
|
|
366
496
|
}
|
|
367
|
-
|
|
368
|
-
|
|
497
|
+
inputOffset = ack.nextOffset;
|
|
498
|
+
pendingInput = null;
|
|
499
|
+
pumpInput();
|
|
369
500
|
});
|
|
370
|
-
socket.on('dedicated:ssh:data', (frame, receipt) => {
|
|
371
|
-
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)
|
|
372
503
|
return;
|
|
373
|
-
|
|
504
|
+
try {
|
|
374
505
|
const bytes = Buffer.from(frame.data, 'base64');
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 });
|
|
383
516
|
}
|
|
384
517
|
else {
|
|
385
|
-
|
|
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);
|
|
386
529
|
}
|
|
387
530
|
}
|
|
531
|
+
catch {
|
|
532
|
+
receipt?.({ channelId, ok: false, epoch: frame.epoch });
|
|
533
|
+
finish(1, 'Could not preserve the SSH output stream.');
|
|
534
|
+
}
|
|
388
535
|
});
|
|
389
|
-
socket.on('dedicated:ssh:closed', (frame) => {
|
|
390
|
-
if (!frame || frame.channelId !== channelId)
|
|
536
|
+
socket.on('dedicated:ssh:closed', (frame, receipt) => {
|
|
537
|
+
if (!frame || frame.channelId !== channelId || (frame.epoch && frame.epoch !== epoch))
|
|
391
538
|
return;
|
|
392
|
-
|
|
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();
|
|
393
559
|
});
|
|
394
560
|
socket.on('disconnect', () => {
|
|
395
561
|
if (finished)
|
|
396
562
|
return;
|
|
397
|
-
|
|
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();
|
|
398
570
|
});
|
|
399
571
|
});
|
|
400
572
|
}
|
|
@@ -9,6 +9,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
9
9
|
const socket_io_client_1 = require("socket.io-client");
|
|
10
10
|
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
11
11
|
const agentic_stream_1 = require("./agentic-stream");
|
|
12
|
+
const dpop_auth_1 = require("./dpop-auth");
|
|
12
13
|
/**
|
|
13
14
|
* A real pseudo-terminal on a Dedicated Runtime, from a local terminal.
|
|
14
15
|
*
|
|
@@ -100,14 +101,16 @@ function runDedicatedTerminal(options) {
|
|
|
100
101
|
let retryTimer;
|
|
101
102
|
let recoveryTimer;
|
|
102
103
|
let connected = false;
|
|
103
|
-
const
|
|
104
|
+
const wsOrigin = (0, agentic_stream_1.toWebSocketOrigin)(options.baseURL);
|
|
105
|
+
const socket = connect(wsOrigin, {
|
|
104
106
|
path: '/ws/agentic',
|
|
105
|
-
|
|
107
|
+
// OSK-12020 — a DPoP-bound token proves possession on every handshake.
|
|
108
|
+
auth: (0, dpop_auth_1.socketAuthWithDpop)({
|
|
106
109
|
token: options.token,
|
|
107
110
|
...(0, data_provider_1.getClientIdentityAuth)(),
|
|
108
111
|
clientPlatform: 'cli',
|
|
109
112
|
clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
|
|
110
|
-
},
|
|
113
|
+
}, wsOrigin),
|
|
111
114
|
transports: ['websocket'],
|
|
112
115
|
// A bare PTY cannot be resumed: the relay forgets the session with the
|
|
113
116
|
// socket, and silently opening a NEW shell would drop the user into a
|