@skrr-ai/cli 0.1.44 → 0.1.46
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 +13 -1
- package/dist/base-command.js +37 -3
- package/dist/commands/agents/actions/create.js +11 -4
- package/dist/commands/agents/chat.js +3 -4
- package/dist/commands/browser/cloud-agents.d.ts +20 -0
- package/dist/commands/browser/cloud-agents.js +43 -0
- package/dist/commands/code/handover.d.ts +11 -0
- package/dist/commands/code/handover.js +105 -1
- package/dist/commands/commitments/handover.d.ts +29 -0
- package/dist/commands/commitments/handover.js +99 -0
- package/dist/commands/followups/list.js +1 -1
- package/dist/commands/followups/watch.js +40 -5
- package/dist/commands/initiatives/list.d.ts +1 -0
- package/dist/commands/initiatives/list.js +11 -2
- package/dist/commands/labels/list.d.ts +28 -0
- package/dist/commands/labels/list.js +50 -32
- package/dist/commands/tasks/fork.d.ts +32 -0
- package/dist/commands/tasks/fork.js +171 -0
- package/dist/commands/tasks/handover.d.ts +23 -0
- package/dist/commands/tasks/handover.js +205 -0
- package/dist/commands/tasks/promote.d.ts +23 -0
- package/dist/commands/tasks/promote.js +82 -0
- package/dist/commands/tasks/runs.d.ts +16 -0
- package/dist/commands/tasks/runs.js +46 -1
- package/dist/commands/tasks/show.d.ts +23 -0
- package/dist/commands/tasks/show.js +59 -0
- package/dist/lib/agentic-stream.js +41 -2
- package/dist/lib/api-fetch.d.ts +7 -1
- package/dist/lib/api-fetch.js +9 -2
- package/dist/lib/auth-core-init.js +11 -4
- package/dist/lib/code-handover.d.ts +60 -0
- package/dist/lib/code-handover.js +120 -0
- package/dist/lib/commitments.d.ts +22 -0
- package/dist/lib/commitments.js +42 -0
- package/dist/lib/dedicated-wait.js +22 -1
- package/dist/lib/followups.d.ts +18 -0
- package/dist/lib/followups.js +44 -0
- package/dist/lib/initiatives.d.ts +15 -3
- package/dist/lib/label-ref.d.ts +2 -0
- package/dist/lib/node-adapter.js +6 -0
- package/dist/lib/task-extras.d.ts +44 -0
- package/dist/lib/task-extras.js +73 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +29 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +169 -23
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +91 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.d.ts +8 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.js +7 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessHome.js +45 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/harnessSkillLayout.d.ts +65 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/harnessSkillLayout.js +99 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +3 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +26 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/loginLocalhost.js +177 -26
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +119 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +346 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +29 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +169 -24
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +93 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.d.ts +8 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.js +7 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessHome.js +45 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/harnessSkillLayout.d.ts +65 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/harnessSkillLayout.js +94 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +3 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +10 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/loginLocalhost.js +177 -26
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +119 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +333 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +2746 -2711
- package/dist/node_modules/@skrr-ai/inference-broker/package.json +1 -1
- package/oclif.manifest.json +34300 -33843
- package/package.json +2 -2
|
@@ -6,12 +6,27 @@
|
|
|
6
6
|
* implementation. The daemon and CLI both consume this module so they
|
|
7
7
|
* land on the SAME on-disk artifacts:
|
|
8
8
|
*
|
|
9
|
-
* <
|
|
10
|
-
* <
|
|
9
|
+
* <machine root>/profiles/<p>/cred-dek-wrapped — KEK-wrapped DEK
|
|
10
|
+
* <machine root>/profiles/<p>/cred-envelope.lock — proper-lockfile sentinel
|
|
11
11
|
*
|
|
12
|
-
* `<
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* `<machine root>` comes from `profileStateDir.ts`, which derives it from
|
|
13
|
+
* `getAuthMachineConfigDir()`. That indirection is not decoration. This header
|
|
14
|
+
* used to say the paths were built from `configDir` and that "both binaries
|
|
15
|
+
* today resolve to `~/.skrr` so first-enrollment races between `sky` and
|
|
16
|
+
* `oversky` serialize against the same lockfile" — and it had been false for as
|
|
17
|
+
* long as the daemon has been profile-aware. The daemon passes a configDir that
|
|
18
|
+
* is ALREADY `~/.skrr/profiles/<p>`, so it wrote to
|
|
19
|
+
* `~/.skrr/profiles/<p>/profiles/<p>/…` while the CLI wrote to
|
|
20
|
+
* `~/.skrr/profiles/<p>/…`. One profile, two wrapped DEKs, two lockfiles, and
|
|
21
|
+
* the mutex this module exists to share serialized neither process against the
|
|
22
|
+
* other (OSK-10314). Reading the machine root is what makes the sentence above
|
|
23
|
+
* true rather than aspirational.
|
|
24
|
+
*
|
|
25
|
+
* On a machine that already has both, `profileStateDir.ts` keeps each process
|
|
26
|
+
* reading the copy it has been using until the migration has proved the two are
|
|
27
|
+
* equivalent, and refuses to pick a winner when they are not — see that file.
|
|
28
|
+
* The LOCK is always taken at the canonical path regardless, because a mutex
|
|
29
|
+
* that is per-consumer is not a mutex.
|
|
15
30
|
*
|
|
16
31
|
* NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
|
|
17
32
|
* (init + sign + persist + telemetry) from the pure-crypto primitive
|
|
@@ -25,12 +40,14 @@
|
|
|
25
40
|
* when not active. CLI leaves it unset; legacy `OVERSKY_KEK_REQUIRED=1`
|
|
26
41
|
* env var is still honored as a back-compat trigger.
|
|
27
42
|
*/
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
28
44
|
import fs from 'node:fs';
|
|
29
45
|
import path from 'node:path';
|
|
30
46
|
import lockfile from 'proper-lockfile';
|
|
31
47
|
import { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, isEnvelopeString as isCredEnvelopeString, generateDek, serialize as credEnvelopeSerialize, } from './credentialEnvelope.js';
|
|
32
48
|
import { getKekStrategy, zeroizeKekCaches } from './kek/index.js';
|
|
33
|
-
import {
|
|
49
|
+
import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, } from './profileStateDir.js';
|
|
50
|
+
import { emitAuthTelemetry, getAuthBinaryName, getAuthLogger, getCredEnvelopeFailClosed, } from './runtime.js';
|
|
34
51
|
/**
|
|
35
52
|
* Account name the wrapped DEK lives under (file fallback). Keeping the
|
|
36
53
|
* literal here so a future rename surfaces as a single grep target.
|
|
@@ -56,15 +73,15 @@ export function __getStateForTest() {
|
|
|
56
73
|
return _state;
|
|
57
74
|
}
|
|
58
75
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
76
|
+
* The wrapped DEK this process must read and write for `profile`.
|
|
77
|
+
*
|
|
78
|
+
* Canonical on any machine that has only ever had one location, and on one that
|
|
79
|
+
* has been consolidated. On a machine that still has two, this returns the copy
|
|
80
|
+
* this process has been using — see `profileStateDir.ts` for why swapping it out
|
|
81
|
+
* from under a daemon that has sealed credentials with it is not an option.
|
|
62
82
|
*/
|
|
63
|
-
function profileConfigDir(profile) {
|
|
64
|
-
return path.join(getAuthConfigDir(), 'profiles', profile);
|
|
65
|
-
}
|
|
66
83
|
function wrappedDekFilePath(profile) {
|
|
67
|
-
return
|
|
84
|
+
return resolveProfileStateFile(profile, ACCOUNT_CRED_DEK_WRAPPED).path;
|
|
68
85
|
}
|
|
69
86
|
/**
|
|
70
87
|
* Atomic 0600 write — tmp+fsync+rename+dir-fsync. Same shape as the
|
|
@@ -131,8 +148,15 @@ const CRED_LOCK_STALE_MS = 30_000;
|
|
|
131
148
|
const CRED_LOCK_RETRY_MS = 100;
|
|
132
149
|
// Exponential retry capped at 400 ms: 50 retries are ~20 s total, not 5 s.
|
|
133
150
|
const CRED_LOCK_MAX_RETRIES = 50;
|
|
151
|
+
/**
|
|
152
|
+
* ALWAYS the canonical directory, even when this process is reading a legacy
|
|
153
|
+
* wrapped DEK. The lock's job is to serialize first enrollment for one profile
|
|
154
|
+
* on one machine across both binaries; resolving it the way the DEK resolves
|
|
155
|
+
* would give each consumer its own sentinel, which is how the mutex documented
|
|
156
|
+
* in this file's header came to protect nobody.
|
|
157
|
+
*/
|
|
134
158
|
function credEnvelopeLockPath(profile) {
|
|
135
|
-
return path.join(
|
|
159
|
+
return path.join(profileStateDir(profile), 'cred-envelope.lock');
|
|
136
160
|
}
|
|
137
161
|
/**
|
|
138
162
|
* Acquire the cross-process credential-envelope lock for a profile, run
|
|
@@ -183,6 +207,94 @@ async function withCredEnvelopeLock(profile, body) {
|
|
|
183
207
|
}
|
|
184
208
|
}
|
|
185
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Sticky record of the last consolidation refusal for this process — two usable
|
|
212
|
+
* and DIFFERENT wrapped DEKs for one profile. Surfaced through
|
|
213
|
+
* `describeCredEnvelopeState()` so `skrr status` / `oversky status` can say so:
|
|
214
|
+
* the split cannot be repaired here (each side's secrets live in its own
|
|
215
|
+
* keychain namespace, sealed under its own DEK, and re-sealing them is not
|
|
216
|
+
* auth-core's to do), so being loud about it IS the remedy.
|
|
217
|
+
*/
|
|
218
|
+
let _pathConflict = null;
|
|
219
|
+
/**
|
|
220
|
+
* Identity of a wrapped DEK: the SHA-256 of the key it unwraps to.
|
|
221
|
+
*
|
|
222
|
+
* Not of the file. `kek.wrap` draws a fresh nonce, so wrapping ONE key twice
|
|
223
|
+
* gives two different files — comparing bytes would call two copies of the same
|
|
224
|
+
* key a conflict and refuse a migration that was safe. `unwrapExisting` is the
|
|
225
|
+
* read-only probe: it must never mint a KEK as a side effect of asking whether
|
|
226
|
+
* an old file is still readable.
|
|
227
|
+
*/
|
|
228
|
+
async function wrappedDekIdentity(target, kek, aad) {
|
|
229
|
+
let dek = null;
|
|
230
|
+
try {
|
|
231
|
+
if (!kek.unwrapExisting)
|
|
232
|
+
return null;
|
|
233
|
+
dek = await kek.unwrapExisting(fs.readFileSync(target), aad);
|
|
234
|
+
if (dek.length !== 32)
|
|
235
|
+
return null;
|
|
236
|
+
return createHash('sha256').update(dek).digest('hex');
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
dek?.fill(0);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Run the OSK-10314 consolidation for this profile's wrapped DEK.
|
|
247
|
+
*
|
|
248
|
+
* Under the canonical lock, because the window it closes is real: a daemon
|
|
249
|
+
* copying its legacy DEK into an empty canonical slot must not race a CLI
|
|
250
|
+
* enrolling a fresh one there — the loser's key would be overwritten and
|
|
251
|
+
* anything already sealed under it lost. That is the same race the lock was
|
|
252
|
+
* introduced for, one layer up.
|
|
253
|
+
*
|
|
254
|
+
* Never fatal. A migration that cannot run leaves the process exactly where it
|
|
255
|
+
* was, which is the state it has been operating in all along.
|
|
256
|
+
*/
|
|
257
|
+
async function consolidateWrappedDek(profile, kek, aad) {
|
|
258
|
+
const resolved = resolveProfileStateFile(profile, ACCOUNT_CRED_DEK_WRAPPED);
|
|
259
|
+
if (!resolved.usingLegacy) {
|
|
260
|
+
_pathConflict = null;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let outcome;
|
|
264
|
+
try {
|
|
265
|
+
outcome = await withCredEnvelopeLock(profile, () => consolidateProfileState(profile, {
|
|
266
|
+
files: [ACCOUNT_CRED_DEK_WRAPPED],
|
|
267
|
+
primary: ACCOUNT_CRED_DEK_WRAPPED,
|
|
268
|
+
identify: (dir) => wrappedDekIdentity(path.join(dir, ACCOUNT_CRED_DEK_WRAPPED), kek, aad),
|
|
269
|
+
onEvent: (name, success, meta) => emit(`cred_envelope.${name}`, success, meta),
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
emit('cred_envelope.path_consolidation.failed', false, {
|
|
274
|
+
profile,
|
|
275
|
+
message: err?.message ?? 'unknown',
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (outcome.status !== 'conflict') {
|
|
280
|
+
_pathConflict = null;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
_pathConflict = {
|
|
284
|
+
canonicalPath: path.join(outcome.canonicalDir, ACCOUNT_CRED_DEK_WRAPPED),
|
|
285
|
+
legacyPath: path.join(outcome.legacyDir ?? '', ACCOUNT_CRED_DEK_WRAPPED),
|
|
286
|
+
};
|
|
287
|
+
// The paths go in the MESSAGE, not only the metadata: a log pipeline that
|
|
288
|
+
// drops structured fields would otherwise render this as an unactionable
|
|
289
|
+
// noun. Naming the remedy matters too — logging out on one side deletes that
|
|
290
|
+
// side's key and lets the next start adopt the other.
|
|
291
|
+
getAuthLogger().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs; ` +
|
|
292
|
+
`refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
|
|
293
|
+
`${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
|
|
294
|
+
"credentials, and neither can read the other's. To converge, log out on one " +
|
|
295
|
+
`side (\`${getAuthBinaryName()} logout\` here) and log back in; the surviving key ` +
|
|
296
|
+
'is then adopted by both.');
|
|
297
|
+
}
|
|
186
298
|
/**
|
|
187
299
|
* Initialize the envelope path. Idempotent — second call when state is
|
|
188
300
|
* non-uninit returns immediately.
|
|
@@ -230,6 +342,11 @@ export async function initCredEnvelope(profile) {
|
|
|
230
342
|
return;
|
|
231
343
|
}
|
|
232
344
|
const aad = buildCredentialAad(profile);
|
|
345
|
+
// OSK-10314 — converge this profile's two possible locations BEFORE any path
|
|
346
|
+
// is resolved, so the rest of init sees one answer. A no-op costing one string
|
|
347
|
+
// comparison on a machine that only ever had one location (every CLI process,
|
|
348
|
+
// and every daemon installed after this change).
|
|
349
|
+
await consolidateWrappedDek(profile, kek, aad);
|
|
233
350
|
const targetPath = wrappedDekFilePath(profile);
|
|
234
351
|
// Capture the kek's tier-aware kind once at init so transform-side events
|
|
235
352
|
// can include it without holding a live `kek` reference. macOS sets `kind`
|
|
@@ -378,18 +495,29 @@ export async function initCredEnvelope(profile) {
|
|
|
378
495
|
* Reset state. With `clearOnDisk` deletes the wrapped DEK file too —
|
|
379
496
|
* used by `oversky logout` / `sky logout`. Zeroes the in-memory DEK
|
|
380
497
|
* before dropping state for forward secrecy on the active credential.
|
|
498
|
+
*
|
|
499
|
+
* It deletes EVERY location this profile's key can occupy, plus the
|
|
500
|
+
* consolidation marker. `profileStateDir.ts` never deletes anything and says so;
|
|
501
|
+
* this is the one caller that must, and the reason is not symmetry — a logout
|
|
502
|
+
* that removed only the canonical copy would leave the pre-consolidation copy
|
|
503
|
+
* behind, and the next init would adopt it straight back. That is a deleted
|
|
504
|
+
* credential key resurrecting itself, which is a worse bug than the one this
|
|
505
|
+
* whole change is fixing.
|
|
381
506
|
*/
|
|
382
507
|
export async function resetCredEnvelope(opts) {
|
|
383
508
|
if (opts?.clearOnDisk && opts.profile) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
509
|
+
for (const target of profileStateFilePathsToClear(opts.profile, ACCOUNT_CRED_DEK_WRAPPED)) {
|
|
510
|
+
try {
|
|
511
|
+
fs.unlinkSync(target);
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
if (err.code !== 'ENOENT') {
|
|
515
|
+
emit('cred_envelope.clear_failed', false, {
|
|
516
|
+
profile: opts.profile,
|
|
517
|
+
path: target,
|
|
518
|
+
message: err?.message ?? 'unknown',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
393
521
|
}
|
|
394
522
|
}
|
|
395
523
|
}
|
|
@@ -398,6 +526,7 @@ export async function resetCredEnvelope(opts) {
|
|
|
398
526
|
}
|
|
399
527
|
_state = { kind: 'uninit' };
|
|
400
528
|
_kekRotationDetected = false;
|
|
529
|
+
_pathConflict = null;
|
|
401
530
|
}
|
|
402
531
|
/**
|
|
403
532
|
* Idempotent shutdown — must be called from the daemon's graceful-exit
|
|
@@ -435,6 +564,7 @@ export function shutdownCredEnvelope() {
|
|
|
435
564
|
}
|
|
436
565
|
_state = { kind: 'uninit' };
|
|
437
566
|
_kekRotationDetected = false;
|
|
567
|
+
_pathConflict = null;
|
|
438
568
|
}
|
|
439
569
|
/** @internal Test seam — re-arm `shutdownCredEnvelope` so it can run again. */
|
|
440
570
|
export function __resetShutdownGuardForTest() {
|
|
@@ -447,6 +577,19 @@ export function __resetShutdownGuardForTest() {
|
|
|
447
577
|
export function isCredEnvelopeActive() {
|
|
448
578
|
return _state.kind === 'active';
|
|
449
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* True while this profile holds two usable and different wrapped DEKs.
|
|
582
|
+
*
|
|
583
|
+
* Read by `deviceIdentityBridge` before it migrates anything of its own. The
|
|
584
|
+
* device private key is sealed with whichever DEK is active, so moving it to the
|
|
585
|
+
* canonical directory while the two binaries are still on DIFFERENT DEKs hands
|
|
586
|
+
* the other one a key it cannot open — it regenerates, overwrites, and the two
|
|
587
|
+
* processes then destroy each other's device identity on every start. Unresolved
|
|
588
|
+
* conflicts freeze the whole profile's layout, not just the DEK's.
|
|
589
|
+
*/
|
|
590
|
+
export function hasCredEnvelopePathConflict() {
|
|
591
|
+
return _pathConflict !== null;
|
|
592
|
+
}
|
|
450
593
|
/**
|
|
451
594
|
* Operator-readable summary of envelope state. Used by `oversky status`
|
|
452
595
|
* and CLI debug commands. Never includes the DEK or any key material.
|
|
@@ -462,6 +605,7 @@ export function describeCredEnvelopeState() {
|
|
|
462
605
|
kind: 'disabled',
|
|
463
606
|
reason: _state.reason,
|
|
464
607
|
...(_kekRotationDetected ? { kekRotated: true } : {}),
|
|
608
|
+
...(_pathConflict ? { pathConflict: _pathConflict } : {}),
|
|
465
609
|
};
|
|
466
610
|
}
|
|
467
611
|
if (_state.kind === 'active') {
|
|
@@ -471,9 +615,10 @@ export function describeCredEnvelopeState() {
|
|
|
471
615
|
kekKind: _state.kekKind,
|
|
472
616
|
profile: _state.profile,
|
|
473
617
|
...(_kekRotationDetected ? { kekRotated: true } : {}),
|
|
618
|
+
...(_pathConflict ? { pathConflict: _pathConflict } : {}),
|
|
474
619
|
};
|
|
475
620
|
}
|
|
476
|
-
return { kind: 'uninit' };
|
|
621
|
+
return { kind: 'uninit', ...(_pathConflict ? { pathConflict: _pathConflict } : {}) };
|
|
477
622
|
}
|
|
478
623
|
/**
|
|
479
624
|
* Sync write transform. Returns the wrapped serialized form when active.
|
|
@@ -6,15 +6,25 @@
|
|
|
6
6
|
* implementation. Daemon and CLI both consume this module so they land
|
|
7
7
|
* on the SAME on-disk artifacts:
|
|
8
8
|
*
|
|
9
|
-
* <
|
|
10
|
-
* <
|
|
11
|
-
* <
|
|
9
|
+
* <machine root>/profiles/<p>/device-public-key.jwk — plaintext JWK
|
|
10
|
+
* <machine root>/profiles/<p>/device-private-key — L12-wrapped JSON
|
|
11
|
+
* <machine root>/profiles/<p>/device-enrollment.json — server-ack marker
|
|
12
12
|
*
|
|
13
13
|
* Architectural decision (locked): CLI shares the daemon's keypair on
|
|
14
14
|
* the same machine. Same machine = same device identity. The scope
|
|
15
15
|
* discriminator is the JWT claim (`scope: 'cli'` vs `scope: 'daemon'`),
|
|
16
16
|
* not the key.
|
|
17
17
|
*
|
|
18
|
+
* That decision was locked and then quietly broken. This file spelled the
|
|
19
|
+
* directory `path.join(getAuthConfigDir(), 'profiles', profile)` — the same
|
|
20
|
+
* expression `credentialEnvelopeBridge` spelled independently — and the daemon's
|
|
21
|
+
* `configDir` is already profile-scoped, so the two consumers minted two
|
|
22
|
+
* keypairs per profile and neither noticed (OSK-10314). The directory now comes
|
|
23
|
+
* from `profileStateDir.ts`, and the four files below resolve as a GROUP: a
|
|
24
|
+
* public JWK from one copy beside a private key from the other is a keypair
|
|
25
|
+
* mismatch, which `loadKeypair` correctly rejects and would then paper over by
|
|
26
|
+
* regenerating.
|
|
27
|
+
*
|
|
18
28
|
* NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
|
|
19
29
|
* (init + sign + enroll + telemetry) from the pure-crypto primitive
|
|
20
30
|
* file `deviceKey.ts` it composes.
|
|
@@ -22,12 +32,25 @@
|
|
|
22
32
|
import fs from 'node:fs';
|
|
23
33
|
import path from 'node:path';
|
|
24
34
|
import { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, } from './deviceKey.js';
|
|
25
|
-
import { isCredEnvelopeActive, maybeDecryptOnRead, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
|
|
26
|
-
import {
|
|
35
|
+
import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
|
|
36
|
+
import { consolidateProfileState, profileStateFilePathsToClear, resolveProfileStateDir, } from './profileStateDir.js';
|
|
37
|
+
import { emitAuthTelemetry, getAuthLogger } from './runtime.js';
|
|
27
38
|
const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
|
|
28
39
|
const PRIVATE_KEY_FILENAME = 'device-private-key';
|
|
29
40
|
const ENROLLMENT_MARKER_FILENAME = 'device-enrollment.json';
|
|
30
41
|
const ENROLLMENT_BACKOFF_FILENAME = 'device-enrollment-backoff.json';
|
|
42
|
+
/**
|
|
43
|
+
* The device identity's files, in the order they are copied by a migration.
|
|
44
|
+
* The public JWK goes first so a reader that catches the directory mid-copy sees
|
|
45
|
+
* a public key without a private one — which `loadKeypair` treats as "no
|
|
46
|
+
* keypair" — rather than a private key it will pair with a stale public one.
|
|
47
|
+
*/
|
|
48
|
+
const DEVICE_IDENTITY_FILES = [
|
|
49
|
+
PUBLIC_KEY_FILENAME,
|
|
50
|
+
PRIVATE_KEY_FILENAME,
|
|
51
|
+
ENROLLMENT_MARKER_FILENAME,
|
|
52
|
+
ENROLLMENT_BACKOFF_FILENAME,
|
|
53
|
+
];
|
|
31
54
|
// A missing Daemon row cannot become enrollable until registration changes
|
|
32
55
|
// server state. Persist the negative result across short-lived `sky` processes
|
|
33
56
|
// so every CLI command does not repeat the same guaranteed 404.
|
|
@@ -43,8 +66,13 @@ export function __setStateForTest(next) {
|
|
|
43
66
|
export function __getStateForTest() {
|
|
44
67
|
return _state;
|
|
45
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The directory holding this profile's device identity, anchored on the PRIVATE
|
|
71
|
+
* key: that is the file `loadKeypair` cannot proceed without, so it is the one
|
|
72
|
+
* that decides which copy the group is read from.
|
|
73
|
+
*/
|
|
46
74
|
function profileConfigDir(profile) {
|
|
47
|
-
return
|
|
75
|
+
return resolveProfileStateDir(profile, PRIVATE_KEY_FILENAME).dir;
|
|
48
76
|
}
|
|
49
77
|
function publicKeyPath(profile) {
|
|
50
78
|
return path.join(profileConfigDir(profile), PUBLIC_KEY_FILENAME);
|
|
@@ -195,6 +223,55 @@ function loadKeypair(profile) {
|
|
|
195
223
|
thumbprint: recomputed,
|
|
196
224
|
};
|
|
197
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Identity of a device-identity directory: the thumbprint of the PLAINTEXT
|
|
228
|
+
* public JWK.
|
|
229
|
+
*
|
|
230
|
+
* The private key is envelope-wrapped with a random nonce, so its bytes say
|
|
231
|
+
* nothing about whether two copies are the same key. The public JWK is written
|
|
232
|
+
* in the clear and is derived from the private one, so it answers the question
|
|
233
|
+
* exactly and without needing the DEK.
|
|
234
|
+
*/
|
|
235
|
+
function deviceIdentityOf(dir) {
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, PUBLIC_KEY_FILENAME), 'utf-8'));
|
|
238
|
+
if (!parsed.publicJwk || typeof parsed.publicJwk.x !== 'string')
|
|
239
|
+
return null;
|
|
240
|
+
return jwkThumbprint(parsed.publicJwk);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Run the OSK-10314 consolidation for this profile's device identity.
|
|
248
|
+
*
|
|
249
|
+
* Refuses outright while the credential envelope is holding two different DEKs:
|
|
250
|
+
* see `hasCredEnvelopePathConflict`. Never fatal — a failed migration leaves the
|
|
251
|
+
* process reading exactly what it read before.
|
|
252
|
+
*/
|
|
253
|
+
async function consolidateDeviceIdentity(profile) {
|
|
254
|
+
if (!resolveProfileStateDir(profile, PRIVATE_KEY_FILENAME).usingLegacy)
|
|
255
|
+
return;
|
|
256
|
+
if (hasCredEnvelopePathConflict()) {
|
|
257
|
+
emit('path_consolidation.deferred', true, { profile, reason: 'cred_envelope_path_conflict' }, { logLevel: 'info' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
await consolidateProfileState(profile, {
|
|
262
|
+
files: DEVICE_IDENTITY_FILES,
|
|
263
|
+
primary: PRIVATE_KEY_FILENAME,
|
|
264
|
+
identify: deviceIdentityOf,
|
|
265
|
+
onEvent: (name, success, meta) => emit(name, success, meta),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
emit('path_consolidation.failed', false, {
|
|
270
|
+
profile,
|
|
271
|
+
message: err?.message ?? 'unknown',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
198
275
|
/**
|
|
199
276
|
* Initialize device identity. Idempotent.
|
|
200
277
|
*
|
|
@@ -213,6 +290,11 @@ export async function initDeviceIdentity(profile) {
|
|
|
213
290
|
emit('init_failed', false, { reason: 'empty_profile' });
|
|
214
291
|
return;
|
|
215
292
|
}
|
|
293
|
+
// OSK-10314 — converge the two directories this profile may have before
|
|
294
|
+
// resolving any of the four files. Must run after `initCredEnvelope`, which
|
|
295
|
+
// both consumers already guarantee, because the private key's readability
|
|
296
|
+
// depends on which DEK ended up active.
|
|
297
|
+
await consolidateDeviceIdentity(profile);
|
|
216
298
|
let keypair = null;
|
|
217
299
|
try {
|
|
218
300
|
keypair = loadKeypair(profile);
|
|
@@ -280,7 +362,11 @@ export async function initDeviceIdentity(profile) {
|
|
|
280
362
|
*/
|
|
281
363
|
export async function resetDeviceIdentity(opts) {
|
|
282
364
|
if (opts?.clearOnDisk && opts.profile) {
|
|
283
|
-
|
|
365
|
+
// Every location, not just the resolved one. A reset that cleared only the
|
|
366
|
+
// canonical copy would leave the pre-consolidation copy for the next init to
|
|
367
|
+
// adopt — a deleted device key that reappears.
|
|
368
|
+
const targets = [PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => profileStateFilePathsToClear(opts.profile, name));
|
|
369
|
+
for (const p of targets) {
|
|
284
370
|
try {
|
|
285
371
|
fs.unlinkSync(p);
|
|
286
372
|
}
|
|
@@ -313,6 +313,13 @@ declare const ENGINE_ENV_SUFFIXES: Readonly<{
|
|
|
313
313
|
allowUpstreamEgress: "ALLOW_UPSTREAM_EGRESS";
|
|
314
314
|
compatOpencode: "COMPAT_OPENCODE";
|
|
315
315
|
noCompatPrompts: "NO_COMPAT_PROMPTS";
|
|
316
|
+
/**
|
|
317
|
+
* Advisory input-token budget for a managed session, set by the daemon from
|
|
318
|
+
* the server-issued `requestEnvelope.maxInputTokens` so the engine's
|
|
319
|
+
* auto-compaction can trigger before the relay refuses the request. The
|
|
320
|
+
* engine must treat it as a hint: enforcement stays server-side.
|
|
321
|
+
*/
|
|
322
|
+
maxInputTokens: "MAX_INPUT_TOKENS";
|
|
316
323
|
}>;
|
|
317
324
|
export type FirstPartyHarnessEngineEnvKey = keyof typeof ENGINE_ENV_SUFFIXES;
|
|
318
325
|
/**
|
|
@@ -350,7 +357,7 @@ export declare const FIRST_PARTY_HARNESS_ENGINE: Readonly<{
|
|
|
350
357
|
* `readFirstPartyHarnessEngineEnv` to read one the way the engine does and
|
|
351
358
|
* `firstPartyHarnessEngineEnvEntries` to set one every engine build honours.
|
|
352
359
|
*/
|
|
353
|
-
env: Readonly<Record<"appName" | "serverPassword" | "serverUsername" | "home" | "config" | "scriptName" | "allowUpstreamEgress" | "compatOpencode" | "noCompatPrompts", string>>;
|
|
360
|
+
env: Readonly<Record<"appName" | "serverPassword" | "serverUsername" | "home" | "config" | "scriptName" | "allowUpstreamEgress" | "compatOpencode" | "noCompatPrompts" | "maxInputTokens", string>>;
|
|
354
361
|
}>;
|
|
355
362
|
/**
|
|
356
363
|
* Every name the engine reads for one purpose, current namespace first — the
|
|
@@ -415,6 +415,13 @@ const ENGINE_ENV_SUFFIXES = Object.freeze({
|
|
|
415
415
|
allowUpstreamEgress: 'ALLOW_UPSTREAM_EGRESS',
|
|
416
416
|
compatOpencode: 'COMPAT_OPENCODE',
|
|
417
417
|
noCompatPrompts: 'NO_COMPAT_PROMPTS',
|
|
418
|
+
/**
|
|
419
|
+
* Advisory input-token budget for a managed session, set by the daemon from
|
|
420
|
+
* the server-issued `requestEnvelope.maxInputTokens` so the engine's
|
|
421
|
+
* auto-compaction can trigger before the relay refuses the request. The
|
|
422
|
+
* engine must treat it as a hint: enforcement stays server-side.
|
|
423
|
+
*/
|
|
424
|
+
maxInputTokens: 'MAX_INPUT_TOKENS',
|
|
418
425
|
});
|
|
419
426
|
function engineEnvName(appName, key) {
|
|
420
427
|
return `${appName.toUpperCase().replace(/-/g, '_')}_${ENGINE_ENV_SUFFIXES[key]}`;
|
|
@@ -53,6 +53,13 @@ import { FIRST_PARTY_HARNESS, FIRST_PARTY_HARNESS_ENGINE, firstPartyHarnessArtif
|
|
|
53
53
|
function olderSpellings() {
|
|
54
54
|
return firstPartyHarnessArtifactSpellings().filter((spelling) => spelling !== FIRST_PARTY_HARNESS.provider);
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Entries rescued from a legacy home when the canonical one already exists as a
|
|
58
|
+
* shell. `bin` is what managed resolution needs; `skills` is the user's own
|
|
59
|
+
* data. Deliberately a short, named list rather than "everything": a merge this
|
|
60
|
+
* module cannot enumerate (there is no readdir here) would be guessing.
|
|
61
|
+
*/
|
|
62
|
+
const STRANDED_HOME_ENTRIES = ['bin', 'skills'];
|
|
56
63
|
/** Suffix of the rollback copy the installer keeps beside the binary. */
|
|
57
64
|
export const FIRST_PARTY_HARNESS_PREVIOUS_SUFFIX = '.previous';
|
|
58
65
|
/**
|
|
@@ -175,6 +182,44 @@ export function migrateFirstPartyHarnessHome(options = {}) {
|
|
|
175
182
|
break;
|
|
176
183
|
}
|
|
177
184
|
}
|
|
185
|
+
else if (isRealDirectory(fs.lstat(canonicalHome))) {
|
|
186
|
+
// 1b. The canonical home EXISTS, so the rename above is impossible — but it
|
|
187
|
+
// may be a shell rather than the real home. A managed write that reaches
|
|
188
|
+
// `<root>/<appName>` before this migration runs creates the directory, and
|
|
189
|
+
// from then on step 1 skips forever and the legacy home keeps the binary.
|
|
190
|
+
// Observed on a real machine: `<root>/<appName>` holding a 2-byte `{}`
|
|
191
|
+
// config written hours after `<root>/<legacy appName>/bin` received the
|
|
192
|
+
// signed 107MB engine, which left managed resolution pointing at a bin
|
|
193
|
+
// directory that did not exist and every managed session with no engine to
|
|
194
|
+
// spawn.
|
|
195
|
+
//
|
|
196
|
+
// A rename cannot merge two directories, and this module has no readdir, so
|
|
197
|
+
// this moves the two entries that carry weight and nothing else: `bin`,
|
|
198
|
+
// which is what resolution needs, and `skills`, which is user data. An
|
|
199
|
+
// entry already present at the canonical home is never touched — the shell
|
|
200
|
+
// may be empty, but it is not automatically wrong.
|
|
201
|
+
for (const legacyAppName of FIRST_PARTY_HARNESS_ENGINE.legacyAppNames) {
|
|
202
|
+
const aliasHome = path.join(root, legacyAppName);
|
|
203
|
+
if (!isRealDirectory(fs.lstat(aliasHome)))
|
|
204
|
+
continue;
|
|
205
|
+
for (const entry of STRANDED_HOME_ENTRIES) {
|
|
206
|
+
const from = path.join(aliasHome, entry);
|
|
207
|
+
const to = path.join(canonicalHome, entry);
|
|
208
|
+
if (!isRealDirectory(fs.lstat(from)))
|
|
209
|
+
continue;
|
|
210
|
+
if (fs.lstat(to) !== null)
|
|
211
|
+
continue;
|
|
212
|
+
try {
|
|
213
|
+
fs.rename(from, to);
|
|
214
|
+
moved.push(`${from} -> ${to}`);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
errors.push(`could not move ${from} to ${to}: ${describe(err)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
178
223
|
// 2. The binary and its companions — follow the provider — inside the home.
|
|
179
224
|
const binDir = path.join(canonicalHome, 'bin');
|
|
180
225
|
if (!isRealDirectory(fs.lstat(binDir))) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSK-10409 — which harnesses resolve the `.claude/skills/<name>/SKILL.md`
|
|
3
|
+
* layout, shared by the server and the daemon.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this lives in auth-core ────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The same reason `harnessTrust.ts` does: it is a DECLARED per-harness fact
|
|
8
|
+
* that several processes have to agree on, and the cost of them disagreeing is
|
|
9
|
+
* silent. It is not auth, and it does not pretend to be — auth-core is where
|
|
10
|
+
* this repo already keeps the small tables that a harness's identity answers.
|
|
11
|
+
*
|
|
12
|
+
* ── What the fact IS ───────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* The daemon materializes a session's skills at
|
|
15
|
+
* `<root>/.claude/skills/<name>/SKILL.md` and hands `<root>` to the harness —
|
|
16
|
+
* `--add-dir` for the Claude CLI, ACP `additionalDirectories` for Devin. A
|
|
17
|
+
* harness on this list READS that layout: it enumerates the tree, surfaces the
|
|
18
|
+
* manifest line, and resolves the body from disk. A harness that is not on it
|
|
19
|
+
* ignores the tree entirely, so nothing laid there ever reaches its model.
|
|
20
|
+
*
|
|
21
|
+
* It is a capability with EVIDENCE, never a brand check. The gate this replaced
|
|
22
|
+
* read `provider !== 'claude'` on the stated premise that every other harness
|
|
23
|
+
* ignores the layout, and the premise was false for Devin (OSK-10264): the
|
|
24
|
+
* daemon lays the same tree, passes it on `additionalDirectories`, and Devin
|
|
25
|
+
* loads it — measured live on 3000.10.31. Add a harness here only with the same
|
|
26
|
+
* kind of evidence, that its sessions actually surface a skill laid out this
|
|
27
|
+
* way. A harness that ignores the layout gains nothing and pays for the wire.
|
|
28
|
+
*
|
|
29
|
+
* ── Three readers, in three directions ─────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* 1. The server's PUSH gate (`buildDaemonServerSkills`): a harness on this
|
|
32
|
+
* list is one a pushed skill should be sent to.
|
|
33
|
+
* 2. The server's INVOKED-BODY gate (`buildInvokedSkillsPrompt`, OSK-10361):
|
|
34
|
+
* a harness on this list must NOT also have the body pasted into its
|
|
35
|
+
* prompt — it already has it from the tree, and on ACP the paste lands in
|
|
36
|
+
* the transcript at full skill size on every invoked turn.
|
|
37
|
+
* 3. The daemon's SHADOWING protection (`skill-shadowing.ts`, OSK-10044): a
|
|
38
|
+
* harness on this list resolves `/<name>` from its own `.claude/skills`
|
|
39
|
+
* roots, so a picked skill can be shadowed there by a same-named one — and
|
|
40
|
+
* the daemon is the only place that collision can be seen. That gate spelled
|
|
41
|
+
* `claude` and so never reached Devin, which the server-side paste was
|
|
42
|
+
* accidentally masking until (2) stopped pasting.
|
|
43
|
+
*
|
|
44
|
+
* Reader 3 is why the table moved out of the API: the daemon cannot require an
|
|
45
|
+
* api-side module, and a second copy of a table whose whole value is that every
|
|
46
|
+
* reader agrees is worse than no table at all.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Harness provider → the evidence that its sessions load the layout.
|
|
50
|
+
*
|
|
51
|
+
* The reason string is part of the entry, not decoration: an entry added
|
|
52
|
+
* without one is an entry nobody can audit later.
|
|
53
|
+
*/
|
|
54
|
+
export declare const HARNESSES_LOADING_CLAUDE_SKILLS_LAYOUT: ReadonlyMap<string, string>;
|
|
55
|
+
/**
|
|
56
|
+
* Whether this harness resolves a skill from the `.claude/skills` tree the
|
|
57
|
+
* daemon materializes, so the body reaches it without being pasted anywhere.
|
|
58
|
+
*
|
|
59
|
+
* An absent provider is NOT a native resolver. "We do not know what this is"
|
|
60
|
+
* must never mean "it reads the layout" — that direction withholds the body
|
|
61
|
+
* from a harness that needed it, which is a turn that looks like the skill ran.
|
|
62
|
+
*/
|
|
63
|
+
export declare function harnessLoadsClaudeSkillsLayout(provider: string | null | undefined): boolean;
|
|
64
|
+
/** The stated evidence for a harness on the list, or `null` when it is not. */
|
|
65
|
+
export declare function claudeSkillsLayoutEvidence(provider: string | null | undefined): string | null;
|