@skrr-ai/cli 0.1.58 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-command.d.ts +0 -16
- package/dist/base-command.js +65 -11
- package/dist/commands/logout.d.ts +0 -46
- package/dist/commands/logout.js +21 -3
- package/dist/lib/agentic-stream.d.ts +42 -0
- package/dist/lib/agentic-stream.js +103 -14
- package/dist/lib/api-fetch.d.ts +11 -0
- package/dist/lib/api-fetch.js +98 -1
- package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
- package/dist/lib/daemonBrokerRefusal.js +87 -2
- package/dist/lib/dedicated-guest.d.ts +13 -0
- package/dist/lib/dedicated-guest.js +22 -0
- package/dist/lib/dedicated-machines.js +17 -1
- package/dist/lib/dedicated-service.d.ts +1 -13
- package/dist/lib/dedicated-service.js +7 -19
- package/dist/lib/login.js +10 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
- 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 +5 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -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/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +4391 -4363
- package/oclif.manifest.json +33201 -33201
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isAnsweredBrokerRefusal = isAnsweredBrokerRefusal;
|
|
4
4
|
exports.describeBrokerRefusal = describeBrokerRefusal;
|
|
5
|
+
exports.describeGuestDaemonUnavailable = describeGuestDaemonUnavailable;
|
|
5
6
|
/**
|
|
6
7
|
* True when a daemon ANSWERED the hand-off and refused it — as opposed to no
|
|
7
8
|
* daemon being there to ask (`no_bootstrap`, a stale descriptor nobody listens
|
|
@@ -81,8 +82,8 @@ function describeBrokerRefusal(failure, bin = 'skrr') {
|
|
|
81
82
|
`if it persists, check the machine from your own computer with \`${bin} machines dedicated health-check <lease-id>\` ` +
|
|
82
83
|
`(\`${bin} machines dedicated list\` shows the lease id).`
|
|
83
84
|
: 'Nobody signs in on a Dedicated Runtime: its daemon holds the account. From your own computer, run ' +
|
|
84
|
-
`\`${bin} machines dedicated health-check <lease-id>\` (\`${bin} machines dedicated list\` shows the lease id)
|
|
85
|
-
|
|
85
|
+
`\`${bin} machines dedicated health-check <lease-id>\` (\`${bin} machines dedicated list\` shows the lease id). ` +
|
|
86
|
+
guestReissueRemedy(bin, 'If it stays refused');
|
|
86
87
|
}
|
|
87
88
|
else if (code === 'DEVICE_PROOF_PIN_MISMATCH') {
|
|
88
89
|
remedy =
|
|
@@ -125,3 +126,87 @@ function describeBrokerRefusal(failure, bin = 'skrr') {
|
|
|
125
126
|
},
|
|
126
127
|
};
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The owner-side repair that issues a guest's daemon a fresh credential.
|
|
131
|
+
*
|
|
132
|
+
* `stop` then `start`, not `restart`: a restart keeps the instance, so its
|
|
133
|
+
* daemon re-enrolls through the attested re-issue, which the server refuses
|
|
134
|
+
* for a credential a person revoked (`REISSUE_REVOKE_REASON`). Observed on a
|
|
135
|
+
* revoked guest: `restart` sat in `restarting` for 16 minutes and ended
|
|
136
|
+
* `dedicated_runtime_guest_not_ready_after_restart`, while stop + start
|
|
137
|
+
* recovered it (OSK-12473).
|
|
138
|
+
*/
|
|
139
|
+
function guestReissueRemedy(bin, when) {
|
|
140
|
+
return (`${when}, \`${bin} machines dedicated stop <lease-id>\` then \`${bin} machines dedicated start <lease-id>\` ` +
|
|
141
|
+
'gives the machine a fresh credential (files under /workspace are kept; a restart does not re-issue one).');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* What to tell a person on a Dedicated Runtime guest when NO daemon answered
|
|
145
|
+
* the hand-off at all (OSK-12473).
|
|
146
|
+
*
|
|
147
|
+
* `describeBrokerRefusal` speaks only for a daemon that answered. On a guest
|
|
148
|
+
* the realistic broken state is a daemon that is absent: its credential was
|
|
149
|
+
* revoked and it exited by design, or systemd is cycling it, or it crashed and
|
|
150
|
+
* left its descriptor behind. The CLI then saw `no_bootstrap` / `network`, and
|
|
151
|
+
* fell to "Not signed in. Run `skrr login` first." — a login nobody performs
|
|
152
|
+
* on a guest, and which would only put a personal credential on a machine the
|
|
153
|
+
* daemon is meant to hold the account for. Say the machine's service is not
|
|
154
|
+
* running and name the owner's repair instead.
|
|
155
|
+
*
|
|
156
|
+
* Returns null off a guest, or for a daemon that answered (that is
|
|
157
|
+
* `describeBrokerRefusal`'s case).
|
|
158
|
+
*/
|
|
159
|
+
function describeGuestDaemonUnavailable(failure, opts) {
|
|
160
|
+
const bin = opts.bin ?? 'skrr';
|
|
161
|
+
if (!opts.onGuest && failure?.dedicatedGuest !== true)
|
|
162
|
+
return null;
|
|
163
|
+
if (failure && isAnsweredBrokerRefusal(failure))
|
|
164
|
+
return null;
|
|
165
|
+
if (failure && (failure.reason === 'base_url_mismatch' || failure.reason === 'rate_limited')) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const who = `This Dedicated Runtime's skrr service${failure?.daemonId ? ` (daemon ${failure.daemonId})` : ''}`;
|
|
169
|
+
let headline;
|
|
170
|
+
let transient = false;
|
|
171
|
+
const reason = failure?.reason ?? 'no_bootstrap';
|
|
172
|
+
if (reason === 'network') {
|
|
173
|
+
headline = `${who} is not running: its sign-in hand-off names a port nothing is listening on.`;
|
|
174
|
+
}
|
|
175
|
+
else if (reason === 'bootstrap_parse') {
|
|
176
|
+
headline = `${who} is not available: its sign-in hand-off file could not be read.`;
|
|
177
|
+
}
|
|
178
|
+
else if (reason === 'stale_bootstrap') {
|
|
179
|
+
// An uncoded 401 on the descriptor secret: the service rotated it, which
|
|
180
|
+
// it does when it restarts. Re-reading the file next time heals it.
|
|
181
|
+
headline = `${who} refused this CLI's hand-off secret, which it rotates when it restarts.`;
|
|
182
|
+
transient = true;
|
|
183
|
+
}
|
|
184
|
+
else if (opts.descriptorPresent) {
|
|
185
|
+
headline = `${who} is not running: its sign-in hand-off was left behind by a process that has exited.`;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
headline = `${who} is not running, so nothing on this machine can sign this CLI in.`;
|
|
189
|
+
}
|
|
190
|
+
const remedy = transient
|
|
191
|
+
? 'Retry in a moment. Nobody signs in on a Dedicated Runtime, so `' +
|
|
192
|
+
`${bin} login\` is not the fix; if it persists, the machine's owner can run ` +
|
|
193
|
+
`\`${bin} machines dedicated health-check <lease-id>\` from their own computer.`
|
|
194
|
+
: `Nobody signs in on a Dedicated Runtime — its service holds the account — so \`${bin} login\` is not the fix. ` +
|
|
195
|
+
"If the machine is still starting, retry in a minute. Otherwise, from the owner's own computer: " +
|
|
196
|
+
`\`${bin} machines dedicated health-check <lease-id>\` shows why (\`${bin} machines dedicated list\` shows the lease id). ` +
|
|
197
|
+
guestReissueRemedy(bin, 'If the service stays down');
|
|
198
|
+
return {
|
|
199
|
+
headline,
|
|
200
|
+
remedy,
|
|
201
|
+
transient,
|
|
202
|
+
dedicatedGuest: true,
|
|
203
|
+
broker: {
|
|
204
|
+
reason,
|
|
205
|
+
...(typeof failure?.status === 'number' ? { status: failure.status } : {}),
|
|
206
|
+
...(failure?.code ? { code: failure.code } : {}),
|
|
207
|
+
...(failure?.detail ? { detail: failure.detail } : {}),
|
|
208
|
+
...(failure?.descriptorPath ? { descriptorPath: failure.descriptorPath } : {}),
|
|
209
|
+
...(failure?.daemonId ? { daemonId: failure.daemonId } : {}),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The daemon's workload-facing service socket on a Dedicated Runtime guest. */
|
|
2
|
+
export declare const GUEST_SERVICE_SOCKET_PATH = "/run/skrr-dedicated-runtime/service-propose/service-propose.sock";
|
|
3
|
+
/**
|
|
4
|
+
* True when this process is running on a Dedicated Runtime guest — the
|
|
5
|
+
* workload-uid test, or the guest markers the image/daemon own. An SSH
|
|
6
|
+
* session may not carry the wrapper's env stamp, so the filesystem markers
|
|
7
|
+
* count too; the socket itself remains the authority.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runningOnDedicatedRuntimeGuest(env?: NodeJS.ProcessEnv, getuid?: (() => number) | undefined, paths?: {
|
|
10
|
+
socketPath?: string;
|
|
11
|
+
attestationPath?: string;
|
|
12
|
+
runtimeDir?: string;
|
|
13
|
+
}): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GUEST_SERVICE_SOCKET_PATH = void 0;
|
|
4
|
+
exports.runningOnDedicatedRuntimeGuest = runningOnDedicatedRuntimeGuest;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const first_party_harness_1 = require("./first-party-harness");
|
|
7
|
+
/** The daemon's workload-facing service socket on a Dedicated Runtime guest. */
|
|
8
|
+
exports.GUEST_SERVICE_SOCKET_PATH = '/run/skrr-dedicated-runtime/service-propose/service-propose.sock';
|
|
9
|
+
/**
|
|
10
|
+
* True when this process is running on a Dedicated Runtime guest — the
|
|
11
|
+
* workload-uid test, or the guest markers the image/daemon own. An SSH
|
|
12
|
+
* session may not carry the wrapper's env stamp, so the filesystem markers
|
|
13
|
+
* count too; the socket itself remains the authority.
|
|
14
|
+
*/
|
|
15
|
+
function runningOnDedicatedRuntimeGuest(env = process.env, getuid = process.getuid?.bind(process), paths = {}) {
|
|
16
|
+
if ((0, first_party_harness_1.isDedicatedRuntimeWorkload)(env, getuid))
|
|
17
|
+
return true;
|
|
18
|
+
const socketPath = paths.socketPath ?? exports.GUEST_SERVICE_SOCKET_PATH;
|
|
19
|
+
const attestation = paths.attestationPath ?? '/etc/skrr-dedicated-runtime/attestation.json';
|
|
20
|
+
const runtimeDir = paths.runtimeDir ?? '/run/skrr-dedicated-runtime';
|
|
21
|
+
return (0, node_fs_1.existsSync)(socketPath) || (0, node_fs_1.existsSync)(attestation) || (0, node_fs_1.existsSync)(runtimeDir);
|
|
22
|
+
}
|
|
@@ -1280,7 +1280,23 @@ function describeRefusalReasons(err, context = {}) {
|
|
|
1280
1280
|
if (spend)
|
|
1281
1281
|
return spend;
|
|
1282
1282
|
const sentences = (0, harnesses_1.describeHealthReasonSentences)(details?.reasons, details?.state);
|
|
1283
|
-
|
|
1283
|
+
// A refusal whose public `reasons` came back empty used to print the bare
|
|
1284
|
+
// sentence, which reads as "no reason" when the server said more: that it
|
|
1285
|
+
// withheld reasons it does not publish, or that its own check did not finish
|
|
1286
|
+
// (OSK-12473). Say which, so the person knows whether to retry.
|
|
1287
|
+
const extra = [];
|
|
1288
|
+
const incomplete = Array.isArray(details?.checkIncompleteReasons)
|
|
1289
|
+
? details.checkIncompleteReasons.length
|
|
1290
|
+
: 0;
|
|
1291
|
+
if (details?.checkIncomplete === true || incomplete > 0) {
|
|
1292
|
+
extra.push('skrr could not finish checking the machine, so run the check again.');
|
|
1293
|
+
}
|
|
1294
|
+
const omitted = Number(details?.omittedReasonCount);
|
|
1295
|
+
if (Number.isInteger(omitted) && omitted > 0) {
|
|
1296
|
+
extra.push(`${omitted} further reason${omitted === 1 ? ' was' : 's were'} not published by the server.`);
|
|
1297
|
+
}
|
|
1298
|
+
const tail = [sentences, ...extra].filter(Boolean).join(' ');
|
|
1299
|
+
return tail ? ` Reasons: ${tail}` : '';
|
|
1284
1300
|
}
|
|
1285
1301
|
/**
|
|
1286
1302
|
* The numbers behind a cap refusal: the cap, this month's spend so far, and
|
|
@@ -373,16 +373,4 @@ export declare function mergeServiceTemplate(template: DedicatedServiceTemplate,
|
|
|
373
373
|
* guest-local status commands so both read the same shape.
|
|
374
374
|
*/
|
|
375
375
|
export declare function serviceDetailText(detail: unknown): string | undefined;
|
|
376
|
-
|
|
377
|
-
export declare const GUEST_SERVICE_SOCKET_PATH = "/run/skrr-dedicated-runtime/service-propose/service-propose.sock";
|
|
378
|
-
/**
|
|
379
|
-
* True when this process is running on a Dedicated Runtime guest — the
|
|
380
|
-
* workload-uid test, or the guest markers the image/daemon own. An SSH
|
|
381
|
-
* session may not carry the wrapper's env stamp, so the filesystem markers
|
|
382
|
-
* count too; the socket itself remains the authority.
|
|
383
|
-
*/
|
|
384
|
-
export declare function runningOnDedicatedRuntimeGuest(env?: NodeJS.ProcessEnv, getuid?: (() => number) | undefined, paths?: {
|
|
385
|
-
socketPath?: string;
|
|
386
|
-
attestationPath?: string;
|
|
387
|
-
runtimeDir?: string;
|
|
388
|
-
}): boolean;
|
|
376
|
+
export { GUEST_SERVICE_SOCKET_PATH, runningOnDedicatedRuntimeGuest } from './dedicated-guest';
|
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.GUEST_SERVICE_SOCKET_PATH = exports.DedicatedServiceChannel = exports.DedicatedServiceError = exports.SERVICE_ACTIONS = exports.SERVICE_RESTART_POLICIES = exports.SERVICE_RESERVED_NAMES = exports.SERVICE_SECRET_NAME_PATTERN = exports.SERVICE_NAME_PATTERN = exports.SERVICE_CALL_TIMEOUT_MS = exports.DEDICATED_PERSISTENT_TERMINAL_CAPABILITY = exports.DEDICATED_TERMINAL_NAME_PATTERN = exports.DEDICATED_TERMINAL_EVENTS = exports.DEDICATED_SERVICE_EVENTS = void 0;
|
|
6
|
+
exports.runningOnDedicatedRuntimeGuest = exports.GUEST_SERVICE_SOCKET_PATH = exports.DedicatedServiceChannel = exports.DedicatedServiceError = exports.SERVICE_ACTIONS = exports.SERVICE_RESTART_POLICIES = exports.SERVICE_RESERVED_NAMES = exports.SERVICE_SECRET_NAME_PATTERN = exports.SERVICE_NAME_PATTERN = exports.SERVICE_CALL_TIMEOUT_MS = exports.DEDICATED_PERSISTENT_TERMINAL_CAPABILITY = exports.DEDICATED_TERMINAL_NAME_PATTERN = exports.DEDICATED_TERMINAL_EVENTS = exports.DEDICATED_SERVICE_EVENTS = void 0;
|
|
7
7
|
exports.isServiceUnsupportedCode = isServiceUnsupportedCode;
|
|
8
8
|
exports.describeServiceRefusal = describeServiceRefusal;
|
|
9
9
|
exports.describeTerminalRefusal = describeTerminalRefusal;
|
|
@@ -19,7 +19,6 @@ exports.serviceTemplatesDir = serviceTemplatesDir;
|
|
|
19
19
|
exports.loadLocalServiceTemplate = loadLocalServiceTemplate;
|
|
20
20
|
exports.mergeServiceTemplate = mergeServiceTemplate;
|
|
21
21
|
exports.serviceDetailText = serviceDetailText;
|
|
22
|
-
exports.runningOnDedicatedRuntimeGuest = runningOnDedicatedRuntimeGuest;
|
|
23
22
|
const node_crypto_1 = require("node:crypto");
|
|
24
23
|
const node_fs_1 = require("node:fs");
|
|
25
24
|
const node_os_1 = require("node:os");
|
|
@@ -27,7 +26,6 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
27
26
|
const socket_io_client_1 = require("socket.io-client");
|
|
28
27
|
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
29
28
|
const agentic_stream_1 = require("./agentic-stream");
|
|
30
|
-
const first_party_harness_1 = require("./first-party-harness");
|
|
31
29
|
const format_1 = require("./format");
|
|
32
30
|
/**
|
|
33
31
|
* Declared services on a Dedicated Runtime — the CLI half of
|
|
@@ -801,19 +799,9 @@ function serviceDetailText(detail) {
|
|
|
801
799
|
parts.push(d.detail);
|
|
802
800
|
return parts.join(', ') || undefined;
|
|
803
801
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
* count too; the socket itself remains the authority.
|
|
811
|
-
*/
|
|
812
|
-
function runningOnDedicatedRuntimeGuest(env = process.env, getuid = process.getuid?.bind(process), paths = {}) {
|
|
813
|
-
if ((0, first_party_harness_1.isDedicatedRuntimeWorkload)(env, getuid))
|
|
814
|
-
return true;
|
|
815
|
-
const socketPath = paths.socketPath ?? exports.GUEST_SERVICE_SOCKET_PATH;
|
|
816
|
-
const attestation = paths.attestationPath ?? '/etc/skrr-dedicated-runtime/attestation.json';
|
|
817
|
-
const runtimeDir = paths.runtimeDir ?? '/run/skrr-dedicated-runtime';
|
|
818
|
-
return (0, node_fs_1.existsSync)(socketPath) || (0, node_fs_1.existsSync)(attestation) || (0, node_fs_1.existsSync)(runtimeDir);
|
|
819
|
-
}
|
|
802
|
+
// Guest detection lives in its own light module so the auth path (base-command,
|
|
803
|
+
// login) can ask "is this a guest?" without loading socket.io and the stream
|
|
804
|
+
// client this module needs (OSK-12473). Re-exported for existing callers.
|
|
805
|
+
var dedicated_guest_1 = require("./dedicated-guest");
|
|
806
|
+
Object.defineProperty(exports, "GUEST_SERVICE_SOCKET_PATH", { enumerable: true, get: function () { return dedicated_guest_1.GUEST_SERVICE_SOCKET_PATH; } });
|
|
807
|
+
Object.defineProperty(exports, "runningOnDedicatedRuntimeGuest", { enumerable: true, get: function () { return dedicated_guest_1.runningOnDedicatedRuntimeGuest; } });
|
package/dist/lib/login.js
CHANGED
|
@@ -37,6 +37,7 @@ const device_code_1 = require("./device-code");
|
|
|
37
37
|
const device_identity_1 = require("./device-identity");
|
|
38
38
|
const oauthLogin_1 = require("./oauthLogin");
|
|
39
39
|
const daemonBroker_1 = require("./daemonBroker");
|
|
40
|
+
const dedicated_guest_1 = require("./dedicated-guest");
|
|
40
41
|
const daemonBrokerRefusal_1 = require("./daemonBrokerRefusal");
|
|
41
42
|
/**
|
|
42
43
|
* What a shell with no terminal can do instead (OSK-12117). The refusal used to
|
|
@@ -119,8 +120,16 @@ async function cliLogin(opts = {}) {
|
|
|
119
120
|
// — the token lives for this process and the next `skrr` re-redeems
|
|
120
121
|
// over loopback. An explicit PKCE/device-code login further down
|
|
121
122
|
// still writes a real user-owned family, unchanged.
|
|
123
|
+
//
|
|
124
|
+
// Only a Dedicated guest's hand-off is the PLATFORM's. A laptop
|
|
125
|
+
// daemon's island file advertises `access_token` too, and there this
|
|
126
|
+
// sentence claimed a platform-managed machine that does not exist —
|
|
127
|
+
// the logout half of the same bug was OSK-12467 (c951446486).
|
|
122
128
|
console.log('');
|
|
123
|
-
console.log(
|
|
129
|
+
console.log((0, dedicated_guest_1.runningOnDedicatedRuntimeGuest)()
|
|
130
|
+
? " This machine is signed in through the platform's managed hand-off."
|
|
131
|
+
: ' Signed in through the skrr background service on this computer (no browser needed). ' +
|
|
132
|
+
'It lends this CLI short-lived tokens, so nothing was stored for this profile.');
|
|
124
133
|
return {
|
|
125
134
|
token: brokerResult.accessToken,
|
|
126
135
|
...(brokerResult.accessExpiresAt !== undefined
|
|
@@ -23,6 +23,37 @@ export declare function __setStateForTest(next: CredEnvelopeState): CredEnvelope
|
|
|
23
23
|
export declare function __getStateForTest(): CredEnvelopeState;
|
|
24
24
|
/** @internal Test seam — each spec owns its warn-dedupe horizon. */
|
|
25
25
|
export declare function __clearWarnDedupeForTest(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Run the OSK-10314 consolidation for this profile's wrapped DEK.
|
|
28
|
+
*
|
|
29
|
+
* Under the canonical lock, because the window it closes is real: a daemon
|
|
30
|
+
* copying its legacy DEK into an empty canonical slot must not race a CLI
|
|
31
|
+
* enrolling a fresh one there — the loser's key would be overwritten and
|
|
32
|
+
* anything already sealed under it lost. That is the same race the lock was
|
|
33
|
+
* introduced for, one layer up.
|
|
34
|
+
*
|
|
35
|
+
* Never fatal. A migration that cannot run leaves the process exactly where it
|
|
36
|
+
* was, which is the state it has been operating in all along.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Whether a split profile may be converged onto the legacy (daemon) key.
|
|
40
|
+
*
|
|
41
|
+
* Only the daemon ever has a legacy location, so only the daemon runs this, and
|
|
42
|
+
* the legacy key is the one it signs with. Since OSK-12063 the CLI enrolls no
|
|
43
|
+
* device key and only an interactive daemon login re-pins, so the key the server
|
|
44
|
+
* pins for this machine is the daemon's, UNLESS the daemon has itself been
|
|
45
|
+
* refused over its device key. That is the one fact that proves otherwise, and
|
|
46
|
+
* then nothing here knows which key the server holds, so it refuses as before.
|
|
47
|
+
*
|
|
48
|
+
* Why converging matters (OSK-12462): a CLI credential brokered by the daemon
|
|
49
|
+
* is tied to the daemon's machine row, and the server checks its refresh proof
|
|
50
|
+
* against the key pinned there. A CLI signing with a different key has that
|
|
51
|
+
* refresh refused as DEVICE_PROOF_THUMBPRINT_MISMATCH at its first renewal.
|
|
52
|
+
*/
|
|
53
|
+
export declare function splitKeyConvergencePolicy(): {
|
|
54
|
+
converge: boolean;
|
|
55
|
+
reason: 'daemon_key_pinned' | 'daemon_key_refused';
|
|
56
|
+
};
|
|
26
57
|
/**
|
|
27
58
|
* Initialize the envelope path. Idempotent — second call when state is
|
|
28
59
|
* non-uninit returns immediately.
|
|
@@ -96,6 +127,16 @@ export declare function describeCredEnvelopeState(): {
|
|
|
96
127
|
canonicalPath: string;
|
|
97
128
|
legacyPath: string;
|
|
98
129
|
};
|
|
130
|
+
/**
|
|
131
|
+
* Set when THIS process converged a split profile onto the legacy (daemon)
|
|
132
|
+
* key (OSK-12462). `canonicalPath` held the key that is now retired.
|
|
133
|
+
*/
|
|
134
|
+
convergedFrom?: {
|
|
135
|
+
canonicalPath: string;
|
|
136
|
+
legacyPath: string;
|
|
137
|
+
};
|
|
138
|
+
/** How many retired DEKs this process can still open old values with. */
|
|
139
|
+
retiredKeys?: number;
|
|
99
140
|
};
|
|
100
141
|
/**
|
|
101
142
|
* Sync write transform. Returns the wrapped serialized form when active.
|
|
@@ -7,6 +7,7 @@ exports.DEFAULT_UNREADABLE_CREDENTIAL_HINT = void 0;
|
|
|
7
7
|
exports.__setStateForTest = __setStateForTest;
|
|
8
8
|
exports.__getStateForTest = __getStateForTest;
|
|
9
9
|
exports.__clearWarnDedupeForTest = __clearWarnDedupeForTest;
|
|
10
|
+
exports.splitKeyConvergencePolicy = splitKeyConvergencePolicy;
|
|
10
11
|
exports.initCredEnvelope = initCredEnvelope;
|
|
11
12
|
exports.resetCredEnvelope = resetCredEnvelope;
|
|
12
13
|
exports.shutdownCredEnvelope = shutdownCredEnvelope;
|
|
@@ -67,6 +68,7 @@ const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
|
|
|
67
68
|
const credentialEnvelope_js_1 = require("./credentialEnvelope.js");
|
|
68
69
|
const index_js_1 = require("./kek/index.js");
|
|
69
70
|
const profileStateDir_js_1 = require("./profileStateDir.js");
|
|
71
|
+
const refresh_js_1 = require("./refresh.js");
|
|
70
72
|
const runtime_js_1 = require("./runtime.js");
|
|
71
73
|
/**
|
|
72
74
|
* Account name the wrapped DEK lives under (file fallback). Keeping the
|
|
@@ -259,6 +261,20 @@ async function withCredEnvelopeLock(profile, body) {
|
|
|
259
261
|
* auth-core's to do), so being loud about it IS the remedy.
|
|
260
262
|
*/
|
|
261
263
|
let _pathConflict = null;
|
|
264
|
+
/**
|
|
265
|
+
* DEKs this profile used before a converging consolidation retired them
|
|
266
|
+
* (OSK-12462). Read-only: nothing is ever sealed under one again. A value that
|
|
267
|
+
* only a retired DEK opens is returned with `needsMigration: true`, the same
|
|
268
|
+
* signal a legacy plaintext value carries, so every consumer that already
|
|
269
|
+
* upgrades plaintext in place re-seals it under the active DEK on its own
|
|
270
|
+
* schedule. auth-core does not need to know where the consumer keeps it.
|
|
271
|
+
*/
|
|
272
|
+
let _retiredDeks = [];
|
|
273
|
+
/**
|
|
274
|
+
* Set when THIS process converged a split profile onto the legacy key. For the
|
|
275
|
+
* status surface and tests; the on-disk marker is the durable record.
|
|
276
|
+
*/
|
|
277
|
+
let _convergedFrom = null;
|
|
262
278
|
/**
|
|
263
279
|
* Identity of a wrapped DEK: the SHA-256 of the key it unwraps to.
|
|
264
280
|
*
|
|
@@ -297,18 +313,48 @@ async function wrappedDekIdentity(target, kek, aad) {
|
|
|
297
313
|
* Never fatal. A migration that cannot run leaves the process exactly where it
|
|
298
314
|
* was, which is the state it has been operating in all along.
|
|
299
315
|
*/
|
|
316
|
+
/**
|
|
317
|
+
* Whether a split profile may be converged onto the legacy (daemon) key.
|
|
318
|
+
*
|
|
319
|
+
* Only the daemon ever has a legacy location, so only the daemon runs this, and
|
|
320
|
+
* the legacy key is the one it signs with. Since OSK-12063 the CLI enrolls no
|
|
321
|
+
* device key and only an interactive daemon login re-pins, so the key the server
|
|
322
|
+
* pins for this machine is the daemon's, UNLESS the daemon has itself been
|
|
323
|
+
* refused over its device key. That is the one fact that proves otherwise, and
|
|
324
|
+
* then nothing here knows which key the server holds, so it refuses as before.
|
|
325
|
+
*
|
|
326
|
+
* Why converging matters (OSK-12462): a CLI credential brokered by the daemon
|
|
327
|
+
* is tied to the daemon's machine row, and the server checks its refresh proof
|
|
328
|
+
* against the key pinned there. A CLI signing with a different key has that
|
|
329
|
+
* refresh refused as DEVICE_PROOF_THUMBPRINT_MISMATCH at its first renewal.
|
|
330
|
+
*/
|
|
331
|
+
function splitKeyConvergencePolicy() {
|
|
332
|
+
let reauthReason;
|
|
333
|
+
try {
|
|
334
|
+
const state = (0, refresh_js_1.needsReauthState)();
|
|
335
|
+
reauthReason = state.needed ? state.reason : undefined;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
reauthReason = undefined;
|
|
339
|
+
}
|
|
340
|
+
return reauthReason === 'DEVICE_KEY_MISMATCH'
|
|
341
|
+
? { converge: false, reason: 'daemon_key_refused' }
|
|
342
|
+
: { converge: true, reason: 'daemon_key_pinned' };
|
|
343
|
+
}
|
|
300
344
|
async function consolidateWrappedDek(profile, kek, aad) {
|
|
301
345
|
const resolved = (0, profileStateDir_js_1.resolveProfileStateFile)(profile, ACCOUNT_CRED_DEK_WRAPPED);
|
|
302
346
|
if (!resolved.usingLegacy) {
|
|
303
347
|
_pathConflict = null;
|
|
304
348
|
return;
|
|
305
349
|
}
|
|
350
|
+
const policy = splitKeyConvergencePolicy();
|
|
306
351
|
let outcome;
|
|
307
352
|
try {
|
|
308
353
|
outcome = await withCredEnvelopeLock(profile, () => (0, profileStateDir_js_1.consolidateProfileState)(profile, {
|
|
309
354
|
files: [ACCOUNT_CRED_DEK_WRAPPED],
|
|
310
355
|
primary: ACCOUNT_CRED_DEK_WRAPPED,
|
|
311
356
|
identify: (dir) => wrappedDekIdentity(node_path_1.default.join(dir, ACCOUNT_CRED_DEK_WRAPPED), kek, aad),
|
|
357
|
+
onConflict: policy.converge ? 'adopt_legacy' : 'refuse',
|
|
312
358
|
onEvent: (name, success, meta) => emit(`cred_envelope.${name}`, success, meta),
|
|
313
359
|
}));
|
|
314
360
|
}
|
|
@@ -319,6 +365,19 @@ async function consolidateWrappedDek(profile, kek, aad) {
|
|
|
319
365
|
});
|
|
320
366
|
return;
|
|
321
367
|
}
|
|
368
|
+
if (outcome.status === 'converged') {
|
|
369
|
+
_pathConflict = null;
|
|
370
|
+
_convergedFrom = {
|
|
371
|
+
canonicalPath: node_path_1.default.join(outcome.canonicalDir, ACCOUNT_CRED_DEK_WRAPPED),
|
|
372
|
+
legacyPath: node_path_1.default.join(outcome.legacyDir ?? '', ACCOUNT_CRED_DEK_WRAPPED),
|
|
373
|
+
};
|
|
374
|
+
(0, runtime_js_1.getAuthLogger)().info(`[credEnvelope] profile "${profile}" had two different usable wrapped DEKs; ` +
|
|
375
|
+
`converged on the daemon's (${_convergedFrom.legacyPath}), which is the key the ` +
|
|
376
|
+
"server pins for this machine. The CLI's former key is kept beside it as " +
|
|
377
|
+
`${ACCOUNT_CRED_DEK_WRAPPED}${profileStateDir_js_1.RETIRED_COPY_SUFFIX}.* and still opens what was ` +
|
|
378
|
+
'sealed under it; each value is re-sealed under the shared key on its next write.');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
322
381
|
if (outcome.status !== 'conflict') {
|
|
323
382
|
_pathConflict = null;
|
|
324
383
|
return;
|
|
@@ -335,13 +394,62 @@ async function consolidateWrappedDek(profile, kek, aad) {
|
|
|
335
394
|
// BINARY restarts and adopts its legacy key into the empty canonical slot,
|
|
336
395
|
// and the CLI logs back in on it. Not `skrr daemon restart`: any `skrr`
|
|
337
396
|
// command mints a fresh key into an empty slot before doing anything else.
|
|
338
|
-
|
|
397
|
+
//
|
|
398
|
+
// Reached only when the daemon itself was refused over its device key
|
|
399
|
+
// (`splitKeyConvergencePolicy`); an ordinary split converges above.
|
|
400
|
+
(0, runtime_js_1.getAuthLogger)().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs, and ` +
|
|
401
|
+
"the server refused the daemon's key, so neither is known to be the pinned one; " +
|
|
339
402
|
`refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
|
|
340
403
|
`${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
|
|
341
404
|
"credentials, and neither can read the other's. Do not delete " +
|
|
342
405
|
`${_pathConflict.legacyPath}: it is the key the daemon reads. To converge on it, ` +
|
|
343
406
|
'run `skrr logout && skrrd restart && skrr login`.');
|
|
344
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Load every retired DEK beside the canonical one (OSK-12462). Only files a
|
|
410
|
+
* converging consolidation set aside are read; the `.superseded.*` copy an
|
|
411
|
+
* UNUSABLE key leaves is not a key anything can open. Read-only unwrap, never
|
|
412
|
+
* fatal: a retired key that no longer opens is simply not in the ring.
|
|
413
|
+
*/
|
|
414
|
+
async function loadRetiredDeks(profile, kek, aad, active) {
|
|
415
|
+
for (const old of _retiredDeks)
|
|
416
|
+
old.fill(0);
|
|
417
|
+
_retiredDeks = [];
|
|
418
|
+
for (const target of retiredDekFiles(profile)) {
|
|
419
|
+
try {
|
|
420
|
+
if (!kek.unwrapExisting)
|
|
421
|
+
return;
|
|
422
|
+
const dek = await kek.unwrapExisting(node_fs_1.default.readFileSync(target), aad);
|
|
423
|
+
if (dek.length === 32 && !dek.equals(active)) {
|
|
424
|
+
_retiredDeks.push(dek);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
dek.fill(0);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
emit('cred_envelope.retired_dek.unreadable', false, {
|
|
432
|
+
profile,
|
|
433
|
+
file: node_path_1.default.basename(target),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Retired wrapped DEKs for `profile`, oldest first. */
|
|
439
|
+
function retiredDekFiles(profile) {
|
|
440
|
+
const dir = (0, profileStateDir_js_1.profileStateDir)(profile);
|
|
441
|
+
const prefix = `${ACCOUNT_CRED_DEK_WRAPPED}${profileStateDir_js_1.RETIRED_COPY_SUFFIX}.`;
|
|
442
|
+
try {
|
|
443
|
+
return node_fs_1.default
|
|
444
|
+
.readdirSync(dir)
|
|
445
|
+
.filter((name) => name.startsWith(prefix))
|
|
446
|
+
.sort()
|
|
447
|
+
.map((name) => node_path_1.default.join(dir, name));
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
}
|
|
345
453
|
/**
|
|
346
454
|
* Initialize the envelope path. Idempotent — second call when state is
|
|
347
455
|
* non-uninit returns immediately.
|
|
@@ -416,6 +524,7 @@ async function initCredEnvelope(profile) {
|
|
|
416
524
|
const wrappedBytes = node_fs_1.default.readFileSync(targetPath);
|
|
417
525
|
const candidate = await kek.unwrapExisting(wrappedBytes, aad);
|
|
418
526
|
if (candidate.length === 32) {
|
|
527
|
+
await loadRetiredDeks(profile, kek, aad, candidate);
|
|
419
528
|
_state = {
|
|
420
529
|
kind: 'active',
|
|
421
530
|
dek: candidate,
|
|
@@ -523,6 +632,7 @@ async function initCredEnvelope(profile) {
|
|
|
523
632
|
return;
|
|
524
633
|
}
|
|
525
634
|
}
|
|
635
|
+
await loadRetiredDeks(profile, kek, aad, dek);
|
|
526
636
|
_state = {
|
|
527
637
|
kind: 'active',
|
|
528
638
|
dek: dek,
|
|
@@ -553,7 +663,12 @@ async function initCredEnvelope(profile) {
|
|
|
553
663
|
*/
|
|
554
664
|
async function resetCredEnvelope(opts) {
|
|
555
665
|
if (opts?.clearOnDisk && opts.profile) {
|
|
556
|
-
|
|
666
|
+
// A logout ends every key this profile held, the retired ones included: a
|
|
667
|
+
// retired DEK exists only to open values the logout is deleting anyway.
|
|
668
|
+
for (const target of [
|
|
669
|
+
...(0, profileStateDir_js_1.profileStateFilePathsToClear)(opts.profile, ACCOUNT_CRED_DEK_WRAPPED),
|
|
670
|
+
...retiredDekFiles(opts.profile),
|
|
671
|
+
]) {
|
|
557
672
|
try {
|
|
558
673
|
node_fs_1.default.unlinkSync(target);
|
|
559
674
|
}
|
|
@@ -574,6 +689,10 @@ async function resetCredEnvelope(opts) {
|
|
|
574
689
|
_state = { kind: 'uninit' };
|
|
575
690
|
_kekRotationDetected = false;
|
|
576
691
|
_pathConflict = null;
|
|
692
|
+
_convergedFrom = null;
|
|
693
|
+
for (const old of _retiredDeks)
|
|
694
|
+
old.fill(0);
|
|
695
|
+
_retiredDeks = [];
|
|
577
696
|
}
|
|
578
697
|
/**
|
|
579
698
|
* Idempotent shutdown — must be called from the daemon's graceful-exit
|
|
@@ -612,6 +731,10 @@ function shutdownCredEnvelope() {
|
|
|
612
731
|
_state = { kind: 'uninit' };
|
|
613
732
|
_kekRotationDetected = false;
|
|
614
733
|
_pathConflict = null;
|
|
734
|
+
_convergedFrom = null;
|
|
735
|
+
for (const old of _retiredDeks)
|
|
736
|
+
old.fill(0);
|
|
737
|
+
_retiredDeks = [];
|
|
615
738
|
}
|
|
616
739
|
/** @internal Test seam — re-arm `shutdownCredEnvelope` so it can run again. */
|
|
617
740
|
function __resetShutdownGuardForTest() {
|
|
@@ -647,6 +770,10 @@ function hasCredEnvelopePathConflict() {
|
|
|
647
770
|
* `oversky status`; clears on the next clean `resetCredEnvelope`.
|
|
648
771
|
*/
|
|
649
772
|
function describeCredEnvelopeState() {
|
|
773
|
+
const extras = {
|
|
774
|
+
...(_convergedFrom ? { convergedFrom: _convergedFrom } : {}),
|
|
775
|
+
...(_retiredDeks.length > 0 ? { retiredKeys: _retiredDeks.length } : {}),
|
|
776
|
+
};
|
|
650
777
|
if (_state.kind === 'disabled') {
|
|
651
778
|
return {
|
|
652
779
|
kind: 'disabled',
|
|
@@ -663,6 +790,7 @@ function describeCredEnvelopeState() {
|
|
|
663
790
|
profile: _state.profile,
|
|
664
791
|
...(_kekRotationDetected ? { kekRotated: true } : {}),
|
|
665
792
|
...(_pathConflict ? { pathConflict: _pathConflict } : {}),
|
|
793
|
+
...extras,
|
|
666
794
|
};
|
|
667
795
|
}
|
|
668
796
|
return { kind: 'uninit', ...(_pathConflict ? { pathConflict: _pathConflict } : {}) };
|
|
@@ -784,12 +912,28 @@ function maybeDecryptOnRead(stored, options = {}) {
|
|
|
784
912
|
});
|
|
785
913
|
return { plaintext: null, needsMigration: false };
|
|
786
914
|
}
|
|
915
|
+
const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(_state.profile);
|
|
787
916
|
try {
|
|
788
|
-
const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(_state.profile);
|
|
789
917
|
const pt = (0, credentialEnvelope_js_1.decrypt)(sealed, _state.dek, aad).toString('utf-8');
|
|
790
918
|
return { plaintext: pt, needsMigration: false };
|
|
791
919
|
}
|
|
792
920
|
catch (err) {
|
|
921
|
+
// Sealed before a converging consolidation retired its key (OSK-12462).
|
|
922
|
+
// Readable, and flagged for the in-place upgrade every consumer already
|
|
923
|
+
// performs for legacy plaintext, which re-seals it under the active DEK.
|
|
924
|
+
for (const retired of _retiredDeks) {
|
|
925
|
+
try {
|
|
926
|
+
const pt = (0, credentialEnvelope_js_1.decrypt)(sealed, retired, aad).toString('utf-8');
|
|
927
|
+
emit('cred_envelope.read.retired_key', true, {
|
|
928
|
+
kekId: _state.kekId,
|
|
929
|
+
kekKind: _state.kekKind,
|
|
930
|
+
});
|
|
931
|
+
return { plaintext: pt, needsMigration: true };
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
/* not this one */
|
|
935
|
+
}
|
|
936
|
+
}
|
|
793
937
|
emit('cred_envelope.read.decrypt_failed', false, {
|
|
794
938
|
kekId: _state.kekId,
|
|
795
939
|
kekKind: _state.kekKind,
|
|
@@ -15,6 +15,23 @@ type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed';
|
|
|
15
15
|
export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
|
|
16
16
|
/** @internal Test seam — read state. */
|
|
17
17
|
export declare function __getStateForTest(): DeviceIdentityState;
|
|
18
|
+
/**
|
|
19
|
+
* The device-key split, read from disk: the daemon (legacy directory, not yet
|
|
20
|
+
* consolidated) and the CLI (canonical directory) holding DIFFERENT keypairs
|
|
21
|
+
* for one profile. `null` when this process has no legacy location, when the
|
|
22
|
+
* legacy copy is consolidated, or when either side has no readable public key.
|
|
23
|
+
*
|
|
24
|
+
* This is the fact the server acts on (OSK-12462): a CLI credential brokered by
|
|
25
|
+
* the daemon is checked against the key pinned on the daemon's machine row, so
|
|
26
|
+
* while the thumbprints differ the CLI's refresh is refused. Reading it from
|
|
27
|
+
* the public JWKs, which are plaintext, needs no DEK and changes nothing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function describeDeviceKeySplit(profile: string): {
|
|
30
|
+
daemonThumbprint: string;
|
|
31
|
+
cliThumbprint: string;
|
|
32
|
+
daemonDir: string;
|
|
33
|
+
cliDir: string;
|
|
34
|
+
} | null;
|
|
18
35
|
/**
|
|
19
36
|
* Initialize device identity. Idempotent.
|
|
20
37
|
*
|