@skrr-ai/cli 0.1.58 → 0.1.59

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 (36) hide show
  1. package/dist/base-command.d.ts +0 -16
  2. package/dist/base-command.js +65 -11
  3. package/dist/commands/logout.d.ts +0 -46
  4. package/dist/commands/logout.js +21 -3
  5. package/dist/lib/agentic-stream.d.ts +42 -0
  6. package/dist/lib/agentic-stream.js +103 -14
  7. package/dist/lib/api-fetch.d.ts +11 -0
  8. package/dist/lib/api-fetch.js +98 -1
  9. package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
  10. package/dist/lib/daemonBrokerRefusal.js +87 -2
  11. package/dist/lib/dedicated-guest.d.ts +13 -0
  12. package/dist/lib/dedicated-guest.js +22 -0
  13. package/dist/lib/dedicated-machines.js +17 -1
  14. package/dist/lib/dedicated-service.d.ts +1 -13
  15. package/dist/lib/dedicated-service.js +7 -19
  16. package/dist/lib/login.js +10 -1
  17. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +5 -3
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -1
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +21 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
  33. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  34. package/dist/node_modules/@skrr-ai/data-provider/index.js +4391 -4363
  35. package/oclif.manifest.json +33201 -33201
  36. package/package.json +1 -1
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.__setStateForTest = __setStateForTest;
7
7
  exports.__getStateForTest = __getStateForTest;
8
+ exports.describeDeviceKeySplit = describeDeviceKeySplit;
8
9
  exports.initDeviceIdentity = initDeviceIdentity;
9
10
  exports.resetDeviceIdentity = resetDeviceIdentity;
10
11
  exports.isDeviceIdentityActive = isDeviceIdentityActive;
@@ -262,6 +263,37 @@ function deviceIdentityOf(dir) {
262
263
  return null;
263
264
  }
264
265
  }
266
+ /**
267
+ * The device-key split, read from disk: the daemon (legacy directory, not yet
268
+ * consolidated) and the CLI (canonical directory) holding DIFFERENT keypairs
269
+ * for one profile. `null` when this process has no legacy location, when the
270
+ * legacy copy is consolidated, or when either side has no readable public key.
271
+ *
272
+ * This is the fact the server acts on (OSK-12462): a CLI credential brokered by
273
+ * the daemon is checked against the key pinned on the daemon's machine row, so
274
+ * while the thumbprints differ the CLI's refresh is refused. Reading it from
275
+ * the public JWKs, which are plaintext, needs no DEK and changes nothing.
276
+ */
277
+ function describeDeviceKeySplit(profile) {
278
+ try {
279
+ const resolved = (0, profileStateDir_js_1.resolveProfileStateDir)(profile, PRIVATE_KEY_FILENAME);
280
+ if (!resolved.usingLegacy || resolved.legacyDir === null)
281
+ return null;
282
+ const daemonThumbprint = deviceIdentityOf(resolved.legacyDir);
283
+ const cliThumbprint = deviceIdentityOf(resolved.canonicalDir);
284
+ if (!daemonThumbprint || !cliThumbprint || daemonThumbprint === cliThumbprint)
285
+ return null;
286
+ return {
287
+ daemonThumbprint,
288
+ cliThumbprint,
289
+ daemonDir: resolved.legacyDir,
290
+ cliDir: resolved.canonicalDir,
291
+ };
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
265
297
  /**
266
298
  * Run the OSK-10314 consolidation for this profile's device identity.
267
299
  *
@@ -276,11 +308,17 @@ async function consolidateDeviceIdentity(profile) {
276
308
  emit('path_consolidation.deferred', true, { profile, reason: 'cred_envelope_path_conflict' }, { logLevel: 'info' });
277
309
  return;
278
310
  }
311
+ // Two different keypairs under one DEK are converged the way two DEKs are:
312
+ // onto the daemon's, unless the daemon itself was refused over it. The CLI's
313
+ // keypair is set aside as `.retired`, not deleted (OSK-12462). Leaving them
314
+ // split is what had the CLI refresh refused as a device-key mismatch.
315
+ const policy = (0, credentialEnvelopeBridge_js_1.splitKeyConvergencePolicy)();
279
316
  try {
280
317
  await (0, profileStateDir_js_1.consolidateProfileState)(profile, {
281
318
  files: DEVICE_IDENTITY_FILES,
282
319
  primary: PRIVATE_KEY_FILENAME,
283
320
  identify: deviceIdentityOf,
321
+ onConflict: policy.converge ? 'adopt_legacy' : 'refuse',
284
322
  onEvent: (name, success, meta) => emit(name, success, meta),
285
323
  });
286
324
  }
@@ -27,8 +27,8 @@ export { formatRecoveryCode, parseRecoveryCode, normalizeRecoveryCode, isValidRe
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
28
  export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, 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
- export { initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
31
- export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
30
+ export { initCredEnvelope, 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';
31
+ 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
32
  export { getMachineUuid, hostedRuntimeIdOverride, HOSTED_RUNTIME_MACHINE_SOURCES, __resetMachineUuidCacheForTest, } from './machineUuid.js';
33
33
  export { ensureMachineDaemonId, legacyDaemonUuids, __resetCachedDaemonIdForTest, } from './machineId.js';
34
34
  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';
@@ -24,9 +24,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.generateVerifier = exports.invalidateHeadersHelperCache = exports.runHeadersHelper = exports.SENTINEL_TRIGGERING_FAILURES = exports.__resetAuthHelperFailureReasonForTest = exports.getLastAuthHelperFailureReason = exports.getDefaultHelperTtlMs = exports.invokeAuthHelper = exports.AuthHelperUnavailableError = exports.AuthHelperUntrustedError = exports.CI_TOKEN_PREFIX = exports.classifyTokenKind = exports.invalidateAuthHelperCache = exports.__resetCredentialResolverForTest = exports.CREDENTIAL_PRECEDENCE = exports.CredentialResolver = exports.__setFdReaderForTest = exports.readAuthFromFd = exports.createRefreshScheduler = exports.UNKNOWN_401_BUDGET = exports.__resetUnknown401StreaksForTest = exports.__refreshDiagnostics = exports.installLockExitHandlers = exports.clearReauthState = exports.needsReauthState = exports.persistReauthState = exports.withAuthLock = exports.revokeDaemonRefreshSession = exports.refreshDaemonToken = exports.classifyRefreshResponse = exports.EXIT_NEEDS_REAUTH = exports.reauthCommand = exports.formatReauthMessage = exports.handleMidStreamAuthExpiry = exports.isAuthExpiredSseEvent = exports.decodeJwtPayload = exports.decodeJwtExpiry = exports.TransientAuthFailure = exports.PermanentAuthFailure = exports.emitAuthTelemetry = exports.getAuthBinaryName = exports.getAuthKillSwitches = exports.getAuthConfigDir = exports.getAuthLogger = exports.configureAuthCore = exports.OAUTH_CLI_SCOPE = exports.isDaemonScope = exports.VALID_DAEMON_SCOPE_SET = exports.ALL_DAEMON_SCOPES = exports.SCOPES = void 0;
26
26
  exports.publicJwkFromX = exports.normalizeHtu = exports.jwkThumbprint = exports.generateDeviceKeyPair = exports.buildDeviceProof = exports.zeroizeKekCaches = exports.WindowsKek = exports.registerZeroizeHook = exports.MacosKek = exports.LinuxKek = exports.KekUnavailableError = exports.UnavailableKek = exports.InMemoryKek = exports.getKekStrategy = exports.__setKekStrategyForTest = exports.credEnvelopeWrap = exports.credEnvelopeUnwrap = exports.credEnvelopeSerialize = exports.credSealBuffer = exports.credOpenBuffer = exports.CRED_KEY_LEN = exports.isCredEnvelopeString = exports.generateDek = exports.CRED_ENVELOPE_MAGIC = exports.credEnvelopeEncrypt = exports.credEnvelopeDeserialize = exports.credEnvelopeDecrypt = exports.buildCredentialAad = exports.isValidRecoveryCode = exports.normalizeRecoveryCode = exports.parseRecoveryCode = exports.formatRecoveryCode = exports.MAX_TTL_DAYS = exports.MIN_TTL_DAYS = exports.parseTtlDays = exports.LoginExchangeError = exports.LoginInitError = exports.loginWithLocalhost = exports.LocalCallbackMalformedError = exports.LocalCallbackDeniedError = exports.LocalCallbackStateMismatchError = exports.LocalCallbackTimeoutError = exports.LocalCallbackBindError = exports.LocalCallbackError = exports.DEFAULT_LOGIN_TIMEOUT_MS = exports.startLocalCallback = exports.verifyChallenge = exports.safeEqual = exports.generateState = exports.challenge = void 0;
27
- exports.openPairBundleBase64 = exports.generatePairKeyPair = exports.buildPairUrl = exports.buildPairAad = exports.pairBase64UrlToUint8Array = exports.pairBase64ToUint8Array = exports.assemblePairBundleBase64 = exports.PairBundleError = exports.PAIR_SECRET_LEN = exports.PAIR_PUBKEY_LEN = exports.PAIR_NONCE_LEN = exports.PAIR_BUNDLE_MAX_BASE64_LEN = exports.PAIR_AAD_MAX_LEN = exports.PAIR_AAD_PREFIX = exports.__resetCachedDaemonIdForTest = exports.legacyDaemonUuids = exports.ensureMachineDaemonId = exports.__resetMachineUuidCacheForTest = exports.HOSTED_RUNTIME_MACHINE_SOURCES = exports.hostedRuntimeIdOverride = exports.getMachineUuid = exports.__getDeviceIdentityStateForTest = exports.__setDeviceIdentityStateForTest = exports.__enrollmentBackoffPathForTest = exports.__enrollmentMarkerPathForTest = exports.__privateKeyPathForTest = exports.__publicKeyPathForTest = exports.describeDeviceIdentityState = exports.getDevicePublicKey = exports.isDeviceIdentityActive = exports.clearEnrollmentMarker = exports.enrollWithServer = exports.signServerRequest = exports.resetDeviceIdentity = exports.initDeviceIdentity = exports.__resetShutdownGuardForTest = exports.__getCredEnvelopeStateForTest = exports.__setCredEnvelopeStateForTest = exports.__wrappedDekFilePathForTest = exports.DEFAULT_UNREADABLE_CREDENTIAL_HINT = exports.maybeDecryptOnRead = exports.maybeEncryptForWrite = exports.describeCredEnvelopeState = exports.hasCredEnvelopePathConflict = exports.isCredEnvelopeActive = exports.shutdownCredEnvelope = exports.resetCredEnvelope = exports.initCredEnvelope = exports.verifyDeviceProof = exports.tokenHash = void 0;
28
- exports.OVERSKY_RELEASE_PUBLIC_KEYS = exports.resolveFirstPartyHarnessChannel = exports.resolveChannelManifestUrl = exports.isFirstPartyHarnessChannel = exports.firstPartyHarnessFeedCandidates = exports.firstPartyHarnessFeedBase = exports.firstPartyHarnessChannelPrefix = exports.FIRST_PARTY_HARNESS_CHANNELS = exports.DEFAULT_FIRST_PARTY_HARNESS_CHANNEL = exports.migrateFirstPartyHarnessHome = exports.linkFirstPartyHarnessAliases = exports.firstPartyHarnessInstalledBinaryPath = exports.firstPartyHarnessHomeDir = exports.firstPartyHarnessBinDir = exports.FIRST_PARTY_HARNESS_PROVENANCE_SUFFIX = exports.FIRST_PARTY_HARNESS_PREVIOUS_SUFFIX = exports.describeLegacyState = exports.findLegacyLocalState = exports.DEFAULT_BINARY_NAME = exports.LEGACY_NATIVE_ID_PREFIXES = exports.NATIVE_ID_PREFIX = exports.LEGACY_CONFIG_DIR_NAMES = exports.CONFIG_DIR_NAME = exports.LOOPBACK_DEFAULT_MAX_BODY_BYTES = exports.LoopbackTargetRefusedError = exports.isLoopbackAddress = exports.isLoopbackHostname = exports.assertLoopbackHttpUrl = exports.loopbackFetch = exports.withLoopbackNoProxy = exports.envHasProxy = exports.LOOPBACK_NO_PROXY_HOSTS = exports.SENSITIVE_ENV_PREFIX_EXCEPTIONS = exports.SENSITIVE_ENV_NAME_TOKENS = exports.SENSITIVE_ENV_SUFFIXES = exports.SENSITIVE_ENV_PREFIXES = exports.SENSITIVE_ENV_VARS = exports.sanitizeSpawnEnv = exports.harnessLoadsClaudeSkillsLayout = exports.claudeSkillsLayoutEvidence = exports.HARNESSES_LOADING_CLAUDE_SKILLS_LAYOUT = exports.tierPermitsConfiguredAutoMode = exports.credentialPolicyForTier = exports.getHarnessTier = exports.HARNESS_TIERS = exports.pairUint8ArrayToBase64Url = exports.pairUint8ArrayToBase64 = exports.parsePairUrl = exports.parsePairPlaintext = exports.parsePairBundle = void 0;
29
- exports.consolidateProfileState = exports.profileStateFilePathsToClear = exports.profileStateFileLocations = exports.resolveProfileStateFile = exports.resolveProfileStateDir = exports.legacyProfileStateDir = exports.profileStateDir = exports.SUPERSEDED_COPY_SUFFIX = exports.SUPERSEDED_MARKER_SUFFIX = exports.PROFILES_SEGMENT = exports.isConfigRootOverridden = exports.configRootPath = exports.resolveConfigRoot = exports.verifyManifest = exports.verifyArtifact = exports.isTrustedArtifactOrigin = exports.getDeltaFor = exports.buildManifestMessage = exports.buildArtifactMessage = exports.TRUSTED_FEED_ORIGINS = exports.MAX_SIGS = void 0;
27
+ exports.buildPairUrl = exports.buildPairAad = exports.pairBase64UrlToUint8Array = exports.pairBase64ToUint8Array = exports.assemblePairBundleBase64 = exports.PairBundleError = exports.PAIR_SECRET_LEN = exports.PAIR_PUBKEY_LEN = exports.PAIR_NONCE_LEN = exports.PAIR_BUNDLE_MAX_BASE64_LEN = exports.PAIR_AAD_MAX_LEN = exports.PAIR_AAD_PREFIX = exports.__resetCachedDaemonIdForTest = exports.legacyDaemonUuids = exports.ensureMachineDaemonId = exports.__resetMachineUuidCacheForTest = exports.HOSTED_RUNTIME_MACHINE_SOURCES = exports.hostedRuntimeIdOverride = exports.getMachineUuid = exports.__getDeviceIdentityStateForTest = exports.__setDeviceIdentityStateForTest = exports.__enrollmentBackoffPathForTest = exports.__enrollmentMarkerPathForTest = exports.__privateKeyPathForTest = exports.__publicKeyPathForTest = exports.describeDeviceKeySplit = exports.describeDeviceIdentityState = exports.getDevicePublicKey = exports.isDeviceIdentityActive = exports.clearEnrollmentMarker = exports.enrollWithServer = exports.signServerRequest = exports.resetDeviceIdentity = exports.initDeviceIdentity = exports.__resetShutdownGuardForTest = exports.__getCredEnvelopeStateForTest = exports.__setCredEnvelopeStateForTest = exports.__wrappedDekFilePathForTest = exports.DEFAULT_UNREADABLE_CREDENTIAL_HINT = exports.maybeDecryptOnRead = exports.maybeEncryptForWrite = exports.splitKeyConvergencePolicy = exports.describeCredEnvelopeState = exports.hasCredEnvelopePathConflict = exports.isCredEnvelopeActive = exports.shutdownCredEnvelope = exports.resetCredEnvelope = exports.initCredEnvelope = exports.verifyDeviceProof = exports.tokenHash = void 0;
28
+ exports.resolveChannelManifestUrl = exports.isFirstPartyHarnessChannel = exports.firstPartyHarnessFeedCandidates = exports.firstPartyHarnessFeedBase = exports.firstPartyHarnessChannelPrefix = exports.FIRST_PARTY_HARNESS_CHANNELS = exports.DEFAULT_FIRST_PARTY_HARNESS_CHANNEL = exports.migrateFirstPartyHarnessHome = exports.linkFirstPartyHarnessAliases = exports.firstPartyHarnessInstalledBinaryPath = exports.firstPartyHarnessHomeDir = exports.firstPartyHarnessBinDir = exports.FIRST_PARTY_HARNESS_PROVENANCE_SUFFIX = exports.FIRST_PARTY_HARNESS_PREVIOUS_SUFFIX = exports.describeLegacyState = exports.findLegacyLocalState = exports.DEFAULT_BINARY_NAME = exports.LEGACY_NATIVE_ID_PREFIXES = exports.NATIVE_ID_PREFIX = exports.LEGACY_CONFIG_DIR_NAMES = exports.CONFIG_DIR_NAME = exports.LOOPBACK_DEFAULT_MAX_BODY_BYTES = exports.LoopbackTargetRefusedError = exports.isLoopbackAddress = exports.isLoopbackHostname = exports.assertLoopbackHttpUrl = exports.loopbackFetch = exports.withLoopbackNoProxy = exports.envHasProxy = exports.LOOPBACK_NO_PROXY_HOSTS = exports.SENSITIVE_ENV_PREFIX_EXCEPTIONS = exports.SENSITIVE_ENV_NAME_TOKENS = exports.SENSITIVE_ENV_SUFFIXES = exports.SENSITIVE_ENV_PREFIXES = exports.SENSITIVE_ENV_VARS = exports.sanitizeSpawnEnv = exports.harnessLoadsClaudeSkillsLayout = exports.claudeSkillsLayoutEvidence = exports.HARNESSES_LOADING_CLAUDE_SKILLS_LAYOUT = exports.tierPermitsConfiguredAutoMode = exports.credentialPolicyForTier = exports.getHarnessTier = exports.HARNESS_TIERS = exports.pairUint8ArrayToBase64Url = exports.pairUint8ArrayToBase64 = exports.parsePairUrl = exports.parsePairPlaintext = exports.parsePairBundle = exports.openPairBundleBase64 = exports.generatePairKeyPair = void 0;
29
+ exports.consolidateProfileState = exports.profileStateFilePathsToClear = exports.profileStateFileLocations = exports.resolveProfileStateFile = exports.resolveProfileStateDir = exports.legacyProfileStateDir = exports.profileStateDir = exports.SUPERSEDED_COPY_SUFFIX = exports.SUPERSEDED_MARKER_SUFFIX = exports.PROFILES_SEGMENT = exports.isConfigRootOverridden = exports.configRootPath = exports.resolveConfigRoot = exports.verifyManifest = exports.verifyArtifact = exports.isTrustedArtifactOrigin = exports.getDeltaFor = exports.buildManifestMessage = exports.buildArtifactMessage = exports.TRUSTED_FEED_ORIGINS = exports.MAX_SIGS = exports.OVERSKY_RELEASE_PUBLIC_KEYS = exports.resolveFirstPartyHarnessChannel = void 0;
30
30
  // Daemon scope vocabulary — single source of truth for non-Mongoose tiers
31
31
  // (daemon, desktop, cli). Schema-bound canonical lives in
32
32
  // `@skrr-ai/data-schemas/common/daemonScopes`; the two are kept byte-equal
@@ -232,6 +232,7 @@ Object.defineProperty(exports, "shutdownCredEnvelope", { enumerable: true, get:
232
232
  Object.defineProperty(exports, "isCredEnvelopeActive", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.isCredEnvelopeActive; } });
233
233
  Object.defineProperty(exports, "hasCredEnvelopePathConflict", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.hasCredEnvelopePathConflict; } });
234
234
  Object.defineProperty(exports, "describeCredEnvelopeState", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.describeCredEnvelopeState; } });
235
+ Object.defineProperty(exports, "splitKeyConvergencePolicy", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.splitKeyConvergencePolicy; } });
235
236
  Object.defineProperty(exports, "maybeEncryptForWrite", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.maybeEncryptForWrite; } });
236
237
  Object.defineProperty(exports, "maybeDecryptOnRead", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.maybeDecryptOnRead; } });
237
238
  Object.defineProperty(exports, "DEFAULT_UNREADABLE_CREDENTIAL_HINT", { enumerable: true, get: function () { return credentialEnvelopeBridge_js_1.DEFAULT_UNREADABLE_CREDENTIAL_HINT; } });
@@ -252,6 +253,7 @@ Object.defineProperty(exports, "clearEnrollmentMarker", { enumerable: true, get:
252
253
  Object.defineProperty(exports, "isDeviceIdentityActive", { enumerable: true, get: function () { return deviceIdentityBridge_js_1.isDeviceIdentityActive; } });
253
254
  Object.defineProperty(exports, "getDevicePublicKey", { enumerable: true, get: function () { return deviceIdentityBridge_js_1.getDevicePublicKey; } });
254
255
  Object.defineProperty(exports, "describeDeviceIdentityState", { enumerable: true, get: function () { return deviceIdentityBridge_js_1.describeDeviceIdentityState; } });
256
+ Object.defineProperty(exports, "describeDeviceKeySplit", { enumerable: true, get: function () { return deviceIdentityBridge_js_1.describeDeviceKeySplit; } });
255
257
  // Test seams — file path resolvers + state setters for spec files.
256
258
  Object.defineProperty(exports, "__publicKeyPathForTest", { enumerable: true, get: function () { return deviceIdentityBridge_js_1.__publicKeyPathForTest; } });
257
259
  Object.defineProperty(exports, "__privateKeyPathForTest", { enumerable: true, get: function () { return deviceIdentityBridge_js_1.__privateKeyPathForTest; } });
@@ -11,6 +11,13 @@ export declare const SUPERSEDED_MARKER_SUFFIX = ".superseded.json";
11
11
  * canonical copy existed and was NOT usable — a usable one is never replaced.
12
12
  */
13
13
  export declare const SUPERSEDED_COPY_SUFFIX = ".superseded";
14
+ /**
15
+ * Suffix for a USABLE canonical file set aside by a converging consolidation
16
+ * (`onConflict: 'adopt_legacy'`). Distinct from `SUPERSEDED_COPY_SUFFIX`, which
17
+ * names an unusable copy: a retired key still opens what was sealed under it,
18
+ * and the credential envelope reads these as its keyring (OSK-12462).
19
+ */
20
+ export declare const RETIRED_COPY_SUFFIX = ".retired";
14
21
  /** The canonical directory for a profile's auth state. */
15
22
  export declare function profileStateDir(profile: string): string;
16
23
  /**
@@ -59,7 +66,12 @@ export type ConsolidationStatus =
59
66
  /** Canonical was present but unusable; replaced, and kept under `.superseded`. */
60
67
  | 'superseded'
61
68
  /** Both copies are usable and DIFFERENT. Refused — a human decides. */
62
- | 'conflict';
69
+ | 'conflict'
70
+ /**
71
+ * Both copies were usable and DIFFERENT, and the caller chose the legacy one
72
+ * (`onConflict: 'adopt_legacy'`). The canonical copy is kept as `.retired`.
73
+ */
74
+ | 'converged';
63
75
  export interface ConsolidationOutcome {
64
76
  status: ConsolidationStatus;
65
77
  profile: string;
@@ -98,6 +110,14 @@ export interface ConsolidationSpec {
98
110
  identify?: (dir: string) => Promise<string | null> | string | null;
99
111
  /** Report what would happen and write nothing. */
100
112
  dryRun?: boolean;
113
+ /**
114
+ * What to do when both copies are usable and DIFFERENT. `refuse` (default)
115
+ * leaves both untouched and reports `conflict`. `adopt_legacy` sets the
116
+ * canonical copy aside as `<file>.retired.<stamp>` and adopts the legacy one,
117
+ * reporting `converged`. Only a caller that has decided which key must win
118
+ * may pass it — the files are kept, but nothing here can re-seal content.
119
+ */
120
+ onConflict?: 'refuse' | 'adopt_legacy';
101
121
  onEvent?: (name: string, success: boolean, meta: Record<string, unknown>) => void;
102
122
  }
103
123
  /**
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SUPERSEDED_COPY_SUFFIX = exports.SUPERSEDED_MARKER_SUFFIX = exports.PROFILES_SEGMENT = void 0;
6
+ exports.RETIRED_COPY_SUFFIX = exports.SUPERSEDED_COPY_SUFFIX = exports.SUPERSEDED_MARKER_SUFFIX = exports.PROFILES_SEGMENT = void 0;
7
7
  exports.profileStateDir = profileStateDir;
8
8
  exports.legacyProfileStateDir = legacyProfileStateDir;
9
9
  exports.resolveProfileStateDir = resolveProfileStateDir;
@@ -69,10 +69,14 @@ exports.profileStateFilePathsToClear = profileStateFilePathsToClear;
69
69
  * 4. no canonical copy of the file → legacy (not yet consolidated)
70
70
  * 5. both copies, no marker → legacy (unresolved: stay put)
71
71
  *
72
- * The migration writes the marker, never deletes, and refuses case 5 when both
73
- * sides hold a usable and DIFFERENT key — because merging those would require
74
- * re-sealing content that lives in the OS keychain and is not auth-core's to
75
- * re-seal. A human resolves that one, and the loud signal is the deliverable.
72
+ * The migration writes the marker and never deletes. Case 5 with a usable and
73
+ * DIFFERENT key on each side is refused by default. A caller that knows which
74
+ * key must win can pass `onConflict: 'adopt_legacy'`, which moves the canonical
75
+ * copy aside as `<file>.retired.<stamp>` (kept, never deleted) and adopts the
76
+ * legacy one. The credential envelope does that for the daemon's key, and keeps
77
+ * the retired DEK readable so nothing sealed under it is lost (OSK-12462): the
78
+ * refusal alone left the CLI signing with a key the server does not pin, which
79
+ * gets its refresh refused as a device-key mismatch.
76
80
  *
77
81
  * ROLLBACK. The legacy file is left exactly where it was, under its own name.
78
82
  * Older code looks there, finds it, and works — no operator step. The marker is
@@ -95,6 +99,13 @@ exports.SUPERSEDED_MARKER_SUFFIX = '.superseded.json';
95
99
  * canonical copy existed and was NOT usable — a usable one is never replaced.
96
100
  */
97
101
  exports.SUPERSEDED_COPY_SUFFIX = '.superseded';
102
+ /**
103
+ * Suffix for a USABLE canonical file set aside by a converging consolidation
104
+ * (`onConflict: 'adopt_legacy'`). Distinct from `SUPERSEDED_COPY_SUFFIX`, which
105
+ * names an unusable copy: a retired key still opens what was sealed under it,
106
+ * and the credential envelope reads these as its keyring (OSK-12462).
107
+ */
108
+ exports.RETIRED_COPY_SUFFIX = '.retired';
98
109
  /** The canonical directory for a profile's auth state. */
99
110
  function profileStateDir(profile) {
100
111
  assertProfile(profile);
@@ -226,6 +237,28 @@ async function consolidateProfileState(profile, spec) {
226
237
  identities,
227
238
  };
228
239
  }
240
+ if (spec.onConflict === 'adopt_legacy') {
241
+ const aside = dryRun
242
+ ? plannedAside(canonicalDir, spec.files)
243
+ : moveAside(canonicalDir, spec.files, exports.RETIRED_COPY_SUFFIX);
244
+ const moved = adopt(legacyDir, canonicalDir, spec.files, dryRun);
245
+ if (!dryRun)
246
+ writeMarker(markerPath, canonicalPrimary, 'converged');
247
+ emitOutcome(spec, 'converged', {
248
+ profile,
249
+ identities,
250
+ from: legacyDir,
251
+ to: canonicalDir,
252
+ retired: aside,
253
+ });
254
+ return {
255
+ ...base,
256
+ status: 'converged',
257
+ written: moved.written,
258
+ supersededAside: aside,
259
+ identities,
260
+ };
261
+ }
229
262
  emitOutcome(spec, 'conflict', { profile, identities, canonicalDir, legacyDir });
230
263
  return { ...base, status: 'conflict', identities };
231
264
  }
@@ -310,14 +343,14 @@ function adopt(fromDir, toDir, files, dryRun) {
310
343
  }
311
344
  return { written, supersededAside: [] };
312
345
  }
313
- function moveAside(dir, files) {
346
+ function moveAside(dir, files, suffix = exports.SUPERSEDED_COPY_SUFFIX) {
314
347
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
315
348
  const aside = [];
316
349
  for (const name of files) {
317
350
  const target = node_path_1.default.join(dir, name);
318
351
  if (!existsFile(target))
319
352
  continue;
320
- node_fs_1.default.renameSync(target, `${target}${exports.SUPERSEDED_COPY_SUFFIX}.${stamp}`);
353
+ node_fs_1.default.renameSync(target, `${target}${suffix}.${stamp}`);
321
354
  aside.push(target);
322
355
  }
323
356
  return aside;
@@ -23,6 +23,37 @@ export declare function __setStateForTest(next: CredEnvelopeState): CredEnvelope
23
23
  export declare function __getStateForTest(): CredEnvelopeState;
24
24
  /** @internal Test seam — each spec owns its warn-dedupe horizon. */
25
25
  export declare function __clearWarnDedupeForTest(): void;
26
+ /**
27
+ * Run the OSK-10314 consolidation for this profile's wrapped DEK.
28
+ *
29
+ * Under the canonical lock, because the window it closes is real: a daemon
30
+ * copying its legacy DEK into an empty canonical slot must not race a CLI
31
+ * enrolling a fresh one there — the loser's key would be overwritten and
32
+ * anything already sealed under it lost. That is the same race the lock was
33
+ * introduced for, one layer up.
34
+ *
35
+ * Never fatal. A migration that cannot run leaves the process exactly where it
36
+ * was, which is the state it has been operating in all along.
37
+ */
38
+ /**
39
+ * Whether a split profile may be converged onto the legacy (daemon) key.
40
+ *
41
+ * Only the daemon ever has a legacy location, so only the daemon runs this, and
42
+ * the legacy key is the one it signs with. Since OSK-12063 the CLI enrolls no
43
+ * device key and only an interactive daemon login re-pins, so the key the server
44
+ * pins for this machine is the daemon's, UNLESS the daemon has itself been
45
+ * refused over its device key. That is the one fact that proves otherwise, and
46
+ * then nothing here knows which key the server holds, so it refuses as before.
47
+ *
48
+ * Why converging matters (OSK-12462): a CLI credential brokered by the daemon
49
+ * is tied to the daemon's machine row, and the server checks its refresh proof
50
+ * against the key pinned there. A CLI signing with a different key has that
51
+ * refresh refused as DEVICE_PROOF_THUMBPRINT_MISMATCH at its first renewal.
52
+ */
53
+ export declare function splitKeyConvergencePolicy(): {
54
+ converge: boolean;
55
+ reason: 'daemon_key_pinned' | 'daemon_key_refused';
56
+ };
26
57
  /**
27
58
  * Initialize the envelope path. Idempotent — second call when state is
28
59
  * non-uninit returns immediately.
@@ -96,6 +127,16 @@ export declare function describeCredEnvelopeState(): {
96
127
  canonicalPath: string;
97
128
  legacyPath: string;
98
129
  };
130
+ /**
131
+ * Set when THIS process converged a split profile onto the legacy (daemon)
132
+ * key (OSK-12462). `canonicalPath` held the key that is now retired.
133
+ */
134
+ convergedFrom?: {
135
+ canonicalPath: string;
136
+ legacyPath: string;
137
+ };
138
+ /** How many retired DEKs this process can still open old values with. */
139
+ retiredKeys?: number;
99
140
  };
100
141
  /**
101
142
  * Sync write transform. Returns the wrapped serialized form when active.
@@ -46,7 +46,8 @@ 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
48
  import { getKekStrategy, zeroizeKekCaches } from './kek/index.js';
49
- import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, } from './profileStateDir.js';
49
+ import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
50
+ import { needsReauthState } from './refresh.js';
50
51
  import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
51
52
  /**
52
53
  * Account name the wrapped DEK lives under (file fallback). Keeping the
@@ -239,6 +240,20 @@ async function withCredEnvelopeLock(profile, body) {
239
240
  * auth-core's to do), so being loud about it IS the remedy.
240
241
  */
241
242
  let _pathConflict = null;
243
+ /**
244
+ * DEKs this profile used before a converging consolidation retired them
245
+ * (OSK-12462). Read-only: nothing is ever sealed under one again. A value that
246
+ * only a retired DEK opens is returned with `needsMigration: true`, the same
247
+ * signal a legacy plaintext value carries, so every consumer that already
248
+ * upgrades plaintext in place re-seals it under the active DEK on its own
249
+ * schedule. auth-core does not need to know where the consumer keeps it.
250
+ */
251
+ let _retiredDeks = [];
252
+ /**
253
+ * Set when THIS process converged a split profile onto the legacy key. For the
254
+ * status surface and tests; the on-disk marker is the durable record.
255
+ */
256
+ let _convergedFrom = null;
242
257
  /**
243
258
  * Identity of a wrapped DEK: the SHA-256 of the key it unwraps to.
244
259
  *
@@ -277,18 +292,48 @@ async function wrappedDekIdentity(target, kek, aad) {
277
292
  * Never fatal. A migration that cannot run leaves the process exactly where it
278
293
  * was, which is the state it has been operating in all along.
279
294
  */
295
+ /**
296
+ * Whether a split profile may be converged onto the legacy (daemon) key.
297
+ *
298
+ * Only the daemon ever has a legacy location, so only the daemon runs this, and
299
+ * the legacy key is the one it signs with. Since OSK-12063 the CLI enrolls no
300
+ * device key and only an interactive daemon login re-pins, so the key the server
301
+ * pins for this machine is the daemon's, UNLESS the daemon has itself been
302
+ * refused over its device key. That is the one fact that proves otherwise, and
303
+ * then nothing here knows which key the server holds, so it refuses as before.
304
+ *
305
+ * Why converging matters (OSK-12462): a CLI credential brokered by the daemon
306
+ * is tied to the daemon's machine row, and the server checks its refresh proof
307
+ * against the key pinned there. A CLI signing with a different key has that
308
+ * refresh refused as DEVICE_PROOF_THUMBPRINT_MISMATCH at its first renewal.
309
+ */
310
+ export function splitKeyConvergencePolicy() {
311
+ let reauthReason;
312
+ try {
313
+ const state = needsReauthState();
314
+ reauthReason = state.needed ? state.reason : undefined;
315
+ }
316
+ catch {
317
+ reauthReason = undefined;
318
+ }
319
+ return reauthReason === 'DEVICE_KEY_MISMATCH'
320
+ ? { converge: false, reason: 'daemon_key_refused' }
321
+ : { converge: true, reason: 'daemon_key_pinned' };
322
+ }
280
323
  async function consolidateWrappedDek(profile, kek, aad) {
281
324
  const resolved = resolveProfileStateFile(profile, ACCOUNT_CRED_DEK_WRAPPED);
282
325
  if (!resolved.usingLegacy) {
283
326
  _pathConflict = null;
284
327
  return;
285
328
  }
329
+ const policy = splitKeyConvergencePolicy();
286
330
  let outcome;
287
331
  try {
288
332
  outcome = await withCredEnvelopeLock(profile, () => consolidateProfileState(profile, {
289
333
  files: [ACCOUNT_CRED_DEK_WRAPPED],
290
334
  primary: ACCOUNT_CRED_DEK_WRAPPED,
291
335
  identify: (dir) => wrappedDekIdentity(path.join(dir, ACCOUNT_CRED_DEK_WRAPPED), kek, aad),
336
+ onConflict: policy.converge ? 'adopt_legacy' : 'refuse',
292
337
  onEvent: (name, success, meta) => emit(`cred_envelope.${name}`, success, meta),
293
338
  }));
294
339
  }
@@ -299,6 +344,19 @@ async function consolidateWrappedDek(profile, kek, aad) {
299
344
  });
300
345
  return;
301
346
  }
347
+ if (outcome.status === 'converged') {
348
+ _pathConflict = null;
349
+ _convergedFrom = {
350
+ canonicalPath: path.join(outcome.canonicalDir, ACCOUNT_CRED_DEK_WRAPPED),
351
+ legacyPath: path.join(outcome.legacyDir ?? '', ACCOUNT_CRED_DEK_WRAPPED),
352
+ };
353
+ getAuthLogger().info(`[credEnvelope] profile "${profile}" had two different usable wrapped DEKs; ` +
354
+ `converged on the daemon's (${_convergedFrom.legacyPath}), which is the key the ` +
355
+ "server pins for this machine. The CLI's former key is kept beside it as " +
356
+ `${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.* and still opens what was ` +
357
+ 'sealed under it; each value is re-sealed under the shared key on its next write.');
358
+ return;
359
+ }
302
360
  if (outcome.status !== 'conflict') {
303
361
  _pathConflict = null;
304
362
  return;
@@ -315,13 +373,62 @@ async function consolidateWrappedDek(profile, kek, aad) {
315
373
  // BINARY restarts and adopts its legacy key into the empty canonical slot,
316
374
  // and the CLI logs back in on it. Not `skrr daemon restart`: any `skrr`
317
375
  // command mints a fresh key into an empty slot before doing anything else.
318
- getAuthLogger().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs; ` +
376
+ //
377
+ // Reached only when the daemon itself was refused over its device key
378
+ // (`splitKeyConvergencePolicy`); an ordinary split converges above.
379
+ getAuthLogger().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs, and ` +
380
+ "the server refused the daemon's key, so neither is known to be the pinned one; " +
319
381
  `refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
320
382
  `${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
321
383
  "credentials, and neither can read the other's. Do not delete " +
322
384
  `${_pathConflict.legacyPath}: it is the key the daemon reads. To converge on it, ` +
323
385
  'run `skrr logout && skrrd restart && skrr login`.');
324
386
  }
387
+ /**
388
+ * Load every retired DEK beside the canonical one (OSK-12462). Only files a
389
+ * converging consolidation set aside are read; the `.superseded.*` copy an
390
+ * UNUSABLE key leaves is not a key anything can open. Read-only unwrap, never
391
+ * fatal: a retired key that no longer opens is simply not in the ring.
392
+ */
393
+ async function loadRetiredDeks(profile, kek, aad, active) {
394
+ for (const old of _retiredDeks)
395
+ old.fill(0);
396
+ _retiredDeks = [];
397
+ for (const target of retiredDekFiles(profile)) {
398
+ try {
399
+ if (!kek.unwrapExisting)
400
+ return;
401
+ const dek = await kek.unwrapExisting(fs.readFileSync(target), aad);
402
+ if (dek.length === 32 && !dek.equals(active)) {
403
+ _retiredDeks.push(dek);
404
+ }
405
+ else {
406
+ dek.fill(0);
407
+ }
408
+ }
409
+ catch {
410
+ emit('cred_envelope.retired_dek.unreadable', false, {
411
+ profile,
412
+ file: path.basename(target),
413
+ });
414
+ }
415
+ }
416
+ }
417
+ /** Retired wrapped DEKs for `profile`, oldest first. */
418
+ function retiredDekFiles(profile) {
419
+ const dir = profileStateDir(profile);
420
+ const prefix = `${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.`;
421
+ try {
422
+ return fs
423
+ .readdirSync(dir)
424
+ .filter((name) => name.startsWith(prefix))
425
+ .sort()
426
+ .map((name) => path.join(dir, name));
427
+ }
428
+ catch {
429
+ return [];
430
+ }
431
+ }
325
432
  /**
326
433
  * Initialize the envelope path. Idempotent — second call when state is
327
434
  * non-uninit returns immediately.
@@ -396,6 +503,7 @@ export async function initCredEnvelope(profile) {
396
503
  const wrappedBytes = fs.readFileSync(targetPath);
397
504
  const candidate = await kek.unwrapExisting(wrappedBytes, aad);
398
505
  if (candidate.length === 32) {
506
+ await loadRetiredDeks(profile, kek, aad, candidate);
399
507
  _state = {
400
508
  kind: 'active',
401
509
  dek: candidate,
@@ -503,6 +611,7 @@ export async function initCredEnvelope(profile) {
503
611
  return;
504
612
  }
505
613
  }
614
+ await loadRetiredDeks(profile, kek, aad, dek);
506
615
  _state = {
507
616
  kind: 'active',
508
617
  dek: dek,
@@ -533,7 +642,12 @@ export async function initCredEnvelope(profile) {
533
642
  */
534
643
  export async function resetCredEnvelope(opts) {
535
644
  if (opts?.clearOnDisk && opts.profile) {
536
- for (const target of profileStateFilePathsToClear(opts.profile, ACCOUNT_CRED_DEK_WRAPPED)) {
645
+ // A logout ends every key this profile held, the retired ones included: a
646
+ // retired DEK exists only to open values the logout is deleting anyway.
647
+ for (const target of [
648
+ ...profileStateFilePathsToClear(opts.profile, ACCOUNT_CRED_DEK_WRAPPED),
649
+ ...retiredDekFiles(opts.profile),
650
+ ]) {
537
651
  try {
538
652
  fs.unlinkSync(target);
539
653
  }
@@ -554,6 +668,10 @@ export async function resetCredEnvelope(opts) {
554
668
  _state = { kind: 'uninit' };
555
669
  _kekRotationDetected = false;
556
670
  _pathConflict = null;
671
+ _convergedFrom = null;
672
+ for (const old of _retiredDeks)
673
+ old.fill(0);
674
+ _retiredDeks = [];
557
675
  }
558
676
  /**
559
677
  * Idempotent shutdown — must be called from the daemon's graceful-exit
@@ -592,6 +710,10 @@ export function shutdownCredEnvelope() {
592
710
  _state = { kind: 'uninit' };
593
711
  _kekRotationDetected = false;
594
712
  _pathConflict = null;
713
+ _convergedFrom = null;
714
+ for (const old of _retiredDeks)
715
+ old.fill(0);
716
+ _retiredDeks = [];
595
717
  }
596
718
  /** @internal Test seam — re-arm `shutdownCredEnvelope` so it can run again. */
597
719
  export function __resetShutdownGuardForTest() {
@@ -627,6 +749,10 @@ export function hasCredEnvelopePathConflict() {
627
749
  * `oversky status`; clears on the next clean `resetCredEnvelope`.
628
750
  */
629
751
  export function describeCredEnvelopeState() {
752
+ const extras = {
753
+ ...(_convergedFrom ? { convergedFrom: _convergedFrom } : {}),
754
+ ...(_retiredDeks.length > 0 ? { retiredKeys: _retiredDeks.length } : {}),
755
+ };
630
756
  if (_state.kind === 'disabled') {
631
757
  return {
632
758
  kind: 'disabled',
@@ -643,6 +769,7 @@ export function describeCredEnvelopeState() {
643
769
  profile: _state.profile,
644
770
  ...(_kekRotationDetected ? { kekRotated: true } : {}),
645
771
  ...(_pathConflict ? { pathConflict: _pathConflict } : {}),
772
+ ...extras,
646
773
  };
647
774
  }
648
775
  return { kind: 'uninit', ...(_pathConflict ? { pathConflict: _pathConflict } : {}) };
@@ -764,12 +891,28 @@ export function maybeDecryptOnRead(stored, options = {}) {
764
891
  });
765
892
  return { plaintext: null, needsMigration: false };
766
893
  }
894
+ const aad = buildCredentialAad(_state.profile);
767
895
  try {
768
- const aad = buildCredentialAad(_state.profile);
769
896
  const pt = credEnvelopeDecrypt(sealed, _state.dek, aad).toString('utf-8');
770
897
  return { plaintext: pt, needsMigration: false };
771
898
  }
772
899
  catch (err) {
900
+ // Sealed before a converging consolidation retired its key (OSK-12462).
901
+ // Readable, and flagged for the in-place upgrade every consumer already
902
+ // performs for legacy plaintext, which re-seals it under the active DEK.
903
+ for (const retired of _retiredDeks) {
904
+ try {
905
+ const pt = credEnvelopeDecrypt(sealed, retired, aad).toString('utf-8');
906
+ emit('cred_envelope.read.retired_key', true, {
907
+ kekId: _state.kekId,
908
+ kekKind: _state.kekKind,
909
+ });
910
+ return { plaintext: pt, needsMigration: true };
911
+ }
912
+ catch {
913
+ /* not this one */
914
+ }
915
+ }
773
916
  emit('cred_envelope.read.decrypt_failed', false, {
774
917
  kekId: _state.kekId,
775
918
  kekKind: _state.kekKind,
@@ -15,6 +15,23 @@ type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed';
15
15
  export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
16
16
  /** @internal Test seam — read state. */
17
17
  export declare function __getStateForTest(): DeviceIdentityState;
18
+ /**
19
+ * The device-key split, read from disk: the daemon (legacy directory, not yet
20
+ * consolidated) and the CLI (canonical directory) holding DIFFERENT keypairs
21
+ * for one profile. `null` when this process has no legacy location, when the
22
+ * legacy copy is consolidated, or when either side has no readable public key.
23
+ *
24
+ * This is the fact the server acts on (OSK-12462): a CLI credential brokered by
25
+ * the daemon is checked against the key pinned on the daemon's machine row, so
26
+ * while the thumbprints differ the CLI's refresh is refused. Reading it from
27
+ * the public JWKs, which are plaintext, needs no DEK and changes nothing.
28
+ */
29
+ export declare function describeDeviceKeySplit(profile: string): {
30
+ daemonThumbprint: string;
31
+ cliThumbprint: string;
32
+ daemonDir: string;
33
+ cliDir: string;
34
+ } | null;
18
35
  /**
19
36
  * Initialize device identity. Idempotent.
20
37
  *