@skrr-ai/cli 0.1.78 → 0.1.80

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 (49) hide show
  1. package/dist/base-command.js +9 -2
  2. package/dist/commands/inbox/index.js +4 -2
  3. package/dist/commands/login.d.ts +8 -3
  4. package/dist/commands/login.js +14 -4
  5. package/dist/commands/spaces/workflows/events.js +9 -2
  6. package/dist/lib/agentic-stream.js +13 -4
  7. package/dist/lib/api-fetch.js +6 -2
  8. package/dist/lib/brokered-dpop.d.ts +34 -0
  9. package/dist/lib/brokered-dpop.js +89 -0
  10. package/dist/lib/daemonBroker.d.ts +71 -2
  11. package/dist/lib/daemonBroker.js +203 -36
  12. package/dist/lib/daemonBrokerRefusal.d.ts +26 -0
  13. package/dist/lib/daemonBrokerRefusal.js +83 -4
  14. package/dist/lib/daemonRestartWait.d.ts +39 -0
  15. package/dist/lib/daemonRestartWait.js +181 -0
  16. package/dist/lib/dedicated-lease-command.js +1 -0
  17. package/dist/lib/dedicated-service.js +10 -5
  18. package/dist/lib/dedicated-ssh.js +6 -3
  19. package/dist/lib/dedicated-terminal.d.ts +3 -0
  20. package/dist/lib/dedicated-terminal.js +77 -18
  21. package/dist/lib/dpop-auth.d.ts +35 -0
  22. package/dist/lib/dpop-auth.js +57 -0
  23. package/dist/lib/login.d.ts +6 -0
  24. package/dist/lib/login.js +13 -4
  25. package/dist/lib/node-adapter.js +4 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +42 -1
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +35 -3
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.d.ts +49 -1
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialSession.js +47 -9
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +56 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +42 -20
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.d.ts +101 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +179 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +19 -3
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +42 -1
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +32 -2
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.d.ts +49 -1
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialSession.js +47 -9
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +56 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +39 -20
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.d.ts +101 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +138 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +4 -0
  46. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  47. package/dist/node_modules/@skrr-ai/data-provider/index.js +3422 -3407
  48. package/oclif.manifest.json +41505 -41505
  49. package/package.json +2 -2
@@ -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
+ }
@@ -129,6 +129,7 @@ class DedicatedLeaseCommand extends base_command_1.BaseCommand {
129
129
  ...(flags.cwd ? { cwd: flags.cwd } : {}),
130
130
  ...(initialCommand ? { initialCommand } : {}),
131
131
  persistent,
132
+ resumeExistingSupported: capabilities.includes('persistent_terminal_resume_v1'),
132
133
  ...(sessionName ? { sessionName } : {}),
133
134
  leaseId,
134
135
  input: process.stdin,
@@ -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
- auth: {
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 ? { executionOriginToken: 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
- this.socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), socketOptions(options.token));
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.
@@ -22,6 +22,7 @@ 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");
25
26
  /**
26
27
  * Owner SSH to a Dedicated Runtime — SSH protocol over the admitted relay.
27
28
  *
@@ -284,14 +285,16 @@ function runSshProxy(options) {
284
285
  return new Promise((resolve) => {
285
286
  let started = false;
286
287
  let finished = false;
287
- const socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), {
288
+ const wsOrigin = (0, agentic_stream_1.toWebSocketOrigin)(options.baseURL);
289
+ const socket = connect(wsOrigin, {
288
290
  path: '/ws/agentic',
289
- auth: {
291
+ // OSK-12020 — a DPoP-bound token proves possession on every handshake.
292
+ auth: (0, dpop_auth_1.socketAuthWithDpop)({
290
293
  token: options.token,
291
294
  ...(0, data_provider_1.getClientIdentityAuth)(),
292
295
  clientPlatform: 'cli',
293
296
  clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
294
- },
297
+ }, wsOrigin),
295
298
  transports: ['websocket'],
296
299
  // A reconnect cannot resume a channel whose admission line the daemon
297
300
  // revoked on socket death — say it ended and let ssh retry fresh.
@@ -27,6 +27,7 @@ export interface TerminalOutputFrame {
27
27
  encoding?: 'utf8' | 'base64';
28
28
  exitCode?: number | null;
29
29
  signal?: string | null;
30
+ code?: string;
30
31
  }
31
32
  export interface TerminalStartAck {
32
33
  sessionId?: string;
@@ -66,6 +67,8 @@ export interface DedicatedTerminalOptions {
66
67
  * capability before asking.
67
68
  */
68
69
  persistent?: boolean;
70
+ /** Capability-gated recovery can attach only an existing named shell. */
71
+ resumeExistingSupported?: boolean;
69
72
  /**
70
73
  * The session suffix (`skrr-term-<name>`). The same name re-attaches the
71
74
  * same session; omitted under `persistent`, the machine names the session
@@ -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
  *
@@ -67,6 +68,9 @@ function describeTerminalStartRefusal(ack) {
67
68
  if (code === 'DEDICATED_RUNTIME_GENERATION_STALE') {
68
69
  return 'The machine was replaced since its daemon registered. Wait a minute and try again.';
69
70
  }
71
+ if (code === 'persistent-session-missing') {
72
+ return 'The original terminal no longer exists. Start a new terminal explicitly; no replacement shell was opened.';
73
+ }
70
74
  if (code === 'not_found' || code === 'offline') {
71
75
  return 'The machine’s daemon is not connected. Wait for it to come online and try again.';
72
76
  }
@@ -94,14 +98,19 @@ function runDedicatedTerminal(options) {
94
98
  let sessionReady = false;
95
99
  let reattaching = false;
96
100
  let persistentName = options.sessionName;
97
- const socket = connect((0, agentic_stream_1.toWebSocketOrigin)(options.baseURL), {
101
+ let retryTimer;
102
+ let recoveryTimer;
103
+ let connected = false;
104
+ const wsOrigin = (0, agentic_stream_1.toWebSocketOrigin)(options.baseURL);
105
+ const socket = connect(wsOrigin, {
98
106
  path: '/ws/agentic',
99
- auth: {
107
+ // OSK-12020 — a DPoP-bound token proves possession on every handshake.
108
+ auth: (0, dpop_auth_1.socketAuthWithDpop)({
100
109
  token: options.token,
101
110
  ...(0, data_provider_1.getClientIdentityAuth)(),
102
111
  clientPlatform: 'cli',
103
112
  clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
104
- },
113
+ }, wsOrigin),
105
114
  transports: ['websocket'],
106
115
  // A bare PTY cannot be resumed: the relay forgets the session with the
107
116
  // socket, and silently opening a NEW shell would drop the user into a
@@ -170,6 +179,10 @@ function runDedicatedTerminal(options) {
170
179
  const onSigterm = () => finish(143, true);
171
180
  const onSighup = () => finish(129, true);
172
181
  const onStartTimeout = () => {
182
+ if (reattaching) {
183
+ scheduleRetry();
184
+ return;
185
+ }
173
186
  fail(new DedicatedTerminalError('The machine did not answer the terminal start in time. Its daemon may be busy or offline.', 'TERMINAL_START_TIMEOUT'));
174
187
  };
175
188
  let startTimer = setTimeout(onStartTimeout, exports.TERMINAL_START_TIMEOUT_MS);
@@ -190,6 +203,8 @@ function runDedicatedTerminal(options) {
190
203
  return;
191
204
  finished = true;
192
205
  clearTimeout(startTimer);
206
+ clearTimeout(retryTimer);
207
+ clearTimeout(recoveryTimer);
193
208
  pending?.close();
194
209
  restoreTty();
195
210
  detachReconnectFailed();
@@ -203,6 +218,8 @@ function runDedicatedTerminal(options) {
203
218
  return;
204
219
  finished = true;
205
220
  clearTimeout(startTimer);
221
+ clearTimeout(retryTimer);
222
+ clearTimeout(recoveryTimer);
206
223
  pending?.close();
207
224
  restoreTty();
208
225
  detachReconnectFailed();
@@ -212,15 +229,19 @@ function runDedicatedTerminal(options) {
212
229
  socket.on('connect_error', (err) => {
213
230
  // A failed retry while reattaching is not fatal — the attempt countdown
214
231
  // keeps running and `reconnect_failed` ends it if they all miss.
215
- if (persistent && reattaching)
232
+ if (persistent && reattaching && !err.data?.code)
216
233
  return;
217
234
  fail(new DedicatedTerminalError(`Could not connect to skrr: ${err.message}`, err.data?.code || 'TERMINAL_CONNECT_FAILED'));
218
235
  });
219
- socket.on('connect', () => {
220
- if (finished)
236
+ function sendStart() {
237
+ if (finished || !connected)
221
238
  return;
239
+ // Clear the previous relay entry too; never replay bytes sent before loss.
240
+ if (sessionId)
241
+ socket.emit(events.stop, { sessionId });
222
242
  sessionId = (0, node_crypto_1.randomUUID)();
223
243
  clearTimeout(startTimer);
244
+ clearTimeout(retryTimer);
224
245
  startTimer = setTimeout(onStartTimeout, exports.TERMINAL_START_TIMEOUT_MS);
225
246
  startTimer.unref();
226
247
  socket.emit(events.start, {
@@ -228,13 +249,44 @@ function runDedicatedTerminal(options) {
228
249
  sessionId,
229
250
  ...(options.cwd ? { cwd: options.cwd } : {}),
230
251
  ...(persistent ? { persistent: true } : {}),
231
- // The RESOLVED name, not just the requested one: a `--new` session the
232
- // machine named itself comes back in the ack, and a re-attach must
233
- // name it — `tmux -A` without `-s` would open a fresh session instead.
252
+ ...(reattaching ? { resumeExisting: true } : {}),
234
253
  ...(persistentName ? { sessionName: persistentName } : {}),
235
254
  ...(options.leaseId ? { leaseId: options.leaseId } : {}),
236
255
  ...size(),
237
256
  });
257
+ }
258
+ function scheduleRetry() {
259
+ clearTimeout(startTimer);
260
+ clearTimeout(retryTimer);
261
+ if (finished || !reattaching)
262
+ return;
263
+ retryTimer = setTimeout(sendStart, 2_000);
264
+ retryTimer.unref();
265
+ }
266
+ function recover() {
267
+ if (finished || reattaching)
268
+ return;
269
+ started = false;
270
+ pending?.close(); // The sign-in command is never typed a second time.
271
+ clearTimeout(startTimer);
272
+ if (!persistent || !persistentName || !options.resumeExistingSupported) {
273
+ errorOutput.write(`\r\n[terminal attachment lost; rerun the same command to reattach${persistentName ? ` to '${persistentName}'` : ''}]\r\n`);
274
+ finish(1, false);
275
+ return;
276
+ }
277
+ reattaching = true;
278
+ errorOutput.write(`\r\n[lost the terminal attachment — reattaching to session '${persistentName}'…]\r\n`);
279
+ recoveryTimer = setTimeout(() => {
280
+ errorOutput.write(`\r\n[could not reattach within 60 seconds; rerun the same command for session '${persistentName}']\r\n`);
281
+ finish(1, false);
282
+ }, 60_000);
283
+ recoveryTimer.unref();
284
+ if (connected)
285
+ sendStart();
286
+ }
287
+ socket.on('connect', () => {
288
+ connected = true;
289
+ sendStart();
238
290
  });
239
291
  socket.on(events.startAck, (ack) => {
240
292
  if (!ack || ack.sessionId !== sessionId || started || finished)
@@ -242,6 +294,12 @@ function runDedicatedTerminal(options) {
242
294
  if (ack.daemonId && ack.daemonId !== options.daemonId)
243
295
  return;
244
296
  if (!ack.ok) {
297
+ // Only daemon availability refusals are retryable. Authorization,
298
+ // generation fencing and a missing original shell end recovery.
299
+ if (reattaching && (ack.code === 'offline' || ack.code === 'not_found')) {
300
+ scheduleRetry();
301
+ return;
302
+ }
245
303
  fail(new DedicatedTerminalError(describeTerminalStartRefusal(ack), ack.code || 'TERMINAL_START_REFUSED'));
246
304
  return;
247
305
  }
@@ -249,6 +307,8 @@ function runDedicatedTerminal(options) {
249
307
  started = true;
250
308
  reattaching = false;
251
309
  clearTimeout(startTimer);
310
+ clearTimeout(retryTimer);
311
+ clearTimeout(recoveryTimer);
252
312
  if (typeof ack.persistentName === 'string' && ack.persistentName) {
253
313
  persistentName = ack.persistentName;
254
314
  }
@@ -278,8 +338,13 @@ function runDedicatedTerminal(options) {
278
338
  return;
279
339
  if (frame.daemonId && frame.daemonId !== options.daemonId)
280
340
  return;
341
+ if (frame.code === 'daemon-disconnected' ||
342
+ (frame.type === 'error' && (frame.code === 'not-found' || frame.code === 'closed'))) {
343
+ recover();
344
+ return;
345
+ }
281
346
  if (frame.type === 'exit') {
282
- finish(typeof frame.exitCode === 'number' ? frame.exitCode : 0, false);
347
+ finish(typeof frame.exitCode === 'number' ? frame.exitCode : 1, false);
283
348
  return;
284
349
  }
285
350
  if (frame.type === 'error' || frame.type === 'info') {
@@ -318,17 +383,11 @@ function runDedicatedTerminal(options) {
318
383
  }
319
384
  }
320
385
  socket.on('disconnect', () => {
386
+ connected = false;
321
387
  if (finished)
322
388
  return;
323
389
  if (persistent) {
324
- // The tmux session outlives the link — the tmux client's death only
325
- // detaches. socket.io retries and the next start re-attaches by name.
326
- started = false;
327
- if (!reattaching) {
328
- reattaching = true;
329
- const named = persistentName ? ` '${persistentName}'` : '';
330
- errorOutput.write(`\r\n[lost the connection to skrr — reattaching to session${named}…]\r\n`);
331
- }
390
+ recover();
332
391
  return;
333
392
  }
334
393
  if (!started) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * dpop-auth.ts — attach RFC 9449 proofs for a DPoP-bound hand-off token
3
+ * (OSK-12020).
4
+ *
5
+ * The ONE place a request path asks for a proof, so every transport the CLI
6
+ * has — `apiFetch`, the data-service node adapter, SSE, and Socket.IO — binds
7
+ * the same way. For any other credential both helpers are no-ops and the wire
8
+ * is byte-identical to before.
9
+ *
10
+ * The CLI never holds the key. Each proof comes from the local daemon
11
+ * (`brokeredDpopProof`), which keeps the private half in its own memory; that
12
+ * is what makes a copied token useless anywhere but on this machine.
13
+ */
14
+ /** RFC 9449 §4.1. */
15
+ export declare const DPOP_HEADER = "DPoP";
16
+ /** Headers to add to one HTTP request made with `token`; `{}` when unbound. */
17
+ export declare function dpopHeadersFor(token: string | null | undefined, method: string, url: string): Promise<Record<string, string>>;
18
+ /**
19
+ * The URI a Socket.IO handshake proof commits to: the realtime origin's
20
+ * http(s) form plus the engine path, no trailing slash — what the server
21
+ * rebuilds from the handshake request (`reconstructHandshakeHtu`).
22
+ */
23
+ export declare function socketHandshakeHtu(wsOrigin: string, ioPath?: string): string;
24
+ type SocketAuth = Record<string, unknown>;
25
+ type SocketAuthFn = (cb: (data: SocketAuth) => void) => void;
26
+ /**
27
+ * Socket.IO `auth` for a handshake made with `auth.token`. Unbound: the object
28
+ * itself, unchanged. Bound: a FUNCTION, which socket.io-client calls on every
29
+ * (re)connect — so each handshake presents a fresh, single-use proof instead
30
+ * of replaying the first one into the server's jti cache.
31
+ */
32
+ export declare function socketAuthWithDpop(auth: SocketAuth & {
33
+ token?: string | null;
34
+ }, wsOrigin: string, ioPath?: string): SocketAuth | SocketAuthFn;
35
+ export {};
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * dpop-auth.ts — attach RFC 9449 proofs for a DPoP-bound hand-off token
4
+ * (OSK-12020).
5
+ *
6
+ * The ONE place a request path asks for a proof, so every transport the CLI
7
+ * has — `apiFetch`, the data-service node adapter, SSE, and Socket.IO — binds
8
+ * the same way. For any other credential both helpers are no-ops and the wire
9
+ * is byte-identical to before.
10
+ *
11
+ * The CLI never holds the key. Each proof comes from the local daemon
12
+ * (`brokeredDpopProof`), which keeps the private half in its own memory; that
13
+ * is what makes a copied token useless anywhere but on this machine.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.DPOP_HEADER = void 0;
17
+ exports.dpopHeadersFor = dpopHeadersFor;
18
+ exports.socketHandshakeHtu = socketHandshakeHtu;
19
+ exports.socketAuthWithDpop = socketAuthWithDpop;
20
+ const brokered_dpop_1 = require("./brokered-dpop");
21
+ /** RFC 9449 §4.1. */
22
+ exports.DPOP_HEADER = 'DPoP';
23
+ /** Headers to add to one HTTP request made with `token`; `{}` when unbound. */
24
+ async function dpopHeadersFor(token, method, url) {
25
+ if (!(0, brokered_dpop_1.isDpopBoundHandoffToken)(token))
26
+ return {};
27
+ const proof = await (0, brokered_dpop_1.brokeredDpopProof)(token, method, url);
28
+ return proof ? { [exports.DPOP_HEADER]: proof } : {};
29
+ }
30
+ /**
31
+ * The URI a Socket.IO handshake proof commits to: the realtime origin's
32
+ * http(s) form plus the engine path, no trailing slash — what the server
33
+ * rebuilds from the handshake request (`reconstructHandshakeHtu`).
34
+ */
35
+ function socketHandshakeHtu(wsOrigin, ioPath = '/ws/agentic') {
36
+ const u = new URL(wsOrigin);
37
+ u.protocol = u.protocol === 'wss:' ? 'https:' : u.protocol === 'ws:' ? 'http:' : u.protocol;
38
+ u.pathname = ioPath.replace(/\/+$/, '');
39
+ u.search = '';
40
+ u.hash = '';
41
+ return u.toString();
42
+ }
43
+ /**
44
+ * Socket.IO `auth` for a handshake made with `auth.token`. Unbound: the object
45
+ * itself, unchanged. Bound: a FUNCTION, which socket.io-client calls on every
46
+ * (re)connect — so each handshake presents a fresh, single-use proof instead
47
+ * of replaying the first one into the server's jti cache.
48
+ */
49
+ function socketAuthWithDpop(auth, wsOrigin, ioPath = '/ws/agentic') {
50
+ const token = typeof auth.token === 'string' ? auth.token : null;
51
+ if (!(0, brokered_dpop_1.isDpopBoundHandoffToken)(token))
52
+ return auth;
53
+ const htu = socketHandshakeHtu(wsOrigin, ioPath);
54
+ return (cb) => {
55
+ void (0, brokered_dpop_1.brokeredDpopProof)(token, 'GET', htu).then((proof) => cb(proof ? { ...auth, dpop: proof } : auth), () => cb(auth));
56
+ };
57
+ }
@@ -42,6 +42,12 @@ export interface CliLoginWithRefreshTokenOptions {
42
42
  * What a shell with no terminal can do instead (OSK-12117). The refusal used to
43
43
  * name only CI tokens, which is not a sign-in: an agent session asking for one
44
44
  * could only hand the job to a person typing `! skrr login`.
45
+ *
46
+ * It also used to send CI to `skrr create-token` + `OVERSKY_TOKEN=osk_ci_...`.
47
+ * That token only bootstraps a daemon; every `skrr` command rejects it with 401
48
+ * (create-token.ts, base-command.ts), so the advice was a dead end (OSK-13209).
49
+ * The route that works today is a minted machine session — see
50
+ * docs/cli/SKRR_AUTH.md "CI today" for its caveats.
45
51
  */
46
52
  export declare const HEADLESS_LOGIN_HINT: string;
47
53
  /**
package/dist/lib/login.js CHANGED
@@ -43,10 +43,18 @@ const daemonBrokerRefusal_1 = require("./daemonBrokerRefusal");
43
43
  * What a shell with no terminal can do instead (OSK-12117). The refusal used to
44
44
  * name only CI tokens, which is not a sign-in: an agent session asking for one
45
45
  * could only hand the job to a person typing `! skrr login`.
46
+ *
47
+ * It also used to send CI to `skrr create-token` + `OVERSKY_TOKEN=osk_ci_...`.
48
+ * That token only bootstraps a daemon; every `skrr` command rejects it with 401
49
+ * (create-token.ts, base-command.ts), so the advice was a dead end (OSK-13209).
50
+ * The route that works today is a minted machine session — see
51
+ * docs/cli/SKRR_AUTH.md "CI today" for its caveats.
46
52
  */
47
53
  exports.HEADLESS_LOGIN_HINT = '`skrr login --device-code` works without a terminal: it prints a URL and a code for a ' +
48
- 'person to approve in any browser, and finishes when they do. For CI, use ' +
49
- '`skrr create-token` and set OVERSKY_TOKEN=osk_ci_...';
54
+ 'person to approve in any browser, and finishes when they do. With no person to approve ' +
55
+ '(CI, a script), mint a session for this machine elsewhere with `skrr token mint --for <id>` ' +
56
+ 'and run `skrr login` here with OVERSKY_REFRESH_TOKEN and OVERSKY_CLI_ID set. ' +
57
+ '`skrr create-token` tokens are not accepted by skrr commands.';
50
58
  /**
51
59
  * The line printed when the daemon descriptor this CLI found is for another
52
60
  * server (OSK-12117).
@@ -164,8 +172,9 @@ async function cliLogin(opts = {}) {
164
172
  // silently fall through to PKCE / device-code below.
165
173
  if (brokerResult.reason === 'ci_token_refused') {
166
174
  throw new Error('The skrr background service on this computer is bootstrapped from a CI token, which cannot broker an interactive CLI login. ' +
167
- 'Use `skrr create-token --name <name>` to mint a long-lived CI credential directly, ' +
168
- 'or run `skrr login` on a machine where the daemon was authenticated interactively.');
175
+ 'Sign this CLI in on its own with `skrr login --device-code`, or with a session minted elsewhere by ' +
176
+ '`skrr token mint --for <id>` (OVERSKY_REFRESH_TOKEN + OVERSKY_CLI_ID). ' +
177
+ 'A `skrr create-token` token is not accepted by skrr commands.');
169
178
  }
170
179
  // `base_url_mismatch` is a configuration drift the user can resolve
171
180
  // explicitly; print a one-line hint so they're not surprised when
@@ -12,6 +12,7 @@ const config_1 = require("./config");
12
12
  const auth_storage_1 = require("./auth-storage");
13
13
  const credential_resolver_1 = require("./credential-resolver");
14
14
  const daemonBroker_1 = require("./daemonBroker");
15
+ const dpop_auth_1 = require("./dpop-auth");
15
16
  const refresh_1 = require("./refresh");
16
17
  const delegated_cli_1 = require("./delegated-cli");
17
18
  const workspace_header_1 = require("./workspace-header");
@@ -159,6 +160,9 @@ function createNodeAdapter(opts = {}) {
159
160
  }
160
161
  }
161
162
  const finalUrl = appendQuery(resolveUrl(url), options?.params);
163
+ // OSK-12020 — a DPoP-bound hand-off token needs a fresh proof per request
164
+ // (and per retry: proofs are single-use). No-op for every other token.
165
+ Object.assign(headers, await (0, dpop_auth_1.dpopHeadersFor)(currentCredential().token, method, finalUrl));
162
166
  return fetch(finalUrl, { method, headers, body: finalBody });
163
167
  }
164
168
  /**