crbro-memory 2.9.1 → 2.11.0

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 (197) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -456
  3. package/SECURITY.md +135 -8
  4. package/bin/crbro.mjs +555 -299
  5. package/dist/build-id.json +10 -0
  6. package/dist/cli/common.js +211 -0
  7. package/dist/cli/export.js +522 -0
  8. package/dist/cli/import.js +683 -0
  9. package/dist/cli/markdown.js +313 -0
  10. package/dist/cli/mcpclient.js +107 -0
  11. package/dist/cli/serve.js +453 -0
  12. package/dist/cli/view.js +358 -0
  13. package/dist/cli/viewer-page.js +378 -0
  14. package/dist/daemon/daemon.js +119 -11
  15. package/dist/daemon/endpoint.js +222 -21
  16. package/dist/daemon/lines.js +102 -13
  17. package/dist/daemon/proxy.js +480 -125
  18. package/dist/daemon/surface.js +116 -0
  19. package/dist/daemon/trace.js +73 -0
  20. package/dist/engine/atrest.js +132 -0
  21. package/dist/engine/backup.js +143 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +762 -311
  25. package/dist/engine/doctor.js +676 -0
  26. package/dist/engine/evidence.js +171 -0
  27. package/dist/engine/heat.js +81 -37
  28. package/dist/engine/hippocampus.js +50 -36
  29. package/dist/engine/hookrefresh.js +296 -0
  30. package/dist/engine/hookverify.js +15 -3
  31. package/dist/engine/installer.js +1022 -0
  32. package/dist/engine/installio.js +279 -0
  33. package/dist/engine/keychain.js +735 -144
  34. package/dist/engine/maintenance.js +355 -87
  35. package/dist/engine/modinstall.js +98 -45
  36. package/dist/engine/prefrontal.js +126 -69
  37. package/dist/engine/projects.js +80 -0
  38. package/dist/engine/quarantine.js +204 -0
  39. package/dist/engine/secrets.js +41 -5
  40. package/dist/engine/shelf.js +34 -11
  41. package/dist/engine/synapses.js +183 -80
  42. package/dist/engine/triggers.js +141 -11
  43. package/dist/index.js +37 -3
  44. package/dist/mcp-surface.json +1 -0
  45. package/dist/miner/extractor.js +64 -17
  46. package/dist/miner/scheduler.js +144 -68
  47. package/dist/search/index.js +810 -176
  48. package/dist/search/quality.js +31 -0
  49. package/dist/search/semantic.js +208 -18
  50. package/dist/search/tokenize.js +44 -0
  51. package/dist/server.js +649 -343
  52. package/dist/sync/git.js +203 -72
  53. package/dist/sync/materialize.js +33 -8
  54. package/dist/sync/space.js +286 -101
  55. package/dist/sync/validate.js +204 -0
  56. package/dist/utils/budget.js +15 -2
  57. package/dist/utils/fs.js +40 -6
  58. package/dist/utils/ids.js +128 -14
  59. package/dist/utils/lock.js +219 -52
  60. package/hooks/crbro-codex.mjs +284 -0
  61. package/hooks/crbro-guard.mjs +281 -173
  62. package/hooks/crbro-hooks.shipped.json +20 -0
  63. package/hooks/crbro-lifecycle.mjs +16 -4
  64. package/hooks/crbro-subagent.mjs +180 -180
  65. package/package.json +12 -8
  66. package/dist/daemon/daemon.d.ts +0 -25
  67. package/dist/daemon/daemon.d.ts.map +0 -1
  68. package/dist/daemon/daemon.js.map +0 -1
  69. package/dist/daemon/endpoint.d.ts +0 -66
  70. package/dist/daemon/endpoint.d.ts.map +0 -1
  71. package/dist/daemon/endpoint.js.map +0 -1
  72. package/dist/daemon/lines.d.ts +0 -55
  73. package/dist/daemon/lines.d.ts.map +0 -1
  74. package/dist/daemon/lines.js.map +0 -1
  75. package/dist/daemon/proxy.d.ts +0 -55
  76. package/dist/daemon/proxy.d.ts.map +0 -1
  77. package/dist/daemon/proxy.js.map +0 -1
  78. package/dist/engine/backup.d.ts +0 -68
  79. package/dist/engine/backup.d.ts.map +0 -1
  80. package/dist/engine/backup.js.map +0 -1
  81. package/dist/engine/brain.d.ts +0 -79
  82. package/dist/engine/brain.d.ts.map +0 -1
  83. package/dist/engine/brain.js.map +0 -1
  84. package/dist/engine/cortex.d.ts +0 -670
  85. package/dist/engine/cortex.d.ts.map +0 -1
  86. package/dist/engine/cortex.js.map +0 -1
  87. package/dist/engine/dates.d.ts +0 -28
  88. package/dist/engine/dates.d.ts.map +0 -1
  89. package/dist/engine/dates.js.map +0 -1
  90. package/dist/engine/heat.d.ts +0 -28
  91. package/dist/engine/heat.d.ts.map +0 -1
  92. package/dist/engine/heat.js.map +0 -1
  93. package/dist/engine/hippocampus.d.ts +0 -60
  94. package/dist/engine/hippocampus.d.ts.map +0 -1
  95. package/dist/engine/hippocampus.js.map +0 -1
  96. package/dist/engine/hookverify.d.ts +0 -42
  97. package/dist/engine/hookverify.d.ts.map +0 -1
  98. package/dist/engine/hookverify.js.map +0 -1
  99. package/dist/engine/keychain.d.ts +0 -32
  100. package/dist/engine/keychain.d.ts.map +0 -1
  101. package/dist/engine/keychain.js.map +0 -1
  102. package/dist/engine/maintenance.d.ts +0 -218
  103. package/dist/engine/maintenance.d.ts.map +0 -1
  104. package/dist/engine/maintenance.js.map +0 -1
  105. package/dist/engine/modinstall.d.ts +0 -215
  106. package/dist/engine/modinstall.d.ts.map +0 -1
  107. package/dist/engine/modinstall.js.map +0 -1
  108. package/dist/engine/postmortem.d.ts +0 -81
  109. package/dist/engine/postmortem.d.ts.map +0 -1
  110. package/dist/engine/postmortem.js.map +0 -1
  111. package/dist/engine/prefrontal.d.ts +0 -61
  112. package/dist/engine/prefrontal.d.ts.map +0 -1
  113. package/dist/engine/prefrontal.js.map +0 -1
  114. package/dist/engine/secrets.d.ts +0 -30
  115. package/dist/engine/secrets.d.ts.map +0 -1
  116. package/dist/engine/secrets.js.map +0 -1
  117. package/dist/engine/shelf.d.ts +0 -156
  118. package/dist/engine/shelf.d.ts.map +0 -1
  119. package/dist/engine/shelf.js.map +0 -1
  120. package/dist/engine/source.d.ts +0 -6
  121. package/dist/engine/source.d.ts.map +0 -1
  122. package/dist/engine/source.js.map +0 -1
  123. package/dist/engine/synapses.d.ts +0 -82
  124. package/dist/engine/synapses.d.ts.map +0 -1
  125. package/dist/engine/synapses.js.map +0 -1
  126. package/dist/engine/triggers.d.ts +0 -53
  127. package/dist/engine/triggers.d.ts.map +0 -1
  128. package/dist/engine/triggers.js.map +0 -1
  129. package/dist/engine/usage.d.ts +0 -61
  130. package/dist/engine/usage.d.ts.map +0 -1
  131. package/dist/engine/usage.js.map +0 -1
  132. package/dist/index.d.ts +0 -2
  133. package/dist/index.d.ts.map +0 -1
  134. package/dist/index.js.map +0 -1
  135. package/dist/miner/extractor.d.ts +0 -12
  136. package/dist/miner/extractor.d.ts.map +0 -1
  137. package/dist/miner/extractor.js.map +0 -1
  138. package/dist/miner/index.d.ts +0 -61
  139. package/dist/miner/index.d.ts.map +0 -1
  140. package/dist/miner/index.js.map +0 -1
  141. package/dist/miner/scheduler.d.ts +0 -30
  142. package/dist/miner/scheduler.d.ts.map +0 -1
  143. package/dist/miner/scheduler.js.map +0 -1
  144. package/dist/miner/types.d.ts +0 -11
  145. package/dist/miner/types.d.ts.map +0 -1
  146. package/dist/miner/types.js.map +0 -1
  147. package/dist/search/index.d.ts +0 -323
  148. package/dist/search/index.d.ts.map +0 -1
  149. package/dist/search/index.js.map +0 -1
  150. package/dist/search/semantic.d.ts +0 -80
  151. package/dist/search/semantic.d.ts.map +0 -1
  152. package/dist/search/semantic.js.map +0 -1
  153. package/dist/search/synonyms.d.ts +0 -2
  154. package/dist/search/synonyms.d.ts.map +0 -1
  155. package/dist/search/synonyms.js.map +0 -1
  156. package/dist/search/tokenize.d.ts +0 -39
  157. package/dist/search/tokenize.d.ts.map +0 -1
  158. package/dist/search/tokenize.js.map +0 -1
  159. package/dist/server.d.ts +0 -41
  160. package/dist/server.d.ts.map +0 -1
  161. package/dist/server.js.map +0 -1
  162. package/dist/sync/git.d.ts +0 -36
  163. package/dist/sync/git.d.ts.map +0 -1
  164. package/dist/sync/git.js.map +0 -1
  165. package/dist/sync/materialize.d.ts +0 -35
  166. package/dist/sync/materialize.d.ts.map +0 -1
  167. package/dist/sync/materialize.js.map +0 -1
  168. package/dist/sync/ops.d.ts +0 -142
  169. package/dist/sync/ops.d.ts.map +0 -1
  170. package/dist/sync/ops.js.map +0 -1
  171. package/dist/sync/space.d.ts +0 -116
  172. package/dist/sync/space.d.ts.map +0 -1
  173. package/dist/sync/space.js.map +0 -1
  174. package/dist/types/index.d.ts +0 -346
  175. package/dist/types/index.d.ts.map +0 -1
  176. package/dist/types/index.js.map +0 -1
  177. package/dist/utils/budget.d.ts +0 -44
  178. package/dist/utils/budget.d.ts.map +0 -1
  179. package/dist/utils/budget.js.map +0 -1
  180. package/dist/utils/fs.d.ts +0 -59
  181. package/dist/utils/fs.d.ts.map +0 -1
  182. package/dist/utils/fs.js.map +0 -1
  183. package/dist/utils/hash.d.ts +0 -20
  184. package/dist/utils/hash.d.ts.map +0 -1
  185. package/dist/utils/hash.js.map +0 -1
  186. package/dist/utils/ids.d.ts +0 -47
  187. package/dist/utils/ids.d.ts.map +0 -1
  188. package/dist/utils/ids.js.map +0 -1
  189. package/dist/utils/lock.d.ts +0 -11
  190. package/dist/utils/lock.d.ts.map +0 -1
  191. package/dist/utils/lock.js.map +0 -1
  192. package/dist/utils/transcripts.d.ts +0 -63
  193. package/dist/utils/transcripts.d.ts.map +0 -1
  194. package/dist/utils/transcripts.js.map +0 -1
  195. package/dist/version.d.ts +0 -26
  196. package/dist/version.d.ts.map +0 -1
  197. package/dist/version.js.map +0 -1
@@ -25,20 +25,48 @@
25
25
  // The store lives OUTSIDE the brain on purpose. Nothing under ~/.crbro is ever
26
26
  // consulted here, so no sync, no team space and no `crbro_share` can reach a
27
27
  // secret even if some future bug tried to.
28
+ //
29
+ // 2.11. Every operation is written once, as a generator that yields the
30
+ // commands it needs, and driven two ways: synchronously (the CLI and the
31
+ // callers that already used the sync API) or asynchronously, so that a slow
32
+ // PowerShell start inside the shared daemon no longer blocks every other
33
+ // conversation. The probe is memoised; the Windows file is never treated as
34
+ // empty when it has gone missing; and every successful write keeps a rotated
35
+ // byte copy of the sealed file next to it.
28
36
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.KeychainUnavailable = void 0;
37
+ exports.KeychainMissing = exports.KeychainUnavailable = exports.SECRET_NAME_RE = void 0;
30
38
  exports.detectBackend = detectBackend;
39
+ exports.detectBackendAsync = detectBackendAsync;
40
+ exports.listSealedCopies = listSealedCopies;
41
+ exports.restoreSealedCopy = restoreSealedCopy;
31
42
  exports.setSecret = setSecret;
32
43
  exports.getSecret = getSecret;
33
44
  exports.listSecrets = listSecrets;
34
45
  exports.removeSecret = removeSecret;
46
+ exports.setSecretAsync = setSecretAsync;
47
+ exports.getSecretAsync = getSecretAsync;
48
+ exports.listSecretsAsync = listSecretsAsync;
49
+ exports.removeSecretAsync = removeSecretAsync;
50
+ exports.secretConfirmToken = secretConfirmToken;
51
+ exports.sealedCopyCount = sealedCopyCount;
35
52
  exports._resetWindowsStoreForTests = _resetWindowsStoreForTests;
53
+ exports._setKeychainRuntimeForTests = _setKeychainRuntimeForTests;
54
+ exports._resetKeychainMemoForTests = _resetKeychainMemoForTests;
36
55
  const node_child_process_1 = require("node:child_process");
56
+ const node_crypto_1 = require("node:crypto");
37
57
  const node_os_1 = require("node:os");
38
58
  const node_path_1 = require("node:path");
39
59
  const node_fs_1 = require("node:fs");
60
+ const lock_js_1 = require("../utils/lock.js");
40
61
  /** Namespace under which every CRBRO secret is filed in the OS store. */
41
62
  const SERVICE = 'crbro';
63
+ /** The one shape a secret name may take, on every action. */
64
+ exports.SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
65
+ function checkName(name) {
66
+ if (typeof name !== 'string' || !exports.SECRET_NAME_RE.test(name)) {
67
+ throw new Error(`Invalid secret name "${String(name).slice(0, 60)}". Use SCREAMING_SNAKE_CASE, e.g. WORDPRESS_APP_PASSWORD.`);
68
+ }
69
+ }
42
70
  /**
43
71
  * Windows only: the sealed file, deliberately a sibling of the brain, never
44
72
  * inside it. CRBRO_KEYS_DIR redirects it, which is how the tests avoid writing
@@ -47,7 +75,14 @@ const SERVICE = 'crbro';
47
75
  */
48
76
  function winPaths() {
49
77
  const dir = process.env['CRBRO_KEYS_DIR'] || (0, node_path_1.join)((0, node_os_1.homedir)(), '.crbro-keys');
50
- return { dir, file: (0, node_path_1.join)(dir, 'keys.dpapi') };
78
+ return {
79
+ dir,
80
+ file: (0, node_path_1.join)(dir, 'keys.dpapi'),
81
+ /** Plain, unsealed: says a store existed here. Holds dates only, never names. */
82
+ marker: (0, node_path_1.join)(dir, 'keys.dpapi.marker'),
83
+ /** Byte copies of the sealed file, never opened except to validate a restore. */
84
+ copies: (0, node_path_1.join)(dir, 'sealed-copies'),
85
+ };
51
86
  }
52
87
  class KeychainUnavailable extends Error {
53
88
  reason;
@@ -58,38 +93,139 @@ class KeychainUnavailable extends Error {
58
93
  }
59
94
  }
60
95
  exports.KeychainUnavailable = KeychainUnavailable;
61
- /** Runs a command without a shell, so a value with spaces or quotes cannot be reinterpreted. */
62
- function run(cmd, args, input) {
63
- return (0, node_child_process_1.spawnSync)(cmd, args, {
96
+ /**
97
+ * The Windows store file is gone but something says it existed: the marker, a
98
+ * sealed copy or a leftover temporary file. Reading that as "empty" is how a
99
+ * keychain of seventy entries turned into one of a single entry (2.10).
100
+ */
101
+ class KeychainMissing extends KeychainUnavailable {
102
+ evidence;
103
+ constructor(reason, evidence) {
104
+ super(reason);
105
+ this.evidence = evidence;
106
+ this.name = 'KeychainMissing';
107
+ }
108
+ }
109
+ exports.KeychainMissing = KeychainMissing;
110
+ const RUN_TIMEOUT_MS = 30_000;
111
+ /**
112
+ * On Windows a bare command name is searched in the child's working directory
113
+ * before PATH, so a powershell.exe dropped into whatever folder the client was
114
+ * opened in would run instead of the real one. The system copy has a fixed
115
+ * place; use it whenever it is there.
116
+ */
117
+ function resolveCommand(cmd) {
118
+ if ((0, node_os_1.platform)() === 'win32' && cmd === 'powershell.exe') {
119
+ const root = process.env['SystemRoot'] || process.env['windir'] || 'C:\\Windows';
120
+ const abs = (0, node_path_1.join)(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
121
+ if ((0, node_fs_1.existsSync)(abs))
122
+ return abs;
123
+ }
124
+ return cmd;
125
+ }
126
+ function runSyncReal(c) {
127
+ const r = (0, node_child_process_1.spawnSync)(resolveCommand(c.cmd), c.args, {
64
128
  encoding: 'utf8',
65
- input,
66
- timeout: 30_000,
129
+ input: c.input,
130
+ timeout: RUN_TIMEOUT_MS,
67
131
  windowsHide: true,
68
132
  });
133
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: r.error };
69
134
  }
70
- /**
71
- * Which store this machine can actually use — not which one it should have.
72
- * Returns null with a reason the caller can show the user, because "no
73
- * keychain here" is a normal answer on a headless box, not a failure.
74
- */
75
- function detectBackend() {
76
- const os = (0, node_os_1.platform)();
77
- if (os === 'darwin') {
78
- const probe = run('security', ['-h']);
79
- if (probe.error)
135
+ function runAsyncReal(c) {
136
+ return new Promise(resolve => {
137
+ let stdout = '';
138
+ let stderr = '';
139
+ let settled = false;
140
+ const done = (r) => { if (!settled) {
141
+ settled = true;
142
+ clearTimeout(timer);
143
+ resolve(r);
144
+ } };
145
+ let child;
146
+ try {
147
+ child = (0, node_child_process_1.spawn)(resolveCommand(c.cmd), c.args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
148
+ }
149
+ catch (err) {
150
+ done({ status: null, stdout: '', stderr: '', error: err });
151
+ return;
152
+ }
153
+ const timer = setTimeout(() => {
154
+ child.kill();
155
+ done({ status: null, stdout, stderr, error: Object.assign(new Error(`${c.cmd} timed out`), { code: 'ETIMEDOUT' }) });
156
+ }, RUN_TIMEOUT_MS);
157
+ child.stdout.setEncoding('utf8');
158
+ child.stderr.setEncoding('utf8');
159
+ child.stdout.on('data', d => { stdout += d; });
160
+ child.stderr.on('data', d => { stderr += d; });
161
+ child.on('error', err => done({ status: null, stdout, stderr, error: err }));
162
+ child.on('close', code => done({ status: code, stdout, stderr }));
163
+ child.stdin.on('error', () => { });
164
+ child.stdin.end(c.input ?? '');
165
+ });
166
+ }
167
+ let runSync = runSyncReal;
168
+ let runAsync = runAsyncReal;
169
+ let platformOverride = null;
170
+ const os = () => platformOverride ?? (0, node_os_1.platform)();
171
+ function driveSync(g) {
172
+ let step = g.next();
173
+ while (!step.done)
174
+ step = g.next(runSync(step.value));
175
+ return step.value;
176
+ }
177
+ async function driveAsync(g) {
178
+ let step = g.next();
179
+ while (!step.done)
180
+ step = g.next(await runAsync(step.value));
181
+ return step.value;
182
+ }
183
+ /** The lock that serialises read-modify-write on every backend (macOS and Linux keep an index). */
184
+ function storeLockTarget() {
185
+ return winPaths().file;
186
+ }
187
+ function lockedSync(make) {
188
+ return (0, lock_js_1.withLockSync)(storeLockTarget(), () => driveSync(make()));
189
+ }
190
+ function lockedAsync(make) {
191
+ return (0, lock_js_1.withLock)(storeLockTarget(), () => driveAsync(make()));
192
+ }
193
+ // ─── Which store, memoised ───────────────────────────────────────
194
+ // The probe is a PowerShell start on Windows, around half a second, and it
195
+ // used to run before every get, list and set. Its answer does not change from
196
+ // one call to the next: a success is kept five minutes, a failure thirty
197
+ // seconds (a transient failure should not hide a store for long).
198
+ const PROBE_OK_MS = 5 * 60_000;
199
+ const PROBE_FAIL_MS = 30_000;
200
+ let probe = null;
201
+ function* detectGen() {
202
+ const current = os();
203
+ if (probe && probe.os === current) {
204
+ const ttl = probe.result.backend ? PROBE_OK_MS : PROBE_FAIL_MS;
205
+ if (Date.now() - probe.at < ttl)
206
+ return probe.result;
207
+ }
208
+ const result = yield* probeGen(current);
209
+ probe = { result, at: Date.now(), os: current };
210
+ return result;
211
+ }
212
+ function* probeGen(current) {
213
+ if (current === 'darwin') {
214
+ const r = yield { cmd: 'security', args: ['-h'] };
215
+ if (r.error)
80
216
  return { backend: null, reason: 'security(1) not found — unexpected on macOS.' };
81
217
  return { backend: 'macos-keychain' };
82
218
  }
83
- if (os === 'win32') {
219
+ if (current === 'win32') {
84
220
  // Starting PowerShell is not the same as being able to encrypt. On CI
85
221
  // runners and locked-down corporate machines it answers happily and then
86
222
  // fails at the first write, so the probe seals a byte for real.
87
- const probe = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', DPAPI_PROBE]);
88
- if (probe.error || probe.status !== 0 || !probe.stdout.includes('ok')) {
223
+ const r = yield { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', DPAPI_PROBE] };
224
+ if (r.error || r.status !== 0 || !r.stdout.includes('ok')) {
89
225
  return {
90
226
  backend: null,
91
227
  reason: 'DPAPI is not reachable from PowerShell here, which happens on CI runners and ' +
92
- 'locked-down machines. Credentials can still be passed as environment variables.',
228
+ 'locked-down machines. Credentials can still be passed as CRBRO_SECRET_<NAME> environment variables.',
93
229
  };
94
230
  }
95
231
  return { backend: 'windows-dpapi' };
@@ -97,25 +233,59 @@ function detectBackend() {
97
233
  // Linux and the BSDs. secret-tool needs a running Secret Service over D-Bus;
98
234
  // over SSH with no desktop session there is usually none, and that is worth
99
235
  // saying plainly rather than failing with a D-Bus error.
100
- const probe = run('secret-tool', ['--help']);
101
- if (probe.error) {
236
+ const r = yield { cmd: 'secret-tool', args: ['--help'] };
237
+ if (r.error) {
102
238
  return { backend: null, reason: 'secret-tool not installed. On Debian/Ubuntu: apt install libsecret-tools.' };
103
239
  }
104
240
  if (!process.env['DBUS_SESSION_BUS_ADDRESS']) {
105
241
  return {
106
242
  backend: null,
107
243
  reason: 'secret-tool is installed but no D-Bus session is running, which is normal over SSH. ' +
108
- 'Start a session bus or use the CRBRO_SECRET_* environment variables instead.',
244
+ 'Start a session bus or use CRBRO_SECRET_<NAME> environment variables instead.',
109
245
  };
110
246
  }
111
247
  return { backend: 'linux-secret-service' };
112
248
  }
113
- function requireBackend() {
114
- const { backend, reason } = detectBackend();
249
+ /**
250
+ * Which store this machine can actually use — not which one it should have.
251
+ * Returns null with a reason the caller can show the user, because "no
252
+ * keychain here" is a normal answer on a headless box, not a failure.
253
+ */
254
+ function detectBackend() {
255
+ return driveSync(detectGen());
256
+ }
257
+ let probeInFlight = null;
258
+ function detectBackendAsync() {
259
+ if (!probeInFlight) {
260
+ probeInFlight = driveAsync(detectGen()).finally(() => { probeInFlight = null; });
261
+ }
262
+ return probeInFlight;
263
+ }
264
+ function* requireBackendGen() {
265
+ const { backend, reason } = yield* detectGen();
115
266
  if (!backend)
116
267
  throw new KeychainUnavailable(reason ?? 'No credential store available on this machine.');
117
268
  return backend;
118
269
  }
270
+ // ─── Environment overrides: explicit, never the whole environment ─
271
+ /**
272
+ * Until 2.10 any variable of the server's environment came back from get —
273
+ * USERNAME, ANTHROPIC_API_KEY, whatever the client had. Now only two things
274
+ * count, both opt-in: a variable named CRBRO_SECRET_<NAME>, which exists for
275
+ * no other reason, and a plain <NAME> that CRBRO_SECRET_ENV lists by name.
276
+ */
277
+ function fromEnvironment(name) {
278
+ const prefixed = process.env[`CRBRO_SECRET_${name}`];
279
+ if (prefixed)
280
+ return prefixed;
281
+ const allowed = (process.env['CRBRO_SECRET_ENV'] || '').split(/[\s,;]+/).filter(Boolean);
282
+ if (allowed.includes(name)) {
283
+ const plain = process.env[name];
284
+ if (plain)
285
+ return plain;
286
+ }
287
+ return null;
288
+ }
119
289
  // Every DPAPI call costs a PowerShell start, about a second. A script that
120
290
  // needs four credentials would pay four seconds for no reason, so a read is
121
291
  // held briefly in memory.
@@ -123,11 +293,22 @@ function requireBackend() {
123
293
  // Briefly is the whole point. The MCP server is long-lived, and a cache with
124
294
  // no expiry would keep every secret in the heap for as long as the editor is
125
295
  // open. Thirty seconds covers the burst of reads at the start of a task and
126
- // nothing beyond it.
296
+ // nothing beyond it — and a timer drops it then, not the next call.
127
297
  const CACHE_TTL_MS = 30_000;
128
298
  let cached = null;
299
+ let cacheTimer = null;
129
300
  function invalidateCache() {
130
301
  cached = null;
302
+ if (cacheTimer) {
303
+ clearTimeout(cacheTimer);
304
+ cacheTimer = null;
305
+ }
306
+ }
307
+ function remember(store, file, sealed) {
308
+ invalidateCache();
309
+ cached = { store, at: Date.now(), file, sealed };
310
+ cacheTimer = setTimeout(() => { cached = null; cacheTimer = null; }, CACHE_TTL_MS);
311
+ cacheTimer.unref?.();
131
312
  }
132
313
  // ConvertTo-SecureString and its twin live in Microsoft.PowerShell.Security,
133
314
  // and there are Windows machines where that module does not load at all — CI
@@ -150,56 +331,278 @@ const DPAPI_OPEN = DPAPI_PREAMBLE +
150
331
  const DPAPI_PROBE = DPAPI_PREAMBLE +
151
332
  "$p=[Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('ok'),$null,'CurrentUser');" +
152
333
  "[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($p,$null,'CurrentUser'))";
153
- function psDpapi(script, input) {
154
- const r = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], input);
334
+ const LEGACY_OPEN = '$e = [Console]::In.ReadToEnd().Trim();' +
335
+ '$s = ConvertTo-SecureString $e;' +
336
+ '$b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);' +
337
+ 'try { [Runtime.InteropServices.Marshal]::PtrToStringAuto($b) }' +
338
+ 'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }';
339
+ function* psDpapi(script, input) {
340
+ const r = yield { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], input };
155
341
  if (r.error || r.status !== 0) {
156
- throw new KeychainUnavailable(`DPAPI call failed: ${(r.stderr || '').trim() || 'unknown error'}`);
342
+ throw new KeychainUnavailable(`DPAPI call failed: ${(r.stderr || '').trim() || r.error?.message || 'unknown error'}`);
157
343
  }
158
344
  return r.stdout;
159
345
  }
160
- function winRead() {
161
- const { file } = winPaths();
162
- if (cached && cached.file === file && Date.now() - cached.at < CACHE_TTL_MS) {
163
- return cached.store;
164
- }
165
- if (!(0, node_fs_1.existsSync)(file))
166
- return {};
167
- const sealed = (0, node_fs_1.readFileSync)(file, 'utf8').trim();
168
- if (!sealed)
169
- return {};
170
- // ConvertTo-SecureString without -Key is DPAPI at CurrentUser scope: only
171
- // this Windows account, on this machine, can turn it back into text.
346
+ /** Opens sealed text into a store object. Throws on anything that is not one. */
347
+ function* openSealed(sealed) {
172
348
  // 1.8.0 and 1.8.1 sealed with ConvertFrom-SecureString, whose output is hex.
173
349
  // Anything else is the ProtectedData format. Reading both means an existing
174
350
  // store keeps working instead of looking corrupt after an update.
175
351
  const legacy = /^[0-9a-f]+$/i.test(sealed);
176
- const json = legacy
177
- ? psDpapi('$e = [Console]::In.ReadToEnd().Trim();' +
178
- '$s = ConvertTo-SecureString $e;' +
179
- '$b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);' +
180
- 'try { [Runtime.InteropServices.Marshal]::PtrToStringAuto($b) }' +
181
- 'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }', sealed)
182
- : psDpapi(DPAPI_OPEN, sealed);
352
+ const json = yield* psDpapi(legacy ? LEGACY_OPEN : DPAPI_OPEN, sealed);
353
+ let store;
183
354
  try {
184
- const store = JSON.parse(json);
185
- cached = { store, at: Date.now(), file };
186
- return store;
355
+ store = JSON.parse(json);
187
356
  }
188
357
  catch {
189
358
  throw new KeychainUnavailable('The key store exists but could not be read. It was sealed by a different Windows account.');
190
359
  }
360
+ if (!store || typeof store !== 'object' || Array.isArray(store)) {
361
+ throw new KeychainUnavailable('The key store opened but does not hold a credential list. Refusing to use it.');
362
+ }
363
+ return store;
364
+ }
365
+ // ── Evidence that a store existed ──
366
+ //
367
+ // The marker is written on every successful write; sealed copies and
368
+ // temporary files are what a crash or an older writer leaves behind. Any one
369
+ // of them next to a missing keys.dpapi means "lost", not "never had one".
370
+ /** Unique temporary files are named keys.dpapi.<pid>.<uuid>.tmp; older releases used keys.dpapi.tmp. */
371
+ const TMP_RE = /^keys\.dpapi(?:\..+)?\.tmp$/;
372
+ const UNIQUE_TMP_RE = /^keys\.dpapi(?:\.marker)?\.\d+\.[0-9a-f-]{36}\.tmp$/;
373
+ const LEGACY_BAK_RE = /^keys\.dpapi\.bak/;
374
+ const COPY_RE = /^keys\.dpapi\.(\d{8}T\d{9})-[0-9a-f]{6}\.sealed$/;
375
+ function safeList(dir) {
376
+ try {
377
+ return (0, node_fs_1.readdirSync)(dir);
378
+ }
379
+ catch {
380
+ return [];
381
+ }
382
+ }
383
+ /** Sealed copies, temporaries and manual .bak files, newest first. Never opens them. */
384
+ function listSealedCopies() {
385
+ const { dir, copies } = winPaths();
386
+ const out = [];
387
+ const add = (folder, f, kind) => {
388
+ try {
389
+ const st = (0, node_fs_1.statSync)((0, node_path_1.join)(folder, f));
390
+ if (!st.isFile())
391
+ return;
392
+ out.push({ id: f, kind, modified: st.mtime.toISOString(), bytes: st.size });
393
+ }
394
+ catch { /* gone meanwhile */ }
395
+ };
396
+ for (const f of safeList(copies))
397
+ if (COPY_RE.test(f))
398
+ add(copies, f, 'sealed-copy');
399
+ for (const f of safeList(dir)) {
400
+ if (TMP_RE.test(f))
401
+ add(dir, f, 'temporary');
402
+ else if (LEGACY_BAK_RE.test(f))
403
+ add(dir, f, 'manual-backup');
404
+ }
405
+ return out.sort((a, b) => b.modified.localeCompare(a.modified) || b.id.localeCompare(a.id));
406
+ }
407
+ function evidenceOfStore() {
408
+ const { marker } = winPaths();
409
+ const ev = [];
410
+ if ((0, node_fs_1.existsSync)(marker))
411
+ ev.push('keys.dpapi.marker');
412
+ for (const c of listSealedCopies())
413
+ ev.push(c.kind === 'sealed-copy' ? `sealed-copies/${c.id}` : c.id);
414
+ return ev;
415
+ }
416
+ function missingError() {
417
+ const { dir } = winPaths();
418
+ const ev = evidenceOfStore();
419
+ const newest = listSealedCopies()[0];
420
+ return new KeychainMissing(`The key store ${(0, node_path_1.join)(dir, 'keys.dpapi')} is missing, but ${ev.slice(0, 4).join(', ')}` +
421
+ `${ev.length > 4 ? ` and ${ev.length - 4} more` : ''} show it existed. It was NOT treated as empty and nothing ` +
422
+ 'was written. To recover: `npx crbro-memory secret copies` lists sealed copies and leftovers; ' +
423
+ `\`npx crbro-memory secret restore ${newest ? newest.id : '<id>'} --yes\` validates one with DPAPI and puts it back. ` +
424
+ 'To start an empty store on purpose instead: `npx crbro-memory secret set NAME --force`.', ev);
425
+ }
426
+ function readMain(file) {
427
+ try {
428
+ return (0, node_fs_1.readFileSync)(file, 'utf8').trim();
429
+ }
430
+ catch (err) {
431
+ if (err.code === 'ENOENT')
432
+ return null;
433
+ throw err;
434
+ }
435
+ }
436
+ /**
437
+ * The store, or {} on a machine that never had one. `allowMissing` is the
438
+ * explicit --force of set: an empty store over a lost one, on purpose.
439
+ */
440
+ function* winRead(fresh = false, allowMissing = false) {
441
+ const { file } = winPaths();
442
+ // Compare the ciphertext itself, not mtime/size: a same-size replacement or
443
+ // restored timestamp must not hide an external update. Reading is cheap;
444
+ // the cache avoids the expensive DPAPI subprocess only while bytes match.
445
+ const sealed = readMain(file);
446
+ if (sealed === null) {
447
+ invalidateCache();
448
+ if (!allowMissing && evidenceOfStore().length > 0)
449
+ throw missingError();
450
+ return {};
451
+ }
452
+ if (!fresh && cached && cached.file === file && cached.sealed === sealed && Date.now() - cached.at < CACHE_TTL_MS) {
453
+ return cached.store;
454
+ }
455
+ invalidateCache();
456
+ if (!sealed)
457
+ throw new KeychainUnavailable('The key store is empty or truncated. Refusing to overwrite it.');
458
+ const store = yield* openSealed(sealed);
459
+ remember(store, file, sealed);
460
+ return store;
461
+ }
462
+ /** Writes with wx then renames, so a crash never leaves half a file under the final name. */
463
+ function atomicWrite(target, content) {
464
+ const tmp = `${target}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`;
465
+ let created = false;
466
+ let fd;
467
+ try {
468
+ fd = (0, node_fs_1.openSync)(tmp, 'wx', 0o600);
469
+ created = true;
470
+ (0, node_fs_1.writeFileSync)(fd, content, { encoding: 'ascii' });
471
+ (0, node_fs_1.closeSync)(fd);
472
+ fd = undefined;
473
+ (0, node_fs_1.renameSync)(tmp, target);
474
+ }
475
+ finally {
476
+ if (fd !== undefined)
477
+ (0, node_fs_1.closeSync)(fd);
478
+ if (created && (0, node_fs_1.existsSync)(tmp))
479
+ (0, node_fs_1.unlinkSync)(tmp);
480
+ }
481
+ }
482
+ function copiesToKeep() {
483
+ const n = Number(process.env['CRBRO_KEYS_COPIES']);
484
+ return Number.isInteger(n) && n >= 2 && n <= 100 ? n : 5;
485
+ }
486
+ function stamp() {
487
+ return new Date().toISOString().replace(/[-:.Z]/g, '').slice(0, 18);
488
+ }
489
+ /** A byte copy of sealed text, skipped when the newest copy already holds it. */
490
+ function keepCopy(sealed) {
491
+ const { copies } = winPaths();
492
+ if (!(0, node_fs_1.existsSync)(copies))
493
+ (0, node_fs_1.mkdirSync)(copies, { recursive: true, mode: 0o700 });
494
+ const newest = listSealedCopies().find(c => c.kind === 'sealed-copy');
495
+ if (newest) {
496
+ try {
497
+ if ((0, node_fs_1.readFileSync)((0, node_path_1.join)(copies, newest.id), 'utf8').trim() === sealed)
498
+ return;
499
+ }
500
+ catch { /* unreadable copy: write a fresh one */ }
501
+ }
502
+ atomicWrite((0, node_path_1.join)(copies, `keys.dpapi.${stamp()}-${(0, node_crypto_1.randomBytes)(3).toString('hex')}.sealed`), sealed);
503
+ }
504
+ function rotateCopies() {
505
+ const { copies } = winPaths();
506
+ const all = listSealedCopies().filter(c => c.kind === 'sealed-copy');
507
+ for (const old of all.slice(copiesToKeep())) {
508
+ try {
509
+ (0, node_fs_1.unlinkSync)((0, node_path_1.join)(copies, old.id));
510
+ }
511
+ catch { /* next write retries */ }
512
+ }
191
513
  }
192
- function winWrite(store) {
514
+ /** Our own temporaries, older than an hour: a crash between write and rename. Never the legacy name. */
515
+ function sweepStaleTemporaries() {
516
+ const { dir } = winPaths();
517
+ const limit = Date.now() - 60 * 60_000;
518
+ for (const f of safeList(dir)) {
519
+ if (!UNIQUE_TMP_RE.test(f))
520
+ continue;
521
+ const p = (0, node_path_1.join)(dir, f);
522
+ try {
523
+ if ((0, node_fs_1.statSync)(p).mtimeMs < limit)
524
+ (0, node_fs_1.unlinkSync)(p);
525
+ }
526
+ catch { /* in use or gone */ }
527
+ }
528
+ }
529
+ function writeMarker() {
530
+ const { marker } = winPaths();
531
+ let created = new Date().toISOString();
532
+ try {
533
+ const prev = JSON.parse((0, node_fs_1.readFileSync)(marker, 'utf8'));
534
+ if (typeof prev.created === 'string')
535
+ created = prev.created;
536
+ }
537
+ catch { /* first write */ }
538
+ try {
539
+ atomicWrite(marker, JSON.stringify({ v: 1, store: 'keys.dpapi', created, last_write: new Date().toISOString() }) + '\n');
540
+ }
541
+ catch { /* the store itself is written; the marker comes back on the next write */ }
542
+ }
543
+ /**
544
+ * Seal, keep the previous bytes as a copy, replace atomically, keep the new
545
+ * bytes as a copy, rotate. A copy of the previous state that cannot be written
546
+ * stops the write: the copy is what makes an overwrite or a removal reversible.
547
+ */
548
+ function* winWrite(store) {
193
549
  const { dir, file } = winPaths();
550
+ invalidateCache();
194
551
  if (!(0, node_fs_1.existsSync)(dir))
195
552
  (0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
196
- const sealed = psDpapi(DPAPI_SEAL, JSON.stringify(store)).trim();
197
- // Atomic: a crash mid-write leaves the previous store intact rather than a
198
- // truncated file that would take every secret with it.
199
- const tmp = `${file}.tmp`;
200
- (0, node_fs_1.writeFileSync)(tmp, sealed, { encoding: 'ascii', mode: 0o600 });
201
- (0, node_fs_1.renameSync)(tmp, file);
202
- cached = { store, at: Date.now(), file };
553
+ const sealed = (yield* psDpapi(DPAPI_SEAL, JSON.stringify(store))).trim();
554
+ if (!sealed)
555
+ throw new KeychainUnavailable('DPAPI returned nothing to store. Nothing was written.');
556
+ const previous = readMain(file);
557
+ if (previous)
558
+ keepCopy(previous);
559
+ atomicWrite(file, sealed);
560
+ remember(store, file, sealed);
561
+ try {
562
+ keepCopy(sealed);
563
+ rotateCopies();
564
+ }
565
+ catch { /* the store is written; copies catch up on the next write */ }
566
+ writeMarker();
567
+ if (previous)
568
+ sweepStaleTemporaries();
569
+ }
570
+ /**
571
+ * Put a sealed copy (or a leftover temporary, or a manual .bak) back as the
572
+ * store. It is opened with DPAPI first, so a copy from another account or a
573
+ * truncated file is refused; the current store, if any, is kept as a copy.
574
+ */
575
+ function* restoreGen(id) {
576
+ const { dir, copies, file } = winPaths();
577
+ if (typeof id !== 'string' || /[\\/]/.test(id) || id.includes('..'))
578
+ throw new Error('Invalid copy id.');
579
+ const cand = listSealedCopies().find(c => c.id === id);
580
+ if (!cand)
581
+ throw new Error(`No sealed copy or leftover named "${id}". List them with: npx crbro-memory secret copies`);
582
+ const src = (0, node_path_1.join)(cand.kind === 'sealed-copy' ? copies : dir, id);
583
+ const sealed = (0, node_fs_1.readFileSync)(src, 'utf8').trim();
584
+ if (!sealed)
585
+ throw new KeychainUnavailable(`"${id}" is empty. Refusing to restore it.`);
586
+ const store = yield* openSealed(sealed);
587
+ const current = readMain(file);
588
+ if (current)
589
+ keepCopy(current);
590
+ if (!(0, node_fs_1.existsSync)(dir))
591
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
592
+ atomicWrite(file, sealed);
593
+ invalidateCache();
594
+ try {
595
+ keepCopy(sealed);
596
+ rotateCopies();
597
+ }
598
+ catch { /* best effort */ }
599
+ writeMarker();
600
+ return { restored: id, entries: Object.keys(store).length };
601
+ }
602
+ function restoreSealedCopy(id) {
603
+ if (os() !== 'win32')
604
+ throw new Error('Sealed copies exist only for the Windows DPAPI store.');
605
+ return lockedSync(() => restoreGen(id));
203
606
  }
204
607
  // ─── macOS: an index of its own ──────────────────────────────────
205
608
  //
@@ -225,133 +628,308 @@ function macDecode(raw) {
225
628
  // removed: .trim() would eat spaces that belong to the secret.
226
629
  return Buffer.from(raw.replace(/\n$/, ''), 'base64').toString('utf8');
227
630
  }
228
- const MAC_INDEX = '__crbro_index__';
229
- function macReadIndex() {
230
- const r = run('security', ['find-generic-password', '-s', SERVICE, '-a', MAC_INDEX, '-w']);
231
- if (r.status !== 0)
232
- return {};
631
+ const INDEX_ACCOUNT = '__crbro_index__';
632
+ /** errSecItemNotFound, truncated to eight bits by security(1). */
633
+ const MAC_NOT_FOUND = 44;
634
+ /** errSecInteractionNotAllowed: a locked keychain. */
635
+ const MAC_LOCKED = 36;
636
+ function macFailure(what, r) {
637
+ if (r.status === MAC_LOCKED) {
638
+ return new KeychainUnavailable('The macOS keychain is locked, which is usual over SSH or in CI. Unlock it in a desktop session, ' +
639
+ 'or pass the credential as a CRBRO_SECRET_<NAME> environment variable instead.');
640
+ }
641
+ return new KeychainUnavailable(`${what} (exit ${r.status ?? 'none'}): ${(r.stderr || r.error?.message || '').trim() || 'no detail'}`);
642
+ }
643
+ function parseIndex(raw, where) {
644
+ let index;
233
645
  try {
234
- return JSON.parse(macDecode(r.stdout));
646
+ index = JSON.parse(raw);
235
647
  }
236
648
  catch {
237
- return {};
649
+ throw new KeychainUnavailable(`The CRBRO name index in ${where} is unreadable. Refusing to rewrite it, so no name is lost.`);
650
+ }
651
+ if (!index || typeof index !== 'object' || Array.isArray(index)) {
652
+ throw new KeychainUnavailable(`The CRBRO name index in ${where} is not a name list. Refusing to rewrite it.`);
238
653
  }
654
+ return index;
239
655
  }
240
- function macWriteIndex(index) {
241
- run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', MAC_INDEX, '-w', macEncode(JSON.stringify(index)), '-T', '/usr/bin/security']);
656
+ // Only "not found" is an empty index. Until 2.10 any failure — a locked
657
+ // keychain, a denied prompt, a truncated value — read as {}, and the next set
658
+ // rewrote the index with a single name: the others stayed stored but vanished
659
+ // from list.
660
+ function* macReadIndex() {
661
+ const r = yield { cmd: 'security', args: ['find-generic-password', '-s', SERVICE, '-a', INDEX_ACCOUNT, '-w'] };
662
+ if (r.status === MAC_NOT_FOUND)
663
+ return {};
664
+ if (r.error || r.status !== 0)
665
+ throw macFailure('Could not read the CRBRO name index from the keychain', r);
666
+ return parseIndex(macDecode(r.stdout), 'the macOS keychain');
667
+ }
668
+ function* macWriteIndex(index) {
669
+ const r = yield {
670
+ cmd: 'security',
671
+ args: ['add-generic-password', '-U', '-s', SERVICE, '-a', INDEX_ACCOUNT, '-w', macEncode(JSON.stringify(index)), '-T', '/usr/bin/security'],
672
+ };
673
+ if (r.error || r.status !== 0)
674
+ throw macFailure('The keychain refused to update the CRBRO name index', r);
675
+ }
676
+ // ─── Linux: an index too ─────────────────────────────────────────
677
+ //
678
+ // `secret-tool search --all` prints every matching item WITH its secret, so
679
+ // listing names that way pulled every value into this process. The names now
680
+ // live in an item of their own, like on macOS. A store from before 2.11 has no
681
+ // index; the first listing builds it once from a search, keeping names only.
682
+ function linuxNotFound(r) {
683
+ return r.status === 1 && !(r.stderr || '').trim() && !r.error;
242
684
  }
243
- // ─── Public surface ──────────────────────────────────────────────
244
- function setSecret(name, value, description = '') {
245
- const backend = requireBackend();
246
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
247
- throw new Error(`Invalid secret name "${name}". Use SCREAMING_SNAKE_CASE, e.g. WORDPRESS_APP_PASSWORD.`);
685
+ function* linuxReadIndex() {
686
+ const r = yield { cmd: 'secret-tool', args: ['lookup', 'service', SERVICE, 'account', INDEX_ACCOUNT] };
687
+ if (r.status === 0)
688
+ return parseIndex(r.stdout, 'the Secret Service');
689
+ if (!linuxNotFound(r)) {
690
+ throw new KeychainUnavailable(`secret-tool could not read the CRBRO name index: ${(r.stderr || '').trim() || `exit ${r.status}`}`);
248
691
  }
249
- if (!value)
250
- throw new Error('Refusing to store an empty value.');
692
+ // Migration from 2.10: one search, names and descriptions only.
693
+ const s = yield { cmd: 'secret-tool', args: ['search', '--all', 'service', SERVICE] };
694
+ if (s.status !== 0) {
695
+ if (linuxNotFound(s))
696
+ return {};
697
+ throw new KeychainUnavailable(`secret-tool could not list the store: ${(s.stderr || '').trim() || `exit ${s.status}`}`);
698
+ }
699
+ const index = {};
700
+ let current = null;
701
+ for (const line of s.stdout.split('\n')) {
702
+ if (line.startsWith('[')) {
703
+ current = null;
704
+ continue;
705
+ }
706
+ const acc = /^attribute\.account = (.+)$/.exec(line);
707
+ if (acc) {
708
+ const n = acc[1].trim();
709
+ current = exports.SECRET_NAME_RE.test(n) ? n : null;
710
+ if (current && !index[current])
711
+ index[current] = { description: '', updated: '' };
712
+ continue;
713
+ }
714
+ const desc = /^attribute\.description = (.*)$/.exec(line);
715
+ if (desc && current)
716
+ index[current].description = desc[1].trim();
717
+ }
718
+ s.stdout = ''; // the values went by in that output; keep nothing of it
719
+ return index;
720
+ }
721
+ function* linuxWriteIndex(index) {
722
+ const r = yield {
723
+ cmd: 'secret-tool',
724
+ args: ['store', '--label', 'CRBRO name index', 'service', SERVICE, 'account', INDEX_ACCOUNT],
725
+ input: JSON.stringify(index),
726
+ };
727
+ if (r.error || r.status !== 0) {
728
+ throw new KeychainUnavailable(`secret-tool refused to update the CRBRO name index: ${(r.stderr || '').trim() || `exit ${r.status}`}`);
729
+ }
730
+ }
731
+ function* setGen(name, value, description, backend, opts) {
732
+ const today = new Date().toISOString().slice(0, 10);
251
733
  if (backend === 'windows-dpapi') {
252
- const store = winRead();
253
- store[name] = { value, description, updated: new Date().toISOString().slice(0, 10) };
254
- winWrite(store);
734
+ const store = yield* winRead(true, !!opts.force);
735
+ store[name] = { value, description, updated: today };
736
+ yield* winWrite(store);
255
737
  return;
256
738
  }
257
739
  if (backend === 'macos-keychain') {
740
+ // Read the index first: if it cannot be read, nothing is written at all.
741
+ const index = yield* macReadIndex();
258
742
  // -U updates in place instead of erroring when the item already exists.
259
743
  // -w with the value on argv is visible to `ps` for the life of the call;
260
- // security(1) offers no stdin form, so the exposure is unavoidable and
261
- // measured in milliseconds on the user's own machine.
262
- const r = run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', name, '-w', macEncode(value), '-j', description, '-T', '/usr/bin/security']);
263
- if (r.status !== 0)
264
- throw new KeychainUnavailable(`Keychain refused the write: ${(r.stderr || '').trim()}`);
265
- const index = macReadIndex();
266
- index[name] = { description, updated: new Date().toISOString().slice(0, 10) };
267
- macWriteIndex(index);
744
+ // security(1) offers no stdin form for it, so the exposure is unavoidable
745
+ // and measured in milliseconds on the user's own machine (SECURITY.md).
746
+ const r = yield {
747
+ cmd: 'security',
748
+ args: ['add-generic-password', '-U', '-s', SERVICE, '-a', name, '-w', macEncode(value), '-j', description, '-T', '/usr/bin/security'],
749
+ };
750
+ if (r.error || r.status !== 0)
751
+ throw macFailure('Keychain refused the write', r);
752
+ index[name] = { description, updated: today };
753
+ yield* macWriteIndex(index);
268
754
  return;
269
755
  }
756
+ const index = yield* linuxReadIndex();
270
757
  // secret-tool reads the secret from stdin, so it never reaches the process
271
- // table. It stays open waiting for EOF, which spawnSync gives it.
272
- const r = run('secret-tool', ['store', '--label', `CRBRO ${name}`, 'service', SERVICE, 'account', name, 'description', description], value);
273
- if (r.status !== 0)
758
+ // table. It stays open waiting for EOF, which the runner gives it.
759
+ const r = yield {
760
+ cmd: 'secret-tool',
761
+ args: ['store', '--label', `CRBRO ${name}`, 'service', SERVICE, 'account', name, 'description', description],
762
+ input: value,
763
+ };
764
+ if (r.error || r.status !== 0)
274
765
  throw new KeychainUnavailable(`secret-tool refused the write: ${(r.stderr || '').trim()}`);
766
+ index[name] = { description, updated: today };
767
+ yield* linuxWriteIndex(index);
275
768
  }
276
- /**
277
- * The value, or null when there is no such secret. An environment variable of
278
- * the same name wins, which is what makes CI and one-off overrides work
279
- * without touching the store.
280
- */
281
- function getSecret(name) {
282
- const fromEnv = process.env[name];
283
- if (fromEnv)
284
- return fromEnv;
285
- const backend = requireBackend();
769
+ function* getGen(name) {
770
+ const env = fromEnvironment(name);
771
+ if (env)
772
+ return { value: env, source: 'env' };
773
+ const backend = yield* requireBackendGen();
286
774
  if (backend === 'windows-dpapi') {
287
- return winRead()[name]?.value ?? null;
775
+ const v = (yield* winRead())[name]?.value;
776
+ return v ? { value: v, source: 'keychain' } : null;
288
777
  }
289
778
  if (backend === 'macos-keychain') {
290
- const r = run('security', ['find-generic-password', '-s', SERVICE, '-a', name, '-w']);
291
- // security(1) returns the OSStatus itself, truncated to eight bits: 44 is
292
- // errSecItemNotFound, 36 is errSecInteractionNotAllowed — a locked
293
- // keychain, which is the normal state over SSH and in CI. Reporting that
294
- // one as "no such secret" would send someone hunting for a typo.
295
- if (r.status === 36) {
296
- throw new KeychainUnavailable('The macOS keychain is locked, which is usual over SSH or in CI. Unlock it in a desktop session, ' +
297
- 'or pass the credential as an environment variable instead.');
298
- }
299
- if (r.status !== 0)
779
+ const r = yield { cmd: 'security', args: ['find-generic-password', '-s', SERVICE, '-a', name, '-w'] };
780
+ // Only "not found" is null. A locked keychain, a denied prompt or any other
781
+ // failure is an error: reporting it as "no such secret" sends someone to
782
+ // ask the user for a credential that is sitting right there.
783
+ if (r.status === MAC_NOT_FOUND)
300
784
  return null;
301
- return macDecode(r.stdout);
785
+ if (r.error || r.status !== 0)
786
+ throw macFailure('The keychain refused the read', r);
787
+ return { value: macDecode(r.stdout), source: 'keychain' };
302
788
  }
303
789
  // Unlike security(1), secret-tool adds no trailing newline when stdout
304
790
  // is a pipe: write_password_stdout only appends one for a tty. Trimming
305
791
  // here would silently eat a newline that was genuinely part of the secret.
306
792
  // Exit 1 with nothing on stderr is how it reports "no such item"; anything
307
793
  // on stderr is a real failure and must not be mistaken for absence.
308
- const r = run('secret-tool', ['lookup', 'service', SERVICE, 'account', name]);
794
+ const r = yield { cmd: 'secret-tool', args: ['lookup', 'service', SERVICE, 'account', name] };
309
795
  if (r.status === 0)
310
- return r.stdout;
311
- if (r.status === 1 && !(r.stderr || '').trim())
796
+ return { value: r.stdout, source: 'keychain' };
797
+ if (linuxNotFound(r))
312
798
  return null;
313
799
  throw new KeychainUnavailable(`secret-tool failed: ${(r.stderr || '').trim() || `exit ${r.status}`}`);
314
800
  }
315
- /** Names and descriptions only. Values are never returned by design. */
316
- function listSecrets() {
317
- const backend = requireBackend();
318
- if (backend === 'windows-dpapi') {
319
- return Object.entries(winRead())
320
- .map(([name, e]) => ({ name, description: e.description, updated: e.updated }))
321
- .sort((a, b) => a.name.localeCompare(b.name));
322
- }
323
- if (backend === 'macos-keychain') {
324
- return Object.entries(macReadIndex())
325
- .map(([name, e]) => ({ name, description: e.description, updated: e.updated }))
326
- .sort((a, b) => a.name.localeCompare(b.name));
327
- }
328
- const r = run('secret-tool', ['search', '--all', 'service', SERVICE]);
329
- if (r.status !== 0)
330
- return [];
331
- const names = [...r.stdout.matchAll(/^attribute\.account = (.+)$/gm)].map(m => m[1].trim());
332
- return [...new Set(names)].sort().map(name => ({ name, description: '', updated: '' }));
801
+ function sortEntries(index) {
802
+ return Object.entries(index)
803
+ .filter(([name]) => exports.SECRET_NAME_RE.test(name))
804
+ .map(([name, e]) => ({ name, description: e?.description ?? '', updated: e?.updated ?? '' }))
805
+ .sort((a, b) => a.name.localeCompare(b.name));
333
806
  }
334
- function removeSecret(name) {
335
- const backend = requireBackend();
807
+ function* listGen() {
808
+ const backend = yield* requireBackendGen();
809
+ if (backend === 'windows-dpapi')
810
+ return sortEntries(yield* winRead());
811
+ if (backend === 'macos-keychain')
812
+ return sortEntries(yield* macReadIndex());
813
+ return sortEntries(yield* linuxReadIndex());
814
+ }
815
+ function* removeGen(name, backend) {
336
816
  if (backend === 'windows-dpapi') {
337
- const store = winRead();
817
+ const store = yield* winRead(true);
338
818
  if (!(name in store))
339
819
  return false;
340
820
  delete store[name];
341
- winWrite(store);
821
+ // winWrite keeps the previous sealed bytes as a copy: the removal can be
822
+ // undone with `secret restore` until the copies rotate it out.
823
+ yield* winWrite(store);
342
824
  return true;
343
825
  }
344
826
  if (backend === 'macos-keychain') {
345
- const gone = run('security', ['delete-generic-password', '-s', SERVICE, '-a', name]).status === 0;
346
- if (gone) {
347
- const index = macReadIndex();
348
- delete index[name];
349
- macWriteIndex(index);
827
+ const index = yield* macReadIndex();
828
+ const r = yield { cmd: 'security', args: ['delete-generic-password', '-s', SERVICE, '-a', name] };
829
+ if (r.status === MAC_NOT_FOUND) {
830
+ if (name in index) {
831
+ delete index[name];
832
+ yield* macWriteIndex(index);
833
+ }
834
+ return false;
350
835
  }
351
- return gone;
836
+ if (r.error || r.status !== 0)
837
+ throw macFailure('The keychain refused the removal', r);
838
+ delete index[name];
839
+ yield* macWriteIndex(index);
840
+ return true;
841
+ }
842
+ const index = yield* linuxReadIndex();
843
+ const r = yield { cmd: 'secret-tool', args: ['clear', 'service', SERVICE, 'account', name] };
844
+ if (r.error || (r.status !== 0 && !linuxNotFound(r))) {
845
+ throw new KeychainUnavailable(`secret-tool refused the removal: ${(r.stderr || '').trim() || `exit ${r.status}`}`);
352
846
  }
353
- return run('secret-tool', ['clear', 'service', SERVICE, 'account', name]).status === 0;
847
+ const had = name in index;
848
+ if (had) {
849
+ delete index[name];
850
+ yield* linuxWriteIndex(index);
851
+ }
852
+ // secret-tool clear exits 0 whether or not something matched; the index says.
853
+ return had;
854
+ }
855
+ // ─── Public surface: synchronous (CLI, existing callers) ─────────
856
+ function setSecret(name, value, description = '', opts = {}) {
857
+ checkName(name);
858
+ const backend = driveSync(requireBackendGen());
859
+ if (!value)
860
+ throw new Error('Refusing to store an empty value.');
861
+ lockedSync(() => setGen(name, value, description, backend, opts));
862
+ }
863
+ /**
864
+ * The value, or null when there is no such secret. An environment variable
865
+ * counts only when it opts in: CRBRO_SECRET_<NAME>, or <NAME> listed in
866
+ * CRBRO_SECRET_ENV. That keeps CI and one-off overrides working without
867
+ * turning get into a reader of the whole server environment.
868
+ */
869
+ function getSecret(name) {
870
+ checkName(name);
871
+ return driveSync(getGen(name))?.value ?? null;
354
872
  }
873
+ /** Names and descriptions only. Values are never returned by design. */
874
+ function listSecrets() {
875
+ return driveSync(listGen());
876
+ }
877
+ function removeSecret(name) {
878
+ checkName(name);
879
+ const backend = driveSync(requireBackendGen());
880
+ return lockedSync(() => removeGen(name, backend));
881
+ }
882
+ // ─── Public surface: asynchronous (the server, inside the daemon) ─
883
+ async function setSecretAsync(name, value, description = '', opts = {}) {
884
+ checkName(name);
885
+ const backend = await driveAsync(requireBackendGen());
886
+ if (!value)
887
+ throw new Error('Refusing to store an empty value.');
888
+ await lockedAsync(() => setGen(name, value, description, backend, opts));
889
+ }
890
+ async function getSecretAsync(name) {
891
+ checkName(name);
892
+ return driveAsync(getGen(name));
893
+ }
894
+ function listSecretsAsync() {
895
+ return driveAsync(listGen());
896
+ }
897
+ async function removeSecretAsync(name) {
898
+ checkName(name);
899
+ const backend = await driveAsync(requireBackendGen());
900
+ return lockedAsync(() => removeGen(name, backend));
901
+ }
902
+ // ─── Confirmation for destructive writes through MCP ─────────────
903
+ //
904
+ // A token is an HMAC, under a key that never leaves this process, of the
905
+ // action, the name, the state of the store and (for set) the new value. A
906
+ // stale token — the store changed, the value changed, the server restarted —
907
+ // is refused, and nobody can produce one without asking for it first.
908
+ const TOKEN_KEY = (0, node_crypto_1.randomBytes)(32);
909
+ function* stateGen(name) {
910
+ const backend = yield* requireBackendGen();
911
+ if (backend === 'windows-dpapi') {
912
+ const sealed = readMain(winPaths().file) ?? '';
913
+ return (0, node_crypto_1.createHash)('sha256').update(sealed).digest('hex');
914
+ }
915
+ const index = backend === 'macos-keychain' ? yield* macReadIndex() : yield* linuxReadIndex();
916
+ return JSON.stringify(index[name] ?? null);
917
+ }
918
+ async function secretConfirmToken(action, name, value) {
919
+ checkName(name);
920
+ const state = await driveAsync(stateGen(name));
921
+ const valueDigest = value === undefined ? '' : (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
922
+ return (0, node_crypto_1.createHmac)('sha256', TOKEN_KEY)
923
+ .update(`${action}\0${name}\0${state}\0${valueDigest}`)
924
+ .digest('hex').slice(0, 16);
925
+ }
926
+ /** Windows: how many sealed copies sit next to the store. Null elsewhere. */
927
+ function sealedCopyCount() {
928
+ if (os() !== 'win32')
929
+ return null;
930
+ return listSealedCopies().filter(c => c.kind === 'sealed-copy').length;
931
+ }
932
+ // ─── Tests only ──────────────────────────────────────────────────
355
933
  /** Windows only, and only for tests: forget the sealed file entirely. */
356
934
  function _resetWindowsStoreForTests() {
357
935
  invalidateCache();
@@ -359,4 +937,17 @@ function _resetWindowsStoreForTests() {
359
937
  if ((0, node_fs_1.existsSync)(file))
360
938
  (0, node_fs_1.unlinkSync)(file);
361
939
  }
940
+ /** Tests: replace the process runner and the platform, so macOS/Linux logic runs on any CI. */
941
+ function _setKeychainRuntimeForTests(opts) {
942
+ runSync = opts?.sync ?? runSyncReal;
943
+ runAsync = opts?.async ?? (opts?.sync ? (c) => Promise.resolve(opts.sync(c)) : runAsyncReal);
944
+ platformOverride = opts?.platform ?? null;
945
+ probe = null;
946
+ invalidateCache();
947
+ }
948
+ /** Tests: drop the memoised probe and the read cache. */
949
+ function _resetKeychainMemoForTests() {
950
+ probe = null;
951
+ invalidateCache();
952
+ }
362
953
  //# sourceMappingURL=keychain.js.map