@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
|
@@ -10,6 +10,7 @@ exports.resetCredEnvelope = resetCredEnvelope;
|
|
|
10
10
|
exports.shutdownCredEnvelope = shutdownCredEnvelope;
|
|
11
11
|
exports.__resetShutdownGuardForTest = __resetShutdownGuardForTest;
|
|
12
12
|
exports.isCredEnvelopeActive = isCredEnvelopeActive;
|
|
13
|
+
exports.hasCredEnvelopePathConflict = hasCredEnvelopePathConflict;
|
|
13
14
|
exports.describeCredEnvelopeState = describeCredEnvelopeState;
|
|
14
15
|
exports.maybeEncryptForWrite = maybeEncryptForWrite;
|
|
15
16
|
exports.wrapIfActiveOrPassthrough = wrapIfActiveOrPassthrough;
|
|
@@ -23,12 +24,27 @@ exports.__wrappedDekFilePathForTest = __wrappedDekFilePathForTest;
|
|
|
23
24
|
* implementation. The daemon and CLI both consume this module so they
|
|
24
25
|
* land on the SAME on-disk artifacts:
|
|
25
26
|
*
|
|
26
|
-
* <
|
|
27
|
-
* <
|
|
27
|
+
* <machine root>/profiles/<p>/cred-dek-wrapped — KEK-wrapped DEK
|
|
28
|
+
* <machine root>/profiles/<p>/cred-envelope.lock — proper-lockfile sentinel
|
|
28
29
|
*
|
|
29
|
-
* `<
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* `<machine root>` comes from `profileStateDir.ts`, which derives it from
|
|
31
|
+
* `getAuthMachineConfigDir()`. That indirection is not decoration. This header
|
|
32
|
+
* used to say the paths were built from `configDir` and that "both binaries
|
|
33
|
+
* today resolve to `~/.skrr` so first-enrollment races between `sky` and
|
|
34
|
+
* `oversky` serialize against the same lockfile" — and it had been false for as
|
|
35
|
+
* long as the daemon has been profile-aware. The daemon passes a configDir that
|
|
36
|
+
* is ALREADY `~/.skrr/profiles/<p>`, so it wrote to
|
|
37
|
+
* `~/.skrr/profiles/<p>/profiles/<p>/…` while the CLI wrote to
|
|
38
|
+
* `~/.skrr/profiles/<p>/…`. One profile, two wrapped DEKs, two lockfiles, and
|
|
39
|
+
* the mutex this module exists to share serialized neither process against the
|
|
40
|
+
* other (OSK-10314). Reading the machine root is what makes the sentence above
|
|
41
|
+
* true rather than aspirational.
|
|
42
|
+
*
|
|
43
|
+
* On a machine that already has both, `profileStateDir.ts` keeps each process
|
|
44
|
+
* reading the copy it has been using until the migration has proved the two are
|
|
45
|
+
* equivalent, and refuses to pick a winner when they are not — see that file.
|
|
46
|
+
* The LOCK is always taken at the canonical path regardless, because a mutex
|
|
47
|
+
* that is per-consumer is not a mutex.
|
|
32
48
|
*
|
|
33
49
|
* NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
|
|
34
50
|
* (init + sign + persist + telemetry) from the pure-crypto primitive
|
|
@@ -42,11 +58,13 @@ exports.__wrappedDekFilePathForTest = __wrappedDekFilePathForTest;
|
|
|
42
58
|
* when not active. CLI leaves it unset; legacy `OVERSKY_KEK_REQUIRED=1`
|
|
43
59
|
* env var is still honored as a back-compat trigger.
|
|
44
60
|
*/
|
|
61
|
+
const node_crypto_1 = require("node:crypto");
|
|
45
62
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
46
63
|
const node_path_1 = __importDefault(require("node:path"));
|
|
47
64
|
const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
|
|
48
65
|
const credentialEnvelope_js_1 = require("./credentialEnvelope.js");
|
|
49
66
|
const index_js_1 = require("./kek/index.js");
|
|
67
|
+
const profileStateDir_js_1 = require("./profileStateDir.js");
|
|
50
68
|
const runtime_js_1 = require("./runtime.js");
|
|
51
69
|
/**
|
|
52
70
|
* Account name the wrapped DEK lives under (file fallback). Keeping the
|
|
@@ -73,15 +91,15 @@ function __getStateForTest() {
|
|
|
73
91
|
return _state;
|
|
74
92
|
}
|
|
75
93
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
94
|
+
* The wrapped DEK this process must read and write for `profile`.
|
|
95
|
+
*
|
|
96
|
+
* Canonical on any machine that has only ever had one location, and on one that
|
|
97
|
+
* has been consolidated. On a machine that still has two, this returns the copy
|
|
98
|
+
* this process has been using — see `profileStateDir.ts` for why swapping it out
|
|
99
|
+
* from under a daemon that has sealed credentials with it is not an option.
|
|
79
100
|
*/
|
|
80
|
-
function profileConfigDir(profile) {
|
|
81
|
-
return node_path_1.default.join((0, runtime_js_1.getAuthConfigDir)(), 'profiles', profile);
|
|
82
|
-
}
|
|
83
101
|
function wrappedDekFilePath(profile) {
|
|
84
|
-
return
|
|
102
|
+
return (0, profileStateDir_js_1.resolveProfileStateFile)(profile, ACCOUNT_CRED_DEK_WRAPPED).path;
|
|
85
103
|
}
|
|
86
104
|
/**
|
|
87
105
|
* Atomic 0600 write — tmp+fsync+rename+dir-fsync. Same shape as the
|
|
@@ -148,8 +166,15 @@ const CRED_LOCK_STALE_MS = 30_000;
|
|
|
148
166
|
const CRED_LOCK_RETRY_MS = 100;
|
|
149
167
|
// Exponential retry capped at 400 ms: 50 retries are ~20 s total, not 5 s.
|
|
150
168
|
const CRED_LOCK_MAX_RETRIES = 50;
|
|
169
|
+
/**
|
|
170
|
+
* ALWAYS the canonical directory, even when this process is reading a legacy
|
|
171
|
+
* wrapped DEK. The lock's job is to serialize first enrollment for one profile
|
|
172
|
+
* on one machine across both binaries; resolving it the way the DEK resolves
|
|
173
|
+
* would give each consumer its own sentinel, which is how the mutex documented
|
|
174
|
+
* in this file's header came to protect nobody.
|
|
175
|
+
*/
|
|
151
176
|
function credEnvelopeLockPath(profile) {
|
|
152
|
-
return node_path_1.default.join(
|
|
177
|
+
return node_path_1.default.join((0, profileStateDir_js_1.profileStateDir)(profile), 'cred-envelope.lock');
|
|
153
178
|
}
|
|
154
179
|
/**
|
|
155
180
|
* Acquire the cross-process credential-envelope lock for a profile, run
|
|
@@ -200,6 +225,94 @@ async function withCredEnvelopeLock(profile, body) {
|
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Sticky record of the last consolidation refusal for this process — two usable
|
|
230
|
+
* and DIFFERENT wrapped DEKs for one profile. Surfaced through
|
|
231
|
+
* `describeCredEnvelopeState()` so `skrr status` / `oversky status` can say so:
|
|
232
|
+
* the split cannot be repaired here (each side's secrets live in its own
|
|
233
|
+
* keychain namespace, sealed under its own DEK, and re-sealing them is not
|
|
234
|
+
* auth-core's to do), so being loud about it IS the remedy.
|
|
235
|
+
*/
|
|
236
|
+
let _pathConflict = null;
|
|
237
|
+
/**
|
|
238
|
+
* Identity of a wrapped DEK: the SHA-256 of the key it unwraps to.
|
|
239
|
+
*
|
|
240
|
+
* Not of the file. `kek.wrap` draws a fresh nonce, so wrapping ONE key twice
|
|
241
|
+
* gives two different files — comparing bytes would call two copies of the same
|
|
242
|
+
* key a conflict and refuse a migration that was safe. `unwrapExisting` is the
|
|
243
|
+
* read-only probe: it must never mint a KEK as a side effect of asking whether
|
|
244
|
+
* an old file is still readable.
|
|
245
|
+
*/
|
|
246
|
+
async function wrappedDekIdentity(target, kek, aad) {
|
|
247
|
+
let dek = null;
|
|
248
|
+
try {
|
|
249
|
+
if (!kek.unwrapExisting)
|
|
250
|
+
return null;
|
|
251
|
+
dek = await kek.unwrapExisting(node_fs_1.default.readFileSync(target), aad);
|
|
252
|
+
if (dek.length !== 32)
|
|
253
|
+
return null;
|
|
254
|
+
return (0, node_crypto_1.createHash)('sha256').update(dek).digest('hex');
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
dek?.fill(0);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Run the OSK-10314 consolidation for this profile's wrapped DEK.
|
|
265
|
+
*
|
|
266
|
+
* Under the canonical lock, because the window it closes is real: a daemon
|
|
267
|
+
* copying its legacy DEK into an empty canonical slot must not race a CLI
|
|
268
|
+
* enrolling a fresh one there — the loser's key would be overwritten and
|
|
269
|
+
* anything already sealed under it lost. That is the same race the lock was
|
|
270
|
+
* introduced for, one layer up.
|
|
271
|
+
*
|
|
272
|
+
* Never fatal. A migration that cannot run leaves the process exactly where it
|
|
273
|
+
* was, which is the state it has been operating in all along.
|
|
274
|
+
*/
|
|
275
|
+
async function consolidateWrappedDek(profile, kek, aad) {
|
|
276
|
+
const resolved = (0, profileStateDir_js_1.resolveProfileStateFile)(profile, ACCOUNT_CRED_DEK_WRAPPED);
|
|
277
|
+
if (!resolved.usingLegacy) {
|
|
278
|
+
_pathConflict = null;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
let outcome;
|
|
282
|
+
try {
|
|
283
|
+
outcome = await withCredEnvelopeLock(profile, () => (0, profileStateDir_js_1.consolidateProfileState)(profile, {
|
|
284
|
+
files: [ACCOUNT_CRED_DEK_WRAPPED],
|
|
285
|
+
primary: ACCOUNT_CRED_DEK_WRAPPED,
|
|
286
|
+
identify: (dir) => wrappedDekIdentity(node_path_1.default.join(dir, ACCOUNT_CRED_DEK_WRAPPED), kek, aad),
|
|
287
|
+
onEvent: (name, success, meta) => emit(`cred_envelope.${name}`, success, meta),
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
emit('cred_envelope.path_consolidation.failed', false, {
|
|
292
|
+
profile,
|
|
293
|
+
message: err?.message ?? 'unknown',
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (outcome.status !== 'conflict') {
|
|
298
|
+
_pathConflict = null;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
_pathConflict = {
|
|
302
|
+
canonicalPath: node_path_1.default.join(outcome.canonicalDir, ACCOUNT_CRED_DEK_WRAPPED),
|
|
303
|
+
legacyPath: node_path_1.default.join(outcome.legacyDir ?? '', ACCOUNT_CRED_DEK_WRAPPED),
|
|
304
|
+
};
|
|
305
|
+
// The paths go in the MESSAGE, not only the metadata: a log pipeline that
|
|
306
|
+
// drops structured fields would otherwise render this as an unactionable
|
|
307
|
+
// noun. Naming the remedy matters too — logging out on one side deletes that
|
|
308
|
+
// side's key and lets the next start adopt the other.
|
|
309
|
+
(0, runtime_js_1.getAuthLogger)().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs; ` +
|
|
310
|
+
`refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
|
|
311
|
+
`${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
|
|
312
|
+
"credentials, and neither can read the other's. To converge, log out on one " +
|
|
313
|
+
`side (\`${(0, runtime_js_1.getAuthBinaryName)()} logout\` here) and log back in; the surviving key ` +
|
|
314
|
+
'is then adopted by both.');
|
|
315
|
+
}
|
|
203
316
|
/**
|
|
204
317
|
* Initialize the envelope path. Idempotent — second call when state is
|
|
205
318
|
* non-uninit returns immediately.
|
|
@@ -247,6 +360,11 @@ async function initCredEnvelope(profile) {
|
|
|
247
360
|
return;
|
|
248
361
|
}
|
|
249
362
|
const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(profile);
|
|
363
|
+
// OSK-10314 — converge this profile's two possible locations BEFORE any path
|
|
364
|
+
// is resolved, so the rest of init sees one answer. A no-op costing one string
|
|
365
|
+
// comparison on a machine that only ever had one location (every CLI process,
|
|
366
|
+
// and every daemon installed after this change).
|
|
367
|
+
await consolidateWrappedDek(profile, kek, aad);
|
|
250
368
|
const targetPath = wrappedDekFilePath(profile);
|
|
251
369
|
// Capture the kek's tier-aware kind once at init so transform-side events
|
|
252
370
|
// can include it without holding a live `kek` reference. macOS sets `kind`
|
|
@@ -395,18 +513,29 @@ async function initCredEnvelope(profile) {
|
|
|
395
513
|
* Reset state. With `clearOnDisk` deletes the wrapped DEK file too —
|
|
396
514
|
* used by `oversky logout` / `sky logout`. Zeroes the in-memory DEK
|
|
397
515
|
* before dropping state for forward secrecy on the active credential.
|
|
516
|
+
*
|
|
517
|
+
* It deletes EVERY location this profile's key can occupy, plus the
|
|
518
|
+
* consolidation marker. `profileStateDir.ts` never deletes anything and says so;
|
|
519
|
+
* this is the one caller that must, and the reason is not symmetry — a logout
|
|
520
|
+
* that removed only the canonical copy would leave the pre-consolidation copy
|
|
521
|
+
* behind, and the next init would adopt it straight back. That is a deleted
|
|
522
|
+
* credential key resurrecting itself, which is a worse bug than the one this
|
|
523
|
+
* whole change is fixing.
|
|
398
524
|
*/
|
|
399
525
|
async function resetCredEnvelope(opts) {
|
|
400
526
|
if (opts?.clearOnDisk && opts.profile) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
527
|
+
for (const target of (0, profileStateDir_js_1.profileStateFilePathsToClear)(opts.profile, ACCOUNT_CRED_DEK_WRAPPED)) {
|
|
528
|
+
try {
|
|
529
|
+
node_fs_1.default.unlinkSync(target);
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
if (err.code !== 'ENOENT') {
|
|
533
|
+
emit('cred_envelope.clear_failed', false, {
|
|
534
|
+
profile: opts.profile,
|
|
535
|
+
path: target,
|
|
536
|
+
message: err?.message ?? 'unknown',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
410
539
|
}
|
|
411
540
|
}
|
|
412
541
|
}
|
|
@@ -415,6 +544,7 @@ async function resetCredEnvelope(opts) {
|
|
|
415
544
|
}
|
|
416
545
|
_state = { kind: 'uninit' };
|
|
417
546
|
_kekRotationDetected = false;
|
|
547
|
+
_pathConflict = null;
|
|
418
548
|
}
|
|
419
549
|
/**
|
|
420
550
|
* Idempotent shutdown — must be called from the daemon's graceful-exit
|
|
@@ -452,6 +582,7 @@ function shutdownCredEnvelope() {
|
|
|
452
582
|
}
|
|
453
583
|
_state = { kind: 'uninit' };
|
|
454
584
|
_kekRotationDetected = false;
|
|
585
|
+
_pathConflict = null;
|
|
455
586
|
}
|
|
456
587
|
/** @internal Test seam — re-arm `shutdownCredEnvelope` so it can run again. */
|
|
457
588
|
function __resetShutdownGuardForTest() {
|
|
@@ -464,6 +595,19 @@ function __resetShutdownGuardForTest() {
|
|
|
464
595
|
function isCredEnvelopeActive() {
|
|
465
596
|
return _state.kind === 'active';
|
|
466
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* True while this profile holds two usable and different wrapped DEKs.
|
|
600
|
+
*
|
|
601
|
+
* Read by `deviceIdentityBridge` before it migrates anything of its own. The
|
|
602
|
+
* device private key is sealed with whichever DEK is active, so moving it to the
|
|
603
|
+
* canonical directory while the two binaries are still on DIFFERENT DEKs hands
|
|
604
|
+
* the other one a key it cannot open — it regenerates, overwrites, and the two
|
|
605
|
+
* processes then destroy each other's device identity on every start. Unresolved
|
|
606
|
+
* conflicts freeze the whole profile's layout, not just the DEK's.
|
|
607
|
+
*/
|
|
608
|
+
function hasCredEnvelopePathConflict() {
|
|
609
|
+
return _pathConflict !== null;
|
|
610
|
+
}
|
|
467
611
|
/**
|
|
468
612
|
* Operator-readable summary of envelope state. Used by `oversky status`
|
|
469
613
|
* and CLI debug commands. Never includes the DEK or any key material.
|
|
@@ -479,6 +623,7 @@ function describeCredEnvelopeState() {
|
|
|
479
623
|
kind: 'disabled',
|
|
480
624
|
reason: _state.reason,
|
|
481
625
|
...(_kekRotationDetected ? { kekRotated: true } : {}),
|
|
626
|
+
...(_pathConflict ? { pathConflict: _pathConflict } : {}),
|
|
482
627
|
};
|
|
483
628
|
}
|
|
484
629
|
if (_state.kind === 'active') {
|
|
@@ -488,9 +633,10 @@ function describeCredEnvelopeState() {
|
|
|
488
633
|
kekKind: _state.kekKind,
|
|
489
634
|
profile: _state.profile,
|
|
490
635
|
...(_kekRotationDetected ? { kekRotated: true } : {}),
|
|
636
|
+
...(_pathConflict ? { pathConflict: _pathConflict } : {}),
|
|
491
637
|
};
|
|
492
638
|
}
|
|
493
|
-
return { kind: 'uninit' };
|
|
639
|
+
return { kind: 'uninit', ...(_pathConflict ? { pathConflict: _pathConflict } : {}) };
|
|
494
640
|
}
|
|
495
641
|
/**
|
|
496
642
|
* Sync write transform. Returns the wrapped serialized form when active.
|
|
@@ -25,15 +25,25 @@ exports.enrollWithServer = enrollWithServer;
|
|
|
25
25
|
* implementation. Daemon and CLI both consume this module so they land
|
|
26
26
|
* on the SAME on-disk artifacts:
|
|
27
27
|
*
|
|
28
|
-
* <
|
|
29
|
-
* <
|
|
30
|
-
* <
|
|
28
|
+
* <machine root>/profiles/<p>/device-public-key.jwk — plaintext JWK
|
|
29
|
+
* <machine root>/profiles/<p>/device-private-key — L12-wrapped JSON
|
|
30
|
+
* <machine root>/profiles/<p>/device-enrollment.json — server-ack marker
|
|
31
31
|
*
|
|
32
32
|
* Architectural decision (locked): CLI shares the daemon's keypair on
|
|
33
33
|
* the same machine. Same machine = same device identity. The scope
|
|
34
34
|
* discriminator is the JWT claim (`scope: 'cli'` vs `scope: 'daemon'`),
|
|
35
35
|
* not the key.
|
|
36
36
|
*
|
|
37
|
+
* That decision was locked and then quietly broken. This file spelled the
|
|
38
|
+
* directory `path.join(getAuthConfigDir(), 'profiles', profile)` — the same
|
|
39
|
+
* expression `credentialEnvelopeBridge` spelled independently — and the daemon's
|
|
40
|
+
* `configDir` is already profile-scoped, so the two consumers minted two
|
|
41
|
+
* keypairs per profile and neither noticed (OSK-10314). The directory now comes
|
|
42
|
+
* from `profileStateDir.ts`, and the four files below resolve as a GROUP: a
|
|
43
|
+
* public JWK from one copy beside a private key from the other is a keypair
|
|
44
|
+
* mismatch, which `loadKeypair` correctly rejects and would then paper over by
|
|
45
|
+
* regenerating.
|
|
46
|
+
*
|
|
37
47
|
* NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
|
|
38
48
|
* (init + sign + enroll + telemetry) from the pure-crypto primitive
|
|
39
49
|
* file `deviceKey.ts` it composes.
|
|
@@ -42,11 +52,24 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
42
52
|
const node_path_1 = __importDefault(require("node:path"));
|
|
43
53
|
const deviceKey_js_1 = require("./deviceKey.js");
|
|
44
54
|
const credentialEnvelopeBridge_js_1 = require("./credentialEnvelopeBridge.js");
|
|
55
|
+
const profileStateDir_js_1 = require("./profileStateDir.js");
|
|
45
56
|
const runtime_js_1 = require("./runtime.js");
|
|
46
57
|
const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
|
|
47
58
|
const PRIVATE_KEY_FILENAME = 'device-private-key';
|
|
48
59
|
const ENROLLMENT_MARKER_FILENAME = 'device-enrollment.json';
|
|
49
60
|
const ENROLLMENT_BACKOFF_FILENAME = 'device-enrollment-backoff.json';
|
|
61
|
+
/**
|
|
62
|
+
* The device identity's files, in the order they are copied by a migration.
|
|
63
|
+
* The public JWK goes first so a reader that catches the directory mid-copy sees
|
|
64
|
+
* a public key without a private one — which `loadKeypair` treats as "no
|
|
65
|
+
* keypair" — rather than a private key it will pair with a stale public one.
|
|
66
|
+
*/
|
|
67
|
+
const DEVICE_IDENTITY_FILES = [
|
|
68
|
+
PUBLIC_KEY_FILENAME,
|
|
69
|
+
PRIVATE_KEY_FILENAME,
|
|
70
|
+
ENROLLMENT_MARKER_FILENAME,
|
|
71
|
+
ENROLLMENT_BACKOFF_FILENAME,
|
|
72
|
+
];
|
|
50
73
|
// A missing Daemon row cannot become enrollable until registration changes
|
|
51
74
|
// server state. Persist the negative result across short-lived `sky` processes
|
|
52
75
|
// so every CLI command does not repeat the same guaranteed 404.
|
|
@@ -62,8 +85,13 @@ function __setStateForTest(next) {
|
|
|
62
85
|
function __getStateForTest() {
|
|
63
86
|
return _state;
|
|
64
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The directory holding this profile's device identity, anchored on the PRIVATE
|
|
90
|
+
* key: that is the file `loadKeypair` cannot proceed without, so it is the one
|
|
91
|
+
* that decides which copy the group is read from.
|
|
92
|
+
*/
|
|
65
93
|
function profileConfigDir(profile) {
|
|
66
|
-
return
|
|
94
|
+
return (0, profileStateDir_js_1.resolveProfileStateDir)(profile, PRIVATE_KEY_FILENAME).dir;
|
|
67
95
|
}
|
|
68
96
|
function publicKeyPath(profile) {
|
|
69
97
|
return node_path_1.default.join(profileConfigDir(profile), PUBLIC_KEY_FILENAME);
|
|
@@ -214,6 +242,55 @@ function loadKeypair(profile) {
|
|
|
214
242
|
thumbprint: recomputed,
|
|
215
243
|
};
|
|
216
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Identity of a device-identity directory: the thumbprint of the PLAINTEXT
|
|
247
|
+
* public JWK.
|
|
248
|
+
*
|
|
249
|
+
* The private key is envelope-wrapped with a random nonce, so its bytes say
|
|
250
|
+
* nothing about whether two copies are the same key. The public JWK is written
|
|
251
|
+
* in the clear and is derived from the private one, so it answers the question
|
|
252
|
+
* exactly and without needing the DEK.
|
|
253
|
+
*/
|
|
254
|
+
function deviceIdentityOf(dir) {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, PUBLIC_KEY_FILENAME), 'utf-8'));
|
|
257
|
+
if (!parsed.publicJwk || typeof parsed.publicJwk.x !== 'string')
|
|
258
|
+
return null;
|
|
259
|
+
return (0, deviceKey_js_1.jwkThumbprint)(parsed.publicJwk);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Run the OSK-10314 consolidation for this profile's device identity.
|
|
267
|
+
*
|
|
268
|
+
* Refuses outright while the credential envelope is holding two different DEKs:
|
|
269
|
+
* see `hasCredEnvelopePathConflict`. Never fatal — a failed migration leaves the
|
|
270
|
+
* process reading exactly what it read before.
|
|
271
|
+
*/
|
|
272
|
+
async function consolidateDeviceIdentity(profile) {
|
|
273
|
+
if (!(0, profileStateDir_js_1.resolveProfileStateDir)(profile, PRIVATE_KEY_FILENAME).usingLegacy)
|
|
274
|
+
return;
|
|
275
|
+
if ((0, credentialEnvelopeBridge_js_1.hasCredEnvelopePathConflict)()) {
|
|
276
|
+
emit('path_consolidation.deferred', true, { profile, reason: 'cred_envelope_path_conflict' }, { logLevel: 'info' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await (0, profileStateDir_js_1.consolidateProfileState)(profile, {
|
|
281
|
+
files: DEVICE_IDENTITY_FILES,
|
|
282
|
+
primary: PRIVATE_KEY_FILENAME,
|
|
283
|
+
identify: deviceIdentityOf,
|
|
284
|
+
onEvent: (name, success, meta) => emit(name, success, meta),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
emit('path_consolidation.failed', false, {
|
|
289
|
+
profile,
|
|
290
|
+
message: err?.message ?? 'unknown',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
217
294
|
/**
|
|
218
295
|
* Initialize device identity. Idempotent.
|
|
219
296
|
*
|
|
@@ -232,6 +309,11 @@ async function initDeviceIdentity(profile) {
|
|
|
232
309
|
emit('init_failed', false, { reason: 'empty_profile' });
|
|
233
310
|
return;
|
|
234
311
|
}
|
|
312
|
+
// OSK-10314 — converge the two directories this profile may have before
|
|
313
|
+
// resolving any of the four files. Must run after `initCredEnvelope`, which
|
|
314
|
+
// both consumers already guarantee, because the private key's readability
|
|
315
|
+
// depends on which DEK ended up active.
|
|
316
|
+
await consolidateDeviceIdentity(profile);
|
|
235
317
|
let keypair = null;
|
|
236
318
|
try {
|
|
237
319
|
keypair = loadKeypair(profile);
|
|
@@ -299,7 +381,11 @@ async function initDeviceIdentity(profile) {
|
|
|
299
381
|
*/
|
|
300
382
|
async function resetDeviceIdentity(opts) {
|
|
301
383
|
if (opts?.clearOnDisk && opts.profile) {
|
|
302
|
-
|
|
384
|
+
// Every location, not just the resolved one. A reset that cleared only the
|
|
385
|
+
// canonical copy would leave the pre-consolidation copy for the next init to
|
|
386
|
+
// adopt — a deleted device key that reappears.
|
|
387
|
+
const targets = [PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => (0, profileStateDir_js_1.profileStateFilePathsToClear)(opts.profile, name));
|
|
388
|
+
for (const p of targets) {
|
|
303
389
|
try {
|
|
304
390
|
node_fs_1.default.unlinkSync(p);
|
|
305
391
|
}
|
|
@@ -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
|
|
@@ -450,6 +450,13 @@ const ENGINE_ENV_SUFFIXES = Object.freeze({
|
|
|
450
450
|
allowUpstreamEgress: 'ALLOW_UPSTREAM_EGRESS',
|
|
451
451
|
compatOpencode: 'COMPAT_OPENCODE',
|
|
452
452
|
noCompatPrompts: 'NO_COMPAT_PROMPTS',
|
|
453
|
+
/**
|
|
454
|
+
* Advisory input-token budget for a managed session, set by the daemon from
|
|
455
|
+
* the server-issued `requestEnvelope.maxInputTokens` so the engine's
|
|
456
|
+
* auto-compaction can trigger before the relay refuses the request. The
|
|
457
|
+
* engine must treat it as a hint: enforcement stays server-side.
|
|
458
|
+
*/
|
|
459
|
+
maxInputTokens: 'MAX_INPUT_TOKENS',
|
|
453
460
|
});
|
|
454
461
|
function engineEnvName(appName, key) {
|
|
455
462
|
return `${appName.toUpperCase().replace(/-/g, '_')}_${ENGINE_ENV_SUFFIXES[key]}`;
|
|
@@ -64,6 +64,13 @@ const firstPartyHarness_js_1 = require("./firstPartyHarness.js");
|
|
|
64
64
|
function olderSpellings() {
|
|
65
65
|
return (0, firstPartyHarness_js_1.firstPartyHarnessArtifactSpellings)().filter((spelling) => spelling !== firstPartyHarness_js_1.FIRST_PARTY_HARNESS.provider);
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Entries rescued from a legacy home when the canonical one already exists as a
|
|
69
|
+
* shell. `bin` is what managed resolution needs; `skills` is the user's own
|
|
70
|
+
* data. Deliberately a short, named list rather than "everything": a merge this
|
|
71
|
+
* module cannot enumerate (there is no readdir here) would be guessing.
|
|
72
|
+
*/
|
|
73
|
+
const STRANDED_HOME_ENTRIES = ['bin', 'skills'];
|
|
67
74
|
/** Suffix of the rollback copy the installer keeps beside the binary. */
|
|
68
75
|
exports.FIRST_PARTY_HARNESS_PREVIOUS_SUFFIX = '.previous';
|
|
69
76
|
/**
|
|
@@ -186,6 +193,44 @@ function migrateFirstPartyHarnessHome(options = {}) {
|
|
|
186
193
|
break;
|
|
187
194
|
}
|
|
188
195
|
}
|
|
196
|
+
else if (isRealDirectory(fs.lstat(canonicalHome))) {
|
|
197
|
+
// 1b. The canonical home EXISTS, so the rename above is impossible — but it
|
|
198
|
+
// may be a shell rather than the real home. A managed write that reaches
|
|
199
|
+
// `<root>/<appName>` before this migration runs creates the directory, and
|
|
200
|
+
// from then on step 1 skips forever and the legacy home keeps the binary.
|
|
201
|
+
// Observed on a real machine: `<root>/<appName>` holding a 2-byte `{}`
|
|
202
|
+
// config written hours after `<root>/<legacy appName>/bin` received the
|
|
203
|
+
// signed 107MB engine, which left managed resolution pointing at a bin
|
|
204
|
+
// directory that did not exist and every managed session with no engine to
|
|
205
|
+
// spawn.
|
|
206
|
+
//
|
|
207
|
+
// A rename cannot merge two directories, and this module has no readdir, so
|
|
208
|
+
// this moves the two entries that carry weight and nothing else: `bin`,
|
|
209
|
+
// which is what resolution needs, and `skills`, which is user data. An
|
|
210
|
+
// entry already present at the canonical home is never touched — the shell
|
|
211
|
+
// may be empty, but it is not automatically wrong.
|
|
212
|
+
for (const legacyAppName of firstPartyHarness_js_1.FIRST_PARTY_HARNESS_ENGINE.legacyAppNames) {
|
|
213
|
+
const aliasHome = node_path_1.default.join(root, legacyAppName);
|
|
214
|
+
if (!isRealDirectory(fs.lstat(aliasHome)))
|
|
215
|
+
continue;
|
|
216
|
+
for (const entry of STRANDED_HOME_ENTRIES) {
|
|
217
|
+
const from = node_path_1.default.join(aliasHome, entry);
|
|
218
|
+
const to = node_path_1.default.join(canonicalHome, entry);
|
|
219
|
+
if (!isRealDirectory(fs.lstat(from)))
|
|
220
|
+
continue;
|
|
221
|
+
if (fs.lstat(to) !== null)
|
|
222
|
+
continue;
|
|
223
|
+
try {
|
|
224
|
+
fs.rename(from, to);
|
|
225
|
+
moved.push(`${from} -> ${to}`);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
errors.push(`could not move ${from} to ${to}: ${describe(err)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
189
234
|
// 2. The binary and its companions — follow the provider — inside the home.
|
|
190
235
|
const binDir = node_path_1.default.join(canonicalHome, 'bin');
|
|
191
236
|
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;
|