@skrr-ai/cli 0.1.123 → 0.1.125

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 (89) hide show
  1. package/dist/commands/agents/chat.d.ts +26 -0
  2. package/dist/commands/agents/chat.js +48 -10
  3. package/dist/commands/agents/delete.d.ts +8 -0
  4. package/dist/commands/agents/delete.js +16 -0
  5. package/dist/commands/context.js +7 -0
  6. package/dist/commands/search/workspace.js +1 -1
  7. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  8. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  9. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  10. package/dist/commands/store/gallery-admissions/show.js +1 -1
  11. package/dist/generated/agent-guides.json +19 -19
  12. package/dist/lib/agentic-stream.js +15 -1
  13. package/dist/lib/auth-core-init.d.ts +15 -2
  14. package/dist/lib/auth-core-init.js +47 -6
  15. package/dist/lib/command-hygiene.js +30 -1
  16. package/dist/lib/daemonBrokerRefusal.js +5 -1
  17. package/dist/lib/keychain.js +41 -4
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +13 -28
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +133 -4
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +387 -90
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +102 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -1
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +6 -0
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +8 -0
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +11 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +43 -3
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +33 -3
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +12 -10
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +15 -37
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +133 -4
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +536 -101
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +13 -3
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -1
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -1
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +15 -0
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +20 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +43 -3
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +104 -8
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +14 -10
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  86. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  87. package/dist/node_modules/@skrr-ai/data-provider/index.js +1094 -1067
  88. package/oclif.manifest.json +16 -3
  89. package/package.json +4 -1
@@ -49,7 +49,9 @@ var import_node_fs = __toESM(require("node:fs"), 1);
49
49
  var import_node_path = __toESM(require("node:path"), 1);
50
50
  var import_proper_lockfile = __toESM(require("proper-lockfile"), 1);
51
51
  var import_runtime = require("./runtime.js");
52
+ var import_liveState = require("./liveState.js");
52
53
  var import_refreshClassification = require("./refreshClassification.js");
54
+ var import_stateFiles = require("./stateFiles.js");
53
55
  var import_types = require("./types.js");
54
56
  const LOCK_STALE_MS = 5e3;
55
57
  const LOCK_RETRY_MS = 100;
@@ -125,9 +127,9 @@ async function withAuthLock(body, daemonId) {
125
127
  const lockPath = authLockPath();
126
128
  const dir = import_node_path.default.dirname(lockPath);
127
129
  if (!import_node_fs.default.existsSync(dir))
128
- import_node_fs.default.mkdirSync(dir, { recursive: true });
130
+ (0, import_stateFiles.ensureStateDir)(dir, "create the auth lock directory", null);
129
131
  if (!import_node_fs.default.existsSync(lockPath)) {
130
- import_node_fs.default.writeFileSync(lockPath, "", { mode: 384 });
132
+ (0, import_stateFiles.writeStateFile)(lockPath, "", { mode: 384 }, "create the auth lock");
131
133
  }
132
134
  const acquireStart = Date.now();
133
135
  (0, import_runtime.emitAuthTelemetry)({
@@ -283,7 +285,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
283
285
  body.machineUuid = options.machineUuid;
284
286
  }
285
287
  let proofAttached = false;
286
- function buildRequestInit() {
288
+ async function buildRequestInit() {
287
289
  proofAttached = false;
288
290
  const headers = { "Content-Type": "application/json" };
289
291
  if (options.origin) {
@@ -292,7 +294,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
292
294
  __refreshDiagnostics.requestAttemptCount += 1;
293
295
  if (options.signRequestProof) {
294
296
  try {
295
- const proof = options.signRequestProof({
297
+ const proof = await options.signRequestProof({
296
298
  method: "POST",
297
299
  url,
298
300
  refreshToken: currentRefreshToken
@@ -315,7 +317,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
315
317
  }
316
318
  let response;
317
319
  try {
318
- response = await fetch(url, buildRequestInit());
320
+ response = await fetch(url, await buildRequestInit());
319
321
  (0, import_runtime.notifyResponseObserver)(response);
320
322
  } catch (err) {
321
323
  log.warn(`[auth/refresh] network error: ${err?.message}`);
@@ -501,11 +503,10 @@ function persistReauthState(state) {
501
503
  try {
502
504
  const dir = import_node_path.default.dirname(flagPath);
503
505
  if (!import_node_fs.default.existsSync(dir))
504
- import_node_fs.default.mkdirSync(dir, { recursive: true, mode: 448 });
505
- import_node_fs.default.writeFileSync(flagPath, JSON.stringify(state, null, 2) + "\n", {
506
- mode: 384
507
- });
506
+ (0, import_stateFiles.ensureStateDir)(dir, "create the re-auth latch directory");
507
+ (0, import_stateFiles.writeStateFile)(flagPath, JSON.stringify(state, null, 2) + "\n", { mode: 384 }, "write the re-auth latch");
508
508
  } catch (err) {
509
+ (0, import_liveState.rethrowIfLiveStateRefusal)(err);
509
510
  log.debug(`[auth/refresh] failed to persist reauth flag: ${err?.message}`);
510
511
  }
511
512
  }
@@ -532,8 +533,9 @@ function clearReauthState() {
532
533
  try {
533
534
  const flagPath = reauthFlagPath();
534
535
  if (import_node_fs.default.existsSync(flagPath))
535
- import_node_fs.default.unlinkSync(flagPath);
536
+ (0, import_stateFiles.removeStateFile)(flagPath, "clear the re-auth latch");
536
537
  } catch (err) {
538
+ (0, import_liveState.rethrowIfLiveStateRefusal)(err);
537
539
  log.debug(`[auth/refresh] clearReauthState: ${err?.message}`);
538
540
  }
539
541
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * stateFiles.ts — the ONE place auth-core mutates credential state on disk.
3
+ *
4
+ * Every write, delete, rename and mkdir of a wrapped DEK, a device key, an enrollment marker, a
5
+ * lock sentinel, a re-auth latch or a machine identity goes through a function here, and each
6
+ * asks `assertMayMutateLiveState` first: a test process aimed at the account's real home is
7
+ * refused with a named error before a byte changes (`liveState.ts` says why that cannot be left
8
+ * to each runner).
9
+ *
10
+ * This is the "own a control surface, or do not touch it" rule applied to the real home. The
11
+ * atomic writer used to exist three times (device identity, credential envelope, profile
12
+ * consolidation), so a guard added to one left two doors open. `stateFileBoundary.spec.ts`
13
+ * fails when a file other than this one starts calling a raw `fs` mutator, so a fourth copy
14
+ * cannot appear unguarded.
15
+ *
16
+ * Outside a test process every function here is exactly the `fs` call it wraps.
17
+ */
18
+ import fs from 'node:fs';
19
+ /** `mkdir -p` for a state directory, 0700 unless `mode` says otherwise. */
20
+ export declare function ensureStateDir(dir: string, what: string, mode?: number | null): void;
21
+ /**
22
+ * Atomic 0600 write — tmp+fsync+rename+dir-fsync. The same shape the daemon and CLI always used,
23
+ * so a partial-write race between `skrr` and `skrrd` produces the same on-disk transitions.
24
+ */
25
+ export declare function atomicWrite0600(target: string, value: Buffer | string, what: string): void;
26
+ /** `fs.writeFileSync` for a state file: lock sentinels, a re-auth latch, a machine identity. */
27
+ export declare function writeStateFile(target: string, data: string | NodeJS.ArrayBufferView, options: fs.WriteFileOptions, what: string): void;
28
+ /** `fs.unlinkSync` for a state file. Throws what `unlinkSync` throws, ENOENT included. */
29
+ export declare function removeStateFile(target: string, what: string): void;
30
+ /** `fs.renameSync` between state paths; both ends are checked. */
31
+ export declare function renameStateFile(from: string, to: string, what: string): void;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var stateFiles_exports = {};
30
+ __export(stateFiles_exports, {
31
+ atomicWrite0600: () => atomicWrite0600,
32
+ ensureStateDir: () => ensureStateDir,
33
+ removeStateFile: () => removeStateFile,
34
+ renameStateFile: () => renameStateFile,
35
+ writeStateFile: () => writeStateFile
36
+ });
37
+ module.exports = __toCommonJS(stateFiles_exports);
38
+ var import_node_fs = __toESM(require("node:fs"), 1);
39
+ var import_node_path = __toESM(require("node:path"), 1);
40
+ var import_liveState = require("./liveState.js");
41
+ function ensureStateDir(dir, what, mode = 448) {
42
+ (0, import_liveState.assertMayMutateLiveState)(dir, what);
43
+ import_node_fs.default.mkdirSync(dir, mode === null ? { recursive: true } : { recursive: true, mode });
44
+ }
45
+ function atomicWrite0600(target, value, what) {
46
+ (0, import_liveState.assertMayMutateLiveState)(target, what);
47
+ const dir = import_node_path.default.dirname(target);
48
+ import_node_fs.default.mkdirSync(dir, { recursive: true, mode: 448 });
49
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
50
+ const buf = typeof value === "string" ? Buffer.from(value, "utf-8") : value;
51
+ const fd = import_node_fs.default.openSync(tmp, "w", 384);
52
+ try {
53
+ import_node_fs.default.writeSync(fd, buf);
54
+ import_node_fs.default.fsyncSync(fd);
55
+ } finally {
56
+ import_node_fs.default.closeSync(fd);
57
+ }
58
+ import_node_fs.default.renameSync(tmp, target);
59
+ try {
60
+ const dirFd = import_node_fs.default.openSync(dir, "r");
61
+ try {
62
+ import_node_fs.default.fsyncSync(dirFd);
63
+ } finally {
64
+ import_node_fs.default.closeSync(dirFd);
65
+ }
66
+ } catch {
67
+ }
68
+ }
69
+ function writeStateFile(target, data, options, what) {
70
+ (0, import_liveState.assertMayMutateLiveState)(target, what);
71
+ import_node_fs.default.writeFileSync(target, data, options);
72
+ }
73
+ function removeStateFile(target, what) {
74
+ (0, import_liveState.assertMayMutateLiveState)(target, what);
75
+ import_node_fs.default.unlinkSync(target);
76
+ }
77
+ function renameStateFile(from, to, what) {
78
+ (0, import_liveState.assertMayMutateLiveState)(from, what);
79
+ (0, import_liveState.assertMayMutateLiveState)(to, what);
80
+ import_node_fs.default.renameSync(from, to);
81
+ }
82
+ // Annotate the CommonJS export names for ESM import in node:
83
+ 0 && (module.exports = {
84
+ atomicWrite0600,
85
+ ensureStateDir,
86
+ removeStateFile,
87
+ renameStateFile,
88
+ writeStateFile
89
+ });
@@ -161,7 +161,11 @@ export type DaemonSupervisor = 'desktop' | 'service';
161
161
  export declare function daemonSignInRemedy(opts?: {
162
162
  /** How the daemon is run; absent when the caller cannot tell. */
163
163
  supervisor?: DaemonSupervisor;
164
- /** The exact `skrrd … login` for this profile, when the daemon knows it. */
164
+ /**
165
+ * The exact login command for this profile (`skrrd … login`, or
166
+ * `skrr daemon login --profile …`), when the caller knows it. Used when the
167
+ * supervisor is `service` or unknown; a desktop-hosted daemon is never told one.
168
+ */
165
169
  loginCommand?: string;
166
170
  /** The refusal was DEVICE_PROOF_PIN_MISMATCH: say that a sign-in re-pins. */
167
171
  repinsDeviceKey?: boolean;
@@ -167,7 +167,10 @@ export function daemonSignInRemedy(opts = {}) {
167
167
  const login = opts.loginCommand ? `\`${opts.loginCommand}\`` : '`skrr daemon login`';
168
168
  return `run ${login}${repin ? ' — an interactive login re-pins the device key' : ''}`;
169
169
  }
170
+ // A caller that cannot tell who runs the daemon may still know which profile it
171
+ // is on: `skrr daemon login` with no `--profile` signs the DEFAULT daemon in
172
+ // (OSK-16435), so the command it names is the one it was given.
170
173
  return ('sign the daemon in again on the machine running it: if the Skrr desktop app runs it, quit ' +
171
- 'and reopen the app; otherwise run `skrr daemon login`' +
174
+ `and reopen the app; otherwise run \`${opts.loginCommand ?? 'skrr daemon login'}\`` +
172
175
  (repin ? ' (either one re-pins the device key)' : ''));
173
176
  }
@@ -47,8 +47,10 @@ 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, KekUnavailableError, zeroizeKekCaches, } from './kek/index.js';
49
49
  import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
50
+ import { assertMayMutateLiveState, rethrowIfLiveStateRefusal } from './liveState.js';
50
51
  import { needsReauthState } from './refresh.js';
51
52
  import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
53
+ import { atomicWrite0600, ensureStateDir, removeStateFile, writeStateFile } from './stateFiles.js';
52
54
  /**
53
55
  * Account name the wrapped DEK lives under (file fallback). Keeping the
54
56
  * literal here so a future rename surfaces as a single grep target.
@@ -84,37 +86,6 @@ export function __getStateForTest() {
84
86
  function wrappedDekFilePath(profile) {
85
87
  return resolveProfileStateFile(profile, ACCOUNT_CRED_DEK_WRAPPED).path;
86
88
  }
87
- /**
88
- * Atomic 0600 write — tmp+fsync+rename+dir-fsync. Same shape as the
89
- * original daemon/CLI implementations so partial-write races between
90
- * `sky` and `oversky` produce the same on-disk transitions.
91
- */
92
- function atomicWrite0600(target, value) {
93
- const dir = path.dirname(target);
94
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
95
- const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
96
- const fd = fs.openSync(tmp, 'w', 0o600);
97
- try {
98
- fs.writeSync(fd, value);
99
- fs.fsyncSync(fd);
100
- }
101
- finally {
102
- fs.closeSync(fd);
103
- }
104
- fs.renameSync(tmp, target);
105
- try {
106
- const dirFd = fs.openSync(dir, 'r');
107
- try {
108
- fs.fsyncSync(dirFd);
109
- }
110
- finally {
111
- fs.closeSync(dirFd);
112
- }
113
- }
114
- catch {
115
- /* best-effort — Windows / some BSDs reject dir fsync */
116
- }
117
- }
118
89
  /**
119
90
  * Telemetry shim. Routes through the consumer-supplied logEvent sink
120
91
  * (daemon: structured event log; CLI: typically no-op or stderr at DEBUG).
@@ -195,9 +166,9 @@ function credEnvelopeLockPath(profile) {
195
166
  async function withCredEnvelopeLock(profile, body) {
196
167
  const lockPath = credEnvelopeLockPath(profile);
197
168
  const dir = path.dirname(lockPath);
198
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
169
+ ensureStateDir(dir, `take the credential envelope lock for profile ${profile}`);
199
170
  if (!fs.existsSync(lockPath)) {
200
- fs.writeFileSync(lockPath, '', { mode: 0o600 });
171
+ writeStateFile(lockPath, '', { mode: 0o600 }, 'create the credential envelope lock');
201
172
  }
202
173
  const acquireStart = Date.now();
203
174
  const release = await lockfile.lock(lockPath, {
@@ -338,6 +309,7 @@ async function consolidateWrappedDek(profile, kek, aad) {
338
309
  }));
339
310
  }
340
311
  catch (err) {
312
+ rethrowIfLiveStateRefusal(err);
341
313
  emit('cred_envelope.path_consolidation.failed', false, {
342
314
  profile,
343
315
  message: err?.message ?? 'unknown',
@@ -473,11 +445,11 @@ function keepUnreadableWrappedDek(profile, wrapped) {
473
445
  }
474
446
  }
475
447
  const name = `${UNREADABLE_DEK_COPY_PREFIX}${new Date().toISOString().replace(/[:.]/g, '-')}`;
476
- atomicWrite0600(path.join(dir, name), wrapped);
448
+ atomicWrite0600(path.join(dir, name), wrapped, 'keep the unreadable wrapped DEK');
477
449
  const kept = [...existing, name].sort();
478
450
  for (const stale of kept.slice(0, Math.max(0, kept.length - MAX_UNREADABLE_DEK_COPIES))) {
479
451
  try {
480
- fs.unlinkSync(path.join(dir, stale));
452
+ removeStateFile(path.join(dir, stale), 'prune a kept wrapped DEK');
481
453
  }
482
454
  catch {
483
455
  /* a copy that will not go costs 92 bytes */
@@ -486,6 +458,7 @@ function keepUnreadableWrappedDek(profile, wrapped) {
486
458
  return name;
487
459
  }
488
460
  catch (err) {
461
+ rethrowIfLiveStateRefusal(err);
489
462
  emit('cred_envelope.unreadable_dek_copy_failed', false, {
490
463
  profile,
491
464
  message: err?.message ?? 'unknown',
@@ -626,6 +599,9 @@ export async function initNewIndependentCredEnvelope(profile) {
626
599
  const dek = generateDek();
627
600
  let temp;
628
601
  try {
602
+ // Everything below creates, links and deletes inside `dir`, with raw flags
603
+ // (O_EXCL, link) the shared writers do not offer, so the refusal is asked once here.
604
+ assertMayMutateLiveState(dir, `enroll the wrapped DEK for background profile ${profile}`);
629
605
  const wrapped = await kek.wrap(dek, buildCredentialAad(profile));
630
606
  // Another enrollment may have won while the platform store was busy.
631
607
  // Its file is opened read-only; this process never overwrites it.
@@ -869,7 +845,7 @@ export async function initCredEnvelope(profile) {
869
845
  try {
870
846
  dek = generateDek();
871
847
  const wrapped = await kek.wrap(dek, aad);
872
- atomicWrite0600(targetPath, wrapped);
848
+ atomicWrite0600(targetPath, wrapped, `write the wrapped DEK for profile ${profile}`);
873
849
  emit('cred_envelope.dek_enrolled', true, {
874
850
  kekId: kek.id,
875
851
  kekKind: captureKind(),
@@ -878,6 +854,7 @@ export async function initCredEnvelope(profile) {
878
854
  });
879
855
  }
880
856
  catch (err) {
857
+ rethrowIfLiveStateRefusal(err);
881
858
  _state = { kind: 'disabled', reason: 'init_failed' };
882
859
  emit('cred_envelope.init_failed', false, {
883
860
  reason: 'enrollment_failed',
@@ -926,9 +903,10 @@ export async function resetCredEnvelope(opts) {
926
903
  ...retiredDekFiles(opts.profile),
927
904
  ]) {
928
905
  try {
929
- fs.unlinkSync(target);
906
+ removeStateFile(target, `delete the wrapped DEK file ${path.basename(target)}`);
930
907
  }
931
908
  catch (err) {
909
+ rethrowIfLiveStateRefusal(err);
932
910
  if (err.code !== 'ENOENT') {
933
911
  emit('cred_envelope.clear_failed', false, {
934
912
  profile: opts.profile,
@@ -1,4 +1,34 @@
1
- import { type DeviceKeyPair, type Ed25519PublicJwk } from './deviceKey.js';
1
+ import { type DeviceKeyAlg, type DevicePublicJwk } from './deviceKey.js';
2
+ import { type DeviceKeyCustody, type DeviceKeyHandle } from './deviceKeyHandle.js';
3
+ import { type HardwareKeyCapability, type HardwareKeyRecord } from './deviceKeyHardware.js';
4
+ /**
5
+ * The `cred_envelope.read.decrypt_failed` hint for the device PRIVATE key
6
+ * (OSK-20506).
7
+ *
8
+ * The envelope's default hint says "signing in again rebuilds it", which is true
9
+ * of a credential and false of this: `skrr login` never creates or replaces a
10
+ * device key, and only an interactive daemon sign-in does (it re-pins the new key
11
+ * on the server). `loadKeypair` hands this to `maybeDecryptOnRead` so the
12
+ * producer says WHAT failed; a presenter that already prints one device-key line
13
+ * (the CLI's `recovery_required` notice) recognises it by identity and prints
14
+ * nothing for the decrypt failure, instead of two lines with two remedies. A
15
+ * logger that prints every warning, the daemon's, shows this one as written.
16
+ *
17
+ * Compared by value by the CLI presenter: change the text and the CLI prints the
18
+ * credential line again, which `auth-core-init.device-key.spec.ts` catches.
19
+ */
20
+ export declare const DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT = "stored device key is unreadable on this machine \u2014 only an interactive daemon sign-in replaces it";
21
+ /**
22
+ * `key` is a handle, never a keypair: nothing in this module (or any caller of
23
+ * it) can read a private scalar once a key is active, which is what lets a
24
+ * hardware key stand in for a software one without a second code path.
25
+ *
26
+ * `envelopeProtected` is about the PRIVATE KEY FILE being sealed under the
27
+ * credential envelope. A hardware record is deliberately not sealed (it is
28
+ * useless off this machine, and sealing it would tie the device identity's life
29
+ * to the envelope key again), so it is always `false` for hardware and says
30
+ * nothing about how well that key is protected: read `key.custody` for that.
31
+ */
2
32
  type DeviceIdentityState = {
3
33
  kind: 'uninit';
4
34
  } | {
@@ -6,7 +36,7 @@ type DeviceIdentityState = {
6
36
  reason: DisabledReason;
7
37
  } | {
8
38
  kind: 'active';
9
- keypair: DeviceKeyPair;
39
+ key: DeviceKeyHandle;
10
40
  profile: string;
11
41
  envelopeProtected: boolean;
12
42
  };
@@ -69,6 +99,69 @@ export declare function resetDeviceIdentity(opts?: {
69
99
  profile?: string;
70
100
  clearOnDisk?: boolean;
71
101
  }): Promise<void>;
102
+ /**
103
+ * A hardware key that exists only in memory. It is not this machine's identity
104
+ * until `commitDeviceKeyCandidate`, and it leaves nothing behind if it is
105
+ * dropped: a Secure Enclave key lives in the blob and nowhere else.
106
+ *
107
+ * That is the whole safety argument for an upgrade. The server learns the new key
108
+ * from a proof signed with it (an interactive sign-in re-pins the row), so the
109
+ * order has to be: create the candidate, sign the sign-in with it, and only after
110
+ * the server has accepted that sign-in make it the identity. The software key
111
+ * stays on disk, untouched and active, through every step before the last one.
112
+ */
113
+ export interface DeviceKeyCandidate {
114
+ readonly key: DeviceKeyHandle;
115
+ /** @internal what `commitDeviceKeyCandidate` persists. */
116
+ readonly record: HardwareKeyRecord;
117
+ }
118
+ export type DeviceKeyCandidateResult = {
119
+ ok: true;
120
+ candidate: DeviceKeyCandidate;
121
+ } | {
122
+ ok: false;
123
+ reason: 'already_hardware' | 'independent_profile' | 'unavailable' | 'create_failed';
124
+ capability?: HardwareKeyCapability;
125
+ message?: string;
126
+ };
127
+ /**
128
+ * Create a hardware key candidate if this machine can hold one and does not
129
+ * already. Never throws and never touches disk, so an interactive sign-in can
130
+ * call it unconditionally and fall back to the key it has on any answer but ok.
131
+ */
132
+ export declare function prepareHardwareKeyCandidate(profile: string): Promise<DeviceKeyCandidateResult>;
133
+ /** Sign a request with a candidate (the upgrade's sign-in proof). Null on failure, like `signServerRequest`. */
134
+ export declare function signWithCandidate(candidate: DeviceKeyCandidate, opts: {
135
+ method: string;
136
+ url: string;
137
+ refreshToken?: string;
138
+ accessToken?: string;
139
+ }): Promise<string | null>;
140
+ /**
141
+ * Make a candidate this profile's device identity. Call it only after the
142
+ * server has accepted a sign-in proven with `candidate`: until then the software
143
+ * key is the identity the server pins, and replacing it early is the failure
144
+ * this API exists to prevent.
145
+ *
146
+ * Under the identity lock, in the order that survives a crash: retire the old
147
+ * key, write the hardware record (authoritative), then the public file, then
148
+ * clear the enrollment marker (it records the OLD thumbprint and would otherwise
149
+ * short-circuit every later enrollment as already done).
150
+ */
151
+ export declare function commitDeviceKeyCandidate(profile: string, candidate: DeviceKeyCandidate): Promise<void>;
152
+ /** A sign-in that takes longer than this has been abandoned; its pending key is not committed. */
153
+ export declare const PENDING_HARDWARE_KEY_MAX_AGE_MS: number;
154
+ /** Park a candidate so a later process can commit or discard it. Replaces any earlier one. */
155
+ export declare function persistPendingHardwareKey(profile: string, candidate: DeviceKeyCandidate): Promise<void>;
156
+ export type PendingHardwareKeyOutcome = 'committed' | 'no_pending' | 'mismatch' | 'expired' | 'unreadable';
157
+ /**
158
+ * Make the parked candidate this profile's identity, if it is the one the caller
159
+ * proved the sign-in with (`thumbprint`) and it is not stale. Anything else
160
+ * commits nothing: the software key stays the identity, and the answer says why.
161
+ */
162
+ export declare function commitPendingHardwareKey(profile: string, thumbprint: string): Promise<PendingHardwareKeyOutcome>;
163
+ /** Drop the parked candidate (the sign-in failed or was refused). Idempotent. */
164
+ export declare function discardPendingHardwareKey(profile: string): Promise<void>;
72
165
  /** True iff init has completed and a keypair is in memory. */
73
166
  export declare function isDeviceIdentityActive(): boolean;
74
167
  /**
@@ -77,8 +170,10 @@ export declare function isDeviceIdentityActive(): boolean;
77
170
  * on the in-flight request.
78
171
  */
79
172
  export declare function getDevicePublicKey(): {
80
- jwk: Ed25519PublicJwk;
173
+ jwk: DevicePublicJwk;
81
174
  thumbprint: string;
175
+ alg: DeviceKeyAlg;
176
+ custody: DeviceKeyCustody;
82
177
  } | null;
83
178
  /**
84
179
  * Operator-readable summary of device-identity state. Used by
@@ -90,13 +185,47 @@ export declare function describeDeviceIdentityState(): {
90
185
  thumbprint?: string;
91
186
  profile?: string;
92
187
  envelopeProtected?: boolean;
188
+ alg?: DeviceKeyAlg;
189
+ /** Whether the private half can be copied off this machine. Absent unless active. */
190
+ custody?: DeviceKeyCustody;
191
+ /**
192
+ * For a hardware key: can THIS process sign with it right now? False means the
193
+ * key exists and is intact but the helper that signs with it is not installed
194
+ * where this process can find it (a CLI without the desktop app's helper, an
195
+ * app update that has not landed). It is a different fact from an unreadable
196
+ * key, and it must never lead to replacing the key.
197
+ */
198
+ canSign?: boolean;
199
+ };
200
+ export type StoredDeviceKeyDescription = {
201
+ stored: false;
202
+ } | {
203
+ stored: true;
204
+ alg: DeviceKeyAlg;
205
+ custody: DeviceKeyCustody;
206
+ thumbprint: string;
207
+ /** A hardware key needs the helper to sign; a software key never does. */
208
+ canSign: boolean;
93
209
  };
210
+ /**
211
+ * What a profile's device key IS, read from its plaintext public record alone. For
212
+ * `status`, which is a different process from the daemon and may run while the
213
+ * daemon is stopped: it opens nothing, needs no envelope key and writes nothing, so
214
+ * it answers even when the private half is unreadable. Not evidence the key can be
215
+ * used (only `describeDeviceIdentityState` in the process that loaded it says that);
216
+ * evidence of what kind of key the profile has, which nothing else showed.
217
+ */
218
+ export declare function describeStoredDeviceKey(profile: string): StoredDeviceKeyDescription;
94
219
  /**
95
220
  * Sign an outgoing request with the device key. Returns the JWS Compact
96
221
  * Serialization, or null when device identity is not active. NEVER
97
222
  * throws — a failed sign returns null and emits a telemetry event so
98
223
  * the caller can fall back to bearer-only auth without an outage.
99
224
  *
225
+ * Asynchronous because a hardware key signs in a helper process. A software key
226
+ * resolves on the next microtask, so a caller that awaits pays nothing it can
227
+ * measure; a caller that could not await could not have a hardware key.
228
+ *
100
229
  * Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
101
230
  * passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
102
231
  */
@@ -105,7 +234,7 @@ export declare function signServerRequest(opts: {
105
234
  url: string;
106
235
  refreshToken?: string;
107
236
  accessToken?: string;
108
- }): string | null;
237
+ }): Promise<string | null>;
109
238
  /** @internal Test seams — file path resolvers. */
110
239
  export declare function __publicKeyPathForTest(profile: string): string;
111
240
  export declare function __privateKeyPathForTest(profile: string): string;