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
7 changes: 5 additions & 2 deletions Dockerfile.jammy
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ COPY --from=openclaw-runtime --chown=1000:1000 /app/docs ./docs
COPY --from=openclaw-runtime --chown=1000:1000 /app/qa ./qa
COPY --from=platformclaw-control-assets --chown=1000:1000 /app/packages/platformclaw-control-plane ./packages/platformclaw-control-plane
COPY --from=platformclaw-control-assets --chown=1000:1000 /app/packages/platformclaw-coding-agent-contract ./packages/platformclaw-coding-agent-contract
COPY --from=platformclaw-control-assets --chown=1000:1000 /app/ui/dist ./ui/dist
COPY --chown=root:root docker/platformclaw-runtime/openclaw.initial.json /etc/platformclaw/openclaw.initial.json
COPY --chown=root:root docker/platformclaw-runtime/reconcile-managed-config.mjs /etc/platformclaw/reconcile-managed-config.mjs
COPY --chown=root:root docker/platformclaw-runtime/validate-managed-config.mjs /etc/platformclaw/validate-managed-config.mjs
Expand Down Expand Up @@ -185,9 +184,13 @@ RUN --mount=type=cache,id=platformclaw-jammy-apt-cache,target=/var/cache/apt,sha
chown -R node:node /home/node/.cache/ms-playwright; \
fi

# Shipped deployment bundles serve /app/ui/dist, including image-only upgrades.
# Keep that root pointing at the upstream UI so both servers share the pruned assets.
# Fail on ownership drift instead of hiding it with a recursive metadata rewrite.
RUN chmod 0755 /home/node && \
install -d -m 0755 -o node -g node /home/node/.config && \
install -d -m 0755 -o node -g node /home/node/.config /app/ui && \
ln -s ../dist/control-ui /app/ui/dist && \
chown -h node:node /app/ui/dist && \
install -d -m 0700 -o node -g node \
/home/node/.openclaw /home/node/.openclaw/workspace /home/node/.config/openclaw \
/var/lib/platformclaw/control /var/lib/platformclaw/workspaces && \
Expand Down
1 change: 0 additions & 1 deletion docker/platformclaw-runtime/Dockerfile.assets
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,3 @@ COPY --from=build /app/packages/platformclaw-control-plane/package.json /app/pac
COPY --from=build /app/packages/platformclaw-control-plane/dist /app/packages/platformclaw-control-plane/dist
COPY --from=build /app/packages/platformclaw-coding-agent-contract/package.json /app/packages/platformclaw-coding-agent-contract/package.json
COPY --from=build /app/packages/platformclaw-coding-agent-contract/dist /app/packages/platformclaw-coding-agent-contract/dist
COPY --from=build /app/dist/control-ui /app/ui/dist
4 changes: 2 additions & 2 deletions docker/platformclaw-runtime/SKILLHUB-NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
PlatformClaw builds its SkillHub server from `iflytek/skillhub` tag `v0.2.16`, commit
`6e133c006e492dc3f468d91b21960aff1d577150`, with the approved private metadata-edit
patch in `skillhub-edit/`. The image tag is
`platformclaw-skillhub-server:6e133c006e49099555e5c88b`. Source archive verification,
`platformclaw-skillhub-server:6e133c006e49cb1217a0ba75`. Source archive verification,
the reviewed patch, and license provenance are recorded in that directory.
The server runtime base and unchanged bundled dependencies are pinned below:

- server: `sha256:80a22a90c9855224a83663ad069cea80ec42ecb5e44c863b58be2fa2869f75b9`
- server JRE: `eclipse-temurin:21.0.11_10-jre-alpine-3.23@sha256:3f08b13888f595cc49edabea7250ba69499ba25602b267da591720769400e08c`, matching the original server's JRE layers
- scanner: `sha256:45275e86b05fdcec1811d5b47c47a87d0639b75be3197aa5ba3a0ce2977a7815`
- PostgreSQL 16.15 Alpine: `sha256:44c4ee9810eff91f7eab4d822642e01115b1a9eccce4bcbdde7604752d68eac6`
- Redis 7.4.10 Alpine: `sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2`
Expand Down
4 changes: 2 additions & 2 deletions docker/platformclaw-runtime/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ services:

skillhub-storage-init:
profiles: [skillhub]
image: ${PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE:-platformclaw-skillhub-server:6e133c006e49099555e5c88b}
image: ${PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE:-platformclaw-skillhub-server:6e133c006e49cb1217a0ba75}
pull_policy: never
user: "0:0"
network_mode: none
Expand Down Expand Up @@ -259,7 +259,7 @@ services:

skillhub-server:
profiles: [skillhub]
image: ${PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE:-platformclaw-skillhub-server:6e133c006e49099555e5c88b}
image: ${PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE:-platformclaw-skillhub-server:6e133c006e49cb1217a0ba75}
pull_policy: never
depends_on:
skillhub-postgres:
Expand Down
2 changes: 1 addition & 1 deletion docker/platformclaw-runtime/deployment.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ PLATFORMCLAW_SANDBOX_IMAGE=platformclaw-sandbox:<sha12>
# The launcher appends this heap option to existing NODE_OPTIONS.
PLATFORMCLAW_GATEWAY_MAX_OLD_SPACE_SIZE=8192
PLATFORMCLAW_SKILL_HUB_ENABLED=true
PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE=platformclaw-skillhub-server:6e133c006e49099555e5c88b
PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE=platformclaw-skillhub-server:6e133c006e49cb1217a0ba75
PLATFORMCLAW_SKILL_HUB_SCANNER_IMAGE=platformclaw-skillhub-scanner:6e133c006e49
PLATFORMCLAW_SKILL_HUB_POSTGRES_IMAGE=platformclaw-skillhub-postgres:44c4ee9810ef
PLATFORMCLAW_SKILL_HUB_REDIS_IMAGE=platformclaw-skillhub-redis:e7723ff73d96
Expand Down
13 changes: 10 additions & 3 deletions docker/platformclaw-runtime/platformclaw-deploy
Original file line number Diff line number Diff line change
Expand Up @@ -809,16 +809,23 @@ reconcile_skillhub_environment() {
}

skillhub_server_upgrade_available() {
[[ "$(read_env_value PLATFORMCLAW_SKILL_HUB_ENABLED)" == "true" &&
"$(read_env_value PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE)" == "platformclaw-skillhub-server:6e133c006e49" ]] &&
local current
local desired
[[ "$(read_env_value PLATFORMCLAW_SKILL_HUB_ENABLED)" == "true" ]] || return 1
current="$(read_env_value PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE)"
desired="$(read_env_value PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE "$script_dir/deployment.env.example")"
# Bundled tags are the source SHA plus an optional patch hash. Advance those
# only inside the snapshotted update; operator-selected images stay untouched.
[[ "$current" != "$desired" && "$current" == "${desired%:*}:"* &&
"${current##*:}" =~ ^[0-9a-f]{12}([0-9a-f]{12})?$ ]] &&
skillhub_bundle_available
}

upgrade_bundled_skillhub_server() {
skillhub_server_upgrade_available || return 0
set_env_value PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE \
"$(read_env_value PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE "$script_dir/deployment.env.example")"
echo "Upgraded the bundled SkillHub server with published metadata editing."
echo "Upgraded the bundled SkillHub server to this deployment's image."
}

set_env_value() {
Expand Down
24 changes: 22 additions & 2 deletions docker/platformclaw-runtime/skillhub-edit/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,27 @@ COPY overlay/ /source/
WORKDIR /source/server
RUN --mount=type=cache,target=/root/.m2 mvn -B -pl skillhub-app -am package -DskipTests

FROM ghcr.io/iflytek/skillhub-server@sha256:80a22a90c9855224a83663ad069cea80ec42ecb5e44c863b58be2fa2869f75b9 AS runtime
# This is the exact JRE base beneath the pinned upstream server on both Linux
# architectures. Starting there avoids retaining its original and chowned JARs.
FROM eclipse-temurin:21.0.11_10-jre-alpine-3.23@sha256:3f08b13888f595cc49edabea7250ba69499ba25602b267da591720769400e08c AS runtime
RUN addgroup -S -g 101 app && adduser -S -u 100 app -G app && \
mkdir -p /app /var/lib/skillhub/storage && \
chown app:app /app /var/lib/skillhub/storage
WORKDIR /app
COPY --from=build --chown=app:app /source/server/skillhub-app/target/skillhub-app-0.1.0.jar /app/app.jar
LABEL org.opencontainers.image.base.name="iflytek/skillhub@6e133c006e492dc3f468d91b21960aff1d577150" \
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=12 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
LABEL org.opencontainers.image.base.name="docker.io/library/eclipse-temurin:21.0.11_10-jre-alpine-3.23" \
org.opencontainers.image.base.digest="sha256:3f08b13888f595cc49edabea7250ba69499ba25602b267da591720769400e08c" \
org.opencontainers.image.created="2026-08-07T05:49:13.659Z" \
org.opencontainers.image.description="Self-hosted, open-source agent skill registry for enterprises. Publish & version skill packages, govern with RBAC and audit logs, deploy on-premise with Docker or Kubernetes." \
org.opencontainers.image.source="https://github.com/iflytek/skillhub" \
org.opencontainers.image.revision="6e133c006e492dc3f468d91b21960aff1d577150" \
org.opencontainers.image.title="skillhub" \
org.opencontainers.image.url="https://github.com/iflytek/skillhub" \
org.opencontainers.image.version="0.2.16" \
org.opencontainers.image.licenses="Apache-2.0" \
ai.platformclaw.skillhub.patch="metadata-edit-v1"
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import zipfile
from pathlib import Path

IMAGE = sys.argv[1] if len(sys.argv) > 1 else "platformclaw-skillhub-server:6e133c006e49099555e5c88b"
IMAGE = sys.argv[1] if len(sys.argv) > 1 else "platformclaw-skillhub-server:6e133c006e49cb1217a0ba75"
PREFIX = "pc-skill-edit-registry-" + secrets.token_hex(4)
containers = []
volumes = []
Expand Down
124 changes: 124 additions & 0 deletions extensions/browser/src/browser/server-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { EventEmitter } from "node:events";
import http from "node:http";
import { setTimeout as delay } from "node:timers/promises";
import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
import type { Express, NextFunction, Request, Response } from "express";
import express from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -48,6 +50,128 @@ afterEach(async () => {
});

describe("installBrowserCommonMiddleware", () => {
it.each([
["gzip", gzipSync],
["deflate", deflateSync],
["br", brotliCompressSync],
] as const)(
"parses allowed %s JSON and enforces the inflated body limit",
async (encoding, compress) => {
const { url, getRouteCalls } = await startMiddlewareTestServer();
for (const oversized of [false, true]) {
const body = { value: oversized ? "x".repeat(1024 * 1024) : "compressed request" };
const response = await fetch(`${url}/mutate`, {
method: "POST",
headers: {
authorization: "Bearer test-token",
"content-type": "application/json",
"content-encoding": encoding,
},
body: new Uint8Array(compress(JSON.stringify(body))),
});
expect(response.status).toBe(oversized ? 413 : 200);
if (oversized) {
await response.text();
} else {
expect(await response.json()).toEqual({ body });
}
}
expect(getRouteCalls()).toBe(1);
},
);

it("finishes a compressed request whose decoder completes before the HTTP body", async () => {
const app = express();
installBrowserCommonMiddleware(app);
installBrowserAuthMiddleware(app, { token: "test-token" });
const first = Buffer.concat([deflateSync('{"ok":true}'), Buffer.from([0])]);
const trailing = Buffer.alloc(1024);
let client: http.ClientRequest | undefined;
app.post("/mutate", (req, res) => {
// Exercise downstream request-completion work after parsing. The decoder
// must detach and drain remaining HTTP bytes instead of leaving it hung.
req.once("end", () => res.json({ body: req.body }));
client?.end(trailing);
});
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => {
server?.once("listening", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected TCP test server address");
}
try {
const result = await new Promise<{ status: number | undefined; body: string }>(
(resolve, reject) => {
client = http.request(
{
host: "127.0.0.1",
port: address.port,
path: "/mutate",
method: "POST",
agent: false,
headers: {
authorization: "Bearer test-token",
"content-type": "application/json",
"content-encoding": "deflate",
"content-length": first.length + trailing.length,
},
},
(response) => {
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk: string) => {
body += chunk;
});
response.on("error", reject);
response.on("end", () => resolve({ status: response.statusCode, body }));
},
);
client.on("error", reject);
client.setTimeout(2000, () =>
client?.destroy(new Error("compressed HTTP request did not finish")),
);
client.write(first);
},
);
expect(result).toEqual({ status: 200, body: '{"body":{"ok":true}}' });
} finally {
client?.destroy();
}
});

it("keeps serving after trailing bytes reach a finished gzip decoder", async () => {
const { url, getRouteCalls } = await startMiddlewareTestServer();
const encoded = Buffer.concat([
gzipSync('{"ok":true}'),
Buffer.from([0]),
Buffer.alloc(100 * 1024, 0x58),
]);
const response = await fetch(`${url}/mutate`, {
method: "POST",
headers: {
authorization: "Bearer test-token",
"content-type": "application/json",
"content-encoding": "gzip",
},
body: new Uint8Array(encoded),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ body: { ok: true } });
// GHSA-27r3-jxmh-p828 raises a late, unhandled decoder error after the
// response. Keep that event observable before proving the server still works.
await delay(200);
const next = await fetch(`${url}/mutate`, {
method: "POST",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
body: '{"stillRunning":true}',
});
expect(next.status).toBe(200);
expect(await next.json()).toEqual({ body: { stillRunning: true } });
expect(getRouteCalls()).toBe(2);
});

it("shadows native request signals with the browser response-lifetime signal", () => {
const middleware: Middleware[] = [];
const app = {
Expand Down
106 changes: 106 additions & 0 deletions extensions/canvas/src/a2ui-zod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { v0_8 } from "@a2ui/lit";
import { describe, expect, it } from "vitest";

// Resolve the public core entry through its owning renderer dependency. Core is
// intentionally not a second direct Canvas dependency.
const rendererRequire = createRequire(createRequire(import.meta.url).resolve("@a2ui/lit"));
const core = await import(pathToFileURL(rendererRequire.resolve("@a2ui/web_core/v0_9")).href);

describe("Canvas A2UI Zod contract", () => {
it("processes v0.8 surfaces, recursive data, rendering, and deletion", () => {
const processor = new v0_8.Data.A2uiMessageProcessor();
processor.processMessages([
{
surfaceUpdate: {
surfaceId: "test",
components: [{ id: "root", component: { Text: { text: { path: "/greeting" } } } }],
},
},
{
dataModelUpdate: {
surfaceId: "test",
contents: [{ key: "greeting", valueString: "Hello" }],
},
},
{ beginRendering: { surfaceId: "test", root: "root" } },
]);
expect(processor.getSurfaces().get("test")?.rootComponentId).toBe("root");
expect(processor.getSurfaces().get("test")?.dataModel.get("greeting")).toBe("Hello");
expect(() => processor.processMessages([{}])).toThrow();
expect(() =>
processor.processMessages(JSON.parse('[{"beginRendering":{"surfaceId":"test","root":7}}]')),
).toThrow();
processor.processMessages([{ deleteSurface: { surfaceId: "test" } }]);
expect(processor.getSurfaces().size).toBe(0);
});

it("keeps v0.9 record values and malformed client/server messages validated", () => {
const action = {
name: "submit",
surfaceId: "test",
sourceComponentId: "button",
timestamp: "2026-10-24T00:00:00Z",
context: { label: "value", nested: { count: 2 } },
};
expect(core.A2uiClientActionSchema.parse(action)).toEqual(action);
expect(
core.A2uiClientDataModelSchema.parse({ version: "v0.9", surfaces: { test: { x: 1 } } }),
).toEqual({ version: "v0.9", surfaces: { test: { x: 1 } } });
expect(
core.A2uiClientDataModelSchema.safeParse({ version: "v0.9", surfaces: { test: 7 } }).success,
).toBe(false);
expect(
core.A2uiMessageSchema.safeParse({ version: "v0.9", createSurface: { surfaceId: 7 } })
.success,
).toBe(false);
expect(
core.A2uiMessageSchema.safeParse({
version: "v0.9",
updateComponents: {
surfaceId: "test",
components: [],
},
}).success,
).toBe(false);
});

it("scrapes v0.9 dynamic/action/child bindings and emits real inline capability schemas", () => {
const text = core.BASIC_COMPONENTS.find(
(component: { name: string }) => component.name === "Text",
);
const row = core.BASIC_COMPONENTS.find(
(component: { name: string }) => component.name === "Row",
);
const button = core.BASIC_COMPONENTS.find(
(component: { name: string }) => component.name === "Button",
);
expect(core.scrapeSchemaBehavior(text.schema).shape.text.type).toBe("DYNAMIC");
expect(core.scrapeSchemaBehavior(row.schema).shape.children.type).toBe("STRUCTURAL");
expect(core.scrapeSchemaBehavior(button.schema).shape.action.type).toBe("ACTION");
const catalog = new core.Catalog("synthetic", core.BASIC_COMPONENTS, core.BASIC_FUNCTIONS);
const processor = new core.MessageProcessor([catalog]);
const capabilities = processor.getClientCapabilities({ includeInlineCatalogs: true });
const inline = capabilities["v0.9"].inlineCatalogs[0];
expect(inline.components.Text.allOf[1].properties.text).toBeDefined();
expect(inline.components.Text.allOf[1].required).toContain("text");
expect(inline.functions.length).toBeGreaterThan(0);
expect(
inline.functions.some(
(fn: { parameters: { properties?: object } }) =>
Object.keys(fn.parameters.properties ?? {}).length > 0,
),
).toBe(true);
processor.processMessages([
{
version: "v0.9",
createSurface: {
surfaceId: "test",
catalogId: "synthetic",
},
},
]);
processor.processMessages([{ version: "v0.9", deleteSurface: { surfaceId: "test" } }]);
});
});
Loading