@huaweidevtools/aishell-dsh-credentials-store 0.0.15 → 0.0.17

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 (75) hide show
  1. package/lib/accounts.d.ts +31 -20
  2. package/lib/accounts.d.ts.map +1 -1
  3. package/lib/accounts.js +57 -24
  4. package/lib/accounts.js.map +1 -1
  5. package/lib/{index-JlyJE1E8.d.ts → index-Cdsu1kDC.d.ts} +87 -12
  6. package/lib/index-Cdsu1kDC.d.ts.map +1 -0
  7. package/lib/index.d.ts +3 -3
  8. package/lib/index.js +165 -29
  9. package/lib/index.js.map +1 -1
  10. package/lib/{paths-DtFRzop6.js → paths-C0wxA5LO.js} +3 -3
  11. package/lib/paths-C0wxA5LO.js.map +1 -0
  12. package/lib/paths.d.ts +10 -9
  13. package/lib/paths.d.ts.map +1 -1
  14. package/lib/paths.js +1 -1
  15. package/lib/{scope-CFetnbG3.d.ts → scope-C7w2eTvE.d.ts} +12 -12
  16. package/lib/{scope-CFetnbG3.d.ts.map → scope-C7w2eTvE.d.ts.map} +1 -1
  17. package/lib/{scope-QmrHZaZt.js → scope-DJcK27Uc.js} +12 -12
  18. package/lib/scope-DJcK27Uc.js.map +1 -0
  19. package/lib/scope.d.ts +1 -1
  20. package/lib/scope.js +1 -1
  21. package/lib/signer-Bx88x7dI.d.ts +72 -0
  22. package/lib/signer-Bx88x7dI.d.ts.map +1 -0
  23. package/lib/{signer-BPR0UAF0.js → signer-CVOjpi6y.js} +39 -3
  24. package/lib/signer-CVOjpi6y.js.map +1 -0
  25. package/lib/signer.d.ts +2 -2
  26. package/lib/signer.js +2 -2
  27. package/lib/types/accounts.d.ts +61 -39
  28. package/lib/types/accounts.d.ts.map +1 -1
  29. package/lib/types/accounts.js +108 -52
  30. package/lib/types/accounts.js.map +1 -1
  31. package/lib/types/backends/fingerprint.d.ts +78 -0
  32. package/lib/types/backends/fingerprint.d.ts.map +1 -1
  33. package/lib/types/backends/fingerprint.js +155 -8
  34. package/lib/types/backends/fingerprint.js.map +1 -1
  35. package/lib/types/backends/keyring.d.ts +7 -7
  36. package/lib/types/backends/keyring.d.ts.map +1 -1
  37. package/lib/types/backends/keyring.js +15 -9
  38. package/lib/types/backends/keyring.js.map +1 -1
  39. package/lib/types/backends/memory.d.ts +4 -17
  40. package/lib/types/backends/memory.d.ts.map +1 -1
  41. package/lib/types/backends/memory.js +21 -32
  42. package/lib/types/backends/memory.js.map +1 -1
  43. package/lib/types/failover.d.ts +16 -18
  44. package/lib/types/failover.d.ts.map +1 -1
  45. package/lib/types/failover.js +19 -21
  46. package/lib/types/failover.js.map +1 -1
  47. package/lib/types/index.d.ts +4 -4
  48. package/lib/types/index.d.ts.map +1 -1
  49. package/lib/types/index.js +4 -4
  50. package/lib/types/index.js.map +1 -1
  51. package/lib/types/paths.d.ts +10 -9
  52. package/lib/types/paths.d.ts.map +1 -1
  53. package/lib/types/paths.js +10 -9
  54. package/lib/types/paths.js.map +1 -1
  55. package/lib/types/scope.d.ts +11 -11
  56. package/lib/types/scope.js +11 -11
  57. package/lib/types/signer.d.ts +32 -0
  58. package/lib/types/signer.d.ts.map +1 -1
  59. package/lib/types/signer.js +40 -5
  60. package/lib/types/signer.js.map +1 -1
  61. package/lib/types/verify.d.ts +7 -11
  62. package/lib/types/verify.d.ts.map +1 -1
  63. package/lib/types/verify.js +27 -11
  64. package/lib/types/verify.js.map +1 -1
  65. package/lib/verify.d.ts +1 -1
  66. package/lib/verify.d.ts.map +1 -1
  67. package/lib/verify.js +11 -1
  68. package/lib/verify.js.map +1 -1
  69. package/package.json +1 -1
  70. package/lib/index-JlyJE1E8.d.ts.map +0 -1
  71. package/lib/paths-DtFRzop6.js.map +0 -1
  72. package/lib/scope-QmrHZaZt.js.map +0 -1
  73. package/lib/signer-BPR0UAF0.js.map +0 -1
  74. package/lib/signer-Et0DIH6o.d.ts +0 -40
  75. package/lib/signer-Et0DIH6o.d.ts.map +0 -1
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { CREDENTIALS_FILE_NAME, CREDENTIAL_SCOPE, DEFAULT_SCOPE, KEYRING_SERVICE } from "./scope-QmrHZaZt.js";
2
- import { credentialsDir } from "./paths-DtFRzop6.js";
1
+ import { CREDENTIALS_FILE_NAME, CREDENTIAL_SCOPE, DEFAULT_SCOPE, KEYRING_SERVICE } from "./scope-DJcK27Uc.js";
2
+ import { credentialsDir } from "./paths-C0wxA5LO.js";
3
3
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
4
4
  import { createRequire } from "node:module";
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import machineIdModule from "node-machine-id";
8
8
 
@@ -25,8 +25,8 @@ function loadKeyring() {
25
25
  * Construction probes the keyring once — if the native module cannot be loaded
26
26
  * (no prebuilt binding for this platform), or the Linux Secret Service is
27
27
  * unavailable, it throws and {@link createCredentialsStore} falls back to the
28
- * next backend. Without the pin the absent-service case does not throw; see
29
- * the module note.
28
+ * next backend. The pin is what makes the second case throw; see the module
29
+ * note.
30
30
  */
31
31
  var KeyringBackend = class {
32
32
  constructor(_config = {}) {
@@ -70,10 +70,37 @@ const SALT = "aishell-dsh/credentials/v1";
70
70
  const KEY_LEN = 32;
71
71
  const NONCE_LEN = 12;
72
72
  const TAG_LEN = 16;
73
+ /**
74
+ * scrypt cost parameters, stated rather than inherited.
75
+ *
76
+ * Passing no options lets the runtime's defaults decide, and those are not a
77
+ * promise: Node documents the defaults but does not freeze them, so a runtime
78
+ * that changed them would silently derive a different key and make every
79
+ * existing `credentials.enc` undecryptable. They are part of the on-disk
80
+ * format for the same reason {@link SALT} is — **changing any of them is a
81
+ * breaking change to every stored file**, not a tuning knob.
82
+ *
83
+ * These are the values the format was defined with, so writing them down keeps
84
+ * every existing file readable while removing the runtime from the equation.
85
+ */
86
+ const SCRYPT_PARAMS = {
87
+ N: 16384,
88
+ r: 8,
89
+ p: 1
90
+ };
91
+ /**
92
+ * How many quarantine filenames to try before giving up.
93
+ *
94
+ * Collisions are only possible when several saves land in the same millisecond
95
+ * on a machine whose last few saves all quarantined — which is already an
96
+ * unhealthy machine. The bound exists so that case reports itself instead of
97
+ * looping.
98
+ */
99
+ const QUARANTINE_NAME_ATTEMPTS = 16;
73
100
  /** Derive a 32-byte key from the machine UUID via scrypt. */
74
101
  function deriveKey() {
75
102
  const machineId = machineIdSync();
76
- return scryptSync(machineId, SALT, KEY_LEN);
103
+ return scryptSync(machineId, SALT, KEY_LEN, SCRYPT_PARAMS);
77
104
  }
78
105
  /** Encrypt plaintext → [nonce][tag][ciphertext] as a Buffer. */
79
106
  function fingerprintEncrypt(plaintext) {
@@ -114,6 +141,21 @@ function resolveCredentialsDir(config) {
114
141
  return config.credentialsDir ?? credentialsDir();
115
142
  }
116
143
  /**
144
+ * A credentials file that exists but cannot be decrypted or parsed.
145
+ *
146
+ * Its own type rather than a plain `Error` because the write path has to tell
147
+ * this case from every other failure: this is the one where overwriting the
148
+ * file would destroy credentials the person still has, so `set` moves it aside
149
+ * first. Anything else propagates unchanged.
150
+ */
151
+ var UnreadableCredentialsFile = class extends Error {
152
+ constructor(filePath, cause) {
153
+ super(`credentials file ${filePath} exists but could not be decrypted: ` + (cause instanceof Error ? cause.message : String(cause)));
154
+ this.filePath = filePath;
155
+ this.name = "UnreadableCredentialsFile";
156
+ }
157
+ };
158
+ /**
117
159
  * Encrypted-file {@link CredentialsStore}. Each account is one entry in a
118
160
  * single JSON map, encrypted as one file under the resolved credentials
119
161
  * directory, named by the build's credential scope (see `../scope.ts`).
@@ -133,17 +175,76 @@ var FingerprintBackend = class {
133
175
  * read, used, and dropped so plaintext does not linger in memory between
134
176
  * operations. The file I/O + scrypt decrypt per call is acceptable: this is
135
177
  * not a hot path (once per chat request, not per token).
178
+ *
179
+ * @throws {@link UnreadableCredentialsFile} when the file exists but cannot be
180
+ * decrypted or parsed. Callers decide what that means; see the note on
181
+ * {@link FingerprintBackend.loadForWrite}.
136
182
  */
137
183
  load() {
138
184
  if (!existsSync(this.filePath)) return new Map();
185
+ const encrypted = readFileSync(this.filePath);
139
186
  try {
140
- const encrypted = readFileSync(this.filePath);
141
187
  const plaintext = fingerprintDecrypt(encrypted);
142
188
  return new Map(Object.entries(JSON.parse(plaintext)));
143
- } catch {
189
+ } catch (error) {
190
+ throw new UnreadableCredentialsFile(this.filePath, error);
191
+ }
192
+ }
193
+ /**
194
+ * Load for a write, where an unreadable file must not be silently replaced.
195
+ *
196
+ * ## Why this is not just `load()` with the error swallowed
197
+ *
198
+ * `set` re-encrypts and rewrites the *whole* map. If load failed and returned
199
+ * empty, the next save would write a one-entry file over every other
200
+ * credential the person had stored — with no error, no backup and nothing to
201
+ * recover from. An unreadable file is not proof of an empty one: a truncated
202
+ * write, a machine-id that changed under the file, or a build whose salt
203
+ * moved all look identical from here.
204
+ *
205
+ * So the unreadable file is moved aside instead of overwritten. Saving
206
+ * therefore keeps working on a machine that inherited someone else's file —
207
+ * the documented VM-clone case — while whatever was in it stays on disk under
208
+ * a name that says what happened to it.
209
+ *
210
+ * @returns the current entries, or an empty map when the old file was moved
211
+ * aside.
212
+ */
213
+ loadForWrite() {
214
+ try {
215
+ return this.load();
216
+ } catch (error) {
217
+ if (!(error instanceof UnreadableCredentialsFile)) throw error;
218
+ this.quarantine(error);
144
219
  return new Map();
145
220
  }
146
221
  }
222
+ /**
223
+ * Move an unreadable file out of the way.
224
+ *
225
+ * The suffix carries a timestamp and, if that name is taken, a counter — the
226
+ * quarantine must never itself throw over a name collision and must never
227
+ * overwrite a previous one, since the files it preserves are the only copy of
228
+ * what was there. The counter is bounded because an unbounded retry loop over
229
+ * a filesystem is a hang, not a fallback; reaching the bound means something
230
+ * outside this process is wrong and saying so is better than spinning.
231
+ */
232
+ quarantine(cause) {
233
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
234
+ for (let attempt = 0; attempt < QUARANTINE_NAME_ATTEMPTS; attempt += 1) {
235
+ const suffix = attempt === 0 ? "" : `-${attempt}`;
236
+ const target = `${this.filePath}.unreadable-${stamp}${suffix}`;
237
+ if (existsSync(target)) continue;
238
+ try {
239
+ renameSync(this.filePath, target);
240
+ return;
241
+ } catch (error) {
242
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
243
+ throw new Error(`credentials file ${this.filePath} could not be read (${cause.message}), and could not be moved aside to ${target} either: ${String(error)}`);
244
+ }
245
+ }
246
+ throw new Error(`credentials file ${this.filePath} could not be read (${cause.message}), and every quarantine name is taken up to ${QUARANTINE_NAME_ATTEMPTS} attempts — move the file aside by hand before saving again.`);
247
+ }
147
248
  /** Encrypt + write the map to disk with owner-only permissions. */
148
249
  persist(entries) {
149
250
  const plaintext = JSON.stringify(Object.fromEntries(entries));
@@ -153,21 +254,54 @@ var FingerprintBackend = class {
153
254
  });
154
255
  writeFileSync(this.filePath, fingerprintEncrypt(plaintext), { mode: 384 });
155
256
  }
257
+ /**
258
+ * Read one entry.
259
+ *
260
+ * An unreadable file reads as absent rather than throwing. The alternative —
261
+ * failing every tool call on a parse error — leaves the person with no
262
+ * working path; "not authenticated" at least routes them to the form that can
263
+ * fix it. The write path is where the difference is preserved; see
264
+ * {@link FingerprintBackend.loadForWrite}.
265
+ */
156
266
  get(account) {
157
- return this.load().get(account);
267
+ try {
268
+ return this.load().get(account);
269
+ } catch {
270
+ return void 0;
271
+ }
158
272
  }
159
273
  set(account, value) {
160
274
  if (value.length === 0) throw new Error("credential value must be non-empty");
161
- const entries = this.load();
275
+ const entries = this.loadForWrite();
162
276
  entries.set(account, value);
163
277
  this.persist(entries);
164
278
  }
279
+ /**
280
+ * Remove one entry.
281
+ *
282
+ * An unreadable file **throws** rather than being a silent no-op. There is
283
+ * nothing to remove from a map that cannot be read, and answering "done"
284
+ * would be a clear that did not clear: the file stays on disk, and the
285
+ * moment whatever made it unreadable is undone — a restored machine id, a
286
+ * repaired file — the credential reads back. `clearCredentials` relies on
287
+ * exactly this signal to tell the person the clear failed.
288
+ *
289
+ * Quarantining instead is not an option here: it would move the file out of
290
+ * the way, which is the destruction this method must not perform — a delete
291
+ * asked for one entry, not for every entry.
292
+ *
293
+ * @throws {@link UnreadableCredentialsFile} when the file cannot be read.
294
+ */
165
295
  delete(account) {
166
296
  const entries = this.load();
167
297
  if (entries.delete(account)) this.persist(entries);
168
298
  }
169
299
  has(account) {
170
- return this.load().has(account);
300
+ try {
301
+ return this.load().has(account);
302
+ } catch {
303
+ return false;
304
+ }
171
305
  }
172
306
  };
173
307
 
@@ -193,26 +327,28 @@ var MemoryBackend = class {
193
327
  }
194
328
  };
195
329
  /**
196
- * The one memory backend the factory hands to every caller in this process.
330
+ * The one memory backend every caller in this process resolves to.
197
331
  *
198
- * ## Why a module-level instance and not a class singleton
332
+ * ## Why the factory shares an instance
199
333
  *
200
- * `createCredentialsStore()` is called once per plugin — four of them — and
201
- * the memory backend is the only one of the three that is not shared by
202
- * construction. The keyring is a daemon and the encrypted file is a path, so
203
- * two callers reaching either are reading the same bytes; two `Map`s are not.
204
- * Without this, a machine with no keyring would have one plugin storing a
205
- * credential and another unable to see it.
334
+ * Every plugin that stores credentials calls `createCredentialsStore()` for
335
+ * itself — four of them — and each call resolves a backend. The keyring is a
336
+ * daemon and the encrypted file is a path, so two callers reaching either read
337
+ * the same bytes by construction. A `Map` is not: with a fresh one per call,
338
+ * the plugin that saved a credential and the plugin that reads it would be
339
+ * looking at different maps, and the second would report "not authenticated"
340
+ * on a machine where the person had just authenticated.
206
341
  *
207
- * Held here rather than on the class so `new MemoryBackend()` stays
208
- * independent, which the specs depend on.
342
+ * The sharing lives here rather than in {@link MemoryBackend} so that
343
+ * `new MemoryBackend()` still produces an independent store, which is what the
344
+ * specs rely on: a spec that constructs one expects it to start empty, and a
345
+ * singleton class would leak state between spec files.
209
346
  *
210
- * ## Why the lazily-created shape
347
+ * ## Why it is created on first use
211
348
  *
212
- * The instance is created on first use rather than at module load, so
213
- * importing this module has no effect a test has to account for — the specs
214
- * that swap the keyring module in and out must not leave a populated store
215
- * behind.
349
+ * Creating it at module load would make importing this module mutate state a
350
+ * test then has to account for. Lazy creation keeps the import inert, so a spec
351
+ * that swaps the keyring module in and out leaves no store behind.
216
352
  *
217
353
  * @param config - forwarded on first construction only; later callers reuse
218
354
  * the instance, since the memory backend keeps nothing config decides.
@@ -256,9 +392,9 @@ const TOMBSTONE_VALUE = "1";
256
392
  *
257
393
  * Stickiness is per instance, not per process: each plugin that consumes
258
394
  * credentials builds its own store, so one instance failing over does not stop
259
- * another from trying the primary. That is intended — the flag only exists to
395
+ * another from trying the primary. That is intentional — the flag exists to
260
396
  * avoid re-hitting a primary already known to be unwritable from *this* store,
261
- * and a second instance's attempt is one probe rather than a policy decision.
397
+ * so a second instance's attempt is one probe rather than a policy decision.
262
398
  * Correctness across instances comes from the persisted record, not the flag.
263
399
  *
264
400
  * Constructed by {@link createCredentialsStore} around a KeyringBackend whose
@@ -391,5 +527,5 @@ function createFallbackStore(config) {
391
527
  }
392
528
 
393
529
  //#endregion
394
- export { CREDENTIALS_FILE_NAME, CREDENTIAL_SCOPE, DEFAULT_SCOPE, FailoverStore, FingerprintBackend, KEYRING_SERVICE, KeyringBackend, MemoryBackend, __resetSharedMemoryBackend, __setKeyringModule, createCredentialsStore, sharedMemoryBackend };
530
+ export { CREDENTIALS_FILE_NAME, CREDENTIAL_SCOPE, DEFAULT_SCOPE, FailoverStore, FingerprintBackend, KEYRING_SERVICE, KeyringBackend, MemoryBackend, UnreadableCredentialsFile, __resetSharedMemoryBackend, __setKeyringModule, createCredentialsStore, sharedMemoryBackend };
395
531
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["keyringModule: KeyringModule | undefined","mock: KeyringModule | undefined","_config: CredentialsStoreConfig","account: string","value: string","error: unknown","machineIdSync: () => string","plaintext: string","data: Buffer","config: CredentialsStoreConfig","entries: Map<string, string>","account: string","value: string","_config: CredentialsStoreConfig","account: string","value: string","shared: MemoryBackend | undefined","config: CredentialsStoreConfig","account: string","primary: CredentialsStore","options: FailoverStoreOptions","value: string","error: unknown","fallbackError: unknown","config: CredentialsStoreConfig","keyring: CredentialsStore | undefined"],"sources":["../src/backends/keyring.ts","../src/backends/fingerprint.ts","../src/backends/memory.ts","../src/failover.ts","../src/index.ts"],"sourcesContent":["/**\n * OS-keyring backend — the durable, cross-process credential store.\n *\n * Wraps `@napi-rs/keyring` (macOS Keychain / Linux Secret Service / Windows\n * Credential Manager). Values persist across process restarts and reboots\n * because the OS owns the store.\n *\n * ## Why Linux pins the Secret Service store\n *\n * On Linux the binding picks a store itself: Secret Service (gnome-keyring,\n * KWallet, KeePassXC) first, then — silently — the kernel keyutils keyring.\n * That fallback is wrong for credentials, and the binding's README says so:\n * the kernel keyring is \"completely in-memory and will not persist across\n * reboots\". A machine with no Secret Service (a container, CI, a systemd unit\n * with no session bus) would therefore accept credentials, report them stored,\n * and lose them at the next reboot — with `credentialsStored: true` left in\n * the profile pointing at nothing.\n *\n * Pinning `{ linux: { store: 'secret-service' } }` makes an unavailable\n * service throw from the constructor probe instead, so\n * {@link createCredentialsStore} falls through to the machine-fingerprint\n * encrypted file: durable, and honest about where the value lives. Requiring\n * the store is the only way to get that throw — the default selection reports\n * success either way.\n *\n * Native keyring errors are caught: `get`/`has` return `undefined`/`false` so\n * callers degrade gracefully; `set`/`delete` throw a descriptive error so the\n * caller knows the store is unavailable.\n */\n\nimport { createRequire } from 'node:module'\nimport { KEYRING_SERVICE } from '../scope.ts'\nimport type { CredentialsStore, CredentialsStoreConfig } from '../index.ts'\n\n// Lazily resolved so a missing native binding (the known @napi-rs optional-deps\n// bug, or a platform with no prebuilt binary) throws inside the constructor\n// probe — where createCredentialsStore's try/catch catches it and falls back\n// to FingerprintBackend — instead of crashing at module-load time.\nconst require = createRequire(import.meta.url)\ntype KeyringEntry = {\n getPassword: () => string | null\n setPassword: (value: string) => void\n deletePassword: () => void\n}\n/** Entry options bag; only the Linux store pin is used (see the module note). */\ntype KeyringEntryOptions = { linux?: { store?: 'secret-service' | 'keyutils' } }\ntype KeyringModule = {\n Entry: new (service: string, account: string, options?: KeyringEntryOptions) => KeyringEntry\n}\nlet keyringModule: KeyringModule | undefined\n\n/** @internal Test hook: inject a mock keyring module (vi.mock cannot intercept createRequire). */\nexport function __setKeyringModule(mock: KeyringModule | undefined): void {\n keyringModule = mock\n}\n\nfunction loadKeyring(): KeyringModule {\n if (keyringModule === undefined) keyringModule = require('@napi-rs/keyring') as KeyringModule\n return keyringModule\n}\n\n/**\n * OS-keyring {@link CredentialsStore}. Constructed with a shared service name;\n * every entry is stored under `new Entry(service, account, { linux: { store:\n * 'secret-service' } })`.\n *\n * Construction probes the keyring once — if the native module cannot be loaded\n * (no prebuilt binding for this platform), or the Linux Secret Service is\n * unavailable, it throws and {@link createCredentialsStore} falls back to the\n * next backend. Without the pin the absent-service case does not throw; see\n * the module note.\n */\nexport class KeyringBackend implements CredentialsStore {\n private readonly serviceName: string\n private readonly options: KeyringEntryOptions\n private readonly Entry: KeyringModule['Entry']\n\n constructor(_config: CredentialsStoreConfig = {}) {\n this.serviceName = KEYRING_SERVICE\n // Required, not preferred: an unavailable Secret Service must throw here so\n // the factory can fall through to a durable backend, rather than silently\n // landing in the kernel keyring's reboot-volatile store.\n this.options = { linux: { store: 'secret-service' } }\n this.Entry = loadKeyring().Entry\n // Probe: a getPassword on a throwaway account exercises the native path.\n new this.Entry(this.serviceName, '__probe__', this.options).getPassword()\n }\n\n get(account: string): string | undefined {\n try {\n const value = new this.Entry(this.serviceName, account, this.options).getPassword()\n return value ?? undefined\n } catch {\n return undefined\n }\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n new this.Entry(this.serviceName, account, this.options).setPassword(value)\n }\n\n delete(account: string): void {\n try {\n new this.Entry(this.serviceName, account, this.options).deletePassword()\n } catch (error: unknown) {\n // deletePassword on an absent entry may throw on some backends;\n // treat that as a no-op rather than an error.\n if (!String(error).includes('not found') && !String(error).includes('no entry')) {\n throw error\n }\n }\n }\n\n has(account: string): boolean {\n try {\n return new this.Entry(this.serviceName, account, this.options).getPassword() !== null\n } catch {\n return false\n }\n }\n}\n","/**\n * Machine-fingerprint-bound encrypted file backend.\n *\n * When the OS keyring is unavailable (headless Linux, Docker, no keyring\n * daemon), credentials are encrypted with a key derived from the machine UUID\n * (node-machine-id) via scrypt, then AES-256-GCM. File format:\n * [12B nonce][16B tag][ciphertext...].\n *\n * The key never touches disk; the ciphertext is useless on a different machine.\n * Same-user-same-machine is trusted. VM cloning copies the machine UUID — a\n * known limitation, not a security hole.\n */\n\nimport { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport machineIdModule from 'node-machine-id'\nimport { CREDENTIALS_FILE_NAME } from '../scope.ts'\nimport { credentialsDir } from '../paths.ts'\nimport type { CredentialsStore, CredentialsStoreConfig } from '../index.ts'\n\nconst machineIdSync: () => string = machineIdModule.machineIdSync\n\n// Deliberately NOT the keyring service name: this is the scrypt salt for\n// the encrypted file, and changing it would make every existing\n// `credentials.enc` undecryptable — the file would read as empty and the\n// credentials would look cleared. It is frozen.\nconst SALT = 'aishell-dsh/credentials/v1'\nconst KEY_LEN = 32 // AES-256\nconst NONCE_LEN = 12 // GCM standard (96-bit nonce)\nconst TAG_LEN = 16 // 128-bit auth tag\n\n/** Derive a 32-byte key from the machine UUID via scrypt. */\nfunction deriveKey(): Buffer {\n const machineId = machineIdSync()\n return scryptSync(machineId, SALT, KEY_LEN)\n}\n\n/** Encrypt plaintext → [nonce][tag][ciphertext] as a Buffer. */\nfunction fingerprintEncrypt(plaintext: string): Buffer {\n const key = deriveKey()\n const nonce = randomBytes(NONCE_LEN)\n const cipher = createCipheriv('aes-256-gcm', key, nonce)\n const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n return Buffer.concat([nonce, tag, ciphertext])\n}\n\n/** Decrypt [nonce][tag][ciphertext] → plaintext. Throws on tamper/wrong machine. */\nfunction fingerprintDecrypt(data: Buffer): string {\n if (data.length < NONCE_LEN + TAG_LEN) {\n throw new Error('credentials file corrupted (too short)')\n }\n const key = deriveKey()\n const nonce = data.subarray(0, NONCE_LEN)\n const tag = data.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN)\n const ciphertext = data.subarray(NONCE_LEN + TAG_LEN)\n const decipher = createDecipheriv('aes-256-gcm', key, nonce)\n decipher.setAuthTag(tag)\n const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])\n return plaintext.toString('utf8')\n}\n\n/**\n * Resolve the directory holding the encrypted credentials file.\n *\n * An explicit `config.credentialsDir` wins; otherwise `<DSH_HOME>/credentials`,\n * resolved by the shared `paths` module so the terraform cache in\n * `aishell-deploy` cannot drift to a different home.\n *\n * Builds are separated by the *file name* — see `../scope.ts` — not by the\n * directory.\n */\nfunction resolveCredentialsDir(config: CredentialsStoreConfig): string {\n return config.credentialsDir ?? credentialsDir()\n}\n\n/**\n * Encrypted-file {@link CredentialsStore}. Each account is one entry in a\n * single JSON map, encrypted as one file under the resolved credentials\n * directory, named by the build's credential scope (see `../scope.ts`).\n *\n * Construction probes the machine ID + crypto — if `node-machine-id` cannot\n * resolve a UUID, construction throws and the factory falls back to\n * {@link MemoryBackend}.\n */\nexport class FingerprintBackend implements CredentialsStore {\n private readonly filePath: string\n private readonly fileDir: string\n\n constructor(config: CredentialsStoreConfig = {}) {\n this.fileDir = resolveCredentialsDir(config)\n this.filePath = join(this.fileDir, CREDENTIALS_FILE_NAME)\n // Probe: deriveKey exercises node-machine-id. If it throws, the factory\n // falls back to MemoryBackend.\n deriveKey()\n }\n\n /**\n * Load + decrypt the file into a fresh map. Not cached — credentials are\n * read, used, and dropped so plaintext does not linger in memory between\n * operations. The file I/O + scrypt decrypt per call is acceptable: this is\n * not a hot path (once per chat request, not per token).\n */\n private load(): Map<string, string> {\n if (!existsSync(this.filePath)) return new Map()\n try {\n const encrypted = readFileSync(this.filePath)\n const plaintext = fingerprintDecrypt(encrypted)\n return new Map(Object.entries(JSON.parse(plaintext) as Record<string, string>))\n } catch {\n // Corrupted or wrong machine — treat as empty.\n return new Map()\n }\n }\n\n /** Encrypt + write the map to disk with owner-only permissions. */\n private persist(entries: Map<string, string>): void {\n const plaintext = JSON.stringify(Object.fromEntries(entries))\n mkdirSync(this.fileDir, { recursive: true, mode: 0o700 })\n // Write owner-only (0600); on platforms that ignore the mode (Windows)\n // the file is still encrypted, and ACLs inherit from the profile dir.\n writeFileSync(this.filePath, fingerprintEncrypt(plaintext), { mode: 0o600 })\n }\n\n get(account: string): string | undefined {\n return this.load().get(account)\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n const entries = this.load()\n entries.set(account, value)\n this.persist(entries)\n }\n\n delete(account: string): void {\n const entries = this.load()\n if (entries.delete(account)) {\n this.persist(entries)\n }\n }\n\n has(account: string): boolean {\n return this.load().has(account)\n }\n}\n","/**\n * In-memory backend — the volatile last-resort credential store.\n *\n * Used when the OS keyring is unavailable (no secret service, keychain\n * locked, headless box without D-Bus). Entries live in a process-scoped\n * `Map` and are lost when the process exits — durable enough for a single\n * DSH session, not for persistence.\n *\n * Callers never select this directly; {@link createCredentialsStore} falls\n * back to it when {@link KeyringBackend} cannot initialize.\n *\n * ## Why the shared instance is not this class's business\n *\n * Each plugin that stores credentials calls `createCredentialsStore()` for\n * itself — four of them — and every call constructs a fresh backend. That is\n * harmless for the keyring and the encrypted file, which are process-external\n * and therefore shared by construction, but a private `Map` is not: without\n * the sharing below, the plugin that saved a credential and the plugin that\n * read it would be looking at different maps, and the second would report\n * \"not authenticated\" on a machine where the person had just authenticated.\n *\n * The sharing lives in {@link sharedMemoryBackend} rather than in this class\n * so that `new MemoryBackend()` keeps producing an independent store. Tests\n * rely on that — a spec constructing one expects it to start empty, and a\n * singleton class would leak state between spec files.\n */\n\nimport type { CredentialsStore, CredentialsStoreConfig } from '../index.ts'\n\n/** Process-scoped {@link CredentialsStore}; entries vanish on exit. */\nexport class MemoryBackend implements CredentialsStore {\n private readonly entries = new Map<string, string>()\n\n constructor(_config: CredentialsStoreConfig = {}) {\n // Service name is accepted for API symmetry with KeyringBackend but not\n // used: the in-process Map is keyed by account alone.\n }\n\n get(account: string): string | undefined {\n return this.entries.get(account)\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n this.entries.set(account, value)\n }\n\n delete(account: string): void {\n this.entries.delete(account)\n }\n\n has(account: string): boolean {\n return this.entries.has(account)\n }\n}\n\n/**\n * The one memory backend the factory hands to every caller in this process.\n *\n * ## Why a module-level instance and not a class singleton\n *\n * `createCredentialsStore()` is called once per plugin — four of them — and\n * the memory backend is the only one of the three that is not shared by\n * construction. The keyring is a daemon and the encrypted file is a path, so\n * two callers reaching either are reading the same bytes; two `Map`s are not.\n * Without this, a machine with no keyring would have one plugin storing a\n * credential and another unable to see it.\n *\n * Held here rather than on the class so `new MemoryBackend()` stays\n * independent, which the specs depend on.\n *\n * ## Why the lazily-created shape\n *\n * The instance is created on first use rather than at module load, so\n * importing this module has no effect a test has to account for — the specs\n * that swap the keyring module in and out must not leave a populated store\n * behind.\n *\n * @param config - forwarded on first construction only; later callers reuse\n * the instance, since the memory backend keeps nothing config decides.\n * @returns the shared backend.\n */\nlet shared: MemoryBackend | undefined\n\nexport function sharedMemoryBackend(config: CredentialsStoreConfig = {}): MemoryBackend {\n shared ??= new MemoryBackend(config)\n return shared\n}\n\n/**\n * Drop the shared instance.\n *\n * For tests, which need each case to start from an empty store. Not called in\n * production: discarding the instance would discard every credential in it.\n */\nexport function __resetSharedMemoryBackend(): void {\n shared = undefined\n}\n","/**\n * Write-failover wrapper — covers the failure class the construction-time\n * probe chain cannot see.\n *\n * {@link createCredentialsStore} picks a backend by probing at construction\n * time, but the highest-priority backend (the OS keyring) can pass that probe\n * and still reject every real write: a locked GNOME keyring collection reads\n * as `null` (the probe's getPassword does not throw) while setPassword fails.\n * Measured on an isolated D-Bus session:\n *\n * - locked collection, no default alias — read 5 ms → null; write 5 ms →\n * \"Couldn't access platform storage: Secret Service: no result found\"\n * - locked collection, default alias set (the real-desktop shape) — read\n * still null; write blocks on the system unlock prompter and rejects with\n * \"Secret Service: unlock prompt was dismissed\" when canceled\n *\n * So the write path needs its own fallback chain, and it must be sticky: a\n * locked keyring fails every write, so retrying it per call would surface the\n * same error (or the same prompt) once per account a save writes. Deletes trip\n * the same failover: a locked collection rejects them with the same error, and\n * the save route (delete old set → write new set) deletes first — without\n * this, a locked keyring would fail the save before the write failover ever\n * engages.\n *\n * ## Which copy wins\n *\n * A locked collection answers `null` for entries that are really there, so the\n * primary's copy of an account can be arbitrarily out of date while still\n * reading as authoritative. Once it unlocks, that copy would outrank the value\n * the user saved in the meantime — resurrecting a credential the user rotated\n * or cleared, with every later signature using the superseded key.\n *\n * The two backends cannot be compared by recency (neither records one), so the\n * fallback is treated as authoritative for any account it has an opinion\n * about, and the primary answers only when the fallback has none:\n *\n * - **A value in the fallback wins.** It is there because a failover write\n * put it there, and that write is by definition newer than whatever the\n * primary holds. No bookkeeping is needed — the value shadows the primary\n * simply by existing.\n * - **A tombstone means deleted.** A clear whose primary delete failed has\n * nothing to shadow with, so it writes an explicit marker; without one, the\n * stale primary copy would read back as present.\n * - **Otherwise the primary answers**, which is the ordinary healthy path.\n *\n * A successful primary write or delete clears both the fallback value and the\n * tombstone, handing authority back to the keyring.\n *\n * Recording deletion this way — rather than marking accounts \"stale\" on every\n * failover write — keeps the value and its shadow in one place. A mark set by a\n * write would have to be cleared in lockstep with the value, and a mark left\n * behind after its value was gone would make a perfect keyring credential read\n * as *absent* rather than merely stale.\n *\n * ## Why the record is persisted, not held in memory\n *\n * The state it describes outlives the store: the failure is \"the keyring is\n * locked now\", and the read that would resurrect the credential happens in a\n * later session, after it has unlocked. It is therefore stored in the fallback\n * backend itself — the encrypted file in practice, and merely the process in\n * the memory-backed last resort, where the failover is no more durable either.\n * Keys carry a NUL prefix, which no real credential account can collide with.\n *\n * ## Cost on the healthy path\n *\n * The fallback is consulted on every read (once for a value, once for a\n * tombstone). On a machine whose keyring has never failed the fallback has\n * written nothing, so its file does not exist and both lookups are\n * sub-millisecond `existsSync` misses (measured 0.02-0.4 ms). Where a fallback\n * file does exist, each lookup decrypts it — the read-through this replaced\n * already paid one decrypt per miss.\n *\n * The fallback is built on the first read rather than the first failed write,\n * which a machine with a working keyring pays once per store: ~97 ms, almost\n * all of it the fingerprint backend's scrypt probe (measured). It is lazy and\n * cached, so later reads are unaffected.\n *\n * @module\n */\n\nimport type { CredentialsStore } from './index.ts'\n\n/**\n * Account-name prefix marking an account as deleted while the primary was\n * unreachable.\n *\n * The NUL byte cannot appear in a real account name (`HW_ACCESS_KEY`,\n * `HW_SECRET_KEY`, `HW_SECURITY_TOKEN`), so a tombstone can never be confused\n * with a credential. Tombstones are only ever written to the fallback, never\n * to the keyring.\n */\nconst TOMBSTONE_PREFIX = '\\u0000aishell:tombstone:'\n\n/** The fallback key recording that `account` was deleted. */\nconst tombstoneKey = (account: string): string => TOMBSTONE_PREFIX + account\n\n/** Tombstone value; never read, only tested for presence. */\nconst TOMBSTONE_VALUE = '1'\n\n/** Options for {@link FailoverStore}. */\nexport interface FailoverStoreOptions {\n /**\n * Builds the store that takes over when the primary fails a write. Called\n * at most once, lazily, on first need — a read miss or the first failed\n * write — so a healthy keyring never pays for constructing it. Must not\n * throw: the factory's closure ends in MemoryBackend.\n */\n readonly createFallback: () => CredentialsStore\n}\n\n/**\n * A {@link CredentialsStore} that delegates to a primary until the primary\n * fails a write, then fails over — for the life of this store — to a\n * fallback supplied by {@link FailoverStoreOptions.createFallback}.\n *\n * Stickiness is per instance, not per process: each plugin that consumes\n * credentials builds its own store, so one instance failing over does not stop\n * another from trying the primary. That is intended — the flag only exists to\n * avoid re-hitting a primary already known to be unwritable from *this* store,\n * and a second instance's attempt is one probe rather than a policy decision.\n * Correctness across instances comes from the persisted record, not the flag.\n *\n * Constructed by {@link createCredentialsStore} around a KeyringBackend whose\n * construction probe passed; not meant for direct use by upper layers.\n */\nexport class FailoverStore implements CredentialsStore {\n private readonly primary: CredentialsStore\n private readonly createFallback: () => CredentialsStore\n private fallback: CredentialsStore | undefined\n /** Set by the first failed primary write; routes writes thereafter. */\n private failedOver = false\n\n constructor(primary: CredentialsStore, options: FailoverStoreOptions) {\n this.primary = primary\n this.createFallback = options.createFallback\n }\n\n /** The lazily built fallback; constructed at most once per store. */\n private get fallbackStore(): CredentialsStore {\n if (this.fallback === undefined) this.fallback = this.createFallback()\n return this.fallback\n }\n\n /**\n * Drop the tombstone for `account`, if any.\n *\n * Paired with the value purge on every path that hands authority back to the\n * primary. Best-effort: a tombstone that outlives its purpose costs one extra\n * fallback lookup, which must never fail a write the keyring has accepted.\n */\n private clearTombstone(account: string): void {\n try {\n this.fallbackStore.delete(tombstoneKey(account))\n } catch {\n // Best-effort only.\n }\n }\n\n get(account: string): string | undefined {\n // Fallback value first: it is authoritative for any account a failover\n // write touched, and reading the primary first would let the keyring's\n // out-of-date copy win the moment it unlocked.\n const failedOverValue = this.fallbackStore.get(account)\n if (failedOverValue !== undefined) return failedOverValue\n if (this.fallbackStore.has(tombstoneKey(account))) return undefined\n return this.primary.get(account)\n }\n\n has(account: string): boolean {\n if (this.fallbackStore.has(account)) return true\n if (this.fallbackStore.has(tombstoneKey(account))) return false\n return this.primary.has(account)\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n if (this.failedOver) {\n // The primary has already proved it cannot be written, so the fallback\n // is the only destination left.\n this.fallbackStore.set(account, value)\n this.clearTombstone(account)\n return\n }\n try {\n this.primary.set(account, value)\n } catch (error: unknown) {\n // The primary answered its construction probe but cannot take writes\n // (locked collection, vanished secret service). Fail over — sticky, so\n // the remaining accounts of this save do not each hit the same error (or\n // the same unlock prompt) again — and put the value somewhere durable.\n this.failedOver = true\n try {\n this.fallbackStore.set(account, value)\n this.clearTombstone(account)\n } catch (fallbackError: unknown) {\n throw new Error(\n 'credential write failed over, but the fallback store rejected it too: '\n + `${String(fallbackError)} (primary error: ${String(error)})`,\n )\n }\n return\n }\n // The keyring took the write, so it is authoritative for this account\n // again: drop whatever an earlier failed-over session left in the fallback\n // — a value, which would otherwise be read in preference to this write, or\n // a tombstone, which would hide it. Best-effort: failing this cleanup must\n // not fail a save that already succeeded.\n try {\n this.fallbackStore.delete(account)\n } catch {\n // Best-effort purge only.\n }\n this.clearTombstone(account)\n }\n\n delete(account: string): void {\n // The value goes first: it is what a read would serve, and clearing it is\n // required regardless of what the primary does.\n try {\n this.fallbackStore.delete(account)\n } catch {\n // Best-effort; the tombstone below still masks the primary's copy.\n }\n if (this.failedOver) {\n // The primary cannot be written, so its copy cannot be removed now. Leave\n // a tombstone: with no value left in the fallback to shadow it, this\n // marker is the only record that the account is gone, and a later read\n // would otherwise serve the very credential being cleared.\n this.writeTombstone(account)\n return\n }\n try {\n this.primary.delete(account)\n this.clearTombstone(account)\n } catch {\n // A locked collection fails deletes as well as writes (\"no result found\"\n // — wording KeyringBackend's absent-entry no-op filter does not match),\n // and the save route deletes before it writes, so this must trip the\n // same sticky failover a failed write would. The copy we could not remove\n // is masked rather than left readable.\n this.failedOver = true\n this.writeTombstone(account)\n }\n }\n\n /**\n * Record the deletion, or report its failure.\n *\n * Unlike the best-effort cleanups above, a tombstone that cannot be written\n * means the delete silently did not happen and the credential will read back\n * later. That is a failed clear, so it throws rather than being swallowed.\n */\n private writeTombstone(account: string): void {\n this.fallbackStore.set(tombstoneKey(account), TOMBSTONE_VALUE)\n }\n}\n","/**\n * Credential storage abstraction for aishell DSH plugins.\n *\n * Upper layers (aishell-llm's adapter, aishell-credentials' host half)\n * call {@link createCredentialsStore} and get a {@link CredentialsStore} —\n * they never learn which backend is serving. The factory probes backends in\n * priority order (OS keyring first, process memory last) and returns the\n * first that answers. New backends (fingerprint + encrypted file, etc.) slot\n * into the chain without touching callers. A keyring that answers the probe\n * but later rejects writes fails over to the next backend on first failure.\n *\n * This is a tool library, not a DSH plugin — no `dsh.client`, no\n * `cordis.patch.yml`, no `apply`. DSH's own `ctx.credentials` is untouched;\n * user-configured models and `.credentials.yaml` keep working.\n *\n * @module @huaweidevtools/aishell-dsh-credentials-store\n */\n\nimport { KeyringBackend } from './backends/keyring.ts'\nimport { FingerprintBackend } from './backends/fingerprint.ts'\nimport { sharedMemoryBackend } from './backends/memory.ts'\nimport { FailoverStore } from './failover.ts'\n\n/** Options shared by every backend / the factory. */\nexport interface CredentialsStoreConfig {\n /**\n * Explicit directory for the fingerprint encrypted-file backend. When set,\n * overrides the default `$DSH_HOME/credentials` resolution. Callers rarely\n * need this — the host's `DSH_HOME` already places credentials alongside\n * its other data.\n */\n readonly credentialsDir?: string\n}\n\n/**\n * Read/write a named credential. Backends are synchronous and per-call — no\n * cache, so a credential changed from outside is visible on the next `get`.\n * Absent credentials read as `undefined` / `has === false`; a backend that\n * cannot serve (e.g. no D-Bus secret service) degrades the same way rather\n * than throwing on read.\n */\nexport interface CredentialsStore {\n /** Read one credential; `undefined` when absent or backend unavailable. */\n get(account: string): string | undefined\n /** Store one credential. Rejects an empty value; throws on backend errors. */\n set(account: string, value: string): void\n /** Remove one credential; absent is a no-op. Throws on backend errors. */\n delete(account: string): void\n /** Whether a credential is currently stored; `false` when absent or backend unavailable. */\n has(account: string): boolean\n}\n\n/**\n * Probe backends in priority order and return the first that answers.\n *\n * Priority: OS keyring (durable, cross-process) → machine-fingerprint\n * encrypted file (durable, same-machine) → process memory (volatile,\n * last resort). A backend that cannot initialize — no D-Bus secret service\n * on a headless Linux box, keychain locked, etc. — is skipped, not fatal.\n *\n * A keyring that passes the construction probe but is later found unwritable\n * (GNOME login keyring locked: reads come back null, writes fail — the probe\n * cannot see this) is wrapped in a {@link FailoverStore}: its first failed\n * write fails the whole process over to the fingerprint backend, so saving\n * credentials works instead of erroring. The wrapper also records which\n * accounts the keyring must not answer for, so a credential saved while the\n * collection was locked is not shadowed by the keyring's stale copy once it\n * unlocks.\n *\n * @returns the highest-priority backend that could initialize.\n */\nexport function createCredentialsStore(config: CredentialsStoreConfig = {}): CredentialsStore {\n const keyring: CredentialsStore | undefined = (() => {\n try {\n return new KeyringBackend(config)\n } catch {\n // OS keyring unavailable — try the fingerprint file backend.\n return undefined\n }\n })()\n if (keyring !== undefined) {\n // FingerprintBackend construction probes machine-id (deriveKey); if that\n // fails, MemoryBackend closes the chain — createFallback never throws.\n return new FailoverStore(keyring, { createFallback: () => createFallbackStore(config) })\n }\n return createFallbackStore(config)\n}\n\n/** Probe the durable-file and memory backends behind the keyring. */\nfunction createFallbackStore(config: CredentialsStoreConfig): CredentialsStore {\n try {\n return new FingerprintBackend(config)\n } catch {\n // Machine fingerprint unavailable (no machine ID) — fall back to memory.\n }\n // The shared instance, not a fresh one: the keyring and the encrypted file\n // are process-external and therefore already common to every caller, while a\n // private Map is not. Four plugins each call the factory, and on a machine\n // with no keyring they must still be reading one store. See\n // `./backends/memory.ts`.\n return sharedMemoryBackend(config)\n}\n\nexport { CREDENTIAL_SCOPE, CREDENTIALS_FILE_NAME, DEFAULT_SCOPE, KEYRING_SERVICE } from './scope.ts'\nexport { KeyringBackend, __setKeyringModule } from './backends/keyring.ts'\nexport { FingerprintBackend } from './backends/fingerprint.ts'\nexport { MemoryBackend, __resetSharedMemoryBackend, sharedMemoryBackend } from './backends/memory.ts'\nexport { FailoverStore } from './failover.ts'\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["keyringModule: KeyringModule | undefined","mock: KeyringModule | undefined","_config: CredentialsStoreConfig","account: string","value: string","error: unknown","machineIdSync: () => string","plaintext: string","data: Buffer","config: CredentialsStoreConfig","filePath: string","cause: unknown","error: unknown","cause: Error","entries: Map<string, string>","account: string","value: string","_config: CredentialsStoreConfig","account: string","value: string","shared: MemoryBackend | undefined","config: CredentialsStoreConfig","account: string","primary: CredentialsStore","options: FailoverStoreOptions","value: string","error: unknown","fallbackError: unknown","config: CredentialsStoreConfig","keyring: CredentialsStore | undefined"],"sources":["../src/backends/keyring.ts","../src/backends/fingerprint.ts","../src/backends/memory.ts","../src/failover.ts","../src/index.ts"],"sourcesContent":["/**\n * OS-keyring backend — the durable, cross-process credential store.\n *\n * Wraps `@napi-rs/keyring` (macOS Keychain / Linux Secret Service / Windows\n * Credential Manager). Values persist across process restarts and reboots\n * because the OS owns the store.\n *\n * ## Why Linux pins the Secret Service store\n *\n * On Linux the binding picks a store itself: Secret Service (gnome-keyring,\n * KWallet, KeePassXC) first, then — silently — the kernel keyutils keyring.\n * The kernel keyring is the wrong target for credentials, and the binding's own\n * README says so: it is \"completely in-memory and will not persist across\n * reboots\". A machine with no Secret Service (a container, CI, a systemd unit\n * with no session bus) would accept credentials, report them stored, and lose\n * them at the next reboot — with `credentialsStored: true` left in the profile\n * pointing at nothing.\n *\n * Pinning `{ linux: { store: 'secret-service' } }` makes an unavailable\n * service throw from the constructor probe instead, so\n * {@link createCredentialsStore} falls through to the machine-fingerprint\n * encrypted file: durable, and honest about where the value lives. Requiring\n * the store is the only way to get that throw — the default selection reports\n * success either way.\n *\n * Native keyring errors are caught: `get`/`has` return `undefined`/`false` so\n * callers degrade gracefully; `set`/`delete` throw a descriptive error so the\n * caller knows the store is unavailable.\n */\n\nimport { createRequire } from 'node:module'\nimport { KEYRING_SERVICE } from '../scope.ts'\nimport type { CredentialsStore, CredentialsStoreConfig } from '../index.ts'\n\n// Lazily resolved so a missing native binding (the known @napi-rs optional-deps\n// bug, or a platform with no prebuilt binary) throws inside the constructor\n// probe — where createCredentialsStore's try/catch catches it and falls back\n// to FingerprintBackend — instead of crashing at module-load time.\nconst require = createRequire(import.meta.url)\ntype KeyringEntry = {\n getPassword: () => string | null\n setPassword: (value: string) => void\n deletePassword: () => void\n}\n/** Entry options bag; only the Linux store pin is used (see the module note). */\ntype KeyringEntryOptions = { linux?: { store?: 'secret-service' | 'keyutils' } }\ntype KeyringModule = {\n Entry: new (service: string, account: string, options?: KeyringEntryOptions) => KeyringEntry\n}\nlet keyringModule: KeyringModule | undefined\n\n/** @internal Test hook: inject a mock keyring module (vi.mock cannot intercept createRequire). */\nexport function __setKeyringModule(mock: KeyringModule | undefined): void {\n keyringModule = mock\n}\n\nfunction loadKeyring(): KeyringModule {\n if (keyringModule === undefined) keyringModule = require('@napi-rs/keyring') as KeyringModule\n return keyringModule\n}\n\n/**\n * OS-keyring {@link CredentialsStore}. Constructed with a shared service name;\n * every entry is stored under `new Entry(service, account, { linux: { store:\n * 'secret-service' } })`.\n *\n * Construction probes the keyring once — if the native module cannot be loaded\n * (no prebuilt binding for this platform), or the Linux Secret Service is\n * unavailable, it throws and {@link createCredentialsStore} falls back to the\n * next backend. The pin is what makes the second case throw; see the module\n * note.\n */\nexport class KeyringBackend implements CredentialsStore {\n private readonly serviceName: string\n private readonly options: KeyringEntryOptions\n private readonly Entry: KeyringModule['Entry']\n\n constructor(_config: CredentialsStoreConfig = {}) {\n this.serviceName = KEYRING_SERVICE\n // Required, not preferred: an unavailable Secret Service must throw here so\n // the factory can fall through to a durable backend, rather than silently\n // landing in the kernel keyring's reboot-volatile store.\n this.options = { linux: { store: 'secret-service' } }\n this.Entry = loadKeyring().Entry\n // Probe: a getPassword on a throwaway account exercises the native path.\n new this.Entry(this.serviceName, '__probe__', this.options).getPassword()\n }\n\n get(account: string): string | undefined {\n try {\n const value = new this.Entry(this.serviceName, account, this.options).getPassword()\n return value ?? undefined\n } catch {\n return undefined\n }\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n new this.Entry(this.serviceName, account, this.options).setPassword(value)\n }\n\n delete(account: string): void {\n try {\n new this.Entry(this.serviceName, account, this.options).deletePassword()\n } catch (error: unknown) {\n // Deleting an absent entry is not an error, but the native binding has no\n // stable code for it — only wording. This filter is deliberately narrow:\n // it matches the *absent-entry* spellings and not \"no result found\",\n // which a **locked** Secret Service collection reports for entries that\n // really exist. Widening it to cover that would turn a failed clear into\n // a silent no-op, and the credential would keep reading back after the\n // person asked for it to be gone. `FailoverStore.delete` is what handles\n // the locked case — it needs this error to escape from here to see it.\n if (!String(error).includes('not found') && !String(error).includes('no entry')) {\n throw error\n }\n }\n }\n\n has(account: string): boolean {\n try {\n return new this.Entry(this.serviceName, account, this.options).getPassword() !== null\n } catch {\n return false\n }\n }\n}\n","/**\n * Machine-fingerprint-bound encrypted file backend.\n *\n * When the OS keyring is unavailable (headless Linux, Docker, no keyring\n * daemon), credentials are encrypted with a key derived from the machine UUID\n * (node-machine-id) via scrypt, then AES-256-GCM. File format:\n * [12B nonce][16B tag][ciphertext...].\n *\n * The key never touches disk; the ciphertext is useless on a different machine.\n * Same-user-same-machine is trusted. VM cloning copies the machine UUID — a\n * known limitation, not a security hole.\n */\n\nimport { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'\nimport { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'\nimport { join } from 'node:path'\nimport machineIdModule from 'node-machine-id'\nimport { CREDENTIALS_FILE_NAME } from '../scope.ts'\nimport { credentialsDir } from '../paths.ts'\nimport type { CredentialsStore, CredentialsStoreConfig } from '../index.ts'\n\nconst machineIdSync: () => string = machineIdModule.machineIdSync\n\n// Deliberately NOT the keyring service name: this is the scrypt salt for\n// the encrypted file, and changing it would make every existing\n// `credentials.enc` undecryptable — the file would read as empty and the\n// credentials would look cleared. It is frozen.\nconst SALT = 'aishell-dsh/credentials/v1'\nconst KEY_LEN = 32 // AES-256\nconst NONCE_LEN = 12 // GCM standard (96-bit nonce)\nconst TAG_LEN = 16 // 128-bit auth tag\n\n/**\n * scrypt cost parameters, stated rather than inherited.\n *\n * Passing no options lets the runtime's defaults decide, and those are not a\n * promise: Node documents the defaults but does not freeze them, so a runtime\n * that changed them would silently derive a different key and make every\n * existing `credentials.enc` undecryptable. They are part of the on-disk\n * format for the same reason {@link SALT} is — **changing any of them is a\n * breaking change to every stored file**, not a tuning knob.\n *\n * These are the values the format was defined with, so writing them down keeps\n * every existing file readable while removing the runtime from the equation.\n */\nconst SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const\n\n/**\n * How many quarantine filenames to try before giving up.\n *\n * Collisions are only possible when several saves land in the same millisecond\n * on a machine whose last few saves all quarantined — which is already an\n * unhealthy machine. The bound exists so that case reports itself instead of\n * looping.\n */\nconst QUARANTINE_NAME_ATTEMPTS = 16\n\n/** Derive a 32-byte key from the machine UUID via scrypt. */\nfunction deriveKey(): Buffer {\n const machineId = machineIdSync()\n return scryptSync(machineId, SALT, KEY_LEN, SCRYPT_PARAMS)\n}\n\n/** Encrypt plaintext → [nonce][tag][ciphertext] as a Buffer. */\nfunction fingerprintEncrypt(plaintext: string): Buffer {\n const key = deriveKey()\n const nonce = randomBytes(NONCE_LEN)\n const cipher = createCipheriv('aes-256-gcm', key, nonce)\n const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n return Buffer.concat([nonce, tag, ciphertext])\n}\n\n/** Decrypt [nonce][tag][ciphertext] → plaintext. Throws on tamper/wrong machine. */\nfunction fingerprintDecrypt(data: Buffer): string {\n if (data.length < NONCE_LEN + TAG_LEN) {\n throw new Error('credentials file corrupted (too short)')\n }\n const key = deriveKey()\n const nonce = data.subarray(0, NONCE_LEN)\n const tag = data.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN)\n const ciphertext = data.subarray(NONCE_LEN + TAG_LEN)\n const decipher = createDecipheriv('aes-256-gcm', key, nonce)\n decipher.setAuthTag(tag)\n const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])\n return plaintext.toString('utf8')\n}\n\n/**\n * Resolve the directory holding the encrypted credentials file.\n *\n * An explicit `config.credentialsDir` wins; otherwise `<DSH_HOME>/credentials`,\n * resolved by the shared `paths` module so the terraform cache in\n * `aishell-deploy` cannot drift to a different home.\n *\n * Builds are separated by the *file name* — see `../scope.ts` — not by the\n * directory.\n */\nfunction resolveCredentialsDir(config: CredentialsStoreConfig): string {\n return config.credentialsDir ?? credentialsDir()\n}\n\n/**\n * A credentials file that exists but cannot be decrypted or parsed.\n *\n * Its own type rather than a plain `Error` because the write path has to tell\n * this case from every other failure: this is the one where overwriting the\n * file would destroy credentials the person still has, so `set` moves it aside\n * first. Anything else propagates unchanged.\n */\nexport class UnreadableCredentialsFile extends Error {\n constructor(\n /** The file that could not be read. */\n readonly filePath: string,\n /** What decryption or parsing reported. */\n cause: unknown,\n ) {\n super(\n `credentials file ${filePath} exists but could not be decrypted: `\n + (cause instanceof Error ? cause.message : String(cause)),\n )\n this.name = 'UnreadableCredentialsFile'\n }\n}\n\n/**\n * Encrypted-file {@link CredentialsStore}. Each account is one entry in a\n * single JSON map, encrypted as one file under the resolved credentials\n * directory, named by the build's credential scope (see `../scope.ts`).\n *\n * Construction probes the machine ID + crypto — if `node-machine-id` cannot\n * resolve a UUID, construction throws and the factory falls back to\n * {@link MemoryBackend}.\n */\nexport class FingerprintBackend implements CredentialsStore {\n private readonly filePath: string\n private readonly fileDir: string\n\n constructor(config: CredentialsStoreConfig = {}) {\n this.fileDir = resolveCredentialsDir(config)\n this.filePath = join(this.fileDir, CREDENTIALS_FILE_NAME)\n // Probe: deriveKey exercises node-machine-id. If it throws, the factory\n // falls back to MemoryBackend.\n deriveKey()\n }\n\n /**\n * Load + decrypt the file into a fresh map. Not cached — credentials are\n * read, used, and dropped so plaintext does not linger in memory between\n * operations. The file I/O + scrypt decrypt per call is acceptable: this is\n * not a hot path (once per chat request, not per token).\n *\n * @throws {@link UnreadableCredentialsFile} when the file exists but cannot be\n * decrypted or parsed. Callers decide what that means; see the note on\n * {@link FingerprintBackend.loadForWrite}.\n */\n private load(): Map<string, string> {\n if (!existsSync(this.filePath)) return new Map()\n const encrypted = readFileSync(this.filePath)\n try {\n const plaintext = fingerprintDecrypt(encrypted)\n return new Map(Object.entries(JSON.parse(plaintext) as Record<string, string>))\n } catch (error) {\n throw new UnreadableCredentialsFile(this.filePath, error)\n }\n }\n\n /**\n * Load for a write, where an unreadable file must not be silently replaced.\n *\n * ## Why this is not just `load()` with the error swallowed\n *\n * `set` re-encrypts and rewrites the *whole* map. If load failed and returned\n * empty, the next save would write a one-entry file over every other\n * credential the person had stored — with no error, no backup and nothing to\n * recover from. An unreadable file is not proof of an empty one: a truncated\n * write, a machine-id that changed under the file, or a build whose salt\n * moved all look identical from here.\n *\n * So the unreadable file is moved aside instead of overwritten. Saving\n * therefore keeps working on a machine that inherited someone else's file —\n * the documented VM-clone case — while whatever was in it stays on disk under\n * a name that says what happened to it.\n *\n * @returns the current entries, or an empty map when the old file was moved\n * aside.\n */\n private loadForWrite(): Map<string, string> {\n try {\n return this.load()\n } catch (error: unknown) {\n if (!(error instanceof UnreadableCredentialsFile)) throw error\n this.quarantine(error)\n return new Map()\n }\n }\n\n /**\n * Move an unreadable file out of the way.\n *\n * The suffix carries a timestamp and, if that name is taken, a counter — the\n * quarantine must never itself throw over a name collision and must never\n * overwrite a previous one, since the files it preserves are the only copy of\n * what was there. The counter is bounded because an unbounded retry loop over\n * a filesystem is a hang, not a fallback; reaching the bound means something\n * outside this process is wrong and saying so is better than spinning.\n */\n private quarantine(cause: Error): void {\n const stamp = new Date().toISOString().replace(/[:.]/g, '-')\n for (let attempt = 0; attempt < QUARANTINE_NAME_ATTEMPTS; attempt += 1) {\n const suffix = attempt === 0 ? '' : `-${attempt}`\n const target = `${this.filePath}.unreadable-${stamp}${suffix}`\n if (existsSync(target)) continue\n try {\n renameSync(this.filePath, target)\n return\n } catch (error: unknown) {\n // Another process quarantined it first: nothing left to move, and the\n // write that follows is against a fresh file either way.\n if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return\n throw new Error(\n `credentials file ${this.filePath} could not be read (${cause.message}), `\n + `and could not be moved aside to ${target} either: ${String(error)}`,\n )\n }\n }\n throw new Error(\n `credentials file ${this.filePath} could not be read (${cause.message}), and every `\n + `quarantine name is taken up to ${QUARANTINE_NAME_ATTEMPTS} attempts — `\n + 'move the file aside by hand before saving again.',\n )\n }\n\n /** Encrypt + write the map to disk with owner-only permissions. */\n private persist(entries: Map<string, string>): void {\n const plaintext = JSON.stringify(Object.fromEntries(entries))\n mkdirSync(this.fileDir, { recursive: true, mode: 0o700 })\n // Write owner-only (0600); on platforms that ignore the mode (Windows)\n // the file is still encrypted, and ACLs inherit from the profile dir.\n writeFileSync(this.filePath, fingerprintEncrypt(plaintext), { mode: 0o600 })\n }\n\n /**\n * Read one entry.\n *\n * An unreadable file reads as absent rather than throwing. The alternative —\n * failing every tool call on a parse error — leaves the person with no\n * working path; \"not authenticated\" at least routes them to the form that can\n * fix it. The write path is where the difference is preserved; see\n * {@link FingerprintBackend.loadForWrite}.\n */\n get(account: string): string | undefined {\n try {\n return this.load().get(account)\n } catch {\n return undefined\n }\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n const entries = this.loadForWrite()\n entries.set(account, value)\n this.persist(entries)\n }\n\n /**\n * Remove one entry.\n *\n * An unreadable file **throws** rather than being a silent no-op. There is\n * nothing to remove from a map that cannot be read, and answering \"done\"\n * would be a clear that did not clear: the file stays on disk, and the\n * moment whatever made it unreadable is undone — a restored machine id, a\n * repaired file — the credential reads back. `clearCredentials` relies on\n * exactly this signal to tell the person the clear failed.\n *\n * Quarantining instead is not an option here: it would move the file out of\n * the way, which is the destruction this method must not perform — a delete\n * asked for one entry, not for every entry.\n *\n * @throws {@link UnreadableCredentialsFile} when the file cannot be read.\n */\n delete(account: string): void {\n const entries = this.load()\n if (entries.delete(account)) {\n this.persist(entries)\n }\n }\n\n has(account: string): boolean {\n try {\n return this.load().has(account)\n } catch {\n return false\n }\n }\n}\n","/**\n * In-memory backend — the volatile last-resort credential store.\n *\n * Used when the OS keyring is unavailable (no secret service, keychain\n * locked, headless box without D-Bus). Entries live in a process-scoped\n * `Map` and are lost when the process exits — durable enough for a single\n * DSH session, not for persistence.\n *\n * Two exports, and the difference between them is who shares what:\n * {@link MemoryBackend} is an independent store, and\n * {@link sharedMemoryBackend} is the one instance every caller in the process\n * resolves to. Its docstring carries the reasoning.\n */\n\nimport type { CredentialsStore, CredentialsStoreConfig } from '../index.ts'\n\n/** Process-scoped {@link CredentialsStore}; entries vanish on exit. */\nexport class MemoryBackend implements CredentialsStore {\n private readonly entries = new Map<string, string>()\n\n constructor(_config: CredentialsStoreConfig = {}) {\n // Service name is accepted for API symmetry with KeyringBackend but not\n // used: the in-process Map is keyed by account alone.\n }\n\n get(account: string): string | undefined {\n return this.entries.get(account)\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n this.entries.set(account, value)\n }\n\n delete(account: string): void {\n this.entries.delete(account)\n }\n\n has(account: string): boolean {\n return this.entries.has(account)\n }\n}\n\n/**\n * The one memory backend every caller in this process resolves to.\n *\n * ## Why the factory shares an instance\n *\n * Every plugin that stores credentials calls `createCredentialsStore()` for\n * itself — four of them — and each call resolves a backend. The keyring is a\n * daemon and the encrypted file is a path, so two callers reaching either read\n * the same bytes by construction. A `Map` is not: with a fresh one per call,\n * the plugin that saved a credential and the plugin that reads it would be\n * looking at different maps, and the second would report \"not authenticated\"\n * on a machine where the person had just authenticated.\n *\n * The sharing lives here rather than in {@link MemoryBackend} so that\n * `new MemoryBackend()` still produces an independent store, which is what the\n * specs rely on: a spec that constructs one expects it to start empty, and a\n * singleton class would leak state between spec files.\n *\n * ## Why it is created on first use\n *\n * Creating it at module load would make importing this module mutate state a\n * test then has to account for. Lazy creation keeps the import inert, so a spec\n * that swaps the keyring module in and out leaves no store behind.\n *\n * @param config - forwarded on first construction only; later callers reuse\n * the instance, since the memory backend keeps nothing config decides.\n * @returns the shared backend.\n */\nlet shared: MemoryBackend | undefined\n\nexport function sharedMemoryBackend(config: CredentialsStoreConfig = {}): MemoryBackend {\n shared ??= new MemoryBackend(config)\n return shared\n}\n\n/**\n * Drop the shared instance.\n *\n * For tests, which need each case to start from an empty store. Not called in\n * production: discarding the instance would discard every credential in it.\n */\nexport function __resetSharedMemoryBackend(): void {\n shared = undefined\n}\n","/**\n * Write-failover wrapper — covers the failure class a construction-time probe\n * cannot see.\n *\n * {@link createCredentialsStore} picks a backend by probing at construction\n * time, but the highest-priority backend (the OS keyring) can pass that probe\n * and still reject every real write: a locked GNOME keyring collection reads as\n * `null` and its `setPassword` fails. Measured on an isolated D-Bus session:\n *\n * - locked collection, no default alias — read 5 ms → null; write 5 ms →\n * \"Couldn't access platform storage: Secret Service: no result found\"\n * - locked collection, default alias set (the real-desktop shape) — read\n * still null; write blocks on the system unlock prompter and rejects with\n * \"Secret Service: unlock prompt was dismissed\" when canceled\n *\n * So the write path needs its own fallback chain, and it must be sticky: a\n * locked keyring fails every write, so retrying it per call would surface the\n * same error (or the same prompt) once per account a save writes. Deletes trip\n * the same failover — a locked collection rejects them with the same error, and\n * the save route deletes the old set before writing the new one, so without\n * this a locked keyring would fail the save before the write failover engaged.\n *\n * ## Which copy wins\n *\n * A locked collection answers `null` for entries that are really there, so the\n * primary's copy of an account can be arbitrarily out of date while still\n * reading as authoritative. Once it unlocks, that copy would outrank the value\n * the user saved in the meantime — resurrecting a credential the user rotated\n * or cleared, with every later signature using the superseded key.\n *\n * The two backends cannot be compared by recency (neither records one), so the\n * fallback is treated as authoritative for any account it has an opinion\n * about, and the primary answers only when the fallback has none:\n *\n * - **A value in the fallback wins.** It is there because a failover write\n * put it there, and that write is by definition newer than whatever the\n * primary holds. No bookkeeping is needed — the value shadows the primary\n * simply by existing.\n * - **A tombstone means deleted.** A clear whose primary delete failed has\n * nothing to shadow with, so it writes an explicit marker; without one, the\n * stale primary copy would read back as present.\n * - **Otherwise the primary answers**, which is the ordinary healthy path.\n *\n * A successful primary write or delete clears both the fallback value and the\n * tombstone, handing authority back to the keyring.\n *\n * The alternative — marking accounts \"stale\" on every failover write — would\n * spread one fact across two records: the mark would have to be cleared in\n * lockstep with the value, and a mark left behind after its value was gone\n * would make a perfect keyring credential read as *absent* rather than merely\n * stale. Keeping the shadow in one place avoids that state entirely.\n *\n * ## Why the record is persisted, not held in memory\n *\n * The state it describes outlives the store: the failure is \"the keyring is\n * locked now\", and the read that would resurrect the credential happens in a\n * later session, after it has unlocked. It is therefore stored in the fallback\n * backend itself — the encrypted file in practice, and merely the process in\n * the memory-backed last resort, where the failover is no more durable either.\n * Keys carry a NUL prefix, which no real credential account can collide with.\n *\n * ## Cost on the healthy path\n *\n * The fallback is consulted on every read (once for a value, once for a\n * tombstone). On a machine whose keyring has never failed the fallback has\n * written nothing, so its file does not exist and both lookups are\n * sub-millisecond `existsSync` misses (measured 0.02-0.4 ms). Where a fallback\n * file does exist, each lookup decrypts it — one decrypt per miss, which is what\n * a read-through cache would cost anyway.\n *\n * The fallback is built on the first read rather than the first failed write,\n * which a machine with a working keyring pays once per store: ~97 ms, almost\n * all of it the fingerprint backend's scrypt probe (measured). It is lazy and\n * cached, so later reads are unaffected.\n *\n * @module\n */\n\nimport type { CredentialsStore } from './index.ts'\n\n/**\n * Account-name prefix marking an account as deleted while the primary was\n * unreachable.\n *\n * The NUL byte cannot appear in a real account name (`HW_ACCESS_KEY`,\n * `HW_SECRET_KEY`, `HW_SECURITY_TOKEN`), so a tombstone can never be confused\n * with a credential. Tombstones are only ever written to the fallback, never\n * to the keyring.\n */\nconst TOMBSTONE_PREFIX = '\\u0000aishell:tombstone:'\n\n/** The fallback key recording that `account` was deleted. */\nconst tombstoneKey = (account: string): string => TOMBSTONE_PREFIX + account\n\n/** Tombstone value; never read, only tested for presence. */\nconst TOMBSTONE_VALUE = '1'\n\n/** Options for {@link FailoverStore}. */\nexport interface FailoverStoreOptions {\n /**\n * Builds the store that takes over when the primary fails a write. Called\n * at most once, lazily, on first need — a read miss or the first failed\n * write — so a healthy keyring never pays for constructing it. Must not\n * throw: the factory's closure ends in MemoryBackend.\n */\n readonly createFallback: () => CredentialsStore\n}\n\n/**\n * A {@link CredentialsStore} that delegates to a primary until the primary\n * fails a write, then fails over — for the life of this store — to a\n * fallback supplied by {@link FailoverStoreOptions.createFallback}.\n *\n * Stickiness is per instance, not per process: each plugin that consumes\n * credentials builds its own store, so one instance failing over does not stop\n * another from trying the primary. That is intentional — the flag exists to\n * avoid re-hitting a primary already known to be unwritable from *this* store,\n * so a second instance's attempt is one probe rather than a policy decision.\n * Correctness across instances comes from the persisted record, not the flag.\n *\n * Constructed by {@link createCredentialsStore} around a KeyringBackend whose\n * construction probe passed; not meant for direct use by upper layers.\n */\nexport class FailoverStore implements CredentialsStore {\n private readonly primary: CredentialsStore\n private readonly createFallback: () => CredentialsStore\n private fallback: CredentialsStore | undefined\n /** Set by the first failed primary write; routes writes thereafter. */\n private failedOver = false\n\n constructor(primary: CredentialsStore, options: FailoverStoreOptions) {\n this.primary = primary\n this.createFallback = options.createFallback\n }\n\n /** The lazily built fallback; constructed at most once per store. */\n private get fallbackStore(): CredentialsStore {\n if (this.fallback === undefined) this.fallback = this.createFallback()\n return this.fallback\n }\n\n /**\n * Drop the tombstone for `account`, if any.\n *\n * Paired with the value purge on every path that hands authority back to the\n * primary. Best-effort: a tombstone that outlives its purpose costs one extra\n * fallback lookup, which must never fail a write the keyring has accepted.\n */\n private clearTombstone(account: string): void {\n try {\n this.fallbackStore.delete(tombstoneKey(account))\n } catch {\n // Best-effort only.\n }\n }\n\n get(account: string): string | undefined {\n // Fallback value first: it is authoritative for any account a failover\n // write touched, and reading the primary first would let the keyring's\n // out-of-date copy win the moment it unlocked.\n const failedOverValue = this.fallbackStore.get(account)\n if (failedOverValue !== undefined) return failedOverValue\n if (this.fallbackStore.has(tombstoneKey(account))) return undefined\n return this.primary.get(account)\n }\n\n has(account: string): boolean {\n if (this.fallbackStore.has(account)) return true\n if (this.fallbackStore.has(tombstoneKey(account))) return false\n return this.primary.has(account)\n }\n\n set(account: string, value: string): void {\n if (value.length === 0) throw new Error('credential value must be non-empty')\n if (this.failedOver) {\n // The primary has already proved it cannot be written, so the fallback\n // is the only destination left.\n this.fallbackStore.set(account, value)\n this.clearTombstone(account)\n return\n }\n try {\n this.primary.set(account, value)\n } catch (error: unknown) {\n // The primary answered its construction probe but cannot take writes\n // (locked collection, vanished secret service). Fail over — sticky, so\n // the remaining accounts of this save do not each hit the same error (or\n // the same unlock prompt) again — and put the value somewhere durable.\n this.failedOver = true\n try {\n this.fallbackStore.set(account, value)\n this.clearTombstone(account)\n } catch (fallbackError: unknown) {\n throw new Error(\n 'credential write failed over, but the fallback store rejected it too: '\n + `${String(fallbackError)} (primary error: ${String(error)})`,\n )\n }\n return\n }\n // The keyring took the write, so it is authoritative for this account\n // again: drop whatever a failed-over session left in the fallback — a\n // value, which would otherwise be read in preference to this write, or a\n // tombstone, which would hide it. Best-effort: failing this cleanup must\n // not fail a save that already succeeded.\n try {\n this.fallbackStore.delete(account)\n } catch {\n // Best-effort purge only.\n }\n this.clearTombstone(account)\n }\n\n delete(account: string): void {\n // The value goes first: it is what a read would serve, and clearing it is\n // required regardless of what the primary does.\n try {\n this.fallbackStore.delete(account)\n } catch {\n // Best-effort; the tombstone below still masks the primary's copy.\n }\n if (this.failedOver) {\n // The primary cannot be written, so its copy cannot be removed now. Leave\n // a tombstone: with no value left in the fallback to shadow it, this\n // marker is the only record that the account is gone, and a later read\n // would otherwise serve the very credential being cleared.\n this.writeTombstone(account)\n return\n }\n try {\n this.primary.delete(account)\n this.clearTombstone(account)\n } catch {\n // A locked collection fails deletes as well as writes (\"no result found\"\n // — wording KeyringBackend's absent-entry no-op filter does not match),\n // and the save route deletes before it writes, so this must trip the\n // same sticky failover a failed write would. The copy we could not remove\n // is masked rather than left readable.\n this.failedOver = true\n this.writeTombstone(account)\n }\n }\n\n /**\n * Record the deletion, or report its failure.\n *\n * Unlike the best-effort cleanups above, a tombstone that cannot be written\n * means the delete silently did not happen and the credential will read back\n * later. That is a failed clear, so it throws rather than being swallowed.\n */\n private writeTombstone(account: string): void {\n this.fallbackStore.set(tombstoneKey(account), TOMBSTONE_VALUE)\n }\n}\n","/**\n * Credential storage abstraction for aishell DSH plugins.\n *\n * Upper layers (aishell-llm's adapter, aishell-credentials' host half)\n * call {@link createCredentialsStore} and get a {@link CredentialsStore} —\n * they never learn which backend is serving. The factory probes backends in\n * priority order (OS keyring first, process memory last) and returns the\n * first that answers, so a backend can be added to the chain without any\n * caller changing. A keyring that answers the probe but later rejects writes\n * fails over to the next backend on first failure.\n *\n * This is a tool library, not a DSH plugin — no `dsh.client`, no\n * `cordis.patch.yml`, no `apply`. DSH's own `ctx.credentials` is untouched;\n * user-configured models and `.credentials.yaml` keep working.\n *\n * @module @huaweidevtools/aishell-dsh-credentials-store\n */\n\nimport { KeyringBackend } from './backends/keyring.ts'\nimport { FingerprintBackend } from './backends/fingerprint.ts'\nimport { sharedMemoryBackend } from './backends/memory.ts'\nimport { FailoverStore } from './failover.ts'\n\n/** Options shared by every backend / the factory. */\nexport interface CredentialsStoreConfig {\n /**\n * Explicit directory for the fingerprint encrypted-file backend. When set,\n * overrides the default `$DSH_HOME/credentials` resolution. Callers rarely\n * need this — the host's `DSH_HOME` already places credentials alongside\n * its other data.\n */\n readonly credentialsDir?: string\n}\n\n/**\n * Read/write a named credential. Backends are synchronous and per-call — no\n * cache, so a credential changed from outside is visible on the next `get`.\n * Absent credentials read as `undefined` / `has === false`; a backend that\n * cannot serve (e.g. no D-Bus secret service) degrades the same way rather\n * than throwing on read.\n */\nexport interface CredentialsStore {\n /** Read one credential; `undefined` when absent or backend unavailable. */\n get(account: string): string | undefined\n /** Store one credential. Rejects an empty value; throws on backend errors. */\n set(account: string, value: string): void\n /** Remove one credential; absent is a no-op. Throws on backend errors. */\n delete(account: string): void\n /** Whether a credential is currently stored; `false` when absent or backend unavailable. */\n has(account: string): boolean\n}\n\n/**\n * Probe backends in priority order and return the first that answers.\n *\n * Priority: OS keyring (durable, cross-process) → machine-fingerprint\n * encrypted file (durable, same-machine) → process memory (volatile,\n * last resort). A backend that cannot initialize — no D-Bus secret service\n * on a headless Linux box, keychain locked, etc. — is skipped, not fatal.\n *\n * A keyring that passes the construction probe but is later found unwritable\n * (GNOME login keyring locked: reads come back null, writes fail — the probe\n * cannot see this) is wrapped in a {@link FailoverStore}: its first failed\n * write fails the whole process over to the fingerprint backend, so saving\n * credentials works instead of erroring. The wrapper also records which\n * accounts the keyring must not answer for, so a credential saved while the\n * collection was locked is not shadowed by the keyring's stale copy once it\n * unlocks.\n *\n * @returns the highest-priority backend that could initialize.\n */\nexport function createCredentialsStore(config: CredentialsStoreConfig = {}): CredentialsStore {\n const keyring: CredentialsStore | undefined = (() => {\n try {\n return new KeyringBackend(config)\n } catch {\n // OS keyring unavailable — try the fingerprint file backend.\n return undefined\n }\n })()\n if (keyring !== undefined) {\n // FingerprintBackend construction probes machine-id (deriveKey); if that\n // fails, MemoryBackend closes the chain — createFallback never throws.\n return new FailoverStore(keyring, { createFallback: () => createFallbackStore(config) })\n }\n return createFallbackStore(config)\n}\n\n/** Probe the durable-file and memory backends behind the keyring. */\nfunction createFallbackStore(config: CredentialsStoreConfig): CredentialsStore {\n try {\n return new FingerprintBackend(config)\n } catch {\n // Machine fingerprint unavailable (no machine ID) — fall back to memory.\n }\n // The shared instance, not a fresh one: the keyring and the encrypted file\n // are process-external and therefore already common to every caller, while a\n // private Map is not. Four plugins each call the factory, and on a machine\n // with no keyring they must still be reading one store. See\n // `./backends/memory.ts`.\n return sharedMemoryBackend(config)\n}\n\nexport { CREDENTIAL_SCOPE, CREDENTIALS_FILE_NAME, DEFAULT_SCOPE, KEYRING_SERVICE } from './scope.ts'\nexport { KeyringBackend, __setKeyringModule } from './backends/keyring.ts'\nexport { FingerprintBackend, UnreadableCredentialsFile } from './backends/fingerprint.ts'\nexport { MemoryBackend, __resetSharedMemoryBackend, sharedMemoryBackend } from './backends/memory.ts'\nexport { FailoverStore } from './failover.ts'\n"],"mappings":";;;;;;;;;AAsCA,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;AAW9C,IAAIA;;AAGJ,SAAgB,mBAAmBC,MAAuC;AACxE,iBAAgB;AACjB;AAED,SAAS,cAA6B;AACpC,KAAI,yBAA6B,iBAAgB,QAAQ,mBAAmB;AAC5E,QAAO;AACR;;;;;;;;;;;;AAaD,IAAa,iBAAb,MAAwD;CAKtD,YAAYC,UAAkC,CAAE,GAAE;AAChD,OAAK,cAAc;AAInB,OAAK,UAAU,EAAE,OAAO,EAAE,OAAO,iBAAkB,EAAE;AACrD,OAAK,QAAQ,aAAa,CAAC;AAE3B,MAAI,KAAK,MAAM,KAAK,aAAa,aAAa,KAAK,SAAS,aAAa;CAC1E;CAED,IAAIC,SAAqC;AACvC,MAAI;GACF,MAAM,QAAQ,IAAI,KAAK,MAAM,KAAK,aAAa,SAAS,KAAK,SAAS,aAAa;AACnF,UAAO;EACR,QAAO;AACN;EACD;CACF;CAED,IAAIA,SAAiBC,OAAqB;AACxC,MAAI,MAAM,WAAW,EAAG,OAAM,IAAI,MAAM;AACxC,MAAI,KAAK,MAAM,KAAK,aAAa,SAAS,KAAK,SAAS,YAAY,MAAM;CAC3E;CAED,OAAOD,SAAuB;AAC5B,MAAI;AACF,OAAI,KAAK,MAAM,KAAK,aAAa,SAAS,KAAK,SAAS,gBAAgB;EACzE,SAAQE,OAAgB;AASvB,QAAK,OAAO,MAAM,CAAC,SAAS,YAAY,KAAK,OAAO,MAAM,CAAC,SAAS,WAAW,CAC7E,OAAM;EAET;CACF;CAED,IAAIF,SAA0B;AAC5B,MAAI;AACF,UAAO,IAAI,KAAK,MAAM,KAAK,aAAa,SAAS,KAAK,SAAS,aAAa,KAAK;EAClF,QAAO;AACN,UAAO;EACR;CACF;AACF;;;;AC1GD,MAAMG,gBAA8B,gBAAgB;AAMpD,MAAM,OAAO;AACb,MAAM,UAAU;AAChB,MAAM,YAAY;AAClB,MAAM,UAAU;;;;;;;;;;;;;;AAehB,MAAM,gBAAgB;CAAE,GAAG;CAAO,GAAG;CAAG,GAAG;AAAG;;;;;;;;;AAU9C,MAAM,2BAA2B;;AAGjC,SAAS,YAAoB;CAC3B,MAAM,YAAY,eAAe;AACjC,QAAO,WAAW,WAAW,MAAM,SAAS,cAAc;AAC3D;;AAGD,SAAS,mBAAmBC,WAA2B;CACrD,MAAM,MAAM,WAAW;CACvB,MAAM,QAAQ,YAAY,UAAU;CACpC,MAAM,SAAS,eAAe,eAAe,KAAK,MAAM;CACxD,MAAM,aAAa,OAAO,OAAO,CAAC,OAAO,OAAO,WAAW,OAAO,EAAE,OAAO,OAAO,AAAC,EAAC;CACpF,MAAM,MAAM,OAAO,YAAY;AAC/B,QAAO,OAAO,OAAO;EAAC;EAAO;EAAK;CAAW,EAAC;AAC/C;;AAGD,SAAS,mBAAmBC,MAAsB;AAChD,KAAI,KAAK,SAAS,YAAY,QAC5B,OAAM,IAAI,MAAM;CAElB,MAAM,MAAM,WAAW;CACvB,MAAM,QAAQ,KAAK,SAAS,GAAG,UAAU;CACzC,MAAM,MAAM,KAAK,SAAS,WAAW,YAAY,QAAQ;CACzD,MAAM,aAAa,KAAK,SAAS,YAAY,QAAQ;CACrD,MAAM,WAAW,iBAAiB,eAAe,KAAK,MAAM;AAC5D,UAAS,WAAW,IAAI;CACxB,MAAM,YAAY,OAAO,OAAO,CAAC,SAAS,OAAO,WAAW,EAAE,SAAS,OAAO,AAAC,EAAC;AAChF,QAAO,UAAU,SAAS,OAAO;AAClC;;;;;;;;;;;AAYD,SAAS,sBAAsBC,QAAwC;AACrE,QAAO,OAAO,kBAAkB,gBAAgB;AACjD;;;;;;;;;AAUD,IAAa,4BAAb,cAA+C,MAAM;CACnD,YAEWC,UAETC,OACA;AACA,SACG,mBAAmB,SAAS,yCAC1B,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM,EAC1D;OAPQ,WAAA;AAQT,OAAK,OAAO;CACb;AACF;;;;;;;;;;AAWD,IAAa,qBAAb,MAA4D;CAI1D,YAAYF,SAAiC,CAAE,GAAE;AAC/C,OAAK,UAAU,sBAAsB,OAAO;AAC5C,OAAK,WAAW,KAAK,KAAK,SAAS,sBAAsB;AAGzD,aAAW;CACZ;;;;;;;;;;;CAYD,OAAoC;AAClC,OAAK,WAAW,KAAK,SAAS,CAAE,QAAO,IAAI;EAC3C,MAAM,YAAY,aAAa,KAAK,SAAS;AAC7C,MAAI;GACF,MAAM,YAAY,mBAAmB,UAAU;AAC/C,UAAO,IAAI,IAAI,OAAO,QAAQ,KAAK,MAAM,UAAU,CAA2B;EAC/E,SAAQ,OAAO;AACd,SAAM,IAAI,0BAA0B,KAAK,UAAU;EACpD;CACF;;;;;;;;;;;;;;;;;;;;;CAsBD,eAA4C;AAC1C,MAAI;AACF,UAAO,KAAK,MAAM;EACnB,SAAQG,OAAgB;AACvB,SAAM,iBAAiB,2BAA4B,OAAM;AACzD,QAAK,WAAW,MAAM;AACtB,UAAO,IAAI;EACZ;CACF;;;;;;;;;;;CAYD,WAAmBC,OAAoB;EACrC,MAAM,QAAQ,IAAI,OAAO,aAAa,CAAC,QAAQ,SAAS,IAAI;AAC5D,OAAK,IAAI,UAAU,GAAG,UAAU,0BAA0B,WAAW,GAAG;GACtE,MAAM,SAAS,YAAY,IAAI,MAAM,GAAG,QAAQ;GAChD,MAAM,UAAU,EAAE,KAAK,SAAS,cAAc,MAAM,EAAE,OAAO;AAC7D,OAAI,WAAW,OAAO,CAAE;AACxB,OAAI;AACF,eAAW,KAAK,UAAU,OAAO;AACjC;GACD,SAAQD,OAAgB;AAGvB,QAAI,iBAAiB,SAAS,UAAU,SAAS,MAAM,SAAS,SAAU;AAC1E,UAAM,IAAI,OACP,mBAAmB,KAAK,SAAS,sBAAsB,MAAM,QAAQ,qCACjC,OAAO,WAAW,OAAO,MAAM,CAAC;GAExE;EACF;AACD,QAAM,IAAI,OACP,mBAAmB,KAAK,SAAS,sBAAsB,MAAM,QAAQ,8CAClC,yBAAyB;CAGhE;;CAGD,QAAgBE,SAAoC;EAClD,MAAM,YAAY,KAAK,UAAU,OAAO,YAAY,QAAQ,CAAC;AAC7D,YAAU,KAAK,SAAS;GAAE,WAAW;GAAM,MAAM;EAAO,EAAC;AAGzD,gBAAc,KAAK,UAAU,mBAAmB,UAAU,EAAE,EAAE,MAAM,IAAO,EAAC;CAC7E;;;;;;;;;;CAWD,IAAIC,SAAqC;AACvC,MAAI;AACF,UAAO,KAAK,MAAM,CAAC,IAAI,QAAQ;EAChC,QAAO;AACN;EACD;CACF;CAED,IAAIA,SAAiBC,OAAqB;AACxC,MAAI,MAAM,WAAW,EAAG,OAAM,IAAI,MAAM;EACxC,MAAM,UAAU,KAAK,cAAc;AACnC,UAAQ,IAAI,SAAS,MAAM;AAC3B,OAAK,QAAQ,QAAQ;CACtB;;;;;;;;;;;;;;;;;CAkBD,OAAOD,SAAuB;EAC5B,MAAM,UAAU,KAAK,MAAM;AAC3B,MAAI,QAAQ,OAAO,QAAQ,CACzB,MAAK,QAAQ,QAAQ;CAExB;CAED,IAAIA,SAA0B;AAC5B,MAAI;AACF,UAAO,KAAK,MAAM,CAAC,IAAI,QAAQ;EAChC,QAAO;AACN,UAAO;EACR;CACF;AACF;;;;;ACvRD,IAAa,gBAAb,MAAuD;CAGrD,YAAYE,UAAkC,CAAE,GAAE;OAFjC,UAAU,IAAI;CAK9B;CAED,IAAIC,SAAqC;AACvC,SAAO,KAAK,QAAQ,IAAI,QAAQ;CACjC;CAED,IAAIA,SAAiBC,OAAqB;AACxC,MAAI,MAAM,WAAW,EAAG,OAAM,IAAI,MAAM;AACxC,OAAK,QAAQ,IAAI,SAAS,MAAM;CACjC;CAED,OAAOD,SAAuB;AAC5B,OAAK,QAAQ,OAAO,QAAQ;CAC7B;CAED,IAAIA,SAA0B;AAC5B,SAAO,KAAK,QAAQ,IAAI,QAAQ;CACjC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BD,IAAIE;AAEJ,SAAgB,oBAAoBC,SAAiC,CAAE,GAAiB;AACtF,YAAW,IAAI,cAAc;AAC7B,QAAO;AACR;;;;;;;AAQD,SAAgB,6BAAmC;AACjD;AACD;;;;;;;;;;;;;ACGD,MAAM,mBAAmB;;AAGzB,MAAM,eAAe,CAACC,YAA4B,mBAAmB;;AAGrE,MAAM,kBAAkB;;;;;;;;;;;;;;;;AA4BxB,IAAa,gBAAb,MAAuD;CAOrD,YAAYC,SAA2BC,SAA+B;OAF9D,aAAa;AAGnB,OAAK,UAAU;AACf,OAAK,iBAAiB,QAAQ;CAC/B;;CAGD,IAAY,gBAAkC;AAC5C,MAAI,KAAK,oBAAwB,MAAK,WAAW,KAAK,gBAAgB;AACtE,SAAO,KAAK;CACb;;;;;;;;CASD,eAAuBF,SAAuB;AAC5C,MAAI;AACF,QAAK,cAAc,OAAO,aAAa,QAAQ,CAAC;EACjD,QAAO,CAEP;CACF;CAED,IAAIA,SAAqC;EAIvC,MAAM,kBAAkB,KAAK,cAAc,IAAI,QAAQ;AACvD,MAAI,2BAA+B,QAAO;AAC1C,MAAI,KAAK,cAAc,IAAI,aAAa,QAAQ,CAAC,CAAE;AACnD,SAAO,KAAK,QAAQ,IAAI,QAAQ;CACjC;CAED,IAAIA,SAA0B;AAC5B,MAAI,KAAK,cAAc,IAAI,QAAQ,CAAE,QAAO;AAC5C,MAAI,KAAK,cAAc,IAAI,aAAa,QAAQ,CAAC,CAAE,QAAO;AAC1D,SAAO,KAAK,QAAQ,IAAI,QAAQ;CACjC;CAED,IAAIA,SAAiBG,OAAqB;AACxC,MAAI,MAAM,WAAW,EAAG,OAAM,IAAI,MAAM;AACxC,MAAI,KAAK,YAAY;AAGnB,QAAK,cAAc,IAAI,SAAS,MAAM;AACtC,QAAK,eAAe,QAAQ;AAC5B;EACD;AACD,MAAI;AACF,QAAK,QAAQ,IAAI,SAAS,MAAM;EACjC,SAAQC,OAAgB;AAKvB,QAAK,aAAa;AAClB,OAAI;AACF,SAAK,cAAc,IAAI,SAAS,MAAM;AACtC,SAAK,eAAe,QAAQ;GAC7B,SAAQC,eAAwB;AAC/B,UAAM,IAAI,OAEL,wEAAE,OAAO,cAAc,CAAC,mBAAmB,OAAO,MAAM,CAAC;GAE/D;AACD;EACD;AAMD,MAAI;AACF,QAAK,cAAc,OAAO,QAAQ;EACnC,QAAO,CAEP;AACD,OAAK,eAAe,QAAQ;CAC7B;CAED,OAAOL,SAAuB;AAG5B,MAAI;AACF,QAAK,cAAc,OAAO,QAAQ;EACnC,QAAO,CAEP;AACD,MAAI,KAAK,YAAY;AAKnB,QAAK,eAAe,QAAQ;AAC5B;EACD;AACD,MAAI;AACF,QAAK,QAAQ,OAAO,QAAQ;AAC5B,QAAK,eAAe,QAAQ;EAC7B,QAAO;AAMN,QAAK,aAAa;AAClB,QAAK,eAAe,QAAQ;EAC7B;CACF;;;;;;;;CASD,eAAuBA,SAAuB;AAC5C,OAAK,cAAc,IAAI,aAAa,QAAQ,EAAE,gBAAgB;CAC/D;AACF;;;;;;;;;;;;;;;;;;;;;;;ACtLD,SAAgB,uBAAuBM,SAAiC,CAAE,GAAoB;CAC5F,MAAMC,UAAwC,CAAC,MAAM;AACnD,MAAI;AACF,UAAO,IAAI,eAAe;EAC3B,QAAO;AAEN;EACD;CACF,IAAG;AACJ,KAAI,mBAGF,QAAO,IAAI,cAAc,SAAS,EAAE,gBAAgB,MAAM,oBAAoB,OAAO,CAAE;AAEzF,QAAO,oBAAoB,OAAO;AACnC;;AAGD,SAAS,oBAAoBD,QAAkD;AAC7E,KAAI;AACF,SAAO,IAAI,mBAAmB;CAC/B,QAAO,CAEP;AAMD,QAAO,oBAAoB,OAAO;AACnC"}
@@ -6,8 +6,8 @@ import { homedir } from "node:os";
6
6
  * The fallback data directory when the host sets no `DSH_HOME`.
7
7
  *
8
8
  * `~/.aishell` — the same directory the desktop app uses, so a library or
9
- * headless run shares one location with a real install rather than inventing a
10
- * second home directory.
9
+ * headless run lands where a real install keeps its state rather than creating
10
+ * a second home directory.
11
11
  */
12
12
  const FALLBACK_HOME_NAME = ".aishell";
13
13
  /**
@@ -43,4 +43,4 @@ function credentialsDir() {
43
43
 
44
44
  //#endregion
45
45
  export { FALLBACK_HOME_NAME, credentialsDir, dshHome };
46
- //# sourceMappingURL=paths-DtFRzop6.js.map
46
+ //# sourceMappingURL=paths-C0wxA5LO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths-C0wxA5LO.js","names":[],"sources":["../src/paths.ts"],"sourcesContent":["/**\n * Where per-host state lives. One definition, shared.\n *\n * ## Why one definition\n *\n * `DSH_HOME` is the host's data directory, and two things in this repo derive\n * from it: the encrypted credentials file here, and the terraform cache in\n * `aishell-deploy`. Two resolutions of the same variable can disagree — a\n * trailing-slash difference, a different fallback — and the result is state\n * written to one directory and looked for in another. One definition makes that\n * impossible.\n *\n * It sits in the lower of the two packages because the dependency graph runs\n * that way: `aishell-deploy` already depends on this one, so a definition here\n * is reachable from there, while the reverse would be a cycle.\n *\n * @module @huaweidevtools/aishell-dsh-credentials-store/paths\n */\n\nimport { homedir } from 'node:os'\nimport { join } from 'node:path'\n\n/**\n * The fallback data directory when the host sets no `DSH_HOME`.\n *\n * `~/.aishell` — the same directory the desktop app uses, so a library or\n * headless run lands where a real install keeps its state rather than creating\n * a second home directory.\n */\nexport const FALLBACK_HOME_NAME = '.aishell'\n\n/**\n * The host's data directory.\n *\n * `DSH_HOME` when the host set it — the desktop app and the CLI both do, so\n * this lands where the rest of that host's data lives — otherwise\n * `~/.aishell`.\n *\n * Read per call, never captured: a host that sets `DSH_HOME` after this module\n * is first imported must still be honoured.\n *\n * @returns the base directory; not created here.\n */\nexport function dshHome(): string {\n const configured = process.env.DSH_HOME\n if (configured !== undefined && configured.trim() !== '') return configured\n return join(homedir(), FALLBACK_HOME_NAME)\n}\n\n/**\n * The directory holding the encrypted credentials file.\n *\n * `<DSH_HOME>/credentials`. Builds are separated by the *file name* (the\n * credential scope — `plugins.credentials.enc` by default), not by a second\n * directory, so two builds sit side by side here without reading each other's\n * secrets.\n *\n * @returns the directory; not created here.\n */\nexport function credentialsDir(): string {\n return join(dshHome(), 'credentials')\n}"],"mappings":";;;;;;;;;;;AA6BA,MAAa,qBAAqB;;;;;;;;;;;;;AAclC,SAAgB,UAAkB;CAChC,MAAM,aAAa,QAAQ,IAAI;AAC/B,KAAI,yBAA4B,WAAW,MAAM,KAAK,GAAI,QAAO;AACjE,QAAO,KAAK,SAAS,EAAE,mBAAmB;AAC3C;;;;;;;;;;;AAYD,SAAgB,iBAAyB;AACvC,QAAO,KAAK,SAAS,EAAE,cAAc;AACtC"}
package/lib/paths.d.ts CHANGED
@@ -2,17 +2,18 @@
2
2
  /**
3
3
  * Where per-host state lives. One definition, shared.
4
4
  *
5
- * ## Why this lives in the store package
5
+ * ## Why one definition
6
6
  *
7
7
  * `DSH_HOME` is the host's data directory, and two things in this repo derive
8
8
  * from it: the encrypted credentials file here, and the terraform cache in
9
- * `aishell-deploy`. Defining it twice would let the two drift — the same
10
- * failure shape the credential-scope constant was created to end.
9
+ * `aishell-deploy`. Two resolutions of the same variable can disagree — a
10
+ * trailing-slash difference, a different fallback — and the result is state
11
+ * written to one directory and looked for in another. One definition makes that
12
+ * impossible.
11
13
  *
12
- * The direction is forced by the dependency graph: `aishell-deploy` already
13
- * depends on this package, so a definition here is reachable from there, while
14
- * the reverse would be a cycle. This module is the lower of the two, so it is
15
- * where the definition belongs.
14
+ * It sits in the lower of the two packages because the dependency graph runs
15
+ * that way: `aishell-deploy` already depends on this one, so a definition here
16
+ * is reachable from there, while the reverse would be a cycle.
16
17
  *
17
18
  * @module @huaweidevtools/aishell-dsh-credentials-store/paths
18
19
  */
@@ -20,8 +21,8 @@
20
21
  * The fallback data directory when the host sets no `DSH_HOME`.
21
22
  *
22
23
  * `~/.aishell` — the same directory the desktop app uses, so a library or
23
- * headless run shares one location with a real install rather than inventing a
24
- * second home directory.
24
+ * headless run lands where a real install keeps its state rather than creating
25
+ * a second home directory.
25
26
  */
26
27
  declare const FALLBACK_HOME_NAME = ".aishell";
27
28
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","names":[],"sources":["../../src/paths.ts"],"sourcesContent":null,"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;cA4Ba,kBAAA;AAAb;;;;;;;;;;;;iBAcgB,OAAA,CAAA;AAAhB;;;;;;;;;;iBAgBgB,cAAA,CAAA;;;AAAhB"}
1
+ {"version":3,"file":"paths.d.ts","names":[],"sources":["../../src/paths.ts"],"sourcesContent":null,"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;cA6Ba,kBAAA;AAAb;;;;;;;;;;;;iBAcgB,OAAA,CAAA;AAAhB;;;;;;;;;;iBAgBgB,cAAA,CAAA;;;AAAhB"}
package/lib/paths.js CHANGED
@@ -1,3 +1,3 @@
1
- import { FALLBACK_HOME_NAME, credentialsDir, dshHome } from "./paths-DtFRzop6.js";
1
+ import { FALLBACK_HOME_NAME, credentialsDir, dshHome } from "./paths-C0wxA5LO.js";
2
2
 
3
3
  export { FALLBACK_HOME_NAME, credentialsDir, dshHome };
@@ -18,25 +18,25 @@
18
18
  * | keyring service name | `aishell-dsh-plugins` |
19
19
  * | encrypted file name | `plugins.credentials.enc` |
20
20
  *
21
- * ## Why it is not a plugin config option
21
+ * ## Why it is a build-time constant
22
22
  *
23
- * It was one, briefly: `keyringService` sat on four plugins' `Config`. Only one
24
- * of the four writes and all four read, so a deployment that set it on a reader
25
- * but not the writer stored credentials in one namespace and looked for them in
26
- * another — the form said "saved" while every tool said "not authenticated".
27
- * A knob whose only observable effect is that failure is not worth having.
23
+ * The value has to be the same on both sides of a credential: the writer that
24
+ * stores it and the reader that looks it up. A runtime setting can be applied
25
+ * to one plugin and not another, and the result is a form that says "saved"
26
+ * beside tools that say "not authenticated" — a failure whose only symptom is
27
+ * the one thing the setting exists to control.
28
28
  *
29
29
  * A constant cannot be set on one side only. Two deployments that must not see
30
30
  * each other's credentials (a staging profile beside a production one) differ
31
31
  * by *building* differently, which is the honest place for a persistence
32
32
  * boundary.
33
33
  *
34
- * ## Why there is no migration
34
+ * ## Why the default namespace carries no migration path
35
35
  *
36
- * The default changed from `aishell-dsh` to `aishell-dsh-plugins` before the
37
- * first release, so no install ever wrote the old namespace. Nothing reads it,
38
- * and adding a migration path would mean keeping the old name alive forever for
39
- * a state that never existed.
36
+ * `plugins` is the first and only scope, so a build that sets nothing lands on
37
+ * it and a build that sets something has deliberately moved to a namespace of
38
+ * its own. There are no two names to reconcile, so there is no migration path
39
+ * to write — and none is needed until a release renames this default.
40
40
  *
41
41
  * @module @huaweidevtools/aishell-dsh-credentials-store/scope
42
42
  */
@@ -68,4 +68,4 @@ declare const CREDENTIALS_FILE_NAME: string;
68
68
  //# sourceMappingURL=scope.d.ts.map
69
69
 
70
70
  export { CREDENTIALS_FILE_NAME as CREDENTIALS_FILE_NAME$1, CREDENTIAL_SCOPE as CREDENTIAL_SCOPE$1, DEFAULT_SCOPE as DEFAULT_SCOPE$1, KEYRING_SERVICE as KEYRING_SERVICE$1 };
71
- //# sourceMappingURL=scope-CFetnbG3.d.ts.map
71
+ //# sourceMappingURL=scope-C7w2eTvE.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scope-CFetnbG3.d.ts","names":[],"sources":["../../src/scope.ts"],"sourcesContent":null,"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAgDa,aAAA;AAAb;cAmDa;AAAb;;;;;;cAQa;AAAb;;;;;;cAQa;;;AAAb"}
1
+ {"version":3,"file":"scope-C7w2eTvE.d.ts","names":[],"sources":["../../src/scope.ts"],"sourcesContent":null,"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAgDa,aAAA;AAAb;cAmDa;AAAb;;;;;;cAQa;AAAb;;;;;;cAQa;;;AAAb"}