Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/platformclaw-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: >-
Expand Down
4 changes: 3 additions & 1 deletion PLATFORMCLAW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions docs/platformclaw/control-plane-phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions docs/platformclaw/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/platformclaw/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions docs/platformclaw/web-ingress-runtime.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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)
5 changes: 4 additions & 1 deletion packages/platformclaw-control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 53 additions & 0 deletions packages/platformclaw-control-plane/src/browser-gateway-hello.ts
Original file line number Diff line number Diff line change
@@ -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),
},
};
}
Loading