mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
0e56fce87b
commit
c708b41af4
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user