@chrysb/alphaclaw 0.9.17 → 0.9.18

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";
@@ -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,
@@ -12,6 +12,7 @@ const {
12
12
  kRootDir,
13
13
  } = require("./constants");
14
14
  const { withOpenclawStartupEnv } = require("./openclaw-runtime-env");
15
+ const { isOpenAiCompatApiEnabled } = require("./alphaclaw-config");
15
16
 
16
17
  let gatewayChild = null;
17
18
  let gatewayExitHandler = null;
@@ -505,6 +506,31 @@ const ensureGatewayProxyConfig = (origin) => {
505
506
  if (!cfg.gateway) cfg.gateway = {};
506
507
  let changed = false;
507
508
 
509
+ if (isOpenAiCompatApiEnabled({ fsModule: fs, openclawDir: OPENCLAW_DIR })) {
510
+ if (!cfg.gateway.http) cfg.gateway.http = {};
511
+ if (!cfg.gateway.http.endpoints) cfg.gateway.http.endpoints = {};
512
+
513
+ const chatCompletions = cfg.gateway.http.endpoints.chatCompletions || {};
514
+ if (chatCompletions.enabled !== true) {
515
+ cfg.gateway.http.endpoints.chatCompletions = {
516
+ ...chatCompletions,
517
+ enabled: true,
518
+ };
519
+ console.log("[alphaclaw] Enabled gateway OpenAI chat completions endpoint");
520
+ changed = true;
521
+ }
522
+
523
+ const responses = cfg.gateway.http.endpoints.responses || {};
524
+ if (responses.enabled !== true) {
525
+ cfg.gateway.http.endpoints.responses = {
526
+ ...responses,
527
+ enabled: true,
528
+ };
529
+ console.log("[alphaclaw] Enabled gateway OpenResponses endpoint");
530
+ changed = true;
531
+ }
532
+ }
533
+
508
534
  if (!Array.isArray(cfg.gateway.trustedProxies)) {
509
535
  cfg.gateway.trustedProxies = [];
510
536
  }
@@ -526,8 +552,144 @@ const ensureGatewayProxyConfig = (origin) => {
526
552
  }
527
553
  }
528
554
 
555
+ // Managed remote MCP server entry. Env-driven so any AlphaClaw operator
556
+ // (Render, Fly, fly.io-style PaaS, plain VPS) can wire OpenClaw to a
557
+ // remote MCP server without hand-editing /data/.openclaw/openclaw.json.
558
+ //
559
+ // REMOTE_MCP_URL upstream MCP endpoint (streamable-http).
560
+ // REMOTE_MCP_API_TOKEN Bearer token the remote MCP expects. Persisted
561
+ // as the ${REMOTE_MCP_API_TOKEN} reference, not
562
+ // raw, so the openclaw.json that gets
563
+ // git-committed never holds the plaintext.
564
+ // REMOTE_MCP_NAME Key under mcp.servers.<name>. Default "remote".
565
+ // REMOTE_MCP_PROXY_URL When set, OpenClaw connects here instead of
566
+ // REMOTE_MCP_URL. Intended for a same-host
567
+ // scanning proxy (e.g. `pipelock mcp proxy
568
+ // --listen ... --upstream <REMOTE_MCP_URL>`),
569
+ // but the implementation is proxy-agnostic.
570
+ // The supervisor that starts that proxy is
571
+ // responsible for unsetting this env var when
572
+ // the proxy is not running, so AlphaClaw never
573
+ // points OpenClaw at a dead listener.
574
+ const remoteMcpUrl = String(process.env.REMOTE_MCP_URL || "").trim();
575
+ const remoteMcpToken = String(
576
+ process.env.REMOTE_MCP_API_TOKEN || "",
577
+ ).trim();
578
+ const remoteMcpProxyUrl = String(
579
+ process.env.REMOTE_MCP_PROXY_URL || "",
580
+ ).trim();
581
+ const remoteMcpNameRaw = String(process.env.REMOTE_MCP_NAME || "").trim();
582
+ // Constrain the managed key. OpenClaw sanitizes names later for tool
583
+ // prefixes, but the config-key itself must be safe to use as an object
584
+ // key and to read back in `openclaw mcp` CLI commands. Reject names
585
+ // with prototype-pollution shapes, spaces, or path-like names; fall
586
+ // back to "remote" with a warning so a typo doesn't silently misroute.
587
+ const kRemoteMcpNamePattern = /^[A-Za-z0-9_-]{1,64}$/;
588
+ const kReservedRemoteMcpNames = new Set([
589
+ "__proto__",
590
+ "constructor",
591
+ "prototype",
592
+ ]);
593
+ let remoteMcpName = "remote";
594
+ if (remoteMcpNameRaw) {
595
+ if (
596
+ kRemoteMcpNamePattern.test(remoteMcpNameRaw) &&
597
+ !kReservedRemoteMcpNames.has(remoteMcpNameRaw)
598
+ ) {
599
+ remoteMcpName = remoteMcpNameRaw;
600
+ } else {
601
+ console.warn(
602
+ `[alphaclaw] REMOTE_MCP_NAME=${JSON.stringify(remoteMcpNameRaw)} is invalid (must match ${kRemoteMcpNamePattern} and not be a reserved key); falling back to "remote"`,
603
+ );
604
+ }
605
+ }
606
+ const placeholderAuth = "Bearer ${REMOTE_MCP_API_TOKEN}";
607
+ const desiredAuth = `Bearer ${remoteMcpToken}`;
608
+ const kManagedMarker = "_alphaclawManaged";
609
+ let mcpChanged = false;
610
+
611
+ // Clean up any managed entries left over from a prior REMOTE_MCP_NAME
612
+ // value. Without this, renaming REMOTE_MCP_NAME from "sure" to "notion"
613
+ // would leave the old "sure" entry behind, duplicating MCP tools or
614
+ // routing callbacks to a stale target. The marker scopes the cleanup so
615
+ // user-managed entries (no marker) are never touched.
616
+ if (cfg.mcp?.servers) {
617
+ for (const [key, entry] of Object.entries(cfg.mcp.servers)) {
618
+ if (
619
+ entry &&
620
+ typeof entry === "object" &&
621
+ entry[kManagedMarker] === true &&
622
+ key !== remoteMcpName
623
+ ) {
624
+ delete cfg.mcp.servers[key];
625
+ mcpChanged = true;
626
+ console.log(
627
+ `[alphaclaw] Removed stale managed MCP server "${key}" (REMOTE_MCP_NAME is now "${remoteMcpName}")`,
628
+ );
629
+ }
630
+ }
631
+ }
632
+
633
+ if (remoteMcpUrl && remoteMcpToken) {
634
+ if (!cfg.mcp) cfg.mcp = {};
635
+ if (!cfg.mcp.servers) cfg.mcp.servers = {};
636
+ const existing = cfg.mcp.servers[remoteMcpName] || {};
637
+ const effectiveUrl = remoteMcpProxyUrl || remoteMcpUrl;
638
+ const existingHeaders = existing.headers || {};
639
+ const existingAuth = existingHeaders.Authorization;
640
+ // Only the placeholder counts as "already sanitized". A plaintext
641
+ // Bearer (even one that matches the current desiredAuth) must trigger a
642
+ // rewrite so the substitution loop below scrubs it back to the
643
+ // ${REMOTE_MCP_API_TOKEN} reference.
644
+ const authIsPlaceholder = existingAuth === placeholderAuth;
645
+ const hasManagedMarker = existing[kManagedMarker] === true;
646
+ if (
647
+ existing.url !== effectiveUrl ||
648
+ existing.transport !== "streamable-http" ||
649
+ !authIsPlaceholder ||
650
+ !hasManagedMarker
651
+ ) {
652
+ cfg.mcp.servers[remoteMcpName] = {
653
+ ...existing,
654
+ url: effectiveUrl,
655
+ transport: "streamable-http",
656
+ headers: {
657
+ ...existingHeaders,
658
+ Authorization: desiredAuth,
659
+ },
660
+ [kManagedMarker]: true,
661
+ };
662
+ mcpChanged = true;
663
+ console.log(
664
+ `[alphaclaw] Configured remote MCP server "${remoteMcpName}" (url=${effectiveUrl}, via_proxy=${Boolean(remoteMcpProxyUrl)})`,
665
+ );
666
+ }
667
+ } else if (
668
+ cfg.mcp?.servers?.[remoteMcpName] &&
669
+ cfg.mcp.servers[remoteMcpName][kManagedMarker] === true
670
+ ) {
671
+ delete cfg.mcp.servers[remoteMcpName];
672
+ mcpChanged = true;
673
+ console.log(
674
+ `[alphaclaw] Removed remote MCP server "${remoteMcpName}" entry (REMOTE_MCP_URL / REMOTE_MCP_API_TOKEN unset)`,
675
+ );
676
+ }
677
+ if (cfg.mcp?.servers && Object.keys(cfg.mcp.servers).length === 0) {
678
+ delete cfg.mcp.servers;
679
+ }
680
+ if (cfg.mcp && Object.keys(cfg.mcp).length === 0) {
681
+ delete cfg.mcp;
682
+ }
683
+ if (mcpChanged) changed = true;
684
+
529
685
  if (changed) {
530
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
686
+ let content = JSON.stringify(cfg, null, 2);
687
+ if (remoteMcpToken) {
688
+ const jsonValue = JSON.stringify(desiredAuth);
689
+ const jsonPlaceholder = JSON.stringify(placeholderAuth);
690
+ content = content.split(jsonValue).join(jsonPlaceholder);
691
+ }
692
+ fs.writeFileSync(configPath, content);
531
693
  }
532
694
  return changed;
533
695
  } catch (e) {
@@ -42,6 +42,8 @@ const registerServerRoutes = ({
42
42
  resolveGithubRepoUrl,
43
43
  resolveModelProvider,
44
44
  ensureGatewayProxyConfig,
45
+ isOpenAiCompatApiEnabled,
46
+ openAiCompatApiThrottle,
45
47
  getBaseUrl,
46
48
  startGateway,
47
49
  ensureManagedExecDefaults,
@@ -133,6 +135,8 @@ const registerServerRoutes = ({
133
135
  authProfiles,
134
136
  watchdog,
135
137
  doctorService,
138
+ ensureGatewayProxyConfig,
139
+ getBaseUrl,
136
140
  });
137
141
  registerBrowseRoutes({
138
142
  app,
@@ -274,6 +278,10 @@ const registerServerRoutes = ({
274
278
  app,
275
279
  proxy,
276
280
  getGatewayUrl,
281
+ getGatewayToken: () =>
282
+ process.env.OPENCLAW_GATEWAY_TOKEN || constants.GATEWAY_TOKEN || "",
283
+ isOpenAiCompatApiEnabled,
284
+ openAiCompatApiThrottle,
277
285
  SETUP_API_PREFIXES,
278
286
  requireAuth,
279
287
  oauthCallbackMiddleware,