@skrr-ai/cli 0.1.79 → 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.
- package/dist/base-command.js +9 -2
- package/dist/commands/inbox/index.js +4 -2
- package/dist/commands/login.d.ts +8 -3
- package/dist/commands/login.js +14 -4
- package/dist/commands/spaces/workflows/events.js +9 -2
- package/dist/lib/agentic-stream.js +13 -4
- package/dist/lib/api-fetch.js +6 -2
- package/dist/lib/brokered-dpop.d.ts +34 -0
- package/dist/lib/brokered-dpop.js +89 -0
- package/dist/lib/daemonBroker.d.ts +71 -2
- package/dist/lib/daemonBroker.js +203 -36
- 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.js +6 -3
- 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 +4 -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/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/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 +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +19 -3
- 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/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/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 +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +4 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +3422 -3407
- package/oclif.manifest.json +31930 -31930
- package/package.json +2 -2
package/dist/base-command.js
CHANGED
|
@@ -399,7 +399,10 @@ class BaseCommand extends core_1.Command {
|
|
|
399
399
|
}
|
|
400
400
|
else if ((this.brokerRefusal =
|
|
401
401
|
(0, daemonBrokerRefusal_1.describeBrokerRefusal)(autoResult.outcome, this.config.bin) ??
|
|
402
|
-
describeAbsentGuestDaemon(autoResult.outcome, this.config.bin)
|
|
402
|
+
describeAbsentGuestDaemon(autoResult.outcome, this.config.bin) ??
|
|
403
|
+
// A laptop daemon that is down or mid-restart is not the person
|
|
404
|
+
// being signed out (OSK-13222).
|
|
405
|
+
(0, daemonBrokerRefusal_1.describeLocalDaemonUnavailable)(autoResult.outcome, this.config.bin))) {
|
|
403
406
|
// Reported by requireAuth(); nothing to print here.
|
|
404
407
|
}
|
|
405
408
|
else if (autoResult.outcome.reason === 'rate_limited') {
|
|
@@ -760,7 +763,11 @@ class BaseCommand extends core_1.Command {
|
|
|
760
763
|
// Same code as the plain case — scripts branch on NOT_SIGNED_IN — with
|
|
761
764
|
// the daemon's answer in the message and in `details.broker`.
|
|
762
765
|
this.failWithCliError({
|
|
763
|
-
|
|
766
|
+
// A daemon that is down or restarting holds the person's sign-in; it
|
|
767
|
+
// has not been lost, so the sentence must not say it has (OSK-13222).
|
|
768
|
+
message: refusal.daemonUnavailable
|
|
769
|
+
? `No credential from the skrr background service. ${refusal.headline}\n→ ${refusal.remedy}`
|
|
770
|
+
: `Not signed in. ${refusal.headline}\n→ ${refusal.remedy}`,
|
|
764
771
|
code: 'NOT_SIGNED_IN',
|
|
765
772
|
exit: 2,
|
|
766
773
|
retryable: refusal.transient,
|
|
@@ -8,6 +8,7 @@ const base_command_1 = require("../../base-command");
|
|
|
8
8
|
const inbox_1 = require("../../lib/inbox");
|
|
9
9
|
const format_1 = require("../../lib/format");
|
|
10
10
|
const agentic_stream_1 = require("../../lib/agentic-stream");
|
|
11
|
+
const dpop_auth_1 = require("../../lib/dpop-auth");
|
|
11
12
|
/**
|
|
12
13
|
* Stop `--follow` on Ctrl-C or SIGTERM, and end with the status a shell
|
|
13
14
|
* reports for that signal (130 / 143).
|
|
@@ -241,11 +242,12 @@ class Inbox extends base_command_1.BaseCommand {
|
|
|
241
242
|
const wsURL = (0, agentic_stream_1.toWebSocketOrigin)(this.cliConfig.baseURL);
|
|
242
243
|
const socket = (0, socket_io_client_1.io)(wsURL, {
|
|
243
244
|
path: '/ws/agentic',
|
|
244
|
-
|
|
245
|
+
// OSK-12020 — a DPoP-bound token proves possession on every handshake.
|
|
246
|
+
auth: (0, dpop_auth_1.socketAuthWithDpop)({
|
|
245
247
|
token: this.resolvedCredential.token,
|
|
246
248
|
...(0, data_provider_1.getClientIdentityAuth)(),
|
|
247
249
|
clientPlatform: 'cli',
|
|
248
|
-
},
|
|
250
|
+
}, wsURL),
|
|
249
251
|
extraHeaders: process.env.OVERSKY_WORKSPACE_ID
|
|
250
252
|
? { 'X-Workspace-Id': process.env.OVERSKY_WORKSPACE_ID }
|
|
251
253
|
: undefined,
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -12,9 +12,14 @@ import { BaseCommand } from '../base-command';
|
|
|
12
12
|
* Refresh, rotation, family revoke, and the cross-process auth.lock are
|
|
13
13
|
* all the same substrate the daemon uses (@skrr-ai/auth-core).
|
|
14
14
|
*
|
|
15
|
-
* CI
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* CI. There is no long-lived token that `skrr` commands accept yet (OSK-13208).
|
|
16
|
+
* `skrr create-token` mints an `osk_ci_*` token, and that token only
|
|
17
|
+
* bootstraps a DAEMON (`OVERSKY_TOKEN=osk_ci_... skrrd start`): every `skrr`
|
|
18
|
+
* command rejects it with 401 (see create-token.ts and base-command.ts). What
|
|
19
|
+
* works today is a session minted for the machine with
|
|
20
|
+
* `skrr token mint --for <id>`, restored by the `OVERSKY_REFRESH_TOKEN` path
|
|
21
|
+
* below — whose refresh token rotates on use — or `OVERSKY_AUTH_HELPER`.
|
|
22
|
+
* docs/cli/SKRR_AUTH.md ("CI today") states the caveats.
|
|
18
23
|
*
|
|
19
24
|
* L13 — non-interactive restore from a refresh token. When
|
|
20
25
|
* `OVERSKY_REFRESH_TOKEN` is set in the environment, the browser /
|
package/dist/commands/login.js
CHANGED
|
@@ -27,9 +27,14 @@ const prompt_1 = require("../lib/prompt");
|
|
|
27
27
|
* Refresh, rotation, family revoke, and the cross-process auth.lock are
|
|
28
28
|
* all the same substrate the daemon uses (@skrr-ai/auth-core).
|
|
29
29
|
*
|
|
30
|
-
* CI
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* CI. There is no long-lived token that `skrr` commands accept yet (OSK-13208).
|
|
31
|
+
* `skrr create-token` mints an `osk_ci_*` token, and that token only
|
|
32
|
+
* bootstraps a DAEMON (`OVERSKY_TOKEN=osk_ci_... skrrd start`): every `skrr`
|
|
33
|
+
* command rejects it with 401 (see create-token.ts and base-command.ts). What
|
|
34
|
+
* works today is a session minted for the machine with
|
|
35
|
+
* `skrr token mint --for <id>`, restored by the `OVERSKY_REFRESH_TOKEN` path
|
|
36
|
+
* below — whose refresh token rotates on use — or `OVERSKY_AUTH_HELPER`.
|
|
37
|
+
* docs/cli/SKRR_AUTH.md ("CI today") states the caveats.
|
|
33
38
|
*
|
|
34
39
|
* L13 — non-interactive restore from a refresh token. When
|
|
35
40
|
* `OVERSKY_REFRESH_TOKEN` is set in the environment, the browser /
|
|
@@ -60,7 +65,12 @@ class Login extends base_command_1.BaseCommand {
|
|
|
60
65
|
'Set OVERSKY_SKIP_DAEMON_BROKER=1 to skip step 1 (--device-code skips it too). After signing ' +
|
|
61
66
|
'in, skrr also signs the background service in, and on an interactive first login on a ' +
|
|
62
67
|
'computer without it, offers to install it. Set SKRR_SKIP_DAEMON_HANDOFF=1 to skip that ' +
|
|
63
|
-
'optional setup; it does not skip step 1
|
|
68
|
+
'optional setup; it does not skip step 1.\n\n' +
|
|
69
|
+
'With no person to approve a sign-in (CI, a provisioning script): mint a session for that ' +
|
|
70
|
+
'machine with `skrr token mint --for <id>` and run this command there with ' +
|
|
71
|
+
'OVERSKY_REFRESH_TOKEN and OVERSKY_CLI_ID set. The refresh token rotates when used, so one ' +
|
|
72
|
+
'minted session serves one machine. Tokens from `skrr create-token` only bring up a ' +
|
|
73
|
+
'background service (`skrrd`); skrr commands reject them.';
|
|
64
74
|
static examples = [
|
|
65
75
|
'<%= config.bin %> login',
|
|
66
76
|
'<%= config.bin %> login --env prod',
|
|
@@ -4,6 +4,7 @@ exports.workflowEventStreamHeaders = workflowEventStreamHeaders;
|
|
|
4
4
|
const core_1 = require("@oclif/core");
|
|
5
5
|
const base_command_1 = require("../../../base-command");
|
|
6
6
|
const workspace_header_1 = require("../../../lib/workspace-header");
|
|
7
|
+
const dpop_auth_1 = require("../../../lib/dpop-auth");
|
|
7
8
|
function workflowEventStreamHeaders(token, env = process.env) {
|
|
8
9
|
return (0, workspace_header_1.withWorkspaceHeader)({
|
|
9
10
|
Accept: 'text/event-stream',
|
|
@@ -31,8 +32,14 @@ class SpacesWorkflowsEvents extends base_command_1.BaseCommand {
|
|
|
31
32
|
this.error('--after must be an ISO-8601 timestamp', { exit: 2 });
|
|
32
33
|
}
|
|
33
34
|
const path = `/api/spaces/${encodeURIComponent(args.spaceId)}/workflows/runs/${encodeURIComponent(args.runId)}/events/stream${flags.after ? `?after=${encodeURIComponent(flags.after)}` : ''}`;
|
|
34
|
-
const
|
|
35
|
-
|
|
35
|
+
const streamUrl = `${this.cliConfig.baseURL.replace(/\/+$/, '')}${path}`;
|
|
36
|
+
const token = this.resolvedCredential.token;
|
|
37
|
+
const response = await fetch(streamUrl, {
|
|
38
|
+
headers: {
|
|
39
|
+
...workflowEventStreamHeaders(token),
|
|
40
|
+
// OSK-12020 — a DPoP-bound hand-off token proves possession here too.
|
|
41
|
+
...(await (0, dpop_auth_1.dpopHeadersFor)(token, 'GET', streamUrl)),
|
|
42
|
+
},
|
|
36
43
|
});
|
|
37
44
|
if (!response.ok || !response.body) {
|
|
38
45
|
const body = await response.text();
|
|
@@ -26,6 +26,7 @@ const socket_io_client_1 = require("socket.io-client");
|
|
|
26
26
|
const data_provider_1 = require("@skrr-ai/data-provider");
|
|
27
27
|
const config_1 = require("./config");
|
|
28
28
|
const publicEndpoints_generated_1 = require("./publicEndpoints.generated");
|
|
29
|
+
const dpop_auth_1 = require("./dpop-auth");
|
|
29
30
|
/**
|
|
30
31
|
* Why a question was dismissed when no terminal exists to ask it on. It is the
|
|
31
32
|
* REASON for a dismissal, never an answer: it travels as the value of the
|
|
@@ -709,12 +710,13 @@ class AgenticStreamClient {
|
|
|
709
710
|
: undefined;
|
|
710
711
|
this.socket = (0, socket_io_client_1.io)(wsURL, {
|
|
711
712
|
path: '/ws/agentic',
|
|
712
|
-
|
|
713
|
+
// OSK-12020 — a DPoP-bound token proves possession on every handshake.
|
|
714
|
+
auth: (0, dpop_auth_1.socketAuthWithDpop)({
|
|
713
715
|
token: this.options.token,
|
|
714
716
|
...(0, data_provider_1.getClientIdentityAuth)(),
|
|
715
717
|
clientPlatform: 'cli',
|
|
716
718
|
clientOs: process.platform === 'darwin' ? 'macos' : process.platform,
|
|
717
|
-
},
|
|
719
|
+
}, wsURL),
|
|
718
720
|
extraHeaders,
|
|
719
721
|
transports: ['websocket'],
|
|
720
722
|
reconnection: true,
|
|
@@ -948,12 +950,19 @@ class AgenticStreamClient {
|
|
|
948
950
|
if (this.closed || this.settled || generation !== this.replayGeneration)
|
|
949
951
|
return;
|
|
950
952
|
this.replayFailures = 0;
|
|
951
|
-
this.receiveSessionOutput({
|
|
953
|
+
this.receiveSessionOutput({
|
|
954
|
+
type: 'session.output',
|
|
955
|
+
sessionId: this.options.sessionId,
|
|
956
|
+
source: 'replay',
|
|
957
|
+
envelopes: result.envelopes,
|
|
958
|
+
});
|
|
952
959
|
// The canonical endpoint explicitly identifies retention gaps. Only
|
|
953
960
|
// then may later frames pass with the existing honest gap notice.
|
|
954
961
|
if (result.gap) {
|
|
955
962
|
// A retention gap proves only this page, never a later live frame.
|
|
956
|
-
const pageSeqs = result.envelopes
|
|
963
|
+
const pageSeqs = result.envelopes
|
|
964
|
+
.map((e) => Number(e?.__seq))
|
|
965
|
+
.filter(Number.isSafeInteger);
|
|
957
966
|
if (pageSeqs.length)
|
|
958
967
|
this.flushPendingEnvelopes(true, Math.max(...pageSeqs));
|
|
959
968
|
this.flushPendingEnvelopes(false);
|
package/dist/lib/api-fetch.js
CHANGED
|
@@ -44,6 +44,7 @@ const node_adapter_1 = require("./node-adapter");
|
|
|
44
44
|
const refresh_1 = require("./refresh");
|
|
45
45
|
const delegated_cli_1 = require("./delegated-cli");
|
|
46
46
|
const daemonBroker_1 = require("./daemonBroker");
|
|
47
|
+
const dpop_auth_1 = require("./dpop-auth");
|
|
47
48
|
const workspace_header_1 = require("./workspace-header");
|
|
48
49
|
const non_json_response_1 = require("./non-json-response");
|
|
49
50
|
class ApiFetchError extends Error {
|
|
@@ -162,7 +163,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
162
163
|
(0, node_adapter_1.getAdapterCredentialOverride)() ??
|
|
163
164
|
(await (0, credential_resolver_1.resolveCredential)());
|
|
164
165
|
}
|
|
165
|
-
const fetchWithCredential = (credential) => {
|
|
166
|
+
const fetchWithCredential = async (credential) => {
|
|
166
167
|
const headers = {
|
|
167
168
|
Accept: 'application/json',
|
|
168
169
|
'X-Oversky-Origin': (0, refresh_1.getCliOrigin)(),
|
|
@@ -178,10 +179,13 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
178
179
|
// key is additive metadata, not a way to re-auth the request.
|
|
179
180
|
for (const [k, v] of Object.entries(callerHeaders)) {
|
|
180
181
|
const lower = k.toLowerCase();
|
|
181
|
-
if (lower === 'authorization' || lower === 'content-type')
|
|
182
|
+
if (lower === 'authorization' || lower === 'content-type' || lower === 'dpop')
|
|
182
183
|
continue;
|
|
183
184
|
headers[k] = v;
|
|
184
185
|
}
|
|
186
|
+
// OSK-12020 — a fresh proof per attempt for a DPoP-bound hand-off token
|
|
187
|
+
// (the 401 retry below re-enters here and gets its own). No-op otherwise.
|
|
188
|
+
Object.assign(headers, await (0, dpop_auth_1.dpopHeadersFor)(credential.token, method, url));
|
|
185
189
|
return fetch(url, {
|
|
186
190
|
method,
|
|
187
191
|
headers: (0, workspace_header_1.withWorkspaceHeader)(headers),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brokered-dpop.ts — this process's DPoP-bound hand-off token, and the proof
|
|
3
|
+
* fetch for it (OSK-12020).
|
|
4
|
+
*
|
|
5
|
+
* Kept apart from `daemonBroker.ts` on purpose: every transport consults it on
|
|
6
|
+
* every request (`dpop-auth.ts`), so it must stay a leaf with no I/O at import
|
|
7
|
+
* and no dependency on the broker's own module graph. `daemonBroker` registers
|
|
8
|
+
* the binding after a redeem whose answer said `tokenType: 'DPoP'`, and clears
|
|
9
|
+
* it after one that did not.
|
|
10
|
+
*/
|
|
11
|
+
/** Where to ask for proofs. Re-resolved per call so a rotated secret heals. */
|
|
12
|
+
export interface DpopOracleEndpoint {
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
secret: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function registerDpopBinding(accessToken: string, resolveEndpoint: () => DpopOracleEndpoint): void;
|
|
18
|
+
export declare function clearDpopBinding(): void;
|
|
19
|
+
/** True when `token` is this process's DPoP-bound hand-off token. */
|
|
20
|
+
export declare function isDpopBoundHandoffToken(token: string | null | undefined): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* A fresh RFC 9449 proof for one request made with `token`, from the local
|
|
23
|
+
* daemon that holds the key the token is bound to. Null when `token` is not the
|
|
24
|
+
* bound one (nothing to prove) or the daemon would not sign — the request then
|
|
25
|
+
* goes out without a proof and the server's `DPOP_PROOF_REQUIRED` names the
|
|
26
|
+
* problem, rather than this client inventing a second error for it.
|
|
27
|
+
*
|
|
28
|
+
* The token itself is never sent: the daemon gets its SHA-256 (`ath`) and signs
|
|
29
|
+
* only for hashes of tokens it issued. Proofs are single-use, so this runs per
|
|
30
|
+
* request and per retry.
|
|
31
|
+
*/
|
|
32
|
+
export declare function brokeredDpopProof(token: string | null | undefined, htm: string, htu: string): Promise<string | null>;
|
|
33
|
+
/** @internal test seam. */
|
|
34
|
+
export declare function __resetDpopBindingForTest(): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* brokered-dpop.ts — this process's DPoP-bound hand-off token, and the proof
|
|
4
|
+
* fetch for it (OSK-12020).
|
|
5
|
+
*
|
|
6
|
+
* Kept apart from `daemonBroker.ts` on purpose: every transport consults it on
|
|
7
|
+
* every request (`dpop-auth.ts`), so it must stay a leaf with no I/O at import
|
|
8
|
+
* and no dependency on the broker's own module graph. `daemonBroker` registers
|
|
9
|
+
* the binding after a redeem whose answer said `tokenType: 'DPoP'`, and clears
|
|
10
|
+
* it after one that did not.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.registerDpopBinding = registerDpopBinding;
|
|
14
|
+
exports.clearDpopBinding = clearDpopBinding;
|
|
15
|
+
exports.isDpopBoundHandoffToken = isDpopBoundHandoffToken;
|
|
16
|
+
exports.brokeredDpopProof = brokeredDpopProof;
|
|
17
|
+
exports.__resetDpopBindingForTest = __resetDpopBindingForTest;
|
|
18
|
+
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
const loopback_http_1 = require("@skrr-ai/auth-core/loopback-http");
|
|
20
|
+
const cli_handoff_wire_1 = require("@skrr-ai/auth-core/cli-handoff-wire");
|
|
21
|
+
let binding = null;
|
|
22
|
+
/** Bound the proof fetch: it sits in front of every request a bound token makes. */
|
|
23
|
+
const DPOP_PROOF_TIMEOUT_MS = 5_000;
|
|
24
|
+
function registerDpopBinding(accessToken, resolveEndpoint) {
|
|
25
|
+
binding = { accessToken, resolveEndpoint };
|
|
26
|
+
}
|
|
27
|
+
function clearDpopBinding() {
|
|
28
|
+
binding = null;
|
|
29
|
+
}
|
|
30
|
+
/** True when `token` is this process's DPoP-bound hand-off token. */
|
|
31
|
+
function isDpopBoundHandoffToken(token) {
|
|
32
|
+
return Boolean(token && binding && token === binding.accessToken);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A fresh RFC 9449 proof for one request made with `token`, from the local
|
|
36
|
+
* daemon that holds the key the token is bound to. Null when `token` is not the
|
|
37
|
+
* bound one (nothing to prove) or the daemon would not sign — the request then
|
|
38
|
+
* goes out without a proof and the server's `DPOP_PROOF_REQUIRED` names the
|
|
39
|
+
* problem, rather than this client inventing a second error for it.
|
|
40
|
+
*
|
|
41
|
+
* The token itself is never sent: the daemon gets its SHA-256 (`ath`) and signs
|
|
42
|
+
* only for hashes of tokens it issued. Proofs are single-use, so this runs per
|
|
43
|
+
* request and per retry.
|
|
44
|
+
*/
|
|
45
|
+
async function brokeredDpopProof(token, htm, htu) {
|
|
46
|
+
const state = binding;
|
|
47
|
+
if (!token || !state || token !== state.accessToken)
|
|
48
|
+
return null;
|
|
49
|
+
let endpoint;
|
|
50
|
+
try {
|
|
51
|
+
endpoint = state.resolveEndpoint();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), DPOP_PROOF_TIMEOUT_MS);
|
|
58
|
+
try {
|
|
59
|
+
// The descriptor secret is loopback-only: never global `fetch`, which can
|
|
60
|
+
// route 127.0.0.1 through HTTP_PROXY (OSK-12134).
|
|
61
|
+
const response = await (0, loopback_http_1.loopbackFetch)(`http://${endpoint.host}:${endpoint.port}${cli_handoff_wire_1.CLI_HANDOFF_DPOP_PROOF_PATH}`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${endpoint.secret}`,
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
Accept: 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
htm: htm.toUpperCase(),
|
|
70
|
+
htu,
|
|
71
|
+
ath: (0, node_crypto_1.createHash)('sha256').update(token, 'utf8').digest('base64url'),
|
|
72
|
+
}),
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
return null;
|
|
77
|
+
return (0, cli_handoff_wire_1.parseCliHandoffDpopProofResponse)(await response.json())?.proof ?? null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** @internal test seam. */
|
|
87
|
+
function __resetDpopBindingForTest() {
|
|
88
|
+
binding = null;
|
|
89
|
+
}
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import { type CliHandoffDescriptor, type CliHandoffTokenResponse } from '@skrr-ai/auth-core/cli-handoff-wire';
|
|
40
40
|
import { type AuthBackend } from './auth-storage';
|
|
41
41
|
import { type CliConfig } from './config';
|
|
42
|
+
import { type AwaitRestartedDaemonOptions } from './daemonRestartWait';
|
|
42
43
|
/**
|
|
43
44
|
* Wire-shape of the bootstrap file — the shared declaration in
|
|
44
45
|
* `@skrr-ai/auth-core/cli-handoff-wire` (`CliHandoffDescriptor`), which the
|
|
@@ -173,6 +174,23 @@ export interface DaemonBrokerFailure {
|
|
|
173
174
|
* the repair for a refusal is the machine, never a login.
|
|
174
175
|
*/
|
|
175
176
|
dedicatedGuest?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* For `no_bootstrap`: what the descriptor file on disk actually was, when
|
|
179
|
+
* one existed. `dead_pid` is a daemon that crashed or is mid-restart and left
|
|
180
|
+
* its hand-off behind; `unreadable` / `invalid` a file that could not be
|
|
181
|
+
* used. Absent when no candidate file existed at all (OSK-13222).
|
|
182
|
+
*/
|
|
183
|
+
descriptorState?: 'dead_pid' | 'unreadable' | 'invalid';
|
|
184
|
+
/** The pid the descriptor named (a dead one, for `dead_pid`). */
|
|
185
|
+
descriptorPid?: number;
|
|
186
|
+
/** The loopback port the descriptor named — for `network`, the refused one. */
|
|
187
|
+
descriptorPort?: number;
|
|
188
|
+
/**
|
|
189
|
+
* How long the CLI waited for a restarting daemon before giving up. Set only
|
|
190
|
+
* when it waited: the local service was starting or is supervised, so a
|
|
191
|
+
* restart was the likely state (OSK-13222).
|
|
192
|
+
*/
|
|
193
|
+
restartWaitedMs?: number;
|
|
176
194
|
}
|
|
177
195
|
export type DaemonBrokerOutcome = (DaemonBrokerResult & {
|
|
178
196
|
ok: true;
|
|
@@ -215,7 +233,12 @@ export interface DaemonBrokerOptions {
|
|
|
215
233
|
* - `'access_token'` — broker mode: redeem a short-lived access token
|
|
216
234
|
* and hold it in memory only. Used by `maybeAutoBroker`: nothing
|
|
217
235
|
* credential-shaped lands on disk and each `skrr` process re-redeems
|
|
218
|
-
* over loopback.
|
|
236
|
+
* over loopback. When the descriptor advertises `access_token`, the
|
|
237
|
+
* token route is the ONLY door for this caller: a failed redeem is
|
|
238
|
+
* reported, never retried as a durable mint (OSK-12018). The automatic
|
|
239
|
+
* path must not mint and persist a refresh family the human never asked
|
|
240
|
+
* for — a durable login is the human's to hold, and only an explicit
|
|
241
|
+
* `skrr login` takes it.
|
|
219
242
|
* - `'refresh_family'` (default) — the legacy durable mint. Used by
|
|
220
243
|
* explicit `skrr login`: a human login still writes a real,
|
|
221
244
|
* user-owned refresh family. A descriptor that advertises ONLY
|
|
@@ -223,6 +246,12 @@ export interface DaemonBrokerOptions {
|
|
|
223
246
|
* regardless — its secret cannot mint a family at all.
|
|
224
247
|
*/
|
|
225
248
|
preferHandoffMode?: 'access_token' | 'refresh_family';
|
|
249
|
+
/**
|
|
250
|
+
* Overrides for the bounded wait on a restarting local daemon (OSK-13222):
|
|
251
|
+
* budgets, the clock and the supervisor probe. Tests use it; production
|
|
252
|
+
* leaves it unset.
|
|
253
|
+
*/
|
|
254
|
+
restartWait?: Omit<AwaitRestartedDaemonOptions, 'profile' | 'ready'>;
|
|
226
255
|
}
|
|
227
256
|
/**
|
|
228
257
|
* Compute the bootstrap file path. Exported for tests so they can assert
|
|
@@ -257,6 +286,17 @@ export declare function resolveBootstrapCandidatePaths(profile?: string): string
|
|
|
257
286
|
* user configures can point the broker somewhere else.
|
|
258
287
|
*/
|
|
259
288
|
export declare const DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = "/run/skrr-dedicated-runtime/cli-handoff/descriptor.json";
|
|
289
|
+
/**
|
|
290
|
+
* A Hosted Machine's workload hand-off descriptor, published only on a box
|
|
291
|
+
* booted under the identity split (OSK-11981, OSK-12019), where the daemon runs
|
|
292
|
+
* as `oversky-daemon` and its island file is unreadable to the workload. Same
|
|
293
|
+
* format and same broker-only contract as the Dedicated descriptor
|
|
294
|
+
* (`daemon/src/dedicated-cli-handoff.ts`, `workloadCliHandoffTarget`), in a
|
|
295
|
+
* DIFFERENT directory so a Hosted Machine is never mistaken for a Dedicated
|
|
296
|
+
* Runtime guest (`runningOnDedicatedRuntimeGuest` keys on that directory). The
|
|
297
|
+
* parent is created by the box's launcher as root and owned by the daemon.
|
|
298
|
+
*/
|
|
299
|
+
export declare const HOSTED_MACHINE_CLI_HANDOFF_DESCRIPTOR = "/run/skrr-hosted-machine/cli-handoff/descriptor.json";
|
|
260
300
|
/** Where the cli-handoff broker looks, in order. */
|
|
261
301
|
export declare function resolveCliHandoffCandidatePaths(profile?: string): string[];
|
|
262
302
|
/**
|
|
@@ -283,6 +323,32 @@ export declare function findLiveDaemonBootstrap({ profile, bootstrapPathOverride
|
|
|
283
323
|
* file is malformed, the CLI shouldn't try to repair it).
|
|
284
324
|
*/
|
|
285
325
|
export declare function readBootstrap(filePath: string): LocalBootstrap | null;
|
|
326
|
+
/**
|
|
327
|
+
* Why a descriptor file did or did not yield a usable bootstrap (OSK-13222).
|
|
328
|
+
*
|
|
329
|
+
* `readBootstrap` answers null for all of these alike, which is right for a
|
|
330
|
+
* caller that only wants "is there a daemon to ask", and wrong for the one
|
|
331
|
+
* that has to explain a failure: a descriptor naming a pid that has exited is
|
|
332
|
+
* a daemon that crashed or is restarting, not a person who is signed out, and
|
|
333
|
+
* the two have opposite repairs.
|
|
334
|
+
*/
|
|
335
|
+
export type BootstrapInspection = {
|
|
336
|
+
state: 'ok';
|
|
337
|
+
bootstrap: LocalBootstrap;
|
|
338
|
+
} | {
|
|
339
|
+
state: 'absent';
|
|
340
|
+
} | {
|
|
341
|
+
state: 'unreadable';
|
|
342
|
+
detail: string;
|
|
343
|
+
} | {
|
|
344
|
+
state: 'invalid';
|
|
345
|
+
detail: string;
|
|
346
|
+
} | {
|
|
347
|
+
state: 'dead_pid';
|
|
348
|
+
pid: number;
|
|
349
|
+
bootstrap: LocalBootstrap;
|
|
350
|
+
};
|
|
351
|
+
export declare function inspectBootstrap(filePath: string): BootstrapInspection;
|
|
286
352
|
/**
|
|
287
353
|
* Try the daemon-as-broker handoff. Returns a tagged outcome so callers
|
|
288
354
|
* can decide between fall-through and surface-to-user without parsing
|
|
@@ -431,7 +497,10 @@ export interface MaybeAutoBrokerResult {
|
|
|
431
497
|
* 'access_token'`. On a descriptor that advertises broker mode the
|
|
432
498
|
* outcome is `brokered` — an in-memory access token, nothing written;
|
|
433
499
|
* the caller holds it for this process and the next `skrr` re-redeems.
|
|
434
|
-
*
|
|
500
|
+
* A failed redeem on such a descriptor is returned as the outcome and
|
|
501
|
+
* never retried as a durable mint (OSK-12018). Only on a legacy/v1
|
|
502
|
+
* descriptor (no `access_token` advertised — the compatibility window
|
|
503
|
+
* documented in `brokerThrough`) is the durable mint persisted to the
|
|
435
504
|
* keychain / cli-auth.json exactly as before.
|
|
436
505
|
* - May generate a new `cliId` if the config doesn't have one. The
|
|
437
506
|
* caller MUST persist `updatedConfig` so the next invocation
|