fix(beam): define share route contract

This commit is contained in:
Peter Steinberger
2026-08-18 13:20:43 -07:00
parent 1b418638cf
commit 2c092e43ca
24 changed files with 409 additions and 87 deletions
+32
View File
@@ -155,6 +155,38 @@ export default definePluginEntry({
`onHost(host)` callback as each host settles; the returned host array remains
required as the final compatibility snapshot.
A provider may declare one readable transcript route with `shareRoute`. This
is a closed contract, not a free-form routing hint:
```ts
const shareRoute = {
kind: "thread-id-prefix",
routeSegment: "my-sessions",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const;
```
The provider must return lowercase hexadecimal `threadId` values of exactly
32 characters on the declared host. When `list(...)` receives a `search`
value that is a valid 12-32 character prefix, that host must return only rows
whose `threadId` starts with the prefix. Return every match up to the requested
limit and set `nextCursor` when more may exist. The Control UI resolves only
one result with no next page; multiple rows or `nextCursor` are explicitly
ambiguous and never select the first row.
`routeSegment` must not use the first segment of a built-in Control UI route
or alias, and it must be unique across active session catalogs. Invalid,
unsupported, reserved, or multiply owned descriptors fail closed; catalog
sessions remain available through the generic
`/chat/<agent>?catalog=...&host=...&thread=...` URL. Keep one plugin-owned
descriptor constant and reuse it for registration, prefix lookup, and URL
generation so those obligations cannot drift.
CLI-backed catalogs that expose the same local-plus-paired-node shape can use
`createSessionCatalogFamily(...)`. The family composer owns canonical cursor
validation, node payload validation, host projection, adopted-session
+2 -2
View File
@@ -8,7 +8,7 @@ import {
readJsonWebhookBodyOrReject,
} from "openclaw/plugin-sdk/webhook-ingress";
import type { BeamStore } from "./store.js";
import { BEAM_MAX_BODY_BYTES, BEAM_ROUTE_SEGMENT, parseBeamUpload } from "./types.js";
import { BEAM_MAX_BODY_BYTES, BEAM_SESSION_SHARE_ROUTE, parseBeamUpload } from "./types.js";
function sendJson(res: ServerResponse, status: number, value: unknown): void {
res.statusCode = status;
@@ -108,7 +108,7 @@ export function createBeamRequestHandler(params: {
ok: true,
beamId: parsed.value.beamId,
url: buildControlUiCatalogSharePath({
routeSegment: BEAM_ROUTE_SEGMENT,
shareRoute: BEAM_SESSION_SHARE_ROUTE,
threadId: parsed.value.beamId,
basePath: params.resolveControlUiBasePath(),
}),
+4 -3
View File
@@ -5,7 +5,7 @@ import type {
} from "openclaw/plugin-sdk/session-catalog";
import { isControlUiCatalogShareId } from "openclaw/plugin-sdk/session-catalog-runtime";
import type { BeamStore } from "./store.js";
import { BEAM_HOST_ID, BEAM_ROUTE_SEGMENT, type BeamStoredSession } from "./types.js";
import { BEAM_HOST_ID, BEAM_SESSION_SHARE_ROUTE, type BeamStoredSession } from "./types.js";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
@@ -87,11 +87,12 @@ export function createBeamSessionCatalog(store: BeamStore): SessionCatalogProvid
return {
id: "beam",
label: "Beam",
shareRoute: { routeSegment: BEAM_ROUTE_SEGMENT, hostId: BEAM_HOST_ID },
shareRoute: BEAM_SESSION_SHARE_ROUTE,
supportsProcessHomeIsolation: true,
async list(params) {
const search = params.search?.trim().toLowerCase();
const shareId = search && isControlUiCatalogShareId(search) ? search : undefined;
const shareId =
search && isControlUiCatalogShareId(BEAM_SESSION_SHARE_ROUTE, search) ? search : undefined;
const sessions = (await store.list())
.filter(
(session) =>
+11 -1
View File
@@ -1,10 +1,20 @@
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
import {
isRecord,
normalizeBoundedOptionalString as readBoundedString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export const BEAM_HOST_ID = "gateway";
export const BEAM_ROUTE_SEGMENT = "beam";
export const BEAM_SESSION_SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "beam",
hostId: BEAM_HOST_ID,
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const satisfies NonNullable<SessionCatalogProvider["shareRoute"]>;
export const BEAM_MAX_BODY_BYTES = 56 * 1024;
export const BEAM_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export const BEAM_MAX_SESSIONS = 500;
@@ -7,6 +7,7 @@ export type {
SessionCatalogLocator,
SessionCatalogPullRequestSummary,
SessionCatalogSession,
SessionCatalogShareRoute,
SessionCatalogTranscriptItem,
SessionsCatalogArchiveParams,
SessionsCatalogArchiveResult,
@@ -1,6 +1,7 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
SessionCatalogShareRouteSchema,
SessionsCatalogHostEventSchema,
SessionsCatalogListParamsSchema,
SessionsCatalogListResultSchema,
@@ -8,6 +9,34 @@ import {
SessionsCatalogStartTerminalResultSchema,
} from "./sessions-catalog.js";
const SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "shared-sessions",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const;
describe("SessionCatalogShareRouteSchema", () => {
it("accepts only the closed prefix-search contract", () => {
expect(Value.Check(SessionCatalogShareRouteSchema, SHARE_ROUTE)).toBe(true);
for (const invalid of [
{ ...SHARE_ROUTE, kind: "future-route" },
{ ...SHARE_ROUTE, identifierAlphabet: "hex" },
{ ...SHARE_ROUTE, fullLength: 64 },
{ ...SHARE_ROUTE, minPrefixLength: 8 },
{ ...SHARE_ROUTE, lookup: "arbitrary-search" },
{ ...SHARE_ROUTE, ambiguity: "first-result" },
{ ...SHARE_ROUTE, unexpected: true },
]) {
expect(Value.Check(SessionCatalogShareRouteSchema, invalid)).toBe(false);
}
});
});
describe("SessionsCatalogListResultSchema", () => {
it("accepts a closed catalog result with hosts", () => {
expect(
@@ -25,6 +54,7 @@ describe("SessionsCatalogListResultSchema", () => {
},
openTerminal: true,
},
shareRoute: SHARE_ROUTE,
hosts: [
{
hostId: "gateway:local",
@@ -28,8 +28,14 @@ export const SessionCatalogCapabilitiesSchema = closedObject({
});
export const SessionCatalogShareRouteSchema = closedObject({
kind: Type.Literal("thread-id-prefix"),
routeSegment: Type.String({ pattern: "^[a-z][a-z0-9-]*$" }),
hostId: NonEmptyString,
identifierAlphabet: Type.Literal("lowercase-hex"),
fullLength: Type.Literal(32),
minPrefixLength: Type.Literal(12),
lookup: Type.Literal("catalog-list-search-by-thread-id-prefix"),
ambiguity: Type.Literal("multiple-results-or-next-cursor"),
});
export const SessionCatalogDescriptorSchema = closedObject({
@@ -200,6 +200,7 @@ export const validateSecretsStoreSetParams = compile(S.SecretsStoreSetParamsSche
export const validateSecretsStoreDeleteParams = compile(S.SecretsStoreDeleteParamsSchema);
export const validateSecretsStoreMutationResult = compile(S.SecretsStoreMutationResultSchema);
export const validateSessionsListParams = compile(S.SessionsListParamsSchema);
export const validateSessionCatalogShareRoute = compile(S.SessionCatalogShareRouteSchema);
export const validateSessionsCatalogListParams = compile(S.SessionsCatalogListParamsSchema);
export const validateSessionsCatalogReadParams = compile(S.SessionsCatalogReadParamsSchema);
export const validateSessionsCatalogContinueParams = compile(S.SessionsCatalogContinueParamsSchema);
+28 -10
View File
@@ -6,6 +6,17 @@ import {
controlUiSessionSlug,
} from "./index.js";
const SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "beam",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const;
type ChatParams = Omit<Parameters<typeof buildControlUiSessionPath>[0], "namespace">;
const UUID_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
@@ -85,8 +96,8 @@ describe("buildControlUiCatalogSharePath", () => {
])("builds a lowercase 12-character share id for $label", ({ basePath, expected }) => {
expect(
buildControlUiCatalogSharePath({
routeSegment: "beam",
threadId: "0123456789ABCDEF0123456789ABCDEF",
shareRoute: SHARE_ROUTE,
threadId: "0123456789abcdef0123456789abcdef",
basePath,
}),
).toBe(expected);
@@ -95,20 +106,27 @@ describe("buildControlUiCatalogSharePath", () => {
it("can retain the full id for an unambiguous fallback", () => {
expect(
buildControlUiCatalogSharePath({
routeSegment: "beam",
shareRoute: SHARE_ROUTE,
threadId: "0123456789abcdef0123456789abcdef",
shortIdLength: 32,
prefixLength: SHARE_ROUTE.fullLength,
}),
).toBe("/beam/0123456789abcdef0123456789abcdef");
});
it.each([
{ routeSegment: "Beam", threadId: "0123456789abcdef0123456789abcdef" },
{ routeSegment: "beam/extra", threadId: "0123456789abcdef0123456789abcdef" },
{ routeSegment: "beam", threadId: "0123456789ab" },
{ routeSegment: "beam", threadId: "not-hex" },
])("rejects invalid catalog share input %#", (params) => {
expect(buildControlUiCatalogSharePath(params)).toBeNull();
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "Beam" },
threadId: "0123456789abcdef0123456789abcdef",
},
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "beam/extra" },
threadId: "0123456789abcdef0123456789abcdef",
},
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ab" },
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ABCDEF0123456789ABCDEF" },
{ shareRoute: SHARE_ROUTE, threadId: "not-hex" },
])("rejects invalid catalog share input %#", ({ shareRoute, threadId }) => {
expect(buildControlUiCatalogSharePath({ shareRoute, threadId })).toBeNull();
});
});
+1 -2
View File
@@ -11,11 +11,10 @@ export { normalizeControlUiBasePath };
export * from "./focus.js";
export {
buildControlUiCatalogSharePath,
CONTROL_UI_CATALOG_SHARE_FULL_ID_LENGTH,
CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH,
isControlUiCatalogShareId,
isControlUiCatalogShareRouteSegment,
matchControlUiCatalogSharePath,
type ControlUiCatalogShareRoute,
type ControlUiCatalogSharePathMatch,
} from "./share.js";
@@ -300,7 +300,6 @@ describe("matchControlUiCatalogSharePath", () => {
expect(matchControlUiCatalogSharePath({ pathname, basePath })).toEqual({
routeSegment: "beam",
shortId,
valid: true,
});
});
@@ -310,11 +309,10 @@ describe("matchControlUiCatalogSharePath", () => {
"/beam/0123456789abcdef0123456789abcdef0",
"/beam/not-hex-value",
"/beam/0123456789ab/extra",
])("classifies strict invalid form %s", (pathname) => {
])("parses the route owner before descriptor validation for %s", (pathname) => {
expect(matchControlUiCatalogSharePath({ pathname })).toEqual({
routeSegment: "beam",
shortId: pathname.slice("/beam/".length),
valid: false,
});
});
+31 -17
View File
@@ -1,21 +1,34 @@
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
import { normalizeControlUiBasePath } from "./grammar.js";
export const CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH = 12;
export const CONTROL_UI_CATALOG_SHARE_FULL_ID_LENGTH = 32;
const CATALOG_SHARE_ID_RE = /^[0-9a-f]{12,32}$/u;
const CATALOG_SHARE_THREAD_ID_RE = /^[0-9a-f]{32}$/iu;
const LOWERCASE_HEX_RE = /^[0-9a-f]+$/u;
const CATALOG_SHARE_ROUTE_SEGMENT_RE = /^[a-z][a-z0-9-]*$/u;
export type ControlUiCatalogShareRoute = {
kind: "thread-id-prefix";
routeSegment: string;
hostId: string;
identifierAlphabet: "lowercase-hex";
fullLength: 32;
minPrefixLength: 12;
lookup: "catalog-list-search-by-thread-id-prefix";
ambiguity: "multiple-results-or-next-cursor";
};
export type ControlUiCatalogSharePathMatch = {
routeSegment: string;
shortId: string;
valid: boolean;
};
export function isControlUiCatalogShareId(value: string): boolean {
return CATALOG_SHARE_ID_RE.test(value);
export function isControlUiCatalogShareId(
shareRoute: ControlUiCatalogShareRoute,
value: string,
): boolean {
return (
value.length >= shareRoute.minPrefixLength &&
value.length <= shareRoute.fullLength &&
LOWERCASE_HEX_RE.test(value)
);
}
export function isControlUiCatalogShareRouteSegment(value: string): boolean {
@@ -43,30 +56,31 @@ export function matchControlUiCatalogSharePath(params: {
return {
routeSegment,
shortId,
valid: idSegments.length === 1 && isControlUiCatalogShareId(shortId),
};
}
export function buildControlUiCatalogSharePath(params: {
routeSegment: string;
shareRoute: ControlUiCatalogShareRoute;
threadId: string;
basePath?: string;
shortIdLength?: number;
prefixLength?: number;
}): string | null {
const threadId = normalizeNullableString(params.threadId);
const shareRoute = params.shareRoute;
if (
!threadId ||
!isControlUiCatalogShareRouteSegment(params.routeSegment) ||
!CATALOG_SHARE_THREAD_ID_RE.test(threadId)
!isControlUiCatalogShareRouteSegment(shareRoute.routeSegment) ||
threadId.length !== shareRoute.fullLength ||
!LOWERCASE_HEX_RE.test(threadId)
) {
return null;
}
const length = Math.min(
CONTROL_UI_CATALOG_SHARE_FULL_ID_LENGTH,
shareRoute.fullLength,
Math.max(
CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH,
Math.floor(params.shortIdLength ?? CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH),
shareRoute.minPrefixLength,
Math.floor(params.prefixLength ?? shareRoute.minPrefixLength),
),
);
return `${normalizeControlUiBasePath(params.basePath)}/${params.routeSegment}/${threadId.toLowerCase().slice(0, length)}`;
return `${normalizeControlUiBasePath(params.basePath)}/${shareRoute.routeSegment}/${threadId.slice(0, length)}`;
}
@@ -0,0 +1,12 @@
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
export const TEST_SESSION_CATALOG_SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "shared-sessions",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const satisfies NonNullable<SessionCatalogProvider["shareRoute"]>;
@@ -5,6 +5,7 @@ import { bindPluginRegistryRuntime } from "../../plugins/registry-runtime-bindin
import type { PluginRegistry } from "../../plugins/registry-types.js";
import { createPluginRuntime } from "../../plugins/runtime/index.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
import { TEST_SESSION_CATALOG_SHARE_ROUTE as SHARE_ROUTE } from "./session-catalog-share-route.test-support.js";
type TestPluginRegistry = Omit<PluginRegistry, "sessionCatalogs"> & {
sessionCatalogs: Array<{
@@ -142,25 +143,25 @@ describe("session catalog Gateway methods", () => {
});
});
it("projects plugin-owned share routes with catalog results", async () => {
hoisted.activeRegistry.sessionCatalogs = [
{
provider: provider("beam", {
shareRoute: { routeSegment: "beam", hostId: "gateway" },
}),
},
];
const respond = await call("sessions.catalog.list", { catalogId: "beam" });
expect(respond).toHaveBeenCalledWith(true, {
catalogs: [
expect.objectContaining({
id: "beam",
shareRoute: { routeSegment: "beam", hostId: "gateway" },
}),
],
it("projects uniquely owned share routes and suppresses collisions", async () => {
hoisted.activeRegistry.sessionCatalogs.push({
provider: provider("external", { shareRoute: SHARE_ROUTE }),
});
let catalogs = (
(await call("sessions.catalog.list", { catalogId: "external" })).mock.calls[0]![1] as {
catalogs: Array<{ shareRoute?: unknown }>;
}
).catalogs;
expect(catalogs[0]?.shareRoute).toEqual(SHARE_ROUTE);
hoisted.activeRegistry.sessionCatalogs = [
{ provider: provider("first", { shareRoute: SHARE_ROUTE }) },
{ provider: provider("second", { shareRoute: SHARE_ROUTE }) },
];
catalogs = (
(await call("sessions.catalog.list", {})).mock.calls[0]![1] as { catalogs: typeof catalogs }
).catalogs;
expect(catalogs.every((catalog) => catalog.shareRoute === undefined)).toBe(true);
});
it("streams completed hosts to only the requesting connection", async () => {
+32 -4
View File
@@ -5,6 +5,7 @@ import {
errorShape,
type SessionCatalog,
type SessionCatalogLocator,
type SessionCatalogShareRoute,
type SessionsCatalogArchiveParams,
type SessionsCatalogContinueParams,
type SessionsCatalogListParams,
@@ -13,6 +14,7 @@ import {
validateSessionsCatalogContinueParams,
validateSessionsCatalogListParams,
validateSessionsCatalogReadParams,
validateSessionCatalogShareRoute,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { pruneMapToMaxSize } from "../../infra/map-size.js";
@@ -75,6 +77,7 @@ type CatalogRegistrationSnapshot = {
source: PluginRegistry["sessionCatalogs"] | undefined;
registrations: PluginRegistry["sessionCatalogs"];
providers: SessionCatalogProvider[];
shareRoutes: ReadonlyMap<SessionCatalogProvider, SessionCatalogShareRoute>;
};
let cachedCatalogRegistrations: CatalogRegistrationSnapshot | undefined;
@@ -91,6 +94,21 @@ function catalogRegistrationSnapshot(): CatalogRegistrationSnapshot {
const sortedRegistrations = (source ?? []).toSorted((left, right) =>
left.provider.id.localeCompare(right.provider.id),
);
const providerList = sortedRegistrations.map((entry) => entry.provider);
const validRoutes = providerList.flatMap((provider) =>
provider.shareRoute && validateSessionCatalogShareRoute(provider.shareRoute)
? [{ provider, route: provider.shareRoute }]
: [],
);
const routeCounts = new Map<string, number>();
for (const { route } of validRoutes) {
routeCounts.set(route.routeSegment, (routeCounts.get(route.routeSegment) ?? 0) + 1);
}
const shareRoutes = new Map(
validRoutes
.filter(({ route }) => routeCounts.get(route.routeSegment) === 1)
.map(({ provider, route }) => [provider, route] as const),
);
// Plugin registration arrays are process-stable until the active registry seam changes. Hoisting
// this sort avoids rebuilding identical order every poll; registry/list identity invalidates it.
// A stale snapshot would route requests to retired plugin instances, so callers share this owner.
@@ -98,7 +116,8 @@ function catalogRegistrationSnapshot(): CatalogRegistrationSnapshot {
registry,
source,
registrations: sortedRegistrations,
providers: sortedRegistrations.map((entry) => entry.provider),
providers: providerList,
shareRoutes,
};
return cachedCatalogRegistrations;
}
@@ -310,6 +329,7 @@ function registrationOrRespond(catalogId: string, respond: RespondFn) {
function catalogResult(
provider: SessionCatalogProvider,
shareRoute: SessionCatalogShareRoute | undefined,
hosts: SessionCatalog["hosts"],
error?: SessionCatalog["error"],
createSession?: NonNullable<SessionCatalog["capabilities"]["createSession"]>,
@@ -323,7 +343,7 @@ function catalogResult(
...(provider.openTerminal ? { openTerminal: true } : {}),
...(createSession ? { createSession } : {}),
},
...(provider.shareRoute ? { shareRoute: provider.shareRoute } : {}),
...(shareRoute ? { shareRoute } : {}),
hosts,
};
if (error) {
@@ -429,6 +449,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
const listNodes = createSessionCatalogRequestNodeSnapshot();
const catalogList = await Promise.all(
selected.map(async (provider): Promise<SessionCatalog> => {
const shareRoute = catalogRegistrations.shareRoutes.get(provider);
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId, config);
const createSession = createTarget.ok
? {
@@ -441,7 +462,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
requestEntries.projectHostCreatedActors(host),
visibility,
);
const catalog = catalogResult(provider, [visibleHost], undefined, createSession);
const catalog = catalogResult(
provider,
shareRoute,
[visibleHost],
undefined,
createSession,
);
// Progressive frames are an optimization. The final RPC response remains
// authoritative when a slow client drops an intermediate host update.
for (const subscriber of progressSubscribers.values()) {
@@ -471,6 +498,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
});
return catalogResult(
provider,
shareRoute,
hosts.map((host) =>
filterSessionCatalogHost(requestEntries.projectHostCreatedActors(host), visibility),
),
@@ -478,7 +506,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
createSession,
);
} catch (error) {
return catalogResult(provider, [], catalogError(error), createSession);
return catalogResult(provider, shareRoute, [], catalogError(error), createSession);
}
}),
);
+27
View File
@@ -5,6 +5,7 @@ import {
sessionCatalogPaging,
sessionCatalogAdoptedSourceKey,
type SessionCatalogFamilyOptions,
type SessionCatalogProvider,
type SessionCatalogSession,
} from "./session-catalog.js";
@@ -26,6 +27,32 @@ const session = (threadId: string): SessionCatalogSession => ({
});
describe("session catalog SDK", () => {
it("exposes the closed share-route contract to external providers", () => {
const shareRoute = {
kind: "thread-id-prefix",
routeSegment: "shared-sessions",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const satisfies NonNullable<SessionCatalogProvider["shareRoute"]>;
const provider = {
id: "external",
label: "External",
shareRoute,
async list() {
return [];
},
async read({ hostId, threadId }) {
return { hostId, threadId, items: [] };
},
} satisfies SessionCatalogProvider;
expect(provider.shareRoute).toEqual(shareRoute);
});
it("owns canonical list/read parameter and cursor parsing", () => {
const cursor = sessionCatalogPaging.encodeCursor(2);
expect(
+1 -1
View File
@@ -173,7 +173,7 @@ type SessionCatalogCreateParams = {
export type SessionCatalogProvider = {
id: string;
label: string;
/** Optional plugin-owned pretty route backed by exact prefix search on one catalog host. */
/** Closed plugin-owned route contract; invalid or colliding declarations are not projected. */
shareRoute?: SessionCatalogShareRoute;
/** Declares that every HOME-sensitive action honors the host isolation policy. */
supportsProcessHomeIsolation?: true;
+4
View File
@@ -108,6 +108,10 @@ describe("Dynamic route startup bridge", () => {
expect(inferBasePathFromPathname("/openclaw/beam/0123456789ab")).toBe("/openclaw");
expect(routeIdFromPath("/settings/about")).toBe("about");
expect(routeIdFromPath("/workboard/0123456789ab")).toBe("workboard");
expect(routeIdFromPath("/plugin/0123456789ab")).toBeNull();
expect(routeIdFromPath("/usage/0123456789ab")).toBeNull();
expect(routeIdFromPath("/settings/0123456789ab")).toBeNull();
expect(routeIdFromPath("/openclaw/skills/0123456789ab", "/openclaw")).toBeNull();
});
it("registers the Updates settings path", () => {
+17 -1
View File
@@ -77,11 +77,23 @@ const APP_ROUTE_DEFINITIONS = {
export type RouteId = keyof typeof APP_ROUTE_DEFINITIONS;
export const APP_ROUTE_IDS = Object.keys(APP_ROUTE_DEFINITIONS) as RouteId[];
const RESERVED_APP_ROUTE_SEGMENTS = new Set(
APP_ROUTE_IDS.flatMap((routeId) => {
const definition = APP_ROUTE_DEFINITIONS[routeId];
const paths: readonly string[] =
"aliases" in definition ? [definition.path, ...definition.aliases] : [definition.path];
return paths.map((path) => path.split("/").find(Boolean)).filter(Boolean);
}),
);
export function isRouteId(routeId: string): routeId is RouteId {
return routeId in APP_ROUTE_DEFINITIONS;
}
export function isReservedAppRouteSegment(segment: string): boolean {
return RESERVED_APP_ROUTE_SEGMENTS.has(segment.toLowerCase());
}
// Single source for page definitions: ui/src/pages/*/route.ts spreads this
// into definePage so router matching can never drift from the table that
// drives routeIdFromPath and base-path inference.
@@ -239,7 +251,11 @@ export function sessionRouteNamespaceFromPath(pathname: string, basePath = ""):
pathname: normalizedPath,
basePath: normalizedBasePath,
});
return catalogShare && !catalogShare.shortId.includes("/") ? "chat" : null;
return catalogShare &&
!catalogShare.shortId.includes("/") &&
!isReservedAppRouteSegment(catalogShare.routeSegment)
? "chat"
: null;
}
export function workboardBoardIdFromPath(pathname: string, basePath = ""): string | null {
@@ -10,6 +10,17 @@ const suite = createControlUiE2eSuite({
unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
});
const SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "beam",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const;
suite.define(() => {
it("opens and refreshes a base-path Beam share without replacing its pretty URL", async () => {
const artifactDir = path.resolve(".artifacts/control-ui-e2e/beam-share-url");
@@ -36,7 +47,7 @@ suite.define(() => {
id: "beam",
label: "Beam",
capabilities: { continueSession: false, archive: false },
shareRoute: { routeSegment: "beam", hostId: "gateway" },
shareRoute: SHARE_ROUTE,
hosts: [
{
hostId: "gateway",
+29 -1
View File
@@ -10,6 +10,17 @@ import {
sessionNavigationTarget,
} from "./route-navigation.ts";
const SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "beam",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const;
describe("sessionNavigationTarget", () => {
it("defaults generic opens to chat and honors a stored dashboard preference", () => {
expect(resolveSessionPreferredFace(undefined)).toBe("chat");
@@ -54,7 +65,7 @@ describe("sessionNavigationTarget", () => {
}),
fallbackAgentId: "main",
basePath: "/openclaw",
catalogShareRoute: { routeSegment: "beam", hostId: "gateway" },
catalogShareRoute: SHARE_ROUTE,
});
expect(target).toEqual({
@@ -63,6 +74,23 @@ describe("sessionNavigationTarget", () => {
});
});
it("keeps generic catalog URLs when a descriptor collides with a built-in route", () => {
const target = sessionNavigationTarget({
face: "chat",
sessionKey: buildCatalogSessionKey({
catalogId: "external",
hostId: "gateway",
threadId: "0123456789abcdef0123456789abcdef",
}),
fallbackAgentId: "main",
catalogShareRoute: { ...SHARE_ROUTE, routeSegment: "plugin" },
});
expect(target.href).toBe(
"/chat/main?catalog=external&host=gateway&thread=0123456789abcdef0123456789abcdef",
);
});
it("requires the destination face while preserving catalog identity", () => {
const catalogKey = {
catalogId: "claude",
+5 -3
View File
@@ -2,7 +2,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import { buildControlUiCatalogSharePath } from "@openclaw/session-url-contract/share";
import type { SessionCatalog } from "../../../../packages/gateway-protocol/src/index.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import { pathForRoute } from "../../app-route-paths.ts";
import { isReservedAppRouteSegment, pathForRoute } from "../../app-route-paths.ts";
import { pathForSession } from "../../app-session-path-builder.ts";
import type { ApplicationNavigationOptions, ApplicationContext } from "../../app/context.ts";
import type { BoardFace } from "../board/settings.ts";
@@ -170,9 +170,11 @@ export function sessionNavigationTarget<TRouteId extends string>(
const catalogKey = parseCatalogSessionKey(row?.key ?? sessionKey);
const catalogShareRoute = params.catalogShareRoute;
const catalogSharePath =
catalogKey && catalogShareRoute?.hostId === catalogKey.hostId
catalogKey &&
catalogShareRoute?.hostId === catalogKey.hostId &&
!isReservedAppRouteSegment(catalogShareRoute.routeSegment)
? buildControlUiCatalogSharePath({
...catalogShareRoute,
shareRoute: catalogShareRoute,
threadId: catalogKey.threadId,
basePath,
})
@@ -5,6 +5,16 @@ import { buildCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
import { loadChatRoute } from "./route-loader.ts";
const fullId = "0123456789abcdef0123456789abcdef";
const SHARE_ROUTE = {
kind: "thread-id-prefix",
routeSegment: "beam",
hostId: "gateway",
identifierAlphabet: "lowercase-hex",
fullLength: 32,
minPrefixLength: 12,
lookup: "catalog-list-search-by-thread-id-prefix",
ambiguity: "multiple-results-or-next-cursor",
} as const;
function catalogContext(
request: (method: string, params: Record<string, unknown>) => Promise<unknown>,
@@ -27,7 +37,7 @@ function beamCatalog(sessions: Array<{ threadId: string; name: string }>, nextCu
id: "beam",
label: "Beam",
capabilities: { continueSession: false, archive: false },
shareRoute: { routeSegment: "beam", hostId: "gateway" },
shareRoute: SHARE_ROUTE,
hosts: [
{
hostId: "gateway",
@@ -50,6 +60,39 @@ function beamCatalog(sessions: Array<{ threadId: string; name: string }>, nextCu
}
describe("catalog share route resolution", () => {
it("resolves an external-style provider descriptor without catalog-specific UI policy", async () => {
const shareRoute = { ...SHARE_ROUTE, routeSegment: "shared-sessions" } as const;
const request = vi.fn(async () => {
const result = beamCatalog([{ threadId: fullId, name: "External session" }]);
return {
catalogs: [
{
...result.catalogs[0],
id: "external",
label: "External",
shareRoute,
},
],
};
});
await expect(
loadChatRoute(
catalogContext(request),
{ pathname: "/shared-sessions/0123456789ab", search: "", hash: "" },
"chat",
new AbortController().signal,
),
).resolves.toMatchObject({
kind: "session",
sessionKey: buildCatalogSessionKey({
catalogId: "external",
hostId: "gateway",
threadId: fullId,
}),
});
});
it("resolves a base-path share independently of the default agent id", async () => {
const request = vi.fn(async (method: string) => {
if (method !== "sessions.catalog.list") {
@@ -164,4 +207,37 @@ describe("catalog share route resolution", () => {
),
).resolves.toMatchObject({ kind: "route-error" });
});
it("keeps paginated empty results ambiguous and rejects duplicate declared hosts", async () => {
const paginated = catalogContext(vi.fn(async () => beamCatalog([], "more")));
await expect(
loadChatRoute(
paginated,
{ pathname: "/beam/0123456789ab", search: "", hash: "" },
"chat",
new AbortController().signal,
),
).resolves.toMatchObject({ kind: "ambiguous", candidates: [], truncated: true });
const duplicateHost = catalogContext(
vi.fn(async () => {
const result = beamCatalog([{ threadId: fullId, name: "First" }]);
const catalog = result.catalogs[0];
if (!catalog) {
throw new Error("catalog fixture missing");
}
return {
catalogs: [{ ...catalog, hosts: [...catalog.hosts, { ...catalog.hosts[0] }] }],
};
}),
);
await expect(
loadChatRoute(
duplicateHost,
{ pathname: "/beam/0123456789ab", search: "", hash: "" },
"chat",
new AbortController().signal,
),
).resolves.toMatchObject({ kind: "route-error" });
});
});
+26 -19
View File
@@ -1,10 +1,11 @@
import {
buildControlUiCatalogSharePath,
isControlUiCatalogShareId,
matchControlUiCatalogSharePath,
} from "@openclaw/session-url-contract/share";
import type { RouteLocation } from "@openclaw/uirouter";
import type { SessionsCatalogListResult } from "../../../../packages/gateway-protocol/src/index.js";
import { INTERNAL_SESSION_PATH_PARAM, isSessionRouteId } from "../../app-route-paths.ts";
import { INTERNAL_SESSION_PATH_PARAM, isReservedAppRouteSegment } from "../../app-route-paths.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { waitForGatewayClient } from "../../app/gateway-readiness.ts";
import { t } from "../../i18n/index.ts";
@@ -18,7 +19,7 @@ function targetFromLocation(context: ApplicationContext, location: RouteLocation
matchControlUiCatalogSharePath({ pathname, basePath: context.basePath });
const internalPath = new URLSearchParams(location.search).get(INTERNAL_SESSION_PATH_PARAM);
const target = (internalPath ? matchPath(internalPath) : null) ?? matchPath(location.pathname);
return target && !isSessionRouteId(target.routeSegment) ? target : null;
return target && !isReservedAppRouteSegment(target.routeSegment) ? target : null;
}
function routeError(message: string): Extract<ChatRouteData, { kind: "route-error" }> {
@@ -43,7 +44,7 @@ export async function loadCatalogShareRouteFromLocation(
});
const listed = await client.request<SessionsCatalogListResult>("sessions.catalog.list", {
agentId,
...(target.valid ? { search: target.shortId } : {}),
search: target.shortId,
limitPerHost: 2,
});
signal.throwIfAborted();
@@ -55,37 +56,35 @@ export async function loadCatalogShareRouteFromLocation(
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
}
const shareRoute = catalog.shareRoute;
if (!target.valid) {
if (!isControlUiCatalogShareId(shareRoute, target.shortId)) {
return routeError(t("chat.sessionRoute.catalogShareInvalid", { catalog: catalog.label }));
}
if (catalog.error) {
return routeError(catalog.error.message);
}
const host = catalog.hosts.find((candidate) => candidate.hostId === shareRoute.hostId);
const matchingHosts = catalog.hosts.filter(
(candidate) => candidate.hostId === shareRoute.hostId,
);
const host = matchingHosts.length === 1 ? matchingHosts[0] : undefined;
if (!host) {
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
}
if (host?.error) {
return routeError(host.error.message);
}
const matches = host.sessions.filter((session) =>
session.threadId.toLowerCase().startsWith(target.shortId),
const matches = host.sessions.filter(
(session) =>
isControlUiCatalogShareId(shareRoute, session.threadId) &&
session.threadId.length === shareRoute.fullLength &&
session.threadId.startsWith(target.shortId),
);
if (matches.length === 0) {
return routeError(
t("chat.sessionRoute.catalogShareNotFound", {
catalog: catalog.label,
shortId: target.shortId,
}),
);
}
if (matches.length > 1 || host.nextCursor) {
const candidates = matches.flatMap((session) => {
const href = buildControlUiCatalogSharePath({
routeSegment: shareRoute.routeSegment,
shareRoute,
threadId: session.threadId,
basePath: context.basePath,
shortIdLength: 32,
prefixLength: shareRoute.fullLength,
});
return href
? [
@@ -106,15 +105,23 @@ export async function loadCatalogShareRouteFromLocation(
face: "chat",
};
}
if (matches.length === 0) {
return routeError(
t("chat.sessionRoute.catalogShareNotFound", {
catalog: catalog.label,
shortId: target.shortId,
}),
);
}
const session = matches[0];
if (!session) {
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
}
if (
!buildControlUiCatalogSharePath({
routeSegment: shareRoute.routeSegment,
shareRoute,
threadId: session.threadId,
shortIdLength: 32,
prefixLength: shareRoute.fullLength,
})
) {
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));