crbro-memory 2.10.0 → 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 (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -465
  3. package/SECURITY.md +114 -12
  4. package/bin/crbro.mjs +539 -300
  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 +139 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +667 -232
  25. package/dist/engine/doctor.js +312 -14
  26. package/dist/engine/heat.js +81 -37
  27. package/dist/engine/hippocampus.js +18 -7
  28. package/dist/engine/hookrefresh.js +296 -0
  29. package/dist/engine/hookverify.js +15 -3
  30. package/dist/engine/installer.js +1022 -0
  31. package/dist/engine/installio.js +279 -0
  32. package/dist/engine/keychain.js +721 -162
  33. package/dist/engine/maintenance.js +355 -87
  34. package/dist/engine/modinstall.js +98 -45
  35. package/dist/engine/prefrontal.js +29 -6
  36. package/dist/engine/quarantine.js +204 -0
  37. package/dist/engine/secrets.js +41 -5
  38. package/dist/engine/shelf.js +25 -3
  39. package/dist/engine/synapses.js +183 -80
  40. package/dist/engine/triggers.js +141 -11
  41. package/dist/index.js +37 -3
  42. package/dist/mcp-surface.json +1 -0
  43. package/dist/miner/extractor.js +64 -17
  44. package/dist/miner/scheduler.js +144 -68
  45. package/dist/search/index.js +797 -176
  46. package/dist/search/semantic.js +208 -18
  47. package/dist/search/tokenize.js +44 -0
  48. package/dist/server.js +583 -373
  49. package/dist/sync/git.js +203 -72
  50. package/dist/sync/space.js +100 -25
  51. package/dist/sync/validate.js +204 -0
  52. package/dist/utils/budget.js +15 -2
  53. package/dist/utils/fs.js +40 -6
  54. package/dist/utils/ids.js +128 -14
  55. package/dist/utils/lock.js +94 -7
  56. package/hooks/crbro-codex.mjs +284 -0
  57. package/hooks/crbro-guard.mjs +281 -173
  58. package/hooks/crbro-hooks.shipped.json +20 -0
  59. package/hooks/crbro-lifecycle.mjs +16 -4
  60. package/hooks/crbro-subagent.mjs +180 -180
  61. package/package.json +9 -5
  62. package/dist/daemon/daemon.d.ts +0 -25
  63. package/dist/daemon/daemon.d.ts.map +0 -1
  64. package/dist/daemon/daemon.js.map +0 -1
  65. package/dist/daemon/endpoint.d.ts +0 -66
  66. package/dist/daemon/endpoint.d.ts.map +0 -1
  67. package/dist/daemon/endpoint.js.map +0 -1
  68. package/dist/daemon/lines.d.ts +0 -55
  69. package/dist/daemon/lines.d.ts.map +0 -1
  70. package/dist/daemon/lines.js.map +0 -1
  71. package/dist/daemon/proxy.d.ts +0 -55
  72. package/dist/daemon/proxy.d.ts.map +0 -1
  73. package/dist/daemon/proxy.js.map +0 -1
  74. package/dist/engine/backup.d.ts +0 -68
  75. package/dist/engine/backup.d.ts.map +0 -1
  76. package/dist/engine/backup.js.map +0 -1
  77. package/dist/engine/brain.d.ts +0 -79
  78. package/dist/engine/brain.d.ts.map +0 -1
  79. package/dist/engine/brain.js.map +0 -1
  80. package/dist/engine/cortex.d.ts +0 -682
  81. package/dist/engine/cortex.d.ts.map +0 -1
  82. package/dist/engine/cortex.js.map +0 -1
  83. package/dist/engine/dates.d.ts +0 -28
  84. package/dist/engine/dates.d.ts.map +0 -1
  85. package/dist/engine/dates.js.map +0 -1
  86. package/dist/engine/doctor.d.ts +0 -93
  87. package/dist/engine/doctor.d.ts.map +0 -1
  88. package/dist/engine/doctor.js.map +0 -1
  89. package/dist/engine/evidence.d.ts +0 -26
  90. package/dist/engine/evidence.d.ts.map +0 -1
  91. package/dist/engine/evidence.js.map +0 -1
  92. package/dist/engine/heat.d.ts +0 -28
  93. package/dist/engine/heat.d.ts.map +0 -1
  94. package/dist/engine/heat.js.map +0 -1
  95. package/dist/engine/hippocampus.d.ts +0 -60
  96. package/dist/engine/hippocampus.d.ts.map +0 -1
  97. package/dist/engine/hippocampus.js.map +0 -1
  98. package/dist/engine/hookverify.d.ts +0 -42
  99. package/dist/engine/hookverify.d.ts.map +0 -1
  100. package/dist/engine/hookverify.js.map +0 -1
  101. package/dist/engine/keychain.d.ts +0 -32
  102. package/dist/engine/keychain.d.ts.map +0 -1
  103. package/dist/engine/keychain.js.map +0 -1
  104. package/dist/engine/maintenance.d.ts +0 -218
  105. package/dist/engine/maintenance.d.ts.map +0 -1
  106. package/dist/engine/maintenance.js.map +0 -1
  107. package/dist/engine/modinstall.d.ts +0 -215
  108. package/dist/engine/modinstall.d.ts.map +0 -1
  109. package/dist/engine/modinstall.js.map +0 -1
  110. package/dist/engine/postmortem.d.ts +0 -81
  111. package/dist/engine/postmortem.d.ts.map +0 -1
  112. package/dist/engine/postmortem.js.map +0 -1
  113. package/dist/engine/prefrontal.d.ts +0 -64
  114. package/dist/engine/prefrontal.d.ts.map +0 -1
  115. package/dist/engine/prefrontal.js.map +0 -1
  116. package/dist/engine/projects.d.ts +0 -20
  117. package/dist/engine/projects.d.ts.map +0 -1
  118. package/dist/engine/projects.js.map +0 -1
  119. package/dist/engine/secrets.d.ts +0 -30
  120. package/dist/engine/secrets.d.ts.map +0 -1
  121. package/dist/engine/secrets.js.map +0 -1
  122. package/dist/engine/shelf.d.ts +0 -155
  123. package/dist/engine/shelf.d.ts.map +0 -1
  124. package/dist/engine/shelf.js.map +0 -1
  125. package/dist/engine/source.d.ts +0 -6
  126. package/dist/engine/source.d.ts.map +0 -1
  127. package/dist/engine/source.js.map +0 -1
  128. package/dist/engine/synapses.d.ts +0 -82
  129. package/dist/engine/synapses.d.ts.map +0 -1
  130. package/dist/engine/synapses.js.map +0 -1
  131. package/dist/engine/triggers.d.ts +0 -53
  132. package/dist/engine/triggers.d.ts.map +0 -1
  133. package/dist/engine/triggers.js.map +0 -1
  134. package/dist/engine/usage.d.ts +0 -61
  135. package/dist/engine/usage.d.ts.map +0 -1
  136. package/dist/engine/usage.js.map +0 -1
  137. package/dist/index.d.ts +0 -2
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js.map +0 -1
  140. package/dist/miner/extractor.d.ts +0 -12
  141. package/dist/miner/extractor.d.ts.map +0 -1
  142. package/dist/miner/extractor.js.map +0 -1
  143. package/dist/miner/index.d.ts +0 -61
  144. package/dist/miner/index.d.ts.map +0 -1
  145. package/dist/miner/index.js.map +0 -1
  146. package/dist/miner/scheduler.d.ts +0 -30
  147. package/dist/miner/scheduler.d.ts.map +0 -1
  148. package/dist/miner/scheduler.js.map +0 -1
  149. package/dist/miner/types.d.ts +0 -11
  150. package/dist/miner/types.d.ts.map +0 -1
  151. package/dist/miner/types.js.map +0 -1
  152. package/dist/search/index.d.ts +0 -328
  153. package/dist/search/index.d.ts.map +0 -1
  154. package/dist/search/index.js.map +0 -1
  155. package/dist/search/quality.d.ts +0 -18
  156. package/dist/search/quality.d.ts.map +0 -1
  157. package/dist/search/quality.js.map +0 -1
  158. package/dist/search/semantic.d.ts +0 -80
  159. package/dist/search/semantic.d.ts.map +0 -1
  160. package/dist/search/semantic.js.map +0 -1
  161. package/dist/search/synonyms.d.ts +0 -2
  162. package/dist/search/synonyms.d.ts.map +0 -1
  163. package/dist/search/synonyms.js.map +0 -1
  164. package/dist/search/tokenize.d.ts +0 -39
  165. package/dist/search/tokenize.d.ts.map +0 -1
  166. package/dist/search/tokenize.js.map +0 -1
  167. package/dist/server.d.ts +0 -41
  168. package/dist/server.d.ts.map +0 -1
  169. package/dist/server.js.map +0 -1
  170. package/dist/sync/git.d.ts +0 -36
  171. package/dist/sync/git.d.ts.map +0 -1
  172. package/dist/sync/git.js.map +0 -1
  173. package/dist/sync/materialize.d.ts +0 -36
  174. package/dist/sync/materialize.d.ts.map +0 -1
  175. package/dist/sync/materialize.js.map +0 -1
  176. package/dist/sync/ops.d.ts +0 -147
  177. package/dist/sync/ops.d.ts.map +0 -1
  178. package/dist/sync/ops.js.map +0 -1
  179. package/dist/sync/space.d.ts +0 -118
  180. package/dist/sync/space.d.ts.map +0 -1
  181. package/dist/sync/space.js.map +0 -1
  182. package/dist/types/index.d.ts +0 -369
  183. package/dist/types/index.d.ts.map +0 -1
  184. package/dist/types/index.js.map +0 -1
  185. package/dist/utils/budget.d.ts +0 -44
  186. package/dist/utils/budget.d.ts.map +0 -1
  187. package/dist/utils/budget.js.map +0 -1
  188. package/dist/utils/fs.d.ts +0 -59
  189. package/dist/utils/fs.d.ts.map +0 -1
  190. package/dist/utils/fs.js.map +0 -1
  191. package/dist/utils/hash.d.ts +0 -20
  192. package/dist/utils/hash.d.ts.map +0 -1
  193. package/dist/utils/hash.js.map +0 -1
  194. package/dist/utils/ids.d.ts +0 -47
  195. package/dist/utils/ids.d.ts.map +0 -1
  196. package/dist/utils/ids.js.map +0 -1
  197. package/dist/utils/lock.d.ts +0 -13
  198. package/dist/utils/lock.d.ts.map +0 -1
  199. package/dist/utils/lock.js.map +0 -1
  200. package/dist/utils/transcripts.d.ts +0 -63
  201. package/dist/utils/transcripts.d.ts.map +0 -1
  202. package/dist/utils/transcripts.js.map +0 -1
  203. package/dist/version.d.ts +0 -26
  204. package/dist/version.d.ts.map +0 -1
  205. package/dist/version.js.map +0 -1
@@ -25,14 +25,33 @@
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");
37
56
  const node_crypto_1 = require("node:crypto");
38
57
  const node_os_1 = require("node:os");
@@ -41,6 +60,13 @@ const node_fs_1 = require("node:fs");
41
60
  const lock_js_1 = require("../utils/lock.js");
42
61
  /** Namespace under which every CRBRO secret is filed in the OS store. */
43
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
+ }
44
70
  /**
45
71
  * Windows only: the sealed file, deliberately a sibling of the brain, never
46
72
  * inside it. CRBRO_KEYS_DIR redirects it, which is how the tests avoid writing
@@ -49,7 +75,14 @@ const SERVICE = 'crbro';
49
75
  */
50
76
  function winPaths() {
51
77
  const dir = process.env['CRBRO_KEYS_DIR'] || (0, node_path_1.join)((0, node_os_1.homedir)(), '.crbro-keys');
52
- 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
+ };
53
86
  }
54
87
  class KeychainUnavailable extends Error {
55
88
  reason;
@@ -60,38 +93,139 @@ class KeychainUnavailable extends Error {
60
93
  }
61
94
  }
62
95
  exports.KeychainUnavailable = KeychainUnavailable;
63
- /** Runs a command without a shell, so a value with spaces or quotes cannot be reinterpreted. */
64
- function run(cmd, args, input) {
65
- 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, {
66
128
  encoding: 'utf8',
67
- input,
68
- timeout: 30_000,
129
+ input: c.input,
130
+ timeout: RUN_TIMEOUT_MS,
69
131
  windowsHide: true,
70
132
  });
133
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: r.error };
71
134
  }
72
- /**
73
- * Which store this machine can actually use — not which one it should have.
74
- * Returns null with a reason the caller can show the user, because "no
75
- * keychain here" is a normal answer on a headless box, not a failure.
76
- */
77
- function detectBackend() {
78
- const os = (0, node_os_1.platform)();
79
- if (os === 'darwin') {
80
- const probe = run('security', ['-h']);
81
- 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)
82
216
  return { backend: null, reason: 'security(1) not found — unexpected on macOS.' };
83
217
  return { backend: 'macos-keychain' };
84
218
  }
85
- if (os === 'win32') {
219
+ if (current === 'win32') {
86
220
  // Starting PowerShell is not the same as being able to encrypt. On CI
87
221
  // runners and locked-down corporate machines it answers happily and then
88
222
  // fails at the first write, so the probe seals a byte for real.
89
- const probe = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', DPAPI_PROBE]);
90
- 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')) {
91
225
  return {
92
226
  backend: null,
93
227
  reason: 'DPAPI is not reachable from PowerShell here, which happens on CI runners and ' +
94
- '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.',
95
229
  };
96
230
  }
97
231
  return { backend: 'windows-dpapi' };
@@ -99,25 +233,59 @@ function detectBackend() {
99
233
  // Linux and the BSDs. secret-tool needs a running Secret Service over D-Bus;
100
234
  // over SSH with no desktop session there is usually none, and that is worth
101
235
  // saying plainly rather than failing with a D-Bus error.
102
- const probe = run('secret-tool', ['--help']);
103
- if (probe.error) {
236
+ const r = yield { cmd: 'secret-tool', args: ['--help'] };
237
+ if (r.error) {
104
238
  return { backend: null, reason: 'secret-tool not installed. On Debian/Ubuntu: apt install libsecret-tools.' };
105
239
  }
106
240
  if (!process.env['DBUS_SESSION_BUS_ADDRESS']) {
107
241
  return {
108
242
  backend: null,
109
243
  reason: 'secret-tool is installed but no D-Bus session is running, which is normal over SSH. ' +
110
- '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.',
111
245
  };
112
246
  }
113
247
  return { backend: 'linux-secret-service' };
114
248
  }
115
- function requireBackend() {
116
- 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();
117
266
  if (!backend)
118
267
  throw new KeychainUnavailable(reason ?? 'No credential store available on this machine.');
119
268
  return backend;
120
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
+ }
121
289
  // Every DPAPI call costs a PowerShell start, about a second. A script that
122
290
  // needs four credentials would pay four seconds for no reason, so a read is
123
291
  // held briefly in memory.
@@ -125,11 +293,22 @@ function requireBackend() {
125
293
  // Briefly is the whole point. The MCP server is long-lived, and a cache with
126
294
  // no expiry would keep every secret in the heap for as long as the editor is
127
295
  // open. Thirty seconds covers the burst of reads at the start of a task and
128
- // nothing beyond it.
296
+ // nothing beyond it — and a timer drops it then, not the next call.
129
297
  const CACHE_TTL_MS = 30_000;
130
298
  let cached = null;
299
+ let cacheTimer = null;
131
300
  function invalidateCache() {
132
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?.();
133
312
  }
134
313
  // ConvertTo-SecureString and its twin live in Microsoft.PowerShell.Security,
135
314
  // and there are Windows machines where that module does not load at all — CI
@@ -152,75 +331,146 @@ const DPAPI_OPEN = DPAPI_PREAMBLE +
152
331
  const DPAPI_PROBE = DPAPI_PREAMBLE +
153
332
  "$p=[Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('ok'),$null,'CurrentUser');" +
154
333
  "[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($p,$null,'CurrentUser'))";
155
- function psDpapi(script, input) {
156
- 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 };
157
341
  if (r.error || r.status !== 0) {
158
- 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'}`);
159
343
  }
160
344
  return r.stdout;
161
345
  }
162
- function winRead(fresh = false) {
163
- const { file } = winPaths();
164
- // Compare the ciphertext itself, not mtime/size: a same-size replacement or
165
- // restored timestamp must not hide an external update. Reading is cheap;
166
- // the cache avoids the expensive DPAPI subprocess only while bytes match.
167
- let sealed;
346
+ /** Opens sealed text into a store object. Throws on anything that is not one. */
347
+ function* openSealed(sealed) {
348
+ // 1.8.0 and 1.8.1 sealed with ConvertFrom-SecureString, whose output is hex.
349
+ // Anything else is the ProtectedData format. Reading both means an existing
350
+ // store keeps working instead of looking corrupt after an update.
351
+ const legacy = /^[0-9a-f]+$/i.test(sealed);
352
+ const json = yield* psDpapi(legacy ? LEGACY_OPEN : DPAPI_OPEN, sealed);
353
+ let store;
168
354
  try {
169
- sealed = (0, node_fs_1.readFileSync)(file, 'utf8').trim();
355
+ store = JSON.parse(json);
356
+ }
357
+ catch {
358
+ throw new KeychainUnavailable('The key store exists but could not be read. It was sealed by a different Windows account.');
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();
170
429
  }
171
430
  catch (err) {
172
- invalidateCache();
173
431
  if (err.code === 'ENOENT')
174
- return {};
432
+ return null;
175
433
  throw err;
176
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
+ }
177
452
  if (!fresh && cached && cached.file === file && cached.sealed === sealed && Date.now() - cached.at < CACHE_TTL_MS) {
178
453
  return cached.store;
179
454
  }
180
455
  invalidateCache();
181
456
  if (!sealed)
182
457
  throw new KeychainUnavailable('The key store is empty or truncated. Refusing to overwrite it.');
183
- // ConvertTo-SecureString without -Key is DPAPI at CurrentUser scope: only
184
- // this Windows account, on this machine, can turn it back into text.
185
- // 1.8.0 and 1.8.1 sealed with ConvertFrom-SecureString, whose output is hex.
186
- // Anything else is the ProtectedData format. Reading both means an existing
187
- // store keeps working instead of looking corrupt after an update.
188
- const legacy = /^[0-9a-f]+$/i.test(sealed);
189
- const json = legacy
190
- ? psDpapi('$e = [Console]::In.ReadToEnd().Trim();' +
191
- '$s = ConvertTo-SecureString $e;' +
192
- '$b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s);' +
193
- 'try { [Runtime.InteropServices.Marshal]::PtrToStringAuto($b) }' +
194
- 'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }', sealed)
195
- : psDpapi(DPAPI_OPEN, sealed);
196
- try {
197
- const store = JSON.parse(json);
198
- cached = { store, at: Date.now(), file, sealed };
199
- return store;
200
- }
201
- catch {
202
- throw new KeychainUnavailable('The key store exists but could not be read. It was sealed by a different Windows account.');
203
- }
458
+ const store = yield* openSealed(sealed);
459
+ remember(store, file, sealed);
460
+ return store;
204
461
  }
205
- function winWrite(store) {
206
- const { dir, file } = winPaths();
207
- invalidateCache();
208
- if (!(0, node_fs_1.existsSync)(dir))
209
- (0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
210
- const sealed = psDpapi(DPAPI_SEAL, JSON.stringify(store)).trim();
211
- // Atomic: a crash mid-write leaves the previous store intact rather than a
212
- // truncated file that would take every secret with it.
213
- const tmp = `${file}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`;
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`;
214
465
  let created = false;
215
466
  let fd;
216
467
  try {
217
468
  fd = (0, node_fs_1.openSync)(tmp, 'wx', 0o600);
218
469
  created = true;
219
- (0, node_fs_1.writeFileSync)(fd, sealed, { encoding: 'ascii' });
470
+ (0, node_fs_1.writeFileSync)(fd, content, { encoding: 'ascii' });
220
471
  (0, node_fs_1.closeSync)(fd);
221
472
  fd = undefined;
222
- (0, node_fs_1.renameSync)(tmp, file);
223
- cached = { store, at: Date.now(), file, sealed };
473
+ (0, node_fs_1.renameSync)(tmp, target);
224
474
  }
225
475
  finally {
226
476
  if (fd !== undefined)
@@ -229,6 +479,131 @@ function winWrite(store) {
229
479
  (0, node_fs_1.unlinkSync)(tmp);
230
480
  }
231
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
+ }
513
+ }
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) {
549
+ const { dir, file } = winPaths();
550
+ invalidateCache();
551
+ if (!(0, node_fs_1.existsSync)(dir))
552
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
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));
606
+ }
232
607
  // ─── macOS: an index of its own ──────────────────────────────────
233
608
  //
234
609
  // There is no clean way to list the items of one service. dump-keychain asks
@@ -253,137 +628,308 @@ function macDecode(raw) {
253
628
  // removed: .trim() would eat spaces that belong to the secret.
254
629
  return Buffer.from(raw.replace(/\n$/, ''), 'base64').toString('utf8');
255
630
  }
256
- const MAC_INDEX = '__crbro_index__';
257
- function macReadIndex() {
258
- const r = run('security', ['find-generic-password', '-s', SERVICE, '-a', MAC_INDEX, '-w']);
259
- if (r.status !== 0)
260
- 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;
261
645
  try {
262
- return JSON.parse(macDecode(r.stdout));
646
+ index = JSON.parse(raw);
263
647
  }
264
648
  catch {
265
- return {};
649
+ throw new KeychainUnavailable(`The CRBRO name index in ${where} is unreadable. Refusing to rewrite it, so no name is lost.`);
266
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.`);
653
+ }
654
+ return index;
655
+ }
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');
267
667
  }
268
- function macWriteIndex(index) {
269
- run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', MAC_INDEX, '-w', macEncode(JSON.stringify(index)), '-T', '/usr/bin/security']);
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);
270
675
  }
271
- // ─── Public surface ──────────────────────────────────────────────
272
- function setSecret(name, value, description = '') {
273
- const backend = requireBackend();
274
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
275
- throw new Error(`Invalid secret name "${name}". Use SCREAMING_SNAKE_CASE, e.g. WORDPRESS_APP_PASSWORD.`);
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;
684
+ }
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}`}`);
276
691
  }
277
- if (!value)
278
- 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);
279
733
  if (backend === 'windows-dpapi') {
280
- (0, lock_js_1.withLockSync)(winPaths().file, () => {
281
- const store = winRead(true);
282
- store[name] = { value, description, updated: new Date().toISOString().slice(0, 10) };
283
- winWrite(store);
284
- });
734
+ const store = yield* winRead(true, !!opts.force);
735
+ store[name] = { value, description, updated: today };
736
+ yield* winWrite(store);
285
737
  return;
286
738
  }
287
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();
288
742
  // -U updates in place instead of erroring when the item already exists.
289
743
  // -w with the value on argv is visible to `ps` for the life of the call;
290
- // security(1) offers no stdin form, so the exposure is unavoidable and
291
- // measured in milliseconds on the user's own machine.
292
- const r = run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', name, '-w', macEncode(value), '-j', description, '-T', '/usr/bin/security']);
293
- if (r.status !== 0)
294
- throw new KeychainUnavailable(`Keychain refused the write: ${(r.stderr || '').trim()}`);
295
- const index = macReadIndex();
296
- index[name] = { description, updated: new Date().toISOString().slice(0, 10) };
297
- 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);
298
754
  return;
299
755
  }
756
+ const index = yield* linuxReadIndex();
300
757
  // secret-tool reads the secret from stdin, so it never reaches the process
301
- // table. It stays open waiting for EOF, which spawnSync gives it.
302
- const r = run('secret-tool', ['store', '--label', `CRBRO ${name}`, 'service', SERVICE, 'account', name, 'description', description], value);
303
- 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)
304
765
  throw new KeychainUnavailable(`secret-tool refused the write: ${(r.stderr || '').trim()}`);
766
+ index[name] = { description, updated: today };
767
+ yield* linuxWriteIndex(index);
305
768
  }
306
- /**
307
- * The value, or null when there is no such secret. An environment variable of
308
- * the same name wins, which is what makes CI and one-off overrides work
309
- * without touching the store.
310
- */
311
- function getSecret(name) {
312
- const fromEnv = process.env[name];
313
- if (fromEnv)
314
- return fromEnv;
315
- 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();
316
774
  if (backend === 'windows-dpapi') {
317
- return winRead()[name]?.value ?? null;
775
+ const v = (yield* winRead())[name]?.value;
776
+ return v ? { value: v, source: 'keychain' } : null;
318
777
  }
319
778
  if (backend === 'macos-keychain') {
320
- const r = run('security', ['find-generic-password', '-s', SERVICE, '-a', name, '-w']);
321
- // security(1) returns the OSStatus itself, truncated to eight bits: 44 is
322
- // errSecItemNotFound, 36 is errSecInteractionNotAllowed — a locked
323
- // keychain, which is the normal state over SSH and in CI. Reporting that
324
- // one as "no such secret" would send someone hunting for a typo.
325
- if (r.status === 36) {
326
- throw new KeychainUnavailable('The macOS keychain is locked, which is usual over SSH or in CI. Unlock it in a desktop session, ' +
327
- 'or pass the credential as an environment variable instead.');
328
- }
329
- 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)
330
784
  return null;
331
- 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' };
332
788
  }
333
789
  // Unlike security(1), secret-tool adds no trailing newline when stdout
334
790
  // is a pipe: write_password_stdout only appends one for a tty. Trimming
335
791
  // here would silently eat a newline that was genuinely part of the secret.
336
792
  // Exit 1 with nothing on stderr is how it reports "no such item"; anything
337
793
  // on stderr is a real failure and must not be mistaken for absence.
338
- const r = run('secret-tool', ['lookup', 'service', SERVICE, 'account', name]);
794
+ const r = yield { cmd: 'secret-tool', args: ['lookup', 'service', SERVICE, 'account', name] };
339
795
  if (r.status === 0)
340
- return r.stdout;
341
- if (r.status === 1 && !(r.stderr || '').trim())
796
+ return { value: r.stdout, source: 'keychain' };
797
+ if (linuxNotFound(r))
342
798
  return null;
343
799
  throw new KeychainUnavailable(`secret-tool failed: ${(r.stderr || '').trim() || `exit ${r.status}`}`);
344
800
  }
345
- /** Names and descriptions only. Values are never returned by design. */
346
- function listSecrets() {
347
- const backend = requireBackend();
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));
806
+ }
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) {
348
816
  if (backend === 'windows-dpapi') {
349
- return Object.entries(winRead())
350
- .map(([name, e]) => ({ name, description: e.description, updated: e.updated }))
351
- .sort((a, b) => a.name.localeCompare(b.name));
817
+ const store = yield* winRead(true);
818
+ if (!(name in store))
819
+ return false;
820
+ delete store[name];
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);
824
+ return true;
352
825
  }
353
826
  if (backend === 'macos-keychain') {
354
- return Object.entries(macReadIndex())
355
- .map(([name, e]) => ({ name, description: e.description, updated: e.updated }))
356
- .sort((a, b) => a.name.localeCompare(b.name));
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;
835
+ }
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;
357
841
  }
358
- const r = run('secret-tool', ['search', '--all', 'service', SERVICE]);
359
- if (r.status !== 0)
360
- return [];
361
- const names = [...r.stdout.matchAll(/^attribute\.account = (.+)$/gm)].map(m => m[1].trim());
362
- return [...new Set(names)].sort().map(name => ({ name, description: '', updated: '' }));
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}`}`);
846
+ }
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;
872
+ }
873
+ /** Names and descriptions only. Values are never returned by design. */
874
+ function listSecrets() {
875
+ return driveSync(listGen());
363
876
  }
364
877
  function removeSecret(name) {
365
- const backend = requireBackend();
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();
366
911
  if (backend === 'windows-dpapi') {
367
- return (0, lock_js_1.withLockSync)(winPaths().file, () => {
368
- const store = winRead(true);
369
- if (!(name in store))
370
- return false;
371
- delete store[name];
372
- winWrite(store);
373
- return true;
374
- });
375
- }
376
- if (backend === 'macos-keychain') {
377
- const gone = run('security', ['delete-generic-password', '-s', SERVICE, '-a', name]).status === 0;
378
- if (gone) {
379
- const index = macReadIndex();
380
- delete index[name];
381
- macWriteIndex(index);
382
- }
383
- return gone;
912
+ const sealed = readMain(winPaths().file) ?? '';
913
+ return (0, node_crypto_1.createHash)('sha256').update(sealed).digest('hex');
384
914
  }
385
- return run('secret-tool', ['clear', 'service', SERVICE, 'account', name]).status === 0;
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);
386
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 ──────────────────────────────────────────────────
387
933
  /** Windows only, and only for tests: forget the sealed file entirely. */
388
934
  function _resetWindowsStoreForTests() {
389
935
  invalidateCache();
@@ -391,4 +937,17 @@ function _resetWindowsStoreForTests() {
391
937
  if ((0, node_fs_1.existsSync)(file))
392
938
  (0, node_fs_1.unlinkSync)(file);
393
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
+ }
394
953
  //# sourceMappingURL=keychain.js.map