diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541b60ffe2..6acc81624cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 446e2b67db0..8af2ee77c6e 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -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 | diff --git a/docs/install/fly.md b/docs/install/fly.md index c1b74dbcf2e..90fa72d18c6 100644 --- a/docs/install/fly.md +++ b/docs/install/fly.md @@ -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) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 8c98a69808f..2e8d9ab6062 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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:` and + `http://127.0.0.1:` 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.” diff --git a/src/config/gateway-control-ui-origins.test.ts b/src/config/gateway-control-ui-origins.test.ts new file mode 100644 index 00000000000..f8172c7a410 --- /dev/null +++ b/src/config/gateway-control-ui-origins.test.ts @@ -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", + ]); + }); +}); diff --git a/src/config/gateway-control-ui-origins.ts b/src/config/gateway-control-ui-origins.ts index e1c91752750..40bfb32122c 100644 --- a/src/config/gateway-control-ui-origins.ts +++ b/src/config/gateway-control-ui-origins.ts @@ -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, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f335dafb680..a59b9b8e1a7 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -328,6 +328,8 @@ export async function startGatewayServer( config: cfgAtStart, writeConfig: writeConfigFile, log, + runtimeBind: opts.bind, + runtimePort: port, }), ); cfgAtStart = controlUiSeed.config; diff --git a/src/gateway/startup-control-ui-origins.test.ts b/src/gateway/startup-control-ui-origins.test.ts new file mode 100644 index 00000000000..4e8e7d6bfe8 --- /dev/null +++ b/src/gateway/startup-control-ui-origins.test.ts @@ -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>(); + 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(); + }); +}); diff --git a/src/gateway/startup-control-ui-origins.ts b/src/gateway/startup-control-ui-origins.ts index 698c50f92bd..fc030f0a1cc 100644 --- a/src/gateway/startup-control-ui-origins.ts +++ b/src/gateway/startup-control-ui-origins.ts @@ -9,9 +9,13 @@ export async function maybeSeedControlUiAllowedOriginsAtStartup(params: { config: OpenClawConfig; writeConfig: (config: OpenClawConfig) => Promise; 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 };