@skrr-ai/cli 0.1.122 → 0.1.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/commands/agents/chat.d.ts +7 -0
  2. package/dist/commands/agents/chat.js +32 -6
  3. package/dist/commands/followups/act.js +3 -0
  4. package/dist/commands/followups/checkin.js +3 -0
  5. package/dist/commands/followups/remind.js +3 -0
  6. package/dist/commands/followups/show.js +1 -1
  7. package/dist/commands/followups/watch.js +3 -0
  8. package/dist/lib/agentic-stream.d.ts +46 -0
  9. package/dist/lib/agentic-stream.js +135 -14
  10. package/dist/lib/auth-failure.js +12 -0
  11. package/dist/lib/exec-runtime-binary.d.ts +9 -0
  12. package/dist/lib/exec-runtime-binary.js +15 -1
  13. package/dist/lib/followups.d.ts +13 -0
  14. package/dist/lib/followups.js +8 -0
  15. package/dist/lib/profile-machine-identity-guard.js +2 -1
  16. package/dist/lib/runtime-start-verification.js +6 -1
  17. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +58 -1
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +2 -0
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +60 -0
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +8 -0
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -0
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +7 -0
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +3 -2
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +3 -2
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +29 -0
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +37 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +1 -1
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +1 -0
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +12 -6
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.d.ts +3 -0
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.js +41 -3
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +7 -1
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +109 -3
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +2 -0
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +78 -0
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +3 -2
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +3 -2
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +29 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +38 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +1 -1
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +1 -0
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +31 -6
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.d.ts +3 -0
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.js +40 -3
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +7 -1
  51. package/dist/node_modules/@skrr-ai/auth-core/package.json +10 -0
  52. package/dist/node_modules/@skrr-ai/data-provider/index.js +7330 -7324
  53. package/oclif.manifest.json +11 -2
  54. package/package.json +1 -1
@@ -45,7 +45,7 @@ import fs from 'node:fs';
45
45
  import path from 'node:path';
46
46
  import lockfile from 'proper-lockfile';
47
47
  import { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, isEnvelopeString as isCredEnvelopeString, generateDek, serialize as credEnvelopeSerialize, } from './credentialEnvelope.js';
48
- import { getKekStrategy, zeroizeKekCaches } from './kek/index.js';
48
+ import { getKekStrategy, KekUnavailableError, zeroizeKekCaches, } from './kek/index.js';
49
49
  import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
50
50
  import { needsReauthState } from './refresh.js';
51
51
  import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
@@ -407,6 +407,12 @@ async function loadRetiredDeks(profile, kek, aad, active) {
407
407
  }
408
408
  }
409
409
  catch {
410
+ // A copy kept because it did not open (`keepUnreadableWrappedDek`) is expected not to open
411
+ // until its KEK comes back; warning on every process start for each of up to eight files
412
+ // would print on every `skrr` command for as long as it lasts. A retired DEK a
413
+ // consolidation set aside is different: it opened once, so it failing is news.
414
+ if (path.basename(target).startsWith(UNREADABLE_DEK_COPY_PREFIX))
415
+ continue;
410
416
  emit('cred_envelope.retired_dek.unreadable', false, {
411
417
  profile,
412
418
  file: path.basename(target),
@@ -414,6 +420,79 @@ async function loadRetiredDeks(profile, kek, aad, active) {
414
420
  }
415
421
  }
416
422
  }
423
+ /**
424
+ * Prefix of the copy kept when a wrapped DEK could not be opened and is about to be
425
+ * replaced. It sits in the retired keyring (`<file>.retired.*`), so a key that opens
426
+ * again later (a restored Keychain item, a transient fault misread as a rotation) brings
427
+ * back everything sealed under it, and a logout deletes it with the rest.
428
+ */
429
+ const UNREADABLE_DEK_COPY_PREFIX = `${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.unreadable-`;
430
+ /**
431
+ * A copy is 92 bytes and only the OLDEST can be the one that matters: every replacement after
432
+ * the first is usually a copy of a DEK this process itself just enrolled. The first cut kept
433
+ * two, and three rotations in thirteen seconds (a test pointed at a real home) pushed the
434
+ * original DEK out, which is exactly the copy that would have restored the profile.
435
+ */
436
+ const MAX_UNREADABLE_DEK_COPIES = 8;
437
+ /**
438
+ * Whether a failed unwrap means the KEK could not be READ right now, as opposed to the KEK
439
+ * being a different key. A locked Keychain, a non-interactive context or a failing helper
440
+ * say nothing about the key; only an authentication-tag failure under a KEK that was read
441
+ * does. Matched by name as well as by class, since two copies of this package in one
442
+ * process (a farmed worktree) have two classes.
443
+ */
444
+ function isKekUnavailableError(err) {
445
+ return (err instanceof KekUnavailableError ||
446
+ err?.name === 'KekUnavailableError');
447
+ }
448
+ /**
449
+ * Keep the wrapped DEK this process could not open before it is replaced. Replacing it
450
+ * orphans everything sealed under the old DEK (the device private key, stored tokens), and
451
+ * the one thing that can still undo that is the old wrapped DEK meeting its KEK again.
452
+ * Best effort and bounded: a copy that cannot be written must not stop the daemon coming
453
+ * up, and the oldest copies are pruned.
454
+ */
455
+ function keepUnreadableWrappedDek(profile, wrapped) {
456
+ try {
457
+ const dir = profileStateDir(profile);
458
+ const existing = fs.existsSync(dir)
459
+ ? fs
460
+ .readdirSync(dir)
461
+ .filter((entry) => entry.startsWith(UNREADABLE_DEK_COPY_PREFIX))
462
+ .sort()
463
+ : [];
464
+ // The same bytes are already kept (a KEK that flaps between two values re-seals nothing
465
+ // new): do not let a duplicate take a slot from a different DEK.
466
+ for (const entry of existing) {
467
+ try {
468
+ if (fs.readFileSync(path.join(dir, entry)).equals(wrapped))
469
+ return entry;
470
+ }
471
+ catch {
472
+ /* an unreadable copy is not a match */
473
+ }
474
+ }
475
+ const name = `${UNREADABLE_DEK_COPY_PREFIX}${new Date().toISOString().replace(/[:.]/g, '-')}`;
476
+ atomicWrite0600(path.join(dir, name), wrapped);
477
+ const kept = [...existing, name].sort();
478
+ for (const stale of kept.slice(0, Math.max(0, kept.length - MAX_UNREADABLE_DEK_COPIES))) {
479
+ try {
480
+ fs.unlinkSync(path.join(dir, stale));
481
+ }
482
+ catch {
483
+ /* a copy that will not go costs 92 bytes */
484
+ }
485
+ }
486
+ return name;
487
+ }
488
+ catch (err) {
489
+ emit('cred_envelope.unreadable_dek_copy_failed', false, {
490
+ profile,
491
+ message: err?.message ?? 'unknown',
492
+ });
493
+ return null;
494
+ }
495
+ }
417
496
  /** Retired wrapped DEKs for `profile`, oldest first. */
418
497
  function retiredDekFiles(profile) {
419
498
  const dir = profileStateDir(profile);
@@ -699,6 +778,8 @@ export async function initCredEnvelope(profile) {
699
778
  }
700
779
  let dek = null;
701
780
  let unwrapAttemptedAndFailed = false;
781
+ let kekUnreadable = null;
782
+ let unreadableWrapped = null;
702
783
  await withCredEnvelopeLock(profile, async () => {
703
784
  // Re-stat after acquiring the lock — a sibling sky/oversky may have
704
785
  // enrolled while we were waiting. Without this, both processes would
@@ -728,6 +809,7 @@ export async function initCredEnvelope(profile) {
728
809
  kekKind: captureKind(),
729
810
  });
730
811
  unwrapAttemptedAndFailed = true;
812
+ unreadableWrapped = wrappedBytes;
731
813
  }
732
814
  else {
733
815
  dek = candidate;
@@ -740,21 +822,45 @@ export async function initCredEnvelope(profile) {
740
822
  kekKind: captureKind(),
741
823
  message: err?.message ?? 'unknown',
742
824
  });
743
- unwrapAttemptedAndFailed = true;
825
+ if (isKekUnavailableError(err)) {
826
+ // The KEK could not be read; that is not evidence it changed. Enrolling here would
827
+ // replace a DEK that is still good with one the next start cannot tell apart from
828
+ // a rotation, and orphan everything sealed under it.
829
+ kekUnreadable = err;
830
+ }
831
+ else {
832
+ unwrapAttemptedAndFailed = true;
833
+ unreadableWrapped = wrappedBytes;
834
+ }
744
835
  }
745
836
  }
837
+ if (kekUnreadable !== null) {
838
+ _state = { kind: 'disabled', reason: 'kek_unavailable' };
839
+ emit('cred_envelope.kek_unavailable', false, {
840
+ kekId: kek.id,
841
+ profile,
842
+ phase: 'unwrap',
843
+ describe: kek.describe(),
844
+ message: kekUnreadable?.message ?? 'unknown',
845
+ });
846
+ return;
847
+ }
746
848
  // KEK rotation detection (gap G4). If the wrapped file was present,
747
849
  // KEK reported `available=true`, but unwrap threw — the most likely
748
850
  // explanation is the KEK was rotated underneath us. Surface this as
749
851
  // a distinct event so dashboards can count rotations separately
750
852
  // from genuinely-corrupt blobs, while still falling through to a
751
- // fresh enrollment so the daemon stays operational.
853
+ // fresh enrollment so the daemon stays operational. The old wrapped DEK is
854
+ // kept first: enrolling replaces it, and it is the only way back to what
855
+ // was sealed under it.
752
856
  if (unwrapAttemptedAndFailed && dek === null) {
753
857
  _kekRotationDetected = true;
858
+ const keptAs = unreadableWrapped ? keepUnreadableWrappedDek(profile, unreadableWrapped) : null;
754
859
  emit('cred_envelope.kek_rotated', false, {
755
860
  kekId: kek.id,
756
861
  kekKind: captureKind(),
757
862
  profile,
863
+ ...(keptAs ? { previousWrappedDekKeptAs: keptAs } : {}),
758
864
  message: 'wrapped DEK present but unwrap failed under available KEK; treating as rotation and re-enrolling',
759
865
  });
760
866
  }
@@ -11,6 +11,8 @@ type DeviceIdentityState = {
11
11
  envelopeProtected: boolean;
12
12
  };
13
13
  type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed' | 'unreadable_key' | 'no_stored_key';
14
+ /** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
15
+ export declare function __setUnreadableReloadIntervalForTest(ms: number | null): void;
14
16
  /** @internal Test seam — force-set state. */
15
17
  export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
16
18
  /** @internal Test seam — read state. */
@@ -58,6 +58,76 @@ const DEVICE_IDENTITY_FILES = [
58
58
  const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
59
59
  let _state = { kind: 'uninit' };
60
60
  let _initializing = null;
61
+ /**
62
+ * What this process saw when it gave up on a stored key it could not read: the profile, and
63
+ * the mtimes of the two key files. A process that is already running has no way to learn that
64
+ * someone else has since written a readable key (the desktop's recovery, an interactive
65
+ * login in another process), and used to sit disabled until it was restarted, signing
66
+ * nothing. `reloadIfRecovered` reads the key again, but only when one of those files has
67
+ * changed, so a key that stays unreadable costs one `stat` and no log line.
68
+ */
69
+ let _unreadableSeen = null;
70
+ let _unreadableReloadIntervalMs = 5_000;
71
+ /** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
72
+ export function __setUnreadableReloadIntervalForTest(ms) {
73
+ _unreadableReloadIntervalMs = ms ?? 5_000;
74
+ }
75
+ function keyFilesStamp(profile) {
76
+ const part = (file) => {
77
+ try {
78
+ const s = fs.statSync(file);
79
+ return `${s.mtimeMs}:${s.size}`;
80
+ }
81
+ catch {
82
+ return 'absent';
83
+ }
84
+ };
85
+ return `${part(publicKeyPath(profile))}|${part(privateKeyPath(profile))}`;
86
+ }
87
+ /**
88
+ * If a key this process could not read has been replaced on disk since, load it. Never
89
+ * creates, replaces or deletes anything: it only picks up what another process wrote
90
+ * under the same lock, which is not the silent replacement the `recoverUnreadable` gate
91
+ * exists to prevent.
92
+ */
93
+ function reloadIfRecovered() {
94
+ if (_state.kind !== 'disabled' || _state.reason !== 'unreadable_key')
95
+ return;
96
+ const seen = _unreadableSeen;
97
+ if (!seen || _initializing)
98
+ return;
99
+ const now = Date.now();
100
+ if (now - seen.checkedAt < _unreadableReloadIntervalMs)
101
+ return;
102
+ seen.checkedAt = now;
103
+ let stamp;
104
+ try {
105
+ stamp = keyFilesStamp(seen.profile);
106
+ }
107
+ catch {
108
+ return;
109
+ }
110
+ if (stamp === seen.stamp)
111
+ return;
112
+ seen.stamp = stamp;
113
+ let keypair = null;
114
+ try {
115
+ keypair = loadKeypair(seen.profile);
116
+ }
117
+ catch {
118
+ keypair = null;
119
+ }
120
+ if (!keypair)
121
+ return;
122
+ _state = {
123
+ kind: 'active',
124
+ keypair,
125
+ profile: seen.profile,
126
+ envelopeProtected: isCredEnvelopeActive(),
127
+ };
128
+ _unreadableSeen = null;
129
+ emit('reloaded_after_recovery', true, { profile: seen.profile, thumbprint: keypair.thumbprint });
130
+ }
61
131
  /**
62
132
  * The CLI, daemon, and desktop's short-lived signer resolve the same canonical
63
133
  * profile directory even when an older private key still lives in the nested
@@ -500,6 +570,9 @@ async function initDeviceIdentityLocked(profile, options = {}) {
500
570
  const hasStoredKey = storedKeyMaterialExists(profile);
501
571
  if (hasStoredKey && (!options.recoverUnreadable || independent)) {
502
572
  _state = { kind: 'disabled', reason: 'unreadable_key' };
573
+ _unreadableSeen = independent
574
+ ? null
575
+ : { profile, stamp: keyFilesStamp(profile), checkedAt: Date.now() };
503
576
  emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
504
577
  return;
505
578
  }
@@ -577,9 +650,11 @@ export async function resetDeviceIdentity(opts) {
577
650
  });
578
651
  }
579
652
  _state = { kind: 'uninit' };
653
+ _unreadableSeen = null;
580
654
  }
581
655
  /** True iff init has completed and a keypair is in memory. */
582
656
  export function isDeviceIdentityActive() {
657
+ reloadIfRecovered();
583
658
  return _state.kind === 'active';
584
659
  }
585
660
  /**
@@ -588,6 +663,7 @@ export function isDeviceIdentityActive() {
588
663
  * on the in-flight request.
589
664
  */
590
665
  export function getDevicePublicKey() {
666
+ reloadIfRecovered();
591
667
  if (_state.kind !== 'active')
592
668
  return null;
593
669
  return { jwk: _state.keypair.publicJwk, thumbprint: _state.keypair.thumbprint };
@@ -597,6 +673,7 @@ export function getDevicePublicKey() {
597
673
  * `oversky status` and CLI debug commands. Never includes the private key.
598
674
  */
599
675
  export function describeDeviceIdentityState() {
676
+ reloadIfRecovered();
600
677
  if (_state.kind === 'disabled') {
601
678
  return { kind: 'disabled', reason: _state.reason };
602
679
  }
@@ -620,6 +697,7 @@ export function describeDeviceIdentityState() {
620
697
  * passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
621
698
  */
622
699
  export function signServerRequest(opts) {
700
+ reloadIfRecovered();
623
701
  if (_state.kind !== 'active')
624
702
  return null;
625
703
  try {
@@ -25,11 +25,11 @@ export { loginWithLocalhost, LoginInitError, LoginExchangeError, type BrowserOpe
25
25
  export { parseTtlDays, MIN_TTL_DAYS, MAX_TTL_DAYS, type ParseTtlResult, type ParseTtlSuccess, type ParseTtlError, } from './ttlParser.js';
26
26
  export { formatRecoveryCode, parseRecoveryCode, normalizeRecoveryCode, isValidRecoveryCode, } from './recoveryCode.js';
27
27
  export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, ENVELOPE_MAGIC as CRED_ENVELOPE_MAGIC, generateDek, isEnvelopeString as isCredEnvelopeString, KEY_LEN as CRED_KEY_LEN, openBuffer as credOpenBuffer, sealBuffer as credSealBuffer, serialize as credEnvelopeSerialize, unwrap as credEnvelopeUnwrap, wrap as credEnvelopeWrap, type SealedBlob as CredSealedBlob, } from './credentialEnvelope.js';
28
- export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type KekStrategy, } from './kek/index.js';
28
+ export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, isMasterKekKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type KekStrategy, } from './kek/index.js';
29
29
  export { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, normalizeHtu, publicJwkFromX, tokenHash, verifyProof as verifyDeviceProof, type DeviceKeyPair, type DeviceProofClaims, type DeviceProofHeader, type Ed25519PrivateJwk, type Ed25519PublicJwk, type VerifyFailureReason as DeviceProofVerifyFailureReason, type VerifyFailure as DeviceProofVerifyFailure, type VerifyOptions as DeviceProofVerifyOptions, type VerifySuccess as DeviceProofVerifySuccess, } from './deviceKey.js';
30
30
  export { DPOP_HEADER, DPOP_MAX_AGE_SECONDS, DPOP_PROOF_INVALID, DPOP_PROOF_REQUIRED, DPOP_PROOF_TYP, DPOP_PROOF_VERIFY_UNAVAILABLE, accessTokenHash, createDpopKey, isAth, isJwkThumbprint, normalizeDpopHtu, verifyDpopProof, type DpopKey, type DpopProofClaims, type DpopProofRequest, type DpopVerifyFailureReason, type DpopVerifyOptions, type DpopVerifyResult, } from './dpop.js';
31
31
  export { initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
32
- export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
32
+ export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
33
33
  export { getMachineUuid, getMachineUuidForProfile, describeMachineIdentity, type MachineIdentityDescription, type MachineIdentityReason, getHardwareMachineUuid, setMachineUuidProfile, getMachineUuidProfile, qualifyMachineUuid, MACHINE_IDENTITY_FILENAME, hostedRuntimeIdOverride, HOSTED_RUNTIME_MACHINE_SOURCES, __resetMachineUuidCacheForTest, } from './machineUuid.js';
34
34
  export { ensureMachineDaemonId, legacyDaemonUuids, __resetCachedDaemonIdForTest, } from './machineId.js';
35
35
  export { PAIR_AAD_PREFIX, PAIR_AAD_MAX_LEN, PAIR_BUNDLE_MAX_BASE64_LEN, PAIR_NONCE_LEN, PAIR_PUBKEY_LEN, PAIR_SECRET_LEN, PairBundleError, assemblePairBundleBase64, base64ToUint8Array as pairBase64ToUint8Array, base64UrlToUint8Array as pairBase64UrlToUint8Array, buildPairAad, buildPairUrl, generatePairKeyPair, openPairBundleBase64, parsePairBundle, parsePairPlaintext, parsePairUrl, uint8ArrayToBase64 as pairUint8ArrayToBase64, uint8ArrayToBase64Url as pairUint8ArrayToBase64Url, type AssembleBundleOptions as PairAssembleBundleOptions, type OpenBundleOptions as PairOpenBundleOptions, type PairBundle, type PairBundleErrorCode, type PairKeyPair, type PairPlaintextV1, type PairUrlFields, } from './daemonPair.js';
@@ -98,7 +98,7 @@ export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as cred
98
98
  // Tests that need the unavailable branch must inject `UnavailableKek`;
99
99
  // `__setKekStrategyForTest(null)` restores the PLATFORM default, which is not
100
100
  // the same thing and differs per OS.
101
- export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, registerZeroizeHook, WindowsKek, zeroizeKekCaches, } from './kek/index.js';
101
+ export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, isMasterKekKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, } from './kek/index.js';
102
102
  // L11 Phase 1 — Device identity (Ed25519) + DPoP-shaped proof JWS.
103
103
  // The crypto half of the two-axis identity story; daemon-side
104
104
  // persistence + signing lives in `daemon/src/deviceIdentity.ts`.
@@ -121,7 +121,7 @@ export {
121
121
  // deviceIdentity bridge
122
122
  initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit,
123
123
  // Test seams — file path resolvers + state setters for spec files.
124
- __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, } from './deviceIdentityBridge.js';
124
+ __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, } from './deviceIdentityBridge.js';
125
125
  // Hardware machine identity sent as `machineUuid` (IOPlatformUUID /
126
126
  // machine-id / MachineGuid). ONE implementation for daemon and CLI —
127
127
  // OSK-12063. Also exposed dependency-free as `@skrr-ai/auth-core/machine-uuid`.
@@ -19,6 +19,7 @@ export { MacosKek } from './macos.js';
19
19
  export { LinuxKek } from './linux.js';
20
20
  export { WindowsKek } from './windows.js';
21
21
  export { zeroizeKekCaches, registerZeroizeHook } from './zeroize-registry.js';
22
+ export { isMasterKekKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
22
23
  /**
23
24
  * @internal Test-only seam — inject a synthetic KEK strategy that wraps
24
25
  * with a fixed in-memory key. Resets to platform default when called
@@ -7,6 +7,7 @@ export { MacosKek } from './macos.js';
7
7
  export { LinuxKek } from './linux.js';
8
8
  export { WindowsKek } from './windows.js';
9
9
  export { zeroizeKekCaches, registerZeroizeHook } from './zeroize-registry.js';
10
+ export { isMasterKekKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
10
11
  /**
11
12
  * Strategy used when the current platform has no implementation. Always
12
13
  * unavailable; never throws on probe. Provides an honest `id` so fleet
@@ -60,12 +60,13 @@ import fs from 'node:fs';
60
60
  import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize as envelopeSerialize, KEY_LEN, } from '../credentialEnvelope.js';
61
61
  import { KekUnavailableError } from './types.js';
62
62
  import { registerZeroizeHook } from './zeroize-registry.js';
63
+ import { MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE } from './masterKekService.js';
63
64
  /** Stable telemetry kind strings — one per backend tier. */
64
65
  const KIND_LIBSECRET = 'linux-libsecret-shellout';
65
66
  const KIND_HKDF = 'linux-machineid-hkdf';
66
67
  /** Secret Service item attributes for the master KEK (Tier-2). */
67
- const SS_SERVICE = 'ai.skrr.daemon.kek';
68
- const SS_ACCOUNT = 'master-kek-v1';
68
+ const SS_SERVICE = MASTER_KEK_KEYCHAIN_SERVICE;
69
+ const SS_ACCOUNT = MASTER_KEK_KEYCHAIN_ACCOUNT;
69
70
  /** D-Bus transport error substrings — any of these in stderr means no session. */
70
71
  const DBUS_TRANSPORT_ERRORS = [
71
72
  'D-Bus',
@@ -57,6 +57,7 @@ import fs from 'node:fs';
57
57
  import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize as envelopeSerialize, KEY_LEN, } from '../credentialEnvelope.js';
58
58
  import { KekUnavailableError } from './types.js';
59
59
  import { registerZeroizeHook } from './zeroize-registry.js';
60
+ import { MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE } from './masterKekService.js';
60
61
  /**
61
62
  * Stable telemetry kind string — fleet dashboards size the macOS cohort
62
63
  * by this value. Only one backend ships in this module today.
@@ -68,8 +69,8 @@ const KIND_SHELLOUT = 'macos-keychain-shellout';
68
69
  * Test seams and smoke tests can override either field via the
69
70
  * `MacosKek` constructor without touching this canonical pair.
70
71
  */
71
- const KEYCHAIN_SERVICE = 'ai.skrr.daemon.kek';
72
- const KEYCHAIN_ACCOUNT = 'master-kek-v1';
72
+ const KEYCHAIN_SERVICE = MASTER_KEK_KEYCHAIN_SERVICE;
73
+ const KEYCHAIN_ACCOUNT = MASTER_KEK_KEYCHAIN_ACCOUNT;
73
74
  /** `security` exit code when the requested item does not exist. */
74
75
  const SECURITY_ITEM_NOT_FOUND = 44;
75
76
  const execFileAsync = promisify(execFile);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * kek/masterKekService.ts — the one place that names the machine-wide master KEK.
3
+ *
4
+ * The master KEK is ONE keychain item shared by every profile on the machine
5
+ * (`service=ai.skrr.daemon.kek`, `account=master-kek-v1`). Each profile's
6
+ * `cred-dek-wrapped` is sealed under it, and everything a profile stores — its
7
+ * tokens, its BYOK keys, its device private key — is sealed under that DEK.
8
+ * Deleting this one item therefore orphans every credential envelope on the
9
+ * machine at once, and nothing can bring it back: the key is random.
10
+ *
11
+ * It was deleted by accident on 2026-10-29. The desktop app's sign-out purge
12
+ * enumerates every service matching `ai.(skrr|oversky).daemon*` and drains each
13
+ * one, because that prefix used to hold only per-profile credential slots. The
14
+ * KEK moved under the same prefix without the purge being told, so a routine
15
+ * "your saved session was rejected" at app launch deleted it. The next daemon
16
+ * start could not open the DEK, treated that as a rotation, enrolled a new one,
17
+ * and the device private key sealed under the old DEK was gone for good — which
18
+ * the server then saw as `DEVICE_PROOF_MISSING` on every CLI hand-off.
19
+ *
20
+ * So anything that deletes keychain items by PREFIX or PATTERN asks
21
+ * `isMasterKekKeychainService` first. Deleting the KEK is a decision a caller
22
+ * makes by name, never a side effect of a sweep.
23
+ */
24
+ /** Keychain `service` of the master KEK. */
25
+ export declare const MASTER_KEK_KEYCHAIN_SERVICE = "ai.skrr.daemon.kek";
26
+ /** Keychain `account` of the master KEK. */
27
+ export declare const MASTER_KEK_KEYCHAIN_ACCOUNT = "master-kek-v1";
28
+ /** Whether a keychain service name holds master-KEK material. */
29
+ export declare function isMasterKekKeychainService(service: string): boolean;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * kek/masterKekService.ts — the one place that names the machine-wide master KEK.
3
+ *
4
+ * The master KEK is ONE keychain item shared by every profile on the machine
5
+ * (`service=ai.skrr.daemon.kek`, `account=master-kek-v1`). Each profile's
6
+ * `cred-dek-wrapped` is sealed under it, and everything a profile stores — its
7
+ * tokens, its BYOK keys, its device private key — is sealed under that DEK.
8
+ * Deleting this one item therefore orphans every credential envelope on the
9
+ * machine at once, and nothing can bring it back: the key is random.
10
+ *
11
+ * It was deleted by accident on 2026-10-29. The desktop app's sign-out purge
12
+ * enumerates every service matching `ai.(skrr|oversky).daemon*` and drains each
13
+ * one, because that prefix used to hold only per-profile credential slots. The
14
+ * KEK moved under the same prefix without the purge being told, so a routine
15
+ * "your saved session was rejected" at app launch deleted it. The next daemon
16
+ * start could not open the DEK, treated that as a rotation, enrolled a new one,
17
+ * and the device private key sealed under the old DEK was gone for good — which
18
+ * the server then saw as `DEVICE_PROOF_MISSING` on every CLI hand-off.
19
+ *
20
+ * So anything that deletes keychain items by PREFIX or PATTERN asks
21
+ * `isMasterKekKeychainService` first. Deleting the KEK is a decision a caller
22
+ * makes by name, never a side effect of a sweep.
23
+ */
24
+ /** Keychain `service` of the master KEK. */
25
+ export const MASTER_KEK_KEYCHAIN_SERVICE = 'ai.skrr.daemon.kek';
26
+ /** Keychain `account` of the master KEK. */
27
+ export const MASTER_KEK_KEYCHAIN_ACCOUNT = 'master-kek-v1';
28
+ /**
29
+ * The master KEK under the current brand and the one before the rename, plus
30
+ * anything namespaced beneath either (`…kek.probe`, the Linux Secret Service
31
+ * probe). The pre-rename item is protected too: leaving a dead item behind costs
32
+ * bytes, and deleting key material is the one step that cannot be taken back.
33
+ */
34
+ const MASTER_KEK_SERVICE_PATTERN = /^ai\.(?:skrr|oversky)\.daemon\.kek(?:\..*)?$/;
35
+ /** Whether a keychain service name holds master-KEK material. */
36
+ export function isMasterKekKeychainService(service) {
37
+ return typeof service === 'string' && MASTER_KEK_SERVICE_PATTERN.test(service);
38
+ }
@@ -1,4 +1,4 @@
1
- export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'DEVICE_KEY_MISMATCH' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
1
+ export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'DEVICE_KEY_MISMATCH' | 'DEVICE_KEY_UNAVAILABLE' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
2
2
  /**
3
3
  * The command that recovers this process from a re-auth latch.
4
4
  *
@@ -13,6 +13,7 @@ const REASON_HUMAN = {
13
13
  REFRESH_EXPIRED: 'Your session has expired.',
14
14
  SESSION_REVOKED: 'Your session was ended (you signed out elsewhere or your password changed).',
15
15
  DEVICE_KEY_MISMATCH: "This computer's device key no longer matches the one skrr registered for it, so its session cannot be renewed.",
16
+ DEVICE_KEY_UNAVAILABLE: "This computer's device key could not be read, so skrr cannot prove which device is asking and its session cannot be renewed. Signing in again creates a new one.",
16
17
  TOKEN_EXPIRED: 'Your access token has expired.',
17
18
  TOKEN_INVALID: 'Your saved credentials are no longer valid.',
18
19
  UNKNOWN_401: 'skrr needs you to sign in again.',
@@ -611,7 +611,12 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
611
611
  // "proof-per-attempt invariant: a retry loop must call buildRequestInit()
612
612
  // per attempt" in `refresh.deviceProof.test.ts` asserts the function
613
613
  // is defined inside `doRefresh` and will fail if it is moved.
614
+ // Whether the request the server just answered carried a device proof. A
615
+ // `DEVICE_PROOF_MISSING` is only a LOCAL fact (no key to sign with) when we
616
+ // know none went out; if one did, something between us stripped it.
617
+ let proofAttached = false;
614
618
  function buildRequestInit() {
619
+ proofAttached = false;
615
620
  // Phase 3-R5: the stable `X-Oversky-Origin` header is the server's
616
621
  // preferred identity signal on the benign-grace rotation match. Newer
617
622
  // servers prefer it over the raw User-Agent; older servers ignore
@@ -646,6 +651,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
646
651
  __refreshDiagnostics.proofAttemptCount += 1;
647
652
  if (typeof proof === 'string' && proof.length > 0) {
648
653
  headers['OverSky-DPoP'] = proof;
654
+ proofAttached = true;
649
655
  }
650
656
  }
651
657
  catch (err) {
@@ -867,10 +873,29 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
867
873
  // the FINAL fallback. The distinct `UNKNOWN_401` telemetry tag is kept
868
874
  // on BOTH branches so monitoring can still see a server-side bug
869
875
  // separately from a real expiration (and watch the retry-vs-latch split).
876
+ //
877
+ // One unknown-shape 401 IS diagnosable: the server demanded a device proof
878
+ // (`DEVICE_PROOF_MISSING`) and this request carried none. That is a fact
879
+ // about this machine — an unreadable, never-created or unwired device key —
880
+ // and it never self-heals, but it stays on the same budget (a proxy that
881
+ // strips the header, or an identity still initialising, reads the same on
882
+ // one attempt). Only what the exhausted latch SAYS differs, so a person is
883
+ // sent to the sign-in that mints a key rather than told "sign in again".
884
+ // If a proof DID go out, the header was lost on the way: that stays generic.
885
+ const noLocalProof = parsedBody.code === 'DEVICE_PROOF_MISSING' && !proofAttached;
886
+ const latchReason = noLocalProof
887
+ ? 'DEVICE_KEY_UNAVAILABLE'
888
+ : 'UNKNOWN_401';
870
889
  const message = parsedBody.message || 'Unknown auth failure';
890
+ const latchMessage = noLocalProof
891
+ ? `This computer has no usable device key, so the refresh carried no device proof (server: ${message})`
892
+ : message;
871
893
  const { exhausted, count } = recordUnknown401(daemonId, Date.now());
872
894
  if (!exhausted) {
873
- log.warn(`[auth/refresh] unknown-shape 401 (${count}/${UNKNOWN_401_BUDGET}) — treating as transient, will retry: ${message}`);
895
+ log.warn(`[auth/refresh] unknown-shape 401 (${count}/${UNKNOWN_401_BUDGET}) — treating as transient, will retry: ${message}` +
896
+ (noLocalProof
897
+ ? ' (no device proof was sent: this machine has no usable device key)'
898
+ : ''));
874
899
  emitAuthTelemetry({
875
900
  timestamp: new Date().toISOString(),
876
901
  eventType: 'auth',
@@ -893,7 +918,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
893
918
  // permanent behavior: latch + persist the reauth flag so the daemon
894
919
  // surfaces the banner and the interactive modal becomes the final
895
920
  // recovery path.
896
- log.warn(`[auth/refresh] unknown-shape 401 budget exhausted (${count}/${UNKNOWN_401_BUDGET}) — latching permanent: ${message}`);
921
+ log.warn(`[auth/refresh] unknown-shape 401 budget exhausted (${count}/${UNKNOWN_401_BUDGET}) — latching permanent ${latchReason}: ${message}`);
897
922
  emitAuthTelemetry({
898
923
  timestamp: new Date().toISOString(),
899
924
  eventType: 'auth',
@@ -902,18 +927,18 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
902
927
  event: 'refresh_failure',
903
928
  daemonId,
904
929
  durationMs: Date.now() - startMs,
905
- errorCode: 'UNKNOWN_401',
930
+ errorCode: latchReason,
906
931
  statusCode: 401,
907
932
  transient: false,
908
933
  unknown401Streak: count,
909
934
  },
910
935
  });
911
936
  persistReauthState({
912
- code: 'UNKNOWN_401',
913
- message,
937
+ code: latchReason,
938
+ message: latchMessage,
914
939
  at: Date.now(),
915
940
  });
916
- throw new PermanentAuthFailure('UNKNOWN_401', message);
941
+ throw new PermanentAuthFailure(latchReason, latchMessage);
917
942
  }
918
943
  // 5xx / unexpected — transient. Caller will back off.
919
944
  // RESET SEMANTICS (Finding 4 follow-up): a 5xx is transient and does NOT
@@ -61,6 +61,9 @@ export declare const PREVIEW_TOKEN_REDACTION: "pvw_[redacted]";
61
61
  export declare const QR_SIGN_IN_REDACTION: "[redacted]";
62
62
  /** Fields are protocol-specific; preserve ordinary request ids, error codes and metrics. */
63
63
  export declare function isQrSignInSecretField(key: string): boolean;
64
+ export declare function isOAuthQuerySecretField(key: string): boolean;
65
+ export declare function isOAuthQueryContainerField(key: string): boolean;
66
+ export declare function isGraphHandoffCookieField(key: string): boolean;
64
67
  /**
65
68
  * Replace every credential that travels in a URL with a marker: a Hosted Machine preview token with
66
69
  * {@link PREVIEW_TOKEN_REDACTION}, and a workspace invitation, platform invitation, member link or operator code with
@@ -107,6 +107,21 @@ export function isQrSignInSecretField(key) {
107
107
  return QR_SECRET_FIELDS.has(leaf.replace(/[_-]/g, '').toLowerCase());
108
108
  }
109
109
  const QR_SECRET_ASSIGNMENT = /((?:["']?)(?:statusToken|claimToken|codeVerifier|codeChallenge|comparisonCode|status_token|claim_token|code_verifier|code_challenge|comparison_code)(?:["']?)\s*[:=]\s*["']?)([A-Za-z0-9._~%+-]+)/gi;
110
+ // OAuth callback queries may be exported detached from their URL. Redact
111
+ // sensitive parameter values even without path context, including when the
112
+ // query separators were percent-encoded by an enclosing URL.
113
+ const OAUTH_QUERY_SECRET = /(^|[?&]|%(?:25)*26)(code|state|error_description|handoff)(=|%(?:25)*3[dD])([^&#\s"'<>]*)/gi;
114
+ export function isOAuthQuerySecretField(key) {
115
+ return ['code', 'state', 'error_description', 'handoff'].includes(key.toLowerCase());
116
+ }
117
+ export function isOAuthQueryContainerField(key) {
118
+ const leaf = (key.split('.').pop() || '').replace(/[_-]/g, '').toLowerCase();
119
+ return leaf === 'query' || leaf === 'querystring';
120
+ }
121
+ export function isGraphHandoffCookieField(key) {
122
+ return key.toLowerCase() === '__secure-pc-graph-handoff';
123
+ }
124
+ const GRAPH_HANDOFF_COOKIE = /((?:_|%(?:25)*5[fF]){2}secure(?:-|%(?:25)*2[dD])pc(?:-|%(?:25)*2[dD])graph(?:-|%(?:25)*2[dD])handoff(?:=|%(?:25)*3[dD]))([^;,&\s"'<>]+)/gi;
110
125
  function redactQrSignInText(value) {
111
126
  // Undici/Sentry may split url.query/query_string from its path. The QR's
112
127
  // direction vocabulary identifies that standalone query without treating an
@@ -124,7 +139,8 @@ const credentialMarker = (_match, prefix) => `${prefix}_[redacted]`;
124
139
  export function redactPreviewTokens(value) {
125
140
  if (typeof value !== 'string')
126
141
  return value;
127
- const qrSafe = redactQrSignInText(value);
142
+ const qrSafe = redactQrSignInText(value).replace(OAUTH_QUERY_SECRET, '$1$2$3[redacted]')
143
+ .replace(GRAPH_HANDOFF_COOKIE, '$1[redacted]');
128
144
  if (!CREDENTIAL_HINTS.some((hint) => qrSafe.indexOf(hint) !== -1))
129
145
  return qrSafe;
130
146
  return qrSafe
@@ -153,7 +169,7 @@ export function redactPreviewTokensDeep(root, limits = {}) {
153
169
  let visited = 0;
154
170
  let complete = true;
155
171
  const rewrite = (holder, key, current) => {
156
- const next = isQrSignInSecretField(key) ? QR_SIGN_IN_REDACTION : redactPreviewTokens(current);
172
+ const next = isQrSignInSecretField(key) || isGraphHandoffCookieField(key) ? QR_SIGN_IN_REDACTION : redactPreviewTokens(current);
157
173
  if (next === current) {
158
174
  return;
159
175
  }
@@ -195,7 +211,28 @@ export function redactPreviewTokensDeep(root, limits = {}) {
195
211
  complete = false;
196
212
  continue;
197
213
  }
198
- if (isQrSignInSecretField(key) || typeof child === 'string') {
214
+ if (isOAuthQueryContainerField(key) && Array.isArray(child)) {
215
+ try {
216
+ holder[key] = '[redacted]';
217
+ }
218
+ catch {
219
+ complete = false;
220
+ }
221
+ if (holder[key] !== '[redacted]')
222
+ complete = false;
223
+ continue;
224
+ }
225
+ if (isOAuthQueryContainerField(key) && child && typeof child === 'object' && !Array.isArray(child)) {
226
+ const query = child;
227
+ for (const queryKey of Object.keys(query)) {
228
+ if (isOAuthQuerySecretField(queryKey)) {
229
+ query[queryKey] = '[redacted]';
230
+ if (query[queryKey] !== '[redacted]')
231
+ complete = false;
232
+ }
233
+ }
234
+ }
235
+ if (isQrSignInSecretField(key) || isGraphHandoffCookieField(key) || typeof child === 'string') {
199
236
  rewrite(holder, key, child);
200
237
  }
201
238
  else if (child !== null && typeof child === 'object') {