@skrr-ai/cli 0.1.57 → 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/agents/show.js +13 -2
- package/dist/commands/harnesses/ping.d.ts +2 -0
- package/dist/commands/harnesses/ping.js +10 -1
- package/dist/commands/login.js +6 -1
- package/dist/commands/logout.d.ts +0 -46
- package/dist/commands/logout.js +28 -2
- package/dist/commands/pair.js +1 -1
- package/dist/lib/agentic-stream.d.ts +54 -0
- package/dist/lib/agentic-stream.js +153 -15
- package/dist/lib/api-fetch.d.ts +11 -0
- package/dist/lib/api-fetch.js +98 -1
- package/dist/lib/auth-storage.d.ts +27 -0
- package/dist/lib/auth-storage.js +42 -0
- package/dist/lib/commitments.js +1 -0
- package/dist/lib/daemonBroker.d.ts +5 -1
- package/dist/lib/daemonBroker.js +13 -2
- package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
- package/dist/lib/daemonBrokerRefusal.js +87 -2
- package/dist/lib/daemonHandoff.d.ts +1 -1
- package/dist/lib/daemonHandoff.js +18 -0
- 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 +12 -3
- package/dist/lib/oauthLogin.js +3 -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 +4388 -4349
- package/oclif.manifest.json +26425 -26425
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
|
|
3
|
+
exports.AgenticStreamClient = exports.USER_CONNECTION_CAP_CODE = exports.TYPE_AHEAD_DISCARD_MS = exports.NON_INTERACTIVE_QUESTION_DISMISSAL = void 0;
|
|
4
4
|
exports.writeJsonLine = writeJsonLine;
|
|
5
|
+
exports.discardTypeAhead = discardTypeAhead;
|
|
5
6
|
exports.describePermissionTarget = describePermissionTarget;
|
|
6
7
|
exports.envelopesText = envelopesText;
|
|
7
8
|
exports.envelopeKey = envelopeKey;
|
|
@@ -54,6 +55,59 @@ exports.NON_INTERACTIVE_QUESTION_DISMISSAL = 'No answer: this turn was started n
|
|
|
54
55
|
function writeJsonLine(output, value) {
|
|
55
56
|
output.write(`${JSON.stringify(value) ?? 'null'}\n`);
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* How long input arriving just before a prompt is shown is thrown away.
|
|
60
|
+
*
|
|
61
|
+
* Long enough for bytes already typed into the terminal to be read out of the
|
|
62
|
+
* kernel once stdin starts flowing (one event-loop turn in practice), short
|
|
63
|
+
* enough that nobody waits for it.
|
|
64
|
+
*/
|
|
65
|
+
exports.TYPE_AHEAD_DISCARD_MS = 60;
|
|
66
|
+
/**
|
|
67
|
+
* Throw away whatever was typed BEFORE a prompt is shown (OSK-12468).
|
|
68
|
+
*
|
|
69
|
+
* A key pressed while the turn was still streaming sits in the stream's buffer
|
|
70
|
+
* or the terminal's, and the first `readline.question` read it as the answer:
|
|
71
|
+
* an owner typed `y` before any `[y/N]` was visible and approved a Bash command
|
|
72
|
+
* they had never seen. An approval is only consent to what was on the screen,
|
|
73
|
+
* so every prompt now starts from an empty input: bytes the stream already
|
|
74
|
+
* holds are read and dropped, then stdin flows into a discarding listener for
|
|
75
|
+
* `windowMs` so the terminal's own buffer is drained too. Raw mode is switched
|
|
76
|
+
* on for that window because a half-typed line (`y`, no Enter) is invisible to
|
|
77
|
+
* a read in canonical mode and would otherwise surface as soon as readline
|
|
78
|
+
* enters raw mode itself. A Ctrl-C in the discarded input is still honoured.
|
|
79
|
+
*/
|
|
80
|
+
async function discardTypeAhead(input, windowMs = exports.TYPE_AHEAD_DISCARD_MS) {
|
|
81
|
+
const tty = input;
|
|
82
|
+
const canSetRaw = Boolean(tty.isTTY) && typeof tty.setRawMode === 'function';
|
|
83
|
+
const wasRaw = canSetRaw ? Boolean(tty.isRaw) : false;
|
|
84
|
+
let sawInterrupt = false;
|
|
85
|
+
const drop = (chunk) => {
|
|
86
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : '';
|
|
87
|
+
if (text.includes('\u0003'))
|
|
88
|
+
sawInterrupt = true;
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
if (canSetRaw && !wasRaw)
|
|
92
|
+
tty.setRawMode(true);
|
|
93
|
+
const readable = input;
|
|
94
|
+
if (typeof readable.read === 'function') {
|
|
95
|
+
for (let chunk = readable.read(); chunk !== null; chunk = readable.read())
|
|
96
|
+
drop(chunk);
|
|
97
|
+
}
|
|
98
|
+
input.on('data', drop);
|
|
99
|
+
input.resume();
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, windowMs)));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
input.removeListener('data', drop);
|
|
104
|
+
input.pause();
|
|
105
|
+
if (canSetRaw && !wasRaw)
|
|
106
|
+
tty.setRawMode(false);
|
|
107
|
+
}
|
|
108
|
+
if (sawInterrupt)
|
|
109
|
+
process.kill(process.pid, 'SIGINT');
|
|
110
|
+
}
|
|
57
111
|
const PERMISSION_DETAIL_MAX = 400;
|
|
58
112
|
/**
|
|
59
113
|
* What a permission request would DO, in one line a person can judge.
|
|
@@ -266,6 +320,13 @@ function describeToolEnd(toolName, status) {
|
|
|
266
320
|
return `[tool failed] ${name}`;
|
|
267
321
|
case 'cancelled':
|
|
268
322
|
return `[tool cancelled] ${name}`;
|
|
323
|
+
// OSK-12469 — a call whose approval was refused never ran. It printed
|
|
324
|
+
// `[tool completed]` just before the line saying it was denied; a call
|
|
325
|
+
// whose ask expired, or whose turn a deploy paused, did the same.
|
|
326
|
+
case 'denied':
|
|
327
|
+
return `[tool denied] ${name} (it did not run)`;
|
|
328
|
+
case 'not_run':
|
|
329
|
+
return `[tool not run] ${name}`;
|
|
269
330
|
case 'unreported':
|
|
270
331
|
return `[tool result not reported] ${name} (the harness never said how it ended; closed when the session ended)`;
|
|
271
332
|
default:
|
|
@@ -563,6 +624,12 @@ class AgenticStreamClient {
|
|
|
563
624
|
* (OSK-12197) instead of leaving `[y/N]` open after the run stopped asking.
|
|
564
625
|
*/
|
|
565
626
|
openPermissionPrompts = new Map();
|
|
627
|
+
/**
|
|
628
|
+
* Question prompts on the terminal still waiting for an answer, by
|
|
629
|
+
* questionId, so a server `question:cancel` can close the one it names
|
|
630
|
+
* (OSK-12208) the same way `permission:cancel` closes a permission prompt.
|
|
631
|
+
*/
|
|
632
|
+
openQuestionPrompts = new Map();
|
|
566
633
|
/** Text already written to the terminal, for suffix-only rendering. */
|
|
567
634
|
renderedText = '';
|
|
568
635
|
idleTimer = null;
|
|
@@ -961,6 +1028,11 @@ class AgenticStreamClient {
|
|
|
961
1028
|
if (match(event))
|
|
962
1029
|
void this.handleQuestion(event);
|
|
963
1030
|
});
|
|
1031
|
+
socket.on('question:cancel', (event) => {
|
|
1032
|
+
if (!match(event))
|
|
1033
|
+
return;
|
|
1034
|
+
this.handleQuestionCancel(event);
|
|
1035
|
+
});
|
|
964
1036
|
socket.on('error:unauthorized', (event) => {
|
|
965
1037
|
if (match(event))
|
|
966
1038
|
this.finish('failed', event);
|
|
@@ -1063,9 +1135,20 @@ class AgenticStreamClient {
|
|
|
1063
1135
|
const interactive = this.options.input !== undefined || Boolean(process.stdin.isTTY);
|
|
1064
1136
|
const answers = {};
|
|
1065
1137
|
if (interactive) {
|
|
1138
|
+
const prompt = new AbortController();
|
|
1139
|
+
if (event.questionId)
|
|
1140
|
+
this.openQuestionPrompts.set(event.questionId, prompt);
|
|
1066
1141
|
for (const question of event.questions ?? []) {
|
|
1067
|
-
|
|
1142
|
+
if (prompt.signal.aborted)
|
|
1143
|
+
break;
|
|
1144
|
+
answers[question.header ?? question.question] = await this.ask(`${question.header ?? 'Question'}: ${question.question}\n> `, prompt.signal);
|
|
1068
1145
|
}
|
|
1146
|
+
if (event.questionId)
|
|
1147
|
+
this.openQuestionPrompts.delete(event.questionId);
|
|
1148
|
+
// Closed by the server (it stopped waiting, or the turn ended): an answer
|
|
1149
|
+
// sent now would be read by nobody, and would look like a person's.
|
|
1150
|
+
if (prompt.signal.aborted)
|
|
1151
|
+
return;
|
|
1069
1152
|
}
|
|
1070
1153
|
else {
|
|
1071
1154
|
answers._dismissed = exports.NON_INTERACTIVE_QUESTION_DISMISSAL;
|
|
@@ -1110,26 +1193,73 @@ class AgenticStreamClient {
|
|
|
1110
1193
|
: {}),
|
|
1111
1194
|
});
|
|
1112
1195
|
}
|
|
1196
|
+
/**
|
|
1197
|
+
* The server closed a question (OSK-12208): nobody answered it in time, or
|
|
1198
|
+
* the turn ended first. Say so, and close the terminal prompt if this
|
|
1199
|
+
* client is still showing it — the question twin of handlePermissionCancel.
|
|
1200
|
+
*/
|
|
1201
|
+
handleQuestionCancel(event) {
|
|
1202
|
+
const questionId = typeof event.questionId === 'string' ? event.questionId : undefined;
|
|
1203
|
+
if (!this.firstTimeSeen('question-cancel', questionId))
|
|
1204
|
+
return;
|
|
1205
|
+
const open = questionId ? this.openQuestionPrompts.get(questionId) : undefined;
|
|
1206
|
+
if (questionId)
|
|
1207
|
+
this.openQuestionPrompts.delete(questionId);
|
|
1208
|
+
open?.abort();
|
|
1209
|
+
this.emit({
|
|
1210
|
+
...event,
|
|
1211
|
+
type: 'question.cancelled',
|
|
1212
|
+
promptClosed: Boolean(open),
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
/** Prompts run one at a time: two readlines on one stdin would both read a line. */
|
|
1216
|
+
askQueue = Promise.resolve();
|
|
1113
1217
|
ask(prompt, signal) {
|
|
1218
|
+
const next = this.askQueue.then(() => this.askNow(prompt, signal));
|
|
1219
|
+
this.askQueue = next.catch(() => undefined);
|
|
1220
|
+
return next;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* One prompt, on a readline that exists only while the prompt is on screen.
|
|
1224
|
+
*
|
|
1225
|
+
* The interface used to be created at the first prompt and kept until the
|
|
1226
|
+
* run closed, and it was created AFTER stdin had been accumulating whatever
|
|
1227
|
+
* the person typed while the turn streamed — so the first question answered
|
|
1228
|
+
* itself from that type-ahead (OSK-12468). Now the input is emptied first
|
|
1229
|
+
* ({@link discardTypeAhead}), only then is the question shown, and the
|
|
1230
|
+
* interface is closed with the answer, so nothing typed between two prompts
|
|
1231
|
+
* can answer the second one either.
|
|
1232
|
+
*/
|
|
1233
|
+
async askNow(prompt, signal) {
|
|
1114
1234
|
// A prompt raised after close has nobody to answer it, and re-creating the
|
|
1115
1235
|
// interface here would resurrect stdin for a run that is already over.
|
|
1116
1236
|
if (this.closed || signal?.aborted)
|
|
1117
|
-
return
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1237
|
+
return '';
|
|
1238
|
+
const input = this.options.input ?? process.stdin;
|
|
1239
|
+
await discardTypeAhead(input, this.options.typeAheadDiscardMs ?? exports.TYPE_AHEAD_DISCARD_MS);
|
|
1240
|
+
if (this.closed || signal?.aborted)
|
|
1241
|
+
return '';
|
|
1242
|
+
const rl = (0, node_readline_1.createInterface)({ input, output: process.stderr });
|
|
1243
|
+
this.readline = rl;
|
|
1244
|
+
try {
|
|
1245
|
+
return await new Promise((resolve) => {
|
|
1246
|
+
// Closed under us (the run ended): nobody is left to answer.
|
|
1247
|
+
rl.once('close', () => resolve(''));
|
|
1248
|
+
if (!signal) {
|
|
1249
|
+
rl.question(prompt, resolve);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
// readline does not call back for a question aborted by its signal, so
|
|
1253
|
+
// the abort settles the prompt itself.
|
|
1254
|
+
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1255
|
+
rl.question(prompt, { signal }, resolve);
|
|
1122
1256
|
});
|
|
1123
1257
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1258
|
+
finally {
|
|
1259
|
+
if (this.readline === rl)
|
|
1260
|
+
this.readline = null;
|
|
1261
|
+
rl.close();
|
|
1126
1262
|
}
|
|
1127
|
-
return new Promise((resolve) => {
|
|
1128
|
-
// readline does not call back for a question aborted by its signal, so
|
|
1129
|
-
// the abort settles the prompt itself.
|
|
1130
|
-
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1131
|
-
this.readline.question(prompt, { signal }, resolve);
|
|
1132
|
-
});
|
|
1133
1263
|
}
|
|
1134
1264
|
/**
|
|
1135
1265
|
* True the first time this (kind, identity) pair is seen; false afterwards.
|
|
@@ -1243,6 +1373,14 @@ class AgenticStreamClient {
|
|
|
1243
1373
|
output.write(`\n[${event.type}]\n`);
|
|
1244
1374
|
this.renderedText = this.accumulated;
|
|
1245
1375
|
}
|
|
1376
|
+
else if (event.type === 'question.cancelled') {
|
|
1377
|
+
// A question the run stopped waiting for (OSK-12208).
|
|
1378
|
+
const why = typeof event.message === 'string' && event.message
|
|
1379
|
+
? event.message
|
|
1380
|
+
: `closed (${String(event.reason ?? 'cancelled')})`;
|
|
1381
|
+
output.write(`\n[question] ${why}\n`);
|
|
1382
|
+
this.renderedText = this.accumulated;
|
|
1383
|
+
}
|
|
1246
1384
|
else if (event.type === 'permission.approved' && event.auto === true) {
|
|
1247
1385
|
// Said out loud, because nobody was asked: the flag approved it, and a
|
|
1248
1386
|
// reader of this log should see which tools that covered (OSK-10323).
|
package/dist/lib/api-fetch.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ResolvedCredential } from './credential-resolver';
|
|
2
|
+
import { type DaemonBrokerFailure } from './daemonBroker';
|
|
2
3
|
export interface ApiFetchOptions {
|
|
3
4
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
4
5
|
body?: unknown;
|
|
@@ -36,6 +37,14 @@ export declare class ApiFetchError extends Error {
|
|
|
36
37
|
* attributed by guesswork — see `node-adapter.ts`.
|
|
37
38
|
*/
|
|
38
39
|
readonly requestId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Set on a 401 when this CLI's own refresh was refused AND the local
|
|
42
|
+
* daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
|
|
43
|
+
* decides the repair, so the 401 message carries it rather than a bare
|
|
44
|
+
* "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
|
|
45
|
+
* it into words. Absent when the daemon was never asked.
|
|
46
|
+
*/
|
|
47
|
+
brokerFailure?: DaemonBrokerFailure;
|
|
39
48
|
constructor(message: string, status: number, body?: string, code?: string, method?: string, requestId?: string);
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -44,6 +53,8 @@ export declare class ApiFetchError extends Error {
|
|
|
44
53
|
* `err.status` / `err.code`.
|
|
45
54
|
*/
|
|
46
55
|
export declare function apiFetch<T>(pathOrUrl: string, opts?: ApiFetchOptions): Promise<T>;
|
|
56
|
+
/** @internal test seam — forget this process's daemon hand-off attempt. */
|
|
57
|
+
export declare function __resetDaemonHandoffFallbackForTest(): void;
|
|
47
58
|
/**
|
|
48
59
|
* True when the current process has a persisted refresh-capable session
|
|
49
60
|
* (`skrr login` has been run and survived). Used by CI-token commands
|
package/dist/lib/api-fetch.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ApiFetchError = void 0;
|
|
4
4
|
exports.apiFetch = apiFetch;
|
|
5
|
+
exports.__resetDaemonHandoffFallbackForTest = __resetDaemonHandoffFallbackForTest;
|
|
5
6
|
exports.hasHumanSession = hasHumanSession;
|
|
6
7
|
/**
|
|
7
8
|
* api-fetch.ts — authenticated fetch helper for endpoints not yet in
|
|
@@ -61,6 +62,14 @@ class ApiFetchError extends Error {
|
|
|
61
62
|
* attributed by guesswork — see `node-adapter.ts`.
|
|
62
63
|
*/
|
|
63
64
|
requestId;
|
|
65
|
+
/**
|
|
66
|
+
* Set on a 401 when this CLI's own refresh was refused AND the local
|
|
67
|
+
* daemon, asked for a hand-off, refused too (OSK-12474). The daemon's reason
|
|
68
|
+
* decides the repair, so the 401 message carries it rather than a bare
|
|
69
|
+
* "Run `skrr login`" (OSK-12149, OSK-12151); `describeBrokerRefusal` turns
|
|
70
|
+
* it into words. Absent when the daemon was never asked.
|
|
71
|
+
*/
|
|
72
|
+
brokerFailure;
|
|
64
73
|
constructor(message, status, body, code, method, requestId) {
|
|
65
74
|
super(message);
|
|
66
75
|
this.name = 'ApiFetchError';
|
|
@@ -181,6 +190,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
181
190
|
});
|
|
182
191
|
};
|
|
183
192
|
let res = await fetchWithCredential(cred);
|
|
193
|
+
let brokerFailure = null;
|
|
184
194
|
if (res.status === 401 && delegatedCli) {
|
|
185
195
|
// Deliberately terminal on broker failure: an Agent whose daemon cannot
|
|
186
196
|
// rotate its family must surface the 401, never fall through to
|
|
@@ -217,6 +227,25 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
217
227
|
(0, node_adapter_1.setAdapterCredentialOverride)(cred);
|
|
218
228
|
res = await fetchWithCredential(cred);
|
|
219
229
|
}
|
|
230
|
+
else if (fresh.skippedReason === 'refresh_failed' ||
|
|
231
|
+
fresh.skippedReason === 'no_refresh_token') {
|
|
232
|
+
// OSK-12474 — this CLI's STORED sign-in could not be renewed (e.g.
|
|
233
|
+
// DEVICE_KEY_MISMATCH after a key split). The node-adapter asks the local
|
|
234
|
+
// daemon for a hand-off at this point; this transport did not, so a
|
|
235
|
+
// healthy daemon was ignored and every apiFetch command demanded
|
|
236
|
+
// `skrr login`. Gated on the stored-credential skip reasons only: a
|
|
237
|
+
// `--token`, OVERSKY_TOKEN, CI token or bare-mode credential is the
|
|
238
|
+
// operator's, and must never be swapped for the daemon's principal.
|
|
239
|
+
const brokered = await daemonHandoffAfterRefusedRefresh();
|
|
240
|
+
if (brokered.credential) {
|
|
241
|
+
cred = brokered.credential;
|
|
242
|
+
(0, node_adapter_1.setAdapterCredentialOverride)(cred);
|
|
243
|
+
res = await fetchWithCredential(cred);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
brokerFailure = brokered.failure;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
220
249
|
}
|
|
221
250
|
if (!res.ok) {
|
|
222
251
|
let text = '';
|
|
@@ -235,7 +264,10 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
235
264
|
catch {
|
|
236
265
|
/* ignore body read failures */
|
|
237
266
|
}
|
|
238
|
-
|
|
267
|
+
const error = new ApiFetchError(`HTTP ${res.status} ${res.statusText} — ${method} ${pathOrUrl}`, res.status, text, code, method, res.headers.get('x-request-id') || undefined);
|
|
268
|
+
if (brokerFailure)
|
|
269
|
+
error.brokerFailure = brokerFailure;
|
|
270
|
+
throw error;
|
|
239
271
|
}
|
|
240
272
|
// 204/205 carry no body by definition; an empty body has no content-type
|
|
241
273
|
// worth judging. Neither is an error.
|
|
@@ -255,6 +287,71 @@ async function apiFetch(pathOrUrl, opts = {}) {
|
|
|
255
287
|
}
|
|
256
288
|
return (await res.json());
|
|
257
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* One daemon hand-off attempt per process (OSK-12474). Memoized, including a
|
|
292
|
+
* failure: a command that makes many requests must not ask the daemon again
|
|
293
|
+
* on every 401, and a hand-off token that is itself refused is renewed by the
|
|
294
|
+
* brokered re-redeem branch above, never by coming back here — so this cannot
|
|
295
|
+
* loop.
|
|
296
|
+
*/
|
|
297
|
+
let daemonHandoffFallback = null;
|
|
298
|
+
function daemonHandoffAfterRefusedRefresh() {
|
|
299
|
+
daemonHandoffFallback ??= attemptDaemonHandoffFallback();
|
|
300
|
+
return daemonHandoffFallback;
|
|
301
|
+
}
|
|
302
|
+
async function attemptDaemonHandoffFallback() {
|
|
303
|
+
const cliConfig = (0, config_1.loadConfig)();
|
|
304
|
+
let result;
|
|
305
|
+
try {
|
|
306
|
+
// `maybeAutoBroker` applies the same opt-outs as the node-adapter's
|
|
307
|
+
// recovery: OVERSKY_TOKEN/OVERSKY_REFRESH_TOKEN and
|
|
308
|
+
// OVERSKY_SKIP_DAEMON_BROKER=1 leave it untriggered.
|
|
309
|
+
result = await (0, daemonBroker_1.maybeAutoBroker)({ cliConfig, bareMode: false });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return { credential: null, failure: null };
|
|
313
|
+
}
|
|
314
|
+
const outcome = result.outcome;
|
|
315
|
+
if (!result.triggered || !outcome)
|
|
316
|
+
return { credential: null, failure: null };
|
|
317
|
+
if (!outcome.ok)
|
|
318
|
+
return { credential: null, failure: outcome };
|
|
319
|
+
if (result.updatedConfig) {
|
|
320
|
+
try {
|
|
321
|
+
(0, config_1.saveConfig)(result.updatedConfig);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
/* non-fatal: the next process mints a new cliId (see BaseCommand.init) */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const accessToken = outcome.accessToken;
|
|
328
|
+
// Where a durable mint was written; a brokered hand-off is never stored.
|
|
329
|
+
const storedSource = !outcome.brokered && outcome.storedIn === 'keychain' ? 'keychain' : 'file';
|
|
330
|
+
// Say where the credential now comes from, once. The adapter slot records a
|
|
331
|
+
// brokered token as `env-token` (for refresh semantics), which a reader would
|
|
332
|
+
// take for OVERSKY_TOKEN; the label must name the descriptor (OSK-12185).
|
|
333
|
+
const label = outcome.brokered
|
|
334
|
+
? (0, credential_resolver_1.describeCredentialSource)('env-token', {
|
|
335
|
+
kind: 'daemon-handoff',
|
|
336
|
+
...(outcome.descriptorPath ? { descriptorPath: outcome.descriptorPath } : {}),
|
|
337
|
+
})
|
|
338
|
+
: `a new sign-in brokered by the local daemon, saved to ${(0, credential_resolver_1.describeCredentialSource)(storedSource)}`;
|
|
339
|
+
process.stderr.write(`[skrr] This CLI's own sign-in could not be renewed; using ${label} instead.\n`);
|
|
340
|
+
return {
|
|
341
|
+
credential: {
|
|
342
|
+
token: accessToken,
|
|
343
|
+
// A brokered hand-off is held in memory for this process only; the
|
|
344
|
+
// durable mint was written to the backend the broker reported.
|
|
345
|
+
source: outcome.brokered ? 'env-token' : storedSource,
|
|
346
|
+
kind: (0, credential_resolver_1.kindOf)(accessToken),
|
|
347
|
+
},
|
|
348
|
+
failure: null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** @internal test seam — forget this process's daemon hand-off attempt. */
|
|
352
|
+
function __resetDaemonHandoffFallbackForTest() {
|
|
353
|
+
daemonHandoffFallback = null;
|
|
354
|
+
}
|
|
258
355
|
function trustedAutonomousBaseUrl() {
|
|
259
356
|
// Only the daemon-owned alias is accepted. OVERSKY_BASE_URL is a normal
|
|
260
357
|
// user/CLI override and may originate in request-carried provider env.
|
|
@@ -11,7 +11,26 @@ export interface AuthBundle {
|
|
|
11
11
|
* current config; callers never need to set it.
|
|
12
12
|
*/
|
|
13
13
|
owner?: CredentialOwner;
|
|
14
|
+
/**
|
|
15
|
+
* Who created the refresh family this bundle carries (OSK-12293). Recorded
|
|
16
|
+
* in the file in plaintext, like `owner`, so it can be read without the key.
|
|
17
|
+
* Absent on everything an older CLI wrote.
|
|
18
|
+
*/
|
|
19
|
+
provenance?: CredentialProvenance;
|
|
14
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* How a stored refresh family came to exist.
|
|
23
|
+
*
|
|
24
|
+
* - `user_login`: a person signed in on purpose (`skrr login` in any of its
|
|
25
|
+
* flows, `skrr pair`). On a Dedicated guest this is the one stored family
|
|
26
|
+
* the broker-mode retirement must never touch: the person asked for it.
|
|
27
|
+
* - `daemon_broker`: the local daemon minted it through its hand-off.
|
|
28
|
+
*
|
|
29
|
+
* An absent value means an older CLI wrote the file and the family's origin
|
|
30
|
+
* is unknown, which on a brokered guest is the pre-broker leftover the
|
|
31
|
+
* retirement exists for (OSK-12194).
|
|
32
|
+
*/
|
|
33
|
+
export type CredentialProvenance = 'user_login' | 'daemon_broker';
|
|
15
34
|
/** Which skrr config a credential belongs to. */
|
|
16
35
|
export interface CredentialOwner {
|
|
17
36
|
/** Resolved config root (`~/.skrr`, or the `SKRR_CONFIG_DIR` override). */
|
|
@@ -39,6 +58,13 @@ export interface WriteOptions {
|
|
|
39
58
|
serverOrigin?: string;
|
|
40
59
|
/** Clear a stale needs-reauth latch after a server has issued fresh credentials. */
|
|
41
60
|
clearReauth?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Who created the family being written. A write that names none is a
|
|
63
|
+
* ROTATION of the stored family (a refresh), and keeps the provenance the
|
|
64
|
+
* stored file records for the same server — so a refresh never erases the
|
|
65
|
+
* marker a login wrote, and never invents one an older CLI did not.
|
|
66
|
+
*/
|
|
67
|
+
provenance?: CredentialProvenance;
|
|
42
68
|
}
|
|
43
69
|
/**
|
|
44
70
|
* File-backend-only read. Exported for the brokered-hand-off migration
|
|
@@ -62,6 +88,7 @@ export declare function readFromFile(): AuthBundle | null;
|
|
|
62
88
|
export declare function readRefreshFamilyFromFile(): {
|
|
63
89
|
refreshToken: string;
|
|
64
90
|
serverOrigin?: string;
|
|
91
|
+
provenance?: CredentialProvenance;
|
|
65
92
|
} | null;
|
|
66
93
|
/**
|
|
67
94
|
* Delete `~/.skrr/cli-auth.json` only (the keychain is untouched). Exported
|
package/dist/lib/auth-storage.js
CHANGED
|
@@ -106,6 +106,12 @@ const cli_id_1 = require("./cli-id");
|
|
|
106
106
|
* `auth.json`, deliberately distinct so the two binaries never clobber
|
|
107
107
|
* each other's tokens (CLI holds scope=cli, daemon holds scope=daemon). */
|
|
108
108
|
const AUTH_FILENAME = 'cli-auth.json';
|
|
109
|
+
const CREDENTIAL_PROVENANCES = ['user_login', 'daemon_broker'];
|
|
110
|
+
function parseProvenance(raw) {
|
|
111
|
+
return CREDENTIAL_PROVENANCES.includes(raw)
|
|
112
|
+
? raw
|
|
113
|
+
: undefined;
|
|
114
|
+
}
|
|
109
115
|
/**
|
|
110
116
|
* The config root — the same one `cli-config.json` (and so `cliId`) lives in.
|
|
111
117
|
*
|
|
@@ -253,8 +259,29 @@ function readRefreshFamilyFromFile() {
|
|
|
253
259
|
return {
|
|
254
260
|
refreshToken: bundle.refreshToken,
|
|
255
261
|
...(bundle.serverOrigin ? { serverOrigin: bundle.serverOrigin } : {}),
|
|
262
|
+
...(bundle.provenance ? { provenance: bundle.provenance } : {}),
|
|
256
263
|
};
|
|
257
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* The provenance this root's `cli-auth.json` records for `serverOrigin`, read
|
|
267
|
+
* from the plaintext field alone — no decrypt, no ownership rewrite. Used by a
|
|
268
|
+
* rotation to carry the marker forward.
|
|
269
|
+
*/
|
|
270
|
+
function storedFileProvenance(serverOrigin) {
|
|
271
|
+
let parsed;
|
|
272
|
+
try {
|
|
273
|
+
parsed = JSON.parse(fs.readFileSync(authFilePath(), 'utf-8'));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const provenance = parseProvenance(parsed?.provenance);
|
|
279
|
+
if (!provenance)
|
|
280
|
+
return undefined;
|
|
281
|
+
const stored = typeof parsed.serverOrigin === 'string' ? normalizeServerOrigin(parsed.serverOrigin) : null;
|
|
282
|
+
const next = serverOrigin ? normalizeServerOrigin(serverOrigin) : null;
|
|
283
|
+
return stored && next && stored === next ? provenance : undefined;
|
|
284
|
+
}
|
|
258
285
|
/**
|
|
259
286
|
* This root's file, refused when it belongs to another config; else, under a
|
|
260
287
|
* root override, the pre-override `$HOME/.skrr` file when it is positively ours.
|
|
@@ -349,6 +376,9 @@ function readFileAt(p, upgradeInPlace, opts = {}) {
|
|
|
349
376
|
}
|
|
350
377
|
if (owner)
|
|
351
378
|
bundle.owner = owner;
|
|
379
|
+
const provenance = parseProvenance(parsed.provenance);
|
|
380
|
+
if (provenance)
|
|
381
|
+
bundle.provenance = provenance;
|
|
352
382
|
// Best-effort upgrade: re-write the bundle so legacy plaintext fields
|
|
353
383
|
// become wrapped on the next read. A failure here doesn't break the
|
|
354
384
|
// read path — the caller already has the plaintext bundle.
|
|
@@ -413,6 +443,8 @@ function writeToFile(bundle) {
|
|
|
413
443
|
// Rewrites of an existing bundle (the envelope upgrade) keep its recorded
|
|
414
444
|
// owner; everything else is stamped with this config.
|
|
415
445
|
wireBundle.owner = bundle.owner ?? ownerFor(bundle);
|
|
446
|
+
if (bundle.provenance)
|
|
447
|
+
wireBundle.provenance = bundle.provenance;
|
|
416
448
|
// tmp + rename so no racing reader ever sees a partial file; chmod 0600
|
|
417
449
|
// on the final file so the same-UID blast-radius is as narrow as the
|
|
418
450
|
// file backend can offer.
|
|
@@ -772,6 +804,16 @@ function writeToBackend(bundle, options = {}) {
|
|
|
772
804
|
// Always stamp THIS config as the owner — a bundle read back and re-written
|
|
773
805
|
// (e.g. `pair`) must not carry a record forward from wherever it came from.
|
|
774
806
|
persistedBundle = { ...persistedBundle, owner: ownerFor(persistedBundle) };
|
|
807
|
+
// Provenance: an explicit one wins (a login or a broker mint creates a new
|
|
808
|
+
// family); otherwise this is a rotation of the stored family and keeps what
|
|
809
|
+
// the file recorded for the same server (OSK-12293).
|
|
810
|
+
const provenance = options.provenance ??
|
|
811
|
+
persistedBundle.provenance ??
|
|
812
|
+
storedFileProvenance(persistedBundle.serverOrigin);
|
|
813
|
+
if (provenance)
|
|
814
|
+
persistedBundle = { ...persistedBundle, provenance };
|
|
815
|
+
else
|
|
816
|
+
delete persistedBundle.provenance;
|
|
775
817
|
let backend;
|
|
776
818
|
if (keychain.isAvailable()) {
|
|
777
819
|
if (writeToKeychain(persistedBundle)) {
|
package/dist/lib/commitments.js
CHANGED
|
@@ -660,6 +660,7 @@ const STALLED_DIAGNOSES = {
|
|
|
660
660
|
observation_unreadable: 'the latest check could not read every configured source',
|
|
661
661
|
execution_budget_exhausted: 'recent planned work was skipped — the execution budget is exhausted',
|
|
662
662
|
no_advancement: 'no server-recorded advancement exists inside the declared window',
|
|
663
|
+
owner_action_blocked: 'a completed work run is blocked pending owner action',
|
|
663
664
|
contract_version_mismatch: 'the running contract is older than the authored one; its triggers have not resynced',
|
|
664
665
|
};
|
|
665
666
|
/**
|
|
@@ -329,7 +329,11 @@ export declare function findBrokeredHandoffDescriptor(profile?: string): LocalBo
|
|
|
329
329
|
* 1. the descriptor that just redeemed IS the Dedicated Runtime guest
|
|
330
330
|
* hand-off file (`/run/skrr-dedicated-runtime/...`),
|
|
331
331
|
* 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
|
|
332
|
-
* 3. its stored `serverOrigin` matches the descriptor's `serverUrl
|
|
332
|
+
* 3. its stored `serverOrigin` matches the descriptor's `serverUrl`,
|
|
333
|
+
* 4. it is not marked `provenance: 'user_login'`. Since OSK-12293 every
|
|
334
|
+
* explicit sign-in (`skrr login`, `skrr pair`) records that marker in
|
|
335
|
+
* the file, so a login the person made seconds ago is kept and used;
|
|
336
|
+
* an unmarked file was written by a CLI that predates the marker.
|
|
333
337
|
*
|
|
334
338
|
* When provenance can't be proven the file is KEPT: an explicit `skrr login`
|
|
335
339
|
* on a guest is rare but real, and a stale extra family is bounded by the
|
package/dist/lib/daemonBroker.js
CHANGED
|
@@ -826,7 +826,11 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
|
|
|
826
826
|
* 1. the descriptor that just redeemed IS the Dedicated Runtime guest
|
|
827
827
|
* hand-off file (`/run/skrr-dedicated-runtime/...`),
|
|
828
828
|
* 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
|
|
829
|
-
* 3. its stored `serverOrigin` matches the descriptor's `serverUrl
|
|
829
|
+
* 3. its stored `serverOrigin` matches the descriptor's `serverUrl`,
|
|
830
|
+
* 4. it is not marked `provenance: 'user_login'`. Since OSK-12293 every
|
|
831
|
+
* explicit sign-in (`skrr login`, `skrr pair`) records that marker in
|
|
832
|
+
* the file, so a login the person made seconds ago is kept and used;
|
|
833
|
+
* an unmarked file was written by a CLI that predates the marker.
|
|
830
834
|
*
|
|
831
835
|
* When provenance can't be proven the file is KEPT: an explicit `skrr login`
|
|
832
836
|
* on a guest is rare but real, and a stale extra family is bounded by the
|
|
@@ -869,6 +873,13 @@ function retirableCliAuthFamily(descriptorServerUrl) {
|
|
|
869
873
|
}
|
|
870
874
|
if (!stored?.refreshToken)
|
|
871
875
|
return null;
|
|
876
|
+
// A family a person signed in for on purpose is never a pre-broker
|
|
877
|
+
// leftover: `skrr login` / `skrr pair` stamp it, and a refresh carries the
|
|
878
|
+
// stamp forward. Retiring it undid an explicit guest login on the very next
|
|
879
|
+
// command (OSK-12293). Only an UNMARKED file — written by a CLI that
|
|
880
|
+
// predates the marker — or a daemon-minted one is retired.
|
|
881
|
+
if (stored.provenance === 'user_login')
|
|
882
|
+
return null;
|
|
872
883
|
const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
|
|
873
884
|
const descriptorOrigin = descriptorServerUrl ? (0, auth_storage_1.normalizeServerOrigin)(descriptorServerUrl) : null;
|
|
874
885
|
if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
|
|
@@ -987,7 +998,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
|
|
|
987
998
|
...(expiryToEpochMs(outcome.refreshExpiresAt) !== undefined
|
|
988
999
|
? { refreshExpiresAt: expiryToEpochMs(outcome.refreshExpiresAt) }
|
|
989
1000
|
: {}),
|
|
990
|
-
}, { serverOrigin, clearReauth: true });
|
|
1001
|
+
}, { serverOrigin, clearReauth: true, provenance: 'daemon_broker' });
|
|
991
1002
|
// Only now: the credential is on disk, so "the predecessor dies iff the
|
|
992
1003
|
// replacement is in place" holds. A server that predates the split ships no
|
|
993
1004
|
// `familyId` and has already revoked eagerly — nothing to do there.
|
|
@@ -49,3 +49,24 @@ export interface BrokerRefusalDescription {
|
|
|
49
49
|
* (`base_url_mismatch`, `rate_limited`).
|
|
50
50
|
*/
|
|
51
51
|
export declare function describeBrokerRefusal(failure: DaemonBrokerFailure, bin?: string): BrokerRefusalDescription | null;
|
|
52
|
+
/**
|
|
53
|
+
* What to tell a person on a Dedicated Runtime guest when NO daemon answered
|
|
54
|
+
* the hand-off at all (OSK-12473).
|
|
55
|
+
*
|
|
56
|
+
* `describeBrokerRefusal` speaks only for a daemon that answered. On a guest
|
|
57
|
+
* the realistic broken state is a daemon that is absent: its credential was
|
|
58
|
+
* revoked and it exited by design, or systemd is cycling it, or it crashed and
|
|
59
|
+
* left its descriptor behind. The CLI then saw `no_bootstrap` / `network`, and
|
|
60
|
+
* fell to "Not signed in. Run `skrr login` first." — a login nobody performs
|
|
61
|
+
* on a guest, and which would only put a personal credential on a machine the
|
|
62
|
+
* daemon is meant to hold the account for. Say the machine's service is not
|
|
63
|
+
* running and name the owner's repair instead.
|
|
64
|
+
*
|
|
65
|
+
* Returns null off a guest, or for a daemon that answered (that is
|
|
66
|
+
* `describeBrokerRefusal`'s case).
|
|
67
|
+
*/
|
|
68
|
+
export declare function describeGuestDaemonUnavailable(failure: DaemonBrokerFailure | null, opts: {
|
|
69
|
+
bin?: string;
|
|
70
|
+
onGuest: boolean;
|
|
71
|
+
descriptorPresent?: boolean;
|
|
72
|
+
}): BrokerRefusalDescription | null;
|