mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix: seed gateway control UI origins from runtime bind
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.”
|
||||
|
||||
88
src/config/gateway-control-ui-origins.test.ts
Normal file
88
src/config/gateway-control-ui-origins.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -328,6 +328,8 @@ export async function startGatewayServer(
|
||||
config: cfgAtStart,
|
||||
writeConfig: writeConfigFile,
|
||||
log,
|
||||
runtimeBind: opts.bind,
|
||||
runtimePort: port,
|
||||
}),
|
||||
);
|
||||
cfgAtStart = controlUiSeed.config;
|
||||
|
||||
51
src/gateway/startup-control-ui-origins.test.ts
Normal file
51
src/gateway/startup-control-ui-origins.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user