@poolzin/pool-bot 2026.2.23 → 2026.2.25

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 (235) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/acp/client.js +207 -18
  3. package/dist/acp/secret-file.js +22 -0
  4. package/dist/agents/agent-scope.js +10 -0
  5. package/dist/agents/bash-process-registry.test-helpers.js +29 -0
  6. package/dist/agents/bash-tools.exec-approval-request.js +20 -0
  7. package/dist/agents/bash-tools.exec-host-gateway.js +230 -0
  8. package/dist/agents/bash-tools.exec-host-node.js +235 -0
  9. package/dist/agents/bash-tools.exec-types.js +1 -0
  10. package/dist/agents/bash-tools.process.js +224 -218
  11. package/dist/agents/content-blocks.js +16 -0
  12. package/dist/agents/model-fallback.js +96 -101
  13. package/dist/agents/models-config.providers.js +299 -182
  14. package/dist/agents/pi-embedded-payloads.js +1 -0
  15. package/dist/agents/pi-embedded-runner/run.overflow-compaction.fixture.js +34 -0
  16. package/dist/agents/skills.test-helpers.js +13 -0
  17. package/dist/agents/stable-stringify.js +12 -0
  18. package/dist/agents/subagent-registry.mocks.shared.js +12 -0
  19. package/dist/agents/test-helpers/assistant-message-fixtures.js +29 -0
  20. package/dist/agents/test-helpers/pi-tools-sandbox-context.js +27 -0
  21. package/dist/agents/tool-policy-shared.js +108 -0
  22. package/dist/agents/tools/browser-tool.js +160 -54
  23. package/dist/agents/tools/cron-tool.test-helpers.js +12 -0
  24. package/dist/agents/tools/discord-actions-moderation-shared.js +27 -0
  25. package/dist/agents/tools/image-tool.js +214 -99
  26. package/dist/agents/tools/sessions-history-tool.js +140 -108
  27. package/dist/agents/workspace.js +222 -46
  28. package/dist/auto-reply/commands-registry.js +15 -18
  29. package/dist/auto-reply/fallback-state.js +114 -0
  30. package/dist/auto-reply/model-runtime.js +68 -0
  31. package/dist/auto-reply/reply/agent-runner-execution.js +36 -4
  32. package/dist/auto-reply/reply/agent-runner.js +165 -39
  33. package/dist/auto-reply/reply/commands-setunset-standard.js +13 -0
  34. package/dist/browser/config.js +26 -0
  35. package/dist/browser/navigation-guard.js +31 -0
  36. package/dist/browser/routes/agent.act.js +431 -424
  37. package/dist/browser/routes/agent.shared.js +47 -3
  38. package/dist/browser/routes/agent.snapshot.js +122 -116
  39. package/dist/browser/routes/agent.storage.js +303 -297
  40. package/dist/browser/routes/tabs.js +154 -100
  41. package/dist/browser/server-lifecycle.js +37 -0
  42. package/dist/build-info.json +3 -3
  43. package/dist/channels/allow-from.js +25 -0
  44. package/dist/channels/plugins/account-action-gate.js +13 -0
  45. package/dist/channels/plugins/message-actions.js +10 -0
  46. package/dist/channels/telegram/api.js +18 -0
  47. package/dist/cli/argv.js +84 -21
  48. package/dist/cli/banner.js +2 -1
  49. package/dist/cli/exec-approvals-cli.js +92 -124
  50. package/dist/cli/memory-cli.js +158 -61
  51. package/dist/cli/nodes-cli/register.push.js +63 -0
  52. package/dist/cli/nodes-media-utils.js +21 -0
  53. package/dist/cli/plugins-cli.js +245 -61
  54. package/dist/cli/program/build-program.js +3 -1
  55. package/dist/cli/program/command-registry.js +223 -136
  56. package/dist/cli/program/help.js +43 -12
  57. package/dist/cli/route.js +1 -1
  58. package/dist/cli/test-runtime-capture.js +24 -0
  59. package/dist/commands/agent.js +163 -87
  60. package/dist/commands/channels.mock-harness.js +23 -0
  61. package/dist/commands/daemon-install-runtime-warning.js +11 -0
  62. package/dist/commands/onboard-helpers.js +4 -4
  63. package/dist/commands/sessions.test-helpers.js +61 -0
  64. package/dist/compat/legacy-names.js +2 -2
  65. package/dist/config/commands.js +3 -0
  66. package/dist/config/config.js +1 -1
  67. package/dist/config/env-substitution.js +62 -34
  68. package/dist/config/env-vars.js +9 -0
  69. package/dist/config/io.js +571 -171
  70. package/dist/config/merge-patch.js +50 -4
  71. package/dist/config/redact-snapshot.js +404 -76
  72. package/dist/config/schema.js +58 -570
  73. package/dist/config/validation.js +140 -85
  74. package/dist/config/zod-schema.hooks.js +40 -11
  75. package/dist/config/zod-schema.installs.js +20 -0
  76. package/dist/config/zod-schema.js +8 -7
  77. package/dist/control-ui/assets/{index-HRr1grwl.js → index-Dvkl4Xlx.js} +2 -1
  78. package/dist/control-ui/assets/{index-HRr1grwl.js.map → index-Dvkl4Xlx.js.map} +1 -1
  79. package/dist/control-ui/index.html +1 -1
  80. package/dist/daemon/cmd-argv.js +21 -0
  81. package/dist/daemon/cmd-set.js +58 -0
  82. package/dist/daemon/service-types.js +1 -0
  83. package/dist/discord/monitor/exec-approvals.js +357 -162
  84. package/dist/gateway/auth.js +38 -3
  85. package/dist/gateway/call.js +149 -68
  86. package/dist/gateway/canvas-capability.js +75 -0
  87. package/dist/gateway/control-plane-audit.js +28 -0
  88. package/dist/gateway/control-plane-rate-limit.js +53 -0
  89. package/dist/gateway/events.js +1 -0
  90. package/dist/gateway/hooks.js +109 -54
  91. package/dist/gateway/http-common.js +22 -0
  92. package/dist/gateway/method-scopes.js +169 -0
  93. package/dist/gateway/net.js +23 -0
  94. package/dist/gateway/openresponses-http.js +120 -110
  95. package/dist/gateway/probe-auth.js +2 -0
  96. package/dist/gateway/protocol/index.js +3 -2
  97. package/dist/gateway/protocol/schema/protocol-schemas.js +2 -0
  98. package/dist/gateway/protocol/schema/push.js +18 -0
  99. package/dist/gateway/protocol/schema.js +1 -0
  100. package/dist/gateway/server-http.js +236 -52
  101. package/dist/gateway/server-methods/agent.js +162 -24
  102. package/dist/gateway/server-methods/chat.js +461 -130
  103. package/dist/gateway/server-methods/config.js +193 -150
  104. package/dist/gateway/server-methods/nodes.helpers.js +12 -0
  105. package/dist/gateway/server-methods/nodes.js +251 -69
  106. package/dist/gateway/server-methods/push.js +53 -0
  107. package/dist/gateway/server-reload-handlers.js +2 -3
  108. package/dist/gateway/server-runtime-config.js +5 -0
  109. package/dist/gateway/server-runtime-state.js +2 -0
  110. package/dist/gateway/server-ws-runtime.js +1 -0
  111. package/dist/gateway/server.impl.js +296 -139
  112. package/dist/gateway/session-preview.test-helpers.js +11 -0
  113. package/dist/gateway/startup-auth.js +126 -0
  114. package/dist/gateway/test-helpers.agent-results.js +15 -0
  115. package/dist/gateway/test-helpers.mocks.js +37 -14
  116. package/dist/gateway/test-helpers.server.js +161 -77
  117. package/dist/hooks/bundled/session-memory/handler.js +165 -34
  118. package/dist/hooks/gmail-watcher-lifecycle.js +23 -0
  119. package/dist/infra/archive-path.js +49 -0
  120. package/dist/infra/device-pairing.js +148 -167
  121. package/dist/infra/exec-approvals-allowlist.js +19 -70
  122. package/dist/infra/exec-approvals-analysis.js +44 -17
  123. package/dist/infra/exec-safe-bin-policy.js +269 -0
  124. package/dist/infra/fixed-window-rate-limit.js +33 -0
  125. package/dist/infra/git-root.js +61 -0
  126. package/dist/infra/heartbeat-active-hours.js +2 -2
  127. package/dist/infra/heartbeat-reason.js +40 -0
  128. package/dist/infra/heartbeat-runner.js +72 -32
  129. package/dist/infra/install-source-utils.js +91 -7
  130. package/dist/infra/node-pairing.js +50 -105
  131. package/dist/infra/npm-integrity.js +45 -0
  132. package/dist/infra/npm-pack-install.js +40 -0
  133. package/dist/infra/outbound/channel-adapters.js +20 -7
  134. package/dist/infra/outbound/message-action-runner.js +107 -327
  135. package/dist/infra/outbound/message.js +59 -36
  136. package/dist/infra/outbound/outbound-policy.js +52 -25
  137. package/dist/infra/outbound/outbound-send-service.js +58 -71
  138. package/dist/infra/pairing-files.js +10 -0
  139. package/dist/infra/plain-object.js +9 -0
  140. package/dist/infra/push-apns.js +365 -0
  141. package/dist/infra/restart-sentinel.js +16 -1
  142. package/dist/infra/restart.js +229 -26
  143. package/dist/infra/scp-host.js +54 -0
  144. package/dist/infra/update-startup.js +86 -9
  145. package/dist/media/inbound-path-policy.js +114 -0
  146. package/dist/media/input-files.js +16 -0
  147. package/dist/memory/test-manager.js +8 -0
  148. package/dist/plugin-sdk/temp-path.js +47 -0
  149. package/dist/plugins/discovery.js +217 -23
  150. package/dist/plugins/hook-runner-global.js +16 -0
  151. package/dist/plugins/loader.js +192 -26
  152. package/dist/plugins/logger.js +8 -0
  153. package/dist/plugins/manifest-registry.js +3 -0
  154. package/dist/plugins/path-safety.js +34 -0
  155. package/dist/plugins/registry.js +5 -2
  156. package/dist/plugins/runtime/index.js +271 -206
  157. package/dist/providers/github-copilot-models.js +4 -1
  158. package/dist/security/audit-channel.js +8 -19
  159. package/dist/security/audit-extra.async.js +354 -182
  160. package/dist/security/audit-extra.js +11 -1
  161. package/dist/security/audit-extra.sync.js +340 -33
  162. package/dist/security/audit-fs.js +31 -13
  163. package/dist/security/audit.js +145 -371
  164. package/dist/security/dm-policy-shared.js +24 -0
  165. package/dist/security/external-content.js +20 -8
  166. package/dist/security/fix.js +49 -85
  167. package/dist/security/scan-paths.js +20 -0
  168. package/dist/security/secret-equal.js +3 -7
  169. package/dist/security/windows-acl.js +30 -15
  170. package/dist/shared/node-list-parse.js +13 -0
  171. package/dist/shared/operator-scope-compat.js +37 -0
  172. package/dist/shared/text-chunking.js +29 -0
  173. package/dist/slack/blocks.test-helpers.js +31 -0
  174. package/dist/slack/monitor/mrkdwn.js +8 -0
  175. package/dist/telegram/bot-message-dispatch.js +366 -164
  176. package/dist/telegram/draft-stream.js +30 -7
  177. package/dist/telegram/reasoning-lane-coordinator.js +128 -0
  178. package/dist/terminal/prompt-select-styled.js +9 -0
  179. package/dist/test-utils/command-runner.js +6 -0
  180. package/dist/test-utils/internal-hook-event-payload.js +10 -0
  181. package/dist/test-utils/model-auth-mock.js +12 -0
  182. package/dist/test-utils/provider-usage-fetch.js +14 -0
  183. package/dist/test-utils/temp-home.js +33 -0
  184. package/dist/tui/components/chat-log.js +9 -0
  185. package/dist/tui/tui-command-handlers.js +36 -27
  186. package/dist/tui/tui-event-handlers.js +122 -32
  187. package/dist/tui/tui.js +181 -45
  188. package/dist/utils/mask-api-key.js +10 -0
  189. package/dist/utils/run-with-concurrency.js +39 -0
  190. package/dist/web/media.js +4 -0
  191. package/docs/tools/slash-commands.md +5 -1
  192. package/extensions/bluebubbles/package.json +1 -1
  193. package/extensions/copilot-proxy/package.json +1 -1
  194. package/extensions/diagnostics-otel/package.json +1 -1
  195. package/extensions/discord/package.json +1 -1
  196. package/extensions/feishu/package.json +1 -1
  197. package/extensions/feishu/src/external-keys.ts +19 -0
  198. package/extensions/google-antigravity-auth/package.json +1 -1
  199. package/extensions/google-gemini-cli-auth/package.json +1 -1
  200. package/extensions/googlechat/package.json +1 -1
  201. package/extensions/imessage/package.json +1 -1
  202. package/extensions/irc/package.json +1 -1
  203. package/extensions/line/package.json +1 -1
  204. package/extensions/llm-task/package.json +1 -1
  205. package/extensions/lobster/package.json +1 -1
  206. package/extensions/lobster/src/windows-spawn.ts +193 -0
  207. package/extensions/matrix/CHANGELOG.md +5 -0
  208. package/extensions/matrix/package.json +1 -1
  209. package/extensions/matrix/src/matrix/actions/limits.ts +6 -0
  210. package/extensions/mattermost/package.json +1 -1
  211. package/extensions/mattermost/src/mattermost/reactions.test-helpers.ts +83 -0
  212. package/extensions/memory-core/package.json +1 -1
  213. package/extensions/memory-lancedb/package.json +1 -1
  214. package/extensions/minimax-portal-auth/package.json +1 -1
  215. package/extensions/msteams/CHANGELOG.md +5 -0
  216. package/extensions/msteams/package.json +1 -1
  217. package/extensions/nextcloud-talk/package.json +1 -1
  218. package/extensions/nostr/CHANGELOG.md +5 -0
  219. package/extensions/nostr/package.json +1 -1
  220. package/extensions/open-prose/package.json +1 -1
  221. package/extensions/openai-codex-auth/package.json +1 -1
  222. package/extensions/signal/package.json +1 -1
  223. package/extensions/slack/package.json +1 -1
  224. package/extensions/telegram/package.json +1 -1
  225. package/extensions/tlon/package.json +1 -1
  226. package/extensions/twitch/CHANGELOG.md +5 -0
  227. package/extensions/twitch/package.json +1 -1
  228. package/extensions/voice-call/CHANGELOG.md +5 -0
  229. package/extensions/voice-call/package.json +1 -1
  230. package/extensions/whatsapp/package.json +1 -1
  231. package/extensions/zalo/CHANGELOG.md +5 -0
  232. package/extensions/zalo/package.json +1 -1
  233. package/extensions/zalouser/CHANGELOG.md +5 -0
  234. package/extensions/zalouser/package.json +1 -1
  235. package/package.json +1 -1
package/dist/config/io.js CHANGED
@@ -2,19 +2,25 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import JSON5 from "json5";
7
+ import { loadDotEnv } from "../infra/dotenv.js";
8
+ import { resolveRequiredHomeDir } from "../infra/home-dir.js";
6
9
  import { loadShellEnvFallback, resolveShellEnvFallbackTimeoutMs, shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js";
10
+ import { VERSION } from "../version.js";
7
11
  import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
12
+ import { rotateConfigBackups } from "./backup-rotation.js";
8
13
  import { applyCompactionDefaults, applyContextPruningDefaults, applyAgentDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js";
9
- import { VERSION } from "../version.js";
10
- import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
11
- import { collectConfigEnvVars } from "./env-vars.js";
14
+ import { restoreEnvVarRefs } from "./env-preserve.js";
15
+ import { MissingEnvVarError, containsEnvVarReference, resolveConfigEnvVars, } from "./env-substitution.js";
16
+ import { applyConfigEnvVars } from "./env-vars.js";
12
17
  import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
13
18
  import { findLegacyConfigIssues } from "./legacy.js";
19
+ import { applyMergePatch } from "./merge-patch.js";
14
20
  import { normalizeConfigPaths } from "./normalize-paths.js";
15
21
  import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
16
22
  import { applyConfigOverrides } from "./runtime-overrides.js";
17
- import { validateConfigObjectWithPlugins } from "./validation.js";
23
+ import { validateConfigObjectRawWithPlugins, validateConfigObjectWithPlugins, } from "./validation.js";
18
24
  import { comparePoolbotVersions } from "./version.js";
19
25
  // Re-export for backwards compatibility
20
26
  export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
@@ -36,10 +42,8 @@ const SHELL_ENV_EXPECTED_KEYS = [
36
42
  "SLACK_APP_TOKEN",
37
43
  "POOLBOT_GATEWAY_TOKEN",
38
44
  "POOLBOT_GATEWAY_PASSWORD",
39
- "CLAWDBOT_GATEWAY_TOKEN",
40
- "CLAWDBOT_GATEWAY_PASSWORD",
41
45
  ];
42
- const CONFIG_BACKUP_COUNT = 5;
46
+ const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
43
47
  const loggedInvalidConfigs = new Set();
44
48
  function hashConfigRaw(raw) {
45
49
  return crypto
@@ -50,11 +54,13 @@ function hashConfigRaw(raw) {
50
54
  export function resolveConfigSnapshotHash(snapshot) {
51
55
  if (typeof snapshot.hash === "string") {
52
56
  const trimmed = snapshot.hash.trim();
53
- if (trimmed)
57
+ if (trimmed) {
54
58
  return trimmed;
59
+ }
55
60
  }
56
- if (typeof snapshot.raw !== "string")
61
+ if (typeof snapshot.raw !== "string") {
57
62
  return null;
63
+ }
58
64
  return hashConfigRaw(snapshot.raw);
59
65
  }
60
66
  function coerceConfig(value) {
@@ -63,29 +69,217 @@ function coerceConfig(value) {
63
69
  }
64
70
  return value;
65
71
  }
66
- async function rotateConfigBackups(configPath, ioFs) {
67
- if (CONFIG_BACKUP_COUNT <= 1)
72
+ function isPlainObject(value) {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+ function hasConfigMeta(value) {
76
+ if (!isPlainObject(value)) {
77
+ return false;
78
+ }
79
+ const meta = value.meta;
80
+ return isPlainObject(meta);
81
+ }
82
+ function resolveGatewayMode(value) {
83
+ if (!isPlainObject(value)) {
84
+ return null;
85
+ }
86
+ const gateway = value.gateway;
87
+ if (!isPlainObject(gateway) || typeof gateway.mode !== "string") {
88
+ return null;
89
+ }
90
+ const trimmed = gateway.mode.trim();
91
+ return trimmed.length > 0 ? trimmed : null;
92
+ }
93
+ function cloneUnknown(value) {
94
+ return structuredClone(value);
95
+ }
96
+ function createMergePatch(base, target) {
97
+ if (!isPlainObject(base) || !isPlainObject(target)) {
98
+ return cloneUnknown(target);
99
+ }
100
+ const patch = {};
101
+ const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
102
+ for (const key of keys) {
103
+ const hasBase = key in base;
104
+ const hasTarget = key in target;
105
+ if (!hasTarget) {
106
+ patch[key] = null;
107
+ continue;
108
+ }
109
+ const targetValue = target[key];
110
+ if (!hasBase) {
111
+ patch[key] = cloneUnknown(targetValue);
112
+ continue;
113
+ }
114
+ const baseValue = base[key];
115
+ if (isPlainObject(baseValue) && isPlainObject(targetValue)) {
116
+ const childPatch = createMergePatch(baseValue, targetValue);
117
+ if (isPlainObject(childPatch) && Object.keys(childPatch).length === 0) {
118
+ continue;
119
+ }
120
+ patch[key] = childPatch;
121
+ continue;
122
+ }
123
+ if (!isDeepStrictEqual(baseValue, targetValue)) {
124
+ patch[key] = cloneUnknown(targetValue);
125
+ }
126
+ }
127
+ return patch;
128
+ }
129
+ function collectEnvRefPaths(value, path, output) {
130
+ if (typeof value === "string") {
131
+ if (containsEnvVarReference(value)) {
132
+ output.set(path, value);
133
+ }
68
134
  return;
69
- const backupBase = `${configPath}.bak`;
70
- const maxIndex = CONFIG_BACKUP_COUNT - 1;
71
- await ioFs.unlink(`${backupBase}.${maxIndex}`).catch(() => {
72
- // best-effort
73
- });
74
- for (let index = maxIndex - 1; index >= 1; index -= 1) {
75
- await ioFs.rename(`${backupBase}.${index}`, `${backupBase}.${index + 1}`).catch(() => {
76
- // best-effort
135
+ }
136
+ if (Array.isArray(value)) {
137
+ value.forEach((item, index) => {
138
+ collectEnvRefPaths(item, `${path}[${index}]`, output);
77
139
  });
140
+ return;
141
+ }
142
+ if (isPlainObject(value)) {
143
+ for (const [key, child] of Object.entries(value)) {
144
+ const childPath = path ? `${path}.${key}` : key;
145
+ collectEnvRefPaths(child, childPath, output);
146
+ }
147
+ }
148
+ }
149
+ function collectChangedPaths(base, target, path, output) {
150
+ if (Array.isArray(base) && Array.isArray(target)) {
151
+ const max = Math.max(base.length, target.length);
152
+ for (let index = 0; index < max; index += 1) {
153
+ const childPath = path ? `${path}[${index}]` : `[${index}]`;
154
+ if (index >= base.length || index >= target.length) {
155
+ output.add(childPath);
156
+ continue;
157
+ }
158
+ collectChangedPaths(base[index], target[index], childPath, output);
159
+ }
160
+ return;
161
+ }
162
+ if (isPlainObject(base) && isPlainObject(target)) {
163
+ const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
164
+ for (const key of keys) {
165
+ const childPath = path ? `${path}.${key}` : key;
166
+ const hasBase = key in base;
167
+ const hasTarget = key in target;
168
+ if (!hasTarget || !hasBase) {
169
+ output.add(childPath);
170
+ continue;
171
+ }
172
+ collectChangedPaths(base[key], target[key], childPath, output);
173
+ }
174
+ return;
175
+ }
176
+ if (!isDeepStrictEqual(base, target)) {
177
+ output.add(path);
178
+ }
179
+ }
180
+ function parentPath(value) {
181
+ if (!value) {
182
+ return "";
183
+ }
184
+ if (value.endsWith("]")) {
185
+ const index = value.lastIndexOf("[");
186
+ return index > 0 ? value.slice(0, index) : "";
187
+ }
188
+ const index = value.lastIndexOf(".");
189
+ return index >= 0 ? value.slice(0, index) : "";
190
+ }
191
+ function isPathChanged(path, changedPaths) {
192
+ if (changedPaths.has(path)) {
193
+ return true;
194
+ }
195
+ let current = parentPath(path);
196
+ while (current) {
197
+ if (changedPaths.has(current)) {
198
+ return true;
199
+ }
200
+ current = parentPath(current);
78
201
  }
79
- await ioFs.rename(backupBase, `${backupBase}.1`).catch(() => {
202
+ return changedPaths.has("");
203
+ }
204
+ function restoreEnvRefsFromMap(value, path, envRefMap, changedPaths) {
205
+ if (typeof value === "string") {
206
+ if (!isPathChanged(path, changedPaths)) {
207
+ const original = envRefMap.get(path);
208
+ if (original !== undefined) {
209
+ return original;
210
+ }
211
+ }
212
+ return value;
213
+ }
214
+ if (Array.isArray(value)) {
215
+ let changed = false;
216
+ const next = value.map((item, index) => {
217
+ const updated = restoreEnvRefsFromMap(item, `${path}[${index}]`, envRefMap, changedPaths);
218
+ if (updated !== item) {
219
+ changed = true;
220
+ }
221
+ return updated;
222
+ });
223
+ return changed ? next : value;
224
+ }
225
+ if (isPlainObject(value)) {
226
+ let changed = false;
227
+ const next = {};
228
+ for (const [key, child] of Object.entries(value)) {
229
+ const childPath = path ? `${path}.${key}` : key;
230
+ const updated = restoreEnvRefsFromMap(child, childPath, envRefMap, changedPaths);
231
+ if (updated !== child) {
232
+ changed = true;
233
+ }
234
+ next[key] = updated;
235
+ }
236
+ return changed ? next : value;
237
+ }
238
+ return value;
239
+ }
240
+ function resolveConfigAuditLogPath(env, homedir) {
241
+ return path.join(resolveStateDir(env, homedir), "logs", CONFIG_AUDIT_LOG_FILENAME);
242
+ }
243
+ function resolveConfigWriteSuspiciousReasons(params) {
244
+ const reasons = [];
245
+ if (!params.existsBefore) {
246
+ return reasons;
247
+ }
248
+ if (typeof params.previousBytes === "number" &&
249
+ typeof params.nextBytes === "number" &&
250
+ params.previousBytes >= 512 &&
251
+ params.nextBytes < Math.floor(params.previousBytes * 0.5)) {
252
+ reasons.push(`size-drop:${params.previousBytes}->${params.nextBytes}`);
253
+ }
254
+ if (!params.hasMetaBefore) {
255
+ reasons.push("missing-meta-before-write");
256
+ }
257
+ if (params.gatewayModeBefore && !params.gatewayModeAfter) {
258
+ reasons.push("gateway-mode-removed");
259
+ }
260
+ return reasons;
261
+ }
262
+ async function appendConfigWriteAuditRecord(deps, record) {
263
+ try {
264
+ const auditPath = resolveConfigAuditLogPath(deps.env, deps.homedir);
265
+ await deps.fs.promises.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 });
266
+ await deps.fs.promises.appendFile(auditPath, `${JSON.stringify(record)}\n`, {
267
+ encoding: "utf-8",
268
+ mode: 0o600,
269
+ });
270
+ }
271
+ catch {
80
272
  // best-effort
81
- });
273
+ }
82
274
  }
83
275
  function warnOnConfigMiskeys(raw, logger) {
84
- if (!raw || typeof raw !== "object")
276
+ if (!raw || typeof raw !== "object") {
85
277
  return;
278
+ }
86
279
  const gateway = raw.gateway;
87
- if (!gateway || typeof gateway !== "object")
280
+ if (!gateway || typeof gateway !== "object") {
88
281
  return;
282
+ }
89
283
  if ("token" in gateway) {
90
284
  logger.warn('Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.');
91
285
  }
@@ -103,26 +297,21 @@ function stampConfigVersion(cfg) {
103
297
  }
104
298
  function warnIfConfigFromFuture(cfg, logger) {
105
299
  const touched = cfg.meta?.lastTouchedVersion;
106
- if (!touched)
300
+ if (!touched) {
107
301
  return;
302
+ }
108
303
  const cmp = comparePoolbotVersions(VERSION, touched);
109
- if (cmp === null)
304
+ if (cmp === null) {
110
305
  return;
111
- if (cmp < 0) {
112
- logger.warn(`Config was last written by a newer Poolbot (${touched}); current version is ${VERSION}.`);
113
306
  }
114
- }
115
- function applyConfigEnv(cfg, env) {
116
- const entries = collectConfigEnvVars(cfg);
117
- for (const [key, value] of Object.entries(entries)) {
118
- if (env[key]?.trim())
119
- continue;
120
- env[key] = value;
307
+ if (cmp < 0) {
308
+ logger.warn(`Config was last written by a newer Pool Bot (${touched}); current version is ${VERSION}.`);
121
309
  }
122
310
  }
123
311
  function resolveConfigPathForDeps(deps) {
124
- if (deps.configPath)
312
+ if (deps.configPath) {
125
313
  return deps.configPath;
314
+ }
126
315
  return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
127
316
  }
128
317
  function normalizeDeps(overrides = {}) {
@@ -130,11 +319,19 @@ function normalizeDeps(overrides = {}) {
130
319
  fs: overrides.fs ?? fs,
131
320
  json5: overrides.json5 ?? JSON5,
132
321
  env: overrides.env ?? process.env,
133
- homedir: overrides.homedir ?? os.homedir,
322
+ homedir: overrides.homedir ?? (() => resolveRequiredHomeDir(overrides.env ?? process.env, os.homedir)),
134
323
  configPath: overrides.configPath ?? "",
135
324
  logger: overrides.logger ?? console,
136
325
  };
137
326
  }
327
+ function maybeLoadDotEnvForConfig(env) {
328
+ // Only hydrate dotenv for the real process env. Callers using injected env
329
+ // objects (tests/diagnostics) should stay isolated.
330
+ if (env !== process.env) {
331
+ return;
332
+ }
333
+ loadDotEnv({ quiet: true });
334
+ }
138
335
  export function parseConfigJson5(raw, json5 = JSON5) {
139
336
  try {
140
337
  return { ok: true, parsed: json5.parse(raw) };
@@ -143,6 +340,23 @@ export function parseConfigJson5(raw, json5 = JSON5) {
143
340
  return { ok: false, error: String(err) };
144
341
  }
145
342
  }
343
+ function resolveConfigIncludesForRead(parsed, configPath, deps) {
344
+ return resolveConfigIncludes(parsed, configPath, {
345
+ readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
346
+ parseJson: (raw) => deps.json5.parse(raw),
347
+ });
348
+ }
349
+ function resolveConfigForRead(resolvedIncludes, env) {
350
+ // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars.
351
+ if (resolvedIncludes && typeof resolvedIncludes === "object" && "env" in resolvedIncludes) {
352
+ applyConfigEnvVars(resolvedIncludes, env);
353
+ }
354
+ return {
355
+ resolvedConfigRaw: resolveConfigEnvVars(resolvedIncludes, env),
356
+ // Capture env snapshot after substitution for write-time ${VAR} restoration.
357
+ envSnapshotForRestore: { ...env },
358
+ };
359
+ }
146
360
  export function createConfigIO(overrides = {}) {
147
361
  const deps = normalizeDeps(overrides);
148
362
  const requestedConfigPath = resolveConfigPathForDeps(deps);
@@ -152,6 +366,7 @@ export function createConfigIO(overrides = {}) {
152
366
  const configPath = candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath;
153
367
  function loadConfig() {
154
368
  try {
369
+ maybeLoadDotEnvForConfig(deps.env);
155
370
  if (!deps.fs.existsSync(configPath)) {
156
371
  if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
157
372
  loadShellEnvFallback({
@@ -166,21 +381,11 @@ export function createConfigIO(overrides = {}) {
166
381
  }
167
382
  const raw = deps.fs.readFileSync(configPath, "utf-8");
168
383
  const parsed = deps.json5.parse(raw);
169
- // Resolve $include directives before validation
170
- const resolved = resolveConfigIncludes(parsed, configPath, {
171
- readFile: (p) => deps.fs.readFileSync(p, "utf-8"),
172
- parseJson: (raw) => deps.json5.parse(raw),
173
- });
174
- // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
175
- if (resolved && typeof resolved === "object" && "env" in resolved) {
176
- applyConfigEnv(resolved, deps.env);
177
- }
178
- // Substitute ${VAR} env var references
179
- const substituted = resolveConfigEnvVars(resolved, deps.env);
180
- const resolvedConfig = substituted;
384
+ const { resolvedConfigRaw: resolvedConfig } = resolveConfigForRead(resolveConfigIncludesForRead(parsed, configPath, deps), deps.env);
181
385
  warnOnConfigMiskeys(resolvedConfig, deps.logger);
182
- if (typeof resolvedConfig !== "object" || resolvedConfig === null)
386
+ if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
183
387
  return {};
388
+ }
184
389
  const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig, {
185
390
  env: deps.env,
186
391
  homedir: deps.homedir,
@@ -218,7 +423,7 @@ export function createConfigIO(overrides = {}) {
218
423
  if (duplicates.length > 0) {
219
424
  throw new DuplicateAgentDirError(duplicates);
220
425
  }
221
- applyConfigEnv(cfg, deps.env);
426
+ applyConfigEnvVars(cfg, deps.env);
222
427
  const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true;
223
428
  if (enabled && !shouldDeferShellEnvFallback(deps.env)) {
224
429
  loadShellEnvFallback({
@@ -244,23 +449,27 @@ export function createConfigIO(overrides = {}) {
244
449
  return {};
245
450
  }
246
451
  }
247
- async function readConfigFileSnapshot() {
452
+ async function readConfigFileSnapshotInternal() {
453
+ maybeLoadDotEnvForConfig(deps.env);
248
454
  const exists = deps.fs.existsSync(configPath);
249
455
  if (!exists) {
250
456
  const hash = hashConfigRaw(null);
251
457
  const config = applyTalkApiKey(applyModelDefaults(applyCompactionDefaults(applyContextPruningDefaults(applyAgentDefaults(applySessionDefaults(applyMessageDefaults({})))))));
252
458
  const legacyIssues = [];
253
459
  return {
254
- path: configPath,
255
- exists: false,
256
- raw: null,
257
- parsed: {},
258
- valid: true,
259
- config,
260
- hash,
261
- issues: [],
262
- warnings: [],
263
- legacyIssues,
460
+ snapshot: {
461
+ path: configPath,
462
+ exists: false,
463
+ raw: null,
464
+ parsed: {},
465
+ resolved: {},
466
+ valid: true,
467
+ config,
468
+ hash,
469
+ issues: [],
470
+ warnings: [],
471
+ legacyIssues,
472
+ },
264
473
  };
265
474
  }
266
475
  try {
@@ -269,118 +478,169 @@ export function createConfigIO(overrides = {}) {
269
478
  const parsedRes = parseConfigJson5(raw, deps.json5);
270
479
  if (!parsedRes.ok) {
271
480
  return {
272
- path: configPath,
273
- exists: true,
274
- raw,
275
- parsed: {},
276
- valid: false,
277
- config: {},
278
- hash,
279
- issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
280
- warnings: [],
281
- legacyIssues: [],
481
+ snapshot: {
482
+ path: configPath,
483
+ exists: true,
484
+ raw,
485
+ parsed: {},
486
+ resolved: {},
487
+ valid: false,
488
+ config: {},
489
+ hash,
490
+ issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
491
+ warnings: [],
492
+ legacyIssues: [],
493
+ },
282
494
  };
283
495
  }
284
496
  // Resolve $include directives
285
497
  let resolved;
286
498
  try {
287
- resolved = resolveConfigIncludes(parsedRes.parsed, configPath, {
288
- readFile: (p) => deps.fs.readFileSync(p, "utf-8"),
289
- parseJson: (raw) => deps.json5.parse(raw),
290
- });
499
+ resolved = resolveConfigIncludesForRead(parsedRes.parsed, configPath, deps);
291
500
  }
292
501
  catch (err) {
293
502
  const message = err instanceof ConfigIncludeError
294
503
  ? err.message
295
504
  : `Include resolution failed: ${String(err)}`;
296
505
  return {
297
- path: configPath,
298
- exists: true,
299
- raw,
300
- parsed: parsedRes.parsed,
301
- valid: false,
302
- config: coerceConfig(parsedRes.parsed),
303
- hash,
304
- issues: [{ path: "", message }],
305
- warnings: [],
306
- legacyIssues: [],
506
+ snapshot: {
507
+ path: configPath,
508
+ exists: true,
509
+ raw,
510
+ parsed: parsedRes.parsed,
511
+ resolved: coerceConfig(parsedRes.parsed),
512
+ valid: false,
513
+ config: coerceConfig(parsedRes.parsed),
514
+ hash,
515
+ issues: [{ path: "", message }],
516
+ warnings: [],
517
+ legacyIssues: [],
518
+ },
307
519
  };
308
520
  }
309
- // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
310
- if (resolved && typeof resolved === "object" && "env" in resolved) {
311
- applyConfigEnv(resolved, deps.env);
312
- }
313
- // Substitute ${VAR} env var references
314
- let substituted;
521
+ let readResolution;
315
522
  try {
316
- substituted = resolveConfigEnvVars(resolved, deps.env);
523
+ readResolution = resolveConfigForRead(resolved, deps.env);
317
524
  }
318
525
  catch (err) {
319
526
  const message = err instanceof MissingEnvVarError
320
527
  ? err.message
321
528
  : `Env var substitution failed: ${String(err)}`;
322
529
  return {
323
- path: configPath,
324
- exists: true,
325
- raw,
326
- parsed: parsedRes.parsed,
327
- valid: false,
328
- config: coerceConfig(resolved),
329
- hash,
330
- issues: [{ path: "", message }],
331
- warnings: [],
332
- legacyIssues: [],
530
+ snapshot: {
531
+ path: configPath,
532
+ exists: true,
533
+ raw,
534
+ parsed: parsedRes.parsed,
535
+ resolved: coerceConfig(resolved),
536
+ valid: false,
537
+ config: coerceConfig(resolved),
538
+ hash,
539
+ issues: [{ path: "", message }],
540
+ warnings: [],
541
+ legacyIssues: [],
542
+ },
333
543
  };
334
544
  }
335
- const resolvedConfigRaw = substituted;
545
+ const resolvedConfigRaw = readResolution.resolvedConfigRaw;
336
546
  const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
337
547
  const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
338
548
  if (!validated.ok) {
339
549
  return {
550
+ snapshot: {
551
+ path: configPath,
552
+ exists: true,
553
+ raw,
554
+ parsed: parsedRes.parsed,
555
+ resolved: coerceConfig(resolvedConfigRaw),
556
+ valid: false,
557
+ config: coerceConfig(resolvedConfigRaw),
558
+ hash,
559
+ issues: validated.issues,
560
+ warnings: validated.warnings,
561
+ legacyIssues,
562
+ },
563
+ };
564
+ }
565
+ warnIfConfigFromFuture(validated.config, deps.logger);
566
+ return {
567
+ snapshot: {
340
568
  path: configPath,
341
569
  exists: true,
342
570
  raw,
343
571
  parsed: parsedRes.parsed,
344
- valid: false,
345
- config: coerceConfig(resolvedConfigRaw),
572
+ // Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
573
+ // for config set/unset operations (issue #6070)
574
+ resolved: coerceConfig(resolvedConfigRaw),
575
+ valid: true,
576
+ config: normalizeConfigPaths(applyTalkApiKey(applyModelDefaults(applyAgentDefaults(applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))))))),
346
577
  hash,
347
- issues: validated.issues,
578
+ issues: [],
348
579
  warnings: validated.warnings,
349
580
  legacyIssues,
350
- };
351
- }
352
- warnIfConfigFromFuture(validated.config, deps.logger);
353
- return {
354
- path: configPath,
355
- exists: true,
356
- raw,
357
- parsed: parsedRes.parsed,
358
- valid: true,
359
- config: normalizeConfigPaths(applyTalkApiKey(applyModelDefaults(applyAgentDefaults(applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))))))),
360
- hash,
361
- issues: [],
362
- warnings: validated.warnings,
363
- legacyIssues,
581
+ },
582
+ envSnapshotForRestore: readResolution.envSnapshotForRestore,
364
583
  };
365
584
  }
366
585
  catch (err) {
367
586
  return {
368
- path: configPath,
369
- exists: true,
370
- raw: null,
371
- parsed: {},
372
- valid: false,
373
- config: {},
374
- hash: hashConfigRaw(null),
375
- issues: [{ path: "", message: `read failed: ${String(err)}` }],
376
- warnings: [],
377
- legacyIssues: [],
587
+ snapshot: {
588
+ path: configPath,
589
+ exists: true,
590
+ raw: null,
591
+ parsed: {},
592
+ resolved: {},
593
+ valid: false,
594
+ config: {},
595
+ hash: hashConfigRaw(null),
596
+ issues: [{ path: "", message: `read failed: ${String(err)}` }],
597
+ warnings: [],
598
+ legacyIssues: [],
599
+ },
378
600
  };
379
601
  }
380
602
  }
381
- async function writeConfigFile(cfg) {
603
+ async function readConfigFileSnapshot() {
604
+ const result = await readConfigFileSnapshotInternal();
605
+ return result.snapshot;
606
+ }
607
+ async function readConfigFileSnapshotForWrite() {
608
+ const result = await readConfigFileSnapshotInternal();
609
+ return {
610
+ snapshot: result.snapshot,
611
+ writeOptions: {
612
+ envSnapshotForRestore: result.envSnapshotForRestore,
613
+ expectedConfigPath: configPath,
614
+ },
615
+ };
616
+ }
617
+ async function writeConfigFile(cfg, options = {}) {
382
618
  clearConfigCache();
383
- const validated = validateConfigObjectWithPlugins(cfg);
619
+ let persistCandidate = cfg;
620
+ const { snapshot } = await readConfigFileSnapshotInternal();
621
+ let envRefMap = null;
622
+ let changedPaths = null;
623
+ if (snapshot.valid && snapshot.exists) {
624
+ const patch = createMergePatch(snapshot.config, cfg);
625
+ persistCandidate = applyMergePatch(snapshot.resolved, patch);
626
+ try {
627
+ const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, {
628
+ readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
629
+ parseJson: (raw) => deps.json5.parse(raw),
630
+ });
631
+ const collected = new Map();
632
+ collectEnvRefPaths(resolvedIncludes, "", collected);
633
+ if (collected.size > 0) {
634
+ envRefMap = collected;
635
+ changedPaths = new Set();
636
+ collectChangedPaths(snapshot.config, cfg, "", changedPaths);
637
+ }
638
+ }
639
+ catch {
640
+ envRefMap = null;
641
+ }
642
+ }
643
+ const validated = validateConfigObjectRawWithPlugins(persistCandidate);
384
644
  if (!validated.ok) {
385
645
  const issue = validated.issues[0];
386
646
  const pathLabel = issue?.path ? issue.path : "<root>";
@@ -392,41 +652,171 @@ export function createConfigIO(overrides = {}) {
392
652
  .join("\n");
393
653
  deps.logger.warn(`Config warnings:\n${details}`);
394
654
  }
655
+ // Restore ${VAR} env var references that were resolved during config loading.
656
+ // Read the current file (pre-substitution) and restore any references whose
657
+ // resolved values match the incoming config — so we don't overwrite
658
+ // "${ANTHROPIC_API_KEY}" with "sk-ant-..." when the caller didn't change it.
659
+ //
660
+ // We use only the root file's parsed content (no $include resolution) to avoid
661
+ // pulling values from included files into the root config on write-back.
662
+ // Apply env restoration to validated.config (which has runtime defaults stripped
663
+ // per issue #6070) rather than the raw caller input.
664
+ let cfgToWrite = validated.config;
665
+ try {
666
+ if (deps.fs.existsSync(configPath)) {
667
+ const currentRaw = await deps.fs.promises.readFile(configPath, "utf-8");
668
+ const parsedRes = parseConfigJson5(currentRaw, deps.json5);
669
+ if (parsedRes.ok) {
670
+ // Use env snapshot from when config was loaded (if available) to avoid
671
+ // TOCTOU issues where env changes between load and write. Falls back to
672
+ // live env if no snapshot exists (e.g., first write before any load).
673
+ const envForRestore = options.envSnapshotForRestore ?? deps.env;
674
+ cfgToWrite = restoreEnvVarRefs(cfgToWrite, parsedRes.parsed, envForRestore);
675
+ }
676
+ }
677
+ }
678
+ catch {
679
+ // If reading the current file fails, write cfg as-is (no env restoration)
680
+ }
395
681
  const dir = path.dirname(configPath);
396
682
  await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
397
- const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
398
- .trimEnd()
399
- .concat("\n");
400
- const tmp = path.join(dir, `${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
401
- await deps.fs.promises.writeFile(tmp, json, {
402
- encoding: "utf-8",
403
- mode: 0o600,
683
+ const outputConfig = envRefMap && changedPaths
684
+ ? restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths)
685
+ : cfgToWrite;
686
+ // Do NOT apply runtime defaults when writing — user config should only contain
687
+ // explicitly set values. Runtime defaults are applied when loading (issue #6070).
688
+ const stampedOutputConfig = stampConfigVersion(outputConfig);
689
+ const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n");
690
+ const nextHash = hashConfigRaw(json);
691
+ const previousHash = resolveConfigSnapshotHash(snapshot);
692
+ const changedPathCount = changedPaths?.size;
693
+ const previousBytes = typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null;
694
+ const nextBytes = Buffer.byteLength(json, "utf-8");
695
+ const hasMetaBefore = hasConfigMeta(snapshot.parsed);
696
+ const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
697
+ const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
698
+ const gatewayModeAfter = resolveGatewayMode(stampedOutputConfig);
699
+ const suspiciousReasons = resolveConfigWriteSuspiciousReasons({
700
+ existsBefore: snapshot.exists,
701
+ previousBytes,
702
+ nextBytes,
703
+ hasMetaBefore,
704
+ gatewayModeBefore,
705
+ gatewayModeAfter,
404
706
  });
405
- if (deps.fs.existsSync(configPath)) {
406
- await rotateConfigBackups(configPath, deps.fs.promises);
407
- await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
408
- // best-effort
707
+ const logConfigOverwrite = () => {
708
+ if (!snapshot.exists) {
709
+ return;
710
+ }
711
+ const isVitest = deps.env.VITEST === "true";
712
+ const shouldLogInVitest = deps.env.CLAWDBOT_TEST_CONFIG_OVERWRITE_LOG === "1";
713
+ if (isVitest && !shouldLogInVitest) {
714
+ return;
715
+ }
716
+ const changeSummary = typeof changedPathCount === "number" ? `, changedPaths=${changedPathCount}` : "";
717
+ deps.logger.warn(`Config overwrite: ${configPath} (sha256 ${previousHash ?? "unknown"} -> ${nextHash}, backup=${configPath}.bak${changeSummary})`);
718
+ };
719
+ const logConfigWriteAnomalies = () => {
720
+ if (suspiciousReasons.length === 0) {
721
+ return;
722
+ }
723
+ // Tests often write minimal configs (missing meta, etc); keep output quiet unless requested.
724
+ const isVitest = deps.env.VITEST === "true";
725
+ const shouldLogInVitest = deps.env.CLAWDBOT_TEST_CONFIG_WRITE_ANOMALY_LOG === "1";
726
+ if (isVitest && !shouldLogInVitest) {
727
+ return;
728
+ }
729
+ deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`);
730
+ };
731
+ const auditRecordBase = {
732
+ ts: new Date().toISOString(),
733
+ source: "config-io",
734
+ event: "config.write",
735
+ configPath,
736
+ pid: process.pid,
737
+ ppid: process.ppid,
738
+ cwd: process.cwd(),
739
+ argv: process.argv.slice(0, 8),
740
+ execArgv: process.execArgv.slice(0, 8),
741
+ watchMode: deps.env.CLAWDBOT_WATCH_MODE === "1",
742
+ watchSession: typeof deps.env.CLAWDBOT_WATCH_SESSION === "string" &&
743
+ deps.env.CLAWDBOT_WATCH_SESSION.trim().length > 0
744
+ ? deps.env.CLAWDBOT_WATCH_SESSION.trim()
745
+ : null,
746
+ watchCommand: typeof deps.env.CLAWDBOT_WATCH_COMMAND === "string" &&
747
+ deps.env.CLAWDBOT_WATCH_COMMAND.trim().length > 0
748
+ ? deps.env.CLAWDBOT_WATCH_COMMAND.trim()
749
+ : null,
750
+ existsBefore: snapshot.exists,
751
+ previousHash: previousHash ?? null,
752
+ nextHash,
753
+ previousBytes,
754
+ nextBytes,
755
+ changedPathCount: typeof changedPathCount === "number" ? changedPathCount : null,
756
+ hasMetaBefore,
757
+ hasMetaAfter,
758
+ gatewayModeBefore,
759
+ gatewayModeAfter,
760
+ suspicious: suspiciousReasons,
761
+ };
762
+ const appendWriteAudit = async (result, err) => {
763
+ const errorCode = err && typeof err === "object" && "code" in err && typeof err.code === "string"
764
+ ? err.code
765
+ : undefined;
766
+ const errorMessage = err && typeof err === "object" && "message" in err && typeof err.message === "string"
767
+ ? err.message
768
+ : undefined;
769
+ await appendConfigWriteAuditRecord(deps, {
770
+ ...auditRecordBase,
771
+ result,
772
+ nextHash: result === "failed" ? null : auditRecordBase.nextHash,
773
+ nextBytes: result === "failed" ? null : auditRecordBase.nextBytes,
774
+ errorCode,
775
+ errorMessage,
409
776
  });
410
- }
777
+ };
778
+ const tmp = path.join(dir, `${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
411
779
  try {
412
- await deps.fs.promises.rename(tmp, configPath);
413
- }
414
- catch (err) {
415
- const code = err.code;
416
- // Windows doesn't reliably support atomic replace via rename when dest exists.
417
- if (code === "EPERM" || code === "EEXIST") {
418
- await deps.fs.promises.copyFile(tmp, configPath);
419
- await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
780
+ await deps.fs.promises.writeFile(tmp, json, {
781
+ encoding: "utf-8",
782
+ mode: 0o600,
783
+ });
784
+ if (deps.fs.existsSync(configPath)) {
785
+ await rotateConfigBackups(configPath, deps.fs.promises);
786
+ await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
420
787
  // best-effort
421
788
  });
789
+ }
790
+ try {
791
+ await deps.fs.promises.rename(tmp, configPath);
792
+ }
793
+ catch (err) {
794
+ const code = err.code;
795
+ // Windows doesn't reliably support atomic replace via rename when dest exists.
796
+ if (code === "EPERM" || code === "EEXIST") {
797
+ await deps.fs.promises.copyFile(tmp, configPath);
798
+ await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
799
+ // best-effort
800
+ });
801
+ await deps.fs.promises.unlink(tmp).catch(() => {
802
+ // best-effort
803
+ });
804
+ logConfigOverwrite();
805
+ logConfigWriteAnomalies();
806
+ await appendWriteAudit("copy-fallback");
807
+ return;
808
+ }
422
809
  await deps.fs.promises.unlink(tmp).catch(() => {
423
810
  // best-effort
424
811
  });
425
- return;
812
+ throw err;
426
813
  }
427
- await deps.fs.promises.unlink(tmp).catch(() => {
428
- // best-effort
429
- });
814
+ logConfigOverwrite();
815
+ logConfigWriteAnomalies();
816
+ await appendWriteAudit("rename");
817
+ }
818
+ catch (err) {
819
+ await appendWriteAudit("failed", err);
430
820
  throw err;
431
821
  }
432
822
  }
@@ -434,35 +824,41 @@ export function createConfigIO(overrides = {}) {
434
824
  configPath,
435
825
  loadConfig,
436
826
  readConfigFileSnapshot,
827
+ readConfigFileSnapshotForWrite,
437
828
  writeConfigFile,
438
829
  };
439
830
  }
440
831
  // NOTE: These wrappers intentionally do *not* cache the resolved config path at
441
- // module scope. `POOLBOT_CONFIG_PATH` / `CLAWDBOT_CONFIG_PATH` (and friends) are expected to work even
832
+ // module scope. `CLAWDBOT_CONFIG_PATH` (and friends) are expected to work even
442
833
  // when set after the module has been imported (tests, one-off scripts, etc.).
443
834
  const DEFAULT_CONFIG_CACHE_MS = 200;
444
835
  let configCache = null;
445
836
  function resolveConfigCacheMs(env) {
446
- const raw = env.POOLBOT_CONFIG_CACHE_MS?.trim() || env.CLAWDBOT_CONFIG_CACHE_MS?.trim();
447
- if (raw === "" || raw === "0")
837
+ const raw = env.CLAWDBOT_CONFIG_CACHE_MS?.trim();
838
+ if (raw === "" || raw === "0") {
448
839
  return 0;
449
- if (!raw)
840
+ }
841
+ if (!raw) {
450
842
  return DEFAULT_CONFIG_CACHE_MS;
843
+ }
451
844
  const parsed = Number.parseInt(raw, 10);
452
- if (!Number.isFinite(parsed))
845
+ if (!Number.isFinite(parsed)) {
453
846
  return DEFAULT_CONFIG_CACHE_MS;
847
+ }
454
848
  return Math.max(0, parsed);
455
849
  }
456
850
  function shouldUseConfigCache(env) {
457
- if (env.POOLBOT_DISABLE_CONFIG_CACHE?.trim() || env.CLAWDBOT_DISABLE_CONFIG_CACHE?.trim())
851
+ if (env.CLAWDBOT_DISABLE_CONFIG_CACHE?.trim()) {
458
852
  return false;
853
+ }
459
854
  return resolveConfigCacheMs(env) > 0;
460
855
  }
461
- function clearConfigCache() {
856
+ export function clearConfigCache() {
462
857
  configCache = null;
463
858
  }
464
859
  export function loadConfig() {
465
- const configPath = resolveConfigPath();
860
+ const io = createConfigIO();
861
+ const configPath = io.configPath;
466
862
  const now = Date.now();
467
863
  if (shouldUseConfigCache(process.env)) {
468
864
  const cached = configCache;
@@ -470,7 +866,7 @@ export function loadConfig() {
470
866
  return cached.config;
471
867
  }
472
868
  }
473
- const config = createConfigIO({ configPath }).loadConfig();
869
+ const config = io.loadConfig();
474
870
  if (shouldUseConfigCache(process.env)) {
475
871
  const cacheMs = resolveConfigCacheMs(process.env);
476
872
  if (cacheMs > 0) {
@@ -484,11 +880,15 @@ export function loadConfig() {
484
880
  return config;
485
881
  }
486
882
  export async function readConfigFileSnapshot() {
487
- return await createConfigIO({
488
- configPath: resolveConfigPath(),
489
- }).readConfigFileSnapshot();
883
+ return await createConfigIO().readConfigFileSnapshot();
884
+ }
885
+ export async function readConfigFileSnapshotForWrite() {
886
+ return await createConfigIO().readConfigFileSnapshotForWrite();
490
887
  }
491
- export async function writeConfigFile(cfg) {
492
- clearConfigCache();
493
- await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
888
+ export async function writeConfigFile(cfg, options = {}) {
889
+ const io = createConfigIO();
890
+ const sameConfigPath = options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
891
+ await io.writeConfigFile(cfg, {
892
+ envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
893
+ });
494
894
  }