fix: seed gateway control UI origins from runtime bind

This commit is contained in:
Peter Steinberger
2026-04-26 01:33:20 +01:00
parent 81c2a1de26
commit 78cfd2a512
9 changed files with 182 additions and 3 deletions

View File

@@ -67,6 +67,9 @@ Docs: https://docs.openclaw.ai
itself for the current Gateway process after repeated failed restarts while
the Gateway keeps running. Fixes #69011. Thanks @siddharthaagarwalofficial-ux,
@FiredMosquito831, and @spikefcz.
- Gateway/Fly.io: seed Control UI allowed origins from the actual runtime
bind and port so CLI-driven non-loopback starts do not crash before config
exists. Fixes #71823.
- Feishu: accept Schema 2.0 card action callbacks that report
`context.open_chat_id` instead of legacy `context.chat_id`, so button
callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.

View File

@@ -112,6 +112,12 @@ All of these run on the main Gateway port and use the same trusted operator auth
| Gateway port | `--port``OPENCLAW_GATEWAY_PORT``gateway.port``18789` |
| Bind mode | CLI/override → `gateway.bind``loopback` |
Gateway startup uses the same effective port and bind when it seeds local
Control UI origins for non-loopback binds. For example, `--bind lan --port 3000`
seeds `http://localhost:3000` and `http://127.0.0.1:3000` before runtime
validation runs. Add any remote browser origins, such as HTTPS proxy URLs, to
`gateway.controlUi.allowedOrigins` explicitly.
### Hot reload modes
| `gateway.reload.mode` | Behavior |

View File

@@ -193,7 +193,14 @@ read_when:
},
"gateway": {
"mode": "local",
"bind": "auto"
"bind": "auto",
"controlUi": {
"allowedOrigins": [
"https://my-openclaw.fly.dev",
"http://localhost:3000",
"http://127.0.0.1:3000"
]
}
},
"meta": {}
}
@@ -202,6 +209,12 @@ read_when:
**Note:** With `OPENCLAW_STATE_DIR=/data`, the config path is `/data/openclaw.json`.
**Note:** Replace `https://my-openclaw.fly.dev` with your real Fly app
origin. Gateway startup seeds local Control UI origins from the runtime
`--bind` and `--port` values so first boot can proceed before config exists,
but browser access through Fly still needs the exact HTTPS origin listed in
`gateway.controlUi.allowedOrigins`.
**Note:** The Discord token can come from either:
- Environment variable: `DISCORD_BOT_TOKEN` (recommended for secrets)

View File

@@ -437,6 +437,9 @@ Notes:
- `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking.
- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins`
explicitly (full origins). This includes remote dev setups.
- Gateway startup may seed local origins such as `http://localhost:<port>` and
`http://127.0.0.1:<port>` from the effective runtime bind and port, but remote
browser origins still need explicit entries.
- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled
local testing. It means allow any browser origin, not “match whatever host I am
using.”

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { ensureControlUiAllowedOriginsForNonLoopbackBind } from "./gateway-control-ui-origins.js";
describe("ensureControlUiAllowedOriginsForNonLoopbackBind", () => {
it("seeds Fly-style runtime bind and port when config is empty", () => {
const result = ensureControlUiAllowedOriginsForNonLoopbackBind(
{ gateway: {} },
{
runtimeBind: "lan",
runtimePort: 3000,
isContainerEnvironment: () => false,
},
);
expect(result.bind).toBe("lan");
expect(result.seededOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:3000"]);
expect(result.config.gateway?.controlUi?.allowedOrigins).toEqual(result.seededOrigins);
});
it("uses runtime bind before config bind to match gateway startup precedence", () => {
const result = ensureControlUiAllowedOriginsForNonLoopbackBind(
{ gateway: { bind: "loopback" } },
{
runtimeBind: "lan",
isContainerEnvironment: () => false,
},
);
expect(result.bind).toBe("lan");
expect(result.seededOrigins).not.toBeNull();
});
it("uses runtime loopback before config non-loopback and avoids seeding", () => {
const result = ensureControlUiAllowedOriginsForNonLoopbackBind(
{ gateway: { bind: "lan" } },
{
runtimeBind: "loopback",
isContainerEnvironment: () => false,
},
);
expect(result.bind).toBeNull();
expect(result.seededOrigins).toBeNull();
});
it("uses runtime port before config port to match gateway startup precedence", () => {
const result = ensureControlUiAllowedOriginsForNonLoopbackBind(
{ gateway: { bind: "lan", port: 18789 } },
{
runtimePort: 3000,
isContainerEnvironment: () => false,
},
);
expect(result.seededOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:3000"]);
});
it("keeps container fallback when runtime and config bind are unset", () => {
const result = ensureControlUiAllowedOriginsForNonLoopbackBind(
{ gateway: {} },
{ isContainerEnvironment: () => true },
);
expect(result.bind).toBe("auto");
expect(result.seededOrigins).toEqual(["http://localhost:18789", "http://127.0.0.1:18789"]);
});
it("does not overwrite explicit allowed origins", () => {
const result = ensureControlUiAllowedOriginsForNonLoopbackBind(
{
gateway: {
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
},
{
runtimeBind: "lan",
runtimePort: 3000,
isContainerEnvironment: () => false,
},
);
expect(result.bind).toBe("lan");
expect(result.seededOrigins).toBeNull();
expect(result.config.gateway?.controlUi?.allowedOrigins).toEqual([
"https://control.example.com",
]);
});
});

View File

@@ -48,6 +48,12 @@ export function ensureControlUiAllowedOriginsForNonLoopbackBind(
opts?: {
defaultPort?: number;
requireControlUiEnabled?: boolean;
/** Resolved runtime bind override. Mirrors Gateway runtime precedence:
* explicit CLI/runtime bind wins over gateway.bind. */
runtimeBind?: unknown;
/** Resolved runtime port override. Mirrors Gateway runtime precedence:
* explicit CLI/runtime port wins over gateway.port. */
runtimePort?: unknown;
/** Optional container-detection callback. When provided and `gateway.bind`
* is unset, the function is called to determine whether the runtime will
* default to `"auto"` (container) so that origins can be seeded
@@ -60,7 +66,7 @@ export function ensureControlUiAllowedOriginsForNonLoopbackBind(
seededOrigins: string[] | null;
bind: GatewayNonLoopbackBindMode | null;
} {
const bind = config.gateway?.bind;
const bind = opts?.runtimeBind ?? config.gateway?.bind;
// When bind is unset (undefined) and we are inside a container, the runtime
// will default to "auto" → 0.0.0.0 via defaultGatewayBindMode(). We must
// seed origins *before* resolveGatewayRuntimeConfig runs, otherwise the
@@ -83,7 +89,10 @@ export function ensureControlUiAllowedOriginsForNonLoopbackBind(
return { config, seededOrigins: null, bind: effectiveBind };
}
const port = resolveGatewayPortWithDefault(config.gateway?.port, opts?.defaultPort);
const port = resolveGatewayPortWithDefault(
opts?.runtimePort ?? config.gateway?.port,
opts?.defaultPort,
);
const seededOrigins = buildDefaultControlUiAllowedOrigins({
port,
bind: effectiveBind,

View File

@@ -328,6 +328,8 @@ export async function startGatewayServer(
config: cfgAtStart,
writeConfig: writeConfigFile,
log,
runtimeBind: opts.bind,
runtimePort: port,
}),
);
cfgAtStart = controlUiSeed.config;

View File

@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
describe("maybeSeedControlUiAllowedOriginsAtStartup", () => {
it("persists origins seeded from runtime bind and port", async () => {
const written: OpenClawConfig[] = [];
const log = { info: vi.fn(), warn: vi.fn() };
const result = await maybeSeedControlUiAllowedOriginsAtStartup({
config: { gateway: {} },
writeConfig: async (config) => {
written.push(config);
},
log,
runtimeBind: "lan",
runtimePort: 3000,
});
const expectedOrigins = ["http://localhost:3000", "http://127.0.0.1:3000"];
expect(result.persistedAllowedOriginsSeed).toBe(true);
expect(result.config.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins);
expect(written).toHaveLength(1);
expect(written[0]?.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("for bind=lan"));
expect(log.warn).not.toHaveBeenCalled();
});
it("does not rewrite config when origins already exist", async () => {
const config: OpenClawConfig = {
gateway: {
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
};
const writeConfig = vi.fn<() => Promise<void>>();
const log = { info: vi.fn(), warn: vi.fn() };
const result = await maybeSeedControlUiAllowedOriginsAtStartup({
config,
writeConfig,
log,
runtimeBind: "lan",
runtimePort: 3000,
});
expect(result).toEqual({ config, persistedAllowedOriginsSeed: false });
expect(writeConfig).not.toHaveBeenCalled();
expect(log.info).not.toHaveBeenCalled();
expect(log.warn).not.toHaveBeenCalled();
});
});

View File

@@ -9,9 +9,13 @@ export async function maybeSeedControlUiAllowedOriginsAtStartup(params: {
config: OpenClawConfig;
writeConfig: (config: OpenClawConfig) => Promise<void>;
log: { info: (msg: string) => void; warn: (msg: string) => void };
runtimeBind?: unknown;
runtimePort?: unknown;
}): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> {
const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config, {
isContainerEnvironment,
runtimeBind: params.runtimeBind,
runtimePort: params.runtimePort,
});
if (!seeded.seededOrigins || !seeded.bind) {
return { config: params.config, persistedAllowedOriginsSeed: false };