@skrr-ai/cli 0.1.120 → 0.1.121

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 (65) hide show
  1. package/dist/base-command.js +11 -2
  2. package/dist/commands/agent/message/context.d.ts +1 -0
  3. package/dist/commands/agent/message/context.js +3 -2
  4. package/dist/commands/search/agents.js +5 -1
  5. package/dist/commands/search/all.js +74 -22
  6. package/dist/commands/search/skills.d.ts +1 -0
  7. package/dist/commands/search/skills.js +12 -1
  8. package/dist/commands/search/spaces.d.ts +8 -1
  9. package/dist/commands/search/spaces.js +50 -12
  10. package/dist/commands/search/tasks.d.ts +13 -2
  11. package/dist/commands/search/tasks.js +78 -14
  12. package/dist/commands/search/teams.js +5 -1
  13. package/dist/commands/search/workspace.d.ts +19 -0
  14. package/dist/commands/search/workspace.js +126 -0
  15. package/dist/commands/skills/browse.d.ts +1 -0
  16. package/dist/commands/skills/browse.js +8 -1
  17. package/dist/commands/skills/search.d.ts +1 -0
  18. package/dist/commands/skills/search.js +12 -1
  19. package/dist/commands/spaces/list.d.ts +1 -0
  20. package/dist/commands/spaces/list.js +12 -3
  21. package/dist/commands/spaces/search.d.ts +1 -0
  22. package/dist/commands/spaces/search.js +9 -0
  23. package/dist/commands/tasks/search.d.ts +1 -0
  24. package/dist/commands/tasks/search.js +9 -0
  25. package/dist/lib/api-error-reason.d.ts +2 -0
  26. package/dist/lib/api-error-reason.js +14 -0
  27. package/dist/lib/api-fetch.js +3 -0
  28. package/dist/lib/search-display.d.ts +7 -0
  29. package/dist/lib/search-display.js +31 -0
  30. package/dist/lib/skill-store-pagination.d.ts +8 -0
  31. package/dist/lib/skill-store-pagination.js +35 -0
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +10 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonConsentProof.d.ts +20 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonConsentProof.js +72 -0
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/desktopDaemonProof.d.ts +21 -0
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/desktopDaemonProof.js +96 -0
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +2 -3
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +102 -20
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.d.ts +27 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +161 -0
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineScopes.d.ts +26 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineScopes.js +41 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -1
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +15 -0
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +20 -4
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +10 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +172 -1
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonConsentProof.d.ts +20 -0
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonConsentProof.js +43 -0
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/desktopDaemonProof.d.ts +21 -0
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/desktopDaemonProof.js +62 -0
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +2 -3
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +133 -25
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.d.ts +27 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +132 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineScopes.d.ts +26 -0
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineScopes.js +15 -0
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -1
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -1
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +28 -15
  62. package/dist/node_modules/@skrr-ai/auth-core/package.json +31 -1
  63. package/dist/node_modules/@skrr-ai/data-provider/index.js +7627 -7574
  64. package/oclif.manifest.json +634 -21
  65. package/package.json +1 -1
@@ -0,0 +1,21 @@
1
+ export declare const DESKTOP_DAEMON_PROOF_NAMESPACE = "/desktop-proof-v1";
2
+ export declare const DESKTOP_DAEMON_RENEWAL_NAMESPACE = "/desktop-renewal-v1";
3
+ export interface DesktopDaemonOwner {
4
+ userId: string;
5
+ serverUrl: string;
6
+ }
7
+ export interface DesktopDaemonChallenge {
8
+ owner: DesktopDaemonOwner;
9
+ port: number;
10
+ clientNonce: string;
11
+ serverNonce: string;
12
+ }
13
+ /** The same canonical owner is used by the daemon's owner boundary and MAC. */
14
+ export declare function normalizeDesktopDaemonOwner(input: unknown): DesktopDaemonOwner | null;
15
+ /** Role separation prevents reflecting the daemon's proof as client proof. */
16
+ export declare function desktopDaemonProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge): string;
17
+ /** Renewal uses a distinct domain so a proof for this narrow channel cannot
18
+ * be reflected or replayed as a normal desktop attachment. */
19
+ export declare function desktopRenewalProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge): string;
20
+ export declare function verifyDesktopDaemonProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge, proof: unknown): boolean;
21
+ export declare function verifyDesktopRenewalProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge, proof: unknown): boolean;
@@ -0,0 +1,62 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ export const DESKTOP_DAEMON_PROOF_NAMESPACE = '/desktop-proof-v1';
3
+ export const DESKTOP_DAEMON_RENEWAL_NAMESPACE = '/desktop-renewal-v1';
4
+ /** The same canonical owner is used by the daemon's owner boundary and MAC. */
5
+ export function normalizeDesktopDaemonOwner(input) {
6
+ if (!input || typeof input !== 'object' || Array.isArray(input))
7
+ return null;
8
+ const value = input;
9
+ if (typeof value.userId !== 'string' || !value.userId || value.userId.length > 128 ||
10
+ typeof value.serverUrl !== 'string' || value.serverUrl.length > 2048)
11
+ return null;
12
+ try {
13
+ const url = new URL(value.serverUrl);
14
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
15
+ return null;
16
+ return { userId: value.userId, serverUrl: url.origin };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /** Role separation prevents reflecting the daemon's proof as client proof. */
23
+ export function desktopDaemonProof(secret, role, challenge) {
24
+ return proofForPurpose(secret, role, challenge, 'skrr-desktop-daemon-proof:v1\0');
25
+ }
26
+ /** Renewal uses a distinct domain so a proof for this narrow channel cannot
27
+ * be reflected or replayed as a normal desktop attachment. */
28
+ export function desktopRenewalProof(secret, role, challenge) {
29
+ return proofForPurpose(secret, role, challenge, 'skrr-desktop-renewal-proof:v1\0');
30
+ }
31
+ function proofForPurpose(secret, role, challenge, domain) {
32
+ const key = Buffer.from(secret, 'base64');
33
+ const owner = normalizeDesktopDaemonOwner(challenge.owner);
34
+ if (key.length !== 32 || key.toString('base64') !== secret || !owner ||
35
+ !Number.isInteger(challenge.port) || challenge.port < 1 || challenge.port > 65535 ||
36
+ !/^[a-f0-9]{64}$/.test(challenge.clientNonce) || !/^[a-f0-9]{64}$/.test(challenge.serverNonce)) {
37
+ throw new Error('Invalid desktop daemon challenge');
38
+ }
39
+ return createHmac('sha256', key).update(domain).update(JSON.stringify([
40
+ role, challenge.port, owner.userId, owner.serverUrl, challenge.clientNonce, challenge.serverNonce,
41
+ ])).digest('hex');
42
+ }
43
+ export function verifyDesktopDaemonProof(secret, role, challenge, proof) {
44
+ if (typeof proof !== 'string' || !/^[a-f0-9]{64}$/.test(proof))
45
+ return false;
46
+ try {
47
+ return timingSafeEqual(Buffer.from(proof, 'hex'), Buffer.from(desktopDaemonProof(secret, role, challenge), 'hex'));
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export function verifyDesktopRenewalProof(secret, role, challenge, proof) {
54
+ if (typeof proof !== 'string' || !/^[a-f0-9]{64}$/.test(proof))
55
+ return false;
56
+ try {
57
+ return timingSafeEqual(Buffer.from(proof, 'hex'), Buffer.from(desktopRenewalProof(secret, role, challenge), 'hex'));
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
@@ -51,9 +51,8 @@ export declare function describeDeviceKeySplit(profile: string): {
51
51
  * `createIfMissing: false` stops before step 2 (disabled+no_stored_key) and
52
52
  * never replaces anything: for a short-lived process that must only READ the
53
53
  * daemon's key while the daemon itself may be creating it (OSK-15990's
54
- * `skrrd device-proof`). The persist below is not an exclusive create, so a
55
- * second creator racing the daemon could leave the daemon holding one key in
56
- * memory and the disk another.
54
+ * `skrrd device-proof`). Readers and writers both take the profile lock so
55
+ * they cannot observe an incomplete write from another updated process.
57
56
  */
58
57
  export declare function initDeviceIdentity(profile: string, options?: {
59
58
  recoverUnreadable?: boolean;
@@ -31,9 +31,10 @@
31
31
  */
32
32
  import fs from 'node:fs';
33
33
  import path from 'node:path';
34
+ import lockfile from 'proper-lockfile';
34
35
  import { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, } from './deviceKey.js';
35
36
  import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, splitKeyConvergencePolicy, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
36
- import { consolidateProfileState, profileStateFilePathsToClear, resolveProfileStateDir, } from './profileStateDir.js';
37
+ import { consolidateProfileState, profileStateFilePathsToClear, profileStateDir, resolveProfileStateDir, } from './profileStateDir.js';
37
38
  import { emitAuthTelemetry, getAuthLogger } from './runtime.js';
38
39
  const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
39
40
  const PRIVATE_KEY_FILENAME = 'device-private-key';
@@ -56,6 +57,34 @@ const DEVICE_IDENTITY_FILES = [
56
57
  // so every CLI command does not repeat the same guaranteed 404.
57
58
  const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
58
59
  let _state = { kind: 'uninit' };
60
+ let _initializing = null;
61
+ /**
62
+ * The CLI, daemon, and desktop's short-lived signer resolve the same canonical
63
+ * profile directory even when an older private key still lives in the nested
64
+ * legacy directory. Serialize the entire read/consolidate/create transaction;
65
+ * otherwise two first starts can each retain a different key in memory.
66
+ *
67
+ * Never proceed unlocked. The long stale interval favors preserving a pinned
68
+ * identity if a process is paused; a contended caller fails and can retry.
69
+ * Versions that predate this lock do not participate and still require an
70
+ * orderly upgrade before using the new-profile authorization path.
71
+ */
72
+ async function withDeviceIdentityLock(profile, body) {
73
+ const dir = profileStateDir(profile);
74
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ const release = await lockfile.lock(dir, {
76
+ realpath: false,
77
+ lockfilePath: path.join(dir, '.device-identity.lock'),
78
+ stale: 10 * 60 * 1000,
79
+ retries: { retries: 20, minTimeout: 100, maxTimeout: 500, factor: 1.25 },
80
+ });
81
+ try {
82
+ return await body();
83
+ }
84
+ finally {
85
+ await release();
86
+ }
87
+ }
59
88
  /** @internal Test seam — force-set state. */
60
89
  export function __setStateForTest(next) {
61
90
  const prev = _state;
@@ -72,6 +101,11 @@ export function __getStateForTest() {
72
101
  * that decides which copy the group is read from.
73
102
  */
74
103
  function profileConfigDir(profile) {
104
+ // A reserved independent profile has a server-pinned owner and device key.
105
+ // An older nested copy is not authority to replace or shadow its canonical
106
+ // key, even when generic profile-convergence policy favors that copy.
107
+ if (/^desktop-bg-[a-f0-9]{32}$/i.test(profile))
108
+ return profileStateDir(profile);
75
109
  return resolveProfileStateDir(profile, PRIVATE_KEY_FILENAME).dir;
76
110
  }
77
111
  function publicKeyPath(profile) {
@@ -177,6 +211,50 @@ function persistKeypair(profile, kp) {
177
211
  });
178
212
  return { envelopeProtected };
179
213
  }
214
+ /** Reserved identities are read without following links or accepting an
215
+ * unbounded, shared, unsealed or concurrently replaced key file. */
216
+ function readIndependentIdentityFile(file, sealed) {
217
+ if (typeof fs.constants.O_NOFOLLOW !== 'number' || typeof fs.constants.O_NONBLOCK !== 'number')
218
+ return null;
219
+ let fd;
220
+ try {
221
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ try {
227
+ const before = fs.fstatSync(fd);
228
+ if (!before.isFile() || before.nlink !== 1 || before.size > 16_384 || (before.mode & 0o077) !== 0 ||
229
+ (typeof process.getuid === 'function' && before.uid !== process.getuid()))
230
+ return null;
231
+ const bytes = Buffer.alloc(before.size);
232
+ let offset = 0;
233
+ while (offset < bytes.length) {
234
+ const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
235
+ if (count <= 0)
236
+ return null;
237
+ offset += count;
238
+ }
239
+ const after = fs.fstatSync(fd);
240
+ const current = fs.lstatSync(file);
241
+ if (!current.isFile() || current.isSymbolicLink() || current.dev !== before.dev || current.ino !== before.ino ||
242
+ current.size !== before.size || current.nlink !== before.nlink || current.mode !== before.mode ||
243
+ current.uid !== before.uid || current.mtimeMs !== before.mtimeMs || current.ctimeMs !== before.ctimeMs ||
244
+ before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.nlink !== after.nlink ||
245
+ before.mode !== after.mode || before.uid !== after.uid || before.mtimeMs !== after.mtimeMs ||
246
+ before.ctimeMs !== after.ctimeMs)
247
+ return null;
248
+ const value = bytes.toString('utf8');
249
+ return sealed && !value.startsWith('osk-env1:') ? null : value;
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ finally {
255
+ fs.closeSync(fd);
256
+ }
257
+ }
180
258
  /**
181
259
  * Load a previously-persisted keypair. Returns null on missing files,
182
260
  * decrypt failure, public/private mismatch (file-swap defense), or
@@ -185,11 +263,13 @@ function persistKeypair(profile, kp) {
185
263
  function loadKeypair(profile) {
186
264
  const pubPath = publicKeyPath(profile);
187
265
  const privPath = privateKeyPath(profile);
188
- if (!fs.existsSync(pubPath) || !fs.existsSync(privPath)) {
266
+ const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
267
+ if (!independent && (!fs.existsSync(pubPath) || !fs.existsSync(privPath)))
268
+ return null;
269
+ const pubRaw = independent ? readIndependentIdentityFile(pubPath, false) : fs.readFileSync(pubPath, 'utf-8');
270
+ const privRaw = independent ? readIndependentIdentityFile(privPath, true) : fs.readFileSync(privPath, 'utf-8');
271
+ if (pubRaw === null || privRaw === null)
189
272
  return null;
190
- }
191
- const pubRaw = fs.readFileSync(pubPath, 'utf-8');
192
- const privRaw = fs.readFileSync(privPath, 'utf-8');
193
273
  let publicJwk;
194
274
  let storedThumbprint;
195
275
  try {
@@ -348,23 +428,47 @@ async function consolidateDeviceIdentity(profile) {
348
428
  * `createIfMissing: false` stops before step 2 (disabled+no_stored_key) and
349
429
  * never replaces anything: for a short-lived process that must only READ the
350
430
  * daemon's key while the daemon itself may be creating it (OSK-15990's
351
- * `skrrd device-proof`). The persist below is not an exclusive create, so a
352
- * second creator racing the daemon could leave the daemon holding one key in
353
- * memory and the disk another.
431
+ * `skrrd device-proof`). Readers and writers both take the profile lock so
432
+ * they cannot observe an incomplete write from another updated process.
354
433
  */
355
434
  export async function initDeviceIdentity(profile, options = {}) {
435
+ if (_initializing)
436
+ return _initializing;
356
437
  if (_state.kind !== 'uninit')
357
438
  return;
358
- if (typeof profile !== 'string' || profile.length === 0) {
439
+ if (typeof profile !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) {
359
440
  _state = { kind: 'disabled', reason: 'init_failed' };
360
- emit('init_failed', false, { reason: 'empty_profile' });
441
+ emit('init_failed', false, { reason: 'invalid_profile' });
361
442
  return;
362
443
  }
444
+ const job = withDeviceIdentityLock(profile, async () => {
445
+ if (_state.kind === 'uninit')
446
+ await initDeviceIdentityLocked(profile, options);
447
+ }).catch((err) => {
448
+ _state = { kind: 'disabled', reason: 'init_failed' };
449
+ emit('init_failed', false, {
450
+ profile,
451
+ reason: 'identity_lock_unavailable',
452
+ message: err instanceof Error ? err.message : 'unknown',
453
+ });
454
+ });
455
+ _initializing = job;
456
+ try {
457
+ await job;
458
+ }
459
+ finally {
460
+ if (_initializing === job)
461
+ _initializing = null;
462
+ }
463
+ }
464
+ async function initDeviceIdentityLocked(profile, options = {}) {
363
465
  // OSK-10314 — converge the two directories this profile may have before
364
466
  // resolving any of the four files. Must run after `initCredEnvelope`, which
365
467
  // both consumers already guarantee, because the private key's readability
366
468
  // depends on which DEK ended up active.
367
- await consolidateDeviceIdentity(profile);
469
+ const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
470
+ if (!independent)
471
+ await consolidateDeviceIdentity(profile);
368
472
  let keypair = null;
369
473
  try {
370
474
  keypair = loadKeypair(profile);
@@ -394,12 +498,12 @@ export async function initDeviceIdentity(profile, options = {}) {
394
498
  // DEVICE_KEY_MISMATCH. Only an explicit interactive login may replace it;
395
499
  // that login proves the new key and re-pins it before issuing credentials.
396
500
  const hasStoredKey = storedKeyMaterialExists(profile);
397
- if (hasStoredKey && !options.recoverUnreadable) {
501
+ if (hasStoredKey && (!options.recoverUnreadable || independent)) {
398
502
  _state = { kind: 'disabled', reason: 'unreadable_key' };
399
503
  emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
400
504
  return;
401
505
  }
402
- if (options.createIfMissing === false) {
506
+ if (options.createIfMissing === false || (independent && options.createIfMissing !== true)) {
403
507
  _state = { kind: 'disabled', reason: 'no_stored_key' };
404
508
  emit('no_stored_key', false, { profile });
405
509
  return;
@@ -448,25 +552,29 @@ export async function initDeviceIdentity(profile, options = {}) {
448
552
  * the daemon's pinned identity.
449
553
  */
450
554
  export async function resetDeviceIdentity(opts) {
555
+ if (_initializing)
556
+ await _initializing;
451
557
  if (opts?.clearOnDisk && opts.profile) {
452
558
  // Every location, not just the resolved one. A reset that cleared only the
453
559
  // canonical copy would leave the pre-consolidation copy for the next init to
454
560
  // adopt — a deleted device key that reappears.
455
561
  const targets = [PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => profileStateFilePathsToClear(opts.profile, name));
456
- for (const p of targets) {
457
- try {
458
- fs.unlinkSync(p);
459
- }
460
- catch (err) {
461
- if (err.code !== 'ENOENT') {
462
- emit('clear_failed', false, {
463
- profile: opts.profile,
464
- file: path.basename(p),
465
- message: err?.message ?? 'unknown',
466
- });
562
+ await withDeviceIdentityLock(opts.profile, async () => {
563
+ for (const p of targets) {
564
+ try {
565
+ fs.unlinkSync(p);
566
+ }
567
+ catch (err) {
568
+ if (err.code !== 'ENOENT') {
569
+ emit('clear_failed', false, {
570
+ profile: opts.profile,
571
+ file: path.basename(p),
572
+ message: err?.message ?? 'unknown',
573
+ });
574
+ }
467
575
  }
468
576
  }
469
- }
577
+ });
470
578
  }
471
579
  _state = { kind: 'uninit' };
472
580
  }
@@ -0,0 +1,27 @@
1
+ /** The desktop reserves a new profile for every background authorization. */
2
+ export declare const INDEPENDENT_MACHINE_PROFILE_RE: RegExp;
3
+ export declare const INDEPENDENT_MACHINE_RESERVATION_FILE = "independent-machine-attempt.json";
4
+ export interface IndependentMachineReservation {
5
+ version: 2;
6
+ profile: string;
7
+ ownerId: string;
8
+ origin: string;
9
+ secretHash: string;
10
+ encryptedSecret: string;
11
+ createdAt: string;
12
+ }
13
+ /** Reserve a previously absent profile and persist the encrypted attempt secret.
14
+ * `encrypt` must use the platform secure store and throw if it is unavailable. */
15
+ export declare function reserveIndependentMachineProfile(options: {
16
+ root: string;
17
+ ownerId: string;
18
+ origin: string;
19
+ encrypt: (secret: string) => Buffer;
20
+ }): {
21
+ reservation: IndependentMachineReservation;
22
+ secret: string;
23
+ };
24
+ /** A saved reservation is a selection hint, never proof of machine ownership. */
25
+ export declare function readIndependentMachineReservation(root: string, profile: string): IndependentMachineReservation | null;
26
+ /** Confirm a one-shot signer is acting on the desktop's reserved attempt. */
27
+ export declare function verifyIndependentMachineReservation(root: string, profile: string, secret: unknown): boolean;
@@ -0,0 +1,132 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /** The desktop reserves a new profile for every background authorization. */
5
+ export const INDEPENDENT_MACHINE_PROFILE_RE = /^desktop-bg-[a-f0-9]{32}$/;
6
+ export const INDEPENDENT_MACHINE_RESERVATION_FILE = 'independent-machine-attempt.json';
7
+ function canonicalOrigin(input) {
8
+ try {
9
+ const url = new URL(input);
10
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
11
+ return !url.username && !url.password && url.origin === input &&
12
+ (url.protocol === 'https:' || (url.protocol === 'http:' && local));
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function validSecret(secret) {
19
+ return typeof secret === 'string' && /^[A-Za-z0-9_-]{43}$/.test(secret) &&
20
+ Buffer.from(secret, 'base64url').length === 32 &&
21
+ Buffer.from(secret, 'base64url').toString('base64url') === secret;
22
+ }
23
+ function hashSecret(profile, ownerId, origin, secret) {
24
+ return crypto.createHash('sha256').update('independent-machine-reservation:v2\0')
25
+ .update(profile).update('\0').update(ownerId).update('\0').update(origin).update('\0').update(secret).digest();
26
+ }
27
+ function reservationPath(root, profile) {
28
+ if (!path.isAbsolute(root) || !INDEPENDENT_MACHINE_PROFILE_RE.test(profile)) {
29
+ throw new Error('Invalid independent machine profile');
30
+ }
31
+ return path.join(root, 'profiles', profile, INDEPENDENT_MACHINE_RESERVATION_FILE);
32
+ }
33
+ /** Reserve a previously absent profile and persist the encrypted attempt secret.
34
+ * `encrypt` must use the platform secure store and throw if it is unavailable. */
35
+ export function reserveIndependentMachineProfile(options) {
36
+ if (!path.isAbsolute(options.root) || !/^[a-fA-F0-9]{24}$/.test(options.ownerId) ||
37
+ !canonicalOrigin(options.origin))
38
+ throw new Error('Invalid background authorization context');
39
+ const secret = crypto.randomBytes(32).toString('base64url');
40
+ const encrypted = options.encrypt(secret);
41
+ if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length > 4096) {
42
+ throw new Error('Background authorization secure storage is unavailable');
43
+ }
44
+ const profiles = path.join(options.root, 'profiles');
45
+ const rootExisted = fs.existsSync(options.root);
46
+ const profilesExisted = fs.existsSync(profiles);
47
+ fs.mkdirSync(profiles, { recursive: true, mode: 0o700 });
48
+ for (let i = 0; i < 5; i += 1) {
49
+ const profile = `desktop-bg-${crypto.randomBytes(16).toString('hex')}`;
50
+ const dir = path.join(profiles, profile);
51
+ try {
52
+ fs.mkdirSync(dir, { mode: 0o700 });
53
+ }
54
+ catch (error) {
55
+ if (error.code === 'EEXIST')
56
+ continue;
57
+ throw error;
58
+ }
59
+ const reservation = {
60
+ version: 2, profile, ownerId: options.ownerId.toLowerCase(), origin: options.origin,
61
+ secretHash: hashSecret(profile, options.ownerId.toLowerCase(), options.origin, secret).toString('hex'),
62
+ encryptedSecret: encrypted.toString('base64'),
63
+ createdAt: new Date().toISOString(),
64
+ };
65
+ const file = reservationPath(options.root, profile);
66
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
67
+ (fs.constants.O_NOFOLLOW ?? 0), 0o600);
68
+ try {
69
+ fs.writeFileSync(fd, `${JSON.stringify(reservation)}\n`, 'utf8');
70
+ fs.fsyncSync(fd);
71
+ }
72
+ finally {
73
+ fs.closeSync(fd);
74
+ }
75
+ const directories = [dir, profiles, ...(!profilesExisted ? [options.root] : []),
76
+ ...(!rootExisted ? [path.dirname(options.root)] : [])];
77
+ for (const directory of directories) {
78
+ const directoryFd = fs.openSync(directory, 'r');
79
+ try {
80
+ fs.fsyncSync(directoryFd);
81
+ }
82
+ finally {
83
+ fs.closeSync(directoryFd);
84
+ }
85
+ }
86
+ // A profile whose reservation cannot be written is never used. Keep any
87
+ // uncertain directory for recovery rather than deleting other state.
88
+ return { reservation, secret };
89
+ }
90
+ throw new Error('Could not reserve a new background machine profile');
91
+ }
92
+ /** A saved reservation is a selection hint, never proof of machine ownership. */
93
+ export function readIndependentMachineReservation(root, profile) {
94
+ try {
95
+ const file = reservationPath(root, profile);
96
+ const directory = fs.lstatSync(path.dirname(file));
97
+ if (!directory.isDirectory() || directory.isSymbolicLink())
98
+ return null;
99
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
100
+ let raw;
101
+ try {
102
+ const stat = fs.fstatSync(fd);
103
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192 || (stat.mode & 0o077) !== 0)
104
+ return null;
105
+ raw = fs.readFileSync(fd, 'utf8');
106
+ }
107
+ finally {
108
+ fs.closeSync(fd);
109
+ }
110
+ const value = JSON.parse(raw);
111
+ if (value.version !== 2 || value.profile !== profile ||
112
+ typeof value.ownerId !== 'string' || !/^[a-f0-9]{24}$/.test(value.ownerId) ||
113
+ typeof value.origin !== 'string' || !canonicalOrigin(value.origin) ||
114
+ typeof value.secretHash !== 'string' || !/^[a-f0-9]{64}$/.test(value.secretHash) ||
115
+ typeof value.encryptedSecret !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.encryptedSecret) ||
116
+ typeof value.createdAt !== 'string' || !Number.isFinite(Date.parse(value.createdAt)))
117
+ return null;
118
+ return value;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ /** Confirm a one-shot signer is acting on the desktop's reserved attempt. */
125
+ export function verifyIndependentMachineReservation(root, profile, secret) {
126
+ if (!validSecret(secret))
127
+ return false;
128
+ const saved = readIndependentMachineReservation(root, profile);
129
+ if (!saved)
130
+ return false;
131
+ return crypto.timingSafeEqual(Buffer.from(saved.secretHash, 'hex'), hashSecret(profile, saved.ownerId, saved.origin, secret));
132
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The capabilities explained to a person before a separate background
3
+ * machine is authorized. This version is an immutable wire contract: adding
4
+ * access needs a new contract and a new authorization, not an edit to v1.
5
+ */
6
+ export declare const INDEPENDENT_MACHINE_SCOPE_CONTRACT_V1: "desktop-background-v1";
7
+ export declare const INDEPENDENT_MACHINE_PERMISSIONS_V1: readonly [Readonly<{
8
+ scope: "daemon:tools:bash";
9
+ description: "Run terminal commands and local agents";
10
+ }>, Readonly<{
11
+ scope: "daemon:tools:fs:read";
12
+ description: "Read files in allowed folders";
13
+ }>, Readonly<{
14
+ scope: "daemon:tools:fs:write";
15
+ description: "Create and edit files in allowed folders";
16
+ }>, Readonly<{
17
+ scope: "daemon:browser:cdp";
18
+ description: "Control connected browser tabs";
19
+ }>, Readonly<{
20
+ scope: "daemon:session:replay";
21
+ description: "Replay results recorded while offline";
22
+ }>, Readonly<{
23
+ scope: "daemon:offline-queue";
24
+ description: "Send queued results after reconnecting";
25
+ }>];
26
+ export declare const INDEPENDENT_MACHINE_SCOPES_V1: readonly string[];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The capabilities explained to a person before a separate background
3
+ * machine is authorized. This version is an immutable wire contract: adding
4
+ * access needs a new contract and a new authorization, not an edit to v1.
5
+ */
6
+ export const INDEPENDENT_MACHINE_SCOPE_CONTRACT_V1 = 'desktop-background-v1';
7
+ export const INDEPENDENT_MACHINE_PERMISSIONS_V1 = Object.freeze([
8
+ Object.freeze({ scope: 'daemon:tools:bash', description: 'Run terminal commands and local agents' }),
9
+ Object.freeze({ scope: 'daemon:tools:fs:read', description: 'Read files in allowed folders' }),
10
+ Object.freeze({ scope: 'daemon:tools:fs:write', description: 'Create and edit files in allowed folders' }),
11
+ Object.freeze({ scope: 'daemon:browser:cdp', description: 'Control connected browser tabs' }),
12
+ Object.freeze({ scope: 'daemon:session:replay', description: 'Replay results recorded while offline' }),
13
+ Object.freeze({ scope: 'daemon:offline-queue', description: 'Send queued results after reconnecting' }),
14
+ ]);
15
+ export const INDEPENDENT_MACHINE_SCOPES_V1 = Object.freeze(INDEPENDENT_MACHINE_PERMISSIONS_V1.map((permission) => permission.scope));
@@ -28,7 +28,7 @@ export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as cred
28
28
  export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type KekStrategy, } from './kek/index.js';
29
29
  export { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, normalizeHtu, publicJwkFromX, tokenHash, verifyProof as verifyDeviceProof, type DeviceKeyPair, type DeviceProofClaims, type DeviceProofHeader, type Ed25519PrivateJwk, type Ed25519PublicJwk, type VerifyFailureReason as DeviceProofVerifyFailureReason, type VerifyFailure as DeviceProofVerifyFailure, type VerifyOptions as DeviceProofVerifyOptions, type VerifySuccess as DeviceProofVerifySuccess, } from './deviceKey.js';
30
30
  export { DPOP_HEADER, DPOP_MAX_AGE_SECONDS, DPOP_PROOF_INVALID, DPOP_PROOF_REQUIRED, DPOP_PROOF_TYP, DPOP_PROOF_VERIFY_UNAVAILABLE, accessTokenHash, createDpopKey, isAth, isJwkThumbprint, normalizeDpopHtu, verifyDpopProof, type DpopKey, type DpopProofClaims, type DpopProofRequest, type DpopVerifyFailureReason, type DpopVerifyOptions, type DpopVerifyResult, } from './dpop.js';
31
- export { initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
31
+ export { initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
32
32
  export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
33
33
  export { getMachineUuid, getMachineUuidForProfile, describeMachineIdentity, type MachineIdentityDescription, type MachineIdentityReason, getHardwareMachineUuid, setMachineUuidProfile, getMachineUuidProfile, qualifyMachineUuid, MACHINE_IDENTITY_FILENAME, hostedRuntimeIdOverride, HOSTED_RUNTIME_MACHINE_SOURCES, __resetMachineUuidCacheForTest, } from './machineUuid.js';
34
34
  export { ensureMachineDaemonId, legacyDaemonUuids, __resetCachedDaemonIdForTest, } from './machineId.js';
@@ -50,3 +50,4 @@ export * from './harnessPermissionControls.js';
50
50
  export { compareReleaseVersions } from './releaseVersions.js';
51
51
  export * from './computerUse.js';
52
52
  export * from './desktopWire.js';
53
+ export { INDEPENDENT_MACHINE_PROFILE_RE, INDEPENDENT_MACHINE_RESERVATION_FILE, readIndependentMachineReservation, reserveIndependentMachineProfile, verifyIndependentMachineReservation, type IndependentMachineReservation, } from './independentMachineReservation.js';
@@ -112,7 +112,7 @@ export { DPOP_HEADER, DPOP_MAX_AGE_SECONDS, DPOP_PROOF_INVALID, DPOP_PROOF_REQUI
112
112
  // Daemon and CLI consume these; the underlying primitives live above.
113
113
  export {
114
114
  // credEnvelope bridge
115
- initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT,
115
+ initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT,
116
116
  // Test seams — `__`-prefixed so consumers don't import them in
117
117
  // production code paths. Re-exported here so CLI/daemon spec files
118
118
  // can resolve the wrapped-DEK path against the same module instance.
@@ -186,3 +186,4 @@ export * from './harnessPermissionControls.js';
186
186
  export { compareReleaseVersions } from './releaseVersions.js';
187
187
  export * from './computerUse.js';
188
188
  export * from './desktopWire.js';
189
+ export { INDEPENDENT_MACHINE_PROFILE_RE, INDEPENDENT_MACHINE_RESERVATION_FILE, readIndependentMachineReservation, reserveIndependentMachineProfile, verifyIndependentMachineReservation, } from './independentMachineReservation.js';
@@ -272,17 +272,9 @@ async function readKekFromKeychain(service, account) {
272
272
  }
273
273
  }
274
274
  /**
275
- * Persist a master KEK to the keychain. Uses `-U` so the upsert is
276
- * idempotent — repeated first-use races just overwrite each other rather
277
- * than failing with "item already exists." We accept the second-writer-
278
- * wins outcome because (a) the daemon is single-tenant per profile and
279
- * (b) all writers are generating the same shape of secret.
280
- *
281
- * Race note: if two daemon processes both miss and both store within the
282
- * same TOCTOU window, the second overwrite wins and any DEKs already
283
- * wrapped under the first KEK become permanently unrecoverable. The
284
- * envelope path's caller (`credEnvelope.ts:initCredEnvelope`) handles
285
- * this gracefully — `unwrap` failure → log + treat as first enrollment.
275
+ * Create a master KEK without replacing an existing item. This key is shared
276
+ * by profiles, so even a first-use race in a different process must not
277
+ * overwrite the key protecting already wrapped credentials.
286
278
  *
287
279
  * The `service` / `account` parameters are threaded from the calling
288
280
  * `MacosKek` instance so each instance keeps its own keychain entry.
@@ -312,7 +304,6 @@ async function writeKekToKeychain(kek, service, account) {
312
304
  account,
313
305
  '-w',
314
306
  b64,
315
- '-U',
316
307
  keychainPath,
317
308
  ]);
318
309
  }
@@ -410,9 +401,31 @@ async function resolveOrCreateKek(service, account) {
410
401
  return existing;
411
402
  }
412
403
  const fresh = crypto.randomBytes(KEY_LEN);
413
- await writeKekToKeychain(fresh, service, account);
414
- _kekCacheBySvc.set(cacheKey, fresh);
415
- return fresh;
404
+ try {
405
+ try {
406
+ await writeKekToKeychain(fresh, service, account);
407
+ }
408
+ catch (error) {
409
+ // A concurrent creator may have won. Only a successful read of its
410
+ // existing item makes the failed create recoverable; all other errors
411
+ // remain failures and must not cause an update or a second write.
412
+ const winner = await readKekFromKeychain(service, account).catch(() => null);
413
+ if (!winner)
414
+ throw error;
415
+ _kekCacheBySvc.set(cacheKey, winner);
416
+ return winner;
417
+ }
418
+ const persisted = await readKekFromKeychain(service, account);
419
+ if (!persisted || !persisted.equals(fresh)) {
420
+ persisted?.fill(0);
421
+ throw new KekUnavailableError(KIND_SHELLOUT, 'created master KEK could not be verified');
422
+ }
423
+ _kekCacheBySvc.set(cacheKey, persisted);
424
+ return persisted;
425
+ }
426
+ finally {
427
+ fresh.fill(0);
428
+ }
416
429
  }
417
430
  /** Resolve an already-persisted master KEK without creating one. */
418
431
  async function resolveExistingKek(service, account) {