From a1d9b335f21336033102970228bb7696fec2aa41 Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 20 Jul 2026 23:57:35 +0900 Subject: [PATCH 1/7] feat(platformclaw): add gateway runtime adapter --- .../platformclaw-control-plane/package.json | 2 + .../src/browser-gateway-hello.test.ts | 92 +++++++++++++++++++ .../src/browser-gateway-hello.ts | 46 ++++++++++ .../src/browser-gateway-proxy.ts | 12 ++- .../src/gateway-runtime-client.test.ts | 59 ++++++++++++ .../src/gateway-runtime-client.ts | 80 ++++++++++++++++ .../platformclaw-control-plane/src/index.ts | 2 + pnpm-lock.yaml | 6 ++ 8 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts create mode 100644 packages/platformclaw-control-plane/src/browser-gateway-hello.ts create mode 100644 packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts create mode 100644 packages/platformclaw-control-plane/src/gateway-runtime-client.ts diff --git a/packages/platformclaw-control-plane/package.json b/packages/platformclaw-control-plane/package.json index 563ec98b317..cb8a8d6005d 100644 --- a/packages/platformclaw-control-plane/package.json +++ b/packages/platformclaw-control-plane/package.json @@ -19,6 +19,8 @@ "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" }, "dependencies": { + "@openclaw/gateway-client": "workspace:*", + "@openclaw/gateway-protocol": "workspace:*", "@openclaw/normalization-core": "workspace:*", "kysely": "0.29.2" } diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts new file mode 100644 index 00000000000..c42c25a2f36 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts @@ -0,0 +1,92 @@ +import type { HelloOk } from "@openclaw/gateway-protocol"; +import { describe, expect, it } from "vitest"; +import { projectPlatformClawBrowserHello } from "./browser-gateway-hello.js"; +import type { BrowserGatewayAccess } from "./browser-gateway-proxy.js"; + +function upstreamHello(): HelloOk { + return { + type: "hello-ok", + protocol: 4, + server: { version: "2026.7.20", connId: "operator-connection" }, + features: { + methods: ["agents.list", "chat.send", "config.get"], + events: ["chat", "tick", "presence"], + capabilities: ["approvals"], + }, + snapshot: { + presence: [{ host: "private-host", ts: 1 }], + health: { providers: ["private-provider"] }, + stateVersion: { presence: 7, health: 8 }, + uptimeMs: 9, + configPath: "/private/openclaw.json", + stateDir: "/private/state", + sessionDefaults: { + defaultAgentId: "operator-agent", + mainKey: "main", + mainSessionKey: "agent:operator-agent:main", + }, + authMode: "token", + }, + auth: { + role: "operator", + scopes: ["operator.admin"], + deviceToken: "private-device-token", + }, + policy: { maxPayload: 1_024, maxBufferedBytes: 2_048, tickIntervalMs: 30_000 }, + }; +} + +const access: BrowserGatewayAccess = { + user: { + id: "user-1", + accountId: "person.one", + employeeId: "1001", + status: "active", + globalRole: "member", + groups: [], + createdAt: 1, + updatedAt: 1, + }, + binding: { + id: "binding-1", + kind: "personal", + userId: "user-1", + agentId: "person_one", + state: "active", + createdAt: 1, + updatedAt: 1, + }, + mainSessionKey: "agent:person_one:main", +}; + +describe("projectPlatformClawBrowserHello", () => { + it("advertises only browser policy and removes private Gateway metadata", () => { + const projected = projectPlatformClawBrowserHello({ + upstream: upstreamHello(), + access, + connectionId: "browser-1", + }); + + expect(projected.server).toEqual({ version: "2026.7.20", connId: "browser-1" }); + expect(projected.features).toEqual({ + methods: ["agents.list", "chat.send"], + events: ["tick", "chat"], + capabilities: [], + }); + expect(projected.snapshot).toEqual({ + presence: [], + health: {}, + stateVersion: { presence: 7, health: 8 }, + uptimeMs: 9, + sessionDefaults: { + defaultAgentId: "person_one", + mainKey: "main", + mainSessionKey: "agent:person_one:main", + }, + }); + expect(projected.auth).toEqual({ + role: "operator", + scopes: ["operator.read", "operator.write"], + }); + }); +}); diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts new file mode 100644 index 00000000000..cafb2acb115 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts @@ -0,0 +1,46 @@ +import type { HelloOk } from "@openclaw/gateway-protocol"; +import { + PLATFORMCLAW_WEB_GATEWAY_EVENTS, + PLATFORMCLAW_WEB_GATEWAY_METHODS, + type BrowserGatewayAccess, +} from "./browser-gateway-proxy.js"; + +const BROWSER_OPERATOR_SCOPES = ["operator.read", "operator.write"] as const; + +export function projectPlatformClawBrowserHello(params: { + upstream: HelloOk; + access: BrowserGatewayAccess; + connectionId: string; +}): HelloOk { + const upstreamMethods = new Set(params.upstream.features.methods); + const upstreamEvents = new Set(params.upstream.features.events); + return { + type: "hello-ok", + protocol: params.upstream.protocol, + server: { + version: params.upstream.server.version, + connId: params.connectionId, + }, + features: { + methods: PLATFORMCLAW_WEB_GATEWAY_METHODS.filter((method) => upstreamMethods.has(method)), + events: PLATFORMCLAW_WEB_GATEWAY_EVENTS.filter((event) => upstreamEvents.has(event)), + capabilities: [], + }, + snapshot: { + presence: [], + health: {}, + stateVersion: params.upstream.snapshot.stateVersion, + uptimeMs: params.upstream.snapshot.uptimeMs, + sessionDefaults: { + defaultAgentId: params.access.binding.agentId, + mainKey: "main", + mainSessionKey: params.access.mainSessionKey, + }, + }, + auth: { + role: "operator", + scopes: [...BROWSER_OPERATOR_SCOPES], + }, + policy: params.upstream.policy, + }; +} diff --git a/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts b/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts index c2c2ae965e7..3bc74d4f7ef 100644 --- a/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts +++ b/packages/platformclaw-control-plane/src/browser-gateway-proxy.ts @@ -160,8 +160,9 @@ const SESSION_KEY_METHODS = new Map([ ["sessions.patch", "key"], ["tools.effective", "sessionKey"], ]); -const SAFE_GLOBAL_EVENTS = new Set(["shutdown", "tick"]); -const SESSION_SCOPED_EVENTS = new Set([ +export const PLATFORMCLAW_WEB_GATEWAY_EVENTS = [ + "shutdown", + "tick", "chat", "chat.send_timing", "chat.side_result", @@ -169,7 +170,12 @@ const SESSION_SCOPED_EVENTS = new Set([ "session.operation", "session.tool", "sessions.changed", -]); +] as const; + +const SAFE_GLOBAL_EVENTS = new Set(["shutdown", "tick"]); +const SESSION_SCOPED_EVENTS = new Set( + PLATFORMCLAW_WEB_GATEWAY_EVENTS.filter((event) => event !== "shutdown" && event !== "tick"), +); const SESSION_KEY_FIELDS = new Set([ "sessionKey", "parentSessionKey", diff --git a/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts new file mode 100644 index 00000000000..60f6ca400bb --- /dev/null +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts @@ -0,0 +1,59 @@ +import type { GatewayClientOptions } from "@openclaw/gateway-client"; +import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; +import { describe, expect, it, vi } from "vitest"; +import { PlatformClawGatewayRuntimeClient } from "./gateway-runtime-client.js"; + +function hello(): HelloOk { + return { + type: "hello-ok", + protocol: 4, + server: { version: "test", connId: "private" }, + features: { methods: [], events: [] }, + snapshot: { + presence: [], + health: {}, + stateVersion: { presence: 1, health: 1 }, + uptimeMs: 10, + }, + auth: { role: "operator", scopes: ["operator.admin"] }, + policy: { maxPayload: 1_024, maxBufferedBytes: 2_048, tickIntervalMs: 30_000 }, + }; +} + +describe("PlatformClawGatewayRuntimeClient", () => { + it("shares one private client while projecting lifecycle and events", async () => { + let configured: GatewayClientOptions | undefined; + const start = vi.fn(); + const stop = vi.fn(); + const request = vi.fn(async () => ({ ok: true })); + const backend = new PlatformClawGatewayRuntimeClient({ + client: { url: "ws://127.0.0.1:18789", token: "operator-secret" }, + createClient: (options) => { + configured = options; + return { start, stop, request }; + }, + }); + const listener = vi.fn(); + const unsubscribe = backend.subscribe(listener); + + backend.start(); + expect(start).toHaveBeenCalledOnce(); + await expect(backend.request("status")).rejects.toThrow("unavailable"); + + configured?.onHelloOk?.(hello()); + await expect(backend.request("status", { quiet: true })).resolves.toEqual({ ok: true }); + expect(request).toHaveBeenCalledWith("status", { quiet: true }); + + const event: EventFrame = { type: "event", event: "tick", payload: { ts: 1 } }; + configured?.onEvent?.(event); + expect(listener).toHaveBeenCalledWith(event); + unsubscribe(); + configured?.onEvent?.(event); + expect(listener).toHaveBeenCalledOnce(); + + configured?.onClose?.(1006, "closed"); + expect(backend.getHello()).toBeNull(); + backend.stop(); + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/platformclaw-control-plane/src/gateway-runtime-client.ts b/packages/platformclaw-control-plane/src/gateway-runtime-client.ts new file mode 100644 index 00000000000..d3354eb2552 --- /dev/null +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.ts @@ -0,0 +1,80 @@ +import { + GatewayClient, + type GatewayClientOptions, +} from "@openclaw/gateway-client"; +import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; +import type { BrowserGatewayRpc } from "./browser-gateway-proxy.js"; + +export type PlatformClawGatewayBackend = BrowserGatewayRpc & { + start(): void; + stop(): void; + getHello(): HelloOk | null; + subscribe(listener: (event: EventFrame) => void): () => void; +}; + +type GatewayClientLike = { + start(): void; + stop(): void; + request(method: string, params?: unknown): Promise; +}; + +export type PlatformClawGatewayRuntimeClientOptions = { + client: GatewayClientOptions; + createClient?: (options: GatewayClientOptions) => GatewayClientLike; +}; + +/** Owns the single private operator connection shared by browser ingress sessions. */ +export class PlatformClawGatewayRuntimeClient implements PlatformClawGatewayBackend { + private readonly client: GatewayClientLike; + private readonly listeners = new Set<(event: EventFrame) => void>(); + private hello: HelloOk | null = null; + + constructor(options: PlatformClawGatewayRuntimeClientOptions) { + const createClient = options.createClient ?? ((clientOptions) => new GatewayClient(clientOptions)); + const configuredOnEvent = options.client.onEvent; + const configuredOnHello = options.client.onHelloOk; + const configuredOnClose = options.client.onClose; + this.client = createClient({ + ...options.client, + onEvent: (event) => { + configuredOnEvent?.(event); + for (const listener of this.listeners) { + listener(event); + } + }, + onHelloOk: (hello) => { + this.hello = hello; + configuredOnHello?.(hello); + }, + onClose: (code, reason, info) => { + this.hello = null; + configuredOnClose?.(code, reason, info); + }, + }); + } + + start(): void { + this.client.start(); + } + + stop(): void { + this.hello = null; + this.client.stop(); + } + + getHello(): HelloOk | null { + return this.hello; + } + + subscribe(listener: (event: EventFrame) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async request(method: string, params?: unknown): Promise { + if (!this.hello) { + throw new Error("private Gateway connection is unavailable"); + } + return (await this.client.request(method, params)) as T; + } +} diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index 0eb33320272..89274cc84f4 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -2,6 +2,8 @@ export * from "./browser-auth-service.js"; export * from "./browser-auth-http.js"; export * from "./browser-auth-runtime.js"; export * from "./browser-gateway-proxy.js"; +export * from "./browser-gateway-hello.js"; +export * from "./gateway-runtime-client.js"; export * from "./contracts.js"; export * from "./employee-auth-client.js"; export * from "./gateway-admin-rpc-client.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5475c503474..bcfc59f02ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2167,6 +2167,12 @@ importers: packages/platformclaw-control-plane: dependencies: + '@openclaw/gateway-client': + specifier: workspace:* + version: link:../gateway-client + '@openclaw/gateway-protocol': + specifier: workspace:* + version: link:../gateway-protocol '@openclaw/normalization-core': specifier: workspace:* version: link:../normalization-core From 0177272093526371e820b201b92ef0fa71bbfd6c Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 00:44:52 +0900 Subject: [PATCH 2/7] feat(platformclaw): add web ingress runtime --- PLATFORMCLAW.md | 4 +- docs/platformclaw/control-plane-phase-1.md | 11 +- docs/platformclaw/decisions.md | 12 +- docs/platformclaw/index.md | 7 +- docs/platformclaw/web-ingress-runtime.md | 137 +++++ .../platformclaw-control-plane/package.json | 3 +- .../src/browser-gateway-hello.test.ts | 2 + .../src/browser-gateway-hello.ts | 9 +- .../src/browser-login-rate-limiter.test.ts | 38 ++ .../src/browser-login-rate-limiter.ts | 103 ++++ .../platformclaw-control-plane/src/index.ts | 3 + .../src/web-ingress-runtime.test.ts | 83 +++ .../src/web-ingress-runtime.ts | 98 ++++ .../src/web-ingress-server.test.ts | 338 +++++++++++++ .../src/web-ingress-server.ts | 474 ++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/mock_employee_auth.py | 235 +++++++++ 17 files changed, 1550 insertions(+), 10 deletions(-) create mode 100644 docs/platformclaw/web-ingress-runtime.md create mode 100644 packages/platformclaw-control-plane/src/browser-login-rate-limiter.test.ts create mode 100644 packages/platformclaw-control-plane/src/browser-login-rate-limiter.ts create mode 100644 packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts create mode 100644 packages/platformclaw-control-plane/src/web-ingress-runtime.ts create mode 100644 packages/platformclaw-control-plane/src/web-ingress-server.test.ts create mode 100644 packages/platformclaw-control-plane/src/web-ingress-server.ts create mode 100644 scripts/mock_employee_auth.py diff --git a/PLATFORMCLAW.md b/PLATFORMCLAW.md index fc1fc8d0c5a..4c5ca67825c 100644 --- a/PLATFORMCLAW.md +++ b/PLATFORMCLAW.md @@ -25,7 +25,9 @@ The goals are to: - The first control-plane contract slice now lives in `packages/platformclaw-control-plane` with in-memory and SQLite stores, employee browser-session runtime assembly, and Gateway-backed personal-agent - provisioning. + provisioning. A protocol-compatible Web ingress listener now composes browser + authentication with the fail-closed Gateway policy; production secret and + process wiring remain pending. - The clean OpenClaw Linux Docker build and focused credential-free test baseline has been validated. ## Why This Rebuild Exists diff --git a/docs/platformclaw/control-plane-phase-1.md b/docs/platformclaw/control-plane-phase-1.md index b5b82a15d69..5c19a2e09dd 100644 --- a/docs/platformclaw/control-plane-phase-1.md +++ b/docs/platformclaw/control-plane-phase-1.md @@ -47,6 +47,12 @@ calls the Gateway Admin HTTP RPC boundary to create or adopt the exact agent and workspace. The in-memory store covers contract behavior, and the SQLite store persists the approved schema version 1. +The Web ingress runtime now hosts the browser-auth HTTP handlers and a +Gateway-protocol-compatible WebSocket boundary. It reuses the public Gateway +client for the private operator connection, projects a browser-safe hello, and +regenerates event sequence numbers after filtering. Production deployment +composition and the Control UI employee-login bootstrap are not yet complete. + Focused tests cover LDAP metadata refresh, LDAP-to-SAML identity linking, employee ID correction conflicts, concurrent personal and room provisioning, room agent ID compatibility, browser session limits and expiry, account @@ -203,8 +209,9 @@ The proxy must: The operator Gateway credential remains only in the hosting control process. It is never returned to the browser. The current package implements this policy -boundary independently of an HTTP or WebSocket framework; the deployable -listener will inject the private Gateway client and browser cookie extraction. +boundary and its HTTP/WebSocket listener. The deployment composition layer must +inject the private Gateway client credential and public origin without exposing +either through browser configuration. Until Gateway provides a least-privilege downstream identity, browser messages use only command-suppressed `chat.send`; session creation cannot carry an initial message or task. Browsers cannot request approval replay or deliver to diff --git a/docs/platformclaw/decisions.md b/docs/platformclaw/decisions.md index 3491b5a3519..8d719d1b3ec 100644 --- a/docs/platformclaw/decisions.md +++ b/docs/platformclaw/decisions.md @@ -218,11 +218,19 @@ upstream operator feature from silently becoming browser-accessible. Agent IDs are pinned server-side, session keys are checked against the owned agent, and agent/session collections, direct session results, and asynchronous events are filtered again on return. Denials are audited without request payloads or -credentials. The deployable HTTP/WebSocket listener is a separate hosting -slice; it must use this policy boundary rather than reimplement authorization. +credentials. The HTTP/WebSocket listener is a separate hosting slice and uses +this policy boundary rather than reimplementing authorization. It speaks the +upstream Gateway frame protocol, reuses the public Gateway client for the +private connection, replaces the operator hello with a browser-safe projection, +and regenerates event sequence numbers after filtering. Events without an owned session key, including raw agent-run events, are dropped; session-scoped chat and tool events are the browser streaming surface. +The listener requires one configured public origin and does not infer external +authority from forwarded headers. Operator secret resolution, persistent path +selection, process supervision, reverse-proxy routing, and Control UI login +bootstrap remain deployment composition responsibilities. + Because the private Gateway client currently has operator authority, browser messages use only `chat.send`, with command interpretation and external delivery forced off. Session creation cannot include an initial message or diff --git a/docs/platformclaw/index.md b/docs/platformclaw/index.md index 2a3bc86abeb..518787ecbaa 100644 --- a/docs/platformclaw/index.md +++ b/docs/platformclaw/index.md @@ -24,15 +24,16 @@ and Knox direct or group conversations: - [Control plane phase 1](/platformclaw/control-plane-phase-1) - [Architecture decisions](/platformclaw/decisions) - [Employee authentication](/platformclaw/employee-auth) +- [Web ingress runtime](/platformclaw/web-ingress-runtime) - [Private downstream CI](/platformclaw/private-downstream-ci) The first five implementation slices are in the private `packages/platformclaw-control-plane` package. They contain contracts, an in-memory store, the approved SQLite v1 persistent store, the employee-auth adapter, opaque browser-session service, browser-auth HTTP boundary, personal -agent provisioning adapter, and fail-closed Web Gateway policy proxy. The -deployable ingress process that hosts the HTTP and WebSocket boundaries remains -later work. +agent provisioning adapter, fail-closed Web Gateway policy proxy, and the +protocol-compatible Web ingress listener. Production deployment composition +and Control UI employee-login bootstrap remain later work. The VM execution backend is a later phase. It depends on Phase 1 because the sandbox must resolve an authenticated agent owner before selecting a VM profile diff --git a/docs/platformclaw/web-ingress-runtime.md b/docs/platformclaw/web-ingress-runtime.md new file mode 100644 index 00000000000..5466c409949 --- /dev/null +++ b/docs/platformclaw/web-ingress-runtime.md @@ -0,0 +1,137 @@ +--- +summary: "PlatformClaw browser authentication and Gateway WebSocket ingress runtime" +read_when: + - Hosting PlatformClaw Web in front of an OpenClaw Gateway + - Testing employee login and browser-to-agent isolation +title: "Web ingress runtime" +--- + +# Web ingress runtime + +The PlatformClaw Web ingress is a thin backend for the upstream Control UI. It +hosts employee browser authentication and a policy-filtered Gateway WebSocket +without replacing the OpenClaw Gateway or copying its agent runtime. + +The runtime lives in `packages/platformclaw-control-plane`. It is intentionally +separate from OpenClaw `src/gateway/**` so upstream Gateway and Control UI +updates remain practical to merge. + +## Request flow + +1. The browser signs in through the PlatformClaw authentication endpoints. +2. The server stores an opaque browser session in the control-plane database + and returns an `HttpOnly` session cookie. +3. The browser opens `/platformclaw/gateway` with the same-origin cookie. +4. The ingress resolves the active user and personal-agent binding. +5. The ingress forwards only the Gateway methods and parameters approved by + the browser policy proxy. +6. Gateway results and events are filtered to the owned agent and sessions + before they return to the browser. + +The ingress holds one private operator Gateway connection through the public +`@openclaw/gateway-client` package. Operator credentials never enter a browser +frame, cookie, response, or projected Gateway hello. + +## HTTP and WebSocket surfaces + +| Path | Method | Purpose | +| -------------------------------- | ------------- | ------------------------------------------------------------ | +| `/platformclaw/api/auth/login` | `POST` | Authenticate an employee and issue the opaque session cookie | +| `/platformclaw/api/auth/logout` | `POST` | Revoke the active browser session and clear its cookie | +| `/platformclaw/api/auth/session` | `GET`, `HEAD` | Return the current browser authentication state | +| `/platformclaw/gateway` | WebSocket | Expose the policy-filtered OpenClaw Gateway protocol | +| `/platformclaw/health` | `GET`, `HEAD` | Report whether the private Gateway connection is ready | + +Login, logout, and WebSocket upgrade requests require the exact configured +public origin. The listener does not trust forwarded headers when calculating +origin, client identity, or cookie security; deployments provide the external +origin explicitly. A deployment behind a trusted reverse proxy may inject a +`resolveClientIp` function; the default uses the direct socket peer and never +accepts arbitrary forwarding headers. + +## Runtime assembly + +`createPlatformClawWebIngressRuntime` constructs the runtime from deployment +options: + +- `BrowserAuthService`, backed by the SQLite control-plane store; +- `BrowserGatewayProxy`, backed by the same store and audit writer; +- `PlatformClawGatewayRuntimeClient`, configured with a private Gateway URL and + operator credential from the deployment secret store. + +`MemoryBrowserLoginRateLimiter` provides a bounded process-local default for +password attempts. A deployment may inject another implementation of the same +interface when rate limits must be shared across several ingress replicas. + +The host process supplies `publicOrigin`, the persistent database path, +session-key helpers, personal-agent provisioner, employee authentication +configuration, and private Gateway client credentials as typed options. The +composition owns the login limiter, persistent authentication runtime, +one shared `BrowserGatewayProxy` policy layer, one private Gateway client, and +the listener. It does not add another environment-variable or `openclaw.json` +surface. + +There is no proxy instance or upstream Gateway connection per user. Each +browser WebSocket connection carries only its opaque browser session token. +The shared proxy resolves that token to the current user, personal-agent +binding, and allowed session keys for every request or event. The agent access +context is per connection/request; the BFF, policy proxy, and Gateway client +are process-wide single instances. + +The listener uses the upstream Gateway request, response, event, connect, and +hello frame shapes. Browser event sequence numbers are regenerated after +filtering so dropped operator events do not create false sequence gaps in the +Control UI. + +## Test employee authentication + +Start the repository mock on a loopback-only random port: + +```bash +python3 scripts/mock_employee_auth.py --port 0 +``` + +The first output line contains the selected `loginUrl` and the matching +`PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL` value. The default development account +is `person.one` with password `test-password`. + +The mock returns the full normalized employee profile, including `accountId`, +department, part, groups, notes, and directory attributes. It deliberately does +not return an agent ID or session key. PlatformClaw derives the personal agent +ID from the authenticated account ID and owns all session routing. + +Use `--accounts-file ` to supply additional non-sensitive test accounts. +Never put real employee records or passwords in a committed fixture. + +## Security invariants + +- A valid browser cookie is required before WebSocket upgrade and again during + the Gateway connect frame. +- Session validity is rechecked without extending idle expiry for every + forwarded Gateway event. +- A revoked or expired session closes its existing WebSocket and cannot + reconnect. +- Browser-supplied Gateway credentials, roles, scopes, agent IDs, and device + identity do not establish authority. +- The browser hello contains only approved methods and events, its owned agent + defaults, non-admin scopes, and a synthetic connection ID. +- Presence, provider health, device tokens, config paths, state directories, + operator capabilities, and upstream connection IDs are not projected. +- Event sequence numbers and state versions from the private operator stream + are not exposed. +- Unknown methods and newly added upstream parameters fail closed until the + browser policy is reviewed. + +## Current boundary + +The package now provides the runtime composition, Gateway adapter, protocol +listener, mock auth service, and focused tests. Production is not ready until +the host deployment supplies secrets and persistent paths, process supervision, +reverse-proxy routing, and the Control UI employee-login bootstrap. Knox ingress +and VM sandbox execution remain separate capabilities. + +## See also + +- [Control plane phase 1](/platformclaw/control-plane-phase-1) +- [Employee authentication](/platformclaw/employee-auth) +- [Architecture decisions](/platformclaw/decisions) diff --git a/packages/platformclaw-control-plane/package.json b/packages/platformclaw-control-plane/package.json index cb8a8d6005d..984baa3f25f 100644 --- a/packages/platformclaw-control-plane/package.json +++ b/packages/platformclaw-control-plane/package.json @@ -22,6 +22,7 @@ "@openclaw/gateway-client": "workspace:*", "@openclaw/gateway-protocol": "workspace:*", "@openclaw/normalization-core": "workspace:*", - "kysely": "0.29.2" + "kysely": "0.29.2", + "ws": "8.21.0" } } diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts index c42c25a2f36..a8af4a3816c 100644 --- a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts @@ -65,6 +65,7 @@ describe("projectPlatformClawBrowserHello", () => { upstream: upstreamHello(), access, connectionId: "browser-1", + maxPayloadBytes: 512, }); expect(projected.server).toEqual({ version: "2026.7.20", connId: "browser-1" }); @@ -88,5 +89,6 @@ describe("projectPlatformClawBrowserHello", () => { role: "operator", scopes: ["operator.read", "operator.write"], }); + expect(projected.policy.maxPayload).toBe(512); }); }); diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts index cafb2acb115..0633316036b 100644 --- a/packages/platformclaw-control-plane/src/browser-gateway-hello.ts +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts @@ -11,6 +11,7 @@ export function projectPlatformClawBrowserHello(params: { upstream: HelloOk; access: BrowserGatewayAccess; connectionId: string; + maxPayloadBytes?: number; }): HelloOk { const upstreamMethods = new Set(params.upstream.features.methods); const upstreamEvents = new Set(params.upstream.features.events); @@ -41,6 +42,12 @@ export function projectPlatformClawBrowserHello(params: { role: "operator", scopes: [...BROWSER_OPERATOR_SCOPES], }, - policy: params.upstream.policy, + policy: { + ...params.upstream.policy, + maxPayload: + params.maxPayloadBytes === undefined + ? params.upstream.policy.maxPayload + : Math.min(params.upstream.policy.maxPayload, params.maxPayloadBytes), + }, }; } diff --git a/packages/platformclaw-control-plane/src/browser-login-rate-limiter.test.ts b/packages/platformclaw-control-plane/src/browser-login-rate-limiter.test.ts new file mode 100644 index 00000000000..af1fe32f8d9 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-login-rate-limiter.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { MemoryBrowserLoginRateLimiter } from "./browser-login-rate-limiter.js"; + +describe("MemoryBrowserLoginRateLimiter", () => { + it("blocks repeated failures and resets after the window", () => { + let now = 1_000; + const limiter = new MemoryBrowserLoginRateLimiter({ + failureLimit: 2, + windowMs: 100, + blockMs: 50, + now: () => now, + }); + + limiter.recordFailure("192.0.2.1", "login"); + expect(limiter.check("192.0.2.1", "login")).toEqual({ allowed: true, retryAfterMs: 0 }); + limiter.recordFailure("192.0.2.1", "login"); + expect(limiter.check("192.0.2.1", "login")).toEqual({ + allowed: false, + retryAfterMs: 50, + }); + + now += 100; + expect(limiter.check("192.0.2.1", "login")).toEqual({ allowed: true, retryAfterMs: 0 }); + }); + + it("bounds independent client keys", () => { + let now = 1; + const limiter = new MemoryBrowserLoginRateLimiter({ maxKeys: 2, now: () => now++ }); + limiter.recordFailure("192.0.2.1", "login"); + limiter.recordFailure("192.0.2.2", "login"); + limiter.recordFailure("192.0.2.3", "login"); + + for (let attempt = 0; attempt < 4; attempt += 1) { + limiter.recordFailure("192.0.2.1", "login"); + } + expect(limiter.check("192.0.2.1", "login").allowed).toBe(true); + }); +}); diff --git a/packages/platformclaw-control-plane/src/browser-login-rate-limiter.ts b/packages/platformclaw-control-plane/src/browser-login-rate-limiter.ts new file mode 100644 index 00000000000..a476234b46c --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-login-rate-limiter.ts @@ -0,0 +1,103 @@ +import type { BrowserLoginRateLimiter } from "./browser-auth-http.js"; + +const DEFAULT_FAILURE_LIMIT = 5; +const DEFAULT_WINDOW_MS = 10 * 60 * 1_000; +const DEFAULT_BLOCK_MS = 15 * 60 * 1_000; +const DEFAULT_MAX_KEYS = 10_000; + +type LoginRateEntry = { + failures: number; + windowStartedAt: number; + blockedUntil: number; + lastSeenAt: number; +}; + +export type MemoryBrowserLoginRateLimiterOptions = { + failureLimit?: number; + windowMs?: number; + blockMs?: number; + maxKeys?: number; + now?: () => number; +}; + +function positiveInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +/** Bounded process-local protection for employee password login attempts. */ +export class MemoryBrowserLoginRateLimiter implements BrowserLoginRateLimiter { + private readonly entries = new Map(); + private readonly failureLimit: number; + private readonly windowMs: number; + private readonly blockMs: number; + private readonly maxKeys: number; + private readonly now: () => number; + + constructor(options: MemoryBrowserLoginRateLimiterOptions = {}) { + this.failureLimit = positiveInteger( + options.failureLimit ?? DEFAULT_FAILURE_LIMIT, + "login failure limit", + ); + this.windowMs = positiveInteger(options.windowMs ?? DEFAULT_WINDOW_MS, "login window"); + this.blockMs = positiveInteger(options.blockMs ?? DEFAULT_BLOCK_MS, "login block duration"); + this.maxKeys = positiveInteger(options.maxKeys ?? DEFAULT_MAX_KEYS, "login rate-limit key cap"); + this.now = options.now ?? Date.now; + } + + check(clientIp: string | undefined, scope: string): { allowed: boolean; retryAfterMs: number } { + const now = this.now(); + const entry = this.entries.get(this.key(clientIp, scope)); + if (!entry) { + return { allowed: true, retryAfterMs: 0 }; + } + entry.lastSeenAt = now; + if (entry.blockedUntil > now) { + return { allowed: false, retryAfterMs: entry.blockedUntil - now }; + } + if (now - entry.windowStartedAt >= this.windowMs) { + this.entries.delete(this.key(clientIp, scope)); + } + return { allowed: true, retryAfterMs: 0 }; + } + + recordFailure(clientIp: string | undefined, scope: string): void { + const now = this.now(); + const key = this.key(clientIp, scope); + const current = this.entries.get(key); + const entry = + !current || now - current.windowStartedAt >= this.windowMs + ? { failures: 0, windowStartedAt: now, blockedUntil: 0, lastSeenAt: now } + : current; + entry.failures += 1; + entry.lastSeenAt = now; + if (entry.failures >= this.failureLimit) { + entry.blockedUntil = now + this.blockMs; + } + this.entries.set(key, entry); + this.evictOldestKeys(); + } + + private key(clientIp: string | undefined, scope: string): string { + return `${scope}\u0000${clientIp?.trim() || "unknown"}`; + } + + private evictOldestKeys(): void { + while (this.entries.size > this.maxKeys) { + let oldestKey: string | undefined; + let oldestAt = Number.POSITIVE_INFINITY; + for (const [key, entry] of this.entries) { + if (entry.lastSeenAt < oldestAt) { + oldestAt = entry.lastSeenAt; + oldestKey = key; + } + } + if (!oldestKey) { + return; + } + this.entries.delete(oldestKey); + } + } +} diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index 89274cc84f4..ead9f427d4b 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -3,7 +3,10 @@ export * from "./browser-auth-http.js"; export * from "./browser-auth-runtime.js"; export * from "./browser-gateway-proxy.js"; export * from "./browser-gateway-hello.js"; +export * from "./browser-login-rate-limiter.js"; export * from "./gateway-runtime-client.js"; +export * from "./web-ingress-server.js"; +export * from "./web-ingress-runtime.js"; export * from "./contracts.js"; export * from "./employee-auth-client.js"; export * from "./gateway-admin-rpc-client.js"; diff --git a/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts b/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts new file mode 100644 index 00000000000..03688ea1f96 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts @@ -0,0 +1,83 @@ +import type { GatewayClientOptions } from "@openclaw/gateway-client"; +import type { HelloOk } from "@openclaw/gateway-protocol"; +import { describe, expect, it, vi } from "vitest"; +import { createPlatformClawWebIngressRuntime } from "./web-ingress-runtime.js"; + +function hello(): HelloOk { + return { + type: "hello-ok", + protocol: 4, + server: { version: "test", connId: "private" }, + features: { methods: [], events: [] }, + snapshot: { + presence: [], + health: {}, + stateVersion: { presence: 1, health: 1 }, + uptimeMs: 10, + }, + auth: { role: "operator", scopes: ["operator.admin"] }, + policy: { maxPayload: 1_024, maxBufferedBytes: 2_048, tickIntervalMs: 30_000 }, + }; +} + +describe("createPlatformClawWebIngressRuntime", () => { + it("assembles employee auth, the private Gateway, policy proxy, and listener", async () => { + let clientOptions: GatewayClientOptions | undefined; + const stop = vi.fn(); + const provisionOrRefresh = vi.fn(async () => undefined); + const runtime = createPlatformClawWebIngressRuntime({ + databasePath: ":memory:", + initialAdminAccountIds: ["admin.user"], + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + resolveAgentIdFromSessionKey: (sessionKey) => /^agent:([^:]+):/.exec(sessionKey)?.[1] ?? null, + provisioner: { provisionOrRefresh }, + employeeAuth: { + employeeAuthConfig: { loginUrl: "http://127.0.0.1:18080/login" }, + fetchImpl: async () => + new Response( + JSON.stringify({ + authenticated: true, + employeeId: "1001", + accountId: "person.one", + name: "Person One", + department: "Platform", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + tokenFactory: () => "browser-session", + }, + gatewayClient: { + client: { url: "ws://127.0.0.1:18789", token: "test-auth-token" }, + createClient: (options) => { + clientOptions = options; + return { + start: () => options.onHelloOk?.(hello()), + stop, + request: async () => ({}), + }; + }, + }, + publicOrigin: "http://127.0.0.1:3000", + }); + + try { + await runtime.listen({ host: "127.0.0.1", port: 0 }); + expect(clientOptions?.token).toBe("test-auth-token"); + expect(runtime.gateway.getHello()).toEqual(hello()); + + await expect( + runtime.auth.service.loginPassword({ + login: { identifier: "person.one", password: "test-password" }, + }), + ).resolves.toMatchObject({ + status: "authenticated", + user: { accountId: "person.one" }, + binding: { agentId: "person_one" }, + }); + expect(provisionOrRefresh).toHaveBeenCalledOnce(); + } finally { + await runtime.close(); + } + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts new file mode 100644 index 00000000000..a175403a9b9 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts @@ -0,0 +1,98 @@ +import { + createEmployeeBrowserAuthRuntime, + type EmployeeBrowserAuthRuntime, + type EmployeeBrowserAuthRuntimeOptions, +} from "./browser-auth-runtime.js"; +import type { PersonalAgentProvisioner } from "./browser-auth-service.js"; +import { BrowserGatewayProxy } from "./browser-gateway-proxy.js"; +import { + MemoryBrowserLoginRateLimiter, + type MemoryBrowserLoginRateLimiterOptions, +} from "./browser-login-rate-limiter.js"; +import type { MainSessionKeyBuilder } from "./contracts.js"; +import { + PlatformClawGatewayRuntimeClient, + type PlatformClawGatewayRuntimeClientOptions, +} from "./gateway-runtime-client.js"; +import { + PlatformClawWebIngressServer, + type PlatformClawWebIngressListenOptions, + type PlatformClawWebIngressOptions, +} from "./web-ingress-server.js"; + +export type PlatformClawWebIngressRuntimeOptions = { + databasePath: string; + initialAdminAccountIds: readonly string[]; + buildAgentMainSessionKey: MainSessionKeyBuilder; + resolveAgentIdFromSessionKey(sessionKey: string): string | null; + provisioner: PersonalAgentProvisioner; + employeeAuth?: Pick< + EmployeeBrowserAuthRuntimeOptions, + "employeeAuthConfig" | "env" | "fetchImpl" | "now" | "tokenFactory" + >; + gatewayClient: PlatformClawGatewayRuntimeClientOptions; + publicOrigin: string; + loginRateLimiter?: MemoryBrowserLoginRateLimiterOptions; + ingress?: Pick< + PlatformClawWebIngressOptions, + "gatewayPath" | "healthPath" | "maxPayloadBytes" | "resolveClientIp" + >; +}; + +export type PlatformClawWebIngressRuntime = { + auth: EmployeeBrowserAuthRuntime; + gateway: PlatformClawGatewayRuntimeClient; + server: PlatformClawWebIngressServer; + listen(options: PlatformClawWebIngressListenOptions): Promise; + close(): Promise; +}; + +/** Composes one BFF, one policy proxy, and one private Gateway client. */ +export function createPlatformClawWebIngressRuntime( + options: PlatformClawWebIngressRuntimeOptions, +): PlatformClawWebIngressRuntime { + const auth = createEmployeeBrowserAuthRuntime({ + databasePath: options.databasePath, + buildAgentMainSessionKey: options.buildAgentMainSessionKey, + provisioner: options.provisioner, + initialAdminAccountIds: options.initialAdminAccountIds, + ...options.employeeAuth, + }); + const gateway = new PlatformClawGatewayRuntimeClient(options.gatewayClient); + // Browser connections share this proxy; the session token resolves agent-scoped access per call. + const gatewayProxy = new BrowserGatewayProxy({ + authService: auth.service, + store: auth.store, + auditWriter: auth.store, + gateway, + buildAgentMainSessionKey: options.buildAgentMainSessionKey, + resolveAgentIdFromSessionKey: options.resolveAgentIdFromSessionKey, + ...(options.employeeAuth?.now ? { now: options.employeeAuth.now } : {}), + }); + const server = new PlatformClawWebIngressServer({ + publicOrigin: options.publicOrigin, + authService: auth.service, + loginRateLimiter: new MemoryBrowserLoginRateLimiter(options.loginRateLimiter), + gatewayProxy, + gateway, + ...options.ingress, + }); + let closed = false; + return { + auth, + gateway, + server, + listen: (listenOptions) => server.listen(listenOptions), + async close() { + if (closed) { + return; + } + closed = true; + try { + await server.close(); + } finally { + auth.close(); + } + }, + }; +} diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.test.ts b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts new file mode 100644 index 00000000000..18851a71658 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts @@ -0,0 +1,338 @@ +import type { AddressInfo } from "node:net"; +import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import type { BrowserAuthService } from "./browser-auth-service.js"; +import type { BrowserGatewayAccess } from "./browser-gateway-proxy.js"; +import type { PlatformClawGatewayBackend } from "./gateway-runtime-client.js"; +import { + PlatformClawWebIngressServer, + type PlatformClawBrowserGatewayPolicy, +} from "./web-ingress-server.js"; + +const PUBLIC_ORIGIN = "https://platformclaw.example"; +const TEST_SESSION = "test-auth-token"; + +function upstreamHello(): HelloOk { + return { + type: "hello-ok", + protocol: 4, + server: { version: "test", connId: "private" }, + features: { + methods: ["agents.list", "chat.send", "config.get"], + events: ["chat", "tick", "presence"], + }, + snapshot: { + presence: [{ host: "private-host", ts: 1 }], + health: { private: true }, + stateVersion: { presence: 1, health: 1 }, + uptimeMs: 10, + }, + auth: { role: "operator", scopes: ["operator.admin"], deviceToken: "test-auth-token" }, + policy: { maxPayload: 1_024, maxBufferedBytes: 2_048, tickIntervalMs: 30_000 }, + }; +} + +const access: BrowserGatewayAccess = { + user: { + id: "user-1", + accountId: "person.one", + employeeId: "1001", + status: "active", + globalRole: "member", + groups: [], + createdAt: 1, + updatedAt: 1, + }, + binding: { + id: "binding-1", + kind: "personal", + userId: "user-1", + agentId: "person_one", + state: "active", + createdAt: 1, + updatedAt: 1, + }, + mainSessionKey: "agent:person_one:main", +}; + +class FakeGateway implements PlatformClawGatewayBackend { + private readonly listeners = new Set<(event: EventFrame) => void>(); + readonly start = vi.fn(); + readonly stop = vi.fn(); + readonly request = vi.fn(async () => ({ upstream: true })); + + getHello(): HelloOk { + return upstreamHello(); + } + + subscribe(listener: (event: EventFrame) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(event: EventFrame): void { + for (const listener of this.listeners) { + listener(event); + } + } + + listenerCount(): number { + return this.listeners.size; + } +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function createPolicy() { + let active = true; + const policy: PlatformClawBrowserGatewayPolicy = { + resolveAccess: vi.fn(async () => { + if (!active) { + throw new Error("revoked"); + } + return access; + }), + request: vi.fn(async (_token, method, params) => ({ method, params })), + filterEvent: vi.fn(async (_token, event) => event), + }; + return { policy, revoke: () => (active = false) }; +} + +function createFrameQueue(websocket: WebSocket) { + const frames: unknown[] = []; + const waiters: Array<{ + predicate: (frame: unknown) => boolean; + resolve: (frame: unknown) => void; + }> = []; + websocket.on("message", (data) => { + const frame = JSON.parse(data.toString()) as unknown; + const waiterIndex = waiters.findIndex((waiter) => waiter.predicate(frame)); + if (waiterIndex >= 0) { + waiters.splice(waiterIndex, 1)[0]?.resolve(frame); + return; + } + frames.push(frame); + }); + return (predicate: (frame: unknown) => boolean) => { + const index = frames.findIndex(predicate); + if (index >= 0) { + return Promise.resolve(frames.splice(index, 1)[0]); + } + return new Promise((resolve) => { + waiters.push({ predicate, resolve }); + }); + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +describe("PlatformClawWebIngressServer", () => { + let server: PlatformClawWebIngressServer | undefined; + let websocket: WebSocket | undefined; + + afterEach(async () => { + websocket?.terminate(); + websocket = undefined; + await server?.close(); + server = undefined; + }); + + it("speaks the Gateway wire protocol while enforcing browser session ownership", async () => { + const gateway = new FakeGateway(); + const { policy, revoke } = createPolicy(); + server = new PlatformClawWebIngressServer({ + publicOrigin: PUBLIC_ORIGIN, + authService: {} as BrowserAuthService, + loginRateLimiter: { + check: () => ({ allowed: true, retryAfterMs: 0 }), + recordFailure: vi.fn(), + }, + gatewayProxy: policy, + gateway, + }); + await server.listen({ host: "127.0.0.1", port: 0 }); + const port = (server.address() as AddressInfo).port; + websocket = new WebSocket(`ws://127.0.0.1:${port}/platformclaw/gateway`, { + origin: PUBLIC_ORIGIN, + headers: { Cookie: `platformclaw_session=${TEST_SESSION}` }, + }); + const nextFrame = createFrameQueue(websocket); + await new Promise((resolve, reject) => { + websocket?.once("open", resolve); + websocket?.once("error", reject); + }); + await expect( + nextFrame((frame) => isRecord(frame) && frame.event === "connect.challenge"), + ).resolves.toMatchObject({ type: "event", event: "connect.challenge" }); + + websocket.send( + JSON.stringify({ + type: "req", + id: "connect-1", + method: "connect", + params: { + minProtocol: 4, + maxProtocol: 4, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "web", + mode: "webchat", + }, + }, + }), + ); + const connected = await nextFrame( + (frame) => isRecord(frame) && frame.type === "res" && frame.id === "connect-1", + ); + expect(connected).toMatchObject({ + ok: true, + payload: { + type: "hello-ok", + features: { methods: ["agents.list", "chat.send"] }, + snapshot: { + presence: [], + sessionDefaults: { + defaultAgentId: "person_one", + mainSessionKey: "agent:person_one:main", + }, + }, + auth: { scopes: ["operator.read", "operator.write"] }, + }, + }); + + websocket.send( + JSON.stringify({ type: "req", id: "list-1", method: "agents.list", params: {} }), + ); + await expect( + nextFrame((frame) => isRecord(frame) && frame.id === "list-1"), + ).resolves.toMatchObject({ + type: "res", + ok: true, + payload: { method: "agents.list", params: {} }, + }); + expect(policy.request).toHaveBeenCalledWith(TEST_SESSION, "agents.list", {}); + + gateway.emit({ + type: "event", + event: "chat", + payload: { sessionKey: "agent:person_one:main" }, + seq: 99, + stateVersion: { presence: 50, health: 50 }, + }); + await expect(nextFrame((frame) => isRecord(frame) && frame.event === "chat")).resolves.toEqual({ + type: "event", + event: "chat", + payload: { sessionKey: "agent:person_one:main" }, + seq: 1, + }); + + revoke(); + const closed = new Promise((resolve) => { + websocket?.once("close", resolve); + }); + gateway.emit({ type: "event", event: "tick", payload: { ts: 2 }, seq: 100 }); + await expect(closed).resolves.toBe(1008); + expect(gateway.start).toHaveBeenCalledOnce(); + }); + + it("rejects WebSocket upgrades without the exact public origin", async () => { + const gateway = new FakeGateway(); + const { policy } = createPolicy(); + server = new PlatformClawWebIngressServer({ + publicOrigin: PUBLIC_ORIGIN, + authService: {} as BrowserAuthService, + loginRateLimiter: { + check: () => ({ allowed: true, retryAfterMs: 0 }), + recordFailure: vi.fn(), + }, + gatewayProxy: policy, + gateway, + }); + await server.listen({ host: "127.0.0.1", port: 0 }); + const port = (server.address() as AddressInfo).port; + websocket = new WebSocket(`ws://127.0.0.1:${port}/platformclaw/gateway`, { + origin: "https://attacker.example", + headers: { Cookie: `platformclaw_session=${TEST_SESSION}` }, + }); + + const responseStatus = await new Promise((resolve, reject) => { + websocket?.once("unexpected-response", (_request, response) => + resolve(response.statusCode ?? 0), + ); + websocket?.once("error", reject); + }); + expect(responseStatus).toBe(403); + expect(policy.resolveAccess).not.toHaveBeenCalled(); + }); + + it("does not retain a Gateway subscription when the browser closes during connect", async () => { + const gateway = new FakeGateway(); + const connectAccess = deferred(); + let accessCalls = 0; + const policy: PlatformClawBrowserGatewayPolicy = { + resolveAccess: vi.fn(async () => { + accessCalls += 1; + return accessCalls === 1 ? access : connectAccess.promise; + }), + request: vi.fn(async () => ({})), + filterEvent: vi.fn(async (_token, event) => event), + }; + server = new PlatformClawWebIngressServer({ + publicOrigin: PUBLIC_ORIGIN, + authService: {} as BrowserAuthService, + loginRateLimiter: { + check: () => ({ allowed: true, retryAfterMs: 0 }), + recordFailure: vi.fn(), + }, + gatewayProxy: policy, + gateway, + }); + await server.listen({ host: "127.0.0.1", port: 0 }); + const port = (server.address() as AddressInfo).port; + websocket = new WebSocket(`ws://127.0.0.1:${port}/platformclaw/gateway`, { + origin: PUBLIC_ORIGIN, + headers: { Cookie: `platformclaw_session=${TEST_SESSION}` }, + }); + const nextFrame = createFrameQueue(websocket); + await new Promise((resolve, reject) => { + websocket?.once("open", resolve); + websocket?.once("error", reject); + }); + await nextFrame((frame) => isRecord(frame) && frame.event === "connect.challenge"); + websocket.send( + JSON.stringify({ + type: "req", + id: "connect-race", + method: "connect", + params: { + minProtocol: 4, + maxProtocol: 4, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "web", + mode: "webchat", + }, + }, + }), + ); + await vi.waitFor(() => expect(policy.resolveAccess).toHaveBeenCalledTimes(2)); + const closed = new Promise((resolve) => { + websocket?.once("close", () => resolve()); + }); + websocket.terminate(); + await closed; + connectAccess.resolve(access); + await vi.waitFor(() => expect(gateway.listenerCount()).toBe(0)); + }); +}); diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.ts b/packages/platformclaw-control-plane/src/web-ingress-server.ts new file mode 100644 index 00000000000..b1df12d2a37 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-ingress-server.ts @@ -0,0 +1,474 @@ +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; +import { + MIN_CLIENT_PROTOCOL_VERSION, + PROTOCOL_VERSION, + type ConnectParams, + type ErrorShape, + type EventFrame, + type RequestFrame, + type ResponseFrame, + validateConnectParams, + validateRequestFrame, +} from "@openclaw/gateway-protocol"; +import { WebSocket, WebSocketServer, type RawData } from "ws"; +import { + handlePlatformClawBrowserAuthRequest, + readPlatformClawSessionCookie, + type BrowserLoginRateLimiter, + type JsonBodyReader, +} from "./browser-auth-http.js"; +import type { BrowserAuthService } from "./browser-auth-service.js"; +import { projectPlatformClawBrowserHello } from "./browser-gateway-hello.js"; +import { + BrowserGatewayProxyError, + type BrowserGatewayAccess, + type BrowserGatewayEvent, +} from "./browser-gateway-proxy.js"; +import type { PlatformClawGatewayBackend } from "./gateway-runtime-client.js"; + +export const PLATFORMCLAW_GATEWAY_PATH = "/platformclaw/gateway"; +export const PLATFORMCLAW_HEALTH_PATH = "/platformclaw/health"; + +const DEFAULT_MAX_PAYLOAD_BYTES = 8 * 1024 * 1024; +const HANDSHAKE_TIMEOUT_MS = 10_000; + +export type PlatformClawBrowserGatewayPolicy = { + resolveAccess(token: string, touch?: boolean): Promise; + request(token: string, method: string, params?: unknown): Promise; + filterEvent(token: string, event: BrowserGatewayEvent): Promise; +}; + +export type PlatformClawWebIngressOptions = { + publicOrigin: string; + authService: BrowserAuthService; + loginRateLimiter: BrowserLoginRateLimiter; + gatewayProxy: PlatformClawBrowserGatewayPolicy; + gateway: PlatformClawGatewayBackend; + gatewayPath?: string; + healthPath?: string; + maxPayloadBytes?: number; + resolveClientIp?: (req: IncomingMessage) => string | undefined; +}; + +export type PlatformClawWebIngressListenOptions = { + host: string; + port: number; +}; + +function normalizePublicOrigin(value: string): string { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("PlatformClaw public origin must use http or https"); + } + if ( + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + throw new Error( + "PlatformClaw public origin must not include credentials, path, query, or hash", + ); + } + return parsed.origin; +} + +const readJsonBody: JsonBodyReader = async (req, maxBytes) => { + const chunks: Buffer[] = []; + let size = 0; + let exceeded = false; + for await (const rawChunk of req) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk); + size += chunk.byteLength; + if (size > maxBytes) { + exceeded = true; + continue; + } + chunks.push(chunk); + } + if (exceeded) { + return { ok: false, error: "request body too large" }; + } + try { + return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) }; + } catch { + return { ok: false, error: "invalid JSON body" }; + } +}; + +function requestOrigin(req: IncomingMessage): string | undefined { + const value = req.headers.origin; + return Array.isArray(value) ? value[0] : value; +} + +function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(body)); +} + +function rejectUpgrade(socket: Duplex, statusCode: number, statusText: string): void { + if (!socket.destroyed) { + socket.end( + `HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); + } +} + +function proxyErrorShape(error: unknown): ErrorShape { + if (!(error instanceof BrowserGatewayProxyError)) { + return { code: "UNAVAILABLE", message: "Gateway request failed", retryable: true }; + } + switch (error.code) { + case "unauthenticated": + return { code: "UNAUTHENTICATED", message: error.message }; + case "agent-unavailable": + return { code: "UNAVAILABLE", message: error.message, retryable: true }; + case "invalid-params": + return { code: "INVALID_REQUEST", message: error.message }; + case "method-not-allowed": + case "cross-agent-denied": + case "upstream-result-denied": + return { code: "FORBIDDEN", message: error.message }; + } +} + +function responseError(id: string, error: unknown): ResponseFrame { + return { type: "res", id, ok: false, error: proxyErrorShape(error) }; +} + +function responseOk(id: string, payload: unknown): ResponseFrame { + return { type: "res", id, ok: true, payload }; +} + +function isProtocolCompatible(params: ConnectParams): boolean { + return ( + params.minProtocol <= PROTOCOL_VERSION && params.maxProtocol >= MIN_CLIENT_PROTOCOL_VERSION + ); +} + +export class PlatformClawWebIngressServer { + private readonly publicOrigin: string; + private readonly gatewayPath: string; + private readonly healthPath: string; + private readonly websocketServer: WebSocketServer; + private readonly httpServer = createServer((req, res) => { + void this.handleHttpRequest(req, res); + }); + private started = false; + + constructor(private readonly options: PlatformClawWebIngressOptions) { + this.publicOrigin = normalizePublicOrigin(options.publicOrigin); + this.gatewayPath = options.gatewayPath ?? PLATFORMCLAW_GATEWAY_PATH; + this.healthPath = options.healthPath ?? PLATFORMCLAW_HEALTH_PATH; + this.websocketServer = new WebSocketServer({ + noServer: true, + maxPayload: options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES, + }); + this.httpServer.on("upgrade", (req, socket, head) => { + void this.handleUpgrade(req, socket, head).catch(() => { + rejectUpgrade(socket, 500, "Internal Server Error"); + }); + }); + } + + async listen(options: PlatformClawWebIngressListenOptions): Promise { + if (this.started) { + throw new Error("PlatformClaw web ingress is already listening"); + } + this.started = true; + this.options.gateway.start(); + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.httpServer.off("listening", onListening); + reject(error); + }; + const onListening = () => { + this.httpServer.off("error", onError); + resolve(); + }; + this.httpServer.once("error", onError); + this.httpServer.once("listening", onListening); + this.httpServer.listen(options.port, options.host); + }); + } catch (error) { + this.started = false; + this.options.gateway.stop(); + throw error; + } + } + + address(): ReturnType { + return this.httpServer.address(); + } + + async close(): Promise { + if (!this.started) { + return; + } + this.started = false; + for (const socket of this.websocketServer.clients) { + socket.close(1001, "PlatformClaw web ingress stopping"); + } + await new Promise((resolve, reject) => { + this.httpServer.close((error) => (error ? reject(error) : resolve())); + }); + this.options.gateway.stop(); + } + + private isOriginAllowed(req: IncomingMessage): boolean { + const origin = requestOrigin(req); + if (!origin) { + return false; + } + try { + return new URL(origin).origin === this.publicOrigin; + } catch { + return false; + } + } + + private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { + try { + const handled = await handlePlatformClawBrowserAuthRequest(req, res, { + service: this.options.authService, + readJsonBody, + clientIp: this.options.resolveClientIp?.(req) ?? req.socket.remoteAddress, + gatewayUrl: this.browserGatewayUrl(), + requestIsSecure: this.publicOrigin.startsWith("https://"), + isMutationOriginAllowed: (request) => this.isOriginAllowed(request), + rateLimiter: this.options.loginRateLimiter, + }); + if (handled) { + return; + } + const pathname = new URL(req.url ?? "/", this.publicOrigin).pathname; + if (pathname === this.healthPath && (req.method === "GET" || req.method === "HEAD")) { + const ready = this.options.gateway.getHello() !== null; + sendJson(res, ready ? 200 : 503, req.method === "HEAD" ? undefined : { ready }); + return; + } + sendJson(res, 404, { error: "not found" }); + } catch { + if (!res.headersSent) { + sendJson(res, 500, { error: "internal server error" }); + } else { + res.destroy(); + } + } + } + + private browserGatewayUrl(): string { + const url = new URL(this.gatewayPath, this.publicOrigin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); + } + + private async handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): Promise { + const pathname = new URL(req.url ?? "/", this.publicOrigin).pathname; + if (pathname !== this.gatewayPath) { + rejectUpgrade(socket, 404, "Not Found"); + return; + } + if (!this.isOriginAllowed(req)) { + rejectUpgrade(socket, 403, "Forbidden"); + return; + } + const token = readPlatformClawSessionCookie(req); + if (!token) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } + if (!this.options.gateway.getHello()) { + rejectUpgrade(socket, 503, "Service Unavailable"); + return; + } + try { + await this.options.gatewayProxy.resolveAccess(token, false); + } catch { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } + this.websocketServer.handleUpgrade(req, socket, head, (websocket) => { + this.attachBrowserConnection(websocket, token); + }); + } + + private attachBrowserConnection(websocket: WebSocket, token: string): void { + const connectionId = `platformclaw-${randomUUID()}`; + let connected = false; + let connectionClosed = false; + let eventSeq = 0; + let unsubscribe = () => {}; + let messageChain = Promise.resolve(); + let eventChain = Promise.resolve(); + + const send = (frame: unknown): void => { + if (websocket.readyState !== WebSocket.OPEN) { + return; + } + const maxBufferedBytes = this.options.gateway.getHello()?.policy.maxBufferedBytes ?? 0; + if (maxBufferedBytes > 0 && websocket.bufferedAmount > maxBufferedBytes) { + websocket.close(1013, "browser connection is too slow"); + return; + } + websocket.send(JSON.stringify(frame)); + }; + + const closeUnauthorized = () => websocket.close(1008, "browser session is not active"); + const handshakeTimer = setTimeout(() => { + if (!connected) { + websocket.close(1008, "connect handshake timed out"); + } + }, HANDSHAKE_TIMEOUT_MS); + + send({ + type: "event", + event: "connect.challenge", + payload: { nonce: randomUUID(), ts: Date.now() }, + } satisfies EventFrame); + + const forwardEvent = async (event: EventFrame): Promise => { + if (!connected) { + return; + } + try { + await this.options.gatewayProxy.resolveAccess(token, false); + } catch { + closeUnauthorized(); + return; + } + const filtered = await this.options.gatewayProxy.filterEvent(token, event); + if (!filtered) { + return; + } + eventSeq += 1; + send({ + type: "event", + event: filtered.event, + ...(filtered.payload === undefined ? {} : { payload: filtered.payload }), + seq: eventSeq, + } satisfies EventFrame); + }; + + const handleConnect = async (frame: RequestFrame): Promise => { + if (frame.method !== "connect" || !validateConnectParams(frame.params)) { + send( + responseError( + frame.id, + new BrowserGatewayProxyError("invalid-params", "invalid connect request"), + ), + ); + websocket.close(1008, "invalid connect request"); + return; + } + const params = frame.params as ConnectParams; + if (!isProtocolCompatible(params)) { + send( + responseError( + frame.id, + new BrowserGatewayProxyError("invalid-params", "incompatible Gateway protocol"), + ), + ); + websocket.close(1008, "incompatible Gateway protocol"); + return; + } + const upstream = this.options.gateway.getHello(); + if (!upstream) { + send( + responseError( + frame.id, + new BrowserGatewayProxyError("agent-unavailable", "private Gateway is unavailable"), + ), + ); + websocket.close(1013, "private Gateway is unavailable"); + return; + } + try { + const access = await this.options.gatewayProxy.resolveAccess(token); + if (connectionClosed || websocket.readyState !== WebSocket.OPEN) { + return; + } + const hello = projectPlatformClawBrowserHello({ + upstream, + access, + connectionId, + maxPayloadBytes: this.options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES, + }); + unsubscribe = this.options.gateway.subscribe((event) => { + eventChain = eventChain + .then(() => forwardEvent(event)) + .catch(() => websocket.close(1011, "event filtering failed")); + }); + connected = true; + clearTimeout(handshakeTimer); + send(responseOk(frame.id, hello)); + } catch (error) { + send(responseError(frame.id, error)); + closeUnauthorized(); + } + }; + + const handleRequest = async (frame: RequestFrame): Promise => { + if (!connected) { + await handleConnect(frame); + return; + } + if (frame.method === "connect") { + send( + responseError( + frame.id, + new BrowserGatewayProxyError("invalid-params", "already connected"), + ), + ); + return; + } + try { + const payload = await this.options.gatewayProxy.request(token, frame.method, frame.params); + send(responseOk(frame.id, payload)); + } catch (error) { + send(responseError(frame.id, error)); + if (error instanceof BrowserGatewayProxyError && error.code === "unauthenticated") { + closeUnauthorized(); + } + } + }; + + websocket.on("message", (data: RawData, isBinary) => { + messageChain = messageChain + .then(async () => { + if (isBinary) { + websocket.close(1003, "binary frames are not supported"); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(data.toString()); + } catch { + websocket.close(1008, "invalid JSON frame"); + return; + } + if (!validateRequestFrame(parsed)) { + websocket.close(1008, "invalid request frame"); + return; + } + await handleRequest(parsed); + }) + .catch(() => websocket.close(1011, "request handling failed")); + }); + + // `ws` forwards transport/protocol failures as EventEmitter errors; consume them per client. + websocket.on("error", () => websocket.terminate()); + + websocket.once("close", () => { + connectionClosed = true; + clearTimeout(handshakeTimer); + unsubscribe(); + }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcfc59f02ca..596e0c7604a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2179,6 +2179,9 @@ importers: kysely: specifier: 0.29.2 version: 0.29.2 + ws: + specifier: 8.21.0 + version: 8.21.0 packages/retry: {} diff --git a/scripts/mock_employee_auth.py b/scripts/mock_employee_auth.py new file mode 100644 index 00000000000..6ca217f64f9 --- /dev/null +++ b/scripts/mock_employee_auth.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Local employee-auth mock for PlatformClaw browser login development.""" + +from __future__ import annotations + +import argparse +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +DEFAULT_ACCOUNTS = [ + { + "identifier": "person.one", + "password": "test-password", + "employeeId": "1001", + "accountId": "person.one", + "subject": "ldap:person.one", + "displayName": "Person One", + "email": "person.one@example.test", + "department": "Platform", + "part": "Agent Platform", + "confluenceSpace": "PLATFORM", + "notes": "Local PlatformClaw test account", + "groups": ["developers", "platform"], + "attributes": {"title": "Engineer", "costCenters": ["A100"]}, + }, + { + "identifier": "admin.user", + "password": "test-password", + "employeeId": "1002", + "accountId": "admin.user", + "subject": "ldap:admin.user", + "displayName": "Admin User", + "email": "admin.user@example.test", + "department": "Platform", + "part": "Operations", + "groups": ["platform-admins"], + "attributes": {"title": "Administrator"}, + }, +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="PlatformClaw employee-auth mock") + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=18080) + parser.add_argument("--login-path", default="/login") + parser.add_argument("--health-path", default="/healthz") + parser.add_argument("--accounts-file") + parser.add_argument("--bearer-token", default="") + return parser.parse_args() + + +def optional_string(value: Any) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def normalize_account(raw: dict[str, Any]) -> dict[str, Any]: + identifier = optional_string(raw.get("identifier")) + password = raw.get("password") + employee_id = optional_string(raw.get("employeeId")) + account_id = optional_string(raw.get("accountId")) + if not identifier or not isinstance(password, str) or not password: + raise ValueError("each mock account requires identifier and password") + if not employee_id or not account_id: + raise ValueError(f"mock account {identifier} requires employeeId and accountId") + groups = raw.get("groups", []) + attributes = raw.get("attributes", {}) + return { + "identifier": identifier, + "password": password, + "employeeId": employee_id, + "accountId": account_id, + "subject": optional_string(raw.get("subject")) or f"ldap:{account_id}", + "displayName": optional_string(raw.get("displayName") or raw.get("name")), + "email": optional_string(raw.get("email")), + "department": optional_string(raw.get("department")), + "part": optional_string(raw.get("part")), + "confluenceSpace": optional_string(raw.get("confluenceSpace")), + "notes": optional_string(raw.get("notes") or raw.get("note")), + "groups": groups if isinstance(groups, list) else [], + "attributes": attributes if isinstance(attributes, dict) else {}, + } + + +def load_accounts(path: str | None) -> list[dict[str, Any]]: + if not path: + return [normalize_account(account) for account in DEFAULT_ACCOUNTS] + payload = json.loads(Path(path).read_text(encoding="utf-8")) + raw_accounts = payload.get("accounts") if isinstance(payload, dict) else payload + if not isinstance(raw_accounts, list) or not raw_accounts: + raise ValueError("accounts file must contain a non-empty accounts array") + accounts = [normalize_account(account) for account in raw_accounts if isinstance(account, dict)] + if not accounts: + raise ValueError("accounts file contains no valid account records") + return accounts + + +def public_profile(account: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in account.items() + if key not in {"identifier", "password"} and value is not None + } + + +def build_handler(args: argparse.Namespace, accounts: list[dict[str, Any]]): + accounts_by_identifier = {account["identifier"].lower(): account for account in accounts} + + class Handler(BaseHTTPRequestHandler): + server_version = "PlatformClawEmployeeAuthMock/2.0" + + def log_message(self, _format: str, *_args: Any) -> None: + return + + def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def read_json(self) -> dict[str, Any] | None: + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + return None + if length < 0 or length > 64 * 1024: + return None + try: + value = json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + def do_GET(self) -> None: + if self.path != args.health_path: + self.send_json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "not found"}) + return + self.send_json( + HTTPStatus.OK, + { + "ok": True, + "service": "platformclaw-employee-auth-mock", + "loginPath": args.login_path, + "accounts": [ + { + "identifier": account["identifier"], + "employeeId": account["employeeId"], + "accountId": account["accountId"], + } + for account in accounts + ], + }, + ) + + def do_POST(self) -> None: + if self.path != args.login_path: + self.send_json( + HTTPStatus.NOT_FOUND, + {"authenticated": False, "message": "not found"}, + ) + return + if args.bearer_token: + expected = f"Bearer {args.bearer_token}" + if self.headers.get("Authorization", "") != expected: + self.send_json( + HTTPStatus.UNAUTHORIZED, + {"authenticated": False, "message": "invalid bearer token"}, + ) + return + payload = self.read_json() + if payload is None: + self.send_json( + HTTPStatus.BAD_REQUEST, + {"authenticated": False, "message": "invalid JSON payload"}, + ) + return + identifier = optional_string(payload.get("identifier") or payload.get("username")) + password = payload.get("password") + account = accounts_by_identifier.get((identifier or "").lower()) + if not account or password != account["password"]: + self.send_json( + HTTPStatus.UNAUTHORIZED, + {"authenticated": False, "message": "invalid credentials"}, + ) + return + self.send_json( + HTTPStatus.OK, + {"authenticated": True, **public_profile(account)}, + ) + + return Handler + + +def main() -> int: + args = parse_args() + accounts = load_accounts(args.accounts_file) + server = ThreadingHTTPServer((args.bind, args.port), build_handler(args, accounts)) + actual_port = int(server.server_address[1]) + print( + json.dumps( + { + "bind": args.bind, + "port": actual_port, + "loginUrl": f"http://{args.bind}:{actual_port}{args.login_path}", + "healthUrl": f"http://{args.bind}:{actual_port}{args.health_path}", + "environment": { + "PLATFORMCLAW_EMPLOYEE_AUTH_LOGIN_URL": ( + f"http://{args.bind}:{actual_port}{args.login_path}" + ) + }, + } + ), + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a21d059bf25cc8532561e3bd47850afd1b55666e Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 00:47:43 +0900 Subject: [PATCH 3/7] test(platformclaw): use review-safe token fixture --- .../src/browser-gateway-hello.test.ts | 2 +- .../src/gateway-runtime-client.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts index a8af4a3816c..517747a4273 100644 --- a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts @@ -30,7 +30,7 @@ function upstreamHello(): HelloOk { auth: { role: "operator", scopes: ["operator.admin"], - deviceToken: "private-device-token", + deviceToken: "test-auth-token", }, policy: { maxPayload: 1_024, maxBufferedBytes: 2_048, tickIntervalMs: 30_000 }, }; diff --git a/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts index 60f6ca400bb..b8f463d6218 100644 --- a/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts @@ -27,7 +27,7 @@ describe("PlatformClawGatewayRuntimeClient", () => { const stop = vi.fn(); const request = vi.fn(async () => ({ ok: true })); const backend = new PlatformClawGatewayRuntimeClient({ - client: { url: "ws://127.0.0.1:18789", token: "operator-secret" }, + client: { url: "ws://127.0.0.1:18789", token: "test-auth-token" }, createClient: (options) => { configured = options; return { start, stop, request }; From f619f97a24c5e4dc09a24cd52abae02815984be4 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 00:58:24 +0900 Subject: [PATCH 4/7] fix(platformclaw): harden web ingress lifecycle --- .../src/browser-gateway-hello.test.ts | 2 +- .../src/browser-gateway-hello.ts | 2 +- .../src/gateway-runtime-client.test.ts | 4 + .../src/gateway-runtime-client.ts | 18 ++- .../src/web-ingress-server.test.ts | 106 ++++++++++++++++++ .../src/web-ingress-server.ts | 30 ++++- 6 files changed, 153 insertions(+), 9 deletions(-) diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts index 517747a4273..c10dcb59016 100644 --- a/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts @@ -77,7 +77,7 @@ describe("projectPlatformClawBrowserHello", () => { expect(projected.snapshot).toEqual({ presence: [], health: {}, - stateVersion: { presence: 7, health: 8 }, + stateVersion: { presence: 0, health: 0 }, uptimeMs: 9, sessionDefaults: { defaultAgentId: "person_one", diff --git a/packages/platformclaw-control-plane/src/browser-gateway-hello.ts b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts index 0633316036b..1224ca6263c 100644 --- a/packages/platformclaw-control-plane/src/browser-gateway-hello.ts +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts @@ -30,7 +30,7 @@ export function projectPlatformClawBrowserHello(params: { snapshot: { presence: [], health: {}, - stateVersion: params.upstream.snapshot.stateVersion, + stateVersion: { presence: 0, health: 0 }, uptimeMs: params.upstream.snapshot.uptimeMs, sessionDefaults: { defaultAgentId: params.access.binding.agentId, diff --git a/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts index b8f463d6218..27a66e9541e 100644 --- a/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts @@ -34,7 +34,9 @@ describe("PlatformClawGatewayRuntimeClient", () => { }, }); const listener = vi.fn(); + const disconnectListener = vi.fn(); const unsubscribe = backend.subscribe(listener); + const unsubscribeDisconnect = backend.subscribeDisconnect(disconnectListener); backend.start(); expect(start).toHaveBeenCalledOnce(); @@ -53,6 +55,8 @@ describe("PlatformClawGatewayRuntimeClient", () => { configured?.onClose?.(1006, "closed"); expect(backend.getHello()).toBeNull(); + expect(disconnectListener).toHaveBeenCalledOnce(); + unsubscribeDisconnect(); backend.stop(); expect(stop).toHaveBeenCalledOnce(); }); diff --git a/packages/platformclaw-control-plane/src/gateway-runtime-client.ts b/packages/platformclaw-control-plane/src/gateway-runtime-client.ts index d3354eb2552..2eb875c4040 100644 --- a/packages/platformclaw-control-plane/src/gateway-runtime-client.ts +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.ts @@ -1,7 +1,4 @@ -import { - GatewayClient, - type GatewayClientOptions, -} from "@openclaw/gateway-client"; +import { GatewayClient, type GatewayClientOptions } from "@openclaw/gateway-client"; import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; import type { BrowserGatewayRpc } from "./browser-gateway-proxy.js"; @@ -10,6 +7,7 @@ export type PlatformClawGatewayBackend = BrowserGatewayRpc & { stop(): void; getHello(): HelloOk | null; subscribe(listener: (event: EventFrame) => void): () => void; + subscribeDisconnect(listener: () => void): () => void; }; type GatewayClientLike = { @@ -27,10 +25,12 @@ export type PlatformClawGatewayRuntimeClientOptions = { export class PlatformClawGatewayRuntimeClient implements PlatformClawGatewayBackend { private readonly client: GatewayClientLike; private readonly listeners = new Set<(event: EventFrame) => void>(); + private readonly disconnectListeners = new Set<() => void>(); private hello: HelloOk | null = null; constructor(options: PlatformClawGatewayRuntimeClientOptions) { - const createClient = options.createClient ?? ((clientOptions) => new GatewayClient(clientOptions)); + const createClient = + options.createClient ?? ((clientOptions) => new GatewayClient(clientOptions)); const configuredOnEvent = options.client.onEvent; const configuredOnHello = options.client.onHelloOk; const configuredOnClose = options.client.onClose; @@ -48,6 +48,9 @@ export class PlatformClawGatewayRuntimeClient implements PlatformClawGatewayBack }, onClose: (code, reason, info) => { this.hello = null; + for (const listener of this.disconnectListeners) { + listener(); + } configuredOnClose?.(code, reason, info); }, }); @@ -71,6 +74,11 @@ export class PlatformClawGatewayRuntimeClient implements PlatformClawGatewayBack return () => this.listeners.delete(listener); } + subscribeDisconnect(listener: () => void): () => void { + this.disconnectListeners.add(listener); + return () => this.disconnectListeners.delete(listener); + } + async request(method: string, params?: unknown): Promise { if (!this.hello) { throw new Error("private Gateway connection is unavailable"); diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.test.ts b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts index 18851a71658..6f0d2e03fdf 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-server.test.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts @@ -58,6 +58,7 @@ const access: BrowserGatewayAccess = { class FakeGateway implements PlatformClawGatewayBackend { private readonly listeners = new Set<(event: EventFrame) => void>(); + private readonly disconnectListeners = new Set<() => void>(); readonly start = vi.fn(); readonly stop = vi.fn(); readonly request = vi.fn(async () => ({ upstream: true })); @@ -71,6 +72,11 @@ class FakeGateway implements PlatformClawGatewayBackend { return () => this.listeners.delete(listener); } + subscribeDisconnect(listener: () => void): () => void { + this.disconnectListeners.add(listener); + return () => this.disconnectListeners.delete(listener); + } + emit(event: EventFrame): void { for (const listener of this.listeners) { listener(event); @@ -80,6 +86,12 @@ class FakeGateway implements PlatformClawGatewayBackend { listenerCount(): number { return this.listeners.size; } + + disconnect(): void { + for (const listener of this.disconnectListeners) { + listener(); + } + } } function deferred() { @@ -275,6 +287,100 @@ describe("PlatformClawWebIngressServer", () => { expect(policy.resolveAccess).not.toHaveBeenCalled(); }); + it("closes browsers so they resynchronize after the private Gateway disconnects", async () => { + const gateway = new FakeGateway(); + const { policy } = createPolicy(); + server = new PlatformClawWebIngressServer({ + publicOrigin: PUBLIC_ORIGIN, + authService: {} as BrowserAuthService, + loginRateLimiter: { + check: () => ({ allowed: true, retryAfterMs: 0 }), + recordFailure: vi.fn(), + }, + gatewayProxy: policy, + gateway, + }); + await server.listen({ host: "127.0.0.1", port: 0 }); + const port = (server.address() as AddressInfo).port; + websocket = new WebSocket(`ws://127.0.0.1:${port}/platformclaw/gateway`, { + origin: PUBLIC_ORIGIN, + headers: { Cookie: `platformclaw_session=${TEST_SESSION}` }, + }); + await new Promise((resolve, reject) => { + websocket?.once("open", resolve); + websocket?.once("error", reject); + }); + const closed = new Promise((resolve) => { + websocket?.once("close", resolve); + }); + + gateway.disconnect(); + + await expect(closed).resolves.toBe(1012); + }); + + it("bounds queued browser requests and cancels them after disconnect", async () => { + const gateway = new FakeGateway(); + const blockedRequest = deferred(); + const policy: PlatformClawBrowserGatewayPolicy = { + resolveAccess: vi.fn(async () => access), + request: vi.fn(async () => blockedRequest.promise), + filterEvent: vi.fn(async (_token, event) => event), + }; + server = new PlatformClawWebIngressServer({ + publicOrigin: PUBLIC_ORIGIN, + authService: {} as BrowserAuthService, + loginRateLimiter: { + check: () => ({ allowed: true, retryAfterMs: 0 }), + recordFailure: vi.fn(), + }, + gatewayProxy: policy, + gateway, + }); + await server.listen({ host: "127.0.0.1", port: 0 }); + const port = (server.address() as AddressInfo).port; + websocket = new WebSocket(`ws://127.0.0.1:${port}/platformclaw/gateway`, { + origin: PUBLIC_ORIGIN, + headers: { Cookie: `platformclaw_session=${TEST_SESSION}` }, + }); + const nextFrame = createFrameQueue(websocket); + await new Promise((resolve, reject) => { + websocket?.once("open", resolve); + websocket?.once("error", reject); + }); + await nextFrame((frame) => isRecord(frame) && frame.event === "connect.challenge"); + websocket.send( + JSON.stringify({ + type: "req", + id: "connect-queue", + method: "connect", + params: { + minProtocol: 4, + maxProtocol: 4, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "web", + mode: "webchat", + }, + }, + }), + ); + await nextFrame((frame) => isRecord(frame) && frame.id === "connect-queue"); + const closed = new Promise((resolve) => { + websocket?.once("close", resolve); + }); + for (let index = 0; index < 9; index += 1) { + websocket.send( + JSON.stringify({ type: "req", id: `queued-${index}`, method: "agents.list", params: {} }), + ); + } + + await expect(closed).resolves.toBe(1013); + blockedRequest.resolve({}); + await vi.waitFor(() => expect(policy.request).toHaveBeenCalledOnce()); + }); + it("does not retain a Gateway subscription when the browser closes during connect", async () => { const gateway = new FakeGateway(); const connectAccess = deferred(); diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.ts b/packages/platformclaw-control-plane/src/web-ingress-server.ts index b1df12d2a37..06df79e66a8 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-server.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-server.ts @@ -33,6 +33,7 @@ export const PLATFORMCLAW_HEALTH_PATH = "/platformclaw/health"; const DEFAULT_MAX_PAYLOAD_BYTES = 8 * 1024 * 1024; const HANDSHAKE_TIMEOUT_MS = 10_000; +const MAX_PENDING_BROWSER_REQUESTS = 8; export type PlatformClawBrowserGatewayPolicy = { resolveAccess(token: string, touch?: boolean): Promise; @@ -160,6 +161,7 @@ export class PlatformClawWebIngressServer { void this.handleHttpRequest(req, res); }); private started = false; + private unsubscribeGatewayDisconnect = () => {}; constructor(private readonly options: PlatformClawWebIngressOptions) { this.publicOrigin = normalizePublicOrigin(options.publicOrigin); @@ -181,8 +183,13 @@ export class PlatformClawWebIngressServer { throw new Error("PlatformClaw web ingress is already listening"); } this.started = true; - this.options.gateway.start(); + this.unsubscribeGatewayDisconnect = this.options.gateway.subscribeDisconnect(() => { + for (const socket of this.websocketServer.clients) { + socket.close(1012, "private Gateway disconnected"); + } + }); try { + this.options.gateway.start(); await new Promise((resolve, reject) => { const onError = (error: Error) => { this.httpServer.off("listening", onListening); @@ -198,6 +205,8 @@ export class PlatformClawWebIngressServer { }); } catch (error) { this.started = false; + this.unsubscribeGatewayDisconnect(); + this.unsubscribeGatewayDisconnect = () => {}; this.options.gateway.stop(); throw error; } @@ -212,6 +221,8 @@ export class PlatformClawWebIngressServer { return; } this.started = false; + this.unsubscribeGatewayDisconnect(); + this.unsubscribeGatewayDisconnect = () => {}; for (const socket of this.websocketServer.clients) { socket.close(1001, "PlatformClaw web ingress stopping"); } @@ -306,6 +317,7 @@ export class PlatformClawWebIngressServer { let eventSeq = 0; let unsubscribe = () => {}; let messageChain = Promise.resolve(); + let pendingRequestCount = 0; let eventChain = Promise.resolve(); const send = (frame: unknown): void => { @@ -440,8 +452,19 @@ export class PlatformClawWebIngressServer { }; websocket.on("message", (data: RawData, isBinary) => { + if (connectionClosed) { + return; + } + if (pendingRequestCount >= MAX_PENDING_BROWSER_REQUESTS) { + websocket.close(1013, "too many pending browser requests"); + return; + } + pendingRequestCount += 1; messageChain = messageChain .then(async () => { + if (connectionClosed) { + return; + } if (isBinary) { websocket.close(1003, "binary frames are not supported"); return; @@ -459,7 +482,10 @@ export class PlatformClawWebIngressServer { } await handleRequest(parsed); }) - .catch(() => websocket.close(1011, "request handling failed")); + .catch(() => websocket.close(1011, "request handling failed")) + .finally(() => { + pendingRequestCount -= 1; + }); }); // `ws` forwards transport/protocol failures as EventEmitter errors; consume them per client. From a214ac0d65ab23b4ddc4867f8f7ad3000cf27218 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 02:28:02 +0900 Subject: [PATCH 5/7] fix(platformclaw): satisfy ingress lint --- .../src/web-ingress-runtime.ts | 2 +- .../src/web-ingress-server.test.ts | 53 +++++++++++++------ .../src/web-ingress-server.ts | 13 ++++- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts index a175403a9b9..fc77ef958cd 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts @@ -66,7 +66,7 @@ export function createPlatformClawWebIngressRuntime( auditWriter: auth.store, gateway, buildAgentMainSessionKey: options.buildAgentMainSessionKey, - resolveAgentIdFromSessionKey: options.resolveAgentIdFromSessionKey, + resolveAgentIdFromSessionKey: (sessionKey) => options.resolveAgentIdFromSessionKey(sessionKey), ...(options.employeeAuth?.now ? { now: options.employeeAuth.now } : {}), }); const server = new PlatformClawWebIngressServer({ diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.test.ts b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts index 6f0d2e03fdf..1b5103639e6 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-server.test.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts @@ -1,7 +1,7 @@ import type { AddressInfo } from "node:net"; import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { WebSocket } from "ws"; +import { WebSocket, type RawData } from "ws"; import type { BrowserAuthService } from "./browser-auth-service.js"; import type { BrowserGatewayAccess } from "./browser-gateway-proxy.js"; import type { PlatformClawGatewayBackend } from "./gateway-runtime-client.js"; @@ -104,17 +104,32 @@ function deferred() { function createPolicy() { let active = true; + const resolveAccess = vi.fn(async () => { + if (!active) { + throw new Error("revoked"); + } + return access; + }); + const request = vi.fn(async (_token: string, method: string, params?: unknown) => ({ + method, + params, + })); const policy: PlatformClawBrowserGatewayPolicy = { - resolveAccess: vi.fn(async () => { - if (!active) { - throw new Error("revoked"); - } - return access; - }), - request: vi.fn(async (_token, method, params) => ({ method, params })), + resolveAccess, + request, filterEvent: vi.fn(async (_token, event) => event), }; - return { policy, revoke: () => (active = false) }; + return { policy, request, resolveAccess, revoke: () => (active = false) }; +} + +function decodeTestFrame(data: RawData): string { + if (Buffer.isBuffer(data)) { + return data.toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + return Buffer.concat(data).toString("utf8"); } function createFrameQueue(websocket: WebSocket) { @@ -124,7 +139,7 @@ function createFrameQueue(websocket: WebSocket) { resolve: (frame: unknown) => void; }> = []; websocket.on("message", (data) => { - const frame = JSON.parse(data.toString()) as unknown; + const frame = JSON.parse(decodeTestFrame(data)) as unknown; const waiterIndex = waiters.findIndex((waiter) => waiter.predicate(frame)); if (waiterIndex >= 0) { waiters.splice(waiterIndex, 1)[0]?.resolve(frame); @@ -160,7 +175,7 @@ describe("PlatformClawWebIngressServer", () => { it("speaks the Gateway wire protocol while enforcing browser session ownership", async () => { const gateway = new FakeGateway(); - const { policy, revoke } = createPolicy(); + const { policy, request, revoke } = createPolicy(); server = new PlatformClawWebIngressServer({ publicOrigin: PUBLIC_ORIGIN, authService: {} as BrowserAuthService, @@ -232,7 +247,7 @@ describe("PlatformClawWebIngressServer", () => { ok: true, payload: { method: "agents.list", params: {} }, }); - expect(policy.request).toHaveBeenCalledWith(TEST_SESSION, "agents.list", {}); + expect(request).toHaveBeenCalledWith(TEST_SESSION, "agents.list", {}); gateway.emit({ type: "event", @@ -259,7 +274,7 @@ describe("PlatformClawWebIngressServer", () => { it("rejects WebSocket upgrades without the exact public origin", async () => { const gateway = new FakeGateway(); - const { policy } = createPolicy(); + const { policy, resolveAccess } = createPolicy(); server = new PlatformClawWebIngressServer({ publicOrigin: PUBLIC_ORIGIN, authService: {} as BrowserAuthService, @@ -284,7 +299,7 @@ describe("PlatformClawWebIngressServer", () => { websocket?.once("error", reject); }); expect(responseStatus).toBe(403); - expect(policy.resolveAccess).not.toHaveBeenCalled(); + expect(resolveAccess).not.toHaveBeenCalled(); }); it("closes browsers so they resynchronize after the private Gateway disconnects", async () => { @@ -322,9 +337,13 @@ describe("PlatformClawWebIngressServer", () => { it("bounds queued browser requests and cancels them after disconnect", async () => { const gateway = new FakeGateway(); const blockedRequest = deferred(); + let requestCalls = 0; const policy: PlatformClawBrowserGatewayPolicy = { resolveAccess: vi.fn(async () => access), - request: vi.fn(async () => blockedRequest.promise), + request: vi.fn(async () => { + requestCalls += 1; + return blockedRequest.promise; + }), filterEvent: vi.fn(async (_token, event) => event), }; server = new PlatformClawWebIngressServer({ @@ -378,7 +397,7 @@ describe("PlatformClawWebIngressServer", () => { await expect(closed).resolves.toBe(1013); blockedRequest.resolve({}); - await vi.waitFor(() => expect(policy.request).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(requestCalls).toBe(1)); }); it("does not retain a Gateway subscription when the browser closes during connect", async () => { @@ -432,7 +451,7 @@ describe("PlatformClawWebIngressServer", () => { }, }), ); - await vi.waitFor(() => expect(policy.resolveAccess).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(accessCalls).toBe(2)); const closed = new Promise((resolve) => { websocket?.once("close", () => resolve()); }); diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.ts b/packages/platformclaw-control-plane/src/web-ingress-server.ts index 06df79e66a8..c31be3b2c74 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-server.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-server.ts @@ -136,6 +136,17 @@ function proxyErrorShape(error: unknown): ErrorShape { case "upstream-result-denied": return { code: "FORBIDDEN", message: error.message }; } + return { code: "UNAVAILABLE", message: "Gateway request failed", retryable: true }; +} + +function decodeTextFrame(data: RawData): string { + if (Buffer.isBuffer(data)) { + return data.toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + return Buffer.concat(data).toString("utf8"); } function responseError(id: string, error: unknown): ResponseFrame { @@ -471,7 +482,7 @@ export class PlatformClawWebIngressServer { } let parsed: unknown; try { - parsed = JSON.parse(data.toString()); + parsed = JSON.parse(decodeTextFrame(data)); } catch { websocket.close(1008, "invalid JSON frame"); return; From a66960bd65244554d9183fc0f58bea2f696137c9 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 08:01:37 +0900 Subject: [PATCH 6/7] fix(platformclaw): validate CI planner declarations --- .github/workflows/platformclaw-ci.yml | 6 ++++++ scripts/platformclaw-ci-plan.d.mts | 20 ++++++++++++++++++++ scripts/platformclaw-ci-plan.mjs | 2 ++ test/scripts/platformclaw-ci-plan.test.ts | 3 +-- 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 scripts/platformclaw-ci-plan.d.mts diff --git a/.github/workflows/platformclaw-ci.yml b/.github/workflows/platformclaw-ci.yml index b713d706142..f1c38c6adc1 100644 --- a/.github/workflows/platformclaw-ci.yml +++ b/.github/workflows/platformclaw-ci.yml @@ -96,6 +96,7 @@ jobs: run: >- ./node_modules/.bin/oxfmt --check packages/platformclaw-control-plane + scripts/platformclaw-ci-plan.d.mts scripts/platformclaw-*.mjs test/scripts/platformclaw-*.test.ts .github/workflows/platformclaw-*.yml @@ -106,9 +107,14 @@ jobs: run: >- node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json + scripts/platformclaw-ci-plan.d.mts scripts/platformclaw-ci-plan.mjs test/scripts/platformclaw-ci-plan.test.ts + - name: Validate PlatformClaw script declarations + if: steps.plan.outputs.needs_planner_tests == 'true' + run: node scripts/check-script-declarations.mjs + - name: Lint PlatformClaw control plane if: steps.plan.outputs.needs_package_checks == 'true' run: >- diff --git a/scripts/platformclaw-ci-plan.d.mts b/scripts/platformclaw-ci-plan.d.mts new file mode 100644 index 00000000000..33b931722e3 --- /dev/null +++ b/scripts/platformclaw-ci-plan.d.mts @@ -0,0 +1,20 @@ +export type PlatformClawChangeMode = "none" | "docs" | "platformclaw" | "upstream"; + +export type PlatformClawChangePlan = { + files: string[]; + mode: PlatformClawChangeMode; + needs_dependencies: boolean; + needs_policy_guards: boolean; + needs_docs_checks: boolean; + needs_format_check: boolean; + needs_overlay_lint: boolean; + needs_package_checks: boolean; + needs_planner_tests: boolean; + needs_workflow_checks: boolean; + needs_ui_checks: boolean; + needs_deployment_checks: boolean; + needs_changed_surface_checks: boolean; +}; + +export function classifyPlatformClawChanges(inputFiles: string[]): PlatformClawChangePlan; +export function parseGitNameStatus(output: string): string[]; diff --git a/scripts/platformclaw-ci-plan.mjs b/scripts/platformclaw-ci-plan.mjs index f24d59adb7f..55c3865f400 100644 --- a/scripts/platformclaw-ci-plan.mjs +++ b/scripts/platformclaw-ci-plan.mjs @@ -9,6 +9,7 @@ const EXACT_OVERLAY_PATHS = new Set([ ".github/workflows/platformclaw-ci.yml", ".github/workflows/platformclaw-full-ci.yml", "PLATFORMCLAW.md", + "scripts/platformclaw-ci-plan.d.mts", "scripts/platformclaw-ci-plan.mjs", "test/scripts/platformclaw-ci-plan.test.ts", ]); @@ -47,6 +48,7 @@ export function classifyPlatformClawChanges(inputFiles) { const hasPlannerChanges = files.some( (file) => file === "scripts/platformclaw-ci-plan.mjs" || + file === "scripts/platformclaw-ci-plan.d.mts" || file === "test/scripts/platformclaw-ci-plan.test.ts", ); const hasWorkflowChanges = files.some((file) => diff --git a/test/scripts/platformclaw-ci-plan.test.ts b/test/scripts/platformclaw-ci-plan.test.ts index c56d957511d..e1366c62052 100644 --- a/test/scripts/platformclaw-ci-plan.test.ts +++ b/test/scripts/platformclaw-ci-plan.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -// @ts-expect-error The dependency-free CI entrypoint intentionally remains plain ESM. import { classifyPlatformClawChanges, parseGitNameStatus, @@ -30,7 +29,7 @@ describe("classifyPlatformClawChanges", () => { it("validates workflow and planner changes", () => { const plan = classifyPlatformClawChanges([ ".github/workflows/platformclaw-ci.yml", - "scripts/platformclaw-ci-plan.mjs", + "scripts/platformclaw-ci-plan.d.mts", ]); expect(plan.needs_workflow_checks).toBe(true); From cc784e1b7b2ff1c4ccb8ebc57a33296065ca3b76 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 08:26:08 +0900 Subject: [PATCH 7/7] fix(platformclaw): keep overlay lockfile checks focused --- scripts/platformclaw-ci-plan.mjs | 9 ++++++++- test/scripts/platformclaw-ci-plan.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/platformclaw-ci-plan.mjs b/scripts/platformclaw-ci-plan.mjs index 55c3865f400..5c31c424ce7 100644 --- a/scripts/platformclaw-ci-plan.mjs +++ b/scripts/platformclaw-ci-plan.mjs @@ -9,6 +9,7 @@ const EXACT_OVERLAY_PATHS = new Set([ ".github/workflows/platformclaw-ci.yml", ".github/workflows/platformclaw-full-ci.yml", "PLATFORMCLAW.md", + "scripts/mock_employee_auth.py", "scripts/platformclaw-ci-plan.d.mts", "scripts/platformclaw-ci-plan.mjs", "test/scripts/platformclaw-ci-plan.test.ts", @@ -20,6 +21,8 @@ const OVERLAY_PREFIXES = [ "ui/src/platformclaw/", ]; +const SHARED_METADATA_PATHS = new Set(["pnpm-lock.yaml"]); + function normalizePath(file) { return file.replaceAll("\\", "/").replace(/^\.\//, ""); } @@ -61,7 +64,11 @@ export function classifyPlatformClawChanges(inputFiles) { (file) => file.startsWith("docker/platformclaw-") || file === "scripts/platformclaw-build.mjs", ); const hasOverlayChanges = files.some(isOverlayPath); - const hasUpstreamSurface = files.some((file) => !isOverlayPath(file)); + // A lockfile updated alongside an overlay-owned package is validated by the + // frozen install and dependency guards. Lockfile-only changes stay upstream-wide. + const hasUpstreamSurface = files.some( + (file) => !isOverlayPath(file) && (!SHARED_METADATA_PATHS.has(file) || !hasOverlayChanges), + ); const hasCodeChanges = files.some( (file) => file !== "PLATFORMCLAW.md" && !file.startsWith("docs/platformclaw/"), ); diff --git a/test/scripts/platformclaw-ci-plan.test.ts b/test/scripts/platformclaw-ci-plan.test.ts index e1366c62052..eb804ee46e7 100644 --- a/test/scripts/platformclaw-ci-plan.test.ts +++ b/test/scripts/platformclaw-ci-plan.test.ts @@ -18,6 +18,8 @@ describe("classifyPlatformClawChanges", () => { it("runs focused control-plane checks without upstream fanout", () => { const plan = classifyPlatformClawChanges([ "packages/platformclaw-control-plane/src/browser-auth-http.ts", + "pnpm-lock.yaml", + "scripts/mock_employee_auth.py", ]); expect(plan.mode).toBe("platformclaw"); @@ -26,6 +28,13 @@ describe("classifyPlatformClawChanges", () => { expect(plan.needs_changed_surface_checks).toBe(false); }); + it("keeps lockfile-only changes on upstream checks", () => { + const plan = classifyPlatformClawChanges(["pnpm-lock.yaml"]); + + expect(plan.mode).toBe("upstream"); + expect(plan.needs_changed_surface_checks).toBe(true); + }); + it("validates workflow and planner changes", () => { const plan = classifyPlatformClawChanges([ ".github/workflows/platformclaw-ci.yml",