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
5 changes: 3 additions & 2 deletions PLATFORMCLAW.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ Bring upstream changes from `upstream/main` into a sync branch, validate them th
Production Docker deployment migration and validation remains in progress.
Control-plane Phase 1 implementation is now also in progress for enterprise
identity, server sessions, idempotent agent provisioning, and Web and Knox
ingress authorization. The contract, in-memory store, and approved SQLite v1
store slices are complete; runtime adapters have not started. See
ingress authorization. The contract, in-memory store, approved SQLite v1 store,
employee auth, personal-agent provisioner, and Web Gateway policy proxy slices
are complete. The deployable ingress host remains pending. See
`docs/platformclaw/control-plane-phase-1.md` and
`docs/platformclaw/decisions.md`.

Expand Down
33 changes: 25 additions & 8 deletions docs/platformclaw/control-plane-phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ After Phase 1:

## Implementation status

Slices 1 through 4 are implemented in `packages/platformclaw-control-plane`. They
Slices 1 through 5 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 employee browser-auth runtime now assembles the
browser-auth HTTP boundary. The Web Gateway policy proxy resolves the opaque
browser session for every request, pins it to the active personal agent binding,
and filters requests, responses, and events through fail-closed method and
parameter allowlists. 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
Expand All @@ -48,19 +51,20 @@ 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
disablement, Knox DM ownership checks, restart persistence, administrator
bootstrap, managed group/part permissions, and audit events.
bootstrap, managed group/part permissions, cross-agent Gateway denial,
response and event filtering, revoked-session rejection, and audit events.

The LDAP-compatible adapter calls the existing employee-auth HTTP service. The
HTTP boundary exposes login, logout, and current-session handlers, but still
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, 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.
Knox transport code, or the deployable HTTP/WebSocket listener that hosts the
implemented Gateway policy 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 Expand Up @@ -190,11 +194,24 @@ The proxy must:
- ignore browser-supplied agent IDs when the operation is user-scoped;
- construct or validate session keys against the owned agent;
- expose an explicit allowlist of user-facing Gateway methods;
- expose an explicit per-method parameter allowlist so new upstream operator
parameters fail closed until reviewed;
- deny configuration, channel administration, node administration, Gateway
lifecycle, and other operator-only methods;
- filter response rows and asynchronous events by the owned agent and session;
- record an audit event for denied cross-agent attempts.

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.
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
an external channel through the control process credential.
Browsers also cannot supply abort run IDs; cancellation is resolved from their
owned session key.

UI hiding is not authorization. The proxy enforces the rule even when a client
sends a hand-crafted Gateway frame.

Expand Down
31 changes: 31 additions & 0 deletions docs/platformclaw/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,37 @@ 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.

### PC-116 Proxy browser Gateway access through fail-closed policy

Browsers never receive an OpenClaw operator credential and never connect to the
private Gateway directly. `platformclaw-control` resolves the opaque browser
session on every request and event, requires its active personal agent binding,
and uses its own private Gateway client behind the control-plane package.

The proxy maintains explicit method and per-method parameter allowlists. New
upstream methods or parameters are denied until reviewed, preventing an
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.
Events without an owned session key, including raw agent-run events, are
dropped; session-scoped chat and tool events are the browser streaming surface.

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
task, approval replay is not exposed, and operator command catalogs are not
advertised. Browser commands require a later least-privilege Gateway identity
or a separately approved command policy; they must not inherit the control
process credential.
Model selection is limited to the configured catalog and cannot carry a raw
model/auth-profile override. Browser session patches cannot archive sessions
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.

## Open operational decisions

No remaining decision blocks the SQLite v1 store. Deployment work still needs
Expand Down
8 changes: 5 additions & 3 deletions docs/platformclaw/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ and Knox direct or group conversations:
- [Employee authentication](/platformclaw/employee-auth)
- [Private downstream CI](/platformclaw/private-downstream-ci)

The first three implementation slices are in the private
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, and browser-auth HTTP boundary. The
deployable ingress process and Gateway proxy remain later work.
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.

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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function responseHarness() {
setHeader: (name: string, value: string | string[]) => headers.set(name, value),
getHeader: (name: string) => headers.get(name),
end: (value?: unknown) => {
body = String(value ?? "");
body = typeof value === "string" ? value : "";
},
} as unknown as ServerResponse;
return { res, headers, body: () => body };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
type JsonObject = Record<string, unknown>;

function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

export function projectBrowserAgentSummary(value: unknown): JsonObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const source = value as JsonObject;
const id = optionalString(source.id);
if (!id) {
return null;
}
const projected: JsonObject = { id };
const name = optionalString(source.name);
if (name) {
projected.name = name;
}
if (source.identity && typeof source.identity === "object" && !Array.isArray(source.identity)) {
const identitySource = source.identity as JsonObject;
const identity: JsonObject = {};
for (const key of ["name", "theme", "emoji", "avatarUrl"]) {
const field = optionalString(identitySource[key]);
if (field) {
identity[key] = field;
}
}
if (Object.keys(identity).length > 0) {
projected.identity = identity;
}
}
if (source.model && typeof source.model === "object" && !Array.isArray(source.model)) {
const modelSource = source.model as JsonObject;
const primary = optionalString(modelSource.primary);
const fallbacks = Array.isArray(modelSource.fallbacks)
? modelSource.fallbacks.map(optionalString).filter((entry) => entry !== undefined)
: undefined;
if (primary || fallbacks) {
projected.model = { ...(primary ? { primary } : {}), ...(fallbacks ? { fallbacks } : {}) };
}
}
if (Array.isArray(source.thinkingLevels)) {
projected.thinkingLevels = source.thinkingLevels.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const level = entry as JsonObject;
const levelId = optionalString(level.id);
const label = optionalString(level.label);
return levelId && label ? [{ id: levelId, label }] : [];
});
}
if (Array.isArray(source.thinkingOptions)) {
projected.thinkingOptions = source.thinkingOptions
.map(optionalString)
.filter((entry) => entry !== undefined);
}
const thinkingDefault = optionalString(source.thinkingDefault);
if (thinkingDefault) {
projected.thinkingDefault = thinkingDefault;
}
return projected;
}

export function projectBrowserModelChoice(value: unknown): JsonObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const source = value as JsonObject;
const id = optionalString(source.id);
const name = optionalString(source.name);
const provider = optionalString(source.provider);
if (!id || !name || !provider) {
return null;
}
const projected: JsonObject = { id, name, provider };
for (const key of ["alias"] as const) {
const field = optionalString(source[key]);
if (field) {
projected[key] = field;
}
}
for (const key of ["available", "reasoning"] as const) {
if (typeof source[key] === "boolean") {
projected[key] = source[key];
}
}
if (typeof source.contextWindow === "number") {
projected.contextWindow = source.contextWindow;
}
if (Array.isArray(source.input)) {
projected.input = source.input.filter((entry): entry is string => typeof entry === "string");
}
return projected;
}
Loading