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.
- package/LICENSE +21 -21
- package/README.md +256 -465
- package/SECURITY.md +114 -12
- package/bin/crbro.mjs +539 -300
- 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 +139 -19
- package/dist/engine/brain.js +22 -6
- package/dist/engine/codexconfig.js +476 -0
- package/dist/engine/cortex.js +667 -232
- package/dist/engine/doctor.js +312 -14
- package/dist/engine/heat.js +81 -37
- package/dist/engine/hippocampus.js +18 -7
- 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 +721 -162
- package/dist/engine/maintenance.js +355 -87
- package/dist/engine/modinstall.js +98 -45
- package/dist/engine/prefrontal.js +29 -6
- package/dist/engine/quarantine.js +204 -0
- package/dist/engine/secrets.js +41 -5
- package/dist/engine/shelf.js +25 -3
- 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 +797 -176
- package/dist/search/semantic.js +208 -18
- package/dist/search/tokenize.js +44 -0
- package/dist/server.js +583 -373
- package/dist/sync/git.js +203 -72
- package/dist/sync/space.js +100 -25
- 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 +94 -7
- 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 +9 -5
- 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 -682
- 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/doctor.d.ts +0 -93
- package/dist/engine/doctor.d.ts.map +0 -1
- package/dist/engine/doctor.js.map +0 -1
- package/dist/engine/evidence.d.ts +0 -26
- package/dist/engine/evidence.d.ts.map +0 -1
- package/dist/engine/evidence.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 -64
- package/dist/engine/prefrontal.d.ts.map +0 -1
- package/dist/engine/prefrontal.js.map +0 -1
- package/dist/engine/projects.d.ts +0 -20
- package/dist/engine/projects.d.ts.map +0 -1
- package/dist/engine/projects.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 -155
- 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 -328
- package/dist/search/index.d.ts.map +0 -1
- package/dist/search/index.js.map +0 -1
- package/dist/search/quality.d.ts +0 -18
- package/dist/search/quality.d.ts.map +0 -1
- package/dist/search/quality.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 -36
- package/dist/sync/materialize.d.ts.map +0 -1
- package/dist/sync/materialize.js.map +0 -1
- package/dist/sync/ops.d.ts +0 -147
- package/dist/sync/ops.d.ts.map +0 -1
- package/dist/sync/ops.js.map +0 -1
- package/dist/sync/space.d.ts +0 -118
- package/dist/sync/space.d.ts.map +0 -1
- package/dist/sync/space.js.map +0 -1
- package/dist/types/index.d.ts +0 -369
- 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 -13
- 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,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 {
|
|
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
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 (
|
|
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
|
|
90
|
-
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')) {
|
|
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
|
|
103
|
-
if (
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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,
|
|
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,
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
646
|
+
index = JSON.parse(raw);
|
|
263
647
|
}
|
|
264
648
|
catch {
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
// ───
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
(
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
291
|
-
// measured in milliseconds on the user's own machine.
|
|
292
|
-
const r =
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
302
|
-
const r =
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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 =
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|