mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
cc2fc55f9b
* feat(protocol): add portal methods and event Bump the reviewed protocol owner-module count from 55 to 56. * feat(gateway): add portal service and reverse proxy * feat(agents): add portal tool * fix(gateway): refine portal URL and proxy auth * refactor(gateway): keep portal helper types private * fix(gateway): declare portal transport service * test(gateway): satisfy portal proxy lint * test(gateway): narrow websocket payload types * refactor(protocol): compact portal schema exports * fix(gateway): export portal protocol types * feat(ui): add portals page * docs(gateway): add portals guide * fix(gateway): dial portal targets via localhost dual-stack Vite and other Node >=17 dev servers bind ::1 only for localhost, so a fixed 127.0.0.1 dial 502s on the default path. Use hostname localhost with family autoselection and rewrite Host to match. * fix(gateway): type portal dual-stack connection * fix: satisfy portal integration gates * fix(gateway): isolate portal cookie jars per target Cookies are hostname-scoped, not port-scoped, so the per-port origin split alone let Gateway plugin-auth cookies reach agent-run targets. Forward only cookies carrying this portal's own name prefix (stripped), rewrite target Set-Cookie names to the prefixed form incl. the WS 101 handshake, and drop Domain attributes. * fix(ui): detect unreachable portals behind proxied gateways Probe the portal origin from the browser (no-cors, 4s timeout) and show a recovery notice with the gateway-host URL instead of a dead iframe when only the gateway port is exposed (Serve/Funnel/reverse proxy). Docs: cookie isolation + reachability; zh-CN glossary entry. * test(ui): satisfy portal reachability lint * test(gateway): provide control UI request hosts * chore(protocol): regenerate after rebase * fix(gateway): namespace portal auth cookies by listener * fix(gateway): scope portal token URLs to write-capable clients The portal bearer token rides in the summary url/tokenQuery; portal.list is operator.read and portal.changed fans out to read subscribers, so a read-only client could harvest an openable URL. Make those fields optional, redact them from read-scope list responses, and drop them from every portal.changed broadcast; write/admin clients still receive them and the UI refetches the list on change. * docs(web): list the portals route * fix(gateway): type portal open credentials * docs(gateway): clarify portals PORT/PUBLIC_URL are agent-set Opening a portal creates only the proxy listener; the agent sets PORT and PUBLIC_URL in its own exec command, matching the portal tool contract. Removes the implication of an automatic env handoff. * chore(protocol): regenerate portal models * style(gateway): format portal method-order assertions Rebase union-merge left the portal.list assertion wrapped; oxfmt fits it on one line. * chore(plugin-sdk): refresh API baseline after rebase * chore(plugin-sdk): refresh API baseline after rebase * chore(protocol): refresh portal event order after rebase * chore(plugin-sdk): refresh API baseline after rebase * fix(gateway): pin portal referrer policy to no-referrer The portal URL carries its bearer token in the query, and upstream response headers are copied verbatim, so a target answering with Referrer-Policy: unsafe-url could leak that URL to every third-party origin it references. Force no-referrer after the copy and drop any inbound Referer that still carries the token before forwarding.
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
const repoRoot = resolveRepoRoot(import.meta.url);
|
|
const schemaDir = path.join(repoRoot, "packages/gateway-protocol/src/schema");
|
|
const failures: string[] = [];
|
|
const read = (relativePath: string) => fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
|
const check = (condition: unknown, message: string) => {
|
|
if (!condition) {
|
|
failures.push(message);
|
|
}
|
|
};
|
|
const getObjectExport = (module: unknown, binding: string) =>
|
|
isRecord(module) && isRecord(module[binding]) ? module[binding] : undefined;
|
|
|
|
const registryPath = "packages/gateway-protocol/src/schema/protocol-schemas.ts";
|
|
const registrySource = read(registryPath);
|
|
const fragmentImports = [
|
|
...registrySource.matchAll(
|
|
/^import \{ ([A-Za-z0-9_]+) \} from "(\.\/protocol-schema-fragment-[^"]+\.js)";$/gmu,
|
|
),
|
|
].map(([, binding = "", specifier = ""]) => ({ binding, specifier }));
|
|
const importSpecifiers = [...registrySource.matchAll(/^import .* from "([^"]+)";$/gmu)].map(
|
|
([, specifier = ""]) => specifier,
|
|
);
|
|
check(
|
|
importSpecifiers.every(
|
|
(specifier) =>
|
|
specifier === "./protocol-schema-composer.js" ||
|
|
specifier.startsWith("./protocol-schema-fragment-"),
|
|
),
|
|
`${registryPath} may import only the composer and schema fragments`,
|
|
);
|
|
check(
|
|
!/\b[A-Z][A-Za-z0-9]*Schema\b/u.test(registrySource),
|
|
`${registryPath} contains a direct *Schema inventory`,
|
|
);
|
|
|
|
const composition = registrySource.match(
|
|
/export const ProtocolSchemas = composeProtocolSchemaFragments\(\[([\s\S]*?)\]\s+as const\);/u,
|
|
);
|
|
const composedBindings = (composition?.[1] ?? "")
|
|
.split("\n")
|
|
.map((line) => line.trim().replace(/,$/u, ""))
|
|
.filter(Boolean);
|
|
const importedBindings = fragmentImports.map(({ binding }) => binding);
|
|
check(Boolean(composition), `${registryPath} must explicitly compose an ordered fragment array`);
|
|
check(
|
|
composedBindings.length === importedBindings.length &&
|
|
new Set(composedBindings).size === composedBindings.length &&
|
|
importedBindings.every((binding) => composedBindings.includes(binding)),
|
|
`${registryPath} must compose every imported fragment exactly once`,
|
|
);
|
|
|
|
const fragmentFiles = fs
|
|
.readdirSync(schemaDir)
|
|
.filter((name) => /^protocol-schema-fragment-.+\.ts$/u.test(name));
|
|
const importedFiles = fragmentImports.map(({ specifier }) => `${specifier.slice(2, -3)}.ts`);
|
|
check(
|
|
fragmentFiles.length === importedFiles.length &&
|
|
fragmentFiles.every((name) => importedFiles.includes(name)),
|
|
`${registryPath} must explicitly import every protocol schema fragment`,
|
|
);
|
|
|
|
const importsByBinding = new Map(
|
|
fragmentImports.map((fragmentImport) => [fragmentImport.binding, fragmentImport]),
|
|
);
|
|
const seenKeys = new Set<string>();
|
|
const orderedKeys: string[] = [];
|
|
for (const binding of composedBindings) {
|
|
const { specifier } = importsByBinding.get(binding) ?? {};
|
|
if (!specifier) {
|
|
continue;
|
|
}
|
|
const moduleUrl = new URL(specifier.replace(/\.js$/u, ".ts"), pathToFileURL(registryPath));
|
|
const fragment = getObjectExport(await import(moduleUrl.href), binding);
|
|
check(fragment, `${specifier} must export object ${binding}`);
|
|
if (!fragment) {
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(fragment)) {
|
|
check(!seenKeys.has(key), `duplicate protocol schema key ${key}`);
|
|
seenKeys.add(key);
|
|
orderedKeys.push(key);
|
|
}
|
|
}
|
|
const { ProtocolSchemas } =
|
|
await import("../packages/gateway-protocol/src/schema/protocol-schemas.ts");
|
|
check(
|
|
JSON.stringify(Object.keys(ProtocolSchemas)) === JSON.stringify(orderedKeys),
|
|
"ProtocolSchemas must preserve explicit fragment/key order",
|
|
);
|
|
|
|
const composerSource = read("packages/gateway-protocol/src/schema/protocol-schema-composer.ts");
|
|
check(!/\.(?:sort|toSorted)\s*\(/u.test(composerSource), "schema composer must not sort");
|
|
check(
|
|
composerSource.includes("Object.hasOwn(registry, key)"),
|
|
"schema composer must reject duplicate fragment keys",
|
|
);
|
|
|
|
const withoutComments = (source: string) =>
|
|
source
|
|
.replace(/\r\n?/gu, "\n")
|
|
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
|
.replace(/^\s*\/\/.*$/gmu, "")
|
|
.trim();
|
|
const schemaModulesSource = withoutComments(
|
|
read("packages/gateway-protocol/src/schema-modules.ts"),
|
|
);
|
|
const ownerModules = [
|
|
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
|
|
].map(([, moduleName = ""]) => moduleName);
|
|
check(
|
|
ownerModules.length === 57 && new Set(ownerModules).size === ownerModules.length,
|
|
"schema-modules.ts must contain one unique 57-module owner list",
|
|
);
|
|
check(
|
|
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
|
|
"schema-modules.ts may contain only owner-module exports",
|
|
);
|
|
check(
|
|
withoutComments(read("packages/gateway-protocol/src/schema.ts")) ===
|
|
'export * from "./schema-modules.js";\nexport * from "./schema/protocol-schemas.js";',
|
|
"schema.ts must remain a schema-modules/protocol-schemas wrapper",
|
|
);
|
|
check(
|
|
withoutComments(read("packages/gateway-protocol/src/schema-types.ts")) ===
|
|
'export type * from "./schema-modules.js";',
|
|
"schema-types.ts must remain a registry-free schema-modules wrapper",
|
|
);
|
|
|
|
const publicIndexSource = read("packages/gateway-protocol/src/index.ts");
|
|
check(
|
|
publicIndexSource.includes('} from "./schema-modules.js";'),
|
|
"index.ts must explicitly export the reviewed public schema allowlist",
|
|
);
|
|
check(
|
|
!publicIndexSource.includes('export * from "./schema-modules.js";'),
|
|
"index.ts must not expose every schema module export implicitly",
|
|
);
|
|
check(
|
|
!fs.existsSync(path.join(repoRoot, "packages/gateway-protocol/src/schema-export-registry.ts")),
|
|
"the public schema allowlist must stay in the canonical package index",
|
|
);
|
|
|
|
for (const relativePath of [
|
|
"packages/gateway-protocol/src/index.ts",
|
|
"packages/gateway-protocol/src/validator-registry.ts",
|
|
]) {
|
|
check(
|
|
!read(relativePath).includes('from "./schema.js"'),
|
|
`${relativePath} must not cross the registry through schema.ts`,
|
|
);
|
|
}
|
|
const pluginSdkGuard = read("scripts/check-plugin-sdk-exports.mts");
|
|
check(
|
|
pluginSdkGuard.includes("FORBIDDEN_PUBLIC_PROTOCOL_REGISTRY_RE") &&
|
|
pluginSdkGuard.includes("FORBIDDEN PUBLIC DTS REGISTRY"),
|
|
"plugin SDK declaration checks must reject leaked ProtocolSchemas declarations",
|
|
);
|
|
|
|
if (failures.length) {
|
|
throw new Error(
|
|
failures.map((failure) => `protocol registry check failed: ${failure}`).join("\n"),
|
|
);
|
|
}
|
|
console.log("protocol registry check passed");
|