mlclaw 0.3.7 → 0.4.0

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.
@@ -12,7 +12,7 @@ import process2 from "node:process";
12
12
 
13
13
  // src/mlclaw-space-runtime/config.ts
14
14
  import { readFileSync as readFileSync2 } from "node:fs";
15
- import { randomBytes } from "node:crypto";
15
+ import { createHash, randomBytes } from "node:crypto";
16
16
 
17
17
  // src/hf-state-sync/paths.ts
18
18
  var DEFAULT_BUCKET_PREFIX = "openclaw-state";
@@ -5039,6 +5039,18 @@ function validatedBrokerPayload(value, schema, label) {
5039
5039
  return parsed.data;
5040
5040
  }
5041
5041
 
5042
+ // src/mlclaw-space-runtime/local-access.ts
5043
+ import { createHmac, timingSafeEqual } from "node:crypto";
5044
+ var LOCAL_ACCESS_CONTEXT = "mlclaw-local-access-v1";
5045
+ function deriveLocalAccessToken(sessionSecret) {
5046
+ return createHmac("sha256", sessionSecret).update(LOCAL_ACCESS_CONTEXT).digest("base64url");
5047
+ }
5048
+ function localAccessTokenMatches(candidate, expected) {
5049
+ const left = Buffer.from(candidate);
5050
+ const right = Buffer.from(expected);
5051
+ return left.length === right.length && timingSafeEqual(left, right);
5052
+ }
5053
+
5042
5054
  // src/mlclaw-space-runtime/config.ts
5043
5055
  function loadConfig(env = process.env) {
5044
5056
  const port = integer(env.PORT ?? env.MLCLAW_SPACE_PORT, 7860);
@@ -5056,13 +5068,18 @@ function loadConfig(env = process.env) {
5056
5068
  spaceCreatorUserId
5057
5069
  });
5058
5070
  const owner = ownerFromSpaceId(spaceId);
5071
+ const stateBucket = trim(env.OPENCLAW_HF_STATE_BUCKET);
5072
+ const gatewayLocation = trim(env.MLCLAW_GATEWAY_LOCATION);
5073
+ const localAccessUser = gatewayLocation === "local" ? trim(env.MLCLAW_LOCAL_ACCESS_USER) ?? ownerFromRepoId(stateBucket) : void 0;
5059
5074
  const configuredAllowedUsers = splitUsers(env.MLCLAW_ALLOWED_USERS ?? env.ALLOWED_USERS);
5060
5075
  const configuredAdmins = splitUsers(env.MLCLAW_ADMINS);
5061
- const resolvedAdmins = uniqueUsers(
5062
- configuredAdmins.length > 0 ? configuredAdmins : owner ? [owner] : configuredAllowedUsers.slice(0, 1)
5063
- );
5076
+ const resolvedAdmins = uniqueUsers([
5077
+ ...configuredAdmins.length > 0 ? configuredAdmins : owner ? [owner] : configuredAllowedUsers.slice(0, 1),
5078
+ ...localAccessUser ? [localAccessUser] : []
5079
+ ]);
5064
5080
  const allowedUsers = uniqueUsers([...configuredAllowedUsers, ...resolvedAdmins, ...owner ? [owner] : []]);
5065
5081
  const publicUrl = publicUrlFromEnv(env, port);
5082
+ const accessOrigins = accessOriginsFromEnv(env, publicUrl);
5066
5083
  const sessionSecret = trim(env.MLCLAW_SESSION_SECRET ?? env.SESSION_SECRET) ?? randomBytes(48).toString("base64url");
5067
5084
  const configuredCredentialKey = trim(env.MLCLAW_CREDENTIAL_KEY);
5068
5085
  if (mode === "app" && !configuredCredentialKey) {
@@ -5087,6 +5104,7 @@ function loadConfig(env = process.env) {
5087
5104
  openclawUid: integer(env.MLCLAW_OPENCLAW_UID, 1e3),
5088
5105
  openclawGid: integer(env.MLCLAW_OPENCLAW_GID, 1e3),
5089
5106
  publicUrl,
5107
+ accessOrigins,
5090
5108
  providerUrl: trim(env.OPENID_PROVIDER_URL) ?? "https://huggingface.co",
5091
5109
  oauthClientId: trim(env.OAUTH_CLIENT_ID),
5092
5110
  oauthClientSecret: trim(env.OAUTH_CLIENT_SECRET),
@@ -5095,6 +5113,7 @@ function loadConfig(env = process.env) {
5095
5113
  credentialKey,
5096
5114
  credentialKeyGenerated: !configuredCredentialKey,
5097
5115
  cookieSecure: env.MLCLAW_COOKIE_SECURE === "0" ? false : !publicUrl.startsWith("http://"),
5116
+ sessionCookieName: gatewayLocation === "local" ? localSessionCookieName(trim(env.MLCLAW_RUNTIME_ID) ?? publicUrl) : SESSION_COOKIE_PREFIX,
5098
5117
  spaceId,
5099
5118
  canonicalSpaceId,
5100
5119
  canonicalCreatorUserId,
@@ -5102,6 +5121,8 @@ function loadConfig(env = process.env) {
5102
5121
  allowedUsers,
5103
5122
  adminUsers: resolvedAdmins,
5104
5123
  allowAnySignedIn: env.MLCLAW_ALLOW_ANY_SIGNED_IN === "1" || env.MLCLAW_ALLOW_ANY_SIGNED_IN === "true",
5124
+ localAccessUser,
5125
+ localAccessToken: gatewayLocation === "local" && localAccessUser ? deriveLocalAccessToken(sessionSecret) : void 0,
5105
5126
  mode,
5106
5127
  hfToken: readOptionalSecret(trim(env.MLCLAW_TRUSTED_HF_TOKEN_FILE)) ?? trim(env.HF_TOKEN ?? env.HUGGINGFACE_HUB_TOKEN),
5107
5128
  routerToken: trim(env.MLCLAW_ROUTER_TOKEN ?? env.HF_ROUTER_TOKEN),
@@ -5127,10 +5148,10 @@ function loadConfig(env = process.env) {
5127
5148
  model,
5128
5149
  modelChoices: runtimeSettings2.modelChoices ?? parseModelChoicesEnv(env.MLCLAW_MODEL_CHOICES, model),
5129
5150
  routerModelsUrl: trim(env.MLCLAW_ROUTER_MODELS_URL) ?? "https://router.huggingface.co/v1/models",
5130
- stateBucket: trim(env.OPENCLAW_HF_STATE_BUCKET),
5151
+ stateBucket,
5131
5152
  stateMountDir,
5132
5153
  statePrefix,
5133
- gatewayLocation: trim(env.MLCLAW_GATEWAY_LOCATION),
5154
+ gatewayLocation,
5134
5155
  runtimeImage: trim(env.MLCLAW_RUNTIME_IMAGE),
5135
5156
  runtimeId: trim(env.MLCLAW_RUNTIME_ID),
5136
5157
  templateRev: trim(env.MLCLAW_TEMPLATE_REV),
@@ -5138,6 +5159,30 @@ function loadConfig(env = process.env) {
5138
5159
  branding: resolveBranding(env, agentName)
5139
5160
  };
5140
5161
  }
5162
+ var SESSION_COOKIE_PREFIX = "mlclaw_session";
5163
+ function localSessionCookieName(identity) {
5164
+ return `${SESSION_COOKIE_PREFIX}_${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
5165
+ }
5166
+ function accessOriginsFromEnv(env, publicUrl) {
5167
+ const configured = (env.MLCLAW_ACCESS_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
5168
+ if (configured.length > 8) {
5169
+ throw new Error("MLCLAW_ACCESS_ORIGINS supports at most 8 origins");
5170
+ }
5171
+ const origins = [.../* @__PURE__ */ new Set([publicUrl, ...configured.map(parseAccessOrigin)])];
5172
+ if (origins.length > 8) {
5173
+ throw new Error("MLCLAW_ACCESS_ORIGINS supports at most 8 origins including MLCLAW_PUBLIC_URL");
5174
+ }
5175
+ return origins;
5176
+ }
5177
+ function parseAccessOrigin(value) {
5178
+ const url = new URL(value);
5179
+ if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password || url.hostname.includes("*") || url.pathname !== "/" || url.search || url.hash) {
5180
+ throw new Error(
5181
+ "MLCLAW_ACCESS_ORIGINS entries must be HTTP origins without credentials, wildcard hosts, paths, queries, or fragments"
5182
+ );
5183
+ }
5184
+ return url.origin;
5185
+ }
5141
5186
  function readOptionalSecret(file) {
5142
5187
  if (!file) {
5143
5188
  return void 0;
@@ -5172,6 +5217,14 @@ function resolveMode(params) {
5172
5217
  return params.canonicalCreatorUserId === params.spaceCreatorUserId ? "template" : "app";
5173
5218
  }
5174
5219
  function publicUrlFromEnv(env, port) {
5220
+ const explicit = trim(env.MLCLAW_PUBLIC_URL);
5221
+ if (explicit) {
5222
+ const url = new URL(explicit);
5223
+ if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
5224
+ throw new Error("MLCLAW_PUBLIC_URL must be one HTTP origin without credentials, path, query, or fragment");
5225
+ }
5226
+ return url.origin;
5227
+ }
5175
5228
  const host = trim(env.SPACE_HOST);
5176
5229
  if (host) {
5177
5230
  return host.startsWith("http") ? host.replace(/\/+$/, "") : `https://${host.replace(/\/+$/, "")}`;
@@ -5179,7 +5232,10 @@ function publicUrlFromEnv(env, port) {
5179
5232
  return `http://127.0.0.1:${port}`;
5180
5233
  }
5181
5234
  function ownerFromSpaceId(spaceId) {
5182
- const owner = spaceId?.split("/")[0]?.trim();
5235
+ return ownerFromRepoId(spaceId);
5236
+ }
5237
+ function ownerFromRepoId(repoId) {
5238
+ const owner = repoId?.split("/")[0]?.trim();
5183
5239
  return owner || void 0;
5184
5240
  }
5185
5241
  function integer(value, fallback) {
@@ -7322,7 +7378,7 @@ var Hono2 = class extends Hono {
7322
7378
  };
7323
7379
 
7324
7380
  // src/mlclaw-space-runtime/csrf.ts
7325
- import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
7381
+ import { createHmac as createHmac2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
7326
7382
  var CSRF_TTL_SECONDS = 60 * 60;
7327
7383
  function createCsrfToken(params) {
7328
7384
  const now = params.now ?? Date.now();
@@ -7355,16 +7411,16 @@ function verifyCsrfToken(params) {
7355
7411
  return payload.username === params.username && typeof payload.exp === "number" && payload.exp > now && typeof payload.nonce === "string" && payload.nonce.length > 0;
7356
7412
  }
7357
7413
  function sign(value, secret) {
7358
- return createHmac("sha256", secret).update(value).digest("base64url");
7414
+ return createHmac2("sha256", secret).update(value).digest("base64url");
7359
7415
  }
7360
7416
  function signatureMatches(a, b) {
7361
7417
  const left = Buffer.from(a);
7362
7418
  const right = Buffer.from(b);
7363
- return left.length === right.length && timingSafeEqual(left, right);
7419
+ return left.length === right.length && timingSafeEqual2(left, right);
7364
7420
  }
7365
7421
 
7366
7422
  // src/mlclaw-space-runtime/delegated-brokerkit.ts
7367
- import { createHash, createHmac as createHmac2, randomBytes as randomBytes4, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
7423
+ import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes4, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
7368
7424
 
7369
7425
  // src/mlclaw-space-runtime/delegated-revisions.ts
7370
7426
  import { randomBytes as randomBytes3 } from "node:crypto";
@@ -7449,7 +7505,7 @@ var DelegatedBrokerKit = class {
7449
7505
  this.registry = registry;
7450
7506
  this.now = now;
7451
7507
  this.sourceDeadlineMs = sourceDeadlineMs;
7452
- this.key = createHmac2("sha256", sessionSecret).update("mlclaw/brokerkit-delegated-web/v1", "utf8").digest();
7508
+ this.key = createHmac3("sha256", sessionSecret).update("mlclaw/brokerkit-delegated-web/v1", "utf8").digest();
7453
7509
  }
7454
7510
  key;
7455
7511
  handles = /* @__PURE__ */ new Map();
@@ -7666,7 +7722,7 @@ var DelegatedBrokerKit = class {
7666
7722
  this.handlesByIdentity.delete(requestIdentity(record.sourceId, record.requestId, record.revision));
7667
7723
  }
7668
7724
  sign(encoded) {
7669
- return createHmac2("sha256", this.key).update(encoded, "utf8").digest("base64url");
7725
+ return createHmac3("sha256", this.key).update(encoded, "utf8").digest("base64url");
7670
7726
  }
7671
7727
  };
7672
7728
  async function decideWithRecovery(source, requestId, action, decision) {
@@ -7740,7 +7796,7 @@ function handleExpiry(request) {
7740
7796
  return request.pending_expires_at ?? request.active_expires_at ?? "";
7741
7797
  }
7742
7798
  function decisionKey(record, action, actor) {
7743
- return createHash("sha256").update(
7799
+ return createHash2("sha256").update(
7744
7800
  ["mlclaw-brokerkit-decision-v1", record.sourceId, record.requestId, String(record.revision), action, actor].join(
7745
7801
  "\0"
7746
7802
  ),
@@ -7813,7 +7869,7 @@ function validTokenNonce(record) {
7813
7869
  function safeEqual(left, right) {
7814
7870
  const a = Buffer.from(left, "utf8");
7815
7871
  const b = Buffer.from(right, "utf8");
7816
- return a.length === b.length && timingSafeEqual2(a, b);
7872
+ return a.length === b.length && timingSafeEqual3(a, b);
7817
7873
  }
7818
7874
  function safeSourceError(error) {
7819
7875
  const code = error instanceof BrokerOperatorError ? error.code : error instanceof DelegatedBrokerKitError ? error.code : void 0;
@@ -7973,7 +8029,7 @@ import fs from "node:fs/promises";
7973
8029
  import path from "node:path";
7974
8030
 
7975
8031
  // src/mlclaw-space-runtime/mcp-integrations.ts
7976
- import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
8032
+ import { createHmac as createHmac4, timingSafeEqual as timingSafeEqual4 } from "node:crypto";
7977
8033
  import http from "node:http";
7978
8034
  import { Readable } from "node:stream";
7979
8035
  var MAX_REQUEST_BYTES = 16 * 1024 * 1024;
@@ -8237,7 +8293,7 @@ var ResearchRpcError = class extends Error {
8237
8293
  }
8238
8294
  };
8239
8295
  function deriveInternalToken(secret) {
8240
- return createHmac3("sha256", secret).update("mlclaw:mcp-integrations:v1").digest("base64url");
8296
+ return createHmac4("sha256", secret).update("mlclaw:mcp-integrations:v1").digest("base64url");
8241
8297
  }
8242
8298
  function managedMcpServerConfig(config2) {
8243
8299
  const headers = { [INTERNAL_HEADER]: deriveInternalToken(config2.sessionSecret) };
@@ -8467,7 +8523,7 @@ function validInternalToken(value, expected) {
8467
8523
  }
8468
8524
  const actualBuffer = Buffer.from(value);
8469
8525
  const expectedBuffer = Buffer.from(expected);
8470
- return actualBuffer.length === expectedBuffer.length && timingSafeEqual3(actualBuffer, expectedBuffer);
8526
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual4(actualBuffer, expectedBuffer);
8471
8527
  }
8472
8528
  function requestHeader(headers, name) {
8473
8529
  const value = headers[name];
@@ -8547,7 +8603,7 @@ async function configureOpenClawGateway(config2) {
8547
8603
  gateway.controlUi = {
8548
8604
  ...typeof gateway.controlUi === "object" && gateway.controlUi ? gateway.controlUi : {},
8549
8605
  dangerouslyDisableDeviceAuth: true,
8550
- allowedOrigins: [config2.publicUrl],
8606
+ allowedOrigins: config2.accessOrigins,
8551
8607
  embedSandbox: "scripts"
8552
8608
  };
8553
8609
  configureOpenClawModels(openclawConfig, config2);
@@ -8860,7 +8916,9 @@ function validateOpenAiApiKey(value) {
8860
8916
 
8861
8917
  // src/mlclaw-space-runtime/pages.ts
8862
8918
  function templatePage(config2) {
8863
- return page("ML Claw", `
8919
+ return page(
8920
+ "ML Claw",
8921
+ `
8864
8922
  <main>
8865
8923
  <img src="/assets/mlclaw.svg" alt="ML Claw" class="logo">
8866
8924
  <h1>ML Claw</h1>
@@ -8880,30 +8938,85 @@ function templatePage(config2) {
8880
8938
  <p class="muted">Manual duplication is for development or advanced setup only.</p>
8881
8939
  <p class="muted">Source Space: ${escapeHtml(config2.spaceId ?? config2.canonicalSpaceId)}</p>
8882
8940
  </main>
8883
- `);
8941
+ `
8942
+ );
8884
8943
  }
8885
8944
  function loginPage(config2, message, next = "/") {
8886
8945
  const oauthReady = Boolean(config2.oauthClientId && config2.oauthClientSecret);
8887
8946
  const loginPath = next === "/" ? "/oauth/login" : `/oauth/login?next=${encodeURIComponent(next)}`;
8888
8947
  const loginHref = new URL(loginPath, config2.publicUrl).toString();
8889
- return page(`${config2.branding.name} Login`, `
8948
+ return page(
8949
+ `${config2.branding.name} Login`,
8950
+ `
8890
8951
  <main>
8891
8952
  <img src="/assets/hf-logo.svg" alt="Hugging Face" class="logo">
8892
8953
  <h1>${escapeHtml(config2.branding.name)}</h1>
8893
8954
  ${message ? `<p class="notice">${escapeHtml(message)}</p>` : ""}
8894
8955
  ${oauthReady ? `<a class="button" href="${escapeHtml(loginHref)}" target="_blank" rel="noopener">Sign in with Hugging Face</a>` : `<p class="notice">Hugging Face OAuth is not configured for this Space. Update the Space README metadata to include <code>hf_oauth: true</code>, then rebuild.</p>`}
8895
8956
  </main>
8896
- `);
8957
+ `
8958
+ );
8959
+ }
8960
+ function localLoginPage(config2) {
8961
+ return page(
8962
+ `${config2.branding.name} Local Access`,
8963
+ `
8964
+ <main>
8965
+ <img src="/assets/mlclaw.svg" alt="ML Claw" class="logo">
8966
+ <h1>${escapeHtml(config2.branding.name)}</h1>
8967
+ <p class="muted">Use the private access link printed by the ML Claw CLI.</p>
8968
+ <form id="local-login-form">
8969
+ <label for="local-access-token">Local access code</label>
8970
+ <input id="local-access-token" name="token" type="password" autocomplete="off" required>
8971
+ <button class="button" type="submit">Open gateway</button>
8972
+ </form>
8973
+ <p id="local-login-status" class="notice" role="status"></p>
8974
+ </main>
8975
+ <script>
8976
+ const form = document.getElementById("local-login-form");
8977
+ const input = document.getElementById("local-access-token");
8978
+ const status = document.getElementById("local-login-status");
8979
+ const submit = async () => {
8980
+ status.textContent = "Signing in...";
8981
+ const response = await fetch("/mlclaw/api/local-session", {
8982
+ method: "POST",
8983
+ credentials: "same-origin",
8984
+ headers: { "content-type": "application/json" },
8985
+ body: JSON.stringify({ token: input.value }),
8986
+ });
8987
+ input.value = "";
8988
+ if (response.ok) {
8989
+ location.replace("/");
8990
+ return;
8991
+ }
8992
+ status.textContent = "The local access link is invalid. Run mlclaw gateway status again.";
8993
+ };
8994
+ form.addEventListener("submit", (event) => {
8995
+ event.preventDefault();
8996
+ submit().catch(() => { status.textContent = "The local gateway could not be reached."; });
8997
+ });
8998
+ const token = location.hash.slice(1);
8999
+ if (token) {
9000
+ history.replaceState(null, "", location.pathname);
9001
+ input.value = token;
9002
+ submit().catch(() => { status.textContent = "The local gateway could not be reached."; });
9003
+ }
9004
+ </script>
9005
+ `
9006
+ );
8897
9007
  }
8898
9008
  function unauthorizedPage(username) {
8899
- return page("ML Claw Access", `
9009
+ return page(
9010
+ "ML Claw Access",
9011
+ `
8900
9012
  <main>
8901
9013
  <h1>Access not allowed</h1>
8902
9014
  <p>The signed-in Hugging Face account <strong>${escapeHtml(username)}</strong> is not allowed to operate this Space.</p>
8903
9015
  <p class="muted">Set <code>MLCLAW_ALLOWED_USERS</code> to a comma-separated list of usernames, then restart the Space.</p>
8904
9016
  <a class="button secondary" href="/mlclaw/logout">Sign out</a>
8905
9017
  </main>
8906
- `);
9018
+ `
9019
+ );
8907
9020
  }
8908
9021
  function page(title, body) {
8909
9022
  return `<!doctype html>
@@ -9120,7 +9233,7 @@ function positiveNumber2(value) {
9120
9233
  }
9121
9234
 
9122
9235
  // src/mlclaw-space-runtime/cookies.ts
9123
- import { createHmac as createHmac4, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual4 } from "node:crypto";
9236
+ import { createHmac as createHmac5, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual5 } from "node:crypto";
9124
9237
  function createSignedCookie(options, payload) {
9125
9238
  const body = Buffer.from(JSON.stringify({
9126
9239
  ...payload,
@@ -9172,12 +9285,12 @@ function randomState() {
9172
9285
  return randomBytes6(24).toString("base64url");
9173
9286
  }
9174
9287
  function sign2(value, secret) {
9175
- return createHmac4("sha256", secret).update(value).digest("base64url");
9288
+ return createHmac5("sha256", secret).update(value).digest("base64url");
9176
9289
  }
9177
9290
  function signatureMatches2(a, b) {
9178
9291
  const left = Buffer.from(a);
9179
9292
  const right = Buffer.from(b);
9180
- return left.length === right.length && timingSafeEqual4(left, right);
9293
+ return left.length === right.length && timingSafeEqual5(left, right);
9181
9294
  }
9182
9295
  function parseCookies(header) {
9183
9296
  const cookies = /* @__PURE__ */ new Map();
@@ -9220,33 +9333,39 @@ var STATE_COOKIE = "mlclaw_oauth";
9220
9333
  var SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
9221
9334
  var STATE_TTL_SECONDS = 60 * 10;
9222
9335
  function createSessionCookie(params) {
9223
- return createSignedCookie({
9224
- name: SESSION_COOKIE,
9225
- secret: params.sessionSecret,
9226
- maxAgeSeconds: SESSION_TTL_SECONDS,
9227
- secure: params.secure
9228
- }, { username: params.username });
9336
+ return createSignedCookie(
9337
+ {
9338
+ name: params.cookieName ?? SESSION_COOKIE,
9339
+ secret: params.sessionSecret,
9340
+ maxAgeSeconds: SESSION_TTL_SECONDS,
9341
+ secure: params.secure
9342
+ },
9343
+ { username: params.username }
9344
+ );
9229
9345
  }
9230
9346
  function createOauthStateCookie(params) {
9231
9347
  const state = params.state ?? randomState();
9232
9348
  return {
9233
9349
  state,
9234
- cookie: createSignedCookie({
9235
- name: STATE_COOKIE,
9236
- secret: params.sessionSecret,
9237
- maxAgeSeconds: STATE_TTL_SECONDS,
9238
- secure: params.secure
9239
- }, { state, next: normalizeNext(params.next), intent: params.intent ?? "login" })
9350
+ cookie: createSignedCookie(
9351
+ {
9352
+ name: STATE_COOKIE,
9353
+ secret: params.sessionSecret,
9354
+ maxAgeSeconds: STATE_TTL_SECONDS,
9355
+ secure: params.secure
9356
+ },
9357
+ { state, next: normalizeNext(params.next), intent: params.intent ?? "login" }
9358
+ )
9240
9359
  };
9241
9360
  }
9242
- function clearSessionCookie(secure) {
9243
- return clearCookie(SESSION_COOKIE, secure);
9361
+ function clearSessionCookie(secure, cookieName = SESSION_COOKIE) {
9362
+ return clearCookie(cookieName, secure);
9244
9363
  }
9245
9364
  function clearOauthStateCookie(secure) {
9246
9365
  return clearCookie(STATE_COOKIE, secure);
9247
9366
  }
9248
- function readSession(cookieHeader, sessionSecret) {
9249
- return verifySignedCookie(cookieHeader, SESSION_COOKIE, sessionSecret);
9367
+ function readSession(cookieHeader, sessionSecret, cookieName = SESSION_COOKIE) {
9368
+ return verifySignedCookie(cookieHeader, cookieName, sessionSecret);
9250
9369
  }
9251
9370
  function readOauthState(cookieHeader, sessionSecret) {
9252
9371
  return verifySignedCookie(cookieHeader, STATE_COOKIE, sessionSecret);
@@ -9564,6 +9683,7 @@ function createSpaceRuntimeApp(config2, controls) {
9564
9683
  const allowBrokerKitSummary = fixedWindowRateLimit(12, 6e4);
9565
9684
  const allowDelegatedEvents = fixedWindowRateLimit(60, 6e4);
9566
9685
  const allowSummaryEvents = fixedWindowRateLimit(60, 6e4);
9686
+ const allowLocalLogin = fixedWindowRateLimit(10, 6e4);
9567
9687
  const openAiCredentials = new OpenAiCredentialStore(config2.openaiCredentialStoreFile, config2.credentialKey);
9568
9688
  app.get("/health", (c) => health(c, config2, controls));
9569
9689
  app.get("/healthz", (c) => health(c, config2, controls));
@@ -9599,10 +9719,58 @@ function createSpaceRuntimeApp(config2, controls) {
9599
9719
  );
9600
9720
  app.get("/oauth/login", (c) => handleOauthLogin(c, config2));
9601
9721
  app.get("/oauth/callback", (c) => handleOauthCallback(c, config2, controls));
9722
+ app.get("/mlclaw/local-login", (c) => {
9723
+ if (!config2.localAccessUser || !config2.localAccessToken || config2.gatewayLocation !== "local") {
9724
+ return c.text("not found\n", 404);
9725
+ }
9726
+ c.header(
9727
+ "content-security-policy",
9728
+ "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
9729
+ );
9730
+ c.header("cache-control", "no-store");
9731
+ c.header("referrer-policy", "no-referrer");
9732
+ return c.html(localLoginPage(config2));
9733
+ });
9734
+ app.post("/mlclaw/api/local-session", async (c) => {
9735
+ if (!config2.localAccessUser || !config2.localAccessToken || config2.gatewayLocation !== "local") {
9736
+ return c.json({ ok: false, error: "not found" }, 404);
9737
+ }
9738
+ const origin = c.req.header("origin");
9739
+ if (!origin || !config2.accessOrigins.includes(origin) || !allowLocalLogin(origin)) {
9740
+ return c.json({ ok: false, error: "access denied" }, 403);
9741
+ }
9742
+ const contentLength = Number.parseInt(c.req.header("content-length") ?? "0", 10);
9743
+ if (contentLength > 512 || !(c.req.header("content-type") ?? "").startsWith("application/json")) {
9744
+ return c.json({ ok: false, error: "invalid request" }, 400);
9745
+ }
9746
+ const text = await c.req.text();
9747
+ if (text.length > 512) {
9748
+ return c.json({ ok: false, error: "invalid request" }, 400);
9749
+ }
9750
+ let token;
9751
+ try {
9752
+ token = JSON.parse(text).token;
9753
+ } catch {
9754
+ return c.json({ ok: false, error: "invalid request" }, 400);
9755
+ }
9756
+ if (typeof token !== "string" || !localAccessTokenMatches(token, config2.localAccessToken)) {
9757
+ return c.json({ ok: false, error: "access denied" }, 403);
9758
+ }
9759
+ c.header(
9760
+ "set-cookie",
9761
+ createSessionCookie({
9762
+ username: config2.localAccessUser,
9763
+ sessionSecret: config2.sessionSecret,
9764
+ secure: origin.startsWith("https://"),
9765
+ cookieName: config2.sessionCookieName
9766
+ })
9767
+ );
9768
+ return c.json({ ok: true });
9769
+ });
9602
9770
  app.get("/login", (c) => c.html(loginPage(config2, void 0, normalizeNext(c.req.query("next") ?? "/"))));
9603
- app.get("/logout", (c) => logoutResponse(config2, false));
9604
- app.get("/mlclaw/logout", (c) => logoutResponse(config2, false));
9605
- app.post("/mlclaw/api/logout", (c) => logoutResponse(config2, true));
9771
+ app.get("/logout", (c) => logoutResponse(c, config2, false));
9772
+ app.get("/mlclaw/logout", (c) => logoutResponse(c, config2, false));
9773
+ app.post("/mlclaw/api/logout", (c) => logoutResponse(c, config2, true));
9606
9774
  app.get("/mlclaw/assets/*", async (c) => {
9607
9775
  const relative = c.req.path.slice("/mlclaw/assets/".length);
9608
9776
  const safe = safeRelativePath(relative);
@@ -9903,7 +10071,7 @@ function handleOauthLogin(c, config2) {
9903
10071
  if (!config2.oauthClientId || !config2.oauthClientSecret) {
9904
10072
  return c.html(loginPage(config2, "Hugging Face OAuth is not configured.", next));
9905
10073
  }
9906
- const session = readSession(c.req.header("cookie"), config2.sessionSecret);
10074
+ const session = readSession(c.req.header("cookie"), config2.sessionSecret, config2.sessionCookieName);
9907
10075
  const integrationsRequested = c.req.query("intent") === "integrations";
9908
10076
  const intent = integrationsRequested && session && isAdmin(config2, session.username) ? "integrations" : "login";
9909
10077
  const { state, cookie } = createOauthStateCookie({
@@ -9948,7 +10116,7 @@ async function handleOauthCallback(c, config2, controls) {
9948
10116
  return c.html(loginPage(config2, "Hugging Face sign-in failed. Try again."), 401);
9949
10117
  }
9950
10118
  if (stateCookie.intent === "integrations") {
9951
- const session = readSession(c.req.header("cookie"), config2.sessionSecret);
10119
+ const session = readSession(c.req.header("cookie"), config2.sessionSecret, config2.sessionCookieName);
9952
10120
  if (!session || !isAdmin(config2, session.username) || session.username !== identity.username) {
9953
10121
  return c.html(loginPage(config2, "Integration authorization requires the signed-in ML Claw administrator."), 403);
9954
10122
  }
@@ -9971,7 +10139,8 @@ async function handleOauthCallback(c, config2, controls) {
9971
10139
  createSessionCookie({
9972
10140
  username: identity.username,
9973
10141
  sessionSecret: config2.sessionSecret,
9974
- secure: config2.cookieSecure
10142
+ secure: config2.cookieSecure,
10143
+ cookieName: config2.sessionCookieName
9975
10144
  })
9976
10145
  );
9977
10146
  headers.append("set-cookie", clearOauthStateCookie(config2.cookieSecure));
@@ -10042,9 +10211,11 @@ function trustedBrokerKitHeaders(mode, origin) {
10042
10211
  if (asset) headers.set("access-control-allow-origin", "null");
10043
10212
  return headers;
10044
10213
  }
10045
- function logoutResponse(config2, json) {
10214
+ function logoutResponse(c, config2, json) {
10046
10215
  const headers = new Headers();
10047
- headers.append("set-cookie", clearSessionCookie(config2.cookieSecure));
10216
+ const origin = c.req.header("origin");
10217
+ const secure = origin && config2.accessOrigins.includes(origin) ? origin.startsWith("https://") : config2.cookieSecure;
10218
+ headers.append("set-cookie", clearSessionCookie(secure, config2.sessionCookieName));
10048
10219
  if (json) {
10049
10220
  headers.set("content-type", "application/json; charset=utf-8");
10050
10221
  return new Response(`${JSON.stringify({ ok: true })}
@@ -10054,7 +10225,7 @@ function logoutResponse(config2, json) {
10054
10225
  return new Response(null, { status: 302, headers });
10055
10226
  }
10056
10227
  function requireAllowed(c, config2) {
10057
- const session = readSession(c.req.header("cookie"), config2.sessionSecret);
10228
+ const session = readSession(c.req.header("cookie"), config2.sessionSecret, config2.sessionCookieName);
10058
10229
  if (!session) {
10059
10230
  return unauthenticated(c, config2);
10060
10231
  }
@@ -10160,7 +10331,10 @@ function unauthenticated(c, config2) {
10160
10331
  return c.json({ ok: false, error: "authentication required" }, 401);
10161
10332
  }
10162
10333
  if (isBrowserNavigation(c)) {
10163
- return c.redirect(`/login?next=${encodeURIComponent(next)}`, 302);
10334
+ return c.redirect(
10335
+ config2.gatewayLocation === "local" ? "/mlclaw/local-login" : `/login?next=${encodeURIComponent(next)}`,
10336
+ 302
10337
+ );
10164
10338
  }
10165
10339
  return c.html(loginPage(config2, void 0, next), 401);
10166
10340
  }
@@ -10739,7 +10913,7 @@ async function proxyHttp(req, res, config2, identity) {
10739
10913
  delete headers["accept-encoding"];
10740
10914
  delete headers["Accept-Encoding"];
10741
10915
  }
10742
- addTrustedProxyHeaders(headers, config2, identity);
10916
+ addTrustedProxyHeaders(headers, config2, identity, requestAccessOrigin(req, config2));
10743
10917
  const upstream = http2.request(
10744
10918
  {
10745
10919
  host: config2.openclawHost,
@@ -10797,7 +10971,7 @@ function proxyWebSocket(req, socket, head, config2, identity) {
10797
10971
  headers.host = `${config2.openclawHost}:${config2.openclawPort}`;
10798
10972
  headers.connection = "Upgrade";
10799
10973
  headers.upgrade = req.headers.upgrade ?? "websocket";
10800
- addTrustedProxyHeaders(headers, config2, identity);
10974
+ addTrustedProxyHeaders(headers, config2, identity, requestAccessOrigin(req, config2));
10801
10975
  upstream.write(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}\r
10802
10976
  `);
10803
10977
  for (const [key, value] of Object.entries(headers)) {
@@ -10841,19 +11015,26 @@ function sanitizeHeaders(headers) {
10841
11015
  if (HOP_BY_HOP_HEADERS.has(lower)) {
10842
11016
  continue;
10843
11017
  }
10844
- if (lower.startsWith("x-forwarded-") || lower.startsWith("x-openclaw-") || lower === "authorization") {
11018
+ if (lower.startsWith("x-forwarded-") || lower.startsWith("x-openclaw-") || lower.startsWith("tailscale-") || lower === "authorization") {
10845
11019
  continue;
10846
11020
  }
10847
11021
  out[key] = value;
10848
11022
  }
10849
11023
  return out;
10850
11024
  }
10851
- function addTrustedProxyHeaders(headers, config2, identity) {
11025
+ function addTrustedProxyHeaders(headers, config2, identity, accessOrigin) {
10852
11026
  headers["x-forwarded-user"] = identity.username;
10853
- headers["x-forwarded-proto"] = config2.publicUrl.startsWith("https://") ? "https" : "http";
10854
- headers["x-forwarded-host"] = new URL(config2.publicUrl).host;
11027
+ headers["x-forwarded-proto"] = accessOrigin.startsWith("https://") ? "https" : "http";
11028
+ headers["x-forwarded-host"] = new URL(accessOrigin).host;
10855
11029
  headers["x-openclaw-scopes"] = resolveControlUiScopes(config2, identity).join(",");
10856
11030
  }
11031
+ function requestAccessOrigin(req, config2) {
11032
+ const host = req.headers.host?.trim().toLowerCase();
11033
+ if (!host) {
11034
+ return config2.publicUrl;
11035
+ }
11036
+ return config2.accessOrigins.find((origin) => new URL(origin).host.toLowerCase() === host) ?? config2.publicUrl;
11037
+ }
10857
11038
  function resolveControlUiScopes(config2, identity) {
10858
11039
  return config2.adminUsers.includes(identity.username) ? ADMIN_CONTROL_UI_SCOPES : USER_CONTROL_UI_SCOPES;
10859
11040
  }
@@ -10934,7 +11115,7 @@ var SpaceRuntimeServer = class {
10934
11115
  server2.on("upgrade", (req, socket, head) => {
10935
11116
  const netSocket = socket;
10936
11117
  try {
10937
- const session = readSession(req.headers.cookie, this.config.sessionSecret);
11118
+ const session = readSession(req.headers.cookie, this.config.sessionSecret, this.config.sessionCookieName);
10938
11119
  if (!session || !this.isAllowed(session.username)) {
10939
11120
  rejectWebSocket(netSocket);
10940
11121
  return;
@@ -11011,7 +11192,7 @@ var SpaceRuntimeServer = class {
11011
11192
  res.off("close", abortRequest);
11012
11193
  }
11013
11194
  }
11014
- const session = readSession(req.headers.cookie, this.config.sessionSecret);
11195
+ const session = readSession(req.headers.cookie, this.config.sessionSecret, this.config.sessionCookieName);
11015
11196
  if (!session) {
11016
11197
  this.sendUnauthenticated(req, res, url);
11017
11198
  return;
@@ -11091,6 +11272,10 @@ var SpaceRuntimeServer = class {
11091
11272
  }
11092
11273
  sendUnauthenticated(req, res, url) {
11093
11274
  const next = normalizeNext(`${url.pathname}${url.search}`);
11275
+ if (this.config.gatewayLocation === "local" && isBrowserNavigation2(req)) {
11276
+ this.sendRedirect(res, "/mlclaw/local-login");
11277
+ return;
11278
+ }
11094
11279
  if (url.pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
11095
11280
  this.sendHtml(res, loginPage(this.config, void 0, next));
11096
11281
  return;