diff --git a/.github/workflows/browseros-openclaw-image.yml b/.github/workflows/browseros-openclaw-image.yml new file mode 100644 index 000000000000..fad1425e8e92 --- /dev/null +++ b/.github/workflows/browseros-openclaw-image.yml @@ -0,0 +1,130 @@ +name: BrowserOS OpenClaw Image + +on: + workflow_dispatch: + inputs: + image_tag: + description: GHCR image tag to publish + required: true + type: string + +concurrency: + group: browseros-openclaw-image-${{ inputs.image_tag }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-amd64: + runs-on: ubuntu-24.04 + permissions: + packages: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Free runner disk + run: | + df -h + sudo rm -rf \ + /usr/share/dotnet \ + /usr/local/lib/android \ + /opt/ghc \ + /opt/hostedtoolcache/CodeQL + sudo docker system prune -af || true + df -h + + - name: Set up Docker Builder + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push amd64 image + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + platforms: linux/amd64 + cache-from: type=gha,scope=browseros-openclaw-image-amd64 + cache-to: type=gha,mode=min,scope=browseros-openclaw-image-amd64 + build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.image_tag }}-amd64 + push: true + + build-arm64: + runs-on: ubuntu-24.04-arm + permissions: + packages: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Free runner disk + run: | + df -h + sudo rm -rf \ + /usr/share/dotnet \ + /usr/local/lib/android \ + /opt/ghc \ + /opt/hostedtoolcache/CodeQL + sudo docker system prune -af || true + df -h + + - name: Set up Docker Builder + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push arm64 image + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + platforms: linux/arm64 + cache-from: type=gha,scope=browseros-openclaw-image-arm64 + cache-to: type=gha,mode=min,scope=browseros-openclaw-image-arm64 + build-args: | + OPENCLAW_EXTENSIONS=diagnostics-otel + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.image_tag }}-arm64 + push: true + + create-manifest: + needs: [build-amd64, build-arm64] + runs-on: ubuntu-24.04 + permissions: + packages: write + contents: read + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker buildx imagetools create \ + -t "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" \ + "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}-amd64" \ + "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}-arm64" + env: + IMAGE_TAG: ${{ inputs.image_tag }} diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 44d08b5ba286..589fe02c3d7d 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -16,6 +16,7 @@ import { isContainerEnvironment, resolveGatewayBindHost, } from "../../gateway/net.js"; +import { allowsGatewayPrivateIngressNoAuth } from "../../gateway/private-ingress-auth.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setVerbose } from "../../globals.js"; @@ -775,7 +776,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) { bind !== "loopback" && !hasSharedSecret && !canBootstrapToken && - resolvedAuthMode !== "trusted-proxy" + resolvedAuthMode !== "trusted-proxy" && + !(resolvedAuthMode === "none" && allowsGatewayPrivateIngressNoAuth()) ) { defaultRuntime.error( [ diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index a20b0f344d95..7d304be5f43c 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -148,7 +148,7 @@ export function registerOnboardCommand(program: Command) { .option("--custom-text-input", "Mark the custom provider model as text-only") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") - .option("--gateway-auth ", "Gateway auth: token|password") + .option("--gateway-auth ", "Gateway auth: token|password|none") .option("--gateway-token ", "Gateway token (token auth)") .option( "--gateway-token-ref-env ", diff --git a/src/commands/onboard-non-interactive/local/gateway-config.test.ts b/src/commands/onboard-non-interactive/local/gateway-config.test.ts index 7be5ead4bb69..f8a19ca989b3 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.test.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.test.ts @@ -130,6 +130,55 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => { expect(result?.nextConfig.gateway?.auth?.token).toBe("generated-random-token"); }); + it("writes none auth without generating or preserving a gateway token", () => { + const result = applyGatewayConfig({ + nextConfig: createTokenConfig("existing-user-token"), + opts: { gatewayAuth: "none" } as unknown as OnboardOptions, + }); + + expect(result?.authMode).toBe("none"); + expect(result?.nextConfig.gateway?.auth).toEqual({ mode: "none" }); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("rejects gateway token flags when gateway auth is none", () => { + const runtime = createRuntime(); + + const result = applyGatewayConfig({ + opts: { + gatewayAuth: "none", + gatewayToken: "unused-token", + } as unknown as OnboardOptions, + runtime, + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith( + "Use either --gateway-auth none or gateway auth secret flags, not both.", + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("rejects gateway password flags when gateway auth is none", () => { + const runtime = createRuntime(); + + const result = applyGatewayConfig({ + opts: { + gatewayAuth: "none", + gatewayPassword: "unused-password", + } as unknown as OnboardOptions, + runtime, + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith( + "Use either --gateway-auth none or gateway auth secret flags, not both.", + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(randomToken).not.toHaveBeenCalled(); + }); + // --- SecretRef preservation --- it("preserves an existing SecretRef when no flag or env override is provided", () => { diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index bf60c5e48305..0420f58b5c92 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -31,8 +31,8 @@ export function applyNonInteractiveGatewayConfig(params: { const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort; let bind = opts.gatewayBind ?? "loopback"; const authModeRaw = opts.gatewayAuth ?? "token"; - if (authModeRaw !== "token" && authModeRaw !== "password") { - runtime.error("Invalid --gateway-auth (use token|password)."); + if (authModeRaw !== "token" && authModeRaw !== "password" && authModeRaw !== "none") { + runtime.error("Invalid --gateway-auth (use token|password|none)."); runtime.exit(1); return null; } @@ -66,6 +66,23 @@ export function applyNonInteractiveGatewayConfig(params: { let gatewayToken = explicitGatewayToken || existingPlaintextToken || envGatewayToken || undefined; const gatewayTokenRefEnv = normalizeOptionalString(opts.gatewayTokenRefEnv ?? "") ?? ""; + if (authMode === "none") { + if (explicitGatewayToken || gatewayTokenRefEnv || opts.gatewayPassword?.trim()) { + runtime.error("Use either --gateway-auth none or gateway auth secret flags, not both."); + runtime.exit(1); + return null; + } + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + mode: "none", + }, + }, + }; + } + if (authMode === "token") { if (gatewayTokenRefEnv) { if (!isValidEnvSecretRefId(gatewayTokenRefEnv)) { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 4c40a9b43db7..a55729b44301 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -14,7 +14,7 @@ export type AuthChoice = BuiltInAuthChoice | (string & {}); /** Auth choice groups are plugin-owned ids plus the core `custom` bucket. */ export type AuthChoiceGroupId = "custom" | (string & {}); -export type GatewayAuthChoice = "token" | "password"; +export type GatewayAuthChoice = "token" | "password" | "none"; export type ResetScope = "config" | "config+creds+sessions" | "full"; export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet"; export type TailscaleMode = "off" | "serve" | "funnel"; diff --git a/src/gateway/private-ingress-auth.ts b/src/gateway/private-ingress-auth.ts new file mode 100644 index 000000000000..5c6fc1813cfc --- /dev/null +++ b/src/gateway/private-ingress-auth.ts @@ -0,0 +1,7 @@ +import { isTruthyEnvValue } from "../infra/env.js"; + +export const GATEWAY_PRIVATE_INGRESS_NO_AUTH_ENV = "OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH"; + +export function allowsGatewayPrivateIngressNoAuth(env: NodeJS.ProcessEnv = process.env): boolean { + return isTruthyEnvValue(env[GATEWAY_PRIVATE_INGRESS_NO_AUTH_ENV]); +} diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index bba742c735cf..10561703091a 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -115,10 +115,13 @@ describe("resolveGatewayRuntimeConfig", () => { describe("token/password auth modes", () => { let originalToken: string | undefined; + let originalPrivateIngressNoAuth: string | undefined; beforeEach(() => { originalToken = process.env.OPENCLAW_GATEWAY_TOKEN; + originalPrivateIngressNoAuth = process.env.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH; delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH; }); afterEach(() => { @@ -127,6 +130,11 @@ describe("resolveGatewayRuntimeConfig", () => { } else { delete process.env.OPENCLAW_GATEWAY_TOKEN; } + if (originalPrivateIngressNoAuth !== undefined) { + process.env.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH = originalPrivateIngressNoAuth; + } else { + delete process.env.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH; + } }); it.each([ @@ -206,6 +214,24 @@ describe("resolveGatewayRuntimeConfig", () => { ); }); + it("allows non-loopback none auth when private ingress no-auth is enabled", async () => { + process.env.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH = "1"; + + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + bind: "lan", + auth: { mode: "none" }, + controlUi: { allowedOrigins: ["https://control.example.com"] }, + }, + }, + port: 18789, + }); + + expect(result.authMode).toBe("none"); + expect(result.bindHost).toBe("0.0.0.0"); + }); + it.each([ { name: "rejects non-loopback control UI when allowed origins are missing", diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 88deef0e3bd3..003af98bbfbb 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -18,6 +18,7 @@ import { isValidIPv4, resolveGatewayBindHost, } from "./net.js"; +import { allowsGatewayPrivateIngressNoAuth } from "./private-ingress-auth.js"; import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { @@ -141,7 +142,12 @@ export async function resolveGatewayRuntimeConfig(params: { if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) { throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); } - if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") { + if ( + !isLoopbackHost(bindHost) && + !hasSharedSecret && + authMode !== "trusted-proxy" && + !(authMode === "none" && allowsGatewayPrivateIngressNoAuth()) + ) { throw new Error( `refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD; legacy CLAWDBOT_* and MOLTBOT_* environment variables are ignored)`, ); diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 49f1fdbce7d9..1f42e737fcbd 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -210,6 +210,85 @@ describe("ws connect policy", () => { ).toBe("reject-device-required"); }); + test("auth.mode=none skips device identity for operator role only (private-ingress no-auth)", () => { + const policy = resolveControlUiAuthPolicy({ + isControlUi: false, + controlUiConfig: undefined, + deviceRaw: null, + }); + + // Non-Control-UI operator with auth.mode=none: device-less connection is + // allowed. This is the BrowserOS private-ingress backend path. The runtime + // startup gate already required OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH=1 + // for non-loopback bind to boot with auth.mode=none, so reaching this code + // path means the embedding runtime explicitly accepted the threat model. + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: false, + authMode: "none", + }).kind, + ).toBe("allow"); + + // Same call without authMode: falls through to the existing + // reject-device-required path (regression guard — the new branch must not + // affect callers that don't pass authMode). + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: false, + }).kind, + ).toBe("reject-device-required"); + + // Non-"none" auth modes still require pairing (token / password / etc.). + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: true, + isLocalClient: false, + authMode: "token", + }).kind, + ).toBe("reject-device-required"); + + // Node-role registrations must still satisfy device identity even with + // auth.mode=none — the bypass is scoped to operator role only, matching + // the dangerouslyDisableDeviceAuth shape directly above. + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "node", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: false, + authMode: "none", + }).kind, + ).toBe("reject-device-required"); + }); + test("dangerouslyDisableDeviceAuth skips pairing for operator control-ui only", () => { const bypass = resolveControlUiAuthPolicy({ isControlUi: true, diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 27284609174b..2466bec16611 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -115,6 +115,10 @@ export function evaluateMissingDeviceIdentity(params: { authOk: boolean; hasSharedAuth: boolean; isLocalClient: boolean; + // BrowserOS-style private-ingress no-auth deployments. Threaded through so + // the same handshake decision can recognize "I own the network boundary, + // skip auth entirely" without needing a separate evaluator path. + authMode?: string; }): MissingDeviceIdentityDecision { if (params.hasDeviceIdentity) { return { kind: "allow" }; @@ -130,6 +134,18 @@ export function evaluateMissingDeviceIdentity(params: { // registrations (see #45405 review). return { kind: "allow" }; } + if (params.role === "operator" && params.authMode === "none") { + // BrowserOS-style private-ingress no-auth path. The runtime startup gate + // in server-runtime-config.ts already required + // OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH=1 for any non-loopback bind to + // boot with auth.mode=none. Reaching this branch therefore means the + // embedding runtime explicitly opted into "I own the network boundary, + // skip auth entirely". Pairing-as-hygiene adds no security in that + // configuration: any client reaching the bind already needs no credentials. + // Scope to operator role so node-role registrations still require device + // identity (matching the controlUiAuthPolicy.allowBypass shape above). + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index aa59e55aa5a3..4693c854b236 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -648,6 +648,7 @@ export function attachGatewayWsMessageHandler(params: { authOk, hasSharedAuth, isLocalClient, + authMode: resolvedAuth.mode, }); // Shared token/password auth can bypass pairing for trusted operators. // Device-less clients still clear self-declared scopes by default, with