@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.
Files changed (73) hide show
  1. package/dist/base-command.d.ts +13 -1
  2. package/dist/base-command.js +37 -3
  3. package/dist/commands/agents/actions/create.js +11 -4
  4. package/dist/commands/agents/chat.js +3 -4
  5. package/dist/commands/browser/cloud-agents.d.ts +20 -0
  6. package/dist/commands/browser/cloud-agents.js +43 -0
  7. package/dist/commands/code/handover.d.ts +11 -0
  8. package/dist/commands/code/handover.js +105 -1
  9. package/dist/commands/commitments/handover.d.ts +29 -0
  10. package/dist/commands/commitments/handover.js +99 -0
  11. package/dist/commands/followups/list.js +1 -1
  12. package/dist/commands/followups/watch.js +40 -5
  13. package/dist/commands/initiatives/list.d.ts +1 -0
  14. package/dist/commands/initiatives/list.js +11 -2
  15. package/dist/commands/labels/list.d.ts +28 -0
  16. package/dist/commands/labels/list.js +50 -32
  17. package/dist/commands/tasks/fork.d.ts +32 -0
  18. package/dist/commands/tasks/fork.js +171 -0
  19. package/dist/commands/tasks/handover.d.ts +23 -0
  20. package/dist/commands/tasks/handover.js +205 -0
  21. package/dist/commands/tasks/promote.d.ts +23 -0
  22. package/dist/commands/tasks/promote.js +82 -0
  23. package/dist/commands/tasks/runs.d.ts +16 -0
  24. package/dist/commands/tasks/runs.js +46 -1
  25. package/dist/commands/tasks/show.d.ts +23 -0
  26. package/dist/commands/tasks/show.js +59 -0
  27. package/dist/lib/agentic-stream.js +41 -2
  28. package/dist/lib/api-fetch.d.ts +7 -1
  29. package/dist/lib/api-fetch.js +9 -2
  30. package/dist/lib/auth-core-init.js +11 -4
  31. package/dist/lib/code-handover.d.ts +60 -0
  32. package/dist/lib/code-handover.js +120 -0
  33. package/dist/lib/commitments.d.ts +22 -0
  34. package/dist/lib/commitments.js +42 -0
  35. package/dist/lib/dedicated-wait.js +22 -1
  36. package/dist/lib/followups.d.ts +18 -0
  37. package/dist/lib/followups.js +44 -0
  38. package/dist/lib/initiatives.d.ts +15 -3
  39. package/dist/lib/label-ref.d.ts +2 -0
  40. package/dist/lib/node-adapter.js +6 -0
  41. package/dist/lib/task-extras.d.ts +44 -0
  42. package/dist/lib/task-extras.js +73 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +29 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +169 -23
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +91 -5
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.d.ts +8 -1
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarness.js +7 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/firstPartyHarnessHome.js +45 -0
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/harnessSkillLayout.d.ts +65 -0
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/harnessSkillLayout.js +99 -0
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +3 -1
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +26 -2
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/loginLocalhost.js +177 -26
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +119 -0
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +346 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +29 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +169 -24
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +93 -7
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.d.ts +8 -1
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarness.js +7 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/firstPartyHarnessHome.js +45 -0
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/harnessSkillLayout.d.ts +65 -0
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/harnessSkillLayout.js +94 -0
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +3 -1
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +10 -1
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/loginLocalhost.js +177 -26
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +119 -0
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +333 -0
  69. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  70. package/dist/node_modules/@skrr-ai/data-provider/index.js +2746 -2711
  71. package/dist/node_modules/@skrr-ai/inference-broker/package.json +1 -1
  72. package/oclif.manifest.json +34300 -33843
  73. 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
- * <configDir>/profiles/<p>/cred-dek-wrapped — KEK-wrapped DEK
27
- * <configDir>/profiles/<p>/cred-envelope.lock — proper-lockfile sentinel
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
- * `<configDir>` is supplied by the consumer via `configureAuthCore({configDir})`
30
- * — both binaries today resolve to `~/.skrr` so first-enrollment races
31
- * between `sky` and `oversky` serialize against the same lockfile.
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
- * Per-profile config directory. Uses the consumer-supplied configDir from
77
- * `configureAuthCore()`; the whole point of this module is shared on-disk
78
- * artifacts, so daemon/CLI MUST configure the same root.
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 node_path_1.default.join(profileConfigDir(profile), ACCOUNT_CRED_DEK_WRAPPED);
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(profileConfigDir(profile), 'cred-envelope.lock');
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
- try {
402
- node_fs_1.default.unlinkSync(wrappedDekFilePath(opts.profile));
403
- }
404
- catch (err) {
405
- if (err.code !== 'ENOENT') {
406
- emit('cred_envelope.clear_failed', false, {
407
- profile: opts.profile,
408
- message: err?.message ?? 'unknown',
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
- * <configDir>/profiles/<p>/device-public-key.jwk — plaintext JWK
29
- * <configDir>/profiles/<p>/device-private-key — L12-wrapped JSON
30
- * <configDir>/profiles/<p>/device-enrollment.json — server-ack marker
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 node_path_1.default.join((0, runtime_js_1.getAuthConfigDir)(), 'profiles', profile);
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
- for (const p of [publicKeyPath(opts.profile), privateKeyPath(opts.profile)]) {
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;