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
18 changes: 18 additions & 0 deletions docs/platformclaw/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,24 @@ because the operator path would also disable bound administrator cron jobs.
Abort requests target the authenticated session only; browsers cannot select a
run ID because the shared operator connection cannot prove ownership of it.

### PC-117 Bootstrap Web through a narrow Control UI adapter

PlatformClaw serves a separate employee login shell and the upstream Control UI
from the same public origin as `platformclaw-control`. The authenticated app
uses the opaque browser cookie and the shared Web Gateway policy proxy. It does
not receive a Gateway credential or use a per-user proxy or Gateway client.

The first post-login surface reuses upstream chat and session components and
shows only chat, new-session, and personal-session routes. A single
PlatformClaw UI adapter owns the fixed same-origin Gateway URL, session-expiry
redirect, route availability, identity summary, and logout. Detailed visual
design is deferred. UI hiding is not authorization; PC-116 remains the
request/event security boundary.

The legacy employee entry is behavioral reference only. Its browser bootstrap
token, browser-provided agent/session routing, and broad per-page employee-mode
branches are not migrated.

## Open operational decisions

No remaining decision blocks the SQLite v1 store. Deployment work still needs
Expand Down
1 change: 1 addition & 0 deletions docs/platformclaw/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and Knox direct or group conversations:
- [Architecture decisions](/platformclaw/decisions)
- [Employee authentication](/platformclaw/employee-auth)
- [Web ingress runtime](/platformclaw/web-ingress-runtime)
- [Web login bootstrap plan](/platformclaw/web-login-bootstrap-plan)
- [Private downstream CI](/platformclaw/private-downstream-ci)

The first five implementation slices are in the private
Expand Down
369 changes: 369 additions & 0 deletions docs/platformclaw/web-login-bootstrap-plan.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/platformclaw-control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 "./web-assets.js";
export * from "./contracts.js";
export * from "./employee-auth-client.js";
export * from "./gateway-admin-rpc-client.js";
Expand Down
93 changes: 93 additions & 0 deletions packages/platformclaw-control-plane/src/web-assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createPlatformClawWebAssetHandler,
PLATFORMCLAW_WEB_ASSET_PREFIX,
PLATFORMCLAW_WEB_LOGIN_PATH,
} from "./web-assets.js";

const tempDirectories: string[] = [];

function fixtureRoot(): string {
const root = mkdtempSync(join(tmpdir(), "platformclaw-web-assets-"));

Check warning on line 16 in packages/platformclaw-control-plane/src/web-assets.test.ts

View workflow job for this annotation

GitHub Actions / PlatformClaw CI

new mkdtemp temp directory creation: prefer useAutoCleanupTempDirTracker(afterEach) from test/helpers/temp-dir.ts for new test-owned temp directories.
tempDirectories.push(root);
mkdirSync(join(root, "assets"));
writeFileSync(join(root, "platformclaw-login.html"), "<!doctype html><title>Login</title>");
writeFileSync(join(root, "assets", "login-ABC123.js"), "export const ready = true;");
return root;
}

async function serveFixture(root: string): Promise<{
origin: string;
close(): Promise<void>;
}> {
const handler = createPlatformClawWebAssetHandler(root);
const server = createServer((req, res) => {
void handler.handle(req, res).then((handled) => {
if (!handled) {
res.statusCode = 404;
res.end("Not Found");
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as AddressInfo).port;
return {
origin: `http://127.0.0.1:${port}`,
close: async () =>
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
}),
};
}

afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});

describe("createPlatformClawWebAssetHandler", () => {
it("serves the login contract path with document security headers", async () => {
const fixture = await serveFixture(fixtureRoot());
try {
const response = await fetch(`${fixture.origin}${PLATFORMCLAW_WEB_LOGIN_PATH}`);
expect(response.status).toBe(200);
expect(await response.text()).toContain("<title>Login</title>");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
} finally {
await fixture.close();
}
});

it("serves only indexed build assets with immutable caching", async () => {
const fixture = await serveFixture(fixtureRoot());
try {
const asset = await fetch(`${fixture.origin}${PLATFORMCLAW_WEB_ASSET_PREFIX}login-ABC123.js`);
expect(asset.status).toBe(200);
expect(asset.headers.get("cache-control")).toContain("immutable");
expect(asset.headers.get("content-type")).toContain("text/javascript");

const traversal = await fetch(
`${fixture.origin}${PLATFORMCLAW_WEB_ASSET_PREFIX}%2e%2e%2fplatformclaw-login.html`,
);
expect(traversal.status).toBe(404);
} finally {
await fixture.close();
}
});
});
135 changes: 135 additions & 0 deletions packages/platformclaw-control-plane/src/web-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
import { readFile } from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import { extname, join, relative, resolve, sep } from "node:path";

export const PLATFORMCLAW_WEB_LOGIN_PATH = "/platformclaw/login";
export const PLATFORMCLAW_WEB_ASSET_PREFIX = "/platformclaw/assets/";

export type PlatformClawWebAssetHandler = {
handle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
};

const CONTENT_TYPES: Readonly<Record<string, string>> = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".webp": "image/webp",
};

const DOCUMENT_SECURITY_POLICY = [
"default-src 'self'",
"base-uri 'none'",
"connect-src 'self' ws: wss:",
"form-action 'self'",
"frame-ancestors 'none'",
"img-src 'self' data:",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
].join("; ");

type WebAsset = {
filePath: string;
contentType: string;
};

function assertRegularFileInsideRoot(root: string, filePath: string): string {
const resolved = realpathSync(filePath);
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
throw new Error(`PlatformClaw web asset escapes root: ${filePath}`);
}
if (!statSync(resolved).isFile()) {
throw new Error(`PlatformClaw web asset is not a file: ${filePath}`);
}
return resolved;
}

function collectAssetFiles(root: string, directory: string): Map<string, WebAsset> {
const assets = new Map<string, WebAsset>();
const visit = (current: string, entries: Dirent[]): void => {
for (const entry of entries) {
if (entry.isSymbolicLink()) {
throw new Error(`PlatformClaw web assets must not contain symlinks: ${entry.name}`);
}
const candidate = join(current, entry.name);
if (entry.isDirectory()) {
visit(candidate, readdirSync(candidate, { withFileTypes: true }));
continue;
}
if (!entry.isFile()) {
continue;
}
const filePath = assertRegularFileInsideRoot(root, candidate);
const assetPath = relative(directory, filePath).split(sep).join("/");
assets.set(`${PLATFORMCLAW_WEB_ASSET_PREFIX}${assetPath}`, {
filePath,
contentType: CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream",
});
}
};
visit(directory, readdirSync(directory, { withFileTypes: true }));
return assets;
}

function setSecurityHeaders(res: ServerResponse): void {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Frame-Options", "DENY");
}

export function createPlatformClawWebAssetHandler(
rootDirectory: string,
): PlatformClawWebAssetHandler {
const root = realpathSync(resolve(rootDirectory));
const loginFile = assertRegularFileInsideRoot(root, join(root, "platformclaw-login.html"));
const assetsDirectory = realpathSync(join(root, "assets"));
if (!assetsDirectory.startsWith(`${root}${sep}`)) {
throw new Error("PlatformClaw web assets directory escapes root");
}
const assets = collectAssetFiles(root, assetsDirectory);

return {
async handle(req, res) {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
const isLogin = pathname === PLATFORMCLAW_WEB_LOGIN_PATH;
const asset = assets.get(pathname);
if (!isLogin && !asset) {
return false;
}
const filePath = isLogin ? loginFile : asset?.filePath;
if (!filePath) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Allow", "GET, HEAD");
res.end("Method Not Allowed");
return true;
}

setSecurityHeaders(res);
if (isLogin) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Security-Policy", DOCUMENT_SECURITY_POLICY);
} else if (asset) {
res.setHeader("Content-Type", asset.contentType);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
res.statusCode = 200;
if (req.method === "HEAD") {
res.end();
return true;
}
res.end(await readFile(filePath));
return true;
},
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { GatewayClientOptions } from "@openclaw/gateway-client";
import type { HelloOk } from "@openclaw/gateway-protocol";
import { describe, expect, it, vi } from "vitest";
Expand All @@ -22,6 +26,9 @@

describe("createPlatformClawWebIngressRuntime", () => {
it("assembles employee auth, the private Gateway, policy proxy, and listener", async () => {
const controlUiRoot = mkdtempSync(join(tmpdir(), "platformclaw-ingress-ui-"));

Check warning on line 29 in packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts

View workflow job for this annotation

GitHub Actions / PlatformClaw CI

new mkdtemp temp directory creation: prefer useAutoCleanupTempDirTracker(afterEach) from test/helpers/temp-dir.ts for new test-owned temp directories.
mkdirSync(join(controlUiRoot, "assets"));
writeFileSync(join(controlUiRoot, "platformclaw-login.html"), "<!doctype html>Login");
let clientOptions: GatewayClientOptions | undefined;
const stop = vi.fn();
const provisionOrRefresh = vi.fn(async () => undefined);
Expand Down Expand Up @@ -58,12 +65,17 @@
},
},
publicOrigin: "http://127.0.0.1:3000",
controlUiRoot,
});

try {
await runtime.listen({ host: "127.0.0.1", port: 0 });
expect(clientOptions?.token).toBe("test-auth-token");
expect(runtime.gateway.getHello()).toEqual(hello());
const port = (runtime.server.address() as AddressInfo).port;
const loginPage = await fetch(`http://127.0.0.1:${port}/platformclaw/login`);
expect(loginPage.status).toBe(200);
expect(await loginPage.text()).toContain("<!doctype html>Login");

await expect(
runtime.auth.service.loginPassword({
Expand All @@ -77,6 +89,7 @@
expect(provisionOrRefresh).toHaveBeenCalledOnce();
} finally {
await runtime.close();
rmSync(controlUiRoot, { recursive: true, force: true });
}
expect(stop).toHaveBeenCalledOnce();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PlatformClawGatewayRuntimeClient,
type PlatformClawGatewayRuntimeClientOptions,
} from "./gateway-runtime-client.js";
import { createPlatformClawWebAssetHandler } from "./web-assets.js";
import {
PlatformClawWebIngressServer,
type PlatformClawWebIngressListenOptions,
Expand All @@ -32,6 +33,7 @@ export type PlatformClawWebIngressRuntimeOptions = {
>;
gatewayClient: PlatformClawGatewayRuntimeClientOptions;
publicOrigin: string;
controlUiRoot: string;
loginRateLimiter?: MemoryBrowserLoginRateLimiterOptions;
ingress?: Pick<
PlatformClawWebIngressOptions,
Expand Down Expand Up @@ -75,6 +77,7 @@ export function createPlatformClawWebIngressRuntime(
loginRateLimiter: new MemoryBrowserLoginRateLimiter(options.loginRateLimiter),
gatewayProxy,
gateway,
webAssets: createPlatformClawWebAssetHandler(options.controlUiRoot),
...options.ingress,
});
let closed = false;
Expand Down
5 changes: 5 additions & 0 deletions packages/platformclaw-control-plane/src/web-ingress-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type BrowserGatewayEvent,
} from "./browser-gateway-proxy.js";
import type { PlatformClawGatewayBackend } from "./gateway-runtime-client.js";
import type { PlatformClawWebAssetHandler } from "./web-assets.js";

export const PLATFORMCLAW_GATEWAY_PATH = "/platformclaw/gateway";
export const PLATFORMCLAW_HEALTH_PATH = "/platformclaw/health";
Expand All @@ -47,6 +48,7 @@ export type PlatformClawWebIngressOptions = {
loginRateLimiter: BrowserLoginRateLimiter;
gatewayProxy: PlatformClawBrowserGatewayPolicy;
gateway: PlatformClawGatewayBackend;
webAssets?: PlatformClawWebAssetHandler;
gatewayPath?: string;
healthPath?: string;
maxPayloadBytes?: number;
Expand Down Expand Up @@ -269,6 +271,9 @@ export class PlatformClawWebIngressServer {
if (handled) {
return;
}
if (this.options.webAssets && (await this.options.webAssets.handle(req, res))) {
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;
Expand Down
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"build": "vite build && vite build --config vite.platformclaw-login.config.ts",
"dev": "vite",
"preview": "vite preview",
"test": "vitest run --config vitest.config.ts"
Expand Down
Loading
Loading