diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 8345c4957a7e..a15081841350 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -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/?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 diff --git a/extensions/beam/src/http.ts b/extensions/beam/src/http.ts index 91334bbd6e3c..259fec216ec3 100644 --- a/extensions/beam/src/http.ts +++ b/extensions/beam/src/http.ts @@ -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(), }), diff --git a/extensions/beam/src/session-catalog.ts b/extensions/beam/src/session-catalog.ts index 721f7deab252..a9ee15976290 100644 --- a/extensions/beam/src/session-catalog.ts +++ b/extensions/beam/src/session-catalog.ts @@ -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) => diff --git a/extensions/beam/src/types.ts b/extensions/beam/src/types.ts index 3f4fef5d437a..686c4c05f3ae 100644 --- a/extensions/beam/src/types.ts +++ b/extensions/beam/src/types.ts @@ -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; export const BEAM_MAX_BODY_BYTES = 56 * 1024; export const BEAM_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export const BEAM_MAX_SESSIONS = 500; diff --git a/packages/gateway-protocol/src/public-session-catalog.ts b/packages/gateway-protocol/src/public-session-catalog.ts index 84820058fede..4fc1fcffe9e7 100644 --- a/packages/gateway-protocol/src/public-session-catalog.ts +++ b/packages/gateway-protocol/src/public-session-catalog.ts @@ -7,6 +7,7 @@ export type { SessionCatalogLocator, SessionCatalogPullRequestSummary, SessionCatalogSession, + SessionCatalogShareRoute, SessionCatalogTranscriptItem, SessionsCatalogArchiveParams, SessionsCatalogArchiveResult, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts index 7c806a51eb1b..645d5ff7c4b7 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts @@ -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", diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts index 1df9951c23ff..e98236292aeb 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts @@ -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({ diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 15c3888ba0ac..81c8dee87f4a 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -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); diff --git a/packages/session-url-contract/src/index.test.ts b/packages/session-url-contract/src/index.test.ts index 55136bbeb04d..4a731fcf5848 100644 --- a/packages/session-url-contract/src/index.test.ts +++ b/packages/session-url-contract/src/index.test.ts @@ -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[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(); }); }); diff --git a/packages/session-url-contract/src/index.ts b/packages/session-url-contract/src/index.ts index 311fe3f8617f..d810f1598517 100644 --- a/packages/session-url-contract/src/index.ts +++ b/packages/session-url-contract/src/index.ts @@ -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"; diff --git a/packages/session-url-contract/src/parse.test.ts b/packages/session-url-contract/src/parse.test.ts index e507c7061c4a..825f71ba88a3 100644 --- a/packages/session-url-contract/src/parse.test.ts +++ b/packages/session-url-contract/src/parse.test.ts @@ -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, }); }); diff --git a/packages/session-url-contract/src/share.ts b/packages/session-url-contract/src/share.ts index cf04d1817ff3..194a3e084b59 100644 --- a/packages/session-url-contract/src/share.ts +++ b/packages/session-url-contract/src/share.ts @@ -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)}`; } diff --git a/src/gateway/server-methods/session-catalog-share-route.test-support.ts b/src/gateway/server-methods/session-catalog-share-route.test-support.ts new file mode 100644 index 000000000000..806b15d7867a --- /dev/null +++ b/src/gateway/server-methods/session-catalog-share-route.test-support.ts @@ -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; diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index 4840a8bab839..e1c453e203ea 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -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 & { 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 () => { diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 7f67c56a7c2d..a1ca001eb620 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -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; }; 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(); + 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, @@ -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 => { + 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); } }), ); diff --git a/src/plugin-sdk/session-catalog.test.ts b/src/plugin-sdk/session-catalog.test.ts index b92338f74ec0..9912e5873b47 100644 --- a/src/plugin-sdk/session-catalog.test.ts +++ b/src/plugin-sdk/session-catalog.test.ts @@ -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; + 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( diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index 80be8f33fd0c..2460642284ab 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -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; diff --git a/ui/src/app-route-paths.test.ts b/ui/src/app-route-paths.test.ts index c531ab321b15..ebd76d69e510 100644 --- a/ui/src/app-route-paths.test.ts +++ b/ui/src/app-route-paths.test.ts @@ -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", () => { diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index 90aa85941565..85a958735bd2 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -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 { diff --git a/ui/src/e2e/external-session-catalogs.e2e.test.ts b/ui/src/e2e/external-session-catalogs.e2e.test.ts index 4e8b946359d5..6483bf7356ec 100644 --- a/ui/src/e2e/external-session-catalogs.e2e.test.ts +++ b/ui/src/e2e/external-session-catalogs.e2e.test.ts @@ -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", diff --git a/ui/src/lib/sessions/route-navigation.test.ts b/ui/src/lib/sessions/route-navigation.test.ts index ad91dd9e3854..613b36cb3509 100644 --- a/ui/src/lib/sessions/route-navigation.test.ts +++ b/ui/src/lib/sessions/route-navigation.test.ts @@ -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", diff --git a/ui/src/lib/sessions/route-navigation.ts b/ui/src/lib/sessions/route-navigation.ts index 6077ca3eea3b..6ab453cf78fe 100644 --- a/ui/src/lib/sessions/route-navigation.ts +++ b/ui/src/lib/sessions/route-navigation.ts @@ -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( 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, }) diff --git a/ui/src/pages/chat/route-loader-catalog-share.test.ts b/ui/src/pages/chat/route-loader-catalog-share.test.ts index 16dead261065..6109964be0c8 100644 --- a/ui/src/pages/chat/route-loader-catalog-share.test.ts +++ b/ui/src/pages/chat/route-loader-catalog-share.test.ts @@ -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) => Promise, @@ -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" }); + }); }); diff --git a/ui/src/pages/chat/route-loader-catalog-share.ts b/ui/src/pages/chat/route-loader-catalog-share.ts index e375275868f2..336f62956cc5 100644 --- a/ui/src/pages/chat/route-loader-catalog-share.ts +++ b/ui/src/pages/chat/route-loader-catalog-share.ts @@ -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 { @@ -43,7 +44,7 @@ export async function loadCatalogShareRouteFromLocation( }); const listed = await client.request("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"));