@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.
@@ -1,7 +1,202 @@
1
+ const crypto = require("crypto");
2
+ const http = require("http");
3
+ const https = require("https");
4
+ const { URL } = require("url");
5
+
6
+ const kOpenAiCompatProxyPathPattern =
7
+ /^\/v1\/(?:chat\/completions|responses|embeddings|models(?:\/[^/?#]+)?)$/;
8
+ const kHopByHopResponseHeaders = new Set([
9
+ "connection",
10
+ "keep-alive",
11
+ "proxy-authenticate",
12
+ "proxy-authorization",
13
+ "te",
14
+ "trailer",
15
+ "trailers",
16
+ "transfer-encoding",
17
+ "upgrade",
18
+ ]);
19
+ // Strip these even though they're not hop-by-hop: an OpenAI-compatible client
20
+ // (e.g. Sure's external assistant) has no business receiving cookies from the
21
+ // gateway, and a stray Set-Cookie crossing the AlphaClaw boundary would be a
22
+ // real leak.
23
+ const kAlwaysStrippedResponseHeaders = new Set(["set-cookie"]);
24
+
25
+ const extractBearerToken = (authorization) => {
26
+ const match = String(authorization || "").match(/^Bearer\s+(.+)$/i);
27
+ return match ? match[1].trim() : "";
28
+ };
29
+
30
+ const getApiAuthThrottleState = (authThrottle, req, now) => {
31
+ if (!authThrottle || typeof authThrottle.getOrCreateLoginAttemptState !== "function") {
32
+ return null;
33
+ }
34
+ const clientKey =
35
+ typeof authThrottle.getClientKey === "function"
36
+ ? authThrottle.getClientKey(req)
37
+ : req.ip || req.socket?.remoteAddress || "unknown";
38
+ return {
39
+ clientKey,
40
+ state: authThrottle.getOrCreateLoginAttemptState(clientKey, now),
41
+ };
42
+ };
43
+
44
+ const sendTooManyAuthAttempts = (res, retryAfterSec = 1) => {
45
+ const normalizedRetryAfterSec = Math.max(1, Math.ceil(Number(retryAfterSec) || 1));
46
+ res.set("Retry-After", String(normalizedRetryAfterSec));
47
+ return res.status(429).json({
48
+ error: "Too many attempts. Try again shortly.",
49
+ retryAfterSec: normalizedRetryAfterSec,
50
+ });
51
+ };
52
+
53
+ const timingSafeStringEqual = (left, right) => {
54
+ const leftBuffer = Buffer.from(String(left || ""), "utf8");
55
+ const rightBuffer = Buffer.from(String(right || ""), "utf8");
56
+ return (
57
+ leftBuffer.length === rightBuffer.length &&
58
+ crypto.timingSafeEqual(leftBuffer, rightBuffer)
59
+ );
60
+ };
61
+
62
+ const extractBodyBuffer = (req) => {
63
+ if (Buffer.isBuffer(req.body)) return req.body;
64
+ if (typeof req.body === "string") return Buffer.from(req.body, "utf8");
65
+ if (req.body && typeof req.body === "object") {
66
+ return Buffer.from(JSON.stringify(req.body), "utf8");
67
+ }
68
+ return Buffer.alloc(0);
69
+ };
70
+
71
+ const createGatewayProxyHeaders = ({ reqHeaders, bodyBuffer }) => {
72
+ const headers = { ...(reqHeaders || {}) };
73
+ delete headers.host;
74
+ delete headers.connection;
75
+ delete headers["content-length"];
76
+ delete headers["transfer-encoding"];
77
+ // Express has already parsed and (if gzip/deflate) inflated the body, so
78
+ // the bytes we reserialize are plain JSON. Forwarding the original
79
+ // Content-Encoding would tell the gateway to gunzip plain text and fail.
80
+ delete headers["content-encoding"];
81
+ delete headers.cookie;
82
+ if (bodyBuffer.length > 0) {
83
+ headers["content-length"] = String(bodyBuffer.length);
84
+ if (!headers["content-type"]) headers["content-type"] = "application/json";
85
+ }
86
+ return headers;
87
+ };
88
+
89
+ const proxyOpenAiCompatRequest = ({
90
+ req,
91
+ res,
92
+ getGatewayUrl,
93
+ getGatewayToken,
94
+ openAiCompatApiThrottle,
95
+ }) => {
96
+ const now = Date.now();
97
+ const throttleState = getApiAuthThrottleState(
98
+ openAiCompatApiThrottle,
99
+ req,
100
+ now,
101
+ );
102
+ if (
103
+ throttleState &&
104
+ typeof openAiCompatApiThrottle.evaluateLoginThrottle === "function"
105
+ ) {
106
+ const throttle = openAiCompatApiThrottle.evaluateLoginThrottle(
107
+ throttleState.state,
108
+ now,
109
+ );
110
+ if (throttle.blocked) {
111
+ return sendTooManyAuthAttempts(res, throttle.retryAfterSec);
112
+ }
113
+ }
114
+
115
+ const bearerToken = extractBearerToken(req.headers.authorization);
116
+ const expectedGatewayToken = String(getGatewayToken?.() || "").trim();
117
+ if (
118
+ !bearerToken ||
119
+ !expectedGatewayToken ||
120
+ !timingSafeStringEqual(bearerToken, expectedGatewayToken)
121
+ ) {
122
+ if (
123
+ throttleState &&
124
+ typeof openAiCompatApiThrottle.recordLoginFailure === "function"
125
+ ) {
126
+ const failure = openAiCompatApiThrottle.recordLoginFailure(
127
+ throttleState.state,
128
+ now,
129
+ );
130
+ if (failure.locked) {
131
+ return sendTooManyAuthAttempts(res, failure.retryAfterSec);
132
+ }
133
+ }
134
+ return res.status(401).json({ error: "Unauthorized" });
135
+ }
136
+ if (
137
+ throttleState?.clientKey &&
138
+ typeof openAiCompatApiThrottle?.recordLoginSuccess === "function"
139
+ ) {
140
+ openAiCompatApiThrottle.recordLoginSuccess(throttleState.clientKey);
141
+ }
142
+
143
+ let gateway;
144
+ try {
145
+ gateway = new URL(getGatewayUrl());
146
+ } catch {
147
+ return res.status(502).json({ error: "Gateway unavailable" });
148
+ }
149
+
150
+ const bodyBuffer = extractBodyBuffer(req);
151
+ const protocolClient = gateway.protocol === "https:" ? https : http;
152
+ const headers = createGatewayProxyHeaders({
153
+ reqHeaders: req.headers,
154
+ bodyBuffer,
155
+ });
156
+ headers.authorization = `Bearer ${bearerToken}`;
157
+
158
+ const requestOptions = {
159
+ protocol: gateway.protocol,
160
+ hostname: gateway.hostname,
161
+ port: gateway.port,
162
+ method: req.method,
163
+ path: req.originalUrl || req.url,
164
+ headers,
165
+ };
166
+
167
+ const proxyReq = protocolClient.request(requestOptions, (proxyRes) => {
168
+ res.statusCode = proxyRes.statusCode || 502;
169
+ for (const [key, value] of Object.entries(proxyRes.headers || {})) {
170
+ if (value == null) continue;
171
+ const lowerKey = key.toLowerCase();
172
+ if (kHopByHopResponseHeaders.has(lowerKey)) continue;
173
+ if (kAlwaysStrippedResponseHeaders.has(lowerKey)) continue;
174
+ res.setHeader(key, value);
175
+ }
176
+ proxyRes.pipe(res);
177
+ });
178
+
179
+ proxyReq.on("error", () => {
180
+ if (!res.headersSent) {
181
+ res.status(502).json({ error: "Gateway unavailable" });
182
+ } else {
183
+ res.end();
184
+ }
185
+ });
186
+
187
+ if (bodyBuffer.length > 0) {
188
+ proxyReq.write(bodyBuffer);
189
+ }
190
+ proxyReq.end();
191
+ };
192
+
1
193
  const registerProxyRoutes = ({
2
194
  app,
3
195
  proxy,
4
196
  getGatewayUrl,
197
+ getGatewayToken,
198
+ isOpenAiCompatApiEnabled = () => true,
199
+ openAiCompatApiThrottle = null,
5
200
  SETUP_API_PREFIXES,
6
201
  requireAuth,
7
202
  oauthCallbackMiddleware,
@@ -29,10 +224,33 @@ const registerProxyRoutes = ({
29
224
  app.all(kHooksPathPattern, webhookMiddleware);
30
225
  app.all(kWebhookPathPattern, webhookMiddleware);
31
226
 
227
+ app.all(kOpenAiCompatProxyPathPattern, (req, res) => {
228
+ if (!isOpenAiCompatApiEnabled()) {
229
+ return res.status(404).json({ error: "Not found" });
230
+ }
231
+ return proxyOpenAiCompatRequest({
232
+ req,
233
+ res,
234
+ getGatewayUrl,
235
+ getGatewayToken,
236
+ openAiCompatApiThrottle,
237
+ });
238
+ });
239
+
32
240
  app.all(kApiPathPattern, (req, res, next) => {
33
241
  if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return next();
34
242
  proxy.web(req, res, { target: getGatewayUrl() });
35
243
  });
36
244
  };
37
245
 
38
- module.exports = { registerProxyRoutes };
246
+ module.exports = {
247
+ kOpenAiCompatProxyPathPattern,
248
+ registerProxyRoutes,
249
+ // Exported for tests.
250
+ __testing: {
251
+ createGatewayProxyHeaders,
252
+ extractBearerToken,
253
+ kHopByHopResponseHeaders,
254
+ kAlwaysStrippedResponseHeaders,
255
+ },
256
+ };
@@ -1,5 +1,10 @@
1
1
  const { buildManagedPaths } = require("../internal-files-migration");
2
2
  const { readOpenclawConfig } = require("../openclaw-config");
3
+ const { shouldSkipSystemCronInstall } = require("../../cli/git-runtime");
4
+ const {
5
+ readAlphaclawConfig,
6
+ updateOpenAiCompatApiFeature,
7
+ } = require("../alphaclaw-config");
3
8
  const https = require("https");
4
9
 
5
10
  const registerSystemRoutes = ({
@@ -26,6 +31,8 @@ const registerSystemRoutes = ({
26
31
  authProfiles,
27
32
  watchdog,
28
33
  doctorService,
34
+ ensureGatewayProxyConfig,
35
+ getBaseUrl,
29
36
  }) => {
30
37
  let envRestartPending = false;
31
38
  let openclawSecretRuntimePromise = null;
@@ -382,6 +389,9 @@ const registerSystemRoutes = ({
382
389
  kSystemCronConfigPath,
383
390
  JSON.stringify(nextConfig, null, 2),
384
391
  );
392
+ if (shouldSkipSystemCronInstall()) {
393
+ return getSystemCronStatus();
394
+ }
385
395
  if (nextConfig.enabled) {
386
396
  fs.writeFileSync(
387
397
  kSystemCronPath,
@@ -590,6 +600,10 @@ const registerSystemRoutes = ({
590
600
  repo,
591
601
  openclawVersion,
592
602
  alphaclawVersion,
603
+ alphaclaw: readAlphaclawConfig({
604
+ fsModule: fs,
605
+ openclawDir: OPENCLAW_DIR,
606
+ }),
593
607
  syncCron: getSystemCronStatus(),
594
608
  };
595
609
  };
@@ -668,6 +682,57 @@ const registerSystemRoutes = ({
668
682
  res.json({ ok: true, syncCron: status });
669
683
  });
670
684
 
685
+ app.get("/api/alphaclaw/config", (req, res) => {
686
+ res.json({
687
+ ok: true,
688
+ config: readAlphaclawConfig({
689
+ fsModule: fs,
690
+ openclawDir: OPENCLAW_DIR,
691
+ }),
692
+ });
693
+ });
694
+
695
+ app.put("/api/alphaclaw/config/features/openai-compat-api", async (req, res) => {
696
+ const { enabled } = req.body || {};
697
+ if (typeof enabled !== "boolean") {
698
+ return res
699
+ .status(400)
700
+ .json({ ok: false, error: "enabled must be a boolean" });
701
+ }
702
+
703
+ try {
704
+ const { config, changed } = updateOpenAiCompatApiFeature({
705
+ fsModule: fs,
706
+ openclawDir: OPENCLAW_DIR,
707
+ enabled,
708
+ });
709
+ let gatewayConfigChanged = false;
710
+ if (enabled && isOnboarded() && typeof ensureGatewayProxyConfig === "function") {
711
+ gatewayConfigChanged = ensureGatewayProxyConfig(getBaseUrl?.(req));
712
+ if (gatewayConfigChanged && restartRequiredState?.markRequired) {
713
+ restartRequiredState.markRequired("openai_compat_api_enabled");
714
+ }
715
+ }
716
+ const snapshot =
717
+ typeof restartRequiredState?.getSnapshot === "function"
718
+ ? await restartRequiredState.getSnapshot()
719
+ : null;
720
+ res.json({
721
+ ok: true,
722
+ changed,
723
+ gatewayConfigChanged,
724
+ config,
725
+ restartRequired:
726
+ Boolean(snapshot?.restartRequired) || (envRestartPending && isOnboarded()),
727
+ });
728
+ } catch (err) {
729
+ res.status(500).json({
730
+ ok: false,
731
+ error: err.message || "Could not update AlphaClaw config",
732
+ });
733
+ }
734
+ });
735
+
671
736
  app.get("/api/alphaclaw/version", async (req, res) => {
672
737
  const refresh = String(req.query.refresh || "") === "1";
673
738
  const status = await alphaclawVersionService.getVersionStatus(refresh);
@@ -1,5 +1,8 @@
1
1
  const path = require("path");
2
2
  const { readOpenclawConfig, writeOpenclawConfig } = require("./openclaw-config");
3
+ const {
4
+ migrateLegacyTelegramStreamingConfig,
5
+ } = require("./openclaw-config-migrations");
3
6
 
4
7
  const kUsageTrackerPluginPath = path.resolve(
5
8
  __dirname,
@@ -123,7 +126,8 @@ const ensureUsageTrackerPluginConfig = ({ fsModule, openclawDir }) => {
123
126
  openclawDir,
124
127
  fallback: {},
125
128
  });
126
- const changed = reconcileManagedPluginConfig(cfg);
129
+ const migrated = migrateLegacyTelegramStreamingConfig(cfg);
130
+ const changed = reconcileManagedPluginConfig(cfg) || migrated;
127
131
  if (!changed) return false;
128
132
  writeOpenclawConfig({
129
133
  fsModule,
package/lib/server.js CHANGED
@@ -148,6 +148,9 @@ const {
148
148
  const {
149
149
  ensureOpenclawStartupEnv,
150
150
  } = require("./server/openclaw-runtime-env");
151
+ const {
152
+ isOpenAiCompatApiEnabled,
153
+ } = require("./server/alphaclaw-config");
151
154
 
152
155
  const { PORT, kTrustProxyHops, SETUP_API_PREFIXES } = constants;
153
156
 
@@ -165,6 +168,18 @@ const app = express();
165
168
  app.set("trust proxy", kTrustProxyHops);
166
169
  app.use(["/webhook", "/hooks"], express.raw({ type: "*/*", limit: "5mb" }));
167
170
  app.use("/gmail-pubsub", express.raw({ type: "*/*", limit: "5mb" }));
171
+ const openAiCompatJsonParser = express.json({ limit: "50mb" });
172
+ const isOpenAiCompatApiCurrentlyEnabled = () =>
173
+ isOpenAiCompatApiEnabled({
174
+ fsModule: fs,
175
+ openclawDir: constants.OPENCLAW_DIR,
176
+ });
177
+ app.use("/v1", (req, res, next) => {
178
+ if (!isOpenAiCompatApiCurrentlyEnabled()) {
179
+ return res.status(404).json({ error: "Not found" });
180
+ }
181
+ return openAiCompatJsonParser(req, res, next);
182
+ });
168
183
  app.use(express.json({ limit: "5mb" }));
169
184
 
170
185
  const proxy = httpProxy.createProxyServer({
@@ -190,8 +205,25 @@ const agentsService = createAgentsService({
190
205
  restartGateway: () => restartGatewayWithReload(reloadEnv),
191
206
  clawCmd,
192
207
  });
208
+ const loginThrottleStore = createLoginThrottleStore();
193
209
  const loginThrottle = {
194
- ...createLoginThrottle({ store: createLoginThrottleStore() }),
210
+ ...createLoginThrottle({ store: loginThrottleStore }),
211
+ getClientKey,
212
+ };
213
+ const openAiCompatApiThrottle = {
214
+ ...createLoginThrottle({
215
+ store: loginThrottleStore,
216
+ scope: "openai-compat-api",
217
+ windowMs: constants.kOpenAiCompatApiRateWindowMs,
218
+ maxAttempts: constants.kOpenAiCompatApiRateMaxAttempts,
219
+ baseLockMs: constants.kOpenAiCompatApiRateBaseLockMs,
220
+ maxLockMs: constants.kOpenAiCompatApiRateMaxLockMs,
221
+ globalWindowMs: constants.kOpenAiCompatApiRateGlobalWindowMs,
222
+ globalMaxAttempts: constants.kOpenAiCompatApiRateGlobalMaxAttempts,
223
+ globalBaseLockMs: constants.kOpenAiCompatApiRateGlobalBaseLockMs,
224
+ globalMaxLockMs: constants.kOpenAiCompatApiRateGlobalMaxLockMs,
225
+ stateTtlMs: constants.kOpenAiCompatApiRateStateTtlMs,
226
+ }),
195
227
  getClientKey,
196
228
  };
197
229
  const resolveSetupUrl = () =>
@@ -318,6 +350,8 @@ const {
318
350
  resolveGithubRepoUrl,
319
351
  resolveModelProvider,
320
352
  ensureGatewayProxyConfig,
353
+ isOpenAiCompatApiEnabled: isOpenAiCompatApiCurrentlyEnabled,
354
+ openAiCompatApiThrottle,
321
355
  getBaseUrl,
322
356
  startGateway,
323
357
  ensureManagedExecDefaults: () =>
@@ -12,6 +12,23 @@ if [[ -f "$REPO/.env" ]]; then
12
12
  set +a
13
13
  fi
14
14
 
15
+ if [[ -f "$REPO/cron/system-sync.json" ]]; then
16
+ if node - "$REPO/cron/system-sync.json" <<'NODE'
17
+ const fs = require('fs');
18
+ const file = process.argv[2];
19
+ try {
20
+ const config = JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ process.exit(config && config.enabled === false ? 0 : 1);
22
+ } catch {
23
+ process.exit(1);
24
+ }
25
+ NODE
26
+ then
27
+ echo "hourly-git-sync: disabled by cron/system-sync.json"
28
+ exit 0
29
+ fi
30
+ fi
31
+
15
32
  # Drop cron scheduler runtime-only churn when it is metadata/timestamp-only.
16
33
  maybe_restore_if_runtime_only() {
17
34
  local file="$1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "express": "^4.21.0",
35
35
  "http-proxy": "^1.18.1",
36
- "openclaw": "2026.5.28",
36
+ "openclaw": "2026.6.11",
37
37
  "ws": "^8.19.0"
38
38
  },
39
39
  "devDependencies": {
@@ -51,6 +51,6 @@
51
51
  "wouter-preact": "^3.7.1"
52
52
  },
53
53
  "engines": {
54
- "node": ">=22.14.0"
54
+ "node": ">=22.19.0"
55
55
  }
56
56
  }