@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
@@ -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
- * <configDir>/profiles/<p>/cred-dek-wrapped — KEK-wrapped DEK
10
- * <configDir>/profiles/<p>/cred-envelope.lock — proper-lockfile sentinel
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
- * `<configDir>` is supplied by the consumer via `configureAuthCore({configDir})`
13
- * — both binaries today resolve to `~/.skrr` so first-enrollment races
14
- * between `sky` and `oversky` serialize against the same lockfile.
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 { emitAuthTelemetry, getAuthConfigDir, getAuthLogger, getCredEnvelopeFailClosed, } from './runtime.js';
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
- * Per-profile config directory. Uses the consumer-supplied configDir from
60
- * `configureAuthCore()`; the whole point of this module is shared on-disk
61
- * artifacts, so daemon/CLI MUST configure the same root.
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 path.join(profileConfigDir(profile), ACCOUNT_CRED_DEK_WRAPPED);
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(profileConfigDir(profile), 'cred-envelope.lock');
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
- try {
385
- fs.unlinkSync(wrappedDekFilePath(opts.profile));
386
- }
387
- catch (err) {
388
- if (err.code !== 'ENOENT') {
389
- emit('cred_envelope.clear_failed', false, {
390
- profile: opts.profile,
391
- message: err?.message ?? 'unknown',
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
- * <configDir>/profiles/<p>/device-public-key.jwk — plaintext JWK
10
- * <configDir>/profiles/<p>/device-private-key — L12-wrapped JSON
11
- * <configDir>/profiles/<p>/device-enrollment.json — server-ack marker
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 { emitAuthTelemetry, getAuthConfigDir, getAuthLogger } from './runtime.js';
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 path.join(getAuthConfigDir(), 'profiles', profile);
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
- for (const p of [publicKeyPath(opts.profile), privateKeyPath(opts.profile)]) {
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;