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
4 changes: 3 additions & 1 deletion PLATFORMCLAW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions docs/platformclaw/control-plane-phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/platformclaw/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/platformclaw/employee-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>(
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<typeof fetch>(
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");
});
});
61 changes: 61 additions & 0 deletions packages/platformclaw-control-plane/src/browser-auth-runtime.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
Loading