@skrr-ai/cli 0.1.56 → 0.1.58
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 +11 -0
- package/dist/base-command.js +80 -6
- package/dist/commands/agents/chat.js +5 -3
- package/dist/commands/agents/create.js +7 -0
- package/dist/commands/agents/show.js +13 -2
- package/dist/commands/agents/update.js +7 -0
- package/dist/commands/harnesses/ping.d.ts +2 -0
- package/dist/commands/harnesses/ping.js +10 -1
- package/dist/commands/login.js +20 -1
- package/dist/commands/logout.js +8 -0
- package/dist/commands/pair.js +1 -1
- package/dist/commands/tasks/move.js +11 -0
- package/dist/lib/agent-model-check.d.ts +19 -0
- package/dist/lib/agent-model-check.js +68 -0
- package/dist/lib/agentic-stream.d.ts +24 -0
- package/dist/lib/agentic-stream.js +117 -5
- package/dist/lib/auth-core-init.d.ts +0 -9
- package/dist/lib/auth-core-init.js +9 -0
- package/dist/lib/auth-storage.d.ts +42 -0
- package/dist/lib/auth-storage.js +75 -7
- package/dist/lib/commitments.js +4 -0
- package/dist/lib/config.d.ts +4 -0
- package/dist/lib/config.js +4 -2
- package/dist/lib/daemonBroker.d.ts +23 -1
- package/dist/lib/daemonBroker.js +66 -17
- package/dist/lib/daemonHandoff.d.ts +1 -1
- package/dist/lib/daemonHandoff.js +39 -1
- package/dist/lib/internal-id.d.ts +28 -0
- package/dist/lib/internal-id.js +41 -0
- package/dist/lib/login.js +2 -2
- package/dist/lib/oauthLogin.js +3 -1
- package/dist/lib/workflow-state-hint.d.ts +10 -0
- package/dist/lib/workflow-state-hint.js +27 -0
- package/dist/node_modules/@skrr-ai/data-provider/index.js +3292 -3281
- package/oclif.manifest.json +29800 -29800
- package/package.json +1 -1
|
@@ -557,6 +557,18 @@ class AgenticStreamClient {
|
|
|
557
557
|
toolNamesByCall = new Map();
|
|
558
558
|
/** `permission-ask` requestId → tool name, to name its resolution (OSK-12113). */
|
|
559
559
|
toolNamesByPermission = new Map();
|
|
560
|
+
/**
|
|
561
|
+
* Permission prompts on the terminal still waiting for an answer, by
|
|
562
|
+
* toolUseId, so a server `permission:cancel` can close the one it names
|
|
563
|
+
* (OSK-12197) instead of leaving `[y/N]` open after the run stopped asking.
|
|
564
|
+
*/
|
|
565
|
+
openPermissionPrompts = new Map();
|
|
566
|
+
/**
|
|
567
|
+
* Question prompts on the terminal still waiting for an answer, by
|
|
568
|
+
* questionId, so a server `question:cancel` can close the one it names
|
|
569
|
+
* (OSK-12208) the same way `permission:cancel` closes a permission prompt.
|
|
570
|
+
*/
|
|
571
|
+
openQuestionPrompts = new Map();
|
|
560
572
|
/** Text already written to the terminal, for suffix-only rendering. */
|
|
561
573
|
renderedText = '';
|
|
562
574
|
idleTimer = null;
|
|
@@ -946,10 +958,20 @@ class AgenticStreamClient {
|
|
|
946
958
|
return;
|
|
947
959
|
void this.handlePermission(event);
|
|
948
960
|
});
|
|
961
|
+
socket.on('permission:cancel', (event) => {
|
|
962
|
+
if (!match(event))
|
|
963
|
+
return;
|
|
964
|
+
this.handlePermissionCancel(event);
|
|
965
|
+
});
|
|
949
966
|
socket.on('question:ask', (event) => {
|
|
950
967
|
if (match(event))
|
|
951
968
|
void this.handleQuestion(event);
|
|
952
969
|
});
|
|
970
|
+
socket.on('question:cancel', (event) => {
|
|
971
|
+
if (!match(event))
|
|
972
|
+
return;
|
|
973
|
+
this.handleQuestionCancel(event);
|
|
974
|
+
});
|
|
953
975
|
socket.on('error:unauthorized', (event) => {
|
|
954
976
|
if (match(event))
|
|
955
977
|
this.finish('failed', event);
|
|
@@ -1008,7 +1030,16 @@ class AgenticStreamClient {
|
|
|
1008
1030
|
});
|
|
1009
1031
|
return;
|
|
1010
1032
|
}
|
|
1011
|
-
const
|
|
1033
|
+
const prompt = new AbortController();
|
|
1034
|
+
if (event.toolUseId)
|
|
1035
|
+
this.openPermissionPrompts.set(event.toolUseId, prompt);
|
|
1036
|
+
const answer = await this.ask(`Allow ${describePermission(event)}? [y/N] `, prompt.signal);
|
|
1037
|
+
if (event.toolUseId)
|
|
1038
|
+
this.openPermissionPrompts.delete(event.toolUseId);
|
|
1039
|
+
// Closed by the server (it stopped waiting, or another surface answered):
|
|
1040
|
+
// there is nothing left to answer, and a late "no" would read as a person's.
|
|
1041
|
+
if (prompt.signal.aborted)
|
|
1042
|
+
return;
|
|
1012
1043
|
const approved = /^y(es)?$/i.test(answer.trim());
|
|
1013
1044
|
this.socket.emit(approved ? 'permission:approve' : 'permission:deny', {
|
|
1014
1045
|
sessionId: this.options.sessionId,
|
|
@@ -1043,9 +1074,20 @@ class AgenticStreamClient {
|
|
|
1043
1074
|
const interactive = this.options.input !== undefined || Boolean(process.stdin.isTTY);
|
|
1044
1075
|
const answers = {};
|
|
1045
1076
|
if (interactive) {
|
|
1077
|
+
const prompt = new AbortController();
|
|
1078
|
+
if (event.questionId)
|
|
1079
|
+
this.openQuestionPrompts.set(event.questionId, prompt);
|
|
1046
1080
|
for (const question of event.questions ?? []) {
|
|
1047
|
-
|
|
1081
|
+
if (prompt.signal.aborted)
|
|
1082
|
+
break;
|
|
1083
|
+
answers[question.header ?? question.question] = await this.ask(`${question.header ?? 'Question'}: ${question.question}\n> `, prompt.signal);
|
|
1048
1084
|
}
|
|
1085
|
+
if (event.questionId)
|
|
1086
|
+
this.openQuestionPrompts.delete(event.questionId);
|
|
1087
|
+
// Closed by the server (it stopped waiting, or the turn ended): an answer
|
|
1088
|
+
// sent now would be read by nobody, and would look like a person's.
|
|
1089
|
+
if (prompt.signal.aborted)
|
|
1090
|
+
return;
|
|
1049
1091
|
}
|
|
1050
1092
|
else {
|
|
1051
1093
|
answers._dismissed = exports.NON_INTERACTIVE_QUESTION_DISMISSAL;
|
|
@@ -1064,10 +1106,55 @@ class AgenticStreamClient {
|
|
|
1064
1106
|
...(interactive ? {} : { nonInteractive: true }),
|
|
1065
1107
|
});
|
|
1066
1108
|
}
|
|
1067
|
-
|
|
1109
|
+
/**
|
|
1110
|
+
* The server closed a permission request (OSK-12197): it expired unanswered,
|
|
1111
|
+
* the pre-execution step gave up, or another surface answered it. Say so,
|
|
1112
|
+
* and close the terminal prompt if this client is still showing it.
|
|
1113
|
+
*/
|
|
1114
|
+
handlePermissionCancel(event) {
|
|
1115
|
+
const toolUseId = typeof event.toolUseId === 'string' ? event.toolUseId : undefined;
|
|
1116
|
+
const open = toolUseId ? this.openPermissionPrompts.get(toolUseId) : undefined;
|
|
1117
|
+
// Every answer is echoed to the whole session as `answered` — including
|
|
1118
|
+
// this client's own. Only one that closes a prompt still open HERE is news.
|
|
1119
|
+
if (event.reason === 'answered' && !open)
|
|
1120
|
+
return;
|
|
1121
|
+
if (!this.firstTimeSeen('permission-cancel', toolUseId))
|
|
1122
|
+
return;
|
|
1123
|
+
if (toolUseId)
|
|
1124
|
+
this.openPermissionPrompts.delete(toolUseId);
|
|
1125
|
+
open?.abort();
|
|
1126
|
+
this.emit({
|
|
1127
|
+
...event,
|
|
1128
|
+
type: 'permission.cancelled',
|
|
1129
|
+
promptClosed: Boolean(open),
|
|
1130
|
+
...(event.reason === 'answered' && !event.message
|
|
1131
|
+
? { message: 'answered on another surface' }
|
|
1132
|
+
: {}),
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* The server closed a question (OSK-12208): nobody answered it in time, or
|
|
1137
|
+
* the turn ended first. Say so, and close the terminal prompt if this
|
|
1138
|
+
* client is still showing it — the question twin of handlePermissionCancel.
|
|
1139
|
+
*/
|
|
1140
|
+
handleQuestionCancel(event) {
|
|
1141
|
+
const questionId = typeof event.questionId === 'string' ? event.questionId : undefined;
|
|
1142
|
+
if (!this.firstTimeSeen('question-cancel', questionId))
|
|
1143
|
+
return;
|
|
1144
|
+
const open = questionId ? this.openQuestionPrompts.get(questionId) : undefined;
|
|
1145
|
+
if (questionId)
|
|
1146
|
+
this.openQuestionPrompts.delete(questionId);
|
|
1147
|
+
open?.abort();
|
|
1148
|
+
this.emit({
|
|
1149
|
+
...event,
|
|
1150
|
+
type: 'question.cancelled',
|
|
1151
|
+
promptClosed: Boolean(open),
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
ask(prompt, signal) {
|
|
1068
1155
|
// A prompt raised after close has nobody to answer it, and re-creating the
|
|
1069
1156
|
// interface here would resurrect stdin for a run that is already over.
|
|
1070
|
-
if (this.closed)
|
|
1157
|
+
if (this.closed || signal?.aborted)
|
|
1071
1158
|
return Promise.resolve('');
|
|
1072
1159
|
if (!this.readline) {
|
|
1073
1160
|
this.readline = (0, node_readline_1.createInterface)({
|
|
@@ -1075,7 +1162,15 @@ class AgenticStreamClient {
|
|
|
1075
1162
|
output: process.stderr,
|
|
1076
1163
|
});
|
|
1077
1164
|
}
|
|
1078
|
-
|
|
1165
|
+
if (!signal) {
|
|
1166
|
+
return new Promise((resolve) => this.readline.question(prompt, resolve));
|
|
1167
|
+
}
|
|
1168
|
+
return new Promise((resolve) => {
|
|
1169
|
+
// readline does not call back for a question aborted by its signal, so
|
|
1170
|
+
// the abort settles the prompt itself.
|
|
1171
|
+
signal.addEventListener('abort', () => resolve(''), { once: true });
|
|
1172
|
+
this.readline.question(prompt, { signal }, resolve);
|
|
1173
|
+
});
|
|
1079
1174
|
}
|
|
1080
1175
|
/**
|
|
1081
1176
|
* True the first time this (kind, identity) pair is seen; false afterwards.
|
|
@@ -1176,10 +1271,27 @@ class AgenticStreamClient {
|
|
|
1176
1271
|
output.write(`\n[${event.type}] ${describePermission(event)}\n`);
|
|
1177
1272
|
this.renderedText = this.accumulated;
|
|
1178
1273
|
}
|
|
1274
|
+
else if (event.type === 'permission.cancelled') {
|
|
1275
|
+
// A request the run stopped waiting for (OSK-12197). Without this line
|
|
1276
|
+
// an expired approval looked exactly like one still being asked.
|
|
1277
|
+
const why = typeof event.message === 'string' && event.message
|
|
1278
|
+
? event.message
|
|
1279
|
+
: `closed (${String(event.reason ?? 'cancelled')})`;
|
|
1280
|
+
output.write(`\n[permission] ${String(event.toolName ?? 'tool')}: ${why}\n`);
|
|
1281
|
+
this.renderedText = this.accumulated;
|
|
1282
|
+
}
|
|
1179
1283
|
else if (event.type === 'question.requested') {
|
|
1180
1284
|
output.write(`\n[${event.type}]\n`);
|
|
1181
1285
|
this.renderedText = this.accumulated;
|
|
1182
1286
|
}
|
|
1287
|
+
else if (event.type === 'question.cancelled') {
|
|
1288
|
+
// A question the run stopped waiting for (OSK-12208).
|
|
1289
|
+
const why = typeof event.message === 'string' && event.message
|
|
1290
|
+
? event.message
|
|
1291
|
+
: `closed (${String(event.reason ?? 'cancelled')})`;
|
|
1292
|
+
output.write(`\n[question] ${why}\n`);
|
|
1293
|
+
this.renderedText = this.accumulated;
|
|
1294
|
+
}
|
|
1183
1295
|
else if (event.type === 'permission.approved' && event.auto === true) {
|
|
1184
1296
|
// Said out loud, because nobody was asked: the flag approved it, and a
|
|
1185
1297
|
// reader of this log should see which tools that covered (OSK-10323).
|
|
@@ -36,15 +36,6 @@ export declare function plainCredentialEnvelopeWarning(args: readonly unknown[])
|
|
|
36
36
|
* own when a local daemon can hand a credential over.
|
|
37
37
|
*/
|
|
38
38
|
export declare const CREDENTIAL_KEY_CHANGED_NOTICE: string;
|
|
39
|
-
/**
|
|
40
|
-
* What the CLI prints for one auth-core warning: a replacement line, nothing
|
|
41
|
-
* (`''`), or null for "print it as auth-core wrote it".
|
|
42
|
-
*
|
|
43
|
-
* Stateful by design — the process that detected a key change has already
|
|
44
|
-
* told the person why every old credential is unreadable, so the per-value
|
|
45
|
-
* decrypt failures that follow add nothing. A fresh process starts clean, and
|
|
46
|
-
* a later genuinely unreadable credential still gets its own OSK-12180 line.
|
|
47
|
-
*/
|
|
48
39
|
export declare function createCredentialEnvelopeWarningPresenter(): (args: readonly unknown[]) => string | null;
|
|
49
40
|
type DedicatedGenerationPreparationOptions = {
|
|
50
41
|
machineUuid?: string;
|
|
@@ -106,6 +106,8 @@ exports.CREDENTIAL_KEY_CHANGED_NOTICE = "note: this machine's credential key cha
|
|
|
106
106
|
* decrypt failures that follow add nothing. A fresh process starts clean, and
|
|
107
107
|
* a later genuinely unreadable credential still gets its own OSK-12180 line.
|
|
108
108
|
*/
|
|
109
|
+
/** auth-core's device-identity warning for a private key it could not open. */
|
|
110
|
+
const DEVICE_KEY_DECRYPT_FAILED = '[deviceIdentity] private_key_decrypt_failed';
|
|
109
111
|
function createCredentialEnvelopeWarningPresenter() {
|
|
110
112
|
let keyChanged = false;
|
|
111
113
|
return (args) => {
|
|
@@ -130,6 +132,13 @@ function createCredentialEnvelopeWarningPresenter() {
|
|
|
130
132
|
// already said why. A MALFORMED envelope is a different fact and still prints.
|
|
131
133
|
if (keyChanged && event === 'cred_envelope.read.decrypt_failed')
|
|
132
134
|
return '';
|
|
135
|
+
// The device private key is one of those values: auth-core's device
|
|
136
|
+
// identity loads it next, cannot open it, and mints a new keypair. It
|
|
137
|
+
// logs that through its own `[deviceIdentity]` prefix, which reached the
|
|
138
|
+
// terminal as a raw `[auth-core warn]` line right under the notice. The
|
|
139
|
+
// notice covers it; without a key change it is a real fault and prints.
|
|
140
|
+
if (keyChanged && message === DEVICE_KEY_DECRYPT_FAILED)
|
|
141
|
+
return '';
|
|
133
142
|
return plainCredentialEnvelopeWarning(args);
|
|
134
143
|
};
|
|
135
144
|
}
|
|
@@ -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
|
|
@@ -48,6 +74,22 @@ export interface WriteOptions {
|
|
|
48
74
|
* retiring it. Everything else should keep using `readFromBackend()`.
|
|
49
75
|
*/
|
|
50
76
|
export declare function readFromFile(): AuthBundle | null;
|
|
77
|
+
/**
|
|
78
|
+
* The refresh family a stored `cli-auth.json` carries, whether or not it also
|
|
79
|
+
* carries an access token — for the Dedicated guest's retirement of a
|
|
80
|
+
* pre-broker file only (OSK-12194).
|
|
81
|
+
*
|
|
82
|
+
* `readFromFile()` treats a file with an empty `token` as no credential, which
|
|
83
|
+
* is right for signing in and wrong here: the realistic pre-broker shape is
|
|
84
|
+
* `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
|
|
85
|
+
* lapsed, and a reader that returns nothing for it retires nothing. Same
|
|
86
|
+
* ownership rules as `readFromFile()`; never rewrites the file.
|
|
87
|
+
*/
|
|
88
|
+
export declare function readRefreshFamilyFromFile(): {
|
|
89
|
+
refreshToken: string;
|
|
90
|
+
serverOrigin?: string;
|
|
91
|
+
provenance?: CredentialProvenance;
|
|
92
|
+
} | null;
|
|
51
93
|
/**
|
|
52
94
|
* Delete `~/.skrr/cli-auth.json` only (the keychain is untouched). Exported
|
|
53
95
|
* for the brokered-hand-off migration cleanup; `skrr logout` continues to
|
package/dist/lib/auth-storage.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.backends = void 0;
|
|
37
37
|
exports.readFromFile = readFromFile;
|
|
38
|
+
exports.readRefreshFamilyFromFile = readRefreshFamilyFromFile;
|
|
38
39
|
exports.deleteFileBackend = deleteFileBackend;
|
|
39
40
|
exports.readFromBackend = readFromBackend;
|
|
40
41
|
exports.describeForeignCredential = describeForeignCredential;
|
|
@@ -105,6 +106,12 @@ const cli_id_1 = require("./cli-id");
|
|
|
105
106
|
* `auth.json`, deliberately distinct so the two binaries never clobber
|
|
106
107
|
* each other's tokens (CLI holds scope=cli, daemon holds scope=daemon). */
|
|
107
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
|
+
}
|
|
108
115
|
/**
|
|
109
116
|
* The config root — the same one `cli-config.json` (and so `cliId`) lives in.
|
|
110
117
|
*
|
|
@@ -234,13 +241,54 @@ function judgeOwnership(bundle, me, ownSlot) {
|
|
|
234
241
|
function readFromFile() {
|
|
235
242
|
return readFileAssessed().bundle;
|
|
236
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* The refresh family a stored `cli-auth.json` carries, whether or not it also
|
|
246
|
+
* carries an access token — for the Dedicated guest's retirement of a
|
|
247
|
+
* pre-broker file only (OSK-12194).
|
|
248
|
+
*
|
|
249
|
+
* `readFromFile()` treats a file with an empty `token` as no credential, which
|
|
250
|
+
* is right for signing in and wrong here: the realistic pre-broker shape is
|
|
251
|
+
* `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
|
|
252
|
+
* lapsed, and a reader that returns nothing for it retires nothing. Same
|
|
253
|
+
* ownership rules as `readFromFile()`; never rewrites the file.
|
|
254
|
+
*/
|
|
255
|
+
function readRefreshFamilyFromFile() {
|
|
256
|
+
const bundle = readFileAssessed({ requireAccessToken: false }).bundle;
|
|
257
|
+
if (!bundle?.refreshToken)
|
|
258
|
+
return null;
|
|
259
|
+
return {
|
|
260
|
+
refreshToken: bundle.refreshToken,
|
|
261
|
+
...(bundle.serverOrigin ? { serverOrigin: bundle.serverOrigin } : {}),
|
|
262
|
+
...(bundle.provenance ? { provenance: bundle.provenance } : {}),
|
|
263
|
+
};
|
|
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
|
+
}
|
|
237
285
|
/**
|
|
238
286
|
* This root's file, refused when it belongs to another config; else, under a
|
|
239
287
|
* root override, the pre-override `$HOME/.skrr` file when it is positively ours.
|
|
240
288
|
*/
|
|
241
|
-
function readFileAssessed() {
|
|
289
|
+
function readFileAssessed(opts = {}) {
|
|
242
290
|
const me = currentOwner();
|
|
243
|
-
const own = readFileAt(authFilePath(), true);
|
|
291
|
+
const own = readFileAt(authFilePath(), true, opts);
|
|
244
292
|
if (own) {
|
|
245
293
|
const judged = judgeOwnership(own, me, true);
|
|
246
294
|
return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
|
|
@@ -248,7 +296,7 @@ function readFileAssessed() {
|
|
|
248
296
|
const legacy = legacyAuthFilePath();
|
|
249
297
|
if (path.resolve(legacy) === path.resolve(authFilePath()))
|
|
250
298
|
return { bundle: null };
|
|
251
|
-
const adopted = readFileAt(legacy, false);
|
|
299
|
+
const adopted = readFileAt(legacy, false, opts);
|
|
252
300
|
if (adopted && judgeOwnership(adopted, me, false).ours)
|
|
253
301
|
return { bundle: adopted };
|
|
254
302
|
return { bundle: null };
|
|
@@ -265,7 +313,7 @@ function legacyFileIsOurs() {
|
|
|
265
313
|
* Parse one credential file. `upgradeInPlace` lets the envelope migration
|
|
266
314
|
* rewrite it — true only for this root's own file, never a migration source.
|
|
267
315
|
*/
|
|
268
|
-
function readFileAt(p, upgradeInPlace) {
|
|
316
|
+
function readFileAt(p, upgradeInPlace, opts = {}) {
|
|
269
317
|
if (!fs.existsSync(p))
|
|
270
318
|
return null;
|
|
271
319
|
let raw;
|
|
@@ -282,7 +330,9 @@ function readFileAt(p, upgradeInPlace) {
|
|
|
282
330
|
catch {
|
|
283
331
|
return null;
|
|
284
332
|
}
|
|
285
|
-
|
|
333
|
+
const requireAccessToken = opts.requireAccessToken !== false;
|
|
334
|
+
const hasAccessToken = typeof parsed.token === 'string' && parsed.token.length > 0;
|
|
335
|
+
if (!hasAccessToken && requireAccessToken) {
|
|
286
336
|
return null;
|
|
287
337
|
}
|
|
288
338
|
// L12 — route the secret string fields through the envelope. Mixed
|
|
@@ -293,13 +343,16 @@ function readFileAt(p, upgradeInPlace) {
|
|
|
293
343
|
const owner = parseOwner(parsed.owner);
|
|
294
344
|
const ownerRoot = owner?.configRoot ? path.resolve(owner.configRoot) : undefined;
|
|
295
345
|
const decryptOptions = ownerRoot && ownerRoot !== path.resolve((0, config_1.configRoot)()) ? { foreignOwnerRoot: ownerRoot } : {};
|
|
296
|
-
const tokenResult =
|
|
346
|
+
const tokenResult = hasAccessToken
|
|
347
|
+
? (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions)
|
|
348
|
+
: { plaintext: '', needsMigration: false };
|
|
297
349
|
if (tokenResult.plaintext === null) {
|
|
298
350
|
// Envelope on disk but no DEK to unwrap → treat as missing so the
|
|
299
351
|
// caller falls through to a clean `skrr login`.
|
|
300
352
|
return null;
|
|
301
353
|
}
|
|
302
|
-
|
|
354
|
+
// A token-less read is never written back: there is no access token to seal.
|
|
355
|
+
let needsMigration = hasAccessToken && tokenResult.needsMigration;
|
|
303
356
|
const bundle = { token: tokenResult.plaintext };
|
|
304
357
|
if (typeof parsed.expiresAt === 'number' && Number.isFinite(parsed.expiresAt)) {
|
|
305
358
|
bundle.expiresAt = parsed.expiresAt;
|
|
@@ -323,6 +376,9 @@ function readFileAt(p, upgradeInPlace) {
|
|
|
323
376
|
}
|
|
324
377
|
if (owner)
|
|
325
378
|
bundle.owner = owner;
|
|
379
|
+
const provenance = parseProvenance(parsed.provenance);
|
|
380
|
+
if (provenance)
|
|
381
|
+
bundle.provenance = provenance;
|
|
326
382
|
// Best-effort upgrade: re-write the bundle so legacy plaintext fields
|
|
327
383
|
// become wrapped on the next read. A failure here doesn't break the
|
|
328
384
|
// read path — the caller already has the plaintext bundle.
|
|
@@ -387,6 +443,8 @@ function writeToFile(bundle) {
|
|
|
387
443
|
// Rewrites of an existing bundle (the envelope upgrade) keep its recorded
|
|
388
444
|
// owner; everything else is stamped with this config.
|
|
389
445
|
wireBundle.owner = bundle.owner ?? ownerFor(bundle);
|
|
446
|
+
if (bundle.provenance)
|
|
447
|
+
wireBundle.provenance = bundle.provenance;
|
|
390
448
|
// tmp + rename so no racing reader ever sees a partial file; chmod 0600
|
|
391
449
|
// on the final file so the same-UID blast-radius is as narrow as the
|
|
392
450
|
// file backend can offer.
|
|
@@ -746,6 +804,16 @@ function writeToBackend(bundle, options = {}) {
|
|
|
746
804
|
// Always stamp THIS config as the owner — a bundle read back and re-written
|
|
747
805
|
// (e.g. `pair`) must not carry a record forward from wherever it came from.
|
|
748
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;
|
|
749
817
|
let backend;
|
|
750
818
|
if (keychain.isAvailable()) {
|
|
751
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
|
/**
|
|
@@ -1334,6 +1335,9 @@ function formatCommitmentExplanation(explanation) {
|
|
|
1334
1335
|
: explanation.verdict === 'quiet'
|
|
1335
1336
|
? `Chose to stay quiet at: ${explanation.stoppedAt}`
|
|
1336
1337
|
: `Stopped at: ${explanation.stoppedAt}`);
|
|
1338
|
+
// What the next Run may do without asking — an Ask-mode Agent can ask
|
|
1339
|
+
// nobody on an unattended Run (OSK-12201). Same lines as show/preflight.
|
|
1340
|
+
lines.push(...permissionLines(explanation.permissions));
|
|
1337
1341
|
return lines;
|
|
1338
1342
|
}
|
|
1339
1343
|
/**
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -190,6 +190,10 @@ export declare function isEnvAuthOverride(): boolean;
|
|
|
190
190
|
* resolver and every existing install read it.
|
|
191
191
|
*/
|
|
192
192
|
export declare function configRoot(env?: NodeJS.ProcessEnv): string;
|
|
193
|
+
/** The root this machine uses when nothing relocates it (`~/.skrr`). */
|
|
194
|
+
export declare function defaultConfigRoot(): string;
|
|
195
|
+
/** Is this process on the primary root, however it was reached? */
|
|
196
|
+
export declare function isDefaultConfigRoot(env?: NodeJS.ProcessEnv): boolean;
|
|
193
197
|
/**
|
|
194
198
|
* The machine cliId THIS config root mints when it has none stored
|
|
195
199
|
* (OSK-12179). The primary root keeps the historical (host, user) id; any other
|
package/dist/lib/config.js
CHANGED
|
@@ -39,6 +39,8 @@ exports.describeDeployment = describeDeployment;
|
|
|
39
39
|
exports.canonicalizeKnownBaseURL = canonicalizeKnownBaseURL;
|
|
40
40
|
exports.isEnvAuthOverride = isEnvAuthOverride;
|
|
41
41
|
exports.configRoot = configRoot;
|
|
42
|
+
exports.defaultConfigRoot = defaultConfigRoot;
|
|
43
|
+
exports.isDefaultConfigRoot = isDefaultConfigRoot;
|
|
42
44
|
exports.machineCliId = machineCliId;
|
|
43
45
|
exports.priorConfigDir = priorConfigDir;
|
|
44
46
|
exports.loadConfig = loadConfig;
|
|
@@ -276,8 +278,8 @@ function defaultConfigRoot() {
|
|
|
276
278
|
return (0, auth_core_1.resolveConfigRoot)({});
|
|
277
279
|
}
|
|
278
280
|
/** Is this process on the primary root, however it was reached? */
|
|
279
|
-
function isDefaultConfigRoot() {
|
|
280
|
-
return path.resolve(configRoot()) === path.resolve(defaultConfigRoot());
|
|
281
|
+
function isDefaultConfigRoot(env = process.env) {
|
|
282
|
+
return path.resolve(configRoot(env)) === path.resolve(defaultConfigRoot());
|
|
281
283
|
}
|
|
282
284
|
/**
|
|
283
285
|
* The machine cliId THIS config root mints when it has none stored
|
|
@@ -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
|
|
@@ -342,6 +346,24 @@ export declare function maybeRetireStoredCliAuthFile(opts: {
|
|
|
342
346
|
descriptorPath?: string;
|
|
343
347
|
descriptorServerUrl?: string;
|
|
344
348
|
}): Promise<void>;
|
|
349
|
+
/**
|
|
350
|
+
* Should this process broker BEFORE it reads the stored credential?
|
|
351
|
+
* (OSK-12194)
|
|
352
|
+
*
|
|
353
|
+
* True only on a brokered Dedicated guest: the guest hand-off descriptor is
|
|
354
|
+
* live and offers the access-token mode and NOT the durable mint (its secret
|
|
355
|
+
* can only redeem, so any `cli-auth.json` here predates broker mode), and that
|
|
356
|
+
* file carries a refresh family for the descriptor's own server — the same
|
|
357
|
+
* provenance guard the retirement applies.
|
|
358
|
+
*
|
|
359
|
+
* Without this, the retirement could never run: it runs after a successful
|
|
360
|
+
* redeem, the auto-broker runs only when the resolver found no token, and the
|
|
361
|
+
* resolver adopts a file that carries one — so a LIVE pre-broker family was
|
|
362
|
+
* used and kept forever, which is exactly the case the cleanup exists for.
|
|
363
|
+
* A laptop has no guest descriptor and is untouched: there `cli-auth.json` is
|
|
364
|
+
* a legitimate login.
|
|
365
|
+
*/
|
|
366
|
+
export declare function shouldBrokerBeforeStoredCredential(descriptorPath?: string): boolean;
|
|
345
367
|
/**
|
|
346
368
|
* Convenience wrapper: run the broker call and, on a durable-mint success,
|
|
347
369
|
* persist the returned bundle through the canonical Sky CLI auth backend
|
package/dist/lib/daemonBroker.js
CHANGED
|
@@ -86,6 +86,7 @@ exports.redeemBrokeredHandoffToken = redeemBrokeredHandoffToken;
|
|
|
86
86
|
exports.__resetBrokeredHandoffForTest = __resetBrokeredHandoffForTest;
|
|
87
87
|
exports.findBrokeredHandoffDescriptor = findBrokeredHandoffDescriptor;
|
|
88
88
|
exports.maybeRetireStoredCliAuthFile = maybeRetireStoredCliAuthFile;
|
|
89
|
+
exports.shouldBrokerBeforeStoredCredential = shouldBrokerBeforeStoredCredential;
|
|
89
90
|
exports.attemptDaemonBrokerLoginAndPersist = attemptDaemonBrokerLoginAndPersist;
|
|
90
91
|
exports.maybeAutoBroker = maybeAutoBroker;
|
|
91
92
|
const fs = __importStar(require("node:fs"));
|
|
@@ -825,7 +826,11 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
|
|
|
825
826
|
* 1. the descriptor that just redeemed IS the Dedicated Runtime guest
|
|
826
827
|
* hand-off file (`/run/skrr-dedicated-runtime/...`),
|
|
827
828
|
* 2. `~/.skrr/cli-auth.json` reads cleanly and carries a refresh token,
|
|
828
|
-
* 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.
|
|
829
834
|
*
|
|
830
835
|
* When provenance can't be proven the file is KEPT: an explicit `skrr login`
|
|
831
836
|
* on a guest is rare but real, and a stale extra family is bounded by the
|
|
@@ -837,32 +842,76 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
|
|
|
837
842
|
async function maybeRetireStoredCliAuthFile(opts) {
|
|
838
843
|
if (opts.descriptorPath !== exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR)
|
|
839
844
|
return;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
stored = (0, auth_storage_1.readFromFile)();
|
|
843
|
-
}
|
|
844
|
-
catch {
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
if (!stored?.refreshToken)
|
|
848
|
-
return;
|
|
849
|
-
const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
|
|
850
|
-
const descriptorOrigin = opts.descriptorServerUrl
|
|
851
|
-
? (0, auth_storage_1.normalizeServerOrigin)(opts.descriptorServerUrl)
|
|
852
|
-
: null;
|
|
853
|
-
if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
|
|
845
|
+
const stale = retirableCliAuthFamily(opts.descriptorServerUrl);
|
|
846
|
+
if (!stale)
|
|
854
847
|
return;
|
|
855
848
|
// The revoke is authenticated by the refresh token itself — presenting it
|
|
856
849
|
// is the proof of ownership. `revokeDaemonRefreshSession` swallows its own
|
|
857
850
|
// network/HTTP failures (best-effort by contract); the delete still runs.
|
|
858
851
|
try {
|
|
859
|
-
await (0, auth_core_1.revokeDaemonRefreshSession)(
|
|
852
|
+
await (0, auth_core_1.revokeDaemonRefreshSession)(stale.origin, stale.refreshToken);
|
|
860
853
|
}
|
|
861
854
|
catch {
|
|
862
855
|
/* best-effort — delete proceeds regardless */
|
|
863
856
|
}
|
|
864
857
|
(0, auth_storage_1.deleteFileBackend)();
|
|
865
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Guards 2 and 3 of the retirement above: the stored file's refresh family,
|
|
861
|
+
* when its origin matches the descriptor's. Read through
|
|
862
|
+
* `readRefreshFamilyFromFile`, which accepts a file whose access token is
|
|
863
|
+
* empty — the realistic pre-broker shape once that token lapsed, and the one
|
|
864
|
+
* `readFromFile` reports as no credential at all (OSK-12194).
|
|
865
|
+
*/
|
|
866
|
+
function retirableCliAuthFamily(descriptorServerUrl) {
|
|
867
|
+
let stored;
|
|
868
|
+
try {
|
|
869
|
+
stored = (0, auth_storage_1.readRefreshFamilyFromFile)();
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
if (!stored?.refreshToken)
|
|
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;
|
|
883
|
+
const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
|
|
884
|
+
const descriptorOrigin = descriptorServerUrl ? (0, auth_storage_1.normalizeServerOrigin)(descriptorServerUrl) : null;
|
|
885
|
+
if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
|
|
886
|
+
return null;
|
|
887
|
+
return { origin: descriptorOrigin, refreshToken: stored.refreshToken };
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Should this process broker BEFORE it reads the stored credential?
|
|
891
|
+
* (OSK-12194)
|
|
892
|
+
*
|
|
893
|
+
* True only on a brokered Dedicated guest: the guest hand-off descriptor is
|
|
894
|
+
* live and offers the access-token mode and NOT the durable mint (its secret
|
|
895
|
+
* can only redeem, so any `cli-auth.json` here predates broker mode), and that
|
|
896
|
+
* file carries a refresh family for the descriptor's own server — the same
|
|
897
|
+
* provenance guard the retirement applies.
|
|
898
|
+
*
|
|
899
|
+
* Without this, the retirement could never run: it runs after a successful
|
|
900
|
+
* redeem, the auto-broker runs only when the resolver found no token, and the
|
|
901
|
+
* resolver adopts a file that carries one — so a LIVE pre-broker family was
|
|
902
|
+
* used and kept forever, which is exactly the case the cleanup exists for.
|
|
903
|
+
* A laptop has no guest descriptor and is untouched: there `cli-auth.json` is
|
|
904
|
+
* a legitimate login.
|
|
905
|
+
*/
|
|
906
|
+
function shouldBrokerBeforeStoredCredential(descriptorPath = exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR) {
|
|
907
|
+
const bootstrap = readBootstrap(descriptorPath);
|
|
908
|
+
if (!bootstrap)
|
|
909
|
+
return false;
|
|
910
|
+
const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
|
|
911
|
+
if (!modes.includes('access_token') || modes.includes('refresh_family'))
|
|
912
|
+
return false;
|
|
913
|
+
return retirableCliAuthFamily(bootstrap.serverUrl) !== null;
|
|
914
|
+
}
|
|
866
915
|
/** Bound the confirm so a slow server cannot stall a login that already worked. */
|
|
867
916
|
const CONFIRM_TIMEOUT_MS = 10_000;
|
|
868
917
|
/**
|
|
@@ -949,7 +998,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
|
|
|
949
998
|
...(expiryToEpochMs(outcome.refreshExpiresAt) !== undefined
|
|
950
999
|
? { refreshExpiresAt: expiryToEpochMs(outcome.refreshExpiresAt) }
|
|
951
1000
|
: {}),
|
|
952
|
-
}, { serverOrigin, clearReauth: true });
|
|
1001
|
+
}, { serverOrigin, clearReauth: true, provenance: 'daemon_broker' });
|
|
953
1002
|
// Only now: the credential is on disk, so "the predecessor dies iff the
|
|
954
1003
|
// replacement is in place" holds. A server that predates the split ships no
|
|
955
1004
|
// `familyId` and has already revoked eagerly — nothing to do there.
|
|
@@ -40,7 +40,7 @@ export interface DaemonHandoffOutcome {
|
|
|
40
40
|
* The two absent cases are separated because they need different work — one
|
|
41
41
|
* needs a binary fetched, the other only needs the service registered.
|
|
42
42
|
*/
|
|
43
|
-
reason?: 'opted-out' | 'brokered' | 'no-binary' | 'legacy-binary' | 'unsupported-daemon' | 'no-service';
|
|
43
|
+
reason?: 'opted-out' | 'brokered' | 'no-binary' | 'legacy-binary' | 'unsupported-daemon' | 'no-service' | 'foreign-root' | 'mint-refused' | 'platform-managed';
|
|
44
44
|
}
|
|
45
45
|
export declare function handOffToLocalDaemon(env?: NodeJS.ProcessEnv, opts?: {
|
|
46
46
|
loginFlow?: string;
|