@skrr-ai/cli 0.1.51 → 0.1.53
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 +7 -0
- package/dist/base-command.js +38 -8
- package/dist/commands/agents/chat.js +2 -1
- package/dist/commands/agents/create.js +21 -8
- package/dist/commands/agents/update.d.ts +55 -0
- package/dist/commands/agents/update.js +119 -23
- package/dist/commands/commitments/next-tick.js +9 -1
- package/dist/commands/context.js +2 -1
- package/dist/commands/daemon/config.js +16 -1
- package/dist/commands/doctor.js +8 -1
- package/dist/commands/login.d.ts +2 -1
- package/dist/commands/login.js +26 -7
- package/dist/commands/logout.js +3 -0
- package/dist/commands/machines/dedicated/show.js +25 -2
- package/dist/commands/tasks/approvals/list.d.ts +22 -0
- package/dist/commands/tasks/approvals/list.js +80 -3
- package/dist/commands/tasks/create.d.ts +5 -0
- package/dist/commands/tasks/create.js +28 -7
- package/dist/commands/tasks/start.d.ts +14 -0
- package/dist/commands/tasks/start.js +31 -1
- package/dist/commands/tasks/update.js +30 -3
- package/dist/commands/whoami.js +5 -3
- package/dist/lib/agent-config.d.ts +15 -0
- package/dist/lib/agent-config.js +46 -10
- package/dist/lib/agentic-stream.d.ts +21 -1
- package/dist/lib/agentic-stream.js +40 -6
- package/dist/lib/auth-failure.js +4 -1
- package/dist/lib/auth-storage.d.ts +24 -0
- package/dist/lib/auth-storage.js +348 -13
- package/dist/lib/cli-id.d.ts +22 -0
- package/dist/lib/cli-id.js +56 -0
- package/dist/lib/cli-identity.d.ts +49 -0
- package/dist/lib/cli-identity.js +159 -0
- package/dist/lib/commitments.d.ts +4 -0
- package/dist/lib/commitments.js +12 -1
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.js +15 -3
- package/dist/lib/cred-envelope.d.ts +12 -1
- package/dist/lib/cred-envelope.js +20 -3
- package/dist/lib/daemonBroker.d.ts +26 -4
- package/dist/lib/daemonBroker.js +82 -10
- package/dist/lib/daemonBrokerRefusal.d.ts +51 -0
- package/dist/lib/daemonBrokerRefusal.js +127 -0
- package/dist/lib/dedicated-machines.d.ts +53 -4
- package/dist/lib/dedicated-machines.js +95 -5
- package/dist/lib/delegated-cli.js +16 -0
- package/dist/lib/first-party-harness-broker.js +6 -4
- package/dist/lib/keychain.d.ts +39 -0
- package/dist/lib/keychain.js +254 -40
- package/dist/lib/label-ref.d.ts +34 -0
- package/dist/lib/label-ref.js +72 -1
- package/dist/lib/login.js +24 -12
- package/dist/lib/oauthLogin.js +2 -8
- package/dist/lib/refresh.js +6 -2
- package/dist/lib/tasks.js +33 -8
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +13 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +9 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +4 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +13 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +8 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +3138 -3100
- package/oclif.manifest.json +26470 -26449
- package/package.json +1 -1
package/dist/lib/auth-storage.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.backends = void 0;
|
|
|
37
37
|
exports.readFromFile = readFromFile;
|
|
38
38
|
exports.deleteFileBackend = deleteFileBackend;
|
|
39
39
|
exports.readFromBackend = readFromBackend;
|
|
40
|
+
exports.describeForeignCredential = describeForeignCredential;
|
|
40
41
|
exports.writeToBackend = writeToBackend;
|
|
41
42
|
exports.clearAllBackends = clearAllBackends;
|
|
42
43
|
exports.normalizeServerOrigin = normalizeServerOrigin;
|
|
@@ -71,6 +72,21 @@ exports.__authFilePathForTest = __authFilePathForTest;
|
|
|
71
72
|
* interactive) and delegates the keychain/file slots to `readFromBackend()`
|
|
72
73
|
* here. We own token persistence; the resolver owns precedence.
|
|
73
74
|
*
|
|
75
|
+
* Every bundle is also bound to the CONFIG that owns it (OSK-12135). The
|
|
76
|
+
* login Keychain is shared by every skrr setup of this macOS user, while the
|
|
77
|
+
* CLI identity (`cliId`) lives in the config root, so another setup with the
|
|
78
|
+
* same profile name could write this profile's slot and the next refresh
|
|
79
|
+
* presented a token bound to its `cliId`. Now:
|
|
80
|
+
*
|
|
81
|
+
* - a bundle carries its owner `{configRoot, cliId}` (Keychain `cli-owner`
|
|
82
|
+
* slot, or `owner` in the file), and the server-bound identity is also read
|
|
83
|
+
* from the access token's `did` claim, which an older writer cannot omit;
|
|
84
|
+
* - a read REFUSES a bundle whose owner is another root or another cliId,
|
|
85
|
+
* with the owner named (`ownerStatus: 'foreign'`), and never deletes it;
|
|
86
|
+
* - storage is namespaced by config root (see `keychain.ts`; the file lives
|
|
87
|
+
* under the config root), and a slot another root may own is only ever a
|
|
88
|
+
* read-side migration source, adopted on POSITIVE evidence of ownership.
|
|
89
|
+
*
|
|
74
90
|
* Cross-process coordination: every write path should run inside
|
|
75
91
|
* `withAuthLock()` from @skrr-ai/auth-core so the daemon, Electron main,
|
|
76
92
|
* and a sibling `skrr` can't race us on a rotation. Callers are responsible
|
|
@@ -84,17 +100,127 @@ const auth_core_1 = require("@skrr-ai/auth-core");
|
|
|
84
100
|
const keychain = __importStar(require("./keychain"));
|
|
85
101
|
const cred_envelope_1 = require("./cred-envelope");
|
|
86
102
|
const config_1 = require("./config");
|
|
103
|
+
const cli_id_1 = require("./cli-id");
|
|
87
104
|
/** Filename for the non-darwin file backend. Parallel to the daemon's
|
|
88
105
|
* `auth.json`, deliberately distinct so the two binaries never clobber
|
|
89
106
|
* each other's tokens (CLI holds scope=cli, daemon holds scope=daemon). */
|
|
90
107
|
const AUTH_FILENAME = 'cli-auth.json';
|
|
91
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* The config root — the same one `cli-config.json` (and so `cliId`) lives in.
|
|
110
|
+
*
|
|
111
|
+
* This was `$HOME/.skrr` regardless of `SKRR_CONFIG_DIR`, so two configs that
|
|
112
|
+
* shared a HOME shared one credential file while each held its own `cliId`.
|
|
113
|
+
* Unchanged for anyone who has not relocated the root.
|
|
114
|
+
*/
|
|
92
115
|
function authDir() {
|
|
93
|
-
return
|
|
116
|
+
return (0, config_1.configRoot)();
|
|
94
117
|
}
|
|
95
118
|
function authFilePath() {
|
|
96
119
|
return path.join(authDir(), AUTH_FILENAME);
|
|
97
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Where the file lived before it followed the config root. Only differs from
|
|
123
|
+
* `authFilePath()` under a root override, and is then a migration SOURCE: read
|
|
124
|
+
* only when its bundle positively belongs to this config, never written.
|
|
125
|
+
*/
|
|
126
|
+
function legacyAuthFilePath() {
|
|
127
|
+
return path.join(os.homedir(), '.skrr', AUTH_FILENAME);
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------
|
|
130
|
+
// Ownership
|
|
131
|
+
// ---------------------------------------------------------------------
|
|
132
|
+
/** Who this process is: its resolved config root and the cliId it presents. */
|
|
133
|
+
function currentOwner() {
|
|
134
|
+
let cliId;
|
|
135
|
+
try {
|
|
136
|
+
// The id this profile PRESENTS — its own when it was issued one (OSK-12156).
|
|
137
|
+
const value = (0, cli_id_1.resolveCliId)((0, config_1.loadConfig)());
|
|
138
|
+
if (value)
|
|
139
|
+
cliId = value;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// A corrupt config cannot tell us who we are; ownership then rests on the
|
|
143
|
+
// config root alone, which is still enough to refuse another root's bundle.
|
|
144
|
+
}
|
|
145
|
+
return { configRoot: path.resolve((0, config_1.configRoot)()), ...(cliId ? { cliId } : {}) };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The CLI identity the SERVER bound this credential to. CLI access tokens carry
|
|
149
|
+
* it as `did` (the refresh row's `daemonId`), so this answers for bundles written
|
|
150
|
+
* by any version — including an older binary that never stored an owner record.
|
|
151
|
+
*
|
|
152
|
+
* Unverified decode, which `jwtUtils` permits for routing and not for trust.
|
|
153
|
+
* This is routing: it picks which LOCAL slot this config may present from. The
|
|
154
|
+
* server still verifies the token and refuses a rotation against another cliId;
|
|
155
|
+
* reading the claim first only stops us presenting a credential that cannot be
|
|
156
|
+
* ours, and anyone able to forge the slot's contents can already write them.
|
|
157
|
+
*/
|
|
158
|
+
function boundCliId(token) {
|
|
159
|
+
const payload = (0, auth_core_1.decodeJwtPayload)(token);
|
|
160
|
+
return payload && typeof payload.did === 'string' && payload.did.length > 0
|
|
161
|
+
? payload.did
|
|
162
|
+
: undefined;
|
|
163
|
+
}
|
|
164
|
+
function parseOwner(raw) {
|
|
165
|
+
let value = raw;
|
|
166
|
+
if (typeof value === 'string') {
|
|
167
|
+
try {
|
|
168
|
+
value = JSON.parse(value);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!value || typeof value !== 'object')
|
|
175
|
+
return undefined;
|
|
176
|
+
const { configRoot: root, cliId } = value;
|
|
177
|
+
const owner = {};
|
|
178
|
+
if (typeof root === 'string' && root.length > 0)
|
|
179
|
+
owner.configRoot = root;
|
|
180
|
+
if (typeof cliId === 'string' && cliId.length > 0)
|
|
181
|
+
owner.cliId = cliId;
|
|
182
|
+
return owner.configRoot || owner.cliId ? owner : undefined;
|
|
183
|
+
}
|
|
184
|
+
function ownerFor(bundle) {
|
|
185
|
+
const me = currentOwner();
|
|
186
|
+
const cliId = boundCliId(bundle.token) ?? me.cliId;
|
|
187
|
+
return { configRoot: me.configRoot, ...(cliId ? { cliId } : {}) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Does this bundle belong to this config?
|
|
191
|
+
*
|
|
192
|
+
* `ownSlot` is whether the bundle came from the slot this root owns. There,
|
|
193
|
+
* the bundle is ours unless there is evidence it is not. From a slot another
|
|
194
|
+
* root may own (a migration source), it is ours only on POSITIVE evidence —
|
|
195
|
+
* a matching cliId or root — or, per the legacy rule, when it names nobody and
|
|
196
|
+
* this is the account's default root.
|
|
197
|
+
*
|
|
198
|
+
* The token's `did` outranks the stored owner record: an older binary writes
|
|
199
|
+
* the token but not `cli-owner`, so a record that disagrees with the token it
|
|
200
|
+
* sits beside is stale and says nothing about the token.
|
|
201
|
+
*/
|
|
202
|
+
function judgeOwnership(bundle, me, ownSlot) {
|
|
203
|
+
const did = boundCliId(bundle.token);
|
|
204
|
+
const record = bundle.owner;
|
|
205
|
+
const recordValid = record && (!did || !record.cliId || record.cliId === did);
|
|
206
|
+
const storedRoot = recordValid && record.configRoot ? path.resolve(record.configRoot) : undefined;
|
|
207
|
+
const storedCliId = did ?? (recordValid ? record.cliId : undefined);
|
|
208
|
+
const stored = {
|
|
209
|
+
...(storedRoot ? { configRoot: storedRoot } : {}),
|
|
210
|
+
...(storedCliId ? { cliId: storedCliId } : {}),
|
|
211
|
+
};
|
|
212
|
+
if (storedRoot && storedRoot !== me.configRoot)
|
|
213
|
+
return { ours: false, stored };
|
|
214
|
+
if (storedCliId && me.cliId && storedCliId !== me.cliId)
|
|
215
|
+
return { ours: false, stored };
|
|
216
|
+
if (ownSlot)
|
|
217
|
+
return { ours: true, stored };
|
|
218
|
+
const positive = (storedCliId !== undefined && storedCliId === me.cliId) || storedRoot === me.configRoot;
|
|
219
|
+
if (positive)
|
|
220
|
+
return { ours: true, stored };
|
|
221
|
+
const namesNobody = !storedCliId && !storedRoot;
|
|
222
|
+
return { ours: namesNobody && keychain.isDefaultConfigRoot(me.configRoot), stored };
|
|
223
|
+
}
|
|
98
224
|
// ---------------------------------------------------------------------
|
|
99
225
|
// File backend
|
|
100
226
|
// ---------------------------------------------------------------------
|
|
@@ -106,7 +232,40 @@ function authFilePath() {
|
|
|
106
232
|
* retiring it. Everything else should keep using `readFromBackend()`.
|
|
107
233
|
*/
|
|
108
234
|
function readFromFile() {
|
|
109
|
-
|
|
235
|
+
return readFileAssessed().bundle;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* This root's file, refused when it belongs to another config; else, under a
|
|
239
|
+
* root override, the pre-override `$HOME/.skrr` file when it is positively ours.
|
|
240
|
+
*/
|
|
241
|
+
function readFileAssessed() {
|
|
242
|
+
const me = currentOwner();
|
|
243
|
+
const own = readFileAt(authFilePath(), true);
|
|
244
|
+
if (own) {
|
|
245
|
+
const judged = judgeOwnership(own, me, true);
|
|
246
|
+
return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
|
|
247
|
+
}
|
|
248
|
+
const legacy = legacyAuthFilePath();
|
|
249
|
+
if (path.resolve(legacy) === path.resolve(authFilePath()))
|
|
250
|
+
return { bundle: null };
|
|
251
|
+
const adopted = readFileAt(legacy, false);
|
|
252
|
+
if (adopted && judgeOwnership(adopted, me, false).ours)
|
|
253
|
+
return { bundle: adopted };
|
|
254
|
+
return { bundle: null };
|
|
255
|
+
}
|
|
256
|
+
/** Is the pre-override file present AND positively this config's? */
|
|
257
|
+
function legacyFileIsOurs() {
|
|
258
|
+
const legacy = legacyAuthFilePath();
|
|
259
|
+
if (path.resolve(legacy) === path.resolve(authFilePath()))
|
|
260
|
+
return false;
|
|
261
|
+
const bundle = readFileAt(legacy, false);
|
|
262
|
+
return Boolean(bundle && judgeOwnership(bundle, currentOwner(), false).ours);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Parse one credential file. `upgradeInPlace` lets the envelope migration
|
|
266
|
+
* rewrite it — true only for this root's own file, never a migration source.
|
|
267
|
+
*/
|
|
268
|
+
function readFileAt(p, upgradeInPlace) {
|
|
110
269
|
if (!fs.existsSync(p))
|
|
111
270
|
return null;
|
|
112
271
|
let raw;
|
|
@@ -129,7 +288,12 @@ function readFromFile() {
|
|
|
129
288
|
// L12 — route the secret string fields through the envelope. Mixed
|
|
130
289
|
// bundles (e.g., legacy plaintext token + freshly-rotated wrapped
|
|
131
290
|
// refresh) decrypt independently so partial migrations stay safe.
|
|
132
|
-
|
|
291
|
+
// Read the (plaintext) owner first, so a value another config root sealed
|
|
292
|
+
// under its own key fails with a sentence that says so (OSK-12135).
|
|
293
|
+
const owner = parseOwner(parsed.owner);
|
|
294
|
+
const ownerRoot = owner?.configRoot ? path.resolve(owner.configRoot) : undefined;
|
|
295
|
+
const decryptOptions = ownerRoot && ownerRoot !== path.resolve((0, config_1.configRoot)()) ? { foreignOwnerRoot: ownerRoot } : {};
|
|
296
|
+
const tokenResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions);
|
|
133
297
|
if (tokenResult.plaintext === null) {
|
|
134
298
|
// Envelope on disk but no DEK to unwrap → treat as missing so the
|
|
135
299
|
// caller falls through to a clean `skrr login`.
|
|
@@ -141,7 +305,7 @@ function readFromFile() {
|
|
|
141
305
|
bundle.expiresAt = parsed.expiresAt;
|
|
142
306
|
}
|
|
143
307
|
if (typeof parsed.refreshToken === 'string' && parsed.refreshToken.length > 0) {
|
|
144
|
-
const refreshResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.refreshToken);
|
|
308
|
+
const refreshResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.refreshToken, decryptOptions);
|
|
145
309
|
if (refreshResult.plaintext !== null) {
|
|
146
310
|
bundle.refreshToken = refreshResult.plaintext;
|
|
147
311
|
if (refreshResult.needsMigration)
|
|
@@ -157,6 +321,8 @@ function readFromFile() {
|
|
|
157
321
|
if (typeof parsed.serverOrigin === 'string' && parsed.serverOrigin.length > 0) {
|
|
158
322
|
bundle.serverOrigin = parsed.serverOrigin;
|
|
159
323
|
}
|
|
324
|
+
if (owner)
|
|
325
|
+
bundle.owner = owner;
|
|
160
326
|
// Best-effort upgrade: re-write the bundle so legacy plaintext fields
|
|
161
327
|
// become wrapped on the next read. A failure here doesn't break the
|
|
162
328
|
// read path — the caller already has the plaintext bundle.
|
|
@@ -186,7 +352,7 @@ function readFromFile() {
|
|
|
186
352
|
// in-process "lock held" guard (AsyncLocalStorage) — but atomic
|
|
187
353
|
// rename already prevents corruption, so the escalation target is
|
|
188
354
|
// IO efficiency only.
|
|
189
|
-
if (needsMigration) {
|
|
355
|
+
if (needsMigration && upgradeInPlace) {
|
|
190
356
|
try {
|
|
191
357
|
writeToFile(bundle);
|
|
192
358
|
}
|
|
@@ -218,6 +384,9 @@ function writeToFile(bundle) {
|
|
|
218
384
|
if (bundle.serverOrigin) {
|
|
219
385
|
wireBundle.serverOrigin = bundle.serverOrigin;
|
|
220
386
|
}
|
|
387
|
+
// Rewrites of an existing bundle (the envelope upgrade) keep its recorded
|
|
388
|
+
// owner; everything else is stamped with this config.
|
|
389
|
+
wireBundle.owner = bundle.owner ?? ownerFor(bundle);
|
|
221
390
|
// tmp + rename so no racing reader ever sees a partial file; chmod 0600
|
|
222
391
|
// on the final file so the same-UID blast-radius is as narrow as the
|
|
223
392
|
// file backend can offer.
|
|
@@ -251,6 +420,17 @@ function writeToFile(bundle) {
|
|
|
251
420
|
*/
|
|
252
421
|
function deleteFileBackend() {
|
|
253
422
|
const p = authFilePath();
|
|
423
|
+
// This root's own file — unless it holds another config's credential, which
|
|
424
|
+
// is not ours to destroy (an older binary of another config can write here).
|
|
425
|
+
const own = readFileAt(p, false);
|
|
426
|
+
if (!own || judgeOwnership(own, currentOwner(), true).ours)
|
|
427
|
+
unlinkQuietly(p);
|
|
428
|
+
// The pre-override migration source, only when it is positively ours:
|
|
429
|
+
// otherwise a sign-out would leave it to be adopted again on the next read.
|
|
430
|
+
if (legacyFileIsOurs())
|
|
431
|
+
unlinkQuietly(legacyAuthFilePath());
|
|
432
|
+
}
|
|
433
|
+
function unlinkQuietly(p) {
|
|
254
434
|
try {
|
|
255
435
|
if (fs.existsSync(p))
|
|
256
436
|
fs.unlinkSync(p);
|
|
@@ -285,11 +465,95 @@ function readFromKeychain() {
|
|
|
285
465
|
const serverOrigin = keychain.readServerOrigin();
|
|
286
466
|
if (serverOrigin)
|
|
287
467
|
bundle.serverOrigin = serverOrigin;
|
|
468
|
+
const owner = parseOwner(keychain.readOwner());
|
|
469
|
+
if (owner)
|
|
470
|
+
bundle.owner = owner;
|
|
471
|
+
return bundle;
|
|
472
|
+
}
|
|
473
|
+
function bundleFromSlots(slots) {
|
|
474
|
+
const bundle = { token: slots.token.token };
|
|
475
|
+
if (slots.token.expiresAt !== undefined)
|
|
476
|
+
bundle.expiresAt = slots.token.expiresAt;
|
|
477
|
+
if (slots.refresh) {
|
|
478
|
+
bundle.refreshToken = slots.refresh.token;
|
|
479
|
+
if (slots.refresh.expiresAt !== undefined)
|
|
480
|
+
bundle.refreshExpiresAt = slots.refresh.expiresAt;
|
|
481
|
+
}
|
|
482
|
+
if (slots.serverOrigin)
|
|
483
|
+
bundle.serverOrigin = slots.serverOrigin;
|
|
484
|
+
const owner = parseOwner(slots.owner);
|
|
485
|
+
if (owner)
|
|
486
|
+
bundle.owner = owner;
|
|
288
487
|
return bundle;
|
|
289
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* Slots this root may adopt from, in order: the legacy profile-only slot, then
|
|
491
|
+
* the slot the default root displaced this config's credential into.
|
|
492
|
+
*/
|
|
493
|
+
function migrationSlots(me) {
|
|
494
|
+
if (keychain.isLegacySlotCurrent())
|
|
495
|
+
return [];
|
|
496
|
+
return [
|
|
497
|
+
{ kind: 'legacy' },
|
|
498
|
+
...(me.cliId ? [{ kind: 'displaced', cliId: me.cliId }] : []),
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* This root's own slot, refused when it belongs to another config; else a
|
|
503
|
+
* migration slot that positively belongs to this config.
|
|
504
|
+
*
|
|
505
|
+
* The migration is READ-ONLY, deliberately. Copying the bundle into our slot
|
|
506
|
+
* here would be a write outside the auth lock, and a copy that lands after a
|
|
507
|
+
* concurrent rotation puts a spent refresh token back — which the server treats
|
|
508
|
+
* as theft and answers by revoking the family. The next locked write (a refresh
|
|
509
|
+
* or a login) lands in our own slot, and from then on it wins.
|
|
510
|
+
*/
|
|
511
|
+
function readKeychainAssessed() {
|
|
512
|
+
if (!keychain.isAvailable())
|
|
513
|
+
return { bundle: null };
|
|
514
|
+
const me = currentOwner();
|
|
515
|
+
const own = readFromKeychain();
|
|
516
|
+
if (own) {
|
|
517
|
+
const judged = judgeOwnership(own, me, true);
|
|
518
|
+
return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
|
|
519
|
+
}
|
|
520
|
+
for (const slot of migrationSlots(me)) {
|
|
521
|
+
const slots = keychain.readBundleAt(slot);
|
|
522
|
+
if (!slots)
|
|
523
|
+
continue;
|
|
524
|
+
const bundle = bundleFromSlots(slots);
|
|
525
|
+
if (judgeOwnership(bundle, me, false).ours)
|
|
526
|
+
return { bundle };
|
|
527
|
+
}
|
|
528
|
+
return { bundle: null };
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Before the default root overwrites its own slot, move a credential another
|
|
532
|
+
* config's older binary wrote there to that config's displaced slot, keyed by
|
|
533
|
+
* the cliId the server bound it to. A login here must not destroy it: once
|
|
534
|
+
* that config runs a current binary it adopts it from there.
|
|
535
|
+
*/
|
|
536
|
+
function rescueForeignCredential(me) {
|
|
537
|
+
if (!keychain.isLegacySlotCurrent())
|
|
538
|
+
return;
|
|
539
|
+
const token = keychain.readToken();
|
|
540
|
+
if (!token)
|
|
541
|
+
return;
|
|
542
|
+
const quick = boundCliId(token.token);
|
|
543
|
+
if (quick && quick === me.cliId)
|
|
544
|
+
return; // the common case: it is ours
|
|
545
|
+
const slots = keychain.readBundleAt({ kind: 'legacy' });
|
|
546
|
+
if (!slots)
|
|
547
|
+
return;
|
|
548
|
+
const judged = judgeOwnership(bundleFromSlots(slots), me, true);
|
|
549
|
+
if (judged.ours || !judged.stored.cliId)
|
|
550
|
+
return;
|
|
551
|
+
keychain.copyBundle({ kind: 'legacy' }, { kind: 'displaced', cliId: judged.stored.cliId });
|
|
552
|
+
}
|
|
290
553
|
function writeToKeychain(bundle) {
|
|
291
554
|
if (!keychain.isAvailable())
|
|
292
555
|
return false;
|
|
556
|
+
rescueForeignCredential(currentOwner());
|
|
293
557
|
const ok = keychain.writeToken(bundle.token, bundle.expiresAt);
|
|
294
558
|
if (!ok)
|
|
295
559
|
return false;
|
|
@@ -311,11 +575,34 @@ function writeToKeychain(bundle) {
|
|
|
311
575
|
// token pair. Origin-aware readers will fail closed until the next login.
|
|
312
576
|
keychain.deleteServerOrigin();
|
|
313
577
|
}
|
|
578
|
+
// Written last, so the token it describes is already in place.
|
|
579
|
+
if (!keychain.writeOwner(JSON.stringify(bundle.owner ?? ownerFor(bundle))))
|
|
580
|
+
return false;
|
|
314
581
|
return true;
|
|
315
582
|
}
|
|
583
|
+
/** This root's own slot, after a failed partial write. */
|
|
316
584
|
function deleteKeychainBackend() {
|
|
317
585
|
keychain.deleteAll();
|
|
318
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* Sign-out for the Keychain: this root's own slot unless it holds another
|
|
589
|
+
* config's credential, plus any migration slot that is positively ours —
|
|
590
|
+
* otherwise a sign-out would leave a bundle the next read adopts again.
|
|
591
|
+
*/
|
|
592
|
+
function clearKeychainBackend() {
|
|
593
|
+
if (!keychain.isAvailable())
|
|
594
|
+
return;
|
|
595
|
+
const me = currentOwner();
|
|
596
|
+
const own = readFromKeychain();
|
|
597
|
+
if (!own || judgeOwnership(own, me, true).ours)
|
|
598
|
+
keychain.deleteAll();
|
|
599
|
+
for (const slot of migrationSlots(me)) {
|
|
600
|
+
const slots = keychain.readBundleAt(slot);
|
|
601
|
+
if (slots && judgeOwnership(bundleFromSlots(slots), me, false).ours) {
|
|
602
|
+
keychain.deleteAllAt(slot);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
319
606
|
// ---------------------------------------------------------------------
|
|
320
607
|
// Public API — backend-selection logic lives here
|
|
321
608
|
// ---------------------------------------------------------------------
|
|
@@ -331,14 +618,21 @@ function deleteKeychainBackend() {
|
|
|
331
618
|
* function returns whatever is persisted.
|
|
332
619
|
*/
|
|
333
620
|
function readFromBackend(expectedServerOrigin) {
|
|
334
|
-
const fromKeychain =
|
|
335
|
-
const keychainResult = assessBundleOrigin(fromKeychain, 'keychain', expectedServerOrigin);
|
|
621
|
+
const fromKeychain = readKeychainAssessed();
|
|
622
|
+
const keychainResult = assessBundleOrigin(fromKeychain.bundle, 'keychain', expectedServerOrigin);
|
|
336
623
|
if (keychainResult.bundle)
|
|
337
624
|
return keychainResult;
|
|
338
|
-
const fromFile =
|
|
339
|
-
const fileResult = assessBundleOrigin(fromFile, 'file', expectedServerOrigin);
|
|
625
|
+
const fromFile = readFileAssessed();
|
|
626
|
+
const fileResult = assessBundleOrigin(fromFile.bundle, 'file', expectedServerOrigin);
|
|
340
627
|
if (fileResult.bundle)
|
|
341
628
|
return fileResult;
|
|
629
|
+
// A bundle that belongs to another skrr config outranks every other
|
|
630
|
+
// diagnostic: it is why this config has no usable credential, and it is the
|
|
631
|
+
// one case where "run login" would otherwise look like it came from nowhere.
|
|
632
|
+
if (fromKeychain.foreign)
|
|
633
|
+
return foreignResult('keychain', fromKeychain.foreign);
|
|
634
|
+
if (fromFile.foreign)
|
|
635
|
+
return foreignResult('file', fromFile.foreign);
|
|
342
636
|
// Prefer the first concrete rejection for diagnostics. A partially-failed
|
|
343
637
|
// Keychain write may leave an unbound token while a valid file fallback is
|
|
344
638
|
// absent; surfacing the status lets BaseCommand explain why it ignored it.
|
|
@@ -359,6 +653,43 @@ function readFromBackend(expectedServerOrigin) {
|
|
|
359
653
|
: {}),
|
|
360
654
|
};
|
|
361
655
|
}
|
|
656
|
+
function foreignResult(backend, storedOwner) {
|
|
657
|
+
const me = currentOwner();
|
|
658
|
+
return {
|
|
659
|
+
bundle: null,
|
|
660
|
+
backend,
|
|
661
|
+
originStatus: 'none',
|
|
662
|
+
ownerStatus: 'foreign',
|
|
663
|
+
storedOwner,
|
|
664
|
+
expectedOwner: me,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* One sentence naming whose credential was refused and why, for the surfaces
|
|
669
|
+
* that explain an absent sign-in (BaseCommand, `skrr doctor`).
|
|
670
|
+
*/
|
|
671
|
+
function describeForeignCredential(result) {
|
|
672
|
+
if (result.ownerStatus !== 'foreign')
|
|
673
|
+
return null;
|
|
674
|
+
const stored = result.storedOwner ?? {};
|
|
675
|
+
const mine = result.expectedOwner ?? {};
|
|
676
|
+
const where = result.backend === 'file' ? 'credential file' : 'Keychain slot';
|
|
677
|
+
const theirs = [
|
|
678
|
+
stored.cliId ? `CLI identity ${stored.cliId}` : null,
|
|
679
|
+
stored.configRoot ? `config root ${stored.configRoot}` : null,
|
|
680
|
+
]
|
|
681
|
+
.filter(Boolean)
|
|
682
|
+
.join(', ');
|
|
683
|
+
const ours = [
|
|
684
|
+
mine.cliId ? `CLI identity ${mine.cliId}` : null,
|
|
685
|
+
mine.configRoot ? `config root ${mine.configRoot}` : null,
|
|
686
|
+
]
|
|
687
|
+
.filter(Boolean)
|
|
688
|
+
.join(', ');
|
|
689
|
+
return (`The credential in this profile's ${where} belongs to another skrr configuration on this ` +
|
|
690
|
+
`machine (${theirs || 'unknown owner'}), not this one (${ours || 'unknown'}). It was not ` +
|
|
691
|
+
`used and has not been deleted.`);
|
|
692
|
+
}
|
|
362
693
|
/**
|
|
363
694
|
* Persist a token bundle to the preferred backend.
|
|
364
695
|
*
|
|
@@ -412,6 +743,9 @@ function writeToBackend(bundle, options = {}) {
|
|
|
412
743
|
}
|
|
413
744
|
persistedBundle = { ...bundle, serverOrigin: normalized };
|
|
414
745
|
}
|
|
746
|
+
// Always stamp THIS config as the owner — a bundle read back and re-written
|
|
747
|
+
// (e.g. `pair`) must not carry a record forward from wherever it came from.
|
|
748
|
+
persistedBundle = { ...persistedBundle, owner: ownerFor(persistedBundle) };
|
|
415
749
|
let backend;
|
|
416
750
|
if (keychain.isAvailable()) {
|
|
417
751
|
if (writeToKeychain(persistedBundle)) {
|
|
@@ -444,7 +778,7 @@ function writeToBackend(bundle, options = {}) {
|
|
|
444
778
|
* auth-failure cleanup). Idempotent.
|
|
445
779
|
*/
|
|
446
780
|
function clearAllBackends() {
|
|
447
|
-
|
|
781
|
+
clearKeychainBackend();
|
|
448
782
|
deleteFileBackend();
|
|
449
783
|
}
|
|
450
784
|
/**
|
|
@@ -468,11 +802,12 @@ function clearAllBackends() {
|
|
|
468
802
|
*/
|
|
469
803
|
exports.backends = {
|
|
470
804
|
readKeychainToken(expectedServerOrigin) {
|
|
471
|
-
const
|
|
805
|
+
const read = readKeychainAssessed();
|
|
806
|
+
const result = assessBundleOrigin(read.bundle, 'keychain', expectedServerOrigin);
|
|
472
807
|
return result.bundle?.token ?? null;
|
|
473
808
|
},
|
|
474
809
|
readFileToken(expectedServerOrigin) {
|
|
475
|
-
const result = assessBundleOrigin(
|
|
810
|
+
const result = assessBundleOrigin(readFileAssessed().bundle, 'file', expectedServerOrigin);
|
|
476
811
|
return result.bundle?.token ?? null;
|
|
477
812
|
},
|
|
478
813
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's `OVERSKY_CLI_ID` / `SKRR_CLI_ID`, when set. The legacy spelling
|
|
3
|
+
* wins when both are set, as it does for every other overlay variable
|
|
4
|
+
* (`config.ts`, `envOverlayValue`); the config overlay reads THIS function.
|
|
5
|
+
*/
|
|
6
|
+
export declare function envCliIdOverride(env?: NodeJS.ProcessEnv): string | undefined;
|
|
7
|
+
/** True when this process runs a named profile rather than the default one. */
|
|
8
|
+
export declare function isNamedProfile(): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Precedence:
|
|
11
|
+
*
|
|
12
|
+
* 1. `OVERSKY_CLI_ID` / `SKRR_CLI_ID` — the operator said so;
|
|
13
|
+
* 2. `profileCliId` — this named profile was issued its own identity;
|
|
14
|
+
* 3. `cliId` — the machine identity, which the default profile and every
|
|
15
|
+
* profile whose session predates profile identities keep presenting.
|
|
16
|
+
*
|
|
17
|
+
* Never mints: a read must not change who this config is.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveCliId(cfg: {
|
|
20
|
+
cliId?: string;
|
|
21
|
+
profileCliId?: string;
|
|
22
|
+
}): string | undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.envCliIdOverride = envCliIdOverride;
|
|
4
|
+
exports.isNamedProfile = isNamedProfile;
|
|
5
|
+
exports.resolveCliId = resolveCliId;
|
|
6
|
+
/**
|
|
7
|
+
* cli-id.ts — THE cliId this process presents (OSK-12156).
|
|
8
|
+
*
|
|
9
|
+
* The one resolver for the identity presented to the server at login, at
|
|
10
|
+
* refresh, to the daemon broker, and as the owner a stored credential is judged
|
|
11
|
+
* against (`auth-storage.ts`). It used to be read as `cfg.cliId` at a dozen call
|
|
12
|
+
* sites and regenerated in three, which is what made it impossible to give one
|
|
13
|
+
* profile its own id without some reader still presenting the machine one.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately depends on nothing but the active profile: it is read by
|
|
16
|
+
* `config.ts` and `auth-storage.ts` alike, so it must not import either.
|
|
17
|
+
* Issuing an id is a separate act, owned by `cli-identity.ts`.
|
|
18
|
+
*/
|
|
19
|
+
const keychain_1 = require("./keychain");
|
|
20
|
+
/**
|
|
21
|
+
* The operator's `OVERSKY_CLI_ID` / `SKRR_CLI_ID`, when set. The legacy spelling
|
|
22
|
+
* wins when both are set, as it does for every other overlay variable
|
|
23
|
+
* (`config.ts`, `envOverlayValue`); the config overlay reads THIS function.
|
|
24
|
+
*/
|
|
25
|
+
function envCliIdOverride(env = process.env) {
|
|
26
|
+
const legacy = env.OVERSKY_CLI_ID?.trim();
|
|
27
|
+
if (legacy)
|
|
28
|
+
return legacy;
|
|
29
|
+
const renamed = env.SKRR_CLI_ID?.trim();
|
|
30
|
+
return renamed ? renamed : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** True when this process runs a named profile rather than the default one. */
|
|
33
|
+
function isNamedProfile() {
|
|
34
|
+
const profile = (0, keychain_1.getActiveProfile)();
|
|
35
|
+
return Boolean(profile) && profile !== keychain_1.DEFAULT_PROFILE;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Precedence:
|
|
39
|
+
*
|
|
40
|
+
* 1. `OVERSKY_CLI_ID` / `SKRR_CLI_ID` — the operator said so;
|
|
41
|
+
* 2. `profileCliId` — this named profile was issued its own identity;
|
|
42
|
+
* 3. `cliId` — the machine identity, which the default profile and every
|
|
43
|
+
* profile whose session predates profile identities keep presenting.
|
|
44
|
+
*
|
|
45
|
+
* Never mints: a read must not change who this config is.
|
|
46
|
+
*/
|
|
47
|
+
function resolveCliId(cfg) {
|
|
48
|
+
const env = envCliIdOverride();
|
|
49
|
+
if (env)
|
|
50
|
+
return env;
|
|
51
|
+
const profileScoped = cfg.profileCliId?.trim();
|
|
52
|
+
if (profileScoped)
|
|
53
|
+
return profileScoped;
|
|
54
|
+
const machine = cfg.cliId?.trim();
|
|
55
|
+
return machine ? machine : undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type CliConfig } from './config';
|
|
2
|
+
/**
|
|
3
|
+
* A named profile's own identity. Deterministic in (host, user, config root,
|
|
4
|
+
* profile), so a profile that loses its config file and signs in again is
|
|
5
|
+
* issued the same id rather than scattering families, and two profiles — or the
|
|
6
|
+
* same profile name under two config roots — never share one. Same `cli_<12hex>`
|
|
7
|
+
* shape as the machine id: the server requires the `cli_` namespace.
|
|
8
|
+
*/
|
|
9
|
+
export declare function profileScopedCliId(profile?: string, root?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Does this profile hold a credential it would present? Its own backend slot
|
|
12
|
+
* (ownership-checked, any origin) or a pre-migration token still in the config.
|
|
13
|
+
*
|
|
14
|
+
* A read that FAILS answers true: "keep the id you already present" is the
|
|
15
|
+
* outcome that cannot log anyone out, so doubt falls on that side.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hasStoredCredential(cfg: CliConfig): boolean;
|
|
18
|
+
export interface LoginCliIdPlan {
|
|
19
|
+
/** The id this login presents. */
|
|
20
|
+
cliId: string;
|
|
21
|
+
/**
|
|
22
|
+
* `env`: the operator's override. `profile`: an id this profile was issued
|
|
23
|
+
* earlier. `issued`: issued now, to a named profile holding no credential.
|
|
24
|
+
* `machine`: the machine id (the default profile, or a profile whose session
|
|
25
|
+
* was minted under it).
|
|
26
|
+
*/
|
|
27
|
+
source: 'env' | 'profile' | 'issued' | 'machine';
|
|
28
|
+
/** Present when the plan chose an id that is not yet on disk; persist it. */
|
|
29
|
+
updatedConfig?: CliConfig;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decide the id a login is about to present. Pure apart from the credential
|
|
33
|
+
* read; the caller persists `updatedConfig` (see `cliIdForLogin`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function planLoginCliId(cfg: CliConfig, deps?: {
|
|
36
|
+
hasStoredCredential?: (cfg: CliConfig) => boolean;
|
|
37
|
+
}): LoginCliIdPlan;
|
|
38
|
+
/**
|
|
39
|
+
* The id an interactive login presents, persisted BEFORE the exchange so a
|
|
40
|
+
* retry — or a sibling process — presents the same one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function cliIdForLogin(cfg?: CliConfig): string;
|
|
43
|
+
/**
|
|
44
|
+
* Record that this profile now presents `cliId` — a restored (refresh token,
|
|
45
|
+
* cliId) pair. A named profile records it as its own identity; writing the
|
|
46
|
+
* machine field from a named profile would re-identify the default profile and
|
|
47
|
+
* every profile still on the machine id, whose next refresh would be refused.
|
|
48
|
+
*/
|
|
49
|
+
export declare function persistPresentedCliId(cfg: CliConfig, cliId: string): void;
|