mixdog 1.3.4 → 1.3.5

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 (197) hide show
  1. package/LICENSES/oh-my-pi-MIT.txt +23 -0
  2. package/NOTICE.md +15 -0
  3. package/README.md +11 -0
  4. package/package.json +2 -1
  5. package/scripts/embed-google-oauth-client.mjs +49 -0
  6. package/scripts/lib/test-spawn-ledger.mjs +46 -1
  7. package/scripts/test.mjs +5 -0
  8. package/src/defaults/shipped-history.json +8 -0
  9. package/src/defaults/skills/browser-use/SKILL.md +21 -9
  10. package/src/defaults/skills/setup/SKILL.md +7 -2
  11. package/src/defaults/skills/setup/references/actions.md +82 -27
  12. package/src/defaults/skills/setup/references/surfaces.md +67 -34
  13. package/src/defaults/skills/setup-guide/SKILL.md +3 -3
  14. package/src/lib/keychain-cjs.cjs +359 -29
  15. package/src/rules/agent-mode/AGENT_MODE.md +10 -2
  16. package/src/rules/agent-mode/MIXY.md +12 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  19. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  21. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
  22. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
  23. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
  24. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
  25. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
  26. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
  27. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
  28. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
  30. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
  33. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
  35. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
  36. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
  37. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
  41. package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
  42. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
  43. package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
  45. package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
  46. package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
  47. package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
  48. package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
  49. package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
  50. package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
  51. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
  52. package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
  53. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
  55. package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
  56. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
  57. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
  58. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
  59. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
  60. package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
  61. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
  63. package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
  64. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
  65. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
  69. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
  71. package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
  72. package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
  78. package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
  79. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
  80. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
  81. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  83. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
  84. package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
  86. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
  87. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
  88. package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
  90. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
  91. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  92. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
  97. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
  99. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
  100. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
  102. package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
  103. package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
  104. package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
  105. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
  106. package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
  107. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
  108. package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
  109. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
  110. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
  111. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
  112. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
  113. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
  114. package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
  115. package/src/runtime/channels/lib/scheduler.mjs +109 -14
  116. package/src/runtime/channels/lib/schedules-db.mjs +49 -0
  117. package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
  118. package/src/runtime/channels/lib/worker-main.mjs +9 -0
  119. package/src/runtime/office/com/com-adapter.mjs +17 -1
  120. package/src/runtime/office/com/office-com-host.ps1 +51 -1
  121. package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
  122. package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
  123. package/src/runtime/office/pdf/document-preview.mjs +326 -8
  124. package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
  125. package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
  126. package/src/runtime/office/portable/portable-soffice.mjs +45 -5
  127. package/src/runtime/shared/bot-hops.mjs +62 -2
  128. package/src/runtime/shared/config.mjs +49 -22
  129. package/src/runtime/shared/connector-catalog.d.mts +4 -2
  130. package/src/runtime/shared/connector-catalog.mjs +16 -8
  131. package/src/runtime/shared/developer-options.mjs +32 -1
  132. package/src/runtime/shared/google-oauth-client.generated.json +4 -0
  133. package/src/runtime/shared/google-oauth-client.mjs +34 -9
  134. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
  135. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
  136. package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
  137. package/src/runtime/shared/llm/well-formed.mjs +66 -0
  138. package/src/runtime/shared/provider-accounts.mjs +224 -58
  139. package/src/runtime/shared/provider-api-key.mjs +19 -8
  140. package/src/runtime/shared/routine-trigger.mjs +42 -0
  141. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  142. package/src/runtime/shared/transcript-metadata.mjs +38 -0
  143. package/src/runtime/shared/user-data-guard.mjs +30 -2
  144. package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
  145. package/src/session-runtime/agent-home-delivery.mjs +57 -13
  146. package/src/session-runtime/agent-room-delivery.mjs +28 -1
  147. package/src/session-runtime/agent-task-sessions.mjs +292 -79
  148. package/src/session-runtime/boot/begin.mjs +3 -2
  149. package/src/session-runtime/boot/tools.mjs +2 -0
  150. package/src/session-runtime/bot-chain.mjs +32 -0
  151. package/src/session-runtime/bot-status.mjs +7 -4
  152. package/src/session-runtime/channel-config-api.mjs +63 -17
  153. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
  154. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
  155. package/src/session-runtime/internal-tool-executor.mjs +4 -0
  156. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
  157. package/src/session-runtime/mixy-heartbeat.mjs +807 -0
  158. package/src/session-runtime/model-route/route-tuning.mjs +10 -6
  159. package/src/session-runtime/owned-automation-run.mjs +33 -12
  160. package/src/session-runtime/provider-auth/login.mjs +7 -0
  161. package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
  162. package/src/session-runtime/routine-home-turn.mjs +33 -6
  163. package/src/session-runtime/schedule-session-run.mjs +7 -3
  164. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
  165. package/src/session-runtime/services/channel-admin.mjs +66 -18
  166. package/src/session-runtime/services/channel-worker.mjs +1 -1
  167. package/src/session-runtime/services/provider-admin.mjs +128 -25
  168. package/src/session-runtime/services/schedule-access.mjs +14 -0
  169. package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
  170. package/src/session-runtime/session-create/session-options.mjs +7 -4
  171. package/src/session-runtime/settings-system-api.mjs +16 -1
  172. package/src/session-runtime/setup-tool/executor.mjs +49 -14
  173. package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
  174. package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
  175. package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
  176. package/src/session-runtime/task-session-route.mjs +99 -0
  177. package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
  178. package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
  179. package/src/session-runtime/tool-surface.mjs +4 -0
  180. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
  181. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
  182. package/src/session-runtime/workspace/transitions.mjs +10 -2
  183. package/src/standalone/daemon-session-control.mjs +88 -20
  184. package/src/standalone/daemon.mjs +8 -0
  185. package/src/standalone/session-protocol.mjs +5 -0
  186. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
  187. package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
  188. package/src/standalone/session-service.mjs +13 -0
  189. package/src/standalone/session-tool-output.mjs +8 -5
  190. package/src/tui/app/model-options.mjs +2 -0
  191. package/src/tui/dist/index.mjs +24 -2
  192. package/src/tui/session/notice-surface.mjs +17 -1
  193. package/src/tui/session/session-api/integrations.mjs +1 -0
  194. package/src/tui/session/session-api/lifecycle.mjs +8 -0
  195. package/src/tui/session/session-api/settings.mjs +14 -2
  196. package/src/tui/session/turn-stream.mjs +17 -1
  197. package/src/tui/session/turn.mjs +22 -2
@@ -8,7 +8,7 @@ or requests a setting with no mutation action.
8
8
  | Target | Destination |
9
9
  |---|---|
10
10
  | `settings` | Settings home |
11
- | `providers` | Provider authentication and local providers |
11
+ | `providers` | Provider authentication |
12
12
  | `model` | Main model selection |
13
13
  | `websearch` | Web Search route |
14
14
  | `workflow` | Workflow packs |
@@ -17,51 +17,77 @@ or requests a setting with no mutation action.
17
17
  | `theme` | Theme surface where supported |
18
18
  | `profile` | User profile |
19
19
  | `autoclear` | Session lifecycle controls |
20
- | `memory` | Memory capability surface |
21
- | `mcp` | Extensions → Skill → MCP, including OAuth Connect/Reconnect/Disconnect |
22
- | `skills` | Extensions → Skill → Skills |
23
- | `plugins` | Extensions → Plugin |
20
+ | `memory` | Memory capability surface (Built-in card on the Extensions tab) |
21
+ | `mcp` | Extensions → Connectors tab, including OAuth Connect/Reconnect/Disconnect |
22
+ | `skills` | Skills (activity rail destination) |
23
+ | `plugins` | Extensions → Extensions tab (plugins that bring MCP servers: Connectors → Custom connectors) |
24
24
  | `update` | Update surface |
25
- | `usage` | Provider usage sign-in |
25
+ | `usage` | The /usage surface (provider usage); no sign-in |
26
26
  | `doctor` | Runtime diagnostics |
27
27
  | `context` | Context and compaction |
28
28
  | `developer` | Developer options (TUI: Developer picker) |
29
- | `voice` | Voice built-in (TUI: Settings hub) |
29
+ | `voice` | Voice Built-in card on the Extensions tab (TUI: Settings hub) |
30
30
  | `connection` | Web-app pairing; Desktop only, the TUI shows guidance |
31
31
 
32
32
  An attached Desktop or TUI may navigate and return `opened:true`.
33
33
 
34
34
  ## Desktop map
35
35
 
36
- - Settings → General: profile, Web Search exposure, display language,
37
- side panels, and notifications.
38
- - Settings → Context: auto-compact and auto-clear.
36
+ - Settings → General: profile; Features (Regular check-ins, Reactions, Web
37
+ search, import conversations from other apps (UI-only), Load instruction
38
+ files, Auto reasoning base, link preview); notifications; worktree
39
+ defaults; display language, time format, side panels and their position;
40
+ markdown default view.
41
+ - Settings → Context: Let AI rename sessions, auto-compact (also on model
42
+ change, summary model) and auto-clear.
43
+ - Settings → Model management: "Show in /model" and favorites per model.
39
44
  - Settings → Output style: output style selection, plus the desktop-only
40
45
  "Expand tool activity" choice (collapsed / commands and edits / everything),
41
46
  saved per device; Ctrl+O toggles everything open for the current window.
42
- - Settings → Theme (one page): System / Dark / Light or a skin picture
43
- (plate, accent, transparency), imported JSON color themes (about 26 color
44
- roles), installed fonts, font sizes and chat text size. Saved on this device
45
- only. First-run setup has a Theme step with the same System / Dark /
46
- Light / skin choice.
47
+ - Settings → Theme (one page): System / Dark / Light (`theme: "white"`) or a
48
+ skin picture (plate, accent, photo, transparency), imported JSON color
49
+ themes (about 26 color roles; import/export/remove are UI-only), installed
50
+ fonts, font sizes and chat text size. Saved on this device only. First-run
51
+ setup has a Theme step with the same System / Dark / Light / skin choice.
47
52
  - Settings → Bots (desktop only): the representative bot overlay, a small
48
53
  window with the representative bot that opens a quick message to it. On
49
- by default; options to pin it above the message box (default on), keep it
50
- above other windows (default off: it follows the app window's layer),
51
- collapse its bubble and reset its position. The overlay's right-click
52
- menu has the same options. Saved on this PC.
53
- - Settings → Providers: API-key, OAuth, custom provider registration, local
54
- providers, and usage sign-in. A custom provider's model list waits up to
55
- 60 s, can be skipped, and then takes model ids typed by hand.
56
- - Extensions → Plugin → Git & GitHub: Git/GitHub CLI installation and connection.
57
- - Settings → Connection: web-app pairing and linked devices.
54
+ by default; options: enable, Position (4-way: pinned above the message box
55
+ (default), pinned here, free in the app window, free on screen), keep above
56
+ other windows (default off), collapse bubble, auto-advance long replies,
57
+ close replies automatically, size, and reset position (UI-only). The
58
+ overlay's right-click menu has the same options. Saved on this PC.
59
+ - Settings → Providers: API-key and OAuth authentication, custom provider
60
+ registration, and Usage limits (resume automatically). A custom provider's
61
+ model list waits up to 60 s, can be skipped, and then takes model ids typed
62
+ by hand. Local models are managed in the Local Provider Built-in detail.
63
+ - Settings → Connection (desktop only): web-app pairing, this PC's name and
64
+ the remote-access switch, connected devices (rename, Disconnect all), "PCs
65
+ I opened", SSH servers (per-server settings, server logins, cleanup), and
66
+ "Reopen server windows". Only `revoke_linked_device` has a tool action.
67
+ SSH connect: the address dialog's collapsed "Advanced options" take a login
68
+ key file (typed or browsed) and a default server folder; the key file is
69
+ used for every later connection to that server (reconnects included), and a
70
+ server is saved only after a connection with those settings worked. A server
71
+ that is connected already refuses a changed key or folder: disconnect first.
72
+ Per-server "Model login" is "This PC's accounts" (the default; models run
73
+ through this PC, only while it is connected) or "Server accounts" (models run
74
+ with the logins signed in on the server). "Manage server logins" (Server
75
+ accounts, connected server) logs a provider in or out on the server; an API
76
+ key saved there is kept in a private file (owner-only) when the server has
77
+ no keychain, as on Linux. Changing the model login restarts the server's
78
+ Mixdog after one confirmation and stops its running sessions.
58
79
  - Settings → System: update, keep-awake, run in background after the window
59
80
  closes, and Doctor.
60
- - Settings → Developer options: risk-gated OAuth providers.
81
+ - Settings → Developer options: data-driven sections (approvals, risk-gated
82
+ providers); read them with `status developer`.
61
83
  - Projects: Project registration, name, Project Memories, and Common Memory.
62
84
  - Workflows: workflow packs, Main and Web Search defaults, and agent editing.
63
- - Extensions → Plugin: Built-in cards and plugins.
64
- - Extensions → Skill: skills and MCP servers.
85
+ - Extensions → Extensions tab: Built-in cards (including Git & GitHub, Media
86
+ generation and its Default generator) and plugins.
87
+ - Extensions → Connectors tab: MCP servers and catalog connectors (Gmail
88
+ connects only with an app password; Calendar and Drive offer "Use my
89
+ Google key"), plus Custom connectors (including plugin MCP servers).
90
+ - Skills (activity rail): skills.
65
91
 
66
92
  Provider authentication is intentionally hidden from the remote web app; guide
67
93
  the user to Desktop.
@@ -75,8 +101,8 @@ install or toggles run through the attached Desktop; the receipt identifies
75
101
 
76
102
  Computer observe-only and background Memory cycles (`set_recap_enabled`)
77
103
  have no settings control in either app; setup is their only supported route.
78
- Usage pin and the short + long rail bars live in the usage card's flyout menu
79
- on the activity rail.
104
+ Usage pin, the short + long rail bars, expanded accounts and rail slots live
105
+ in the usage card's flyout menu on the activity rail.
80
106
 
81
107
  Schedules/webhooks and workflow/agent/skill definitions use their runtime
82
108
  actions directly. These do not require clicking their Desktop rails.
@@ -85,16 +111,23 @@ actions directly. These do not require clicking their Desktop rails.
85
111
 
86
112
  The setup tool does not mutate:
87
113
 
88
- - API keys, OAuth, usage sign-in, and GitHub CLI connection;
114
+ - API keys, OAuth, and GitHub CLI connection;
115
+ - turning on the developer option "Allow all actions" (only the person turns
116
+ it on; `set_developer_option` can turn it off);
117
+ - custom provider registration and edits (non-secret fields included), group
118
+ room options, bot persona and avatar, import conversations, reset overlay
119
+ position, SSH and remote-access settings, sidebar/list ordering and other
120
+ per-device view state;
89
121
  - MCP server OAuth sign-in and sign-out (Desktop MCP list/editor, TUI
90
122
  `/mcp login` and `/mcp logout`; remote windows paste the redirect address);
91
123
  - notification permission and subscription on the receiving device;
92
124
  - web-app pairing credentials and OS permission dialogs;
93
125
  - TUI-local theme palettes (use `/theme`; Desktop themes are separate);
94
- - importing or exporting a JSON color theme file, choosing a skin or custom
95
- skin picture, and the Lead's pixel face (stored on the host as `leadAvatar`,
96
- edited from the Desktop or a paired client, and shared by all of them; every
97
- Lead-class row and agent wears it, and the agent edit dialog has no avatar);
126
+ - importing, exporting or removing a JSON color theme file, choosing a skin or
127
+ custom skin picture (plate, accent, photo), and the Lead's pixel face
128
+ (stored on the host as `leadAvatar`, edited from the Desktop or a paired
129
+ client, and shared by all of them; bots and Mixy have per-bot avatars in
130
+ the bot profile editor; only the Workflows agent editor has none);
98
131
  - approval grants ("항상 허용" is per feature and per bot, never for payment,
99
132
  permanent delete, Lead, workflow, Mixy link, group rooms or schedules) and
100
133
  browser action confirmations (`MIXDOG_BROWSER_CONFIRM_ACTIONS`,
@@ -67,9 +67,9 @@ connector links an outside service so bots and agents can read and act in it,
67
67
  and anything that sends or posts asks first through an approval card. Then
68
68
  suggest by what the user does, only these: Gmail (with a Google app password;
69
69
  it needs 2-Step Verification on the Google account, and the connect window
70
- opens the app-password page in the browser), Notion, Linear or Jira. GitHub is
71
- not a connector; it is the signed-in GitHub CLI of Git & GitHub. Do not suggest Google Calendar or Google
72
- Drive; their Google sign-in is still under review. Connect one with
70
+ opens the app-password page in the browser), Google Calendar, Google Drive
71
+ (both with the Google sign-in), Notion, Linear or Jira. GitHub is
72
+ not a connector; it is the signed-in GitHub CLI of Git & GitHub. Connect one with
73
73
  `connect_connector`; it only leaves a card, so tell the user to press it and
74
74
  confirm with `status mcp`.
75
75
 
@@ -341,12 +341,81 @@ function keychainDebug(what, detail) {
341
341
  if (process.env.MIXDOG_KEYCHAIN_DEBUG) console.error(`[keychain:debug] ${what}: ${String(detail).slice(0, 500)}`);
342
342
  }
343
343
 
344
+ // A keychain failure that POSITIVELY means "this host has no usable provider"
345
+ // (secret-tool or libsecret missing, no Secret Service on the bus) as opposed to
346
+ // a keyring that exists but is locked, or any failure nobody recognises. Only
347
+ // the former is served by the private secrets file (see "Linux fallback file");
348
+ // a locked keyring must be unlocked, and an unknown error fails closed.
349
+ const KEYRING_UNAVAILABLE = 'KEYRING_UNAVAILABLE';
350
+
351
+ function keyringUnavailable(message) {
352
+ return Object.assign(new Error(message), { code: KEYRING_UNAVAILABLE });
353
+ }
354
+
355
+ // What secret-tool/libsecret/D-Bus print when there is no provider at all:
356
+ // libsecret missing from the loader, no D-Bus session bus to talk to, or no
357
+ // service owning org.freedesktop.secrets. Anything else is NOT matched.
358
+ const NO_PROVIDER_STDERR = new RegExp(
359
+ [
360
+ 'error while loading shared libraries: libsecret',
361
+ 'libsecret-1\\.so[^\\n]*cannot open shared object',
362
+ 'org\\.freedesktop\\.DBus\\.Error\\.ServiceUnknown',
363
+ 'org\\.freedesktop\\.DBus\\.Error\\.NameHasNoOwner',
364
+ 'org\\.freedesktop\\.DBus\\.Error\\.NoServer',
365
+ 'org\\.freedesktop\\.secrets was not provided by any \\.service files',
366
+ 'name org\\.freedesktop\\.secrets (?:is not|has no owner)',
367
+ 'Cannot autolaunch D-Bus without X11',
368
+ 'Unable to autolaunch a dbus-daemon without a \\$DISPLAY',
369
+ 'Failed to execute child process .{0,6}dbus-launch',
370
+ 'Could not connect: (?:No such file or directory|Connection refused)',
371
+ 'Failed to connect to (?:socket|bus)[^\\n]*(?:No such file or directory|Connection refused)',
372
+ 'Error (?:connecting|spawning command line)[^\\n]*dbus',
373
+ 'Cannot spawn a message bus',
374
+ ].join('|'),
375
+ 'i'
376
+ );
377
+
378
+ const SECRET_TOOL_MISSING_MESSAGE =
379
+ '[keychain] secret-tool is not installed — install libsecret-tools (sudo apt install libsecret-tools), ' +
380
+ 'or set the relevant MIXDOG_*/PROVIDER_API_KEY environment variable instead';
381
+
382
+ // Bounded, non-prompting availability probe: `secret-tool --version` never
383
+ // touches the keyring. Resolves true only when the spawn itself reports ENOENT;
384
+ // an existing tool, a timeout or any other failure is "not confirmed missing".
385
+ function secretToolMissing() {
386
+ return new Promise((resolve) => {
387
+ let child;
388
+ try {
389
+ child = spawn('secret-tool', ['--version'], {
390
+ env: secretToolEnv(),
391
+ stdio: ['ignore', 'ignore', 'ignore'],
392
+ shell: false,
393
+ windowsHide: true,
394
+ });
395
+ } catch (error) {
396
+ resolve(error?.code === 'ENOENT');
397
+ return;
398
+ }
399
+ const timer = setTimeout(() => {
400
+ try {
401
+ child.kill('SIGKILL');
402
+ } catch {}
403
+ resolve(false);
404
+ }, 2000);
405
+ child.once('error', (error) => {
406
+ clearTimeout(timer);
407
+ resolve(error?.code === 'ENOENT');
408
+ });
409
+ child.once('close', () => {
410
+ clearTimeout(timer);
411
+ resolve(false);
412
+ });
413
+ });
414
+ }
415
+
344
416
  function secretToolCheck(r) {
345
417
  if (r.error?.code === 'ENOENT') {
346
- throw new Error(
347
- '[keychain] secret-tool is not installed — install libsecret-tools (sudo apt install libsecret-tools), ' +
348
- 'or set the relevant MIXDOG_*/PROVIDER_API_KEY environment variable instead'
349
- );
418
+ throw keyringUnavailable(SECRET_TOOL_MISSING_MESSAGE);
350
419
  }
351
420
  if (r.error?.code === 'ETIMEDOUT') {
352
421
  throw new Error(
@@ -355,6 +424,8 @@ function secretToolCheck(r) {
355
424
  );
356
425
  }
357
426
  if (r.error) {
427
+ // Not ENOENT: an unknown start failure (EACCES, resource limits) is not
428
+ // proof that no provider exists, so it fails closed instead of falling back.
358
429
  keychainDebug('secret-tool failed to start', r.error.message);
359
430
  throw new Error(
360
431
  '[keychain] secret-tool could not be started — check that libsecret-tools is installed and a Secret Service is running'
@@ -439,13 +510,16 @@ function secretToolFailure(action, r) {
439
510
  const detail = (r.stderr || '').trim() || `exit ${r.status}`;
440
511
  // User-facing text is fixed; the raw tool output only goes to the debug log.
441
512
  keychainDebug(`secret-tool ${action} failed`, detail);
442
- if (/locked|denied|dismissed|cancel|not authori[sz]ed|IsLocked/i.test(detail)) {
513
+ if (/locked|unlock|denied|dismissed|cancel|not authori[sz]ed|IsLocked/i.test(detail)) {
443
514
  return new Error(
444
515
  `[keychain] secret-tool ${action} failed: the keyring is locked or the unlock prompt was declined. ` +
445
516
  'Unlock your keyring and retry.'
446
517
  );
447
518
  }
448
- return new Error(`[keychain] secret-tool ${action} failed — ${secretToolHint()}`);
519
+ const message = `[keychain] secret-tool ${action} failed — ${secretToolHint()}`;
520
+ // Only a recognised "no provider" report may fall back to the secrets file.
521
+ // Anything else (storage error, unknown output) fails closed with this error.
522
+ return NO_PROVIDER_STDERR.test(detail) ? keyringUnavailable(message) : new Error(message);
449
523
  }
450
524
 
451
525
  function linuxGetBlocking(account) {
@@ -604,12 +678,32 @@ function _noteKeychainFailure(error) {
604
678
  if (error?.code === 'KEYRING_UNREACHABLE') state = 'unreachable';
605
679
  else if (/not installed/.test(message)) state = 'missing';
606
680
  else if (/locked|timed out/.test(message)) state = 'locked';
607
- _keychainStatus = { state, message };
681
+ // Only a confirmed missing provider counts. A failed passive probe
682
+ // (KEYRING_UNREACHABLE: gdbus missing, unparsable reply) proves nothing, so
683
+ // it keeps its warning and recovery action.
684
+ _keychainStatus = { state, message, unavailable: error?.code === KEYRING_UNAVAILABLE };
685
+ }
686
+
687
+ // Sync entry points feed the same health the async ones do (Linux only).
688
+ function _noteSyncOutcome(error) {
689
+ if (platform() !== 'linux') return;
690
+ if (error) _noteKeychainFailure(error);
691
+ else _noteKeychainOk();
608
692
  }
609
693
 
610
- /** Linux keyring health for the UI: ok | locked | missing | error | unknown. */
694
+ /** Linux keyring health for the UI: ok | locked | missing | unreachable | error |
695
+ * unknown. `fallback` is true only when a keychain write/read confirmed that
696
+ * this host has no provider, so secrets live in the private secrets file
697
+ * instead: a working store, not a failure to report. A locked or unreachable
698
+ * keyring is never a fallback. */
611
699
  function keychainStatus() {
612
- return { ..._keychainStatus };
700
+ const { unavailable, ...status } = _keychainStatus;
701
+ const fallback = unavailable === true && platform() === 'linux';
702
+ // `unsafeStore` ({ path: the secrets directory, kind: type | owner |
703
+ // permissions }, never a value) says the private file store is being ignored
704
+ // because its layout is unsafe — see fallbackUnsafeStore.
705
+ const unsafe = fallback ? fallbackUnsafeStore() : null;
706
+ return { ...status, fallback, ...(unsafe ? { unsafeStore: { path: unsafe.dir, kind: unsafe.kind } } : {}) };
613
707
  }
614
708
 
615
709
  function _linuxLoaded() {
@@ -668,6 +762,10 @@ function parseSecretToolSearchAccounts(stdout, stderr) {
668
762
  async function _linuxAccountsToRead(interactive, remaining) {
669
763
  let found = await searchMixdogItems();
670
764
  if (found === null && !interactive) {
765
+ // An inconclusive passive probe is "unreachable" only while a provider may
766
+ // exist. If secret-tool itself is not installed (headless host without
767
+ // gdbus either) that is a confirmed missing provider, not a warning.
768
+ if (await secretToolMissing()) throw keyringUnavailable(SECRET_TOOL_MISSING_MESSAGE);
671
769
  const error = new Error(LINUX_UNREACHABLE_MESSAGE);
672
770
  error.code = 'KEYRING_UNREACHABLE';
673
771
  throw error;
@@ -996,6 +1094,196 @@ function win32Delete(account) {
996
1094
  }
997
1095
  }
998
1096
 
1097
+ // ---------------------------------------------------------------------------
1098
+ // Linux fallback file — headless hosts without a usable keychain provider
1099
+ // ---------------------------------------------------------------------------
1100
+ // When secret-tool/libsecret is missing or no Secret Service is running (a
1101
+ // headless SSH server, WSL), a secret is kept in `<dataDir>/secrets/
1102
+ // <account>.secret`: one owner-only (0600) file per account in an owner-only
1103
+ // (0700) directory, written atomically. Never in mixdog-config.json. A read
1104
+ // re-verifies that layout (no symlink, regular file, this user's, no group/other
1105
+ // access) and treats a store that fails it as absent. A locked keyring is NOT
1106
+ // such a host — it fails with its unlock message instead.
1107
+ // Order: the keychain is always tried first and wins when it has the account;
1108
+ // the file is read only when the keychain has nothing (or cannot be read), so a
1109
+ // value saved here stays readable after a keychain becomes available, and a
1110
+ // later save into an available keychain supersedes (and removes) the file value.
1111
+
1112
+ function usesFallback(error) {
1113
+ return platform() === 'linux' && error?.code === KEYRING_UNAVAILABLE;
1114
+ }
1115
+
1116
+ function fallbackFile(account) {
1117
+ return path.join(secretsDir(), `${encodeURIComponent(account)}.secret`);
1118
+ }
1119
+
1120
+ function unsafeFallback(message, kind) {
1121
+ return Object.assign(new Error(message), { code: 'EUNSAFE', ...(kind ? { kind } : {}) });
1122
+ }
1123
+
1124
+ // Owner and mode bits are POSIX: without getuid (Windows) there is nothing to compare.
1125
+ function ownedByUser(stat) {
1126
+ return typeof process.getuid !== 'function' || stat.uid === process.getuid();
1127
+ }
1128
+
1129
+ function groupOrOtherAccessible(stat) {
1130
+ return typeof process.getuid === 'function' && (stat.mode & 0o077) !== 0;
1131
+ }
1132
+
1133
+ // Why a stat (an lstat/fstat of the store directory, or of an account file)
1134
+ // disqualifies it, as the repair it needs: 'type' (a symlink or not a regular
1135
+ // file/directory: remove it), 'owner' (someone else's: chown it), 'permissions'
1136
+ // (group/other can reach it: chmod it); null when it is fine.
1137
+ function unsafeKind(stat, directory) {
1138
+ if (directory ? !stat.isDirectory() : !stat.isFile()) return 'type';
1139
+ if (!ownedByUser(stat)) return 'owner';
1140
+ return groupOrOtherAccessible(stat) ? 'permissions' : null;
1141
+ }
1142
+
1143
+ // The verified secrets directory, or null when absent and not `create`d. A
1144
+ // pre-existing symlink, a non-directory, or a directory owned by someone else is
1145
+ // refused: following it would chmod and write outside the data dir. lstat does
1146
+ // not follow links, so a symlink never passes isDirectory(). `create` repairs
1147
+ // the mode itself (chmod 0700); a reader passes `requirePrivate` and refuses a
1148
+ // directory that group/other can reach instead.
1149
+ function fallbackDir({ create = false, requirePrivate = false } = {}) {
1150
+ const dir = secretsDir();
1151
+ let before;
1152
+ try {
1153
+ before = fs.lstatSync(dir);
1154
+ } catch (error) {
1155
+ if (error?.code !== 'ENOENT') throw error;
1156
+ if (!create) return null;
1157
+ fs.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 });
1158
+ fs.mkdirSync(dir, { mode: 0o700 });
1159
+ before = fs.lstatSync(dir);
1160
+ }
1161
+ const kind = unsafeKind(before, true);
1162
+ if (kind && (kind !== 'permissions' || requirePrivate)) {
1163
+ throw unsafeFallback('the secrets directory is not a private directory owned by this user', kind);
1164
+ }
1165
+ if (create) {
1166
+ fs.chmodSync(dir, 0o700);
1167
+ // The chmod must have hit the directory that was verified, not a swapped-in link.
1168
+ const after = fs.lstatSync(dir);
1169
+ if (!after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev) {
1170
+ throw unsafeFallback('the secrets directory changed while it was being secured');
1171
+ }
1172
+ }
1173
+ return dir;
1174
+ }
1175
+
1176
+ // A stored value is served only from a private regular file owned by this user.
1177
+ // The file is opened without following a symlink (O_NOFOLLOW; ELOOP means it is
1178
+ // one) and without blocking on a FIFO (O_NONBLOCK), then judged by fstat on the
1179
+ // descriptor that is actually read, so nothing can be swapped in between check
1180
+ // and read. Anything else reads as absent: the EUNSAFE reason is thrown like the
1181
+ // directory's, and callers that tolerate a failed read treat it as "no secret".
1182
+ function fallbackGet(account) {
1183
+ if (platform() !== 'linux') return null;
1184
+ if (fallbackDir({ requirePrivate: true }) === null) return null;
1185
+ const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fs.constants;
1186
+ let descriptor;
1187
+ try {
1188
+ descriptor = fs.openSync(fallbackFile(account), O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
1189
+ } catch (error) {
1190
+ if (error?.code === 'ENOENT') return null;
1191
+ if (error?.code === 'ELOOP') {
1192
+ throw unsafeFallback('the secret file is a symbolic link, not a private file', 'type');
1193
+ }
1194
+ throw error;
1195
+ }
1196
+ try {
1197
+ const kind = unsafeKind(fs.fstatSync(descriptor), false);
1198
+ if (kind) throw unsafeFallback('the secret file is not a private regular file owned by this user', kind);
1199
+ return fs.readFileSync(descriptor, 'utf8') || null;
1200
+ } finally {
1201
+ fs.closeSync(descriptor);
1202
+ }
1203
+ }
1204
+
1205
+ // Passive health check for the status path: `{ dir, kind }` when the store has a
1206
+ // layout fallbackGet refuses (so its keys read as absent), else null. The read
1207
+ // path swallows that refusal when the keychain also failed, so without this the
1208
+ // user would see a bare "No key". Every call scans the directory afresh, so an
1209
+ // external repair shows at once; callers that need it for many rows ask once
1210
+ // (provider setup passes the one answer down).
1211
+ function fallbackUnsafeStore() {
1212
+ if (platform() !== 'linux') return null;
1213
+ const dir = secretsDir();
1214
+ let value = null;
1215
+ try {
1216
+ if (fallbackDir({ requirePrivate: true }) !== null) {
1217
+ for (const name of fs.readdirSync(dir)) {
1218
+ if (!name.endsWith('.secret')) continue;
1219
+ const kind = unsafeKind(fs.lstatSync(path.join(dir, name)), false);
1220
+ if (kind) {
1221
+ value = { dir, kind };
1222
+ break;
1223
+ }
1224
+ }
1225
+ }
1226
+ } catch (error) {
1227
+ if (error?.code === 'EUNSAFE') value = { dir, kind: error.kind };
1228
+ }
1229
+ return value;
1230
+ }
1231
+
1232
+ /** Whether a read of `account` would be served by the Linux fallback file: the
1233
+ * file exists and the keychain does not hold the account. */
1234
+ function hasFallbackSecret(account) {
1235
+ // A read prefers the keychain: a value it holds (cached) wins over the file.
1236
+ if (_cacheGet(account) != null) return false;
1237
+ try {
1238
+ return fallbackGet(account) != null;
1239
+ } catch {
1240
+ return false;
1241
+ }
1242
+ }
1243
+
1244
+ // For a keychain that threw: an unreadable fallback must not mask that error.
1245
+ function fallbackGetQuiet(account) {
1246
+ try {
1247
+ return fallbackGet(account);
1248
+ } catch {
1249
+ return null;
1250
+ }
1251
+ }
1252
+
1253
+ function fallbackSet(account, value, keychainError) {
1254
+ try {
1255
+ const dir = fallbackDir({ create: true });
1256
+ writeOwnerOnlyAtomicSync(path.join(dir, path.basename(fallbackFile(account))), value);
1257
+ } catch (error) {
1258
+ // The message carries the filesystem error only, never the value.
1259
+ const reason = error?.code || 'unknown error';
1260
+ throw Object.assign(
1261
+ new Error(`${keychainError.message}\n The private secrets file could not be written either: ${reason}`),
1262
+ { cause: error }
1263
+ );
1264
+ }
1265
+ }
1266
+
1267
+ function fallbackRemove(account) {
1268
+ if (platform() !== 'linux') return;
1269
+ if (fallbackDir() === null) return;
1270
+ try {
1271
+ fs.unlinkSync(fallbackFile(account));
1272
+ } catch (error) {
1273
+ if (error?.code !== 'ENOENT') throw error;
1274
+ }
1275
+ }
1276
+
1277
+ // After a save into the keychain: the file value is now stale. Best effort —
1278
+ // the keychain write already succeeded.
1279
+ function fallbackSupersede(account) {
1280
+ try {
1281
+ fallbackRemove(account);
1282
+ } catch (error) {
1283
+ keychainDebug('could not remove superseded fallback secret', error?.message);
1284
+ }
1285
+ }
1286
+
999
1287
  // ---------------------------------------------------------------------------
1000
1288
  // Public API
1001
1289
  // ---------------------------------------------------------------------------
@@ -1022,9 +1310,18 @@ function backend() {
1022
1310
  function getSecret(account) {
1023
1311
  const cached = _cacheGet(account);
1024
1312
  if (cached !== undefined) return cached;
1025
- const value = backend().get(account);
1313
+ let value;
1314
+ try {
1315
+ value = backend().get(account);
1316
+ } catch (error) {
1317
+ // Unreadable keychain (no provider, locked, not read yet): a secret saved
1318
+ // to the fallback file is still there; otherwise the keychain error stands.
1319
+ const stored = fallbackGetQuiet(account);
1320
+ if (stored == null) throw error;
1321
+ return stored;
1322
+ }
1026
1323
  _cacheSet(account, value);
1027
- return value;
1324
+ return value ?? fallbackGet(account);
1028
1325
  }
1029
1326
 
1030
1327
  function hasSecret(account) {
@@ -1040,19 +1337,39 @@ function hasSecret(account) {
1040
1337
 
1041
1338
  function setSecret(account, value) {
1042
1339
  let stored = false;
1340
+ let viaFile = false;
1043
1341
  try {
1044
- backend().set(account, value);
1342
+ try {
1343
+ backend().set(account, value);
1344
+ _noteSyncOutcome(null);
1345
+ fallbackSupersede(account);
1346
+ } catch (error) {
1347
+ _noteSyncOutcome(error);
1348
+ if (!usesFallback(error)) throw error;
1349
+ fallbackSet(account, value, error);
1350
+ viaFile = true;
1351
+ }
1045
1352
  stored = true;
1046
1353
  } finally {
1047
1354
  _cacheInvalidate(account);
1048
- // Linux reads are cache-only, so a fresh write must stay readable.
1049
- if (stored && platform() === 'linux') _cacheSet(account, value);
1355
+ // Linux reads are cache-only, so a fresh keychain write must stay readable.
1356
+ // A file-only value is not cached: reads reach it through the file, and the
1357
+ // cache then means "the keychain holds this".
1358
+ if (stored && !viaFile && platform() === 'linux') _cacheSet(account, value);
1050
1359
  }
1051
1360
  }
1052
1361
 
1053
1362
  function deleteSecret(account) {
1054
1363
  try {
1055
- backend().delete(account);
1364
+ fallbackRemove(account);
1365
+ try {
1366
+ backend().delete(account);
1367
+ _noteSyncOutcome(null);
1368
+ } catch (error) {
1369
+ _noteSyncOutcome(error);
1370
+ // No keychain provider here: nothing can be stored in it to delete.
1371
+ if (!usesFallback(error)) throw error;
1372
+ }
1056
1373
  } finally {
1057
1374
  _cacheInvalidate(account);
1058
1375
  }
@@ -1065,34 +1382,42 @@ async function getSecretAsync(account) {
1065
1382
  if (cached !== undefined) return cached;
1066
1383
  const b = backend();
1067
1384
  if (!b.getAsync) return getSecret(account);
1068
- if (_linuxLoaded()) return null;
1385
+ if (_linuxLoaded()) return fallbackGet(account);
1069
1386
  const generation = _cacheGenerations.get(account) || 0;
1070
1387
  let value;
1071
1388
  try {
1072
1389
  value = await b.getAsync(account);
1073
1390
  } catch (error) {
1074
1391
  _noteKeychainFailure(error);
1075
- throw error;
1392
+ const stored = fallbackGetQuiet(account);
1393
+ if (stored == null) throw error;
1394
+ return stored;
1076
1395
  }
1077
1396
  _noteKeychainOk();
1078
1397
  if ((_cacheGenerations.get(account) || 0) === generation) _cacheSet(account, value);
1079
- return value;
1398
+ return value ?? fallbackGet(account);
1080
1399
  }
1081
1400
 
1082
1401
  async function setSecretAsync(account, value) {
1083
1402
  const b = backend();
1084
1403
  if (!b.setAsync) return setSecret(account, value);
1085
1404
  let stored = false;
1405
+ let viaFile = false;
1086
1406
  try {
1087
- await b.setAsync(account, value);
1407
+ try {
1408
+ await b.setAsync(account, value);
1409
+ _noteKeychainOk();
1410
+ fallbackSupersede(account);
1411
+ } catch (error) {
1412
+ _noteKeychainFailure(error);
1413
+ if (!usesFallback(error)) throw error;
1414
+ fallbackSet(account, value, error);
1415
+ viaFile = true;
1416
+ }
1088
1417
  stored = true;
1089
- _noteKeychainOk();
1090
- } catch (error) {
1091
- _noteKeychainFailure(error);
1092
- throw error;
1093
1418
  } finally {
1094
1419
  _cacheInvalidate(account);
1095
- if (stored) _cacheSet(account, value);
1420
+ if (stored && !viaFile) _cacheSet(account, value);
1096
1421
  }
1097
1422
  }
1098
1423
 
@@ -1100,11 +1425,15 @@ async function deleteSecretAsync(account) {
1100
1425
  const b = backend();
1101
1426
  if (!b.deleteAsync) return deleteSecret(account);
1102
1427
  try {
1103
- await b.deleteAsync(account);
1104
- _noteKeychainOk();
1105
- } catch (error) {
1106
- _noteKeychainFailure(error);
1107
- throw error;
1428
+ fallbackRemove(account);
1429
+ try {
1430
+ await b.deleteAsync(account);
1431
+ _noteKeychainOk();
1432
+ } catch (error) {
1433
+ _noteKeychainFailure(error);
1434
+ // No keychain provider here: nothing can be stored in it to delete.
1435
+ if (!usesFallback(error)) throw error;
1436
+ }
1108
1437
  } finally {
1109
1438
  _cacheInvalidate(account);
1110
1439
  }
@@ -1118,6 +1447,7 @@ module.exports = {
1118
1447
  setSecretAsync,
1119
1448
  deleteSecretAsync,
1120
1449
  hasSecret,
1450
+ hasFallbackSecret,
1121
1451
  keychainStatus,
1122
1452
  invalidateSecretCache,
1123
1453
  prewarmSecrets,