@chrysb/alphaclaw 0.9.17 → 0.9.19

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.
@@ -0,0 +1,76 @@
1
+ import { h } from "preact";
2
+ import htm from "htm";
3
+ import { copyTextToClipboard } from "../lib/clipboard.js";
4
+ import { showToast } from "./toast.js";
5
+ import { FileCopyLineIcon } from "./icons.js";
6
+ import { InfoTooltip } from "./info-tooltip.js";
7
+ import { ToggleSwitch } from "./toggle-switch.js";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ const getApiUrl = () => {
12
+ if (typeof window === "undefined" || !window.location?.origin) return "/v1";
13
+ return `${window.location.origin}/v1`;
14
+ };
15
+
16
+ export const ApiFeaturePanel = ({
17
+ openAiCompatApi = { enabled: false },
18
+ savingOpenAiCompatApi = false,
19
+ onToggleOpenAiCompatApi = () => {},
20
+ }) => {
21
+ const apiHydrated = openAiCompatApi?.hydrated === true;
22
+ const apiEnabled = openAiCompatApi?.enabled === true;
23
+ const apiUrl = getApiUrl();
24
+ const handleCopy = async () => {
25
+ const copied = await copyTextToClipboard(apiUrl);
26
+ showToast(
27
+ copied ? "API URL copied" : "Could not copy API URL",
28
+ copied ? "success" : "error",
29
+ );
30
+ };
31
+
32
+ return html`
33
+ <div class="bg-surface border border-border rounded-xl p-4">
34
+ <div class="flex items-center justify-between gap-3">
35
+ <div class="flex items-center gap-1.5 min-w-0">
36
+ <h2 class="card-label">API</h2>
37
+ <${InfoTooltip}
38
+ text="Allows trusted server-side clients to call OpenClaw via an OpenAI compatible API."
39
+ widthClass="w-72"
40
+ />
41
+ </div>
42
+ <${ToggleSwitch}
43
+ checked=${apiEnabled}
44
+ disabled=${savingOpenAiCompatApi || !apiHydrated}
45
+ label=${savingOpenAiCompatApi
46
+ ? "Saving..."
47
+ : !apiHydrated
48
+ ? "Loading..."
49
+ : apiEnabled
50
+ ? "Enabled"
51
+ : "Disabled"}
52
+ onChange=${onToggleOpenAiCompatApi}
53
+ />
54
+ </div>
55
+ ${apiHydrated && apiEnabled
56
+ ? html`
57
+ <div class="mt-4 text-xs text-fg-muted mb-2">OpenAI compatible URL</div>
58
+ <div class="flex items-center gap-2">
59
+ <code class="flex-1 min-w-0 bg-field border border-border rounded-lg px-3 py-2 text-xs text-body font-mono break-all">
60
+ ${apiUrl}
61
+ </code>
62
+ <button
63
+ type="button"
64
+ class="ac-btn-secondary text-xs p-2 rounded-lg shrink-0"
65
+ title="Copy URL"
66
+ aria-label="Copy API URL"
67
+ onclick=${handleCopy}
68
+ >
69
+ <${FileCopyLineIcon} className="w-4 h-4" />
70
+ </button>
71
+ </div>
72
+ `
73
+ : null}
74
+ </div>
75
+ `;
76
+ };
@@ -8,6 +8,7 @@ import { DevicePairings } from "../device-pairings.js";
8
8
  import { ActionButton } from "../action-button.js";
9
9
  import { Google } from "../google/index.js";
10
10
  import { Features } from "../features.js";
11
+ import { ApiFeaturePanel } from "../api-feature-panel.js";
11
12
  import { GeneralDoctorWarning } from "../doctor/general-warning.js";
12
13
  import { ChevronDownIcon } from "../icons.js";
13
14
  import { UpdateActionButton } from "../update-action-button.js";
@@ -136,6 +137,11 @@ export const GeneralTab = ({
136
137
  onRestartRequired=${onRestartRequired}
137
138
  onOpenGmailWebhook=${onOpenGmailWebhook}
138
139
  />
140
+ <${ApiFeaturePanel}
141
+ openAiCompatApi=${state.openAiCompatApi}
142
+ savingOpenAiCompatApi=${state.savingOpenAiCompatApi}
143
+ onToggleOpenAiCompatApi=${actions.handleOpenAiCompatApiToggle}
144
+ />
139
145
 
140
146
  ${state.repo &&
141
147
  html`
@@ -8,14 +8,36 @@ import {
8
8
  rejectDevice,
9
9
  rejectPairing,
10
10
  triggerWatchdogRepair,
11
+ updateOpenAiCompatApiFeature,
11
12
  updateSyncCron,
12
13
  } from "../../lib/api.js";
13
14
  import { usePolling } from "../../hooks/usePolling.js";
15
+ import {
16
+ kOpenAiCompatApiFeatureCacheKey,
17
+ } from "../../lib/storage-keys.js";
14
18
  import { showToast } from "../toast.js";
15
19
  import { ALL_CHANNELS } from "../channels.js";
16
20
 
17
21
  const kDefaultSyncCronSchedule = "0 * * * *";
18
22
 
23
+ const readCachedOpenAiCompatApi = () => {
24
+ try {
25
+ const rawValue = window.localStorage.getItem(kOpenAiCompatApiFeatureCacheKey);
26
+ if (rawValue === "true") return { enabled: true, hydrated: true };
27
+ if (rawValue === "false") return { enabled: false, hydrated: true };
28
+ } catch {}
29
+ return { enabled: false, hydrated: false };
30
+ };
31
+
32
+ const writeCachedOpenAiCompatApi = (enabled) => {
33
+ try {
34
+ window.localStorage.setItem(
35
+ kOpenAiCompatApiFeatureCacheKey,
36
+ enabled ? "true" : "false",
37
+ );
38
+ } catch {}
39
+ };
40
+
19
41
  export const useGeneralTab = ({
20
42
  statusData = null,
21
43
  watchdogData = null,
@@ -30,6 +52,14 @@ export const useGeneralTab = ({
30
52
  const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule);
31
53
  const [savingSyncCron, setSavingSyncCron] = useState(false);
32
54
  const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule);
55
+ const [cachedOpenAiCompatApi] = useState(readCachedOpenAiCompatApi);
56
+ const [openAiCompatApiEnabled, setOpenAiCompatApiEnabled] = useState(
57
+ cachedOpenAiCompatApi.enabled,
58
+ );
59
+ const [openAiCompatApiHydrated, setOpenAiCompatApiHydrated] = useState(
60
+ cachedOpenAiCompatApi.hydrated,
61
+ );
62
+ const [savingOpenAiCompatApi, setSavingOpenAiCompatApi] = useState(false);
33
63
  const [pairingStatusRefreshing, setPairingStatusRefreshing] = useState(false);
34
64
  const [devicePollingEnabled, setDevicePollingEnabled] = useState(false);
35
65
  const [cliAutoApproveComplete, setCliAutoApproveComplete] = useState(false);
@@ -42,6 +72,8 @@ export const useGeneralTab = ({
42
72
  const channels = status?.channels ?? null;
43
73
  const repo = status?.repo || null;
44
74
  const syncCron = status?.syncCron || null;
75
+ const openAiCompatApi = status?.alphaclaw?.features?.openaiCompatApi || null;
76
+ const hasOpenAiCompatApiStatus = typeof openAiCompatApi?.enabled === "boolean";
45
77
  const openclawVersion = status?.openclawVersion || null;
46
78
 
47
79
  const hasUnpaired = ALL_CHANNELS.some((channel) => {
@@ -134,6 +166,14 @@ export const useGeneralTab = ({
134
166
  );
135
167
  }, [syncCron?.enabled, syncCron?.schedule]);
136
168
 
169
+ useEffect(() => {
170
+ if (!hasOpenAiCompatApiStatus) return;
171
+ const nextEnabled = openAiCompatApi.enabled === true;
172
+ setOpenAiCompatApiEnabled(nextEnabled);
173
+ setOpenAiCompatApiHydrated(true);
174
+ writeCachedOpenAiCompatApi(nextEnabled);
175
+ }, [hasOpenAiCompatApiStatus, openAiCompatApi?.enabled]);
176
+
137
177
  useEffect(
138
178
  () => () => {
139
179
  if (pairingRefreshTimerRef.current) {
@@ -196,6 +236,28 @@ export const useGeneralTab = ({
196
236
  });
197
237
  };
198
238
 
239
+ const handleOpenAiCompatApiToggle = async (enabled) => {
240
+ if (savingOpenAiCompatApi) return;
241
+ const previousEnabled = openAiCompatApiEnabled;
242
+ setOpenAiCompatApiEnabled(enabled);
243
+ setSavingOpenAiCompatApi(true);
244
+ try {
245
+ const data = await updateOpenAiCompatApiFeature(enabled);
246
+ if (!data.ok) {
247
+ throw new Error(data.error || "Could not save API setting");
248
+ }
249
+ writeCachedOpenAiCompatApi(enabled);
250
+ setOpenAiCompatApiHydrated(true);
251
+ showToast(`API ${enabled ? "enabled" : "disabled"}`, "success");
252
+ onRefreshStatuses();
253
+ } catch (err) {
254
+ setOpenAiCompatApiEnabled(previousEnabled);
255
+ showToast(err.message || "Could not save API setting", "error");
256
+ } finally {
257
+ setSavingOpenAiCompatApi(false);
258
+ }
259
+ };
260
+
199
261
  const handleApprove = async (id, channel, accountId = "") => {
200
262
  try {
201
263
  const result = await approvePairing(id, channel, accountId);
@@ -288,12 +350,18 @@ export const useGeneralTab = ({
288
350
  gatewayStatus,
289
351
  hasUnpaired,
290
352
  openclawVersion,
353
+ openAiCompatApi: {
354
+ ...(openAiCompatApi || {}),
355
+ enabled: openAiCompatApiEnabled,
356
+ hydrated: openAiCompatApiHydrated,
357
+ },
291
358
  pending,
292
359
  pairingsPolling: pairingsPoll.isPolling,
293
360
  pairingStatusRefreshing,
294
361
  repairingWatchdog,
295
362
  repo,
296
363
  savingSyncCron,
364
+ savingOpenAiCompatApi,
297
365
  syncCron,
298
366
  syncCronChoice,
299
367
  syncCronEnabled,
@@ -306,6 +374,7 @@ export const useGeneralTab = ({
306
374
  handleDeviceApprove,
307
375
  handleDeviceReject,
308
376
  handleOpenDashboard,
377
+ handleOpenAiCompatApiToggle,
309
378
  handleReject,
310
379
  handleSyncCronChoiceChange,
311
380
  handleWatchdogRepair,
@@ -550,6 +550,25 @@ export async function updateSyncCron(payload) {
550
550
  return data;
551
551
  }
552
552
 
553
+ export async function updateOpenAiCompatApiFeature(enabled) {
554
+ const res = await authFetch("/api/alphaclaw/config/features/openai-compat-api", {
555
+ method: "PUT",
556
+ headers: { "Content-Type": "application/json" },
557
+ body: JSON.stringify({ enabled }),
558
+ });
559
+ const text = await res.text();
560
+ let data;
561
+ try {
562
+ data = text ? JSON.parse(text) : {};
563
+ } catch {
564
+ throw new Error(text || "Could not parse AlphaClaw config response");
565
+ }
566
+ if (!res.ok) {
567
+ throw new Error(data.error || text || `HTTP ${res.status}`);
568
+ }
569
+ return data;
570
+ }
571
+
553
572
  export async function fetchCronJobs({ sortBy = "nextRunAtMs", sortDir = "asc" } = {}) {
554
573
  const params = new URLSearchParams();
555
574
  if (sortBy) params.set("sortBy", String(sortBy));
@@ -31,3 +31,7 @@ export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey";
31
31
 
32
32
  // --- Chat ---
33
33
  export const kChatSessionDraftsStorageKey = "alphaclaw.chat.sessionDrafts";
34
+
35
+ // --- Features ---
36
+ export const kOpenAiCompatApiFeatureCacheKey =
37
+ "alphaclaw.features.openAiCompatApi.enabled";
package/lib/scripts/git CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  REAL_GIT_HINT="@@REAL_GIT@@"
6
6
  OPENCLAW_REPO_ROOT="@@OPENCLAW_REPO_ROOT@@"
7
- ASKPASS_PATH="/tmp/alphaclaw-git-askpass.sh"
7
+ ASKPASS_PATH="@@ASKPASS_PATH@@"
8
8
 
9
9
  same_path() {
10
10
  local left_path right_path
@@ -505,12 +505,16 @@ const readPairedCountsByAccount = ({
505
505
  } catch {}
506
506
 
507
507
  for (const accountId of counts.keys()) {
508
- if (String(channelId || "").trim() === "whatsapp") continue;
509
508
  const accountConfig =
510
509
  accountId === "default" &&
511
510
  !(config.accounts && typeof config.accounts === "object")
512
511
  ? config
513
512
  : config.accounts?.[accountId] || {};
513
+ if (String(channelId || "").trim() === "whatsapp") {
514
+ if (!hasSavedWhatsAppCredentials({ fsImpl, OPENCLAW_DIR, accountId })) {
515
+ continue;
516
+ }
517
+ }
514
518
  const inlineAllowFrom = accountConfig?.allowFrom;
515
519
  if (!Array.isArray(inlineAllowFrom)) continue;
516
520
  counts.set(
@@ -616,7 +620,15 @@ const listConfiguredChannelAccounts = ({ fsImpl, OPENCLAW_DIR, cfg }) => {
616
620
  status:
617
621
  Number(pairedCounts.get(accountId) || 0) > 0
618
622
  ? "paired"
619
- : "configured",
623
+ : hasImplicitWhatsAppSelfPairing({
624
+ fsImpl,
625
+ OPENCLAW_DIR,
626
+ channelId,
627
+ accountId,
628
+ accountConfig,
629
+ })
630
+ ? "paired"
631
+ : "configured",
620
632
  };
621
633
  }),
622
634
  };
@@ -764,6 +776,10 @@ module.exports = {
764
776
  resolveCredentialsDirPath,
765
777
  resolveWhatsAppCredentialCandidatePaths,
766
778
  hasSavedWhatsAppCredentials,
779
+ normalizeChannelAccountId,
780
+ resolveCredentialPairingAccountId,
781
+ hasImplicitWhatsAppSelfPairing,
782
+ readPairedCountsByAccount,
767
783
  resolveAgentWorkspacePath,
768
784
  loadConfig,
769
785
  saveConfig,
@@ -0,0 +1,99 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const kConfigFileName = "alphaclaw.json";
5
+ const kDefaultAlphaclawConfig = Object.freeze({
6
+ features: Object.freeze({
7
+ openaiCompatApi: Object.freeze({
8
+ enabled: false,
9
+ }),
10
+ }),
11
+ });
12
+
13
+ const resolveAlphaclawConfigPath = ({ openclawDir } = {}) =>
14
+ path.join(openclawDir || process.cwd(), kConfigFileName);
15
+
16
+ const normalizeOpenAiCompatApiFeature = (feature = {}) => ({
17
+ ...(feature && typeof feature === "object" ? feature : {}),
18
+ enabled: feature?.enabled === true,
19
+ });
20
+
21
+ const normalizeAlphaclawConfig = (raw = {}) => {
22
+ const base = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
23
+ const features =
24
+ base.features && typeof base.features === "object" && !Array.isArray(base.features)
25
+ ? base.features
26
+ : {};
27
+ return {
28
+ ...base,
29
+ features: {
30
+ ...features,
31
+ openaiCompatApi: normalizeOpenAiCompatApiFeature(features.openaiCompatApi),
32
+ },
33
+ };
34
+ };
35
+
36
+ const readAlphaclawConfig = ({
37
+ fsModule = fs,
38
+ openclawDir,
39
+ fallback = kDefaultAlphaclawConfig,
40
+ } = {}) => {
41
+ try {
42
+ const configPath = resolveAlphaclawConfigPath({ openclawDir });
43
+ const raw = fsModule.readFileSync(configPath, "utf8");
44
+ return normalizeAlphaclawConfig(JSON.parse(raw));
45
+ } catch {
46
+ return normalizeAlphaclawConfig(fallback);
47
+ }
48
+ };
49
+
50
+ const writeAlphaclawConfig = ({
51
+ fsModule = fs,
52
+ openclawDir,
53
+ config,
54
+ spacing = 2,
55
+ } = {}) => {
56
+ const configPath = resolveAlphaclawConfigPath({ openclawDir });
57
+ fsModule.mkdirSync(path.dirname(configPath), { recursive: true });
58
+ const normalized = normalizeAlphaclawConfig(config);
59
+ fsModule.writeFileSync(configPath, `${JSON.stringify(normalized, null, spacing)}\n`);
60
+ return normalized;
61
+ };
62
+
63
+ const isOpenAiCompatApiEnabled = (options = {}) =>
64
+ readAlphaclawConfig(options).features.openaiCompatApi.enabled === true;
65
+
66
+ const updateOpenAiCompatApiFeature = ({
67
+ fsModule = fs,
68
+ openclawDir,
69
+ enabled,
70
+ } = {}) => {
71
+ const current = readAlphaclawConfig({ fsModule, openclawDir });
72
+ const next = normalizeAlphaclawConfig({
73
+ ...current,
74
+ features: {
75
+ ...current.features,
76
+ openaiCompatApi: {
77
+ ...current.features.openaiCompatApi,
78
+ enabled: enabled === true,
79
+ },
80
+ },
81
+ });
82
+ const changed =
83
+ current.features.openaiCompatApi.enabled !== next.features.openaiCompatApi.enabled;
84
+ return {
85
+ config: writeAlphaclawConfig({ fsModule, openclawDir, config: next }),
86
+ changed,
87
+ };
88
+ };
89
+
90
+ module.exports = {
91
+ kConfigFileName,
92
+ kDefaultAlphaclawConfig,
93
+ isOpenAiCompatApiEnabled,
94
+ normalizeAlphaclawConfig,
95
+ readAlphaclawConfig,
96
+ resolveAlphaclawConfigPath,
97
+ updateOpenAiCompatApiFeature,
98
+ writeAlphaclawConfig,
99
+ };
@@ -81,6 +81,45 @@ const kLoginStateTtlMs = Math.max(
81
81
  ),
82
82
  kLoginMaxLockMs,
83
83
  );
84
+ const kOpenAiCompatApiRateWindowMs = parsePositiveInt(
85
+ process.env.OPENAI_COMPAT_API_RATE_WINDOW_MS,
86
+ kLoginWindowMs,
87
+ );
88
+ const kOpenAiCompatApiRateMaxAttempts = parsePositiveInt(
89
+ process.env.OPENAI_COMPAT_API_RATE_MAX_ATTEMPTS,
90
+ 10,
91
+ );
92
+ const kOpenAiCompatApiRateBaseLockMs = parsePositiveInt(
93
+ process.env.OPENAI_COMPAT_API_RATE_BASE_LOCK_MS,
94
+ kLoginBaseLockMs,
95
+ );
96
+ const kOpenAiCompatApiRateMaxLockMs = parsePositiveInt(
97
+ process.env.OPENAI_COMPAT_API_RATE_MAX_LOCK_MS,
98
+ kLoginMaxLockMs,
99
+ );
100
+ const kOpenAiCompatApiRateGlobalWindowMs = parsePositiveInt(
101
+ process.env.OPENAI_COMPAT_API_RATE_GLOBAL_WINDOW_MS,
102
+ kOpenAiCompatApiRateWindowMs,
103
+ );
104
+ const kOpenAiCompatApiRateGlobalMaxAttempts = parsePositiveInt(
105
+ process.env.OPENAI_COMPAT_API_RATE_GLOBAL_MAX_ATTEMPTS,
106
+ Math.max(kOpenAiCompatApiRateMaxAttempts * 10, 100),
107
+ );
108
+ const kOpenAiCompatApiRateGlobalBaseLockMs = parsePositiveInt(
109
+ process.env.OPENAI_COMPAT_API_RATE_GLOBAL_BASE_LOCK_MS,
110
+ kOpenAiCompatApiRateBaseLockMs,
111
+ );
112
+ const kOpenAiCompatApiRateGlobalMaxLockMs = parsePositiveInt(
113
+ process.env.OPENAI_COMPAT_API_RATE_GLOBAL_MAX_LOCK_MS,
114
+ kOpenAiCompatApiRateMaxLockMs,
115
+ );
116
+ const kOpenAiCompatApiRateStateTtlMs = Math.max(
117
+ parsePositiveInt(
118
+ process.env.OPENAI_COMPAT_API_RATE_STATE_TTL_MS,
119
+ Math.max(kOpenAiCompatApiRateWindowMs, kOpenAiCompatApiRateMaxLockMs) * 3,
120
+ ),
121
+ kOpenAiCompatApiRateMaxLockMs,
122
+ );
84
123
 
85
124
  const kOnboardingModelProviders = new Set([
86
125
  "anthropic",
@@ -472,6 +511,15 @@ module.exports = {
472
511
  kLoginGlobalMaxLockMs,
473
512
  kLoginCleanupIntervalMs,
474
513
  kLoginStateTtlMs,
514
+ kOpenAiCompatApiRateWindowMs,
515
+ kOpenAiCompatApiRateMaxAttempts,
516
+ kOpenAiCompatApiRateBaseLockMs,
517
+ kOpenAiCompatApiRateMaxLockMs,
518
+ kOpenAiCompatApiRateGlobalWindowMs,
519
+ kOpenAiCompatApiRateGlobalMaxAttempts,
520
+ kOpenAiCompatApiRateGlobalBaseLockMs,
521
+ kOpenAiCompatApiRateGlobalMaxLockMs,
522
+ kOpenAiCompatApiRateStateTtlMs,
475
523
  kOnboardingModelProviders,
476
524
  kFallbackOnboardingModels,
477
525
  kVersionCacheTtlMs,
package/lib/server/env.js CHANGED
@@ -1,21 +1,48 @@
1
1
  const fs = require("fs");
2
2
  const { ENV_FILE_PATH, kKnownVars } = require("./constants");
3
3
 
4
+ const kSensitiveEnvKeyPattern = /token|key|password/i;
5
+ const kEnvWatchDebounceMs = 250;
6
+ let envWatchDebounceTimer = null;
7
+ let lastLoadedEnvSignature = null;
8
+ let pendingSelfWriteSignature = null;
9
+
10
+ const normalizeEnvVars = (vars) => {
11
+ const byKey = new Map();
12
+ for (const entry of vars || []) {
13
+ const key = String(entry?.key || "").trim();
14
+ if (!key) continue;
15
+ if (byKey.has(key)) byKey.delete(key);
16
+ byKey.set(key, {
17
+ key,
18
+ value: String(entry?.value || ""),
19
+ });
20
+ }
21
+ return Array.from(byKey.values());
22
+ };
23
+
24
+ const buildEnvSignature = (vars) =>
25
+ JSON.stringify(normalizeEnvVars(vars).map(({ key, value }) => [key, value]));
26
+
27
+ const readRawEnvFile = () => {
28
+ const content = fs.readFileSync(ENV_FILE_PATH, "utf8");
29
+ const vars = [];
30
+ for (const line of content.split("\n")) {
31
+ const trimmed = line.trim();
32
+ if (!trimmed || trimmed.startsWith("#")) continue;
33
+ const eqIdx = trimmed.indexOf("=");
34
+ if (eqIdx === -1) continue;
35
+ vars.push({
36
+ key: trimmed.slice(0, eqIdx).trim(),
37
+ value: trimmed.slice(eqIdx + 1),
38
+ });
39
+ }
40
+ return vars;
41
+ };
42
+
4
43
  const readEnvFile = () => {
5
44
  try {
6
- const content = fs.readFileSync(ENV_FILE_PATH, "utf8");
7
- const vars = [];
8
- for (const line of content.split("\n")) {
9
- const trimmed = line.trim();
10
- if (!trimmed || trimmed.startsWith("#")) continue;
11
- const eqIdx = trimmed.indexOf("=");
12
- if (eqIdx === -1) continue;
13
- vars.push({
14
- key: trimmed.slice(0, eqIdx),
15
- value: trimmed.slice(eqIdx + 1),
16
- });
17
- }
18
- return vars;
45
+ return normalizeEnvVars(readRawEnvFile());
19
46
  } catch {
20
47
  return [];
21
48
  }
@@ -23,22 +50,24 @@ const readEnvFile = () => {
23
50
 
24
51
  const writeEnvFile = (vars) => {
25
52
  const lines = [];
26
- for (const { key, value } of vars || []) {
27
- if (!key) continue;
53
+ const normalizedVars = normalizeEnvVars(vars);
54
+ for (const { key, value } of normalizedVars) {
28
55
  lines.push(`${key}=${String(value || "")}`);
29
56
  }
30
57
  fs.writeFileSync(ENV_FILE_PATH, lines.join("\n"));
58
+ pendingSelfWriteSignature = buildEnvSignature(normalizedVars);
31
59
  };
32
60
 
33
61
  const reloadEnv = () => {
34
62
  const vars = readEnvFile();
63
+ const signature = buildEnvSignature(vars);
35
64
  const fileKeys = new Set(vars.map((v) => v.key));
36
65
  let changed = false;
37
66
 
38
67
  for (const { key, value } of vars) {
39
68
  if (value && value !== process.env[key]) {
40
69
  console.log(
41
- `[alphaclaw] Env updated: ${key}=${key.toLowerCase().includes("token") || key.toLowerCase().includes("key") || key.toLowerCase().includes("password") ? "***" : value}`,
70
+ `[alphaclaw] Env updated: ${key}=${kSensitiveEnvKeyPattern.test(key) ? "***" : value}`,
42
71
  );
43
72
  process.env[key] = value;
44
73
  changed = true;
@@ -58,21 +87,43 @@ const reloadEnv = () => {
58
87
  }
59
88
  }
60
89
 
90
+ lastLoadedEnvSignature = signature;
61
91
  return changed;
62
92
  };
63
93
 
94
+ const readEnvFileSignature = () => {
95
+ try {
96
+ return buildEnvSignature(readRawEnvFile());
97
+ } catch {
98
+ return null;
99
+ }
100
+ };
101
+
64
102
  const startEnvWatcher = () => {
65
103
  try {
66
104
  fs.watchFile(ENV_FILE_PATH, { interval: 2000 }, () => {
67
- console.log(
68
- `[alphaclaw] ${ENV_FILE_PATH} changed externally, reloading...`,
69
- );
70
- reloadEnv();
105
+ if (envWatchDebounceTimer) clearTimeout(envWatchDebounceTimer);
106
+ envWatchDebounceTimer = setTimeout(() => {
107
+ envWatchDebounceTimer = null;
108
+ const signature = readEnvFileSignature();
109
+ if (signature && signature === pendingSelfWriteSignature) {
110
+ pendingSelfWriteSignature = null;
111
+ lastLoadedEnvSignature = signature;
112
+ return;
113
+ }
114
+ pendingSelfWriteSignature = null;
115
+ if (signature && signature === lastLoadedEnvSignature) return;
116
+ console.log(
117
+ `[alphaclaw] ${ENV_FILE_PATH} changed externally, reloading...`,
118
+ );
119
+ reloadEnv();
120
+ }, kEnvWatchDebounceMs);
71
121
  });
72
122
  } catch {}
73
123
  };
74
124
 
75
125
  module.exports = {
126
+ normalizeEnvVars,
76
127
  readEnvFile,
77
128
  writeEnvFile,
78
129
  reloadEnv,