From f7c070b5e2511823f9fd8600d339ecf8737e7ae0 Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 20 Jul 2026 18:23:23 +0900 Subject: [PATCH 1/4] feat(platformclaw): assemble browser auth runtime --- .../src/browser-auth-runtime.test.ts | 90 +++++++++++++++++++ .../src/browser-auth-runtime.ts | 61 +++++++++++++ .../platformclaw-control-plane/src/index.ts | 1 + 3 files changed, 152 insertions(+) create mode 100644 packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts create mode 100644 packages/platformclaw-control-plane/src/browser-auth-runtime.ts diff --git a/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts b/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts new file mode 100644 index 00000000000..3a3d184ce19 --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmployeeBrowserAuthRuntime } from "./browser-auth-runtime.js"; +import { EMPLOYEE_AUTH_LOGIN_URL_ENV } from "./employee-auth-client.js"; + +describe("createEmployeeBrowserAuthRuntime", () => { + const runtimes: Array<{ close(): void }> = []; + + afterEach(() => { + for (const runtime of runtimes.splice(0)) { + runtime.close(); + } + }); + + it("wires employee authentication, persistent sessions, and provisioning", async () => { + const sessionValue = "test-session-value"; + const provisioner = { provisionOrRefresh: vi.fn(async () => undefined) }; + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + authenticated: true, + employeeId: "account.name", + accountId: "account.name", + name: "Account Name", + department: "Platform", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const runtime = createEmployeeBrowserAuthRuntime({ + databasePath: ":memory:", + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + provisioner, + initialAdminAccountIds: ["account.name"], + employeeAuthConfig: { loginUrl: "http://127.0.0.1:18080/login" }, + fetchImpl, + now: () => 1_000, + tokenFactory: () => sessionValue, + }); + runtimes.push(runtime); + + const login = await runtime.service.loginPassword({ + login: { identifier: "account.name", password: "test-password" }, + }); + + expect(login).toMatchObject({ + status: "authenticated", + user: { accountId: "account.name", globalRole: "admin" }, + binding: { agentId: "account_name", state: "active" }, + }); + expect(provisioner.provisionOrRefresh).toHaveBeenCalledOnce(); + await expect(runtime.service.authenticateToken(sessionValue, false)).resolves.toMatchObject({ + status: "active", + user: { accountId: "account.name" }, + }); + await expect(runtime.service.logout(sessionValue)).resolves.toBe(true); + await expect(runtime.service.authenticateToken(sessionValue, false)).resolves.toEqual({ + status: "unauthenticated", + reason: "revoked", + }); + }); + + it("loads the employee login URL from deployment environment", async () => { + const env: NodeJS.ProcessEnv = {}; + env[EMPLOYEE_AUTH_LOGIN_URL_ENV] = "http://127.0.0.1:18080/login"; + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ authenticated: false, message: "no" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + const runtime = createEmployeeBrowserAuthRuntime({ + databasePath: ":memory:", + buildAgentMainSessionKey: ({ agentId }) => `agent:${agentId}:main`, + provisioner: { async provisionOrRefresh() {} }, + initialAdminAccountIds: ["admin.account"], + env, + fetchImpl, + }); + runtimes.push(runtime); + + await runtime.service.loginPassword({ + login: { identifier: "account.name", password: "test-password" }, + }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://127.0.0.1:18080/login"); + }); +}); diff --git a/packages/platformclaw-control-plane/src/browser-auth-runtime.ts b/packages/platformclaw-control-plane/src/browser-auth-runtime.ts new file mode 100644 index 00000000000..5b537aeef2e --- /dev/null +++ b/packages/platformclaw-control-plane/src/browser-auth-runtime.ts @@ -0,0 +1,61 @@ +import { BrowserAuthService, type PersonalAgentProvisioner } from "./browser-auth-service.js"; +import type { MainSessionKeyBuilder } from "./contracts.js"; +import { + HttpEmployeeAuthenticator, + loadEmployeeAuthClientConfig, + type EmployeeAuthClientConfig, +} from "./employee-auth-client.js"; +import { SqliteControlPlaneStore } from "./sqlite-store.js"; + +export type EmployeeBrowserAuthRuntimeOptions = { + databasePath: string; + buildAgentMainSessionKey: MainSessionKeyBuilder; + provisioner: PersonalAgentProvisioner; + initialAdminAccountIds: readonly string[]; + employeeAuthConfig?: EmployeeAuthClientConfig; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof globalThis.fetch; + now?: () => number; + tokenFactory?: () => string; +}; + +export type EmployeeBrowserAuthRuntime = { + store: SqliteControlPlaneStore; + service: BrowserAuthService; + close(): void; +}; + +/** Build the production LDAP-phase browser-auth runtime around the persistent store. */ +export function createEmployeeBrowserAuthRuntime( + options: EmployeeBrowserAuthRuntimeOptions, +): EmployeeBrowserAuthRuntime { + const authConfig = options.employeeAuthConfig ?? loadEmployeeAuthClientConfig(options.env); + const authenticator = new HttpEmployeeAuthenticator( + authConfig, + options.fetchImpl ?? globalThis.fetch, + ); + const store = new SqliteControlPlaneStore({ + databasePath: options.databasePath, + buildAgentMainSessionKey: options.buildAgentMainSessionKey, + initialAdminAccountIds: options.initialAdminAccountIds, + }); + const service = new BrowserAuthService({ + store, + authenticator, + provisioner: options.provisioner, + ...(options.now ? { now: options.now } : {}), + ...(options.tokenFactory ? { tokenFactory: options.tokenFactory } : {}), + }); + let closed = false; + return { + store, + service, + close() { + if (closed) { + return; + } + closed = true; + store.close(); + }, + }; +} diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index 2d88b834842..4d532b35e34 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -1,5 +1,6 @@ export * from "./browser-auth-service.js"; export * from "./browser-auth-http.js"; +export * from "./browser-auth-runtime.js"; export * from "./contracts.js"; export * from "./employee-auth-client.js"; export * from "./ids.js"; From 588eab2fdd7af71f7674272256c9f51a775be5f1 Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 20 Jul 2026 19:20:04 +0900 Subject: [PATCH 2/4] feat(platformclaw): provision personal agents --- PLATFORMCLAW.md | 4 +- docs/platformclaw/control-plane-phase-1.md | 16 +- docs/platformclaw/decisions.md | 14 ++ docs/platformclaw/employee-auth.md | 30 +++ .../src/gateway-admin-rpc-client.test.ts | 144 +++++++++++++ .../src/gateway-admin-rpc-client.ts | 203 ++++++++++++++++++ .../platformclaw-control-plane/src/index.ts | 2 + .../src/personal-agent-provisioner.test.ts | 168 +++++++++++++++ .../src/personal-agent-provisioner.ts | 93 ++++++++ 9 files changed, 668 insertions(+), 6 deletions(-) create mode 100644 packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts create mode 100644 packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts create mode 100644 packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts create mode 100644 packages/platformclaw-control-plane/src/personal-agent-provisioner.ts diff --git a/PLATFORMCLAW.md b/PLATFORMCLAW.md index 0bc64d5815c..658954f6102 100644 --- a/PLATFORMCLAW.md +++ b/PLATFORMCLAW.md @@ -23,7 +23,9 @@ The goals are to: - Baseline commit: `17c2ce05d8021b969f9e822a34e92535145922d5` - The Jammy company deployment profile is the first capability being migrated. - The first control-plane contract slice now lives in - `packages/platformclaw-control-plane` with an in-memory implementation. + `packages/platformclaw-control-plane` with in-memory and SQLite stores, + employee browser-session runtime assembly, and Gateway-backed personal-agent + provisioning. - 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 170841d217d..a409d5484f7 100644 --- a/docs/platformclaw/control-plane-phase-1.md +++ b/docs/platformclaw/control-plane-phase-1.md @@ -34,12 +34,15 @@ After Phase 1: ## Implementation status -Slices 1 through 3 are implemented in `packages/platformclaw-control-plane`. They +Slices 1 through 4 are implemented in `packages/platformclaw-control-plane`. They define the identity, browser-session, personal-agent provisioning, Knox room binding, authenticated Knox DM routing, managed group/part contracts, employee-auth HTTP adapter, opaque-session login service, and framework-neutral -browser-auth HTTP boundary. The in-memory store covers contract behavior, and -the SQLite store persists the approved schema version 1. +browser-auth HTTP boundary. The employee browser-auth runtime now assembles the +LDAP-phase adapter and SQLite session store, and the personal-agent provisioner +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. Focused tests cover LDAP metadata refresh, LDAP-to-SAML identity linking, employee ID correction conflicts, concurrent personal and room provisioning, @@ -53,8 +56,11 @@ requires its host process to inject bounded JSON parsing, a trusted client IP, TLS state, and an auth rate limiter. These slices do not contain credential encryption, SAML protocol handling, -Knox transport code, OpenClaw agent creation, workspace file writes, or Gateway -mutations. +Knox transport code, or the deployable HTTP listener/Gateway proxy. OpenClaw +agent creation now occurs only through the private Admin HTTP RPC plugin; +PlatformClaw still does not import OpenClaw core. Initial `USER.md` profile +injection remains deferred until an atomic, ownership-aware Gateway/plugin +contract is approved. ## Process boundary diff --git a/docs/platformclaw/decisions.md b/docs/platformclaw/decisions.md index 0bc1fa6c8d3..741a28af054 100644 --- a/docs/platformclaw/decisions.md +++ b/docs/platformclaw/decisions.md @@ -191,6 +191,20 @@ disabled in the private origin. Workflow enablement is audited after every upstream sync; OpenClaw credentials are never copied into PlatformClaw merely to satisfy unrelated upstream automation. +### PC-115 Provision personal agents through Admin HTTP RPC + +`platformclaw-control` uses the private authenticated `admin-http-rpc` plugin +for existing `agents.list` and `agents.create` methods. It does not import +OpenClaw core or write agent configuration directly. Agent adoption requires +the reserved control-plane agent ID and exact expected workspace. The Gateway +operator bearer stays in the deployment secret store and never enters browser +or workspace state. + +Initial `USER.md` profile injection is not part of this decision. Whole-file +read/write RPC composition cannot safely preserve concurrent user edits or +enforce the expected workspace before mutation. That feature requires a later +atomic Gateway/plugin contract decision. + ## Open operational decisions No remaining decision blocks the SQLite v1 store. Deployment work still needs diff --git a/docs/platformclaw/employee-auth.md b/docs/platformclaw/employee-auth.md index 6a4197358f9..3c0d4f997f1 100644 --- a/docs/platformclaw/employee-auth.md +++ b/docs/platformclaw/employee-auth.md @@ -88,6 +88,36 @@ operator credential. The server enforces the 12-hour idle timeout, seven-day absolute timeout, three-session limit, logout revocation, and account-disable revocation. +`createEmployeeBrowserAuthRuntime` now assembles the deployment-configured +employee authenticator, SQLite store, browser session service, and injected +personal-agent provisioner. The host process still owns its HTTP listener, +trusted client-IP resolution, exact origin policy, TLS termination state, JSON +body limits, and rate limiter. Closing the runtime closes its SQLite handle. + +## OpenClaw agent provisioning + +`GatewayPersonalAgentProvisioner` uses the private, authenticated Admin HTTP RPC +plugin instead of importing OpenClaw core. It lists or creates the reserved +personal agent and verifies the exact returned agent ID and workspace. Once the +binding is active, later logins refresh mutable directory metadata in SQLite but +perform no Gateway discovery or workspace mutation. + +The provisioner requires an injected Admin RPC URL, bearer, and workspace root. +The bearer is a Gateway operator secret and must come from the deployment secret +store. It must never enter the browser session, workspace, SQLite database, logs, +or source control. The Admin HTTP RPC route remains private and uses only its +existing `agents.list` and `agents.create` allowlist entries. + +Provisioning fails closed when an existing agent has a different workspace. A +concurrent `agents.create` winner is adopted only after a new `agents.list` +result proves the exact reserved agent ID and expected workspace. + +Initial `USER.md` profile injection remains deferred. Existing whole-file RPCs +cannot atomically preserve user content, enforce binding ownership, and verify +the expected workspace before mutation. Implementing this safely requires a +separate approved atomic Gateway/plugin contract; PlatformClaw does not add a +racy downstream write merely to complete the bootstrap snapshot. + Schema v1 has not shipped as a tagged PlatformClaw release. Development databases created before the employee-auth adapter introduced a distinct `accountId` contract must be recreated; PC-109 deliberately provides no runtime diff --git a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts new file mode 100644 index 00000000000..ba365b8a374 --- /dev/null +++ b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { GatewayAdminRpcError, HttpGatewayAdminRpcClient } from "./gateway-admin-rpc-client.js"; + +function createClient( + responder: (request: Record) => { status?: number; body: unknown }, +) { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as Record; + const response = responder(request); + return new Response(JSON.stringify(response.body), { + status: response.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const client = new HttpGatewayAdminRpcClient( + { + rpcUrl: "http://127.0.0.1:18789/api/v1/admin/rpc", + bearerToken: "test-bearer-token", + }, + fetchImpl, + ); + return { client, fetchImpl }; +} + +describe("HttpGatewayAdminRpcClient", () => { + it.each([0.5, 2 ** 32])( + "rejects timeout values unsupported by AbortSignal.timeout: %s", + (timeoutMs) => { + expect( + () => + new HttpGatewayAdminRpcClient({ + rpcUrl: "http://127.0.0.1:18789/api/v1/admin/rpc", + bearerToken: "test-bearer-token", + timeoutMs, + }), + ).toThrow("Gateway Admin RPC timeout must be an integer from 1 to 4294967295"); + }, + ); + + it("sends one authenticated RPC request and returns its payload", async () => { + const { client, fetchImpl } = createClient((request) => ({ + body: { id: request.id, ok: true, payload: { agents: [] } }, + })); + + await expect(client.call("agents.list", {})).resolves.toEqual({ agents: [] }); + + const [, init] = fetchImpl.mock.calls[0] ?? []; + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-bearer-token"); + expect(JSON.parse(String(init?.body))).toMatchObject({ method: "agents.list", params: {} }); + }); + + it("returns bounded Gateway errors without exposing response bodies", async () => { + const { client } = createClient((request) => ({ + status: 400, + body: { + id: request.id, + ok: false, + error: { code: "INVALID_REQUEST", message: "agent already exists" }, + }, + })); + + await expect(client.call("agents.create", {})).rejects.toMatchObject({ + name: "GatewayAdminRpcError", + code: "INVALID_REQUEST", + httpStatus: 400, + message: "agent already exists", + }); + }); + + it("fails closed when the response id does not match", async () => { + const { client } = createClient(() => ({ + body: { id: "wrong-id", ok: true, payload: {} }, + })); + + await expect(client.call("agents.list", {})).rejects.toBeInstanceOf(GatewayAdminRpcError); + await expect(client.call("agents.list", {})).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + }); + }); + + it("rejects a response whose declared size exceeds the byte limit", async () => { + const cancel = vi.fn(); + const fetchImpl = vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + }, + cancel, + }), + { + headers: { + "Content-Length": String(1024 * 1024 + 1), + "Content-Type": "application/json", + }, + }, + ), + ); + const client = new HttpGatewayAdminRpcClient( + { + rpcUrl: "http://127.0.0.1:18789/api/v1/admin/rpc", + bearerToken: "test-bearer-token", + }, + fetchImpl, + ); + + await expect(client.call("agents.list", {})).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + message: "Gateway Admin RPC response exceeded the size limit", + }); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("stops reading a chunked response after the byte limit", async () => { + const chunk = new Uint8Array(600 * 1024); + const fetchImpl = vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ); + const client = new HttpGatewayAdminRpcClient( + { + rpcUrl: "http://127.0.0.1:18789/api/v1/admin/rpc", + bearerToken: "test-bearer-token", + }, + fetchImpl, + ); + + await expect(client.call("agents.list", {})).rejects.toMatchObject({ + code: "INVALID_RESPONSE", + message: "Gateway Admin RPC response exceeded the size limit", + }); + }); +}); diff --git a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts new file mode 100644 index 00000000000..96d80a56514 --- /dev/null +++ b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts @@ -0,0 +1,203 @@ +import { randomUUID } from "node:crypto"; + +const DEFAULT_GATEWAY_ADMIN_RPC_TIMEOUT_MS = 15_000; +const MAX_ABORT_SIGNAL_TIMEOUT_MS = 2 ** 32 - 1; +const MAX_GATEWAY_ADMIN_RPC_RESPONSE_BYTES = 1024 * 1024; + +export type GatewayAdminRpcClientConfig = { + rpcUrl: string; + bearerToken: string; + timeoutMs?: number; +}; + +export interface GatewayAdminRpc { + call(method: string, params: unknown): Promise; +} + +type RpcErrorBody = { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; +}; + +export class GatewayAdminRpcError extends Error { + constructor( + message: string, + readonly code: string, + readonly httpStatus?: number, + readonly details?: unknown, + readonly retryable?: boolean, + readonly retryAfterMs?: number, + ) { + super(message); + this.name = "GatewayAdminRpcError"; + } +} + +function normalizeRpcUrl(raw: string): string { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Gateway Admin RPC URL must use http or https"); + } + if (url.username || url.password) { + throw new Error("Gateway Admin RPC URL must not contain credentials"); + } + if (url.search || url.hash) { + throw new Error("Gateway Admin RPC URL must not contain query or fragment data"); + } + return url.toString(); +} + +function parseError(value: unknown): RpcErrorBody | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + if (typeof record.code !== "string" || typeof record.message !== "string") { + return null; + } + return { + code: record.code, + message: record.message, + ...(Object.hasOwn(record, "details") ? { details: record.details } : {}), + ...(typeof record.retryable === "boolean" ? { retryable: record.retryable } : {}), + ...(typeof record.retryAfterMs === "number" ? { retryAfterMs: record.retryAfterMs } : {}), + }; +} + +async function readBoundedJsonResponse(response: Response): Promise { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MAX_GATEWAY_ADMIN_RPC_RESPONSE_BYTES + ) { + void response.body?.cancel().catch(() => undefined); + throw new GatewayAdminRpcError( + "Gateway Admin RPC response exceeded the size limit", + "INVALID_RESPONSE", + response.status, + ); + } + if (!response.body) { + throw new GatewayAdminRpcError( + "Gateway Admin RPC returned an empty response", + "INVALID_RESPONSE", + response.status, + ); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let receivedBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + receivedBytes += value.byteLength; + if (receivedBytes > MAX_GATEWAY_ADMIN_RPC_RESPONSE_BYTES) { + void reader.cancel().catch(() => undefined); + throw new GatewayAdminRpcError( + "Gateway Admin RPC response exceeded the size limit", + "INVALID_RESPONSE", + response.status, + ); + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return JSON.parse(text) as unknown; + } catch (error) { + if (error instanceof GatewayAdminRpcError) { + throw error; + } + throw new GatewayAdminRpcError( + "Gateway Admin RPC returned invalid JSON", + "INVALID_RESPONSE", + response.status, + ); + } +} + +export class HttpGatewayAdminRpcClient implements GatewayAdminRpc { + private readonly config: Required; + + constructor( + config: GatewayAdminRpcClientConfig, + private readonly fetchImpl: typeof globalThis.fetch = globalThis.fetch, + ) { + const bearerToken = config.bearerToken.trim(); + if (!bearerToken) { + throw new Error("Gateway Admin RPC bearer token is required"); + } + const timeoutMs = config.timeoutMs ?? DEFAULT_GATEWAY_ADMIN_RPC_TIMEOUT_MS; + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_ABORT_SIGNAL_TIMEOUT_MS) { + throw new Error("Gateway Admin RPC timeout must be an integer from 1 to 4294967295"); + } + this.config = { rpcUrl: normalizeRpcUrl(config.rpcUrl), bearerToken, timeoutMs }; + } + + async call(method: string, params: unknown): Promise { + const normalizedMethod = method.trim(); + if (!normalizedMethod) { + throw new Error("Gateway Admin RPC method is required"); + } + const id = randomUUID(); + let response: Response; + try { + response = await this.fetchImpl(this.config.rpcUrl, { + method: "POST", + redirect: "error", + headers: { + Authorization: `Bearer ${this.config.bearerToken}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.config.timeoutMs), + body: JSON.stringify({ id, method: normalizedMethod, params }), + }); + } catch { + throw new GatewayAdminRpcError("Gateway Admin RPC unavailable", "UNAVAILABLE"); + } + + const body = await readBoundedJsonResponse(response); + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new GatewayAdminRpcError( + "Gateway Admin RPC returned an invalid response", + "INVALID_RESPONSE", + response.status, + ); + } + const record = body as Record; + if (record.id !== id) { + throw new GatewayAdminRpcError( + "Gateway Admin RPC response id did not match the request", + "INVALID_RESPONSE", + response.status, + ); + } + if (record.ok === true && Object.hasOwn(record, "payload")) { + return record.payload as T; + } + const error = record.ok === false ? parseError(record.error) : null; + if (!error) { + throw new GatewayAdminRpcError( + "Gateway Admin RPC returned an invalid response", + "INVALID_RESPONSE", + response.status, + ); + } + throw new GatewayAdminRpcError( + error.message, + error.code, + response.status, + error.details, + error.retryable, + error.retryAfterMs, + ); + } +} diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index 4d532b35e34..091417a4262 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -3,7 +3,9 @@ export * from "./browser-auth-http.js"; export * from "./browser-auth-runtime.js"; export * from "./contracts.js"; export * from "./employee-auth-client.js"; +export * from "./gateway-admin-rpc-client.js"; export * from "./ids.js"; export * from "./memory-store.js"; +export * from "./personal-agent-provisioner.js"; export * from "./sqlite-schema.js"; export * from "./sqlite-store.js"; diff --git a/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts b/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts new file mode 100644 index 00000000000..a50bee29b5c --- /dev/null +++ b/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts @@ -0,0 +1,168 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { PersonalAgentProvisioningRequest } from "./browser-auth-service.js"; +import { GatewayAdminRpcError, type GatewayAdminRpc } from "./gateway-admin-rpc-client.js"; +import { GatewayPersonalAgentProvisioner } from "./personal-agent-provisioner.js"; + +function request( + overrides: Partial = {}, +): PersonalAgentProvisioningRequest { + return { + user: { + id: "user-1", + accountId: "account.name", + employeeId: "employee-1", + status: "active", + globalRole: "member", + groups: [], + createdAt: 1, + updatedAt: 1, + }, + binding: { + id: "binding-1", + kind: "personal", + userId: "user-1", + agentId: "account_name", + state: "provisioning", + createdAt: 1, + updatedAt: 1, + }, + profile: { + employeeId: "employee-1", + accountId: "account.name", + subject: "account.name", + groups: [], + attributes: {}, + }, + createdBinding: true, + ...overrides, + }; +} + +function createRpc( + handler: (method: string, params: unknown) => unknown | Promise, +): GatewayAdminRpc & { call: ReturnType } { + const call = vi.fn(async (method: string, params: unknown) => handler(method, params) as T); + return { call }; +} + +describe("GatewayPersonalAgentProvisioner", () => { + it("rejects a blank workspace root at startup", () => { + const rpc = createRpc((method) => { + throw new Error(`unexpected method: ${method}`); + }); + + expect(() => new GatewayPersonalAgentProvisioner({ rpc, workspaceRoot: " " })).toThrow( + "personal agent workspace root is required", + ); + expect(rpc.call).not.toHaveBeenCalled(); + }); + + it("creates the reserved personal agent in its expected workspace", async () => { + const workspaceRoot = path.resolve("test-workspaces"); + const workspace = path.join(workspaceRoot, "account_name"); + const rpc = createRpc((method) => { + if (method === "agents.list") { + return { agents: [] }; + } + if (method === "agents.create") { + return { ok: true, agentId: "account_name", workspace }; + } + throw new Error(`unexpected method: ${method}`); + }); + const provisioner = new GatewayPersonalAgentProvisioner({ rpc, workspaceRoot }); + + await provisioner.provisionOrRefresh(request()); + + expect(rpc.call).toHaveBeenNthCalledWith(1, "agents.list", {}); + expect(rpc.call).toHaveBeenNthCalledWith(2, "agents.create", { + name: "account_name", + workspace, + }); + }); + + it("does no Gateway discovery or mutation after activation", async () => { + const rpc = createRpc((method) => { + throw new Error(`unexpected method: ${method}`); + }); + const provisioner = new GatewayPersonalAgentProvisioner({ + rpc, + workspaceRoot: path.resolve("test-workspaces"), + }); + + await provisioner.provisionOrRefresh( + request({ binding: { ...request().binding, state: "active" }, createdBinding: false }), + ); + + expect(rpc.call).not.toHaveBeenCalled(); + }); + + it("fails closed instead of adopting an agent with another workspace", async () => { + const workspaceRoot = path.resolve("test-workspaces"); + const rpc = createRpc(() => ({ + agents: [{ id: "account_name", workspace: path.resolve("other-workspace") }], + })); + const provisioner = new GatewayPersonalAgentProvisioner({ rpc, workspaceRoot }); + + await expect(provisioner.provisionOrRefresh(request())).rejects.toThrow( + "Gateway agent workspace mismatch", + ); + expect(rpc.call).toHaveBeenCalledOnce(); + }); + + it("adopts a concurrent create only after exact id and workspace verification", async () => { + const workspaceRoot = path.resolve("test-workspaces"); + const workspace = path.join(workspaceRoot, "account_name"); + let listCount = 0; + const rpc = createRpc((method) => { + if (method === "agents.list") { + listCount += 1; + return { agents: listCount === 1 ? [] : [{ id: "account_name", workspace }] }; + } + if (method === "agents.create") { + throw new GatewayAdminRpcError("agent already exists", "INVALID_REQUEST", 400); + } + throw new Error(`unexpected method: ${method}`); + }); + const provisioner = new GatewayPersonalAgentProvisioner({ rpc, workspaceRoot }); + + await expect(provisioner.provisionOrRefresh(request())).resolves.toBeUndefined(); + expect(listCount).toBe(2); + }); + + it("rejects agent ids outside the upstream canonical contract before RPC", async () => { + const rpc = createRpc((method) => { + throw new Error(`unexpected method: ${method}`); + }); + const provisioner = new GatewayPersonalAgentProvisioner({ + rpc, + workspaceRoot: path.resolve("test-workspaces"), + }); + const invalid = request({ + binding: { ...request().binding, agentId: `agent-${"x".repeat(64)}` }, + }); + + await expect(provisioner.provisionOrRefresh(invalid)).rejects.toThrow( + "invalid personal agent id", + ); + expect(rpc.call).not.toHaveBeenCalled(); + }); + + it("rejects agent ids with non-canonical whitespace before RPC", async () => { + const rpc = createRpc((method) => { + throw new Error(`unexpected method: ${method}`); + }); + const provisioner = new GatewayPersonalAgentProvisioner({ + rpc, + workspaceRoot: path.resolve("test-workspaces"), + }); + const invalid = request({ + binding: { ...request().binding, agentId: " account_name " }, + }); + + await expect(provisioner.provisionOrRefresh(invalid)).rejects.toThrow( + "invalid personal agent id", + ); + expect(rpc.call).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platformclaw-control-plane/src/personal-agent-provisioner.ts b/packages/platformclaw-control-plane/src/personal-agent-provisioner.ts new file mode 100644 index 00000000000..b90ae173b9d --- /dev/null +++ b/packages/platformclaw-control-plane/src/personal-agent-provisioner.ts @@ -0,0 +1,93 @@ +import path from "node:path"; +import { isValidAgentId } from "@openclaw/normalization-core/agent-id"; +import type { + PersonalAgentProvisioner, + PersonalAgentProvisioningRequest, +} from "./browser-auth-service.js"; +import { GatewayAdminRpcError, type GatewayAdminRpc } from "./gateway-admin-rpc-client.js"; + +type AgentSummary = { id: string; workspace?: string }; +type AgentsListResult = { agents: AgentSummary[] }; +type AgentCreateResult = { ok: true; agentId: string; workspace: string }; + +export type GatewayPersonalAgentProvisionerOptions = { + rpc: GatewayAdminRpc; + workspaceRoot: string; +}; + +export class GatewayPersonalAgentProvisioner implements PersonalAgentProvisioner { + private readonly workspaceRoot: string; + + constructor(private readonly options: GatewayPersonalAgentProvisionerOptions) { + const workspaceRoot = options.workspaceRoot.trim(); + if (!workspaceRoot) { + throw new Error("personal agent workspace root is required"); + } + this.workspaceRoot = path.resolve(workspaceRoot); + } + + async provisionOrRefresh(request: PersonalAgentProvisioningRequest): Promise { + if (request.binding.state === "active") { + return; + } + const workspace = this.workspaceForAgent(request.binding.agentId); + await this.ensureAgent(request.binding.agentId, workspace); + } + + private workspaceForAgent(agentId: string): string { + if ( + agentId !== agentId.trim() || + !isValidAgentId(agentId) || + agentId !== agentId.toLowerCase() + ) { + throw new Error(`invalid personal agent id: ${agentId}`); + } + const workspace = path.resolve(this.workspaceRoot, agentId); + if (path.dirname(workspace) !== this.workspaceRoot) { + throw new Error(`personal agent workspace escaped root: ${agentId}`); + } + return workspace; + } + + private async listAgent(agentId: string): Promise { + const result = await this.options.rpc.call("agents.list", {}); + if (!Array.isArray(result.agents)) { + throw new Error("Gateway agents.list returned an invalid payload"); + } + return result.agents.find((agent) => agent.id === agentId); + } + + private verifyWorkspace(agentId: string, actual: string | undefined, expected: string): void { + if (!actual || path.resolve(actual) !== expected) { + throw new Error(`Gateway agent workspace mismatch: ${agentId}`); + } + } + + private async ensureAgent(agentId: string, workspace: string): Promise { + const existing = await this.listAgent(agentId); + if (existing) { + this.verifyWorkspace(agentId, existing.workspace, workspace); + return; + } + try { + const created = await this.options.rpc.call("agents.create", { + name: agentId, + workspace, + }); + if (created.agentId !== agentId) { + throw new Error(`Gateway created unexpected agent id: ${created.agentId}`); + } + this.verifyWorkspace(agentId, created.workspace, workspace); + } catch (error) { + if (!(error instanceof GatewayAdminRpcError) || error.code !== "INVALID_REQUEST") { + throw error; + } + // Another control process may win agents.create after this process lists agents. + const raced = await this.listAgent(agentId); + if (!raced) { + throw error; + } + this.verifyWorkspace(agentId, raced.workspace, workspace); + } + } +} From 259bf69f0d303ccaea4d1aa7d655c853aa7a54c4 Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 20 Jul 2026 20:12:38 +0900 Subject: [PATCH 3/4] fix(platformclaw): pass control-plane typecheck --- .github/workflows/platformclaw-ci.yml | 7 ++- .../src/browser-auth-runtime.test.ts | 4 +- .../src/browser-auth-service.ts | 13 ++++-- .../src/employee-auth-client.test.ts | 2 +- .../src/employee-auth-client.ts | 2 +- .../src/personal-agent-provisioner.test.ts | 46 +++++++++++-------- 6 files changed, 44 insertions(+), 30 deletions(-) diff --git a/.github/workflows/platformclaw-ci.yml b/.github/workflows/platformclaw-ci.yml index e5e341792ce..0be7401b967 100644 --- a/.github/workflows/platformclaw-ci.yml +++ b/.github/workflows/platformclaw-ci.yml @@ -35,11 +35,14 @@ jobs: with: install-bun: "false" - - name: Run changed-surface checks - run: pnpm check:changed + - name: Typecheck PlatformClaw control plane + run: node scripts/run-tsgo.mjs -p packages/platformclaw-control-plane/tsconfig.json --noEmit - name: Test PlatformClaw control plane run: node scripts/run-vitest.mjs packages/platformclaw-control-plane/src - name: Build PlatformClaw control plane run: pnpm --dir packages/platformclaw-control-plane build + + - name: Run changed-surface checks + run: pnpm check:changed diff --git a/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts b/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts index 3a3d184ce19..8af8f83134e 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-runtime.test.ts @@ -14,7 +14,7 @@ describe("createEmployeeBrowserAuthRuntime", () => { it("wires employee authentication, persistent sessions, and provisioning", async () => { const sessionValue = "test-session-value"; const provisioner = { provisionOrRefresh: vi.fn(async () => undefined) }; - const fetchImpl = vi.fn( + const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ @@ -63,7 +63,7 @@ describe("createEmployeeBrowserAuthRuntime", () => { it("loads the employee login URL from deployment environment", async () => { const env: NodeJS.ProcessEnv = {}; env[EMPLOYEE_AUTH_LOGIN_URL_ENV] = "http://127.0.0.1:18080/login"; - const fetchImpl = vi.fn( + const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ authenticated: false, message: "no" }), { status: 401, diff --git a/packages/platformclaw-control-plane/src/browser-auth-service.ts b/packages/platformclaw-control-plane/src/browser-auth-service.ts index d827c461bf5..bb6661db6e5 100644 --- a/packages/platformclaw-control-plane/src/browser-auth-service.ts +++ b/packages/platformclaw-control-plane/src/browser-auth-service.ts @@ -111,17 +111,22 @@ export class BrowserAuthService { } const sessionAt = (this.options.now ?? Date.now)(); - const currentSession = params.currentSession + const currentSessionToken = params.currentSession?.value; + const currentSession = currentSessionToken ? await this.options.store.resolveBrowserSession({ - tokenHash: hashBrowserSessionToken(params.currentSession.value), + tokenHash: hashBrowserSessionToken(currentSessionToken), resolvedAt: sessionAt, touch: true, }) : undefined; - if (currentSession?.status === "active" && currentSession.user.id === upserted.user.id) { + if ( + currentSessionToken && + currentSession?.status === "active" && + currentSession.user.id === upserted.user.id + ) { return { status: "authenticated", - token: params.currentSession.value, + token: currentSessionToken, session: currentSession.session, user: upserted.user, binding, diff --git a/packages/platformclaw-control-plane/src/employee-auth-client.test.ts b/packages/platformclaw-control-plane/src/employee-auth-client.test.ts index 299caa609eb..a242ede5685 100644 --- a/packages/platformclaw-control-plane/src/employee-auth-client.test.ts +++ b/packages/platformclaw-control-plane/src/employee-auth-client.test.ts @@ -35,7 +35,7 @@ describe("employee auth client config", () => { describe("HttpEmployeeAuthenticator", () => { it("normalizes the full legacy profile while control-plane owns agent routing", async () => { - const fetchImpl = vi.fn( + const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ diff --git a/packages/platformclaw-control-plane/src/employee-auth-client.ts b/packages/platformclaw-control-plane/src/employee-auth-client.ts index 364a5762ce5..19faeccc167 100644 --- a/packages/platformclaw-control-plane/src/employee-auth-client.ts +++ b/packages/platformclaw-control-plane/src/employee-auth-client.ts @@ -74,7 +74,7 @@ type ExternalAuthSuccess = { confluenceSpace?: string; notes?: string; groups?: string[]; - attributes?: Record; + attributes?: EmployeeDirectoryProfile["attributes"]; }; type ExternalAuthResponse = ExternalAuthFailure | ExternalAuthSuccess; diff --git a/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts b/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts index a50bee29b5c..2738ab4a9be 100644 --- a/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts +++ b/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, type Mock } from "vitest"; import type { PersonalAgentProvisioningRequest } from "./browser-auth-service.js"; import { GatewayAdminRpcError, type GatewayAdminRpc } from "./gateway-admin-rpc-client.js"; import { GatewayPersonalAgentProvisioner } from "./personal-agent-provisioner.js"; @@ -39,29 +39,35 @@ function request( }; } -function createRpc( - handler: (method: string, params: unknown) => unknown | Promise, -): GatewayAdminRpc & { call: ReturnType } { - const call = vi.fn(async (method: string, params: unknown) => handler(method, params) as T); - return { call }; +function createRpc(handler: (method: string, params: unknown) => unknown | Promise): { + rpc: GatewayAdminRpc; + call: Mock<(method: string, params: unknown) => Promise>; +} { + const call = vi.fn(async (method: string, params: unknown) => handler(method, params)); + const rpc: GatewayAdminRpc = { + async call(method: string, params: unknown): Promise { + return (await call(method, params)) as T; + }, + }; + return { rpc, call }; } describe("GatewayPersonalAgentProvisioner", () => { it("rejects a blank workspace root at startup", () => { - const rpc = createRpc((method) => { + const { rpc, call } = createRpc((method) => { throw new Error(`unexpected method: ${method}`); }); expect(() => new GatewayPersonalAgentProvisioner({ rpc, workspaceRoot: " " })).toThrow( "personal agent workspace root is required", ); - expect(rpc.call).not.toHaveBeenCalled(); + expect(call).not.toHaveBeenCalled(); }); it("creates the reserved personal agent in its expected workspace", async () => { const workspaceRoot = path.resolve("test-workspaces"); const workspace = path.join(workspaceRoot, "account_name"); - const rpc = createRpc((method) => { + const { rpc, call } = createRpc((method) => { if (method === "agents.list") { return { agents: [] }; } @@ -74,15 +80,15 @@ describe("GatewayPersonalAgentProvisioner", () => { await provisioner.provisionOrRefresh(request()); - expect(rpc.call).toHaveBeenNthCalledWith(1, "agents.list", {}); - expect(rpc.call).toHaveBeenNthCalledWith(2, "agents.create", { + expect(call).toHaveBeenNthCalledWith(1, "agents.list", {}); + expect(call).toHaveBeenNthCalledWith(2, "agents.create", { name: "account_name", workspace, }); }); it("does no Gateway discovery or mutation after activation", async () => { - const rpc = createRpc((method) => { + const { rpc, call } = createRpc((method) => { throw new Error(`unexpected method: ${method}`); }); const provisioner = new GatewayPersonalAgentProvisioner({ @@ -94,12 +100,12 @@ describe("GatewayPersonalAgentProvisioner", () => { request({ binding: { ...request().binding, state: "active" }, createdBinding: false }), ); - expect(rpc.call).not.toHaveBeenCalled(); + expect(call).not.toHaveBeenCalled(); }); it("fails closed instead of adopting an agent with another workspace", async () => { const workspaceRoot = path.resolve("test-workspaces"); - const rpc = createRpc(() => ({ + const { rpc, call } = createRpc(() => ({ agents: [{ id: "account_name", workspace: path.resolve("other-workspace") }], })); const provisioner = new GatewayPersonalAgentProvisioner({ rpc, workspaceRoot }); @@ -107,14 +113,14 @@ describe("GatewayPersonalAgentProvisioner", () => { await expect(provisioner.provisionOrRefresh(request())).rejects.toThrow( "Gateway agent workspace mismatch", ); - expect(rpc.call).toHaveBeenCalledOnce(); + expect(call).toHaveBeenCalledOnce(); }); it("adopts a concurrent create only after exact id and workspace verification", async () => { const workspaceRoot = path.resolve("test-workspaces"); const workspace = path.join(workspaceRoot, "account_name"); let listCount = 0; - const rpc = createRpc((method) => { + const { rpc } = createRpc((method) => { if (method === "agents.list") { listCount += 1; return { agents: listCount === 1 ? [] : [{ id: "account_name", workspace }] }; @@ -131,7 +137,7 @@ describe("GatewayPersonalAgentProvisioner", () => { }); it("rejects agent ids outside the upstream canonical contract before RPC", async () => { - const rpc = createRpc((method) => { + const { rpc, call } = createRpc((method) => { throw new Error(`unexpected method: ${method}`); }); const provisioner = new GatewayPersonalAgentProvisioner({ @@ -145,11 +151,11 @@ describe("GatewayPersonalAgentProvisioner", () => { await expect(provisioner.provisionOrRefresh(invalid)).rejects.toThrow( "invalid personal agent id", ); - expect(rpc.call).not.toHaveBeenCalled(); + expect(call).not.toHaveBeenCalled(); }); it("rejects agent ids with non-canonical whitespace before RPC", async () => { - const rpc = createRpc((method) => { + const { rpc, call } = createRpc((method) => { throw new Error(`unexpected method: ${method}`); }); const provisioner = new GatewayPersonalAgentProvisioner({ @@ -163,6 +169,6 @@ describe("GatewayPersonalAgentProvisioner", () => { await expect(provisioner.provisionOrRefresh(invalid)).rejects.toThrow( "invalid personal agent id", ); - expect(rpc.call).not.toHaveBeenCalled(); + expect(call).not.toHaveBeenCalled(); }); }); From 8207c42990b1210070dba5a28fbe223d025cbabd Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 20 Jul 2026 22:49:22 +0900 Subject: [PATCH 4/4] fix(platformclaw): satisfy control plane lint --- .../src/gateway-admin-rpc-client.test.ts | 11 +++++++++-- .../src/personal-agent-provisioner.test.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts index ba365b8a374..0b81cd87be7 100644 --- a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts +++ b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it, vi } from "vitest"; import { GatewayAdminRpcError, HttpGatewayAdminRpcClient } from "./gateway-admin-rpc-client.js"; +function parseJsonRequestBody(body: BodyInit | null | undefined): Record { + if (typeof body !== "string") { + throw new Error("expected a JSON string request body"); + } + return JSON.parse(body) as Record; +} + function createClient( responder: (request: Record) => { status?: number; body: unknown }, ) { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)) as Record; + const request = parseJsonRequestBody(init?.body); const response = responder(request); return new Response(JSON.stringify(response.body), { status: response.status ?? 200, @@ -47,7 +54,7 @@ describe("HttpGatewayAdminRpcClient", () => { const [, init] = fetchImpl.mock.calls[0] ?? []; expect(init?.method).toBe("POST"); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-bearer-token"); - expect(JSON.parse(String(init?.body))).toMatchObject({ method: "agents.list", params: {} }); + expect(parseJsonRequestBody(init?.body)).toMatchObject({ method: "agents.list", params: {} }); }); it("returns bounded Gateway errors without exposing response bodies", async () => { diff --git a/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts b/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts index 2738ab4a9be..e06616212bb 100644 --- a/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts +++ b/packages/platformclaw-control-plane/src/personal-agent-provisioner.test.ts @@ -39,7 +39,7 @@ function request( }; } -function createRpc(handler: (method: string, params: unknown) => unknown | Promise): { +function createRpc(handler: (method: string, params: unknown) => unknown): { rpc: GatewayAdminRpc; call: Mock<(method: string, params: unknown) => Promise>; } {