mixdog 1.1.0 → 1.1.2

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 (174) hide show
  1. package/README.md +13 -6
  2. package/package.json +1 -2
  3. package/scripts/latest-guard.mjs +63 -0
  4. package/scripts/lib/run-node-tests.mjs +103 -27
  5. package/scripts/lib/test-failure-summary.mjs +7 -2
  6. package/scripts/merge-mac-update-feed.mjs +107 -0
  7. package/scripts/mobile-release.mjs +210 -0
  8. package/scripts/post-release-check.mjs +353 -0
  9. package/scripts/publish-asset-release.mjs +43 -0
  10. package/scripts/release-guard.mjs +63 -0
  11. package/scripts/release-paths.mjs +39 -2
  12. package/scripts/test.mjs +29 -7
  13. package/src/defaults/shipped-history.json +2 -0
  14. package/src/defaults/skills/computer-use/SKILL.md +1 -1
  15. package/src/defaults/skills/setup/references/actions.md +1 -0
  16. package/src/headless-role.mjs +2 -2
  17. package/src/lib/keychain-cjs.cjs +582 -17
  18. package/src/lib/rules-builder.cjs +0 -23
  19. package/src/rules/lead/LEAD.md +2 -2
  20. package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
  21. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  22. package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
  23. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
  27. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
  28. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
  29. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
  30. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
  31. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
  32. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
  34. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +4 -1
  35. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +14 -6
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -7
  40. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
  41. package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -0
  42. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -1
  44. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -5
  45. package/src/runtime/agent/orchestrator/session/context-utils.mjs +12 -14
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
  47. package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
  49. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
  50. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
  52. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -5
  54. package/src/runtime/agent/orchestrator/session/read-dedup.mjs +0 -1
  55. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
  56. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
  57. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +2 -0
  58. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
  59. package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
  61. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
  65. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
  69. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +61 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
  71. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
  77. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
  80. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
  81. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -91
  84. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
  85. package/src/runtime/channels/index.mjs +1 -5
  86. package/src/runtime/channels/lib/runtime-paths.mjs +8 -23
  87. package/src/runtime/channels/lib/tool-dispatch.mjs +1 -13
  88. package/src/runtime/channels/lib/worker-ipc.mjs +2 -2
  89. package/src/runtime/channels/lib/worker-main.mjs +4 -34
  90. package/src/runtime/computer-bridge/client.mjs +25 -1
  91. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
  92. package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
  93. package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
  94. package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
  95. package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
  96. package/src/runtime/effort-judge/judge-input.mjs +5 -2
  97. package/src/runtime/effort-judge/model-install.mjs +9 -3
  98. package/src/runtime/effort-judge/model-manifest.json +7 -7
  99. package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
  100. package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
  101. package/src/runtime/shared/config.mjs +50 -2
  102. package/src/runtime/shared/developer-options.mjs +7 -0
  103. package/src/runtime/shared/instruction-files-mode.mjs +9 -0
  104. package/src/runtime/shared/llm/model-catalog.mjs +2 -1
  105. package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
  106. package/src/runtime/shared/pristine-execution-contract.json +9 -0
  107. package/src/runtime/shared/pristine-execution.mjs +1 -1
  108. package/src/runtime/shared/provider-accounts.mjs +2 -0
  109. package/src/runtime/shared/resource-admission.mjs +10 -3
  110. package/src/runtime/shared/schedule-model-ref.mjs +11 -0
  111. package/src/runtime/shared/semver.d.mts +1 -0
  112. package/src/runtime/shared/semver.mjs +57 -0
  113. package/src/runtime/shared/session-approval-wait.mjs +35 -0
  114. package/src/runtime/shared/shell-display.mjs +4 -2
  115. package/src/runtime/shared/tool-surface.mjs +2 -2
  116. package/src/runtime/shared/turn-worktree-snapshot.mjs +11 -10
  117. package/src/runtime/shared/update-checker.mjs +2 -31
  118. package/src/session-runtime/boot/apis.mjs +5 -0
  119. package/src/session-runtime/boot/begin.mjs +0 -2
  120. package/src/session-runtime/boot/tools.mjs +5 -4
  121. package/src/session-runtime/model-recency.mjs +3 -0
  122. package/src/session-runtime/provider-auth/login.mjs +10 -10
  123. package/src/session-runtime/provider-auth/setup.mjs +22 -2
  124. package/src/session-runtime/provider-models/row-hydration.mjs +12 -4
  125. package/src/session-runtime/provider-readiness.mjs +4 -0
  126. package/src/session-runtime/resource-mcp-api.mjs +7 -3
  127. package/src/session-runtime/runtime-bootstrap.mjs +0 -4
  128. package/src/session-runtime/runtime-facade.mjs +5 -0
  129. package/src/session-runtime/runtime-paths.mjs +0 -1
  130. package/src/session-runtime/schedule-session-run.mjs +3 -1
  131. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +55 -14
  132. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
  133. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
  134. package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
  135. package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
  136. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
  137. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
  138. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
  139. package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
  140. package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
  141. package/src/session-runtime/services/channel-admin.mjs +4 -2
  142. package/src/session-runtime/services/channel-worker.mjs +1 -1
  143. package/src/session-runtime/services/provider-admin.mjs +80 -17
  144. package/src/session-runtime/services/seeds.mjs +7 -10
  145. package/src/session-runtime/services/usage-dashboard-model.mjs +6 -0
  146. package/src/session-runtime/settings-builtin-tools-api.mjs +15 -0
  147. package/src/session-runtime/settings-profile-api.mjs +27 -2
  148. package/src/session-runtime/setup-tool/executor.mjs +5 -0
  149. package/src/session-runtime/setup-tool/tool-defs.mjs +8 -0
  150. package/src/session-runtime/starter-guide-state.mjs +62 -0
  151. package/src/session-runtime/tool-policy-refresh.mjs +3 -0
  152. package/src/session-runtime/webhook-session-run.mjs +3 -1
  153. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -2
  154. package/src/standalone/channel-binding.mjs +1 -2
  155. package/src/standalone/channel-transport/call-dispatch.mjs +4 -10
  156. package/src/standalone/daemon-channels-loader.mjs +2 -3
  157. package/src/standalone/local-session-runtime.mjs +2 -78
  158. package/src/standalone/session-protocol.mjs +2 -0
  159. package/src/tui/app/model-options.mjs +13 -1
  160. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
  161. package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
  162. package/src/tui/dist/index.mjs +84 -34
  163. package/src/tui/session/context-state.mjs +2 -0
  164. package/src/tui/session/live-share/owner.mjs +17 -0
  165. package/src/tui/session/oauth-flows.mjs +2 -0
  166. package/src/tui/session/session-api/extensions.mjs +12 -8
  167. package/src/tui/session/session-api/integrations.mjs +9 -8
  168. package/src/tui/session/session-api/routes.mjs +2 -2
  169. package/src/tui/session/session-api/settings.mjs +1 -0
  170. package/src/tui/session/session-api/shared.mjs +10 -6
  171. package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
  172. package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
  173. package/src/tui/spinner-meta.mjs +26 -10
  174. package/src/runtime/channels/tool-defs.mjs +0 -17
@@ -60,6 +60,12 @@ function _cacheInvalidate(account) {
60
60
  }
61
61
 
62
62
  function invalidateSecretCache(account) {
63
+ // Linux reads are cache-only after one snapshot: an external invalidation
64
+ // means the snapshot may be stale, so the next read reloads it.
65
+ if (process.platform === 'linux') {
66
+ _linuxLoadedUntil = 0;
67
+ _prewarmPromise = null;
68
+ }
63
69
  if (account == null) {
64
70
  _secretCache.clear();
65
71
  _cacheGenerations.clear();
@@ -300,7 +306,7 @@ function secretToolHint() {
300
306
  // environment, which would otherwise be visible via /proc/<pid>/environ for the
301
307
  // lifetime of this child. Only PATH resolution, locale, and the libsecret
302
308
  // D-Bus session bridge are passed through.
303
- function secretTool(args, input) {
309
+ function secretToolEnv() {
304
310
  const passthroughKeys = [
305
311
  'PATH',
306
312
  'HOME',
@@ -321,37 +327,457 @@ function secretTool(args, input) {
321
327
  for (const key of passthroughKeys) {
322
328
  if (process.env[key] !== undefined) env[key] = process.env[key];
323
329
  }
324
- const r = spawnSync('secret-tool', args, {
325
- env,
326
- input: input ?? '',
327
- stdio: ['pipe', 'pipe', 'pipe'],
328
- timeout: KEYCHAIN_TIMEOUT_MS,
329
- encoding: 'utf8',
330
- windowsHide: true,
331
- });
332
- if (r.error?.code === 'ENOENT') throw new Error(`[keychain] secret-tool is not installed — ${secretToolHint()}`);
330
+ return env;
331
+ }
332
+
333
+ // Raw tool output (stderr) may carry paths or service internals; keep it out of
334
+ // user-facing errors and print it only when explicitly debugging. Secrets are
335
+ // never part of it: they travel on stdin only.
336
+ function keychainDebug(what, detail) {
337
+ if (process.env.MIXDOG_KEYCHAIN_DEBUG) console.error(`[keychain:debug] ${what}: ${String(detail).slice(0, 500)}`);
338
+ }
339
+
340
+ function secretToolCheck(r) {
341
+ if (r.error?.code === 'ENOENT') {
342
+ throw new Error(
343
+ '[keychain] secret-tool is not installed — install libsecret-tools (sudo apt install libsecret-tools), ' +
344
+ 'or set the relevant MIXDOG_*/PROVIDER_API_KEY environment variable instead'
345
+ );
346
+ }
333
347
  if (r.error?.code === 'ETIMEDOUT') {
334
- throw new Error(`[keychain] secret-tool timed out after ${KEYCHAIN_TIMEOUT_MS}ms (is the Secret Service locked?)`);
348
+ throw new Error(
349
+ `[keychain] secret-tool timed out after ${r.error.timeoutMs || KEYCHAIN_TIMEOUT_MS}ms — the keyring is locked or its unlock prompt ` +
350
+ 'was not answered. Unlock your keyring (e.g. log in again or open Passwords and Keys) and retry.'
351
+ );
352
+ }
353
+ if (r.error) {
354
+ keychainDebug('secret-tool failed to start', r.error.message);
355
+ throw new Error('[keychain] secret-tool could not be started — check that libsecret-tools is installed and a Secret Service is running');
335
356
  }
336
- if (r.error) throw new Error(`[keychain] secret-tool failed to start: ${r.error.message}`);
337
357
  return r;
338
358
  }
339
359
 
360
+ function secretTool(args, input) {
361
+ return secretToolCheck(
362
+ spawnSync('secret-tool', args, {
363
+ env: secretToolEnv(),
364
+ input: input ?? '',
365
+ stdio: ['pipe', 'pipe', 'pipe'],
366
+ timeout: KEYCHAIN_TIMEOUT_MS,
367
+ encoding: 'utf8',
368
+ windowsHide: true,
369
+ })
370
+ );
371
+ }
372
+
373
+ // Non-blocking variant: the daemon event loop keeps running while a locked
374
+ // keyring waits on its unlock prompt; the child is killed at the same bound.
375
+ function secretToolAsync(args, input, timeoutMs = KEYCHAIN_TIMEOUT_MS) {
376
+ return new Promise((resolve, reject) => {
377
+ let child;
378
+ try {
379
+ child = spawn('secret-tool', args, {
380
+ env: secretToolEnv(),
381
+ stdio: ['pipe', 'pipe', 'pipe'],
382
+ shell: false,
383
+ windowsHide: true,
384
+ });
385
+ } catch (error) {
386
+ reject(error);
387
+ return;
388
+ }
389
+ let stdout = '';
390
+ let stderr = '';
391
+ let settled = false;
392
+ const done = (r) => {
393
+ if (settled) return;
394
+ settled = true;
395
+ clearTimeout(timer);
396
+ try {
397
+ resolve(secretToolCheck(r));
398
+ } catch (error) {
399
+ reject(error);
400
+ }
401
+ };
402
+ const timer = setTimeout(() => {
403
+ done({
404
+ status: null,
405
+ stdout,
406
+ stderr,
407
+ error: Object.assign(new Error('timeout'), { code: 'ETIMEDOUT', timeoutMs }),
408
+ });
409
+ try {
410
+ child.kill('SIGKILL');
411
+ } catch {}
412
+ }, timeoutMs);
413
+ child.stdout.setEncoding('utf8');
414
+ child.stderr.setEncoding('utf8');
415
+ child.stdout.on('data', (chunk) => {
416
+ stdout += chunk;
417
+ });
418
+ child.stderr.on('data', (chunk) => {
419
+ stderr += chunk;
420
+ });
421
+ child.once('error', (error) => done({ status: null, stdout, stderr, error }));
422
+ child.once('close', (status) => done({ status, stdout, stderr, error: null }));
423
+ child.stdin.on('error', () => {
424
+ /* child exited early — close reports it */
425
+ });
426
+ child.stdin.end(input ?? '');
427
+ });
428
+ }
429
+
340
430
  // A missing item exits non-zero with nothing on stderr; anything on stderr is
341
431
  // the Secret Service itself failing (no D-Bus session, locked collection).
342
432
  function secretToolFailure(action, r) {
343
- return new Error(
344
- `[keychain] secret-tool ${action} failed: ${(r.stderr || '').trim() || `exit ${r.status}`} — ${secretToolHint()}`
345
- );
433
+ const detail = (r.stderr || '').trim() || `exit ${r.status}`;
434
+ // User-facing text is fixed; the raw tool output only goes to the debug log.
435
+ keychainDebug(`secret-tool ${action} failed`, detail);
436
+ if (/locked|denied|dismissed|cancel|not authori[sz]ed|IsLocked/i.test(detail)) {
437
+ return new Error(
438
+ `[keychain] secret-tool ${action} failed: the keyring is locked or the unlock prompt was declined. ` +
439
+ 'Unlock your keyring and retry.'
440
+ );
441
+ }
442
+ return new Error(`[keychain] secret-tool ${action} failed — ${secretToolHint()}`);
346
443
  }
347
444
 
348
- function linuxGet(account) {
445
+ function linuxGetBlocking(account) {
349
446
  const r = secretTool(['lookup', 'service', SERVICE, 'account', account]);
350
447
  if (r.status === 0) return r.stdout;
351
448
  if ((r.stderr || '').trim()) throw secretToolFailure('lookup', r);
352
449
  return null;
353
450
  }
354
451
 
452
+ // Daemon-safe state. The synchronous read path NEVER spawns secret-tool on
453
+ // Linux: a locked keyring would freeze the event loop for the whole timeout.
454
+ // One bounded async `secret-tool search --all` snapshot (prewarmSecrets) fills
455
+ // the read cache; afterwards an uncached account is simply absent. Until that
456
+ // snapshot succeeds the sync read throws the actionable keyring error (callers
457
+ // already treat a throw as "no stored key") and a retry is scheduled lazily.
458
+ const LINUX_PREWARM_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_PREWARM_TIMEOUT_MS || 4000);
459
+ const LINUX_PREWARM_RETRY_MS = Number(process.env.MIXDOG_KEYCHAIN_RETRY_MS || 5000);
460
+ // An explicit "Unlock keyring" waits on the user typing a password.
461
+ const LINUX_INTERACTIVE_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_INTERACTIVE_TIMEOUT_MS || 120000);
462
+ // A window-focus re-check skips the TTL but not this floor (focus can flap).
463
+ const LINUX_RECHECK_MIN_MS = Number(process.env.MIXDOG_KEYCHAIN_RECHECK_MIN_MS || 2000);
464
+ const LINUX_LOCKED_MESSAGE =
465
+ '[keychain] the keyring is locked — unlock it (log in again or open Passwords and Keys) and retry';
466
+ let _linuxLoadedUntil = 0;
467
+ let _linuxLastFailureAt = 0;
468
+ let _linuxPrewarmInFlight = false;
469
+ let _linuxSnapshotAt = 0;
470
+ let _linuxSnapshotChanged = false;
471
+ const LINUX_SNAPSHOT_TTL_MS = Number(process.env.MIXDOG_KEYCHAIN_SNAPSHOT_TTL_MS || 45000);
472
+ let _linuxBgTimer = null;
473
+ let _linuxBgAttempt = 0;
474
+ let _interactiveUnlock = false;
475
+ let _linuxPrewarmInteractive = false;
476
+ const _recoveryListeners = new Set();
477
+ let _keychainStatus = { state: 'unknown', message: '' };
478
+
479
+ function _noteKeychainOk() {
480
+ _keychainStatus = { state: 'ok', message: '' };
481
+ _linuxBgAttempt = 0;
482
+ clearTimeout(_linuxBgTimer);
483
+ _linuxBgTimer = null;
484
+ }
485
+
486
+ /** Subscribe to a failed Linux keyring becoming readable (e.g. user unlocked it). */
487
+ function onKeychainRecovered(listener) {
488
+ _recoveryListeners.add(listener);
489
+ return () => _recoveryListeners.delete(listener);
490
+ }
491
+
492
+ // While the keyring is locked/failing nothing may depend on a read to retry, so
493
+ // retry in the background with a bounded backoff (5s → 10s). A missing
494
+ // secret-tool cannot fix itself and is not retried.
495
+ function _scheduleLinuxBackgroundRetry() {
496
+ if (_linuxBgTimer || _keychainStatus.state === 'missing' || _gdbusMissing) return;
497
+ const delay = Math.min(LINUX_PREWARM_RETRY_MS * 2, LINUX_PREWARM_RETRY_MS + _linuxBgAttempt * (LINUX_PREWARM_RETRY_MS / 2));
498
+ _linuxBgAttempt++;
499
+ _linuxBgTimer = setTimeout(async () => {
500
+ _linuxBgTimer = null;
501
+ await retryKeychain();
502
+ }, delay);
503
+ _linuxBgTimer.unref?.();
504
+ }
505
+
506
+ // Passive access to the Secret Service over D-Bus. Unlike secret-tool (whose
507
+ // lookup/search raises GNOME's "Authentication required" prompt as soon as a
508
+ // MATCHING item sits in a locked collection) SearchItems never prompts: it
509
+ // answers with the matching item paths split into unlocked and locked ones.
510
+ // Only Mixdog's own items matter — a locked collection that holds none of them
511
+ // (e.g. the login keyring next to an unlocked default one) is irrelevant.
512
+ // 'unknown' (null) means no gdbus / service unreachable / unparsable; passive
513
+ // callers must treat it as "do not run secret-tool".
514
+ let _gdbusMissing = false;
515
+ const SECRETS_DEST = 'org.freedesktop.secrets';
516
+ const SECRETS_ROOT = '/org/freedesktop/secrets';
517
+ const SECRET_SERVICE_IFACE = 'org.freedesktop.Secret.Service';
518
+ const SECRET_ITEM_IFACE = 'org.freedesktop.Secret.Item';
519
+
520
+ /** One bounded gdbus method call; resolves { ok, out, err } (never rejects). */
521
+ function gdbusCall(objectPath, method, args = []) {
522
+ return new Promise((resolve) => {
523
+ let child;
524
+ try {
525
+ child = spawn(
526
+ 'gdbus',
527
+ ['call', '--session', '--dest', SECRETS_DEST, '--object-path', objectPath, '--method', method, ...args],
528
+ { env: secretToolEnv(), stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true }
529
+ );
530
+ } catch {
531
+ resolve({ ok: false, out: '', err: '' });
532
+ return;
533
+ }
534
+ let stdout = '';
535
+ let stderr = '';
536
+ let settled = false;
537
+ const done = (ok) => {
538
+ if (settled) return;
539
+ settled = true;
540
+ clearTimeout(timer);
541
+ resolve({ ok, out: stdout, err: stderr });
542
+ };
543
+ const timer = setTimeout(() => {
544
+ try {
545
+ child.kill('SIGKILL');
546
+ } catch {}
547
+ done(false);
548
+ }, 2000);
549
+ child.stdout.setEncoding('utf8');
550
+ child.stdout.on('data', (chunk) => {
551
+ stdout += chunk;
552
+ });
553
+ child.stderr.setEncoding('utf8');
554
+ child.stderr.on('data', (chunk) => {
555
+ stderr += chunk;
556
+ });
557
+ child.once('error', (error) => {
558
+ if (error?.code === 'ENOENT') _gdbusMissing = true;
559
+ done(false);
560
+ });
561
+ child.once('close', (status) => done(status === 0));
562
+ });
563
+ }
564
+
565
+ // gdbus prints the (ao, ao) reply as `([objectpath '/…/7'], @ao [])`.
566
+ function parseSearchItems(out) {
567
+ const array = '(@ao \\[\\s*\\]|\\[[^\\]]*\\])';
568
+ const m = new RegExp(`^\\(\\s*${array}\\s*,\\s*${array}\\s*\\)$`).exec(String(out).trim());
569
+ if (!m) return null;
570
+ const paths = (text) => [...text.matchAll(/'(\/org\/freedesktop\/secrets\/collection\/[^']+)'/g)].map((x) => x[1]);
571
+ return { unlocked: paths(m[1]), locked: paths(m[2]) };
572
+ }
573
+
574
+ /** Mixdog's items by lock state, or null when the service cannot be asked. */
575
+ async function searchMixdogItems() {
576
+ const r = await gdbusCall(SECRETS_ROOT, `${SECRET_SERVICE_IFACE}.SearchItems`, [`{'service': '${SERVICE}'}`]);
577
+ return r.ok ? parseSearchItems(r.out) : null;
578
+ }
579
+
580
+ // An item's attributes are readable without unlocking its collection.
581
+ // Resolves the account name, '' for an item that vanished since the search
582
+ // (a keyring can list a just-deleted item), null when the call itself failed.
583
+ async function itemAccount(objectPath) {
584
+ const r = await gdbusCall(objectPath, 'org.freedesktop.DBus.Properties.Get', [SECRET_ITEM_IFACE, 'Attributes']);
585
+ if (!r.ok) return /does not exist|UnknownObject|No such (object|item)/i.test(r.err) ? '' : null;
586
+ const m = /'account':\s*'((?:[^'\\]|\\.)*)'/.exec(r.out);
587
+ return m ? m[1].replace(/\\(.)/g, '$1') : '';
588
+ }
589
+
590
+ const LINUX_UNREACHABLE_MESSAGE =
591
+ '[keychain] the keyring is not reachable — make sure it is running, then use Unlock keyring / Retry';
592
+ function _noteKeychainFailure(error) {
593
+ const message = String(error?.message || error);
594
+ const state =
595
+ error?.code === 'KEYRING_UNREACHABLE'
596
+ ? 'unreachable'
597
+ : /not installed/.test(message)
598
+ ? 'missing'
599
+ : /locked|timed out/.test(message)
600
+ ? 'locked'
601
+ : 'error';
602
+ _keychainStatus = { state, message };
603
+ }
604
+
605
+ /** Linux keyring health for the UI: ok | locked | missing | error | unknown. */
606
+ function keychainStatus() {
607
+ return { ..._keychainStatus };
608
+ }
609
+
610
+ function _linuxLoaded() {
611
+ return _linuxLoadedUntil > Date.now();
612
+ }
613
+
614
+ function linuxGet(account) {
615
+ if (KEYCHAIN_CACHE_TTL_MS === 0) return linuxGetBlocking(account);
616
+ if (_linuxLoaded()) return null;
617
+ if (Date.now() - _linuxLastFailureAt > LINUX_PREWARM_RETRY_MS) void prewarmSecrets();
618
+ throw new Error(
619
+ _keychainStatus.message ||
620
+ '[keychain] the Linux keyring has not been read yet — unlock your keyring (log in again or open Passwords and Keys)'
621
+ );
622
+ }
623
+
624
+ // `secret-tool search --all` splits its report across streams: header, label,
625
+ // secret, created/modified and schema go to stdout while the attribute.* lines
626
+ // go to stderr (observed on GNOME). Pairing items across the split is
627
+ // unreliable, so the search is only used to DISCOVER account names (the
628
+ // attribute.account lines of either stream); values come from per-account
629
+ // `lookup`. The discovered count must match the item count, otherwise the
630
+ // snapshot could be silently incomplete and is rejected.
631
+ function parseSecretToolSearchAccounts(stdout, stderr) {
632
+ const text = `${String(stdout || '')}\n${String(stderr || '')}`;
633
+ const accounts = new Set();
634
+ let attributeLines = 0;
635
+ let items = 0;
636
+ for (const line of text.split(/\r?\n/)) {
637
+ if (/^\[\/.*\]$/.test(line)) items++;
638
+ const m = line.match(/^attribute\.account = (.+)$/);
639
+ if (m) {
640
+ attributeLines++;
641
+ accounts.add(m[1]);
642
+ }
643
+ }
644
+ if (attributeLines !== items) {
645
+ // A locked keyring answers the search with items but no attributes. Keep
646
+ // the parse detail out of the UI-facing message.
647
+ const error = new Error(LINUX_LOCKED_MESSAGE);
648
+ error.detail = `secret-tool search returned ${items} item(s) but ${attributeLines} account attribute(s)`;
649
+ throw error;
650
+ }
651
+ return [...accounts];
652
+ }
653
+ // Which accounts to read, and whether Mixdog items are stuck in a locked
654
+ // collection. Passive reads never prompt: SearchItems says which items are
655
+ // unlocked, and only those are read. secret-tool is used only where that cannot
656
+ // prompt without the user asking: to list accounts when no Mixdog item exists
657
+ // at all (this also proves the tool is installed), and — for an explicit
658
+ // `interactive` unlock — `search --unlock`, which is what raises the prompt
659
+ // (a plain search lists locked items without attributes and never asks).
660
+ // Its listing is not used: attributes and secrets arrive split across stdout
661
+ // and stderr and one stale item makes the whole command fail, so the unlocked
662
+ // items are listed through D-Bus again afterwards.
663
+ async function _linuxAccountsToRead(interactive, remaining) {
664
+ let found = await searchMixdogItems();
665
+ if (found === null && !interactive) {
666
+ const error = new Error(LINUX_UNREACHABLE_MESSAGE);
667
+ error.code = 'KEYRING_UNREACHABLE';
668
+ throw error;
669
+ }
670
+ const listAccounts = async (args) => {
671
+ const r = await secretToolAsync(args, undefined, remaining());
672
+ // Exit 1 with an empty stderr just means "no items".
673
+ if (r.status !== 0 && (r.stderr || '').trim()) throw secretToolFailure('search', r);
674
+ return { accounts: parseSecretToolSearchAccounts(r.stdout, r.stderr), lockedLeft: false };
675
+ };
676
+ if (found !== null && found.unlocked.length === 0 && found.locked.length === 0) {
677
+ return listAccounts(['search', '--all', 'service', SERVICE]);
678
+ }
679
+ if (interactive && (found === null || found.locked.length > 0)) {
680
+ const unlock = await secretToolAsync(['search', '--all', '--unlock', 'service', SERVICE], undefined, remaining());
681
+ found = await searchMixdogItems();
682
+ if (found === null) {
683
+ if (unlock.status !== 0 && (unlock.stderr || '').trim()) throw secretToolFailure('search', unlock);
684
+ return { accounts: parseSecretToolSearchAccounts(unlock.stdout, unlock.stderr), lockedLeft: false };
685
+ }
686
+ }
687
+ const names = await Promise.all(found.unlocked.map((objectPath) => itemAccount(objectPath)));
688
+ if (names.includes(null)) {
689
+ const error = new Error(LINUX_UNREACHABLE_MESSAGE);
690
+ error.code = 'KEYRING_UNREACHABLE';
691
+ throw error;
692
+ }
693
+ return { accounts: [...new Set(names.filter(Boolean))], lockedLeft: found.locked.length > 0 };
694
+ }
695
+ async function _prewarmLinuxOnce() {
696
+ _linuxPrewarmInFlight = true;
697
+ const interactive = _interactiveUnlock;
698
+ _linuxPrewarmInteractive = interactive;
699
+ try {
700
+ if (KEYCHAIN_CACHE_TTL_MS === 0) return;
701
+ // One deadline covers the search AND every per-account lookup.
702
+ const deadline = Date.now() + (interactive ? LINUX_INTERACTIVE_TIMEOUT_MS : LINUX_PREWARM_TIMEOUT_MS);
703
+ const remaining = () => {
704
+ const ms = deadline - Date.now();
705
+ if (ms <= 0) {
706
+ const error = new Error(LINUX_LOCKED_MESSAGE);
707
+ error.detail = 'prewarm deadline exceeded';
708
+ throw error;
709
+ }
710
+ return ms;
711
+ };
712
+ const epoch = _cacheEpoch;
713
+ const generations = new Map(_cacheGenerations);
714
+ const { accounts, lockedLeft } = await _linuxAccountsToRead(interactive, remaining);
715
+ if (_cacheEpoch !== epoch) return;
716
+ const values = [];
717
+ for (const account of accounts) {
718
+ const value = await linuxGetAsync(account, remaining());
719
+ if (value !== null) values.push([account, value]);
720
+ } if (_cacheEpoch !== epoch) return;
721
+ // Replace, not merge: values the keyring now holds overwrite the cache and
722
+ // cached accounts the keyring no longer has are dropped, so external CLI /
723
+ // other-process add, replace and delete are picked up. A same-process
724
+ // set/delete that ran meanwhile bumped the account's generation and wins.
725
+ const fresh = new Map(values);
726
+ const prefix = `${SERVICE}\0`;
727
+ let changed = false;
728
+ // Mixdog items in a locked collection cannot be listed: a cached account the
729
+ // readable items no longer show may live there, so nothing is dropped.
730
+ for (const key of lockedLeft ? [] : [..._secretCache.keys()]) {
731
+ if (!key.startsWith(prefix)) continue;
732
+ const account = key.slice(prefix.length);
733
+ if ((_cacheGenerations.get(account) || 0) !== (generations.get(account) || 0)) continue;
734
+ if (!fresh.has(account)) {
735
+ _secretCache.delete(key);
736
+ changed = true;
737
+ }
738
+ }
739
+ for (const [account, value] of fresh) {
740
+ if ((_cacheGenerations.get(account) || 0) !== (generations.get(account) || 0)) continue;
741
+ if (_cacheGet(account) !== value) changed = true;
742
+ _cacheSet(account, value);
743
+ }
744
+ _linuxSnapshotChanged = changed;
745
+ if (lockedLeft) {
746
+ // The readable items are cached; the rest of the keyring still needs an
747
+ // unlock, so the status stays locked and uncached accounts keep throwing
748
+ // that error instead of reading as absent.
749
+ const error = new Error(LINUX_LOCKED_MESSAGE);
750
+ error.detail = 'Mixdog items sit in a locked collection (passive search)';
751
+ throw error;
752
+ }
753
+ _linuxSnapshotAt = Date.now();
754
+ _linuxLoadedUntil =
755
+ KEYCHAIN_CACHE_TTL_MS === Number.POSITIVE_INFINITY
756
+ ? Number.POSITIVE_INFINITY
757
+ : Date.now() + KEYCHAIN_CACHE_TTL_MS;
758
+ _linuxLastFailureAt = 0;
759
+ const recovered = _keychainStatus.state !== 'ok' && _keychainStatus.state !== 'unknown';
760
+ _noteKeychainOk();
761
+ if (recovered) {
762
+ for (const listener of [..._recoveryListeners]) {
763
+ try {
764
+ listener();
765
+ } catch {
766
+ /* listener failures must not break keyring recovery */
767
+ }
768
+ }
769
+ }
770
+ } catch (error) {
771
+ _linuxLastFailureAt = Date.now();
772
+ _noteKeychainFailure(error);
773
+ // Allow a later lazy retry once the cool-down passes.
774
+ _prewarmPromise = null;
775
+ _scheduleLinuxBackgroundRetry();
776
+ } finally {
777
+ _linuxPrewarmInFlight = false;
778
+ }
779
+ }
780
+
355
781
  function linuxSet(account, value) {
356
782
  const r = secretTool(['store', `--label=${SERVICE} ${account}`, 'service', SERVICE, 'account', account], value);
357
783
  if (r.status !== 0) throw secretToolFailure('store', r);
@@ -362,6 +788,26 @@ function linuxDelete(account) {
362
788
  if (r.status !== 0 && (r.stderr || '').trim()) throw secretToolFailure('clear', r);
363
789
  }
364
790
 
791
+ async function linuxGetAsync(account, timeoutMs) {
792
+ const r = await secretToolAsync(['lookup', 'service', SERVICE, 'account', account], undefined, timeoutMs);
793
+ if (r.status === 0) return r.stdout;
794
+ if ((r.stderr || '').trim()) throw secretToolFailure('lookup', r);
795
+ return null;
796
+ }
797
+
798
+ async function linuxSetAsync(account, value) {
799
+ const r = await secretToolAsync(
800
+ ['store', `--label=${SERVICE} ${account}`, 'service', SERVICE, 'account', account],
801
+ value
802
+ );
803
+ if (r.status !== 0) throw secretToolFailure('store', r);
804
+ }
805
+
806
+ async function linuxDeleteAsync(account) {
807
+ const r = await secretToolAsync(['clear', 'service', SERVICE, 'account', account]);
808
+ if (r.status !== 0 && (r.stderr || '').trim()) throw secretToolFailure('clear', r);
809
+ }
810
+
365
811
  // ---------------------------------------------------------------------------
366
812
  // win32 — DPAPI via PowerShell
367
813
  // ---------------------------------------------------------------------------
@@ -426,6 +872,7 @@ const PS_UNPROTECT_BATCH = [
426
872
 
427
873
  async function _prewarmSecretsOnce() {
428
874
  try {
875
+ if (platform() === 'linux') return await _prewarmLinuxOnce();
429
876
  if (platform() !== 'win32' || KEYCHAIN_CACHE_TTL_MS === 0) return;
430
877
  const dir = secretsDir();
431
878
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
@@ -475,6 +922,54 @@ function prewarmSecrets() {
475
922
  return _prewarmPromise;
476
923
  }
477
924
 
925
+ /** Passive refresh, called on provider-setup reads. While readable it detects a
926
+ * keyring that locked meanwhile (no prompt) and, once the snapshot is older
927
+ * than the TTL (or, for a window-focus re-check, `force`, at most every
928
+ * LINUX_RECHECK_MIN_MS), re-snapshots it so external changes are picked up.
929
+ * Resolves true when cached values changed. Never prompts. */
930
+ async function refreshKeychainStatus({ force = false } = {}) {
931
+ if (platform() !== 'linux' || _keychainStatus.state !== 'ok' || KEYCHAIN_CACHE_TTL_MS === 0) return false;
932
+ const age = Date.now() - _linuxSnapshotAt;
933
+ if (age >= (force ? LINUX_RECHECK_MIN_MS : LINUX_SNAPSHOT_TTL_MS)) {
934
+ if (_linuxPrewarmInFlight && _prewarmPromise) await _prewarmPromise;
935
+ else {
936
+ _prewarmPromise = null;
937
+ _linuxSnapshotChanged = false;
938
+ await prewarmSecrets();
939
+ }
940
+ return _linuxSnapshotChanged;
941
+ }
942
+ const found = await searchMixdogItems();
943
+ if (found && found.locked.length > 0) {
944
+ _keychainStatus = { state: 'locked', message: LINUX_LOCKED_MESSAGE };
945
+ _scheduleLinuxBackgroundRetry();
946
+ }
947
+ return false;
948
+ }
949
+ /** Re-read a Linux keyring that previously failed (locked, tool missing, bad
950
+ * snapshot) — e.g. after the user unlocked it. Resolves true when this call
951
+ * recovered it, so callers can drop caches built while it was unavailable.
952
+ * `interactive` (an explicit user action) may raise the unlock prompt: a
953
+ * passive attempt already in flight cannot, so it is awaited and then
954
+ * followed by one interactive attempt — unless that attempt was interactive
955
+ * itself, which already showed the (possibly declined) prompt. */
956
+ async function retryKeychain({ interactive = false } = {}) {
957
+ if (platform() !== 'linux' || _keychainStatus.state === 'ok' || _keychainStatus.state === 'unknown') return false;
958
+ if (_linuxPrewarmInFlight && _prewarmPromise) {
959
+ await _prewarmPromise;
960
+ if (_keychainStatus.state === 'ok' || interactive !== true || _linuxPrewarmInteractive) {
961
+ return _keychainStatus.state === 'ok';
962
+ }
963
+ }
964
+ _prewarmPromise = null;
965
+ _interactiveUnlock = interactive === true;
966
+ try {
967
+ await prewarmSecrets();
968
+ } finally {
969
+ _interactiveUnlock = false;
970
+ }
971
+ return _keychainStatus.state === 'ok';
972
+ }
478
973
  function win32Set(account, value) {
479
974
  const dir = secretsDir();
480
975
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -499,7 +994,14 @@ function win32Delete(account) {
499
994
 
500
995
  const BACKENDS = {
501
996
  darwin: { get: darwinGet, set: darwinSet, delete: darwinDelete },
502
- linux: { get: linuxGet, set: linuxSet, delete: linuxDelete },
997
+ linux: {
998
+ get: linuxGet,
999
+ set: linuxSet,
1000
+ delete: linuxDelete,
1001
+ getAsync: linuxGetAsync,
1002
+ setAsync: linuxSetAsync,
1003
+ deleteAsync: linuxDeleteAsync,
1004
+ },
503
1005
  win32: { get: win32Get, set: win32Set, delete: win32Delete },
504
1006
  };
505
1007
 
@@ -529,10 +1031,14 @@ function hasSecret(account) {
529
1031
  }
530
1032
 
531
1033
  function setSecret(account, value) {
1034
+ let stored = false;
532
1035
  try {
533
1036
  backend().set(account, value);
1037
+ stored = true;
534
1038
  } finally {
535
1039
  _cacheInvalidate(account);
1040
+ // Linux reads are cache-only, so a fresh write must stay readable.
1041
+ if (stored && platform() === 'linux') _cacheSet(account, value);
536
1042
  }
537
1043
  }
538
1044
 
@@ -544,12 +1050,71 @@ function deleteSecret(account) {
544
1050
  }
545
1051
  }
546
1052
 
1053
+ // Async entry points: on Linux the secret-tool child runs without blocking the
1054
+ // event loop; other platforms keep their existing (sync) providers.
1055
+ async function getSecretAsync(account) {
1056
+ const cached = _cacheGet(account);
1057
+ if (cached !== undefined) return cached;
1058
+ const b = backend();
1059
+ if (!b.getAsync) return getSecret(account);
1060
+ if (_linuxLoaded()) return null;
1061
+ const generation = _cacheGenerations.get(account) || 0;
1062
+ let value;
1063
+ try {
1064
+ value = await b.getAsync(account);
1065
+ } catch (error) {
1066
+ _noteKeychainFailure(error);
1067
+ throw error;
1068
+ }
1069
+ _noteKeychainOk();
1070
+ if ((_cacheGenerations.get(account) || 0) === generation) _cacheSet(account, value);
1071
+ return value;
1072
+ }
1073
+
1074
+ async function setSecretAsync(account, value) {
1075
+ const b = backend();
1076
+ if (!b.setAsync) return setSecret(account, value);
1077
+ let stored = false;
1078
+ try {
1079
+ await b.setAsync(account, value);
1080
+ stored = true;
1081
+ _noteKeychainOk();
1082
+ } catch (error) {
1083
+ _noteKeychainFailure(error);
1084
+ throw error;
1085
+ } finally {
1086
+ _cacheInvalidate(account);
1087
+ if (stored) _cacheSet(account, value);
1088
+ }
1089
+ }
1090
+
1091
+ async function deleteSecretAsync(account) {
1092
+ const b = backend();
1093
+ if (!b.deleteAsync) return deleteSecret(account);
1094
+ try {
1095
+ await b.deleteAsync(account);
1096
+ _noteKeychainOk();
1097
+ } catch (error) {
1098
+ _noteKeychainFailure(error);
1099
+ throw error;
1100
+ } finally {
1101
+ _cacheInvalidate(account);
1102
+ }
1103
+ }
1104
+
547
1105
  module.exports = {
548
1106
  getSecret,
549
1107
  setSecret,
550
1108
  deleteSecret,
1109
+ getSecretAsync,
1110
+ setSecretAsync,
1111
+ deleteSecretAsync,
551
1112
  hasSecret,
1113
+ keychainStatus,
552
1114
  invalidateSecretCache,
553
1115
  prewarmSecrets,
1116
+ retryKeychain,
1117
+ refreshKeychainStatus,
1118
+ onKeychainRecovered,
554
1119
  SERVICE,
555
1120
  };