@phnx-labs/agents-cli 1.20.52 → 1.20.53
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/CHANGELOG.md +57 -2
- package/README.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/exec.js +65 -1
- package/dist/commands/feed.d.ts +29 -0
- package/dist/commands/feed.js +237 -32
- package/dist/commands/memory.d.ts +9 -0
- package/dist/commands/memory.js +164 -0
- package/dist/commands/message.d.ts +11 -6
- package/dist/commands/message.js +140 -5
- package/dist/commands/routines.js +12 -0
- package/dist/commands/secrets-migrate.d.ts +2 -1
- package/dist/commands/secrets-migrate.js +88 -13
- package/dist/commands/secrets.js +4 -1
- package/dist/commands/sessions.js +10 -1
- package/dist/commands/worktree.js +4 -2
- package/dist/index.js +16 -21
- package/dist/lib/agents.js +249 -17
- package/dist/lib/answer-router.d.ts +75 -0
- package/dist/lib/answer-router.js +149 -0
- package/dist/lib/ask-classifier.d.ts +71 -0
- package/dist/lib/ask-classifier.js +197 -0
- package/dist/lib/cloud/antigravity.d.ts +0 -2
- package/dist/lib/cloud/antigravity.js +2 -17
- package/dist/lib/cloud/codex.js +3 -18
- package/dist/lib/cloud/rush.js +3 -15
- package/dist/lib/cloud/stream.js +2 -0
- package/dist/lib/cloud/types.d.ts +21 -0
- package/dist/lib/cloud/types.js +81 -0
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +12 -2
- package/dist/lib/crabbox/lease.d.ts +13 -0
- package/dist/lib/crabbox/lease.js +11 -2
- package/dist/lib/crabbox/progress.d.ts +62 -0
- package/dist/lib/crabbox/progress.js +129 -0
- package/dist/lib/events.js +4 -1
- package/dist/lib/exec.js +19 -1
- package/dist/lib/feed-outcome.d.ts +101 -0
- package/dist/lib/feed-outcome.js +244 -0
- package/dist/lib/feed-policy.d.ts +30 -0
- package/dist/lib/feed-policy.js +133 -0
- package/dist/lib/feed.d.ts +127 -3
- package/dist/lib/feed.js +416 -40
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.js +20 -1
- package/dist/lib/hooks.js +522 -12
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +3 -4
- package/dist/lib/mailbox-gc.d.ts +22 -0
- package/dist/lib/mailbox-gc.js +161 -0
- package/dist/lib/mailbox.d.ts +26 -2
- package/dist/lib/mailbox.js +80 -5
- package/dist/lib/mcp.js +82 -0
- package/dist/lib/memory.d.ts +55 -0
- package/dist/lib/memory.js +274 -0
- package/dist/lib/notify.d.ts +16 -0
- package/dist/lib/notify.js +61 -0
- package/dist/lib/operator.d.ts +26 -0
- package/dist/lib/operator.js +107 -0
- package/dist/lib/plugins.d.ts +35 -0
- package/dist/lib/plugins.js +217 -0
- package/dist/lib/remote-agents-json.d.ts +14 -0
- package/dist/lib/remote-agents-json.js +94 -0
- package/dist/lib/resources/mcp.js +44 -0
- package/dist/lib/resources/memory.d.ts +15 -0
- package/dist/lib/resources/memory.js +46 -0
- package/dist/lib/resources/types.d.ts +2 -2
- package/dist/lib/runner.d.ts +43 -0
- package/dist/lib/runner.js +323 -74
- package/dist/lib/sandbox.js +6 -0
- package/dist/lib/secrets/bundles.js +38 -13
- package/dist/lib/secrets/icloud-import.d.ts +12 -3
- package/dist/lib/secrets/icloud-import.js +37 -7
- package/dist/lib/secrets/index.d.ts +106 -2
- package/dist/lib/secrets/index.js +603 -28
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +47 -17
- package/dist/lib/session/db.d.ts +9 -1
- package/dist/lib/session/db.js +18 -3
- package/dist/lib/session/discover.d.ts +13 -0
- package/dist/lib/session/discover.js +31 -0
- package/dist/lib/session/parse.d.ts +8 -0
- package/dist/lib/session/parse.js +42 -21
- package/dist/lib/session/remote-active.js +8 -89
- package/dist/lib/session/state.d.ts +11 -0
- package/dist/lib/session/state.js +37 -0
- package/dist/lib/session/tail.d.ts +23 -4
- package/dist/lib/session/tail.js +34 -16
- package/dist/lib/session/throughput.d.ts +30 -0
- package/dist/lib/session/throughput.js +86 -0
- package/dist/lib/shim-heal.d.ts +12 -3
- package/dist/lib/shim-heal.js +12 -6
- package/dist/lib/staleness/detectors/subagents.js +57 -3
- package/dist/lib/staleness/writers/hooks.js +7 -3
- package/dist/lib/staleness/writers/subagents.js +37 -6
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/subagents.d.ts +52 -0
- package/dist/lib/subagents.js +315 -12
- package/dist/lib/teams/worktree.d.ts +8 -0
- package/dist/lib/teams/worktree.js +8 -0
- package/dist/lib/types.d.ts +10 -2
- package/dist/lib/versions.js +38 -48
- package/package.json +4 -3
- package/scripts/postinstall.js +61 -1
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* rather than the system's cloud-keychain path.
|
|
24
24
|
*/
|
|
25
25
|
import { execFileSync, spawnSync } from 'child_process';
|
|
26
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
26
27
|
import * as fs from 'fs';
|
|
27
28
|
import * as os from 'os';
|
|
28
29
|
import * as path from 'path';
|
|
@@ -97,6 +98,10 @@ let backend = null;
|
|
|
97
98
|
export function setKeychainBackendForTest(b) {
|
|
98
99
|
const prev = backend;
|
|
99
100
|
backend = b;
|
|
101
|
+
// The hashing state depends on whether a backend is installed — never let a
|
|
102
|
+
// state resolved against the real keychain leak into a backend-driven test.
|
|
103
|
+
hashStateCache = null;
|
|
104
|
+
autoRekeyAttempted = false;
|
|
100
105
|
return prev;
|
|
101
106
|
}
|
|
102
107
|
/** True when a test backend is installed (real keychain / biometry bypassed).
|
|
@@ -118,8 +123,550 @@ export function isKeychainBackendOverridden() {
|
|
|
118
123
|
function isOurItem(item) {
|
|
119
124
|
return item.startsWith('agents-cli.');
|
|
120
125
|
}
|
|
126
|
+
// ─── Hashed service names (GitHub #316, Finding 1) ──────────────────────────
|
|
127
|
+
//
|
|
128
|
+
// The helper's `list` never decrypts and never prompts (by design), which made
|
|
129
|
+
// service names enumerable metadata: any same-user process could silently read
|
|
130
|
+
// every bundle, key, and provider name (`agents-cli.secrets.<bundle>.<KEY>`,
|
|
131
|
+
// `agents-cli.<provider>.token`) and build a target list before ever popping
|
|
132
|
+
// Touch ID. To close that, on macOS every item in our namespace is stored
|
|
133
|
+
// under an opaque HMAC-SHA256-hashed service name:
|
|
134
|
+
//
|
|
135
|
+
// agents-cli.bundles.<name> → agents-cli.h.<ns>.m
|
|
136
|
+
// agents-cli.secrets.<bundle>.<KEY> → agents-cli.h.<ns>.k.<kh>
|
|
137
|
+
// agents-cli.<anything else> → agents-cli.h.o.<ih>
|
|
138
|
+
//
|
|
139
|
+
// where <ns> = HMAC(key, 'ns\0'+bundle) and <kh>/<ih> are per-item HMACs
|
|
140
|
+
// (first 32 hex chars each). The per-bundle <ns> segment is deliberate: a
|
|
141
|
+
// bundle's value items keep a common silent-enumerable prefix
|
|
142
|
+
// (`agents-cli.h.<ns>.k.`), so readAndResolveBundleEnv still fetches metadata
|
|
143
|
+
// + all values in ONE get-batch behind ONE Touch ID — a flat hash of the full
|
|
144
|
+
// name would have forced a second prompt on every bundle read. Names still
|
|
145
|
+
// start with `agents-cli.`, so the helper's JIT-migration guard and prefix
|
|
146
|
+
// gates keep working. What an enumerator learns shrinks to item grouping and
|
|
147
|
+
// counts — never a bundle, key, or provider name.
|
|
148
|
+
//
|
|
149
|
+
// The HMAC key is 32 random bytes in `agents-cli.hmackey`, written through the
|
|
150
|
+
// helper's no-ACL path (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
151
|
+
// device-local, access-group-pinned). Deliberately NO user-presence ACL: the
|
|
152
|
+
// key protects metadata confidentiality only, and gating it behind Touch ID
|
|
153
|
+
// would make every silent operation (list/has) prompt. Per-machine is fine —
|
|
154
|
+
// sync re-materializes items locally through the same primitives, so hashed
|
|
155
|
+
// names never leave the machine. Deriving the key from machine constants was
|
|
156
|
+
// rejected: that would hand any local process a dictionary-confirmation
|
|
157
|
+
// oracle without even touching the keychain.
|
|
158
|
+
//
|
|
159
|
+
// Hashing activates only after the one-time re-key migration
|
|
160
|
+
// (rekeyServiceNames below / `agents secrets rekey`) has moved every existing
|
|
161
|
+
// cleartext-named item; until then all operations use cleartext names exactly
|
|
162
|
+
// as before. The sentinel lives INSIDE the hmackey record — in the keychain,
|
|
163
|
+
// not on disk — so it can never desync from the items it describes (e.g. a
|
|
164
|
+
// keychain restored from a backup brings its matching state along).
|
|
165
|
+
const HASHED_SERVICE_PREFIX = `${SERVICE_PREFIX}.h.`;
|
|
166
|
+
export const HMAC_KEY_ITEM = `${SERVICE_PREFIX}.hmackey`;
|
|
167
|
+
const HASHED_META_RE = /^agents-cli\.h\.[0-9a-f]{32}\.m$/;
|
|
168
|
+
let hashStateCache = null;
|
|
169
|
+
let forcedTestKey = null;
|
|
170
|
+
let rawScopeDepth = 0;
|
|
171
|
+
let rekeyRunning = false;
|
|
172
|
+
let autoRekeyAttempted = false;
|
|
173
|
+
/** Force hashed service names on with a fixed key (test only). Pass null to
|
|
174
|
+
* restore lazy production resolution. Composes with setKeychainBackendForTest
|
|
175
|
+
* so unit tests exercise the exact transform production uses. */
|
|
176
|
+
export function setKeychainServiceHashingForTest(key) {
|
|
177
|
+
forcedTestKey = key;
|
|
178
|
+
hashStateCache = null;
|
|
179
|
+
autoRekeyAttempted = false;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Run `fn` with service-name hashing suspended: every primitive uses the
|
|
183
|
+
* literal names it is given. For migration flows ONLY — they enumerate raw
|
|
184
|
+
* names from the helper (which may be pre-re-key cleartext leftovers) and must
|
|
185
|
+
* read/delete those exact items, not their hashed transforms.
|
|
186
|
+
*/
|
|
187
|
+
export function withRawKeychainServiceNames(fn) {
|
|
188
|
+
rawScopeDepth++;
|
|
189
|
+
try {
|
|
190
|
+
return fn();
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
rawScopeDepth--;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function hmacHex32(key, input) {
|
|
197
|
+
return createHmac('sha256', key).update(input, 'utf8').digest('hex').slice(0, 32);
|
|
198
|
+
}
|
|
199
|
+
function bundleNamespaceHash(bundle, key) {
|
|
200
|
+
return hmacHex32(key, `ns\0${bundle}`);
|
|
201
|
+
}
|
|
202
|
+
/** The hashed (storage) service name for a cleartext item name. Exported for
|
|
203
|
+
* the re-key migration and tests; runtime callers go through the primitives,
|
|
204
|
+
* which apply this transparently. */
|
|
205
|
+
export function hashedServiceName(item, key) {
|
|
206
|
+
if (item.startsWith(BUNDLES_ITEM_PREFIX)) {
|
|
207
|
+
const name = item.slice(BUNDLES_ITEM_PREFIX.length);
|
|
208
|
+
return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(name, key)}.m`;
|
|
209
|
+
}
|
|
210
|
+
if (item.startsWith(SECRETS_ITEM_PREFIX)) {
|
|
211
|
+
// Bundle names may contain dots; env keys and wallet ids never do — the
|
|
212
|
+
// LAST dot is the unambiguous bundle/key split.
|
|
213
|
+
const rest = item.slice(SECRETS_ITEM_PREFIX.length);
|
|
214
|
+
const dot = rest.lastIndexOf('.');
|
|
215
|
+
if (dot > 0 && dot < rest.length - 1) {
|
|
216
|
+
const bundle = rest.slice(0, dot);
|
|
217
|
+
const keyName = rest.slice(dot + 1);
|
|
218
|
+
return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, key)}.k.${hmacHex32(key, `kv\0${bundle}\0${keyName}`)}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return `${HASHED_SERVICE_PREFIX}o.${hmacHex32(key, `it\0${item}`)}`;
|
|
222
|
+
}
|
|
223
|
+
function parseHmacKeyRecord(raw) {
|
|
224
|
+
try {
|
|
225
|
+
const rec = JSON.parse(raw);
|
|
226
|
+
if (rec && typeof rec === 'object' && rec.v === 1 && typeof rec.k === 'string' && /^[0-9a-f]{64}$/.test(rec.k)) {
|
|
227
|
+
return rec;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
/* malformed — treated as absent */
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
function readHmacKeyRecord() {
|
|
236
|
+
// HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
|
|
237
|
+
// (or the test backend) under its literal name. The item is no-ACL, so the
|
|
238
|
+
// read is silent.
|
|
239
|
+
let raw;
|
|
240
|
+
try {
|
|
241
|
+
raw = getKeychainToken(HMAC_KEY_ITEM);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
return parseHmacKeyRecord(raw);
|
|
247
|
+
}
|
|
248
|
+
function writeHmacKeyRecord(rec) {
|
|
249
|
+
// JSON.stringify drops undefined fields (used to clear pendingDeletes).
|
|
250
|
+
// noAcl: reads of this record must stay prompt-free; an old pinned helper
|
|
251
|
+
// without the set-no-acl path rejects this loudly (see setKeychainToken),
|
|
252
|
+
// which is exactly the "old helper never half-runs the re-key" gate.
|
|
253
|
+
setKeychainToken(HMAC_KEY_ITEM, JSON.stringify(rec), { noAcl: true });
|
|
254
|
+
hashStateCache = null;
|
|
255
|
+
}
|
|
256
|
+
function resolveHashState() {
|
|
257
|
+
if (forcedTestKey)
|
|
258
|
+
return { active: true, key: forcedTestKey, record: null };
|
|
259
|
+
if (hashStateCache)
|
|
260
|
+
return hashStateCache;
|
|
261
|
+
if (backend || process.platform !== 'darwin' || process.env.AGENTS_SECRETS_HASH_NAMES === '0') {
|
|
262
|
+
hashStateCache = { active: false, key: null, record: null };
|
|
263
|
+
return hashStateCache;
|
|
264
|
+
}
|
|
265
|
+
const record = readHmacKeyRecord();
|
|
266
|
+
const key = record ? Buffer.from(record.k, 'hex') : null;
|
|
267
|
+
// AGENTS_SECRETS_HASH_NAMES=1 forces hashing on before the machine-wide
|
|
268
|
+
// sentinel flips — used to verify a partial (--prefix) re-key end-to-end.
|
|
269
|
+
const active = !!record && (record.migrated || process.env.AGENTS_SECRETS_HASH_NAMES === '1');
|
|
270
|
+
hashStateCache = { active, key, record };
|
|
271
|
+
return hashStateCache;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The storage-layer service name for `item`: hashed when hashing is active,
|
|
275
|
+
* the item itself otherwise. For callers that mix helper-enumerated
|
|
276
|
+
* (already-hashed) names with computed cleartext names in one lookup map —
|
|
277
|
+
* see readAndResolveBundleEnv.
|
|
278
|
+
*/
|
|
279
|
+
export function keychainServiceAlias(item) {
|
|
280
|
+
return prepareServiceName(item);
|
|
281
|
+
}
|
|
282
|
+
function prepareServiceName(item, opts) {
|
|
283
|
+
if (rawScopeDepth > 0)
|
|
284
|
+
return item;
|
|
285
|
+
if (!isOurItem(item))
|
|
286
|
+
return item;
|
|
287
|
+
if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
|
|
288
|
+
return item;
|
|
289
|
+
if (opts?.autoRekey)
|
|
290
|
+
maybeAutoRekey();
|
|
291
|
+
const st = resolveHashState();
|
|
292
|
+
if (!st.active || !st.key)
|
|
293
|
+
return item;
|
|
294
|
+
return hashedServiceName(item, st.key);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Map a cleartext enumeration prefix to its hashed-storage equivalent. Only
|
|
298
|
+
* two shapes are ever enumerated at sub-namespace granularity (bundle
|
|
299
|
+
* metadata, and one bundle's value items); both map to a broad `agents-cli.`
|
|
300
|
+
* helper query plus a client-side filter. Every mapped filter is a UNION with
|
|
301
|
+
* the original cleartext prefix so mid-migration leftovers (or items written
|
|
302
|
+
* by an older CLI on this machine) stay visible to migration tooling.
|
|
303
|
+
*/
|
|
304
|
+
function prepareListPrefix(prefix) {
|
|
305
|
+
if (rawScopeDepth > 0)
|
|
306
|
+
return { prefix };
|
|
307
|
+
if (!prefix.startsWith(`${SERVICE_PREFIX}.`))
|
|
308
|
+
return { prefix };
|
|
309
|
+
if (prefix.startsWith(HASHED_SERVICE_PREFIX))
|
|
310
|
+
return { prefix };
|
|
311
|
+
maybeAutoRekey();
|
|
312
|
+
const st = resolveHashState();
|
|
313
|
+
if (!st.active || !st.key)
|
|
314
|
+
return { prefix };
|
|
315
|
+
if (prefix === BUNDLES_ITEM_PREFIX) {
|
|
316
|
+
return {
|
|
317
|
+
prefix: `${SERVICE_PREFIX}.`,
|
|
318
|
+
filter: (s) => HASHED_META_RE.test(s) || s.startsWith(BUNDLES_ITEM_PREFIX),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (prefix.startsWith(SECRETS_ITEM_PREFIX) && prefix.endsWith('.') && prefix.length > SECRETS_ITEM_PREFIX.length + 1) {
|
|
322
|
+
const bundle = prefix.slice(SECRETS_ITEM_PREFIX.length, -1);
|
|
323
|
+
const hashedValuePrefix = `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, st.key)}.k.`;
|
|
324
|
+
return {
|
|
325
|
+
prefix: `${SERVICE_PREFIX}.`,
|
|
326
|
+
filter: (s) => s.startsWith(hashedValuePrefix) || s.startsWith(prefix),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return { prefix };
|
|
330
|
+
}
|
|
331
|
+
function listCleartextServices(prefixes) {
|
|
332
|
+
const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
|
|
333
|
+
return all.filter((s) => s.startsWith(`${SERVICE_PREFIX}.`) &&
|
|
334
|
+
!s.startsWith(HASHED_SERVICE_PREFIX) &&
|
|
335
|
+
s !== HMAC_KEY_ITEM &&
|
|
336
|
+
(!prefixes || prefixes.some((p) => s.startsWith(p))));
|
|
337
|
+
}
|
|
338
|
+
function ensureHmacKeyRecord(markMigratedIfCreating) {
|
|
339
|
+
const existing = readHmacKeyRecord();
|
|
340
|
+
if (existing)
|
|
341
|
+
return existing;
|
|
342
|
+
const fresh = { v: 1, k: randomBytes(32).toString('hex'), migrated: markMigratedIfCreating };
|
|
343
|
+
writeHmacKeyRecord(fresh);
|
|
344
|
+
// If two processes raced the first write, the keychain holds exactly one
|
|
345
|
+
// winner — adopt whatever is stored NOW so both sides converge on a single
|
|
346
|
+
// key before hashing anything under it.
|
|
347
|
+
return readHmacKeyRecord() ?? fresh;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Guard against a silently-degraded enumeration. The helper's `list` skips the
|
|
351
|
+
* data-protection pass wholesale when the DP keybag is locked (screen lock —
|
|
352
|
+
* see keychain-helper.swift, errSecInteractionNotAllowed handling), returning
|
|
353
|
+
* an EMPTY result even though items exist and no-ACL reads/writes still work.
|
|
354
|
+
* Observed live on macOS 26: `set-no-acl` + `get` succeed while `list` of the
|
|
355
|
+
* just-written item returns nothing. Without this probe, a re-key run in that
|
|
356
|
+
* state would see "zero cleartext items" and wrongly activate hashed naming,
|
|
357
|
+
* making every existing cleartext item invisible after unlock.
|
|
358
|
+
*
|
|
359
|
+
* The probe requires the hmackey record (a DP item that provably exists — the
|
|
360
|
+
* caller just ensured it) to appear in a raw enumeration. Trivially true for
|
|
361
|
+
* the in-memory test backend.
|
|
362
|
+
*/
|
|
363
|
+
function assertEnumerationTrustworthy() {
|
|
364
|
+
const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
|
|
365
|
+
if (!all.includes(HMAC_KEY_ITEM)) {
|
|
366
|
+
throw new Error('keychain enumeration is unavailable (locked keybag / screen lock?) — refusing to decide the re-key on an empty listing. Retry while unlocked.');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function finishPendingDeletes(rec) {
|
|
370
|
+
const pending = rec.pendingDeletes ?? [];
|
|
371
|
+
if (pending.length === 0)
|
|
372
|
+
return;
|
|
373
|
+
withRawKeychainServiceNames(() => {
|
|
374
|
+
for (const service of pending)
|
|
375
|
+
deleteKeychainToken(service);
|
|
376
|
+
});
|
|
377
|
+
writeHmacKeyRecord({ ...rec, pendingDeletes: undefined });
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* One-shot per process: activate hashing on machines with nothing to move,
|
|
381
|
+
* finish a crash-interrupted delete phase (silent), and run the interactive
|
|
382
|
+
* one-time re-key when cleartext-named items exist and a human is present.
|
|
383
|
+
* Never throws — a failed attempt leaves the process on cleartext names
|
|
384
|
+
* (exact pre-#316 behavior) and the next process retries.
|
|
385
|
+
*/
|
|
386
|
+
function maybeAutoRekey() {
|
|
387
|
+
if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
|
|
388
|
+
return;
|
|
389
|
+
autoRekeyAttempted = true;
|
|
390
|
+
if (forcedTestKey || backend)
|
|
391
|
+
return;
|
|
392
|
+
// Never auto-mutate the developer's real keychain from a test runner.
|
|
393
|
+
if (process.env.VITEST)
|
|
394
|
+
return;
|
|
395
|
+
if (process.platform !== 'darwin')
|
|
396
|
+
return;
|
|
397
|
+
if (process.env.AGENTS_SECRETS_NO_AUTO_REKEY === '1')
|
|
398
|
+
return;
|
|
399
|
+
if (process.env.AGENTS_SECRETS_HASH_NAMES === '0')
|
|
400
|
+
return;
|
|
401
|
+
const st = resolveHashState();
|
|
402
|
+
if (st.active) {
|
|
403
|
+
if (st.record?.pendingDeletes?.length) {
|
|
404
|
+
try {
|
|
405
|
+
finishPendingDeletes(st.record);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* next process retries */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
let cleartext;
|
|
414
|
+
try {
|
|
415
|
+
cleartext = listCleartextServices();
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Moving real items pops Touch ID — only auto-run with a human present. An
|
|
421
|
+
// empty listing still goes through rekeyServiceNames (prompt-free): it
|
|
422
|
+
// verifies the enumeration is trustworthy before activating on "nothing to
|
|
423
|
+
// migrate", so a locked keybag can never masquerade as a fresh machine.
|
|
424
|
+
const interactive = process.stdin.isTTY && process.stderr.isTTY;
|
|
425
|
+
if (cleartext.length > 0 && !interactive)
|
|
426
|
+
return;
|
|
427
|
+
try {
|
|
428
|
+
rekeyServiceNames({ announce: cleartext.length > 0, log: (line) => console.error(line) });
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
// Headless processes stay quiet (e.g. locked-keybag probe failures would
|
|
432
|
+
// otherwise spam every background run); a human gets the pointer.
|
|
433
|
+
if (interactive) {
|
|
434
|
+
console.error(`agents secrets: one-time re-key did not complete (${err.message}). ` +
|
|
435
|
+
`Keychain service names remain enumerable; run 'agents secrets rekey' to retry.`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Build the old→new mapping for a set of cleartext services. Bundle metadata
|
|
441
|
+
* is parsed first to (a) recover each bundle's prompt policy — the persisted
|
|
442
|
+
* `tier` token, where `none`/`never` means the item must be re-written through
|
|
443
|
+
* the no-ACL path — and (b) inject the cleartext `name` into the JSON, because
|
|
444
|
+
* after hashing the service name can no longer carry it (listBundles reads it
|
|
445
|
+
* back from the payload). A value item's tier is resolved from its bundle's
|
|
446
|
+
* metadata PAYLOAD in `values` — under the cleartext metadata name or its
|
|
447
|
+
* hashed transform — never from the metadata item being part of the same
|
|
448
|
+
* `services` batch: a --prefix run can scope a bundle's value items alone, and
|
|
449
|
+
* rekeyServiceNames supplies the out-of-scope metadata reads (see the
|
|
450
|
+
* supplemental batch there). Exported for unit tests.
|
|
451
|
+
*/
|
|
452
|
+
export function computeRekeyPlan(services, values, key) {
|
|
453
|
+
const items = [];
|
|
454
|
+
const unreadable = [];
|
|
455
|
+
const bundleNoAcl = (bundle) => {
|
|
456
|
+
const meta = `${BUNDLES_ITEM_PREFIX}${bundle}`;
|
|
457
|
+
const raw = values.get(meta) ?? values.get(hashedServiceName(meta, key));
|
|
458
|
+
if (raw === undefined)
|
|
459
|
+
return false;
|
|
460
|
+
try {
|
|
461
|
+
const parsed = JSON.parse(raw);
|
|
462
|
+
if (parsed && typeof parsed === 'object')
|
|
463
|
+
return parsed.tier === 'none' || parsed.tier === 'never';
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
/* malformed JSON — ACL'd */
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
469
|
+
};
|
|
470
|
+
for (const service of services) {
|
|
471
|
+
if (!service.startsWith(BUNDLES_ITEM_PREFIX))
|
|
472
|
+
continue;
|
|
473
|
+
const value = values.get(service);
|
|
474
|
+
if (value === undefined) {
|
|
475
|
+
unreadable.push(service);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const name = service.slice(BUNDLES_ITEM_PREFIX.length);
|
|
479
|
+
let payload;
|
|
480
|
+
let noAcl = false;
|
|
481
|
+
try {
|
|
482
|
+
const parsed = JSON.parse(value);
|
|
483
|
+
if (parsed && typeof parsed === 'object') {
|
|
484
|
+
const tier = parsed.tier;
|
|
485
|
+
noAcl = tier === 'none' || tier === 'never';
|
|
486
|
+
payload = JSON.stringify({ ...parsed, name });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
/* malformed JSON — copy verbatim, ACL'd */
|
|
491
|
+
}
|
|
492
|
+
items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl, payload });
|
|
493
|
+
}
|
|
494
|
+
for (const service of services) {
|
|
495
|
+
if (service.startsWith(BUNDLES_ITEM_PREFIX))
|
|
496
|
+
continue;
|
|
497
|
+
const value = values.get(service);
|
|
498
|
+
if (value === undefined) {
|
|
499
|
+
unreadable.push(service);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
let noAcl = false;
|
|
503
|
+
if (service.startsWith(SECRETS_ITEM_PREFIX)) {
|
|
504
|
+
const rest = service.slice(SECRETS_ITEM_PREFIX.length);
|
|
505
|
+
const dot = rest.lastIndexOf('.');
|
|
506
|
+
if (dot > 0)
|
|
507
|
+
noAcl = bundleNoAcl(rest.slice(0, dot));
|
|
508
|
+
}
|
|
509
|
+
items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
|
|
510
|
+
}
|
|
511
|
+
return { items, unreadable };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The one-time re-key: move every cleartext-named `agents-cli.*` item to its
|
|
515
|
+
* hashed service name. Composed entirely from the existing helper primitives —
|
|
516
|
+
* no new Swift command:
|
|
517
|
+
*
|
|
518
|
+
* 1. Enumerate cleartext services (silent) and batch-read every value behind
|
|
519
|
+
* ONE Touch ID (`get-batch`; readItem also sweeps legacy/orphaned copies).
|
|
520
|
+
* 2. Write each hashed copy (`set`/`set-no-acl` never prompt), preserving
|
|
521
|
+
* the no-ACL tier for `never`-policy bundles.
|
|
522
|
+
* 3. Batch-verify every copy round-trips (second Touch ID).
|
|
523
|
+
* 4. Only then activate hashing (sentinel + pendingDeletes) and delete the
|
|
524
|
+
* old items (silent).
|
|
525
|
+
*
|
|
526
|
+
* Add-before-delete throughout: a cancel/crash/failure anywhere before step 4
|
|
527
|
+
* leaves every old item intact and hashing OFF — same rationale as the
|
|
528
|
+
* helper's migrate-orphans, which is also why no pre-write backup is taken.
|
|
529
|
+
* On ANY per-item failure nothing is deleted and the sentinel stays off
|
|
530
|
+
* (all-or-nothing activation); the report names every failed item. A crash
|
|
531
|
+
* between the sentinel write and the deletes is resumed silently by the next
|
|
532
|
+
* process (pendingDeletes). Idempotent: re-running converges.
|
|
533
|
+
*/
|
|
534
|
+
export function rekeyServiceNames(opts = {}) {
|
|
535
|
+
const log = opts.log ?? (() => { });
|
|
536
|
+
if (!backend && process.platform !== 'darwin') {
|
|
537
|
+
throw new Error('secrets rekey is macOS-only — service names are enumerable only via the macOS keychain helper.');
|
|
538
|
+
}
|
|
539
|
+
if (rekeyRunning)
|
|
540
|
+
throw new Error('re-key already running in this process.');
|
|
541
|
+
rekeyRunning = true;
|
|
542
|
+
try {
|
|
543
|
+
const partial = !!opts.prefixes?.length;
|
|
544
|
+
let record = ensureHmacKeyRecord(false);
|
|
545
|
+
const key = Buffer.from(record.k, 'hex');
|
|
546
|
+
// The record we just ensured is a DP item — if enumeration can't see it,
|
|
547
|
+
// every listing below is lying (locked keybag) and no decision — least of
|
|
548
|
+
// all "nothing to migrate, activate" — can be made on it.
|
|
549
|
+
assertEnumerationTrustworthy();
|
|
550
|
+
if (record.pendingDeletes?.length) {
|
|
551
|
+
log(`Finishing interrupted re-key: removing ${record.pendingDeletes.length} already-copied cleartext item(s)…`);
|
|
552
|
+
finishPendingDeletes(record);
|
|
553
|
+
record = readHmacKeyRecord() ?? record;
|
|
554
|
+
}
|
|
555
|
+
const cleartext = listCleartextServices(opts.prefixes);
|
|
556
|
+
if (cleartext.length === 0) {
|
|
557
|
+
if (!record.migrated && !partial) {
|
|
558
|
+
writeHmacKeyRecord({ ...record, migrated: true });
|
|
559
|
+
log('No cleartext-named keychain items found — hashed service names are now active.');
|
|
560
|
+
return { migrated: [], failed: [], activated: true, nothingToDo: true };
|
|
561
|
+
}
|
|
562
|
+
return { migrated: [], failed: [], activated: record.migrated, nothingToDo: true };
|
|
563
|
+
}
|
|
564
|
+
if (opts.announce) {
|
|
565
|
+
log(`One-time secrets re-key: replacing ${cleartext.length} enumerable keychain service name(s) with opaque hashed names (GitHub #316).`);
|
|
566
|
+
log('Touch ID will prompt twice (read + verify). Cancelling is safe — the re-key resumes on a later run.');
|
|
567
|
+
}
|
|
568
|
+
// A --prefix run can scope a bundle's value items WITHOUT its metadata
|
|
569
|
+
// item (`agents-cli.bundles.<bundle>`); the tier decision must still come
|
|
570
|
+
// from the keychain, not from batch membership — otherwise a partial
|
|
571
|
+
// re-key of a `never`-policy bundle would silently re-attach a biometry
|
|
572
|
+
// ACL to its values (and delete the cleartext originals, one-way). Read
|
|
573
|
+
// every such bundle's metadata alongside the values in the same batch:
|
|
574
|
+
// the cleartext name for a not-yet-moved metadata item, its hashed
|
|
575
|
+
// transform for one an earlier partial run already moved. Absent both (a
|
|
576
|
+
// standalone item with no backing bundle), the value stays ACL'd. A full
|
|
577
|
+
// run adds nothing here — every metadata item is already in scope.
|
|
578
|
+
const inScope = new Set(cleartext);
|
|
579
|
+
const supplementalMetaReads = new Set();
|
|
580
|
+
for (const service of cleartext) {
|
|
581
|
+
if (!service.startsWith(SECRETS_ITEM_PREFIX))
|
|
582
|
+
continue;
|
|
583
|
+
const rest = service.slice(SECRETS_ITEM_PREFIX.length);
|
|
584
|
+
const dot = rest.lastIndexOf('.');
|
|
585
|
+
if (dot <= 0)
|
|
586
|
+
continue;
|
|
587
|
+
const meta = `${BUNDLES_ITEM_PREFIX}${rest.slice(0, dot)}`;
|
|
588
|
+
if (inScope.has(meta))
|
|
589
|
+
continue;
|
|
590
|
+
supplementalMetaReads.add(meta);
|
|
591
|
+
supplementalMetaReads.add(hashedServiceName(meta, key));
|
|
592
|
+
}
|
|
593
|
+
const values = withRawKeychainServiceNames(() => getKeychainTokens([...cleartext, ...supplementalMetaReads]));
|
|
594
|
+
const { items, unreadable } = computeRekeyPlan(cleartext, values, key);
|
|
595
|
+
const failed = unreadable.map((item) => ({
|
|
596
|
+
item,
|
|
597
|
+
detail: 'read failed or item absent',
|
|
598
|
+
}));
|
|
599
|
+
const added = [];
|
|
600
|
+
for (const plan of items) {
|
|
601
|
+
try {
|
|
602
|
+
setKeychainToken(plan.newService, plan.payload ?? values.get(plan.oldService), { noAcl: plan.noAcl });
|
|
603
|
+
added.push(plan);
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
failed.push({ item: plan.oldService, detail: `write: ${err.message}` });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const verified = [];
|
|
610
|
+
if (added.length > 0) {
|
|
611
|
+
const readBack = getKeychainTokens(added.map((p) => p.newService));
|
|
612
|
+
for (const plan of added) {
|
|
613
|
+
const expected = plan.payload ?? values.get(plan.oldService);
|
|
614
|
+
if (readBack.get(plan.newService) === expected)
|
|
615
|
+
verified.push(plan);
|
|
616
|
+
else
|
|
617
|
+
failed.push({ item: plan.oldService, detail: 'verify: value mismatch after rewrite' });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (failed.length > 0) {
|
|
621
|
+
log(`Re-key INCOMPLETE — ${failed.length} of ${cleartext.length} item(s) could not be moved; nothing was deleted and hashed naming stays OFF:`);
|
|
622
|
+
for (const f of failed)
|
|
623
|
+
log(` ${f.item}: ${f.detail}`);
|
|
624
|
+
return { migrated: [], failed, activated: false, nothingToDo: false };
|
|
625
|
+
}
|
|
626
|
+
if (partial) {
|
|
627
|
+
withRawKeychainServiceNames(() => {
|
|
628
|
+
for (const plan of verified)
|
|
629
|
+
deleteKeychainToken(plan.oldService);
|
|
630
|
+
});
|
|
631
|
+
log(`Re-keyed ${verified.length} item(s) (partial run — hashed naming NOT activated).`);
|
|
632
|
+
return { migrated: verified.map((p) => p.oldService), failed: [], activated: record.migrated, nothingToDo: false };
|
|
633
|
+
}
|
|
634
|
+
writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: verified.map((p) => p.oldService) });
|
|
635
|
+
withRawKeychainServiceNames(() => {
|
|
636
|
+
for (const plan of verified)
|
|
637
|
+
deleteKeychainToken(plan.oldService);
|
|
638
|
+
});
|
|
639
|
+
writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: undefined });
|
|
640
|
+
log(`Re-keyed ${verified.length} keychain item(s); service names are now opaque (agents-cli.h.*).`);
|
|
641
|
+
return { migrated: verified.map((p) => p.oldService), failed: [], activated: true, nothingToDo: false };
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
rekeyRunning = false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Re-key state snapshot for `agents secrets rekey --status`. */
|
|
648
|
+
export function rekeyStatus() {
|
|
649
|
+
const rec = readHmacKeyRecord();
|
|
650
|
+
let enumerationOk = true;
|
|
651
|
+
if (rec) {
|
|
652
|
+
try {
|
|
653
|
+
assertEnumerationTrustworthy();
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
enumerationOk = false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
migrated: !!rec?.migrated,
|
|
661
|
+
hasKey: !!rec,
|
|
662
|
+
pendingDeletes: rec?.pendingDeletes?.length ?? 0,
|
|
663
|
+
cleartext: listCleartextServices(),
|
|
664
|
+
enumerationOk,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
121
667
|
/** Check if a keychain/keyring item exists. Never prompts for biometry. */
|
|
122
668
|
export function hasKeychainToken(item) {
|
|
669
|
+
item = prepareServiceName(item);
|
|
123
670
|
if (backend)
|
|
124
671
|
return backend.has(item);
|
|
125
672
|
assertSupportedPlatform();
|
|
@@ -145,6 +692,10 @@ export function hasKeychainToken(item) {
|
|
|
145
692
|
* single biometric prompt covers every key in the batch.
|
|
146
693
|
*/
|
|
147
694
|
export function getKeychainToken(item) {
|
|
695
|
+
// Errors keep the requested (human-readable) name; the storage name may be
|
|
696
|
+
// an opaque hash.
|
|
697
|
+
const requested = item;
|
|
698
|
+
item = prepareServiceName(item, { autoRekey: true });
|
|
148
699
|
if (backend)
|
|
149
700
|
return backend.get(item);
|
|
150
701
|
assertSupportedPlatform();
|
|
@@ -161,23 +712,23 @@ export function getKeychainToken(item) {
|
|
|
161
712
|
if (token)
|
|
162
713
|
return token;
|
|
163
714
|
}
|
|
164
|
-
throw new Error(`Keychain item '${
|
|
715
|
+
throw new Error(`Keychain item '${requested}' not found.`);
|
|
165
716
|
}
|
|
166
717
|
const bin = getKeychainHelperPath();
|
|
167
718
|
const result = spawnSync(bin, ['get', item, os.userInfo().username], {
|
|
168
719
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
720
|
});
|
|
170
721
|
if (result.status === 1)
|
|
171
|
-
throw new Error(`Keychain item '${
|
|
722
|
+
throw new Error(`Keychain item '${requested}' not found.`);
|
|
172
723
|
if (result.status === 4)
|
|
173
|
-
throw new Error(`Touch ID cancelled while reading '${
|
|
724
|
+
throw new Error(`Touch ID cancelled while reading '${requested}'.`);
|
|
174
725
|
if (result.status !== 0) {
|
|
175
726
|
const msg = result.stderr?.toString().trim();
|
|
176
|
-
throw new Error(msg || `Failed to read keychain item '${
|
|
727
|
+
throw new Error(msg || `Failed to read keychain item '${requested}'.`);
|
|
177
728
|
}
|
|
178
729
|
const token = result.stdout?.toString();
|
|
179
730
|
if (!token)
|
|
180
|
-
throw new Error(`Keychain item '${
|
|
731
|
+
throw new Error(`Keychain item '${requested}' exists but is empty.`);
|
|
181
732
|
return token;
|
|
182
733
|
}
|
|
183
734
|
/**
|
|
@@ -194,10 +745,23 @@ export function getKeychainTokens(items) {
|
|
|
194
745
|
const result = new Map();
|
|
195
746
|
if (items.length === 0)
|
|
196
747
|
return result;
|
|
748
|
+
// Resolve storage names up front, remembering which requested name each one
|
|
749
|
+
// answers for — the returned map is keyed by the names the CALLER passed,
|
|
750
|
+
// whether those were cleartext (hashed here) or already-hashed (enumerated).
|
|
751
|
+
const requestedByStorage = new Map();
|
|
752
|
+
const storageItems = items.map((item) => {
|
|
753
|
+
const storage = prepareServiceName(item, { autoRekey: true });
|
|
754
|
+
if (!requestedByStorage.has(storage))
|
|
755
|
+
requestedByStorage.set(storage, item);
|
|
756
|
+
return storage;
|
|
757
|
+
});
|
|
758
|
+
const record = (storage, value) => {
|
|
759
|
+
result.set(requestedByStorage.get(storage) ?? storage, value);
|
|
760
|
+
};
|
|
197
761
|
if (backend) {
|
|
198
|
-
for (const
|
|
762
|
+
for (const storage of storageItems) {
|
|
199
763
|
try {
|
|
200
|
-
|
|
764
|
+
record(storage, backend.get(storage));
|
|
201
765
|
}
|
|
202
766
|
catch { /* missing — skip */ }
|
|
203
767
|
}
|
|
@@ -205,25 +769,25 @@ export function getKeychainTokens(items) {
|
|
|
205
769
|
}
|
|
206
770
|
assertSupportedPlatform();
|
|
207
771
|
if (isLinux()) {
|
|
208
|
-
for (const
|
|
772
|
+
for (const storage of storageItems) {
|
|
209
773
|
try {
|
|
210
|
-
|
|
774
|
+
record(storage, linuxBackend.get(storage));
|
|
211
775
|
}
|
|
212
776
|
catch { /* missing — skip */ }
|
|
213
777
|
}
|
|
214
778
|
return result;
|
|
215
779
|
}
|
|
216
780
|
if (isWindows()) {
|
|
217
|
-
for (const
|
|
781
|
+
for (const storage of storageItems) {
|
|
218
782
|
try {
|
|
219
|
-
|
|
783
|
+
record(storage, windowsBackend.get(storage));
|
|
220
784
|
}
|
|
221
785
|
catch { /* missing — skip */ }
|
|
222
786
|
}
|
|
223
787
|
return result;
|
|
224
788
|
}
|
|
225
789
|
const bin = getKeychainHelperPath();
|
|
226
|
-
const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...
|
|
790
|
+
const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
|
|
227
791
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
228
792
|
});
|
|
229
793
|
if (child.status === 4) {
|
|
@@ -234,20 +798,22 @@ export function getKeychainTokens(items) {
|
|
|
234
798
|
throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
|
|
235
799
|
}
|
|
236
800
|
const out = child.stdout?.toString() ?? '';
|
|
237
|
-
parseBatchRecords(out,
|
|
801
|
+
parseBatchRecords(out, record);
|
|
238
802
|
return result;
|
|
239
803
|
}
|
|
240
804
|
/**
|
|
241
|
-
* Parse the helper's batch-read output
|
|
242
|
-
* `
|
|
243
|
-
*
|
|
805
|
+
* Parse the helper's batch-read output, routing each present record through
|
|
806
|
+
* `record(service, value)` — getKeychainTokens uses that to reverse-map hashed
|
|
807
|
+
* storage names back to the names the caller asked with. The format is shared
|
|
808
|
+
* by `get-batch` and `get-batch-synced` — a sequence of records, one per
|
|
809
|
+
* service in input order:
|
|
244
810
|
* "V <service>\n<value>\n" (present)
|
|
245
811
|
* "M <service>\n" (missing)
|
|
246
812
|
* Service names are validated newline/'='-free by setKeychainToken below
|
|
247
813
|
* and values are rejected if they contain newlines — so splitting on '\n'
|
|
248
814
|
* and walking line-by-line is unambiguous.
|
|
249
815
|
*/
|
|
250
|
-
function parseBatchRecords(out,
|
|
816
|
+
function parseBatchRecords(out, record) {
|
|
251
817
|
const lines = out.split('\n');
|
|
252
818
|
let i = 0;
|
|
253
819
|
while (i < lines.length) {
|
|
@@ -257,7 +823,7 @@ function parseBatchRecords(out, into) {
|
|
|
257
823
|
if (line.startsWith('V ')) {
|
|
258
824
|
const service = line.slice(2);
|
|
259
825
|
const value = lines[i + 1] ?? '';
|
|
260
|
-
|
|
826
|
+
record(service, value);
|
|
261
827
|
i += 2;
|
|
262
828
|
}
|
|
263
829
|
else if (line.startsWith('M ')) {
|
|
@@ -279,14 +845,17 @@ function parseBatchRecords(out, into) {
|
|
|
279
845
|
* rather than silently falling back to an ACL'd `set` (which would behave like
|
|
280
846
|
* `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
|
|
281
847
|
export function setKeychainToken(item, value, opts) {
|
|
848
|
+
// Validate the CLEARTEXT name (a hashed storage name is always clean), then
|
|
849
|
+
// resolve the storage name.
|
|
850
|
+
if (/[\x00=\r\n]/.test(item))
|
|
851
|
+
throw new Error('Secret item name contains invalid characters.');
|
|
852
|
+
item = prepareServiceName(item, { autoRekey: true });
|
|
282
853
|
if (backend) {
|
|
283
|
-
backend.set(item, value);
|
|
854
|
+
backend.set(item, value, opts);
|
|
284
855
|
return;
|
|
285
856
|
}
|
|
286
857
|
assertSupportedPlatform();
|
|
287
858
|
assertValueStorable(value);
|
|
288
|
-
if (/[\x00=\r\n]/.test(item))
|
|
289
|
-
throw new Error('Secret item name contains invalid characters.');
|
|
290
859
|
if (isLinux()) {
|
|
291
860
|
linuxBackend.set(item, value);
|
|
292
861
|
return;
|
|
@@ -337,6 +906,7 @@ export function setKeychainToken(item, value, opts) {
|
|
|
337
906
|
}
|
|
338
907
|
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
339
908
|
export function deleteKeychainToken(item) {
|
|
909
|
+
item = prepareServiceName(item);
|
|
340
910
|
if (backend)
|
|
341
911
|
return backend.delete(item);
|
|
342
912
|
assertSupportedPlatform();
|
|
@@ -368,17 +938,22 @@ export function keychainUsesFileFallback() {
|
|
|
368
938
|
return windowsUsesFileFallback();
|
|
369
939
|
return false;
|
|
370
940
|
}
|
|
371
|
-
/** Enumerate keychain/keyring item names starting with the given prefix.
|
|
941
|
+
/** Enumerate keychain/keyring item names starting with the given prefix.
|
|
942
|
+
* With hashed service names active, the two sub-namespace prefixes callers
|
|
943
|
+
* use (bundle metadata; one bundle's value items) are mapped to their hashed
|
|
944
|
+
* shapes — the returned names are then storage (opaque) names. */
|
|
372
945
|
export function listKeychainItems(prefix) {
|
|
946
|
+
const mapped = prepareListPrefix(prefix);
|
|
947
|
+
const apply = (names) => (mapped.filter ? names.filter(mapped.filter) : names);
|
|
373
948
|
if (backend)
|
|
374
|
-
return backend.list(prefix);
|
|
949
|
+
return apply(backend.list(mapped.prefix));
|
|
375
950
|
assertSupportedPlatform();
|
|
376
951
|
if (isLinux())
|
|
377
|
-
return linuxBackend.list(prefix);
|
|
952
|
+
return apply(linuxBackend.list(mapped.prefix));
|
|
378
953
|
if (isWindows())
|
|
379
|
-
return windowsBackend.list(prefix);
|
|
954
|
+
return apply(windowsBackend.list(mapped.prefix));
|
|
380
955
|
const bin = getKeychainHelperPath();
|
|
381
|
-
const result = spawnSync(bin, ['list', prefix], {
|
|
956
|
+
const result = spawnSync(bin, ['list', mapped.prefix], {
|
|
382
957
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
383
958
|
});
|
|
384
959
|
if (result.status !== 0) {
|
|
@@ -386,7 +961,7 @@ export function listKeychainItems(prefix) {
|
|
|
386
961
|
throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
|
|
387
962
|
}
|
|
388
963
|
const out = result.stdout?.toString() || '';
|
|
389
|
-
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
964
|
+
return apply(out.split('\n').map((s) => s.trim()).filter(Boolean));
|
|
390
965
|
}
|
|
391
966
|
/**
|
|
392
967
|
* Enumerate ONLY legacy file-based-keychain item names with the given prefix —
|
|
@@ -476,7 +1051,7 @@ export function getSyncedKeychainTokens(items) {
|
|
|
476
1051
|
const msg = child.stderr?.toString().trim();
|
|
477
1052
|
throw new Error(msg || `Failed to batch-read ${items.length} iCloud keychain items.`);
|
|
478
1053
|
}
|
|
479
|
-
parseBatchRecords(child.stdout?.toString() ?? '', result);
|
|
1054
|
+
parseBatchRecords(child.stdout?.toString() ?? '', (service, value) => { result.set(service, value); });
|
|
480
1055
|
return result;
|
|
481
1056
|
}
|
|
482
1057
|
/**
|