@skrr-ai/cli 0.1.85 → 0.1.87

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.
@@ -41,6 +41,7 @@ exports.readFromBackend = readFromBackend;
41
41
  exports.describeForeignCredential = describeForeignCredential;
42
42
  exports.writeToBackend = writeToBackend;
43
43
  exports.clearAllBackends = clearAllBackends;
44
+ exports.takeResolvedKeychainRead = takeResolvedKeychainRead;
44
45
  exports.normalizeServerOrigin = normalizeServerOrigin;
45
46
  exports.__authFilePathForTest = __authFilePathForTest;
46
47
  /**
@@ -504,6 +505,11 @@ function unlinkQuietly(p) {
504
505
  function readFromKeychain() {
505
506
  if (!keychain.isAvailable())
506
507
  return null;
508
+ // One pass: the owner record is read once for the whole bundle, not once per
509
+ // secret plus once for itself (see `withKeychainReadPass`).
510
+ return keychain.withKeychainReadPass(readFromKeychainInPass);
511
+ }
512
+ function readFromKeychainInPass() {
507
513
  const tokenEntry = keychain.readToken();
508
514
  if (!tokenEntry) {
509
515
  // Read came back empty despite "available" — don't cache a false
@@ -769,6 +775,7 @@ function describeForeignCredential(result) {
769
775
  function writeToBackend(bundle, options = {}) {
770
776
  if ((0, harness_context_1.isHarnessCliContext)())
771
777
  throw new harness_context_1.HarnessCredentialPersistRefused();
778
+ lastKeychainTokenRead = null;
772
779
  const requestedOrigin = options.serverOrigin ?? bundle.serverOrigin;
773
780
  let persistedBundle = bundle;
774
781
  if (requestedOrigin !== undefined) {
@@ -821,9 +828,34 @@ function writeToBackend(bundle, options = {}) {
821
828
  * auth-failure cleanup). Idempotent.
822
829
  */
823
830
  function clearAllBackends() {
831
+ lastKeychainTokenRead = null;
824
832
  clearKeychainBackend();
825
833
  deleteFileBackend();
826
834
  }
835
+ /**
836
+ * The Keychain read the credential resolver just made, for the ONE caller that
837
+ * reads the same bundle again a moment later: `BaseCommand.init()` resolves the
838
+ * credential and then asks `ensureFreshCliCredential` whether it needs a
839
+ * refresh, which re-read every slot (another six `security` spawns, ~100ms) to
840
+ * learn the expiry the first read already had.
841
+ *
842
+ * Handed out at most once, only for the credential that read produced, only
843
+ * for the same server origin, and dropped by any write or clear in this
844
+ * process. It never stands in for the re-read under the auth lock: a refresh
845
+ * still reads the store fresh before it spends a refresh token.
846
+ */
847
+ let lastKeychainTokenRead = null;
848
+ function takeResolvedKeychainRead(credential, expectedServerOrigin) {
849
+ const read = lastKeychainTokenRead;
850
+ lastKeychainTokenRead = null;
851
+ if (!read?.bundle || credential.source !== 'keychain')
852
+ return null;
853
+ if (!credential.token || read.bundle.token !== credential.token)
854
+ return null;
855
+ if (read.expectedServerOrigin !== normalizeServerOrigin(expectedServerOrigin))
856
+ return null;
857
+ return read;
858
+ }
827
859
  /**
828
860
  * The low-level backends, exposed for the credential resolver's
829
861
  * `readKeychainToken` / `readFileToken` slots. Internal callers should
@@ -847,6 +879,7 @@ exports.backends = {
847
879
  readKeychainToken(expectedServerOrigin) {
848
880
  const read = readKeychainAssessed();
849
881
  const result = assessBundleOrigin(read.bundle, 'keychain', expectedServerOrigin);
882
+ lastKeychainTokenRead = result.bundle && result.originStatus === 'matched' ? result : null;
850
883
  return result.bundle?.token ?? null;
851
884
  },
852
885
  readFileToken(expectedServerOrigin) {
@@ -38,6 +38,7 @@ export declare function isAvailable(): boolean;
38
38
  * masquerade as "keychain always available" across the process lifetime.
39
39
  */
40
40
  export declare function resetAvailability(): void;
41
+ export declare function withKeychainReadPass<T>(fn: () => T): T;
41
42
  export interface KeychainEntry {
42
43
  token: string;
43
44
  expiresAt?: number;
@@ -43,6 +43,7 @@ exports.isLegacySlotCurrent = isLegacySlotCurrent;
43
43
  exports.__currentServiceForTest = __currentServiceForTest;
44
44
  exports.isAvailable = isAvailable;
45
45
  exports.resetAvailability = resetAvailability;
46
+ exports.withKeychainReadPass = withKeychainReadPass;
46
47
  exports.readToken = readToken;
47
48
  exports.writeToken = writeToken;
48
49
  exports.readRefreshToken = readRefreshToken;
@@ -256,17 +257,56 @@ function isSecretAccount(account) {
256
257
  return account === ACCOUNT_TOKEN || account === ACCOUNT_REFRESH;
257
258
  }
258
259
  /**
259
- * `raw` returns the stored string exactly, with no envelope transform — for
260
- * copying another config's item, which may be sealed under a key we lack.
260
+ * One synchronous read of a bundle, memoised per (service, account).
261
+ *
262
+ * Every `security` call is a process spawn costing ~15-20ms, and a bundle read
263
+ * used to make eight of them for six items: each secret's envelope decrypt
264
+ * re-read the owner record (`foreignOwnerRootAt`), and the bundle read it a
265
+ * third time for itself. Measured on `skrr agents show`, that was 17 serial
266
+ * spawns and ~280ms before the first request could leave.
267
+ *
268
+ * A pass is SYNCHRONOUS by construction (`execFileSync` throughout, and the
269
+ * callback is not awaited), so nothing else in this process can interleave a
270
+ * write; a write made inside the pass drops the memo for that item. Another
271
+ * process's write is no more or less visible than before — the eight separate
272
+ * reads were never one atomic snapshot either. Outside a pass nothing is
273
+ * memoised: a later read always reaches the Keychain, which is what the
274
+ * refresh path's re-read under the auth lock depends on.
261
275
  */
262
- function readSlot(account, service = currentService(), raw_ = false) {
263
- let raw;
276
+ let readPassMemo = null;
277
+ function withKeychainReadPass(fn) {
278
+ if (readPassMemo)
279
+ return fn();
280
+ readPassMemo = new Map();
281
+ try {
282
+ return fn();
283
+ }
284
+ finally {
285
+ readPassMemo = null;
286
+ }
287
+ }
288
+ function memoKey(service, account) {
289
+ return `${service}\u0000${account}`;
290
+ }
291
+ function forgetSlotInPass(account, service) {
292
+ readPassMemo?.delete(memoKey(service, account));
293
+ }
294
+ /** The stored string exactly as `security` returns it, or null. */
295
+ function readRawSlot(account, service) {
296
+ const key = memoKey(service, account);
297
+ if (readPassMemo?.has(key))
298
+ return readPassMemo.get(key) ?? null;
299
+ const value = execReadSlot(account, service);
300
+ readPassMemo?.set(key, value);
301
+ return value;
302
+ }
303
+ function execReadSlot(account, service) {
264
304
  try {
265
305
  const out = (0, node_child_process_1.execFileSync)('security', ['find-generic-password', '-s', service, '-a', account, '-w'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
266
306
  const trimmed = out.replace(/\n$/, '');
267
307
  if (trimmed.length === 0)
268
308
  return null;
269
- raw = trimmed;
309
+ return trimmed;
270
310
  }
271
311
  catch (err) {
272
312
  const status = err.status;
@@ -280,6 +320,15 @@ function readSlot(account, service = currentService(), raw_ = false) {
280
320
  }
281
321
  return null;
282
322
  }
323
+ }
324
+ /**
325
+ * `raw` returns the stored string exactly, with no envelope transform — for
326
+ * copying another config's item, which may be sealed under a key we lack.
327
+ */
328
+ function readSlot(account, service = currentService(), raw_ = false) {
329
+ const raw = readRawSlot(account, service);
330
+ if (raw === null)
331
+ return null;
283
332
  if (raw_ || !isSecretAccount(account))
284
333
  return raw;
285
334
  // L12 — route secret slots through the envelope.
@@ -339,6 +388,7 @@ function quoteForSecurityInteractive(v) {
339
388
  return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
340
389
  }
341
390
  function writeSlot(account, value, service = currentService(), raw = false) {
391
+ forgetSlotInPass(account, service);
342
392
  try {
343
393
  // L12 — wrap secret-bearing slots through the envelope when active.
344
394
  // Empty / non-secret slots are passthrough, and so is a raw copy.
@@ -364,6 +414,7 @@ function writeSlot(account, value, service = currentService(), raw = false) {
364
414
  }
365
415
  }
366
416
  function deleteSlot(account, service = currentService()) {
417
+ forgetSlotInPass(account, service);
367
418
  try {
368
419
  (0, node_child_process_1.execFileSync)('security', ['delete-generic-password', '-s', service, '-a', account], {
369
420
  stdio: ['ignore', 'ignore', 'ignore'],
@@ -484,15 +535,17 @@ function readBundleAt(slot) {
484
535
  if (!isAvailable())
485
536
  return null;
486
537
  const service = serviceForSlot(slot);
487
- const token = readEntryAt(service, ACCOUNT_TOKEN, ACCOUNT_EXPIRES);
488
- if (!token)
489
- return null;
490
- return {
491
- token,
492
- refresh: readEntryAt(service, ACCOUNT_REFRESH, ACCOUNT_REFRESH_EXPIRES),
493
- serverOrigin: readSlot(ACCOUNT_SERVER_ORIGIN, service) || null,
494
- owner: readSlot(ACCOUNT_OWNER, service) || null,
495
- };
538
+ return withKeychainReadPass(() => {
539
+ const token = readEntryAt(service, ACCOUNT_TOKEN, ACCOUNT_EXPIRES);
540
+ if (!token)
541
+ return null;
542
+ return {
543
+ token,
544
+ refresh: readEntryAt(service, ACCOUNT_REFRESH, ACCOUNT_REFRESH_EXPIRES),
545
+ serverOrigin: readSlot(ACCOUNT_SERVER_ORIGIN, service) || null,
546
+ owner: readSlot(ACCOUNT_OWNER, service) || null,
547
+ };
548
+ });
496
549
  }
497
550
  const BUNDLE_ACCOUNTS = [
498
551
  ACCOUNT_TOKEN,
@@ -38,6 +38,13 @@ export interface EnsureFreshCliCredentialOptions {
38
38
  force?: boolean;
39
39
  /** Respect `--bare`: do not mutate persisted credentials in bare mode. */
40
40
  bareMode?: boolean;
41
+ /**
42
+ * `credential` was produced by the resolver a moment ago; decide from the
43
+ * Keychain read it made rather than reading every slot again
44
+ * (`takeResolvedKeychainRead`). Falls back to a fresh read whenever that read
45
+ * does not describe exactly this credential.
46
+ */
47
+ reuseResolvedRead?: boolean;
41
48
  }
42
49
  export interface EnsureFreshCliCredentialResult {
43
50
  credential: ResolvedCredential;
@@ -133,7 +133,8 @@ async function ensureFreshCliCredential(opts = {}) {
133
133
  return { credential, refreshed: false, skippedReason: 'external_source' };
134
134
  }
135
135
  const cfg = (0, config_1.loadConfig)();
136
- const read = (0, auth_storage_1.readFromBackend)(cfg.baseURL);
136
+ const read = (opts.reuseResolvedRead ? (0, auth_storage_1.takeResolvedKeychainRead)(credential, cfg.baseURL) : null) ??
137
+ (0, auth_storage_1.readFromBackend)(cfg.baseURL);
137
138
  // If a sibling process already rotated the stored access token, adopt it
138
139
  // instead of needlessly consuming the latest refresh token again.
139
140
  if (read.bundle?.token && read.bundle.token !== credential.token) {
@@ -3,6 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.describeAcquisitionRequestLines = describeAcquisitionRequestLines;
4
4
  exports.describeAcquisitionRequestRow = describeAcquisitionRequestRow;
5
5
  const data_provider_1 = require("@skrr-ai/data-provider");
6
+ function describeBuyerTerms(request) {
7
+ if (request.buyerTerms) {
8
+ return `${request.buyerTerms.termsVersion} — ${request.buyerTerms.artifactUri}`;
9
+ }
10
+ if (request.invalidReason === 'buyer_terms_missing') {
11
+ // Stored before OSK-13520 refused to open a request without terms.
12
+ return 'missing — this request was recorded without buyer terms, so it can only be declined';
13
+ }
14
+ return 'not configured, so this purchase cannot be approved';
15
+ }
6
16
  /**
7
17
  * A purchase an agent asked to make, as the lines a terminal shows (OSK-12240).
8
18
  *
@@ -22,9 +32,7 @@ function describeAcquisitionRequestLines(request) {
22
32
  ` Status: ${status.label}${status.detail ? ` — ${status.detail}` : ''}`,
23
33
  ` Price: ${(0, data_provider_1.formatStoreAcquisitionPrice)(request.price)} ${request.currency}`,
24
34
  ` Refunds: ${(0, data_provider_1.describeStoreAcquisitionRefundTerms)(request.refundTerms)}`,
25
- ` Buyer terms: ${request.buyerTerms
26
- ? `${request.buyerTerms.termsVersion} — ${request.buyerTerms.artifactUri}`
27
- : 'not configured, so this purchase cannot be approved'}`,
35
+ ` Buyer terms: ${describeBuyerTerms(request)}`,
28
36
  ` Offer: ${request.offerVersionId}`,
29
37
  ` Capabilities: ${(0, data_provider_1.describeStoreAcquisitionCapabilities)(request.capabilities, request.capabilityCount)}`,
30
38
  ];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skrr-ai/auth-core",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "main": "dist/cjs/index.js",
5
5
  "types": "dist/esm/index.d.ts",
6
6
  "exports": {