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/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 38108229a2d..4f4bea82b27 100644 --- a/docs/platformclaw/decisions.md +++ b/docs/platformclaw/decisions.md @@ -223,11 +223,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 563ec98b317..984baa3f25f 100644 --- a/packages/platformclaw-control-plane/package.json +++ b/packages/platformclaw-control-plane/package.json @@ -19,7 +19,10 @@ "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" + "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 new file mode 100644 index 00000000000..c10dcb59016 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.test.ts @@ -0,0 +1,94 @@ +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: "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", +}; + +describe("projectPlatformClawBrowserHello", () => { + it("advertises only browser policy and removes private Gateway metadata", () => { + const projected = projectPlatformClawBrowserHello({ + upstream: upstreamHello(), + access, + connectionId: "browser-1", + maxPayloadBytes: 512, + }); + + 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: 0, health: 0 }, + uptimeMs: 9, + sessionDefaults: { + defaultAgentId: "person_one", + mainKey: "main", + mainSessionKey: "agent:person_one:main", + }, + }); + expect(projected.auth).toEqual({ + 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 new file mode 100644 index 00000000000..1224ca6263c --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-gateway-hello.ts @@ -0,0 +1,53 @@ +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; + maxPayloadBytes?: number; +}): 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: { presence: 0, health: 0 }, + 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, + 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-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/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/gateway-runtime-client.test.ts b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts new file mode 100644 index 00000000000..27a66e9541e --- /dev/null +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.test.ts @@ -0,0 +1,63 @@ +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: "test-auth-token" }, + createClient: (options) => { + configured = options; + return { start, stop, request }; + }, + }); + const listener = vi.fn(); + const disconnectListener = vi.fn(); + const unsubscribe = backend.subscribe(listener); + const unsubscribeDisconnect = backend.subscribeDisconnect(disconnectListener); + + 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(); + 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 new file mode 100644 index 00000000000..2eb875c4040 --- /dev/null +++ b/packages/platformclaw-control-plane/src/gateway-runtime-client.ts @@ -0,0 +1,88 @@ +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; + subscribeDisconnect(listener: () => 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 readonly disconnectListeners = new Set<() => 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; + for (const listener of this.disconnectListeners) { + listener(); + } + 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); + } + + 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"); + } + 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..ead9f427d4b 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -2,6 +2,11 @@ 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 "./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..fc77ef958cd --- /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: (sessionKey) => options.resolveAgentIdFromSessionKey(sessionKey), + ...(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..1b5103639e6 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-ingress-server.test.ts @@ -0,0 +1,463 @@ +import type { AddressInfo } from "node:net"; +import type { EventFrame, HelloOk } from "@openclaw/gateway-protocol"; +import { afterEach, describe, expect, it, vi } from "vitest"; +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"; +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>(); + private readonly disconnectListeners = new Set<() => 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); + } + + 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); + } + } + + listenerCount(): number { + return this.listeners.size; + } + + disconnect(): void { + for (const listener of this.disconnectListeners) { + listener(); + } + } +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +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, + request, + filterEvent: vi.fn(async (_token, event) => event), + }; + 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) { + const frames: unknown[] = []; + const waiters: Array<{ + predicate: (frame: unknown) => boolean; + resolve: (frame: unknown) => void; + }> = []; + websocket.on("message", (data) => { + 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); + 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, request, 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(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, resolveAccess } = 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(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(); + let requestCalls = 0; + const policy: PlatformClawBrowserGatewayPolicy = { + resolveAccess: vi.fn(async () => access), + request: vi.fn(async () => { + requestCalls += 1; + return 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(requestCalls).toBe(1)); + }); + + 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(accessCalls).toBe(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..c31be3b2c74 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-ingress-server.ts @@ -0,0 +1,511 @@ +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; +const MAX_PENDING_BROWSER_REQUESTS = 8; + +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 }; + } + 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 { + 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; + private unsubscribeGatewayDisconnect = () => {}; + + 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.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); + 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.unsubscribeGatewayDisconnect(); + this.unsubscribeGatewayDisconnect = () => {}; + this.options.gateway.stop(); + throw error; + } + } + + address(): ReturnType { + return this.httpServer.address(); + } + + async close(): Promise { + if (!this.started) { + return; + } + this.started = false; + this.unsubscribeGatewayDisconnect(); + this.unsubscribeGatewayDisconnect = () => {}; + 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 pendingRequestCount = 0; + 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) => { + 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; + } + let parsed: unknown; + try { + parsed = JSON.parse(decodeTextFrame(data)); + } 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")) + .finally(() => { + pendingRequestCount -= 1; + }); + }); + + // `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 5475c503474..596e0c7604a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2167,12 +2167,21 @@ 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 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()) 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..5c31c424ce7 100644 --- a/scripts/platformclaw-ci-plan.mjs +++ b/scripts/platformclaw-ci-plan.mjs @@ -9,6 +9,8 @@ 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", ]); @@ -19,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(/^\.\//, ""); } @@ -47,6 +51,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) => @@ -59,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 c56d957511d..eb804ee46e7 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, @@ -19,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"); @@ -27,10 +28,17 @@ 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", - "scripts/platformclaw-ci-plan.mjs", + "scripts/platformclaw-ci-plan.d.mts", ]); expect(plan.needs_workflow_checks).toBe(true);