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.
- package/LICENSE +21 -21
- package/README.md +256 -456
- package/SECURITY.md +135 -8
- package/bin/crbro.mjs +555 -299
- package/dist/build-id.json +10 -0
- package/dist/cli/common.js +211 -0
- package/dist/cli/export.js +522 -0
- package/dist/cli/import.js +683 -0
- package/dist/cli/markdown.js +313 -0
- package/dist/cli/mcpclient.js +107 -0
- package/dist/cli/serve.js +453 -0
- package/dist/cli/view.js +358 -0
- package/dist/cli/viewer-page.js +378 -0
- package/dist/daemon/daemon.js +119 -11
- package/dist/daemon/endpoint.js +222 -21
- package/dist/daemon/lines.js +102 -13
- package/dist/daemon/proxy.js +480 -125
- package/dist/daemon/surface.js +116 -0
- package/dist/daemon/trace.js +73 -0
- package/dist/engine/atrest.js +132 -0
- package/dist/engine/backup.js +143 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +762 -311
- package/dist/engine/doctor.js +676 -0
- package/dist/engine/evidence.js +171 -0
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +50 -36
- package/dist/engine/hookrefresh.js +296 -0
- package/dist/engine/hookverify.js +15 -3
- package/dist/engine/installer.js +1022 -0
- package/dist/engine/installio.js +279 -0
- package/dist/engine/keychain.js +735 -144
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +126 -69
- package/dist/engine/projects.js +80 -0
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +34 -11
- package/dist/engine/synapses.js +183 -80
- package/dist/engine/triggers.js +141 -11
- package/dist/index.js +37 -3
- package/dist/mcp-surface.json +1 -0
- package/dist/miner/extractor.js +64 -17
- package/dist/miner/scheduler.js +144 -68
- package/dist/search/index.js +810 -176
- package/dist/search/quality.js +31 -0
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +649 -343
- package/dist/sync/git.js +203 -72
- package/dist/sync/materialize.js +33 -8
- package/dist/sync/space.js +286 -101
- package/dist/sync/validate.js +204 -0
- package/dist/utils/budget.js +15 -2
- package/dist/utils/fs.js +40 -6
- package/dist/utils/ids.js +128 -14
- package/dist/utils/lock.js +219 -52
- package/hooks/crbro-codex.mjs +284 -0
- package/hooks/crbro-guard.mjs +281 -173
- package/hooks/crbro-hooks.shipped.json +20 -0
- package/hooks/crbro-lifecycle.mjs +16 -4
- package/hooks/crbro-subagent.mjs +180 -180
- package/package.json +12 -8
- package/dist/daemon/daemon.d.ts +0 -25
- package/dist/daemon/daemon.d.ts.map +0 -1
- package/dist/daemon/daemon.js.map +0 -1
- package/dist/daemon/endpoint.d.ts +0 -66
- package/dist/daemon/endpoint.d.ts.map +0 -1
- package/dist/daemon/endpoint.js.map +0 -1
- package/dist/daemon/lines.d.ts +0 -55
- package/dist/daemon/lines.d.ts.map +0 -1
- package/dist/daemon/lines.js.map +0 -1
- package/dist/daemon/proxy.d.ts +0 -55
- package/dist/daemon/proxy.d.ts.map +0 -1
- package/dist/daemon/proxy.js.map +0 -1
- package/dist/engine/backup.d.ts +0 -68
- package/dist/engine/backup.d.ts.map +0 -1
- package/dist/engine/backup.js.map +0 -1
- package/dist/engine/brain.d.ts +0 -79
- package/dist/engine/brain.d.ts.map +0 -1
- package/dist/engine/brain.js.map +0 -1
- package/dist/engine/cortex.d.ts +0 -670
- package/dist/engine/cortex.d.ts.map +0 -1
- package/dist/engine/cortex.js.map +0 -1
- package/dist/engine/dates.d.ts +0 -28
- package/dist/engine/dates.d.ts.map +0 -1
- package/dist/engine/dates.js.map +0 -1
- package/dist/engine/heat.d.ts +0 -28
- package/dist/engine/heat.d.ts.map +0 -1
- package/dist/engine/heat.js.map +0 -1
- package/dist/engine/hippocampus.d.ts +0 -60
- package/dist/engine/hippocampus.d.ts.map +0 -1
- package/dist/engine/hippocampus.js.map +0 -1
- package/dist/engine/hookverify.d.ts +0 -42
- package/dist/engine/hookverify.d.ts.map +0 -1
- package/dist/engine/hookverify.js.map +0 -1
- package/dist/engine/keychain.d.ts +0 -32
- package/dist/engine/keychain.d.ts.map +0 -1
- package/dist/engine/keychain.js.map +0 -1
- package/dist/engine/maintenance.d.ts +0 -218
- package/dist/engine/maintenance.d.ts.map +0 -1
- package/dist/engine/maintenance.js.map +0 -1
- package/dist/engine/modinstall.d.ts +0 -215
- package/dist/engine/modinstall.d.ts.map +0 -1
- package/dist/engine/modinstall.js.map +0 -1
- package/dist/engine/postmortem.d.ts +0 -81
- package/dist/engine/postmortem.d.ts.map +0 -1
- package/dist/engine/postmortem.js.map +0 -1
- package/dist/engine/prefrontal.d.ts +0 -61
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/secrets.d.ts +0 -30
- package/dist/engine/secrets.d.ts.map +0 -1
- package/dist/engine/secrets.js.map +0 -1
- package/dist/engine/shelf.d.ts +0 -156
- package/dist/engine/shelf.d.ts.map +0 -1
- package/dist/engine/shelf.js.map +0 -1
- package/dist/engine/source.d.ts +0 -6
- package/dist/engine/source.d.ts.map +0 -1
- package/dist/engine/source.js.map +0 -1
- package/dist/engine/synapses.d.ts +0 -82
- package/dist/engine/synapses.d.ts.map +0 -1
- package/dist/engine/synapses.js.map +0 -1
- package/dist/engine/triggers.d.ts +0 -53
- package/dist/engine/triggers.d.ts.map +0 -1
- package/dist/engine/triggers.js.map +0 -1
- package/dist/engine/usage.d.ts +0 -61
- package/dist/engine/usage.d.ts.map +0 -1
- package/dist/engine/usage.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/miner/extractor.d.ts +0 -12
- package/dist/miner/extractor.d.ts.map +0 -1
- package/dist/miner/extractor.js.map +0 -1
- package/dist/miner/index.d.ts +0 -61
- package/dist/miner/index.d.ts.map +0 -1
- package/dist/miner/index.js.map +0 -1
- package/dist/miner/scheduler.d.ts +0 -30
- package/dist/miner/scheduler.d.ts.map +0 -1
- package/dist/miner/scheduler.js.map +0 -1
- package/dist/miner/types.d.ts +0 -11
- package/dist/miner/types.d.ts.map +0 -1
- package/dist/miner/types.js.map +0 -1
- package/dist/search/index.d.ts +0 -323
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/semantic.d.ts +0 -80
- package/dist/search/semantic.d.ts.map +0 -1
- package/dist/search/semantic.js.map +0 -1
- package/dist/search/synonyms.d.ts +0 -2
- package/dist/search/synonyms.d.ts.map +0 -1
- package/dist/search/synonyms.js.map +0 -1
- package/dist/search/tokenize.d.ts +0 -39
- package/dist/search/tokenize.d.ts.map +0 -1
- package/dist/search/tokenize.js.map +0 -1
- package/dist/server.d.ts +0 -41
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/sync/git.d.ts +0 -36
- package/dist/sync/git.d.ts.map +0 -1
- package/dist/sync/git.js.map +0 -1
- package/dist/sync/materialize.d.ts +0 -35
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -142
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -116
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -346
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/budget.d.ts +0 -44
- package/dist/utils/budget.d.ts.map +0 -1
- package/dist/utils/budget.js.map +0 -1
- package/dist/utils/fs.d.ts +0 -59
- package/dist/utils/fs.d.ts.map +0 -1
- package/dist/utils/fs.js.map +0 -1
- package/dist/utils/hash.d.ts +0 -20
- package/dist/utils/hash.d.ts.map +0 -1
- package/dist/utils/hash.js.map +0 -1
- package/dist/utils/ids.d.ts +0 -47
- package/dist/utils/ids.d.ts.map +0 -1
- package/dist/utils/ids.js.map +0 -1
- package/dist/utils/lock.d.ts +0 -11
- package/dist/utils/lock.d.ts.map +0 -1
- package/dist/utils/lock.js.map +0 -1
- package/dist/utils/transcripts.d.ts +0 -63
- package/dist/utils/transcripts.d.ts.map +0 -1
- package/dist/utils/transcripts.js.map +0 -1
- package/dist/version.d.ts +0 -26
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
package/dist/engine/keychain.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 (
|
|
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
|
|
88
|
-
if (
|
|
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
|
|
101
|
-
if (
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
646
|
+
index = JSON.parse(raw);
|
|
235
647
|
}
|
|
236
648
|
catch {
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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:
|
|
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
|
|
261
|
-
// measured in milliseconds on the user's own machine.
|
|
262
|
-
const r =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
272
|
-
const r =
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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 =
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
335
|
-
const backend =
|
|
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
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|