fix(beam): open uploads at canonical catalog URLs (#120927)

* fix(beam): emit canonical catalog URLs

* fix(beam): type readonly runtime config

* fix(gateway): keep minimal metadata startup lazy

* chore(plugin-sdk): refresh API baseline
This commit is contained in:
Peter Steinberger
2026-08-09 02:09:01 -07:00
committed by GitHub
parent 0e56fce87b
commit c708b41af4
11 changed files with 288 additions and 43 deletions
@@ -125,7 +125,7 @@ ccd790bc28ce44ca8a2df9a643103555dc650e9778a969c2f6fdeb1f0e1fba1b module/secret-
f473090754cb12a5c4a2d39d693315700b25cb053c825c30b3a889a555f10f16 module/secret-ref-runtime
3ac69236c14ac1861aff09f267c523b8f34a63b9bbca085d326c21056604ede0 module/security-runtime
15dd73d9240c1bd5d863bde45f241f7f71fbb00c743566fd9803bef709ec29aa module/session-catalog
b23f195a5b07b7478cda5f5e39286a8fe0e1ac6dba1dcc2117aa368b7fdb660b module/session-discussion
074cf714c2958f4e5dc7d1502817f9c56ac1a64f916e5e5bd99678be2f662e10 module/session-discussion
b43997ba4064ef3577a9d8efa178fff4dadb6645a0b6a52a79616360d66840c2 module/session-store-runtime
5af106014a62ef2802236dbc5484d55bfffc2cf7105fd184a4d3b80ac0ab7727 module/setup
b64a0abce77e276b739f8b3979ba60b9b6407955aaedd7803c7d7b9936250574 module/setup-runtime
+1 -1
View File
@@ -90,7 +90,7 @@ A successful upload returns the stable Beam id and a relative Control UI URL:
{
"ok": true,
"beamId": "0123456789abcdef0123456789abcdef",
"url": "/chat?session=catalog%3Abeam%3Agateway%3A0123456789abcdef0123456789abcdef"
"url": "/chat/main?catalog=beam&host=gateway&thread=<beamId>"
}
```
+10 -1
View File
@@ -1,3 +1,5 @@
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createBeamRequestHandler } from "./src/http.js";
import { createBeamMirrorService } from "./src/mirror.js";
@@ -17,7 +19,14 @@ export default definePluginEntry({
match: "exact",
handler: createBeamRequestHandler({
store,
resolveControlUiBasePath: () => api.runtime.config.current().gateway?.controlUi?.basePath,
resolveControlUiTarget: () => {
const config = api.runtime.config.current();
return {
// The resolver only reads; the plugin runtime exposes a DeepReadonly view.
agentId: resolveDefaultAgentId(config as OpenClawConfig),
basePath: config.gateway?.controlUi?.basePath,
};
},
}),
});
api.registerService(createBeamMirrorService({ runtime: api.runtime }));
+22 -6
View File
@@ -24,6 +24,7 @@ function sampleUpload(overrides: Record<string, unknown> = {}): BeamUploadFixtur
}
const writeClient = () => ({ clientIp: "127.0.0.1", scopes: ["operator.write"] });
const mainControlUiTarget = () => ({ agentId: "main" });
function memoryStore(): BeamStore & { values: Map<string, BeamStoredSession> } {
const values = new Map<string, BeamStoredSession>();
@@ -138,7 +139,12 @@ describe("Beam receiver", () => {
const store = memoryStore();
let now = 100;
const endpoint = await serve(
createBeamRequestHandler({ store, now: () => now, resolveClient: writeClient }),
createBeamRequestHandler({
store,
now: () => now,
resolveClient: writeClient,
resolveControlUiTarget: mainControlUiTarget,
}),
);
const first = await fetch(endpoint, {
method: "POST",
@@ -149,7 +155,7 @@ describe("Beam receiver", () => {
expect(await first.json()).toEqual({
ok: true,
beamId: "0123456789abcdef0123456789abcdef",
url: "/chat?session=catalog%3Abeam%3Agateway%3A0123456789abcdef0123456789abcdef",
url: "/chat/main?catalog=beam&host=gateway&thread=0123456789abcdef0123456789abcdef",
});
expect(store.values.get("0123456789abcdef0123456789abcdef")).toMatchObject({
createdAt: 100,
@@ -169,13 +175,16 @@ describe("Beam receiver", () => {
});
});
it("returns a catalog URL beneath the configured Control UI base path", async () => {
it("returns a catalog URL beneath a nested base path for the configured default agent", async () => {
const store = memoryStore();
const endpoint = await serve(
createBeamRequestHandler({
store,
resolveClient: writeClient,
resolveControlUiBasePath: () => "/openclaw/",
resolveControlUiTarget: () => ({
agentId: "research",
basePath: "/admin/openclaw/",
}),
}),
);
const response = await fetch(endpoint, {
@@ -188,7 +197,7 @@ describe("Beam receiver", () => {
expect(await response.json()).toEqual({
ok: true,
beamId: "0123456789abcdef0123456789abcdef",
url: "/openclaw/chat?session=catalog%3Abeam%3Agateway%3A0123456789abcdef0123456789abcdef",
url: "/admin/openclaw/chat/research?catalog=beam&host=gateway&thread=0123456789abcdef0123456789abcdef",
});
});
@@ -198,6 +207,7 @@ describe("Beam receiver", () => {
createBeamRequestHandler({
store,
resolveClient: () => ({ clientIp: "127.0.0.1", scopes: ["operator.read"] }),
resolveControlUiTarget: mainControlUiTarget,
}),
);
const response = await fetch(endpoint, {
@@ -213,7 +223,13 @@ describe("Beam receiver", () => {
it("rejects method, media type, malformed JSON, and oversized bodies", async () => {
const store = memoryStore();
const endpoint = await serve(createBeamRequestHandler({ store, resolveClient: writeClient }));
const endpoint = await serve(
createBeamRequestHandler({
store,
resolveClient: writeClient,
resolveControlUiTarget: mainControlUiTarget,
}),
);
expect((await fetch(endpoint)).status).toBe(405);
expect(
(
+9 -19
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { buildControlUiCatalogSessionUrl } from "openclaw/plugin-sdk/session-catalog-runtime";
import {
beginWebhookRequestPipelineOrReject,
createFixedWindowRateLimiter,
@@ -38,28 +39,11 @@ function canPublish(scopes: readonly string[]): boolean {
return scopes.includes("operator.write") || scopes.includes("operator.admin");
}
function normalizeControlUiBasePath(value: unknown): string {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed || trimmed === "/") {
return "";
}
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withLeadingSlash.replace(/\/+$/, "");
}
function catalogSessionUrl(beamId: string, basePath: unknown): string {
const sessionKey = `catalog:beam:${BEAM_HOST_ID}:${beamId}`;
return `${normalizeControlUiBasePath(basePath)}/chat?session=${encodeURIComponent(sessionKey)}`;
}
export function createBeamRequestHandler(params: {
store: BeamStore;
now?: () => number;
resolveClient?: (req: IncomingMessage) => BeamRequestClient;
resolveControlUiBasePath?: () => unknown;
resolveControlUiTarget: () => { agentId: string; basePath?: string };
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
const rateLimiter = createFixedWindowRateLimiter({
windowMs: 60_000,
@@ -123,7 +107,13 @@ export function createBeamRequestHandler(params: {
sendJson(res, 200, {
ok: true,
beamId: parsed.value.beamId,
url: catalogSessionUrl(parsed.value.beamId, params.resolveControlUiBasePath?.()),
url: buildControlUiCatalogSessionUrl({
namespace: "chat",
...params.resolveControlUiTarget(),
catalog: "beam",
host: BEAM_HOST_ID,
thread: parsed.value.beamId,
}),
});
return true;
} finally {
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { buildControlUiCatalogSessionUrl } from "./index.js";
describe("buildControlUiCatalogSessionUrl", () => {
it.each([
{
label: "root base path",
agentId: "main",
basePath: undefined,
expected: "/chat/main?catalog=beam&host=gateway&thread=beam-1",
},
{
label: "nested base path and non-main agent",
agentId: "research",
basePath: "/admin/openclaw/",
expected: "/admin/openclaw/chat/research?catalog=beam&host=gateway&thread=beam-1",
},
])("builds a canonical URL for $label", ({ agentId, basePath, expected }) => {
expect(
buildControlUiCatalogSessionUrl({
namespace: "chat",
agentId,
basePath,
catalog: "beam",
host: "gateway",
thread: "beam-1",
}),
).toBe(expected);
});
it("encodes reserved query characters", () => {
expect(
buildControlUiCatalogSessionUrl({
namespace: "dashboard",
agentId: "main",
catalog: "claude & codex",
host: "gateway:local/primary",
thread: "thread?one=1&two=2",
}),
).toBe(
"/dashboard/main?catalog=claude+%26+codex&host=gateway%3Alocal%2Fprimary&thread=thread%3Fone%3D1%26two%3D2",
);
});
it.each(["agentId", "catalog", "host", "thread"] as const)(
"returns null when required $field is empty",
(field) => {
expect(
buildControlUiCatalogSessionUrl({
namespace: "chat",
agentId: "main",
catalog: "beam",
host: "gateway",
thread: "beam-1",
[field]: " ",
}),
).toBeNull();
},
);
});
@@ -11,6 +11,15 @@ type BuildControlUiSessionPathParams = {
shortIdLength?: number;
};
type BuildControlUiCatalogSessionUrlParams = {
namespace: ControlUiSessionNamespace;
agentId: string;
basePath?: string;
catalog: string;
host: string;
thread: string;
};
export const SESSION_UUID_SUFFIX_RE =
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/iu;
export const SHORT_SESSION_ID_RE = /^[0-9a-f]{8,32}$/iu;
@@ -151,3 +160,21 @@ export function buildControlUiSessionPath(params: BuildControlUiSessionPathParam
}
return `${namespace}/${encodedAgentId}/${segments.map(encodePathSegment).join("/")}`;
}
export function buildControlUiCatalogSessionUrl(
params: BuildControlUiCatalogSessionUrlParams,
): string | null {
const catalog = optionalString(params.catalog);
const host = optionalString(params.host);
const thread = optionalString(params.thread);
const path = buildControlUiSessionPath({
namespace: params.namespace,
sessionKey: DEFAULT_MAIN_KEY,
fallbackAgentId: params.agentId,
basePath: params.basePath,
});
if (!path || !catalog || !host || !thread) {
return null;
}
return `${path}?${new URLSearchParams({ catalog, host, thread }).toString()}`;
}
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
const mocks = vi.hoisted(() => ({
createRuntime: vi.fn(),
fail: vi.fn(),
invalidate: vi.fn(),
read: vi.fn(),
readStartup: vi.fn(),
refreshPreparedModels: vi.fn(),
refresh: vi.fn(),
registerAuthListener: vi.fn(),
registerModelListener: vi.fn(),
registerSkillsListener: vi.fn(),
unregisterAuthListener: vi.fn(),
unregisterModelListener: vi.fn(),
unregisterSkillsListener: vi.fn(),
}));
vi.mock("./server-methods/chat-metadata-runtime.js", () => ({
createGatewayChatMetadataRuntime: mocks.createRuntime,
}));
vi.mock("../agents/auth-profiles/runtime-snapshots.js", () => ({
registerRuntimeAuthProfileStoreMutationListener: mocks.registerAuthListener,
}));
vi.mock("../agents/prepared-model-runtime.js", () => ({
refreshPreparedModelRuntimeSnapshots: mocks.refreshPreparedModels,
registerPreparedModelRuntimePublicationListener: mocks.registerModelListener,
}));
vi.mock("../skills/runtime/refresh.js", () => ({
registerSkillsChangeListener: mocks.registerSkillsListener,
}));
const { createGatewayChatMetadataLifecycle } = await import("./server-chat-metadata-lifecycle.js");
const config = {} as OpenClawConfig;
const context = {} as GatewayRequestContext;
beforeEach(() => {
for (const mock of Object.values(mocks)) {
mock.mockReset();
}
mocks.createRuntime.mockReturnValue({
fail: mocks.fail,
invalidate: mocks.invalidate,
read: mocks.read,
readStartup: mocks.readStartup,
refresh: mocks.refresh,
});
mocks.refresh.mockResolvedValue(undefined);
mocks.registerAuthListener.mockReturnValue(mocks.unregisterAuthListener);
mocks.registerModelListener.mockReturnValue(mocks.unregisterModelListener);
mocks.registerSkillsListener.mockReturnValue(mocks.unregisterSkillsListener);
});
function createLifecycle(minimalTestGateway: boolean, warn = vi.fn()) {
return {
lifecycle: createGatewayChatMetadataLifecycle({
getConfig: () => config,
minimalTestGateway,
log: { warn } as never,
}),
warn,
};
}
describe("gateway chat metadata lifecycle", () => {
it("keeps minimal Gateway attachment lazy and sidecar-free", async () => {
const { lifecycle: pendingLifecycle } = createLifecycle(true);
const lifecycle = await pendingLifecycle;
const sidecars: Array<{ stop: () => Promise<void> }> = [];
await lifecycle.attachContext(context, sidecars);
expect(mocks.createRuntime).toHaveBeenCalledWith(
expect.objectContaining({
beforeRefresh: expect.any(Function),
refreshOnRead: true,
}),
);
expect(mocks.refresh).not.toHaveBeenCalled();
expect(mocks.registerAuthListener).not.toHaveBeenCalled();
expect(mocks.registerModelListener).not.toHaveBeenCalled();
expect(mocks.registerSkillsListener).not.toHaveBeenCalled();
expect(sidecars).toEqual([]);
});
it("registers full Gateway listeners and awaits one catch-up refresh", async () => {
let releaseRefresh!: () => void;
mocks.refresh.mockReturnValueOnce(
new Promise<void>((resolve) => {
releaseRefresh = resolve;
}),
);
const { lifecycle: pendingLifecycle } = createLifecycle(false);
const lifecycle = await pendingLifecycle;
const sidecars: Array<{ stop: () => Promise<void> }> = [];
let attached = false;
const attach = lifecycle.attachContext(context, sidecars).then(() => {
attached = true;
});
await vi.waitFor(() => expect(mocks.refresh).toHaveBeenCalledOnce());
expect(mocks.registerAuthListener).toHaveBeenCalledOnce();
expect(mocks.registerModelListener).toHaveBeenCalledOnce();
expect(mocks.registerSkillsListener).toHaveBeenCalledOnce();
expect(sidecars).toHaveLength(1);
expect(attached).toBe(false);
releaseRefresh();
await attach;
expect(attached).toBe(true);
expect(mocks.refresh).toHaveBeenCalledOnce();
});
it("logs catch-up failures without rejecting full Gateway startup", async () => {
mocks.refresh.mockRejectedValueOnce(new Error("metadata unavailable"));
const { lifecycle: pendingLifecycle, warn } = createLifecycle(false);
const lifecycle = await pendingLifecycle;
await expect(lifecycle.attachContext(context, [])).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"chat metadata catch-up refresh failed: Error: metadata unavailable",
);
});
});
@@ -94,14 +94,14 @@ export async function createGatewayChatMetadataLifecycle(params: {
const sidecar = await registerRefreshListeners();
if (sidecar) {
sidecars.push(sidecar);
// Auth/model snapshots published before listener registration are otherwise never
// observed; one awaited catch-up refresh reconciles facts (revision-aware, no-op when
// fresh) so post-attach reads cannot serve a pre-publication generation. Failures are
// logged, not thrown: startup must not die on a metadata build error.
await runtime.refresh().catch((error: unknown) => {
params.log.warn(`chat metadata catch-up refresh failed: ${String(error)}`);
});
}
// Auth/model snapshots published before listener registration are otherwise never
// observed; one awaited catch-up refresh reconciles facts (revision-aware, no-op when
// fresh) so post-attach reads cannot serve a pre-publication generation. Failures are
// logged, not thrown: startup must not die on a metadata build error.
await runtime.refresh().catch((error: unknown) => {
params.log.warn(`chat metadata catch-up refresh failed: ${String(error)}`);
});
},
read: runtime.read,
readStartup: runtime.readStartup,
+2 -1
View File
@@ -1,4 +1,5 @@
// Runtime SDK subpath for read-only access to the active registered session catalogs.
// Private runtime helpers for registered session catalogs.
export { buildControlUiCatalogSessionUrl } from "../../packages/session-url-contract/src/index.js";
export {
listActiveSessionCatalogs,
type ActiveSessionCatalog,
+20 -7
View File
@@ -1,3 +1,4 @@
import { buildControlUiCatalogSessionUrl } from "@openclaw/session-url-contract";
import { describe, expect, it } from "vitest";
import type { ApplicationContext } from "../../app/context.ts";
import { buildCatalogSessionKey } from "./catalog-key.ts";
@@ -44,21 +45,33 @@ describe("sessionNavigationTarget", () => {
});
it("requires the destination face while preserving catalog identity", () => {
const catalogKey = {
catalogId: "claude",
hostId: "gateway:local",
threadId: "thread-1",
};
const target = sessionNavigationTarget({
face: "dashboard",
sessionKey: buildCatalogSessionKey({
catalogId: "claude",
hostId: "gateway:local",
threadId: "thread-1",
}),
sessionKey: buildCatalogSessionKey(catalogKey),
fallbackAgentId: "research",
basePath: "/admin/openclaw/",
mainKey: "workspace",
});
const canonicalHref = buildControlUiCatalogSessionUrl({
namespace: "dashboard",
agentId: "research",
basePath: "/admin/openclaw/",
catalog: catalogKey.catalogId,
host: catalogKey.hostId,
thread: catalogKey.threadId,
});
expect(canonicalHref).not.toBeNull();
expect(target.href).toBe(canonicalHref);
expect(target).toEqual({
href: "/dashboard/research?catalog=claude&host=gateway%3Alocal&thread=thread-1",
href: "/admin/openclaw/dashboard/research?catalog=claude&host=gateway%3Alocal&thread=thread-1",
options: {
pathname: "/dashboard/research",
pathname: "/admin/openclaw/dashboard/research",
search: "?catalog=claude&host=gateway%3Alocal&thread=thread-1",
},
});