@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.
- package/CHANGELOG.md +29 -0
- package/dist/acp/client.js +207 -18
- package/dist/acp/secret-file.js +22 -0
- package/dist/agents/agent-scope.js +10 -0
- package/dist/agents/bash-process-registry.test-helpers.js +29 -0
- package/dist/agents/bash-tools.exec-approval-request.js +20 -0
- package/dist/agents/bash-tools.exec-host-gateway.js +230 -0
- package/dist/agents/bash-tools.exec-host-node.js +235 -0
- package/dist/agents/bash-tools.exec-types.js +1 -0
- package/dist/agents/bash-tools.process.js +224 -218
- package/dist/agents/content-blocks.js +16 -0
- package/dist/agents/model-fallback.js +96 -101
- package/dist/agents/models-config.providers.js +299 -182
- package/dist/agents/pi-embedded-payloads.js +1 -0
- package/dist/agents/pi-embedded-runner/run.overflow-compaction.fixture.js +34 -0
- package/dist/agents/skills.test-helpers.js +13 -0
- package/dist/agents/stable-stringify.js +12 -0
- package/dist/agents/subagent-registry.mocks.shared.js +12 -0
- package/dist/agents/test-helpers/assistant-message-fixtures.js +29 -0
- package/dist/agents/test-helpers/pi-tools-sandbox-context.js +27 -0
- package/dist/agents/tool-policy-shared.js +108 -0
- package/dist/agents/tools/browser-tool.js +160 -54
- package/dist/agents/tools/cron-tool.test-helpers.js +12 -0
- package/dist/agents/tools/discord-actions-moderation-shared.js +27 -0
- package/dist/agents/tools/image-tool.js +214 -99
- package/dist/agents/tools/sessions-history-tool.js +140 -108
- package/dist/agents/workspace.js +222 -46
- package/dist/auto-reply/commands-registry.js +15 -18
- package/dist/auto-reply/fallback-state.js +114 -0
- package/dist/auto-reply/model-runtime.js +68 -0
- package/dist/auto-reply/reply/agent-runner-execution.js +36 -4
- package/dist/auto-reply/reply/agent-runner.js +165 -39
- package/dist/auto-reply/reply/commands-setunset-standard.js +13 -0
- package/dist/browser/config.js +26 -0
- package/dist/browser/navigation-guard.js +31 -0
- package/dist/browser/routes/agent.act.js +431 -424
- package/dist/browser/routes/agent.shared.js +47 -3
- package/dist/browser/routes/agent.snapshot.js +122 -116
- package/dist/browser/routes/agent.storage.js +303 -297
- package/dist/browser/routes/tabs.js +154 -100
- package/dist/browser/server-lifecycle.js +37 -0
- package/dist/build-info.json +3 -3
- package/dist/channels/allow-from.js +25 -0
- package/dist/channels/plugins/account-action-gate.js +13 -0
- package/dist/channels/plugins/message-actions.js +10 -0
- package/dist/channels/telegram/api.js +18 -0
- package/dist/cli/argv.js +84 -21
- package/dist/cli/banner.js +2 -1
- package/dist/cli/exec-approvals-cli.js +92 -124
- package/dist/cli/memory-cli.js +158 -61
- package/dist/cli/nodes-cli/register.push.js +63 -0
- package/dist/cli/nodes-media-utils.js +21 -0
- package/dist/cli/plugins-cli.js +245 -61
- package/dist/cli/program/build-program.js +3 -1
- package/dist/cli/program/command-registry.js +223 -136
- package/dist/cli/program/help.js +43 -12
- package/dist/cli/route.js +1 -1
- package/dist/cli/test-runtime-capture.js +24 -0
- package/dist/commands/agent.js +163 -87
- package/dist/commands/channels.mock-harness.js +23 -0
- package/dist/commands/daemon-install-runtime-warning.js +11 -0
- package/dist/commands/onboard-helpers.js +4 -4
- package/dist/commands/sessions.test-helpers.js +61 -0
- package/dist/compat/legacy-names.js +2 -2
- package/dist/config/commands.js +3 -0
- package/dist/config/config.js +1 -1
- package/dist/config/env-substitution.js +62 -34
- package/dist/config/env-vars.js +9 -0
- package/dist/config/io.js +571 -171
- package/dist/config/merge-patch.js +50 -4
- package/dist/config/redact-snapshot.js +404 -76
- package/dist/config/schema.js +58 -570
- package/dist/config/validation.js +140 -85
- package/dist/config/zod-schema.hooks.js +40 -11
- package/dist/config/zod-schema.installs.js +20 -0
- package/dist/config/zod-schema.js +8 -7
- package/dist/control-ui/assets/{index-HRr1grwl.js → index-Dvkl4Xlx.js} +2 -1
- package/dist/control-ui/assets/{index-HRr1grwl.js.map → index-Dvkl4Xlx.js.map} +1 -1
- package/dist/control-ui/index.html +1 -1
- package/dist/daemon/cmd-argv.js +21 -0
- package/dist/daemon/cmd-set.js +58 -0
- package/dist/daemon/service-types.js +1 -0
- package/dist/discord/monitor/exec-approvals.js +357 -162
- package/dist/gateway/auth.js +38 -3
- package/dist/gateway/call.js +149 -68
- package/dist/gateway/canvas-capability.js +75 -0
- package/dist/gateway/control-plane-audit.js +28 -0
- package/dist/gateway/control-plane-rate-limit.js +53 -0
- package/dist/gateway/events.js +1 -0
- package/dist/gateway/hooks.js +109 -54
- package/dist/gateway/http-common.js +22 -0
- package/dist/gateway/method-scopes.js +169 -0
- package/dist/gateway/net.js +23 -0
- package/dist/gateway/openresponses-http.js +120 -110
- package/dist/gateway/probe-auth.js +2 -0
- package/dist/gateway/protocol/index.js +3 -2
- package/dist/gateway/protocol/schema/protocol-schemas.js +2 -0
- package/dist/gateway/protocol/schema/push.js +18 -0
- package/dist/gateway/protocol/schema.js +1 -0
- package/dist/gateway/server-http.js +236 -52
- package/dist/gateway/server-methods/agent.js +162 -24
- package/dist/gateway/server-methods/chat.js +461 -130
- package/dist/gateway/server-methods/config.js +193 -150
- package/dist/gateway/server-methods/nodes.helpers.js +12 -0
- package/dist/gateway/server-methods/nodes.js +251 -69
- package/dist/gateway/server-methods/push.js +53 -0
- package/dist/gateway/server-reload-handlers.js +2 -3
- package/dist/gateway/server-runtime-config.js +5 -0
- package/dist/gateway/server-runtime-state.js +2 -0
- package/dist/gateway/server-ws-runtime.js +1 -0
- package/dist/gateway/server.impl.js +296 -139
- package/dist/gateway/session-preview.test-helpers.js +11 -0
- package/dist/gateway/startup-auth.js +126 -0
- package/dist/gateway/test-helpers.agent-results.js +15 -0
- package/dist/gateway/test-helpers.mocks.js +37 -14
- package/dist/gateway/test-helpers.server.js +161 -77
- package/dist/hooks/bundled/session-memory/handler.js +165 -34
- package/dist/hooks/gmail-watcher-lifecycle.js +23 -0
- package/dist/infra/archive-path.js +49 -0
- package/dist/infra/device-pairing.js +148 -167
- package/dist/infra/exec-approvals-allowlist.js +19 -70
- package/dist/infra/exec-approvals-analysis.js +44 -17
- package/dist/infra/exec-safe-bin-policy.js +269 -0
- package/dist/infra/fixed-window-rate-limit.js +33 -0
- package/dist/infra/git-root.js +61 -0
- package/dist/infra/heartbeat-active-hours.js +2 -2
- package/dist/infra/heartbeat-reason.js +40 -0
- package/dist/infra/heartbeat-runner.js +72 -32
- package/dist/infra/install-source-utils.js +91 -7
- package/dist/infra/node-pairing.js +50 -105
- package/dist/infra/npm-integrity.js +45 -0
- package/dist/infra/npm-pack-install.js +40 -0
- package/dist/infra/outbound/channel-adapters.js +20 -7
- package/dist/infra/outbound/message-action-runner.js +107 -327
- package/dist/infra/outbound/message.js +59 -36
- package/dist/infra/outbound/outbound-policy.js +52 -25
- package/dist/infra/outbound/outbound-send-service.js +58 -71
- package/dist/infra/pairing-files.js +10 -0
- package/dist/infra/plain-object.js +9 -0
- package/dist/infra/push-apns.js +365 -0
- package/dist/infra/restart-sentinel.js +16 -1
- package/dist/infra/restart.js +229 -26
- package/dist/infra/scp-host.js +54 -0
- package/dist/infra/update-startup.js +86 -9
- package/dist/media/inbound-path-policy.js +114 -0
- package/dist/media/input-files.js +16 -0
- package/dist/memory/test-manager.js +8 -0
- package/dist/plugin-sdk/temp-path.js +47 -0
- package/dist/plugins/discovery.js +217 -23
- package/dist/plugins/hook-runner-global.js +16 -0
- package/dist/plugins/loader.js +192 -26
- package/dist/plugins/logger.js +8 -0
- package/dist/plugins/manifest-registry.js +3 -0
- package/dist/plugins/path-safety.js +34 -0
- package/dist/plugins/registry.js +5 -2
- package/dist/plugins/runtime/index.js +271 -206
- package/dist/providers/github-copilot-models.js +4 -1
- package/dist/security/audit-channel.js +8 -19
- package/dist/security/audit-extra.async.js +354 -182
- package/dist/security/audit-extra.js +11 -1
- package/dist/security/audit-extra.sync.js +340 -33
- package/dist/security/audit-fs.js +31 -13
- package/dist/security/audit.js +145 -371
- package/dist/security/dm-policy-shared.js +24 -0
- package/dist/security/external-content.js +20 -8
- package/dist/security/fix.js +49 -85
- package/dist/security/scan-paths.js +20 -0
- package/dist/security/secret-equal.js +3 -7
- package/dist/security/windows-acl.js +30 -15
- package/dist/shared/node-list-parse.js +13 -0
- package/dist/shared/operator-scope-compat.js +37 -0
- package/dist/shared/text-chunking.js +29 -0
- package/dist/slack/blocks.test-helpers.js +31 -0
- package/dist/slack/monitor/mrkdwn.js +8 -0
- package/dist/telegram/bot-message-dispatch.js +366 -164
- package/dist/telegram/draft-stream.js +30 -7
- package/dist/telegram/reasoning-lane-coordinator.js +128 -0
- package/dist/terminal/prompt-select-styled.js +9 -0
- package/dist/test-utils/command-runner.js +6 -0
- package/dist/test-utils/internal-hook-event-payload.js +10 -0
- package/dist/test-utils/model-auth-mock.js +12 -0
- package/dist/test-utils/provider-usage-fetch.js +14 -0
- package/dist/test-utils/temp-home.js +33 -0
- package/dist/tui/components/chat-log.js +9 -0
- package/dist/tui/tui-command-handlers.js +36 -27
- package/dist/tui/tui-event-handlers.js +122 -32
- package/dist/tui/tui.js +181 -45
- package/dist/utils/mask-api-key.js +10 -0
- package/dist/utils/run-with-concurrency.js +39 -0
- package/dist/web/media.js +4 -0
- package/docs/tools/slash-commands.md +5 -1
- package/extensions/bluebubbles/package.json +1 -1
- package/extensions/copilot-proxy/package.json +1 -1
- package/extensions/diagnostics-otel/package.json +1 -1
- package/extensions/discord/package.json +1 -1
- package/extensions/feishu/package.json +1 -1
- package/extensions/feishu/src/external-keys.ts +19 -0
- package/extensions/google-antigravity-auth/package.json +1 -1
- package/extensions/google-gemini-cli-auth/package.json +1 -1
- package/extensions/googlechat/package.json +1 -1
- package/extensions/imessage/package.json +1 -1
- package/extensions/irc/package.json +1 -1
- package/extensions/line/package.json +1 -1
- package/extensions/llm-task/package.json +1 -1
- package/extensions/lobster/package.json +1 -1
- package/extensions/lobster/src/windows-spawn.ts +193 -0
- package/extensions/matrix/CHANGELOG.md +5 -0
- package/extensions/matrix/package.json +1 -1
- package/extensions/matrix/src/matrix/actions/limits.ts +6 -0
- package/extensions/mattermost/package.json +1 -1
- package/extensions/mattermost/src/mattermost/reactions.test-helpers.ts +83 -0
- package/extensions/memory-core/package.json +1 -1
- package/extensions/memory-lancedb/package.json +1 -1
- package/extensions/minimax-portal-auth/package.json +1 -1
- package/extensions/msteams/CHANGELOG.md +5 -0
- package/extensions/msteams/package.json +1 -1
- package/extensions/nextcloud-talk/package.json +1 -1
- package/extensions/nostr/CHANGELOG.md +5 -0
- package/extensions/nostr/package.json +1 -1
- package/extensions/open-prose/package.json +1 -1
- package/extensions/openai-codex-auth/package.json +1 -1
- package/extensions/signal/package.json +1 -1
- package/extensions/slack/package.json +1 -1
- package/extensions/telegram/package.json +1 -1
- package/extensions/tlon/package.json +1 -1
- package/extensions/twitch/CHANGELOG.md +5 -0
- package/extensions/twitch/package.json +1 -1
- package/extensions/voice-call/CHANGELOG.md +5 -0
- package/extensions/voice-call/package.json +1 -1
- package/extensions/whatsapp/package.json +1 -1
- package/extensions/zalo/CHANGELOG.md +5 -0
- package/extensions/zalo/package.json +1 -1
- package/extensions/zalouser/CHANGELOG.md +5 -0
- package/extensions/zalouser/package.json +1 -1
- 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 {
|
|
10
|
-
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 =
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
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
|
-
|
|
345
|
-
config
|
|
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:
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await deps.fs.promises.copyFile(
|
|
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
|
-
|
|
812
|
+
throw err;
|
|
426
813
|
}
|
|
427
|
-
|
|
428
|
-
|
|
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. `
|
|
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.
|
|
447
|
-
if (raw === "" || raw === "0")
|
|
837
|
+
const raw = env.CLAWDBOT_CONFIG_CACHE_MS?.trim();
|
|
838
|
+
if (raw === "" || raw === "0") {
|
|
448
839
|
return 0;
|
|
449
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
}
|