@skrr-ai/cli 0.1.122 → 0.1.124

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 (111) hide show
  1. package/dist/commands/agents/chat.d.ts +7 -0
  2. package/dist/commands/agents/chat.js +32 -6
  3. package/dist/commands/agents/delete.d.ts +8 -0
  4. package/dist/commands/agents/delete.js +16 -0
  5. package/dist/commands/context.js +7 -0
  6. package/dist/commands/followups/act.js +3 -0
  7. package/dist/commands/followups/checkin.js +3 -0
  8. package/dist/commands/followups/remind.js +3 -0
  9. package/dist/commands/followups/show.js +1 -1
  10. package/dist/commands/followups/watch.js +3 -0
  11. package/dist/commands/search/workspace.js +1 -1
  12. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  13. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  14. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  15. package/dist/commands/store/gallery-admissions/show.js +1 -1
  16. package/dist/lib/agentic-stream.d.ts +46 -0
  17. package/dist/lib/agentic-stream.js +135 -14
  18. package/dist/lib/auth-core-init.d.ts +15 -2
  19. package/dist/lib/auth-core-init.js +47 -6
  20. package/dist/lib/auth-failure.js +12 -0
  21. package/dist/lib/command-hygiene.js +30 -1
  22. package/dist/lib/daemonBrokerRefusal.js +5 -1
  23. package/dist/lib/exec-runtime-binary.d.ts +9 -0
  24. package/dist/lib/exec-runtime-binary.js +15 -1
  25. package/dist/lib/followups.d.ts +13 -0
  26. package/dist/lib/followups.js +8 -0
  27. package/dist/lib/keychain.js +41 -4
  28. package/dist/lib/profile-machine-identity-guard.js +2 -1
  29. package/dist/lib/runtime-start-verification.js +6 -1
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +69 -27
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +135 -4
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +440 -83
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +110 -0
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +13 -0
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +11 -2
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +14 -2
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +69 -0
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +67 -0
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +1 -1
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +1 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +24 -16
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.d.ts +3 -0
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.js +41 -3
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +7 -1
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +122 -38
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +135 -4
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +603 -90
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +14 -4
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -0
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +18 -2
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +23 -2
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +69 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +134 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +1 -1
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +1 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +45 -16
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.d.ts +3 -0
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.js +40 -3
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +7 -1
  108. package/dist/node_modules/@skrr-ai/auth-core/package.json +21 -1
  109. package/dist/node_modules/@skrr-ai/data-provider/index.js +7349 -7325
  110. package/oclif.manifest.json +11 -2
  111. package/package.json +4 -1
@@ -21,6 +21,28 @@ export declare function __setExecFileForTest(fn: ExecFileLike | null): void;
21
21
  export declare function __resetSecurityProbeCacheForTest(): void;
22
22
  /** @internal Test seam — clear the resolved-keychain cache. */
23
23
  export declare function __resetResolvedKeychainCacheForTest(): void;
24
+ /**
25
+ * Create a master KEK without replacing an existing item. This key is shared
26
+ * by profiles, so even a first-use race in a different process must not
27
+ * overwrite the key protecting already wrapped credentials.
28
+ *
29
+ * The `service` / `account` parameters are threaded from the calling
30
+ * `MacosKek` instance so each instance keeps its own keychain entry.
31
+ *
32
+ * Explicit-keychain target: the resolved login-keychain path is passed
33
+ * as the trailing positional argument to `add-generic-password` so
34
+ * `security(1)` cannot fall back to a "default keychain" lookup that
35
+ * triggers a system-modal `Keychain Not Found` dialog. If no keychain
36
+ * resolves, throw `KekUnavailableError` immediately rather than
37
+ * shelling out at all.
38
+ */
39
+ declare function writeKekToKeychain(kek: Buffer, service: string, account: string): Promise<void>;
40
+ /**
41
+ * @internal Test seam — the Keychain write on its own, so a spec can reach the live-state
42
+ * guard without `wrap()`'s `security` probe getting in the way. A spec using it empties `PATH`,
43
+ * so that a guard which stopped firing reaches no `security` binary.
44
+ */
45
+ export declare const __writeKekToKeychainForTest: typeof writeKekToKeychain;
24
46
  /**
25
47
  * Zero out and drop every cached master-KEK buffer.
26
48
  *
@@ -57,6 +57,8 @@ import fs from 'node:fs';
57
57
  import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize as envelopeSerialize, KEY_LEN, } from '../credentialEnvelope.js';
58
58
  import { KekUnavailableError } from './types.js';
59
59
  import { registerZeroizeHook } from './zeroize-registry.js';
60
+ import { MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE } from './masterKekService.js';
61
+ import { assertMayMutateLiveKeychain, rethrowIfLiveStateRefusal } from '../liveState.js';
60
62
  /**
61
63
  * Stable telemetry kind string — fleet dashboards size the macOS cohort
62
64
  * by this value. Only one backend ships in this module today.
@@ -68,8 +70,8 @@ const KIND_SHELLOUT = 'macos-keychain-shellout';
68
70
  * Test seams and smoke tests can override either field via the
69
71
  * `MacosKek` constructor without touching this canonical pair.
70
72
  */
71
- const KEYCHAIN_SERVICE = 'ai.skrr.daemon.kek';
72
- const KEYCHAIN_ACCOUNT = 'master-kek-v1';
73
+ const KEYCHAIN_SERVICE = MASTER_KEK_KEYCHAIN_SERVICE;
74
+ const KEYCHAIN_ACCOUNT = MASTER_KEK_KEYCHAIN_ACCOUNT;
73
75
  /** `security` exit code when the requested item does not exist. */
74
76
  const SECURITY_ITEM_NOT_FOUND = 44;
75
77
  const execFileAsync = promisify(execFile);
@@ -294,6 +296,15 @@ async function writeKekToKeychain(kek, service, account) {
294
296
  if (keychainPath === null) {
295
297
  throw new KekUnavailableError(KIND_SHELLOUT, 'no user login keychain resolvable; cannot write master KEK');
296
298
  }
299
+ // A test that injected a fake `security` writes nothing real. One that did not is about to add
300
+ // an item to the keychain it just resolved, and the master KEK is the one item that cannot be
301
+ // recreated. A keychain that is not the account's (a throwaway file, or one a faked `security`
302
+ // names) cannot hold the installed product's KEK, so only the account's is protected.
303
+ if (_execFileForTest === null) {
304
+ assertMayMutateLiveKeychain(service, `add the Keychain item ${account} (a master KEK)`, {
305
+ keychainFile: keychainPath,
306
+ });
307
+ }
297
308
  const b64 = kek.toString('base64');
298
309
  try {
299
310
  await exec('security', [
@@ -323,6 +334,12 @@ async function writeKekToKeychain(kek, service, account) {
323
334
  });
324
335
  }
325
336
  }
337
+ /**
338
+ * @internal Test seam — the Keychain write on its own, so a spec can reach the live-state
339
+ * guard without `wrap()`'s `security` probe getting in the way. A spec using it empties `PATH`,
340
+ * so that a guard which stopped firing reaches no `security` binary.
341
+ */
342
+ export const __writeKekToKeychainForTest = writeKekToKeychain;
326
343
  /**
327
344
  * In-process cache of master KEKs, keyed by `${service}::${account}` so
328
345
  * multiple `MacosKek` instances scoped to different keychain entries
@@ -409,6 +426,7 @@ async function resolveOrCreateKek(service, account) {
409
426
  // A concurrent creator may have won. Only a successful read of its
410
427
  // existing item makes the failed create recoverable; all other errors
411
428
  // remain failures and must not cause an update or a second write.
429
+ rethrowIfLiveStateRefusal(error);
412
430
  const winner = await readKekFromKeychain(service, account).catch(() => null);
413
431
  if (!winner)
414
432
  throw error;
@@ -546,6 +564,9 @@ export class MacosKek {
546
564
  catch (err) {
547
565
  if (err instanceof KekUnavailableError)
548
566
  throw err;
567
+ // A refusal is not "the KEK is unavailable": wrapping it would let the credential
568
+ // envelope read a test aimed at the real Keychain as a locked one and carry on.
569
+ rethrowIfLiveStateRefusal(err);
549
570
  throw new KekUnavailableError(this.id, err?.message ?? 'shellout wrap failed', {
550
571
  cause: err,
551
572
  });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * kek/masterKekService.ts — the one place that names the machine-wide master KEK.
3
+ *
4
+ * The master KEK is ONE keychain item shared by every profile on the machine
5
+ * (`service=ai.skrr.daemon.kek`, `account=master-kek-v1`). Each profile's
6
+ * `cred-dek-wrapped` is sealed under it, and everything a profile stores — its
7
+ * tokens, its BYOK keys, its device private key — is sealed under that DEK.
8
+ * Deleting this one item therefore orphans every credential envelope on the
9
+ * machine at once, and nothing can bring it back: the key is random.
10
+ *
11
+ * It was deleted by accident on 2026-10-29. The desktop app's sign-out purge
12
+ * enumerates every service matching `ai.(skrr|oversky).daemon*` and drains each
13
+ * one, because that prefix used to hold only per-profile credential slots. The
14
+ * KEK moved under the same prefix without the purge being told, so a routine
15
+ * "your saved session was rejected" at app launch deleted it. The next daemon
16
+ * start could not open the DEK, treated that as a rotation, enrolled a new one,
17
+ * and the device private key sealed under the old DEK was gone for good — which
18
+ * the server then saw as `DEVICE_PROOF_MISSING` on every CLI hand-off.
19
+ *
20
+ * The fix first written for it was a denylist on a wildcard: the sweep kept
21
+ * matching everything under `ai.(skrr|oversky).daemon*` and skipped the services
22
+ * it knew to be precious. That fails open. The default for a service added under
23
+ * that prefix tomorrow was "deleted", which is how the KEK went, and how the
24
+ * person's own BYOK keys were being swept as well.
25
+ *
26
+ * So this module names what a sweep may delete, not what it may not. Every
27
+ * service in the daemon's Keychain namespace is in exactly one class
28
+ * (`classifyDaemonKeychainService`), and a sign-out selects with
29
+ * `isSessionKeychainService`: a service nobody has classified is LEFT IN PLACE.
30
+ * Deleting the KEK is a decision a caller makes by name, never a side effect of
31
+ * a sweep.
32
+ */
33
+ /** Keychain `service` of the master KEK. */
34
+ export declare const MASTER_KEK_KEYCHAIN_SERVICE = "ai.skrr.daemon.kek";
35
+ /** Keychain `account` of the master KEK. */
36
+ export declare const MASTER_KEK_KEYCHAIN_ACCOUNT = "master-kek-v1";
37
+ /** Whether a keychain service name holds master-KEK material. */
38
+ export declare function isMasterKekKeychainService(service: string): boolean;
39
+ /** Whether a keychain service name holds a secret the person supplied, not a session. */
40
+ export declare function isUserOwnedSecretKeychainService(service: string): boolean;
41
+ /**
42
+ * Which kind of thing a service in the daemon's Keychain namespace holds. Exactly
43
+ * one, and the order of the checks in `classifyDaemonKeychainService` is what makes
44
+ * that true.
45
+ *
46
+ * `master_kek` key material every profile's envelope is sealed under. No sweep
47
+ * deletes it; a caller that must does so by name.
48
+ * `user_secret` what the person typed in (BYOK provider keys). A sign-out keeps
49
+ * it; a full uninstall removes it.
50
+ * `session` a profile's sign-in. A sign-out removes it.
51
+ * `unclassified` none of the above. Left in place, and reported, so that the next
52
+ * engineer who adds a service has to decide what it is.
53
+ */
54
+ export type DaemonKeychainServiceClass = 'master_kek' | 'user_secret' | 'session' | 'unclassified';
55
+ /**
56
+ * Classify one Keychain `service` name. The named non-members are asked FIRST: a
57
+ * profile may legally be called `byok.work` or `kek.x`, and its service then has
58
+ * the session shape AND the shape of the namespace it collides with. Reading it as
59
+ * the protected class is the safe direction (a sign-out leaves that profile's slots
60
+ * in place), so the protected classes win.
61
+ */
62
+ export declare function classifyDaemonKeychainService(service: string): DaemonKeychainServiceClass;
63
+ /**
64
+ * Whether a sign-out owns this service. This is the ONE predicate a sign-out sweep
65
+ * selects with (`services.filter(isSessionKeychainService)`): it names what is
66
+ * deleted, not what is spared. An uninstall adds `isUserOwnedSecretKeychainService`
67
+ * to it; no sweep adds the KEK.
68
+ */
69
+ export declare function isSessionKeychainService(service: string): boolean;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * kek/masterKekService.ts — the one place that names the machine-wide master KEK.
3
+ *
4
+ * The master KEK is ONE keychain item shared by every profile on the machine
5
+ * (`service=ai.skrr.daemon.kek`, `account=master-kek-v1`). Each profile's
6
+ * `cred-dek-wrapped` is sealed under it, and everything a profile stores — its
7
+ * tokens, its BYOK keys, its device private key — is sealed under that DEK.
8
+ * Deleting this one item therefore orphans every credential envelope on the
9
+ * machine at once, and nothing can bring it back: the key is random.
10
+ *
11
+ * It was deleted by accident on 2026-10-29. The desktop app's sign-out purge
12
+ * enumerates every service matching `ai.(skrr|oversky).daemon*` and drains each
13
+ * one, because that prefix used to hold only per-profile credential slots. The
14
+ * KEK moved under the same prefix without the purge being told, so a routine
15
+ * "your saved session was rejected" at app launch deleted it. The next daemon
16
+ * start could not open the DEK, treated that as a rotation, enrolled a new one,
17
+ * and the device private key sealed under the old DEK was gone for good — which
18
+ * the server then saw as `DEVICE_PROOF_MISSING` on every CLI hand-off.
19
+ *
20
+ * The fix first written for it was a denylist on a wildcard: the sweep kept
21
+ * matching everything under `ai.(skrr|oversky).daemon*` and skipped the services
22
+ * it knew to be precious. That fails open. The default for a service added under
23
+ * that prefix tomorrow was "deleted", which is how the KEK went, and how the
24
+ * person's own BYOK keys were being swept as well.
25
+ *
26
+ * So this module names what a sweep may delete, not what it may not. Every
27
+ * service in the daemon's Keychain namespace is in exactly one class
28
+ * (`classifyDaemonKeychainService`), and a sign-out selects with
29
+ * `isSessionKeychainService`: a service nobody has classified is LEFT IN PLACE.
30
+ * Deleting the KEK is a decision a caller makes by name, never a side effect of
31
+ * a sweep.
32
+ */
33
+ /** Keychain `service` of the master KEK. */
34
+ export const MASTER_KEK_KEYCHAIN_SERVICE = 'ai.skrr.daemon.kek';
35
+ /** Keychain `account` of the master KEK. */
36
+ export const MASTER_KEK_KEYCHAIN_ACCOUNT = 'master-kek-v1';
37
+ /**
38
+ * The master KEK under the current brand and the two names before the rename
39
+ * (`ai.oversky.daemon.kek`, and `com.oversky.daemon.kek`, which was the very first
40
+ * KEK identifier: see `LEGACY_NATIVE_ID_PREFIXES`), plus anything namespaced
41
+ * beneath any of them (`…kek.probe`, the Linux Secret Service probe). The
42
+ * pre-rename items are protected too: leaving a dead item behind costs bytes, and
43
+ * deleting key material is the one step that cannot be taken back.
44
+ */
45
+ const MASTER_KEK_SERVICE_PATTERN = /^(?:ai\.(?:skrr|oversky)|com\.oversky)\.daemon\.kek(?:\..*)?$/;
46
+ /** Whether a keychain service name holds master-KEK material. */
47
+ export function isMasterKekKeychainService(service) {
48
+ return typeof service === 'string' && MASTER_KEK_SERVICE_PATTERN.test(service);
49
+ }
50
+ /**
51
+ * The person's own secrets, kept under the same `ai.*.daemon.` prefix: local BYOK provider keys
52
+ * (`ai.skrr.daemon.byok.<profile>-<suffix>`, written by `skrr daemon byok set`).
53
+ *
54
+ * They are not part of a session, and they are not key material either. They have their own
55
+ * lifecycle (`byok set`, `byok remove`), so a sign-out, above all one triggered by a server that
56
+ * rejected the saved session, must not delete them: that is the person's data, typed in once, and
57
+ * the sign-out that took it never asked. Only a full uninstall removes them: it adds this
58
+ * predicate to `isSessionKeychainService`, and no other sweep selects these services.
59
+ */
60
+ const USER_OWNED_SECRET_SERVICE_PATTERN = /^ai\.(?:skrr|oversky)\.daemon\.byok(?:\..*)?$/;
61
+ /** Whether a keychain service name holds a secret the person supplied, not a session. */
62
+ export function isUserOwnedSecretKeychainService(service) {
63
+ return typeof service === 'string' && USER_OWNED_SECRET_SERVICE_PATTERN.test(service);
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // What a sign-out OWNS
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * A profile name as the daemon accepts it: `VALID_PROFILE_RE` in
70
+ * `daemon/src/profile.ts` (letters, digits, dot, dash, underscore; starts with a
71
+ * letter or digit; 64 characters at most). Dots are allowed, which is why the
72
+ * session grammar below cannot be told apart from the KEK or BYOK namespaces by
73
+ * its shape alone, and why the classifier asks those two first.
74
+ */
75
+ const PROFILE_NAME = '[A-Za-z0-9][A-Za-z0-9._-]{0,63}';
76
+ /**
77
+ * The config-dir suffix every current service carries: the first eight hex
78
+ * characters of sha256 of the profile's config dir (`configDirSuffix` in
79
+ * `daemon/src/keychain.ts`, mirrored by the desktop supervisor).
80
+ */
81
+ const CONFIG_DIR_HASH = '-[a-f0-9]{8}';
82
+ /**
83
+ * The session grammar: the services one profile's sign-in lives in, which are the
84
+ * ones a sign-out is for. It is POSITIVE and deliberately narrow.
85
+ *
86
+ * ai.skrr.daemon.<profile>-<hash8>
87
+ * The current layout, written by the daemon and the desktop supervisor
88
+ * (`computeKeychainServiceForProfile`, and `machine-dek.ts`). The hash is
89
+ * REQUIRED. `ai.skrr.daemon.<profile>` with no hash is the same shape as the
90
+ * KEK (`ai.skrr.daemon.kek`, a "profile" called kek), and no current code
91
+ * writes one, so a name of that shape is not a session: it is unclassified.
92
+ *
93
+ * ai.oversky.daemon[.<profile>[-<hash8>]]
94
+ * The three shapes the retired brand left on machines carried through the
95
+ * rename: the pre-Phase-K bare service, the per-profile service, and the
96
+ * per-profile service with the config-dir hash. The brand is retired, so this
97
+ * set is closed: nothing new is ever written under it. A sign-out still has to
98
+ * clear them, or a person who signed out keeps a live refresh token in the
99
+ * Keychain.
100
+ *
101
+ * Anything else, including a service added under `ai.skrr.daemon` tomorrow, is not
102
+ * matched here, and a sweep that selects with this leaves it alone.
103
+ */
104
+ const SESSION_SERVICE_PATTERNS = [
105
+ new RegExp(`^ai\\.skrr\\.daemon\\.${PROFILE_NAME}${CONFIG_DIR_HASH}$`),
106
+ new RegExp(`^ai\\.oversky\\.daemon(?:\\.${PROFILE_NAME}(?:${CONFIG_DIR_HASH})?)?$`),
107
+ ];
108
+ /**
109
+ * Classify one Keychain `service` name. The named non-members are asked FIRST: a
110
+ * profile may legally be called `byok.work` or `kek.x`, and its service then has
111
+ * the session shape AND the shape of the namespace it collides with. Reading it as
112
+ * the protected class is the safe direction (a sign-out leaves that profile's slots
113
+ * in place), so the protected classes win.
114
+ */
115
+ export function classifyDaemonKeychainService(service) {
116
+ if (typeof service !== 'string')
117
+ return 'unclassified';
118
+ if (isMasterKekKeychainService(service))
119
+ return 'master_kek';
120
+ if (isUserOwnedSecretKeychainService(service))
121
+ return 'user_secret';
122
+ if (SESSION_SERVICE_PATTERNS.some((pattern) => pattern.test(service)))
123
+ return 'session';
124
+ return 'unclassified';
125
+ }
126
+ /**
127
+ * Whether a sign-out owns this service. This is the ONE predicate a sign-out sweep
128
+ * selects with (`services.filter(isSessionKeychainService)`): it names what is
129
+ * deleted, not what is spared. An uninstall adds `isUserOwnedSecretKeychainService`
130
+ * to it; no sweep adds the KEK.
131
+ */
132
+ export function isSessionKeychainService(service) {
133
+ return classifyDaemonKeychainService(service) === 'session';
134
+ }
@@ -0,0 +1,154 @@
1
+ import { CONFIG_DIR_NAME } from './localIdentity.js';
2
+ /** Directory name of the state root inside a home directory. */
3
+ export { CONFIG_DIR_NAME };
4
+ /** Opt-in for a deliberate check against the machine's real state. */
5
+ export declare const ALLOW_LIVE_STATE_ENV = "SKRR_TEST_ALLOW_LIVE_STATE";
6
+ /** Set by every hermetic runner to the throwaway home it gave the process. */
7
+ export declare const HERMETIC_HOME_ENV = "SKRR_TEST_HERMETIC_HOME";
8
+ /** `LiveStateRefusedError.code`. Kept at its pre-consolidation value: scripts match on it. */
9
+ export declare const LIVE_STATE_REFUSED_CODE = "SKRR_TEST_LIVE_STATE";
10
+ /**
11
+ * Which signal says this is a test process, or `null`. The text names the signal so a false
12
+ * positive (a shell that exports `NODE_ENV=test`) diagnoses itself.
13
+ *
14
+ * `NODE_TEST_CONTEXT` is set by `node --test` in every file's child process and inherited by
15
+ * anything a test spawns. `--test` in `execArgv` covers `--test-isolation=none`, where tests run
16
+ * in the runner itself. A test file run DIRECTLY (`node file.test.ts`) has neither: node:test
17
+ * sets no marker for it, so the entry script's name is the only evidence. `JEST_WORKER_ID` is
18
+ * set in the real environment of every jest worker (and by `--runInBand`), so a child a spec
19
+ * spawns without `env` still carries it. `NODE_ENV=test` is what jest, vitest and `bun test`
20
+ * default to, and what custom scripts set by hand.
21
+ *
22
+ * `argv` is the process's own only when `env` is: a caller describing a hypothetical process
23
+ * (`{}`) describes all of it.
24
+ */
25
+ export declare function testProcessSignal(env?: NodeJS.ProcessEnv, execArgv?: readonly string[], argv?: readonly string[]): string | null;
26
+ /** Is this a test process? See `testProcessSignal` for what counts. */
27
+ export declare function isTestProcess(env?: NodeJS.ProcessEnv, execArgv?: readonly string[], argv?: readonly string[]): boolean;
28
+ /** Should a test process be refused access to the machine's real state? */
29
+ export declare function refusesLiveStateInTest(env?: NodeJS.ProcessEnv, execArgv?: readonly string[], argv?: readonly string[]): boolean;
30
+ /**
31
+ * The account's home directory from the user database. A test that points `HOME` somewhere else
32
+ * cannot move it, which is the point: it names where the INSTALLED product keeps its state,
33
+ * whatever this process believes.
34
+ *
35
+ * Node's `os.userInfo().homedir` reads the user database. Bun's returns `$HOME`, which is
36
+ * exactly what a test moves, so the compiled daemon asks the database itself — without that, a
37
+ * compiled binary spawned by a test with its own `HOME` saw that home as the live one and
38
+ * refused to start. `null` when it cannot be determined.
39
+ */
40
+ export declare function accountHomeDir(): string | null;
41
+ /**
42
+ * @internal Test seam — make `accountHomeDir()` answer `home` (`null`: "cannot be determined"),
43
+ * or restore the user-database lookup with `undefined`. It lets a spec stand a THROWAWAY
44
+ * directory in for the account's real home, so the guard can be exercised end to end — and its
45
+ * refusal removed, to prove the spec notices — without any path ever touching the real one.
46
+ * Never called from production code; `stateFileBoundary.spec.ts` fails if it is.
47
+ */
48
+ export declare function __setAccountHomeForTest(home: string | null | undefined): void;
49
+ /** The state root of the product installed for this account, when it can be known. */
50
+ export declare function liveStateRoot(home?: string | null): string | null;
51
+ /**
52
+ * What a guard decision is made against. Everything defaults to the running process; a spec
53
+ * injects a throwaway directory as `realHome` to prove the guard fires WITHOUT going near the
54
+ * machine's actual home.
55
+ */
56
+ export interface LiveStateProbe {
57
+ env?: NodeJS.ProcessEnv;
58
+ execArgv?: readonly string[];
59
+ argv?: readonly string[];
60
+ /** The account's real home. `null` means "cannot be determined". Default: the user database. */
61
+ realHome?: string | null;
62
+ }
63
+ export declare class LiveStateRefusedError extends Error {
64
+ readonly code = "SKRR_TEST_LIVE_STATE";
65
+ /** What the mutation was aimed at: a path, or a Keychain service. */
66
+ readonly target: string;
67
+ /** Why this process was taken for a test. */
68
+ readonly signal: string;
69
+ constructor(what: string, target: string, signal: string, why: string, hint?: string);
70
+ }
71
+ /**
72
+ * Is `err` a live-state refusal? By name and code as well as by class: auth-core ships as both
73
+ * ESM and CJS, and two copies of it in one process (a farmed worktree) have two classes.
74
+ */
75
+ export declare function isLiveStateRefusal(err: unknown): err is LiveStateRefusedError;
76
+ /**
77
+ * First line of any `catch` that wraps a guarded mutation. A refusal swallowed into a telemetry
78
+ * event is a write that silently did not happen under a test that reports green, which is the
79
+ * "everything reads healthy" shape this repo has paid for before. A no-op outside a test
80
+ * process, where no refusal is ever constructed.
81
+ */
82
+ export declare function rethrowIfLiveStateRefusal(err: unknown): void;
83
+ /**
84
+ * Throw `LiveStateRefusedError` when a test process is about to mutate `target`, a path under
85
+ * the account's real home. Returns without reading anything else outside a test process.
86
+ *
87
+ * `what` completes "Refusing to …" (`replace the wrapped DEK`). `hint` replaces the default
88
+ * advice about how to run the test safely.
89
+ */
90
+ export declare function assertMayMutateLiveState(target: string, what: string, probe?: LiveStateProbe, hint?: string): void;
91
+ /** Service name prefix of the daemon's credential slots; always followed by `.<profile>`. */
92
+ export declare const DAEMON_KEYCHAIN_SERVICE_BASE = "ai.skrr.daemon";
93
+ /**
94
+ * Separate Keychain service for local BYOK secrets.
95
+ *
96
+ * Keeping provider keys out of the daemon-auth service prevents a broad "export daemon
97
+ * credentials" operation from accidentally including them and lets the user remove a provider
98
+ * key without disturbing their login.
99
+ */
100
+ export declare const DAEMON_BYOK_KEYCHAIN_SERVICE_BASE = "ai.skrr.daemon.byok";
101
+ /**
102
+ * The PRE-RENAME, pre-Phase-K service name. Read ONLY by the daemon's migration helper.
103
+ *
104
+ * It must NOT equal `DAEMON_KEYCHAIN_SERVICE_BASE`, and it held that value until a fix: the
105
+ * skrr rename swept the string like any other, and the two became identical. Nothing failed,
106
+ * because the failure is an absence — `migrateLegacyServiceToProfile` read a service nothing had
107
+ * ever written, found no slots, and reported success. Pre-rename credentials stayed where they
108
+ * were and the user silently re-authenticated. Written as a literal on purpose.
109
+ */
110
+ export declare const LEGACY_DAEMON_KEYCHAIN_SERVICE = "ai.oversky.daemon";
111
+ /** The profile the installed daemon runs when nothing selects another. */
112
+ export declare const LIVE_DEFAULT_PROFILE = "default";
113
+ /**
114
+ * `-<sha256(dir)[0:8]>`, the suffix of every per-profile Keychain service name. The input is the
115
+ * absolute, trailing-slash-stripped config directory, so two OS users running the daemon under
116
+ * their own `~/.skrr/` get distinct names and cannot read each other's tokens.
117
+ */
118
+ export declare function keychainSuffixForDir(dir: string): string;
119
+ /**
120
+ * Keychain services that belong to the product installed on this machine: the master KEK
121
+ * (current brand and the ones before it), the default profile's credential and BYOK slots under
122
+ * the account's real home, and the legacy names the daemon migrates from on start — a write to
123
+ * one of those is adopted by the next restart instead of the next read.
124
+ *
125
+ * The per-profile names are derived from the ACCOUNT's home, which no `HOME` override can reach.
126
+ * When the home is unknown only the names that need no home are returned; see
127
+ * `isLiveKeychainService` for what is done about the rest.
128
+ */
129
+ export declare function liveKeychainServices(probe?: Pick<LiveStateProbe, 'realHome'>): ReadonlySet<string>;
130
+ /**
131
+ * Does `service` name an item of the installed product? When the real home is unknown, a
132
+ * default-profile slot with a hash suffix is assumed live: its hash cannot be checked, and the
133
+ * guard fails closed.
134
+ */
135
+ export declare function isLiveKeychainService(service: string, probe?: Pick<LiveStateProbe, 'realHome'>): boolean;
136
+ /** Would a mutation of Keychain `service` be refused in a process with this shape? */
137
+ export declare function refusesLiveKeychainMutation(service: string, probe?: LiveStateProbe): boolean;
138
+ /** What a Keychain guard decision is made against. */
139
+ export interface LiveKeychainProbe extends LiveStateProbe {
140
+ /**
141
+ * The keychain FILE the write is aimed at, when the caller knows it (macOS passes the login
142
+ * keychain it resolved). A keychain outside the real home is not the account's, so nothing
143
+ * the installed product reads can be in it, whatever the service is called: a throwaway
144
+ * keychain file, or a fake `security` that answers with a path of its own. Without it, the
145
+ * service name alone decides.
146
+ */
147
+ keychainFile?: string;
148
+ }
149
+ /**
150
+ * Throw `LiveStateRefusedError` when a test process is about to write or delete an item in a
151
+ * Keychain service the installed product reads. Callers that can be handed a fake `security`
152
+ * binary call this only when the real one would run: a mocked write touches nothing.
153
+ */
154
+ export declare function assertMayMutateLiveKeychain(service: string, what: string, probe?: LiveKeychainProbe, hint?: string): void;