feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
255
canvas-agent/src/local-runtime.ts
Normal file
255
canvas-agent/src/local-runtime.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
import express from "express";
|
||||
|
||||
import {
|
||||
LOCAL_RUNTIME_API_VERSION,
|
||||
LOCAL_RUNTIME_ID,
|
||||
type LocalRuntimeModuleDescriptor,
|
||||
type LocalRuntimeModuleId,
|
||||
type LocalRuntimeScope,
|
||||
} from "./local-runtime-contract.js";
|
||||
import {
|
||||
assertExactKeys,
|
||||
exactAuthorityGuard,
|
||||
noStore,
|
||||
protectedCorsHeaders,
|
||||
legacyOrSignedRuntimeGuard,
|
||||
runtimeCors,
|
||||
runtimeErrorHandler,
|
||||
signedRuntimeGuard,
|
||||
strictJsonObject,
|
||||
trustedOriginGuard,
|
||||
type RuntimeCorsPolicy,
|
||||
} from "./local-runtime-security.js";
|
||||
import {
|
||||
LocalRuntimeSessionError,
|
||||
type LocalRuntimeSessionManager,
|
||||
} from "./local-runtime-session.js";
|
||||
|
||||
export type LocalRuntimeProtectedRoute = {
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
scope: LocalRuntimeScope;
|
||||
handler: RequestHandler;
|
||||
lastEventId?: boolean;
|
||||
queryKeys?: readonly string[];
|
||||
legacy?: boolean;
|
||||
};
|
||||
|
||||
export type LocalRuntimeModule = {
|
||||
descriptor: LocalRuntimeModuleDescriptor;
|
||||
routes: readonly LocalRuntimeProtectedRoute[];
|
||||
start?: () => void | Promise<void>;
|
||||
onRuntimeSessionRevoked?: (sessionId: string) => void;
|
||||
dispose?: () => void | Promise<void>;
|
||||
publicHealth?: () => Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
export type CreateLocalRuntimeAppOptions = {
|
||||
authority: string;
|
||||
endpoint: string;
|
||||
version: string;
|
||||
sessionManager: LocalRuntimeSessionManager;
|
||||
modules: readonly LocalRuntimeModule[];
|
||||
legacyMasterToken?: string;
|
||||
legacyOrigins?: readonly string[];
|
||||
};
|
||||
|
||||
export function createLocalRuntimeApp(options: CreateLocalRuntimeAppOptions) {
|
||||
const modules = validateModules(options.modules);
|
||||
const policies = corsPolicies(modules, options.legacyOrigins ?? []);
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.raw({
|
||||
type: "application/json",
|
||||
limit: "30mb",
|
||||
}));
|
||||
app.use(noStore);
|
||||
app.use(exactAuthorityGuard(options.authority));
|
||||
app.use(runtimeCors(policies, options.sessionManager));
|
||||
|
||||
app.get("/runtime/info", (req, res) => {
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
|
||||
res.json({
|
||||
runtime: LOCAL_RUNTIME_ID,
|
||||
apiVersion: LOCAL_RUNTIME_API_VERSION,
|
||||
protocolVersion: "framefield-runtime-session-v1",
|
||||
runtimeInstanceId: options.sessionManager.runtimeInstanceId,
|
||||
originTrusted: options.sessionManager.isTrustedOrigin(origin),
|
||||
});
|
||||
});
|
||||
app.get("/health", (_req, res) => {
|
||||
const health = modules.reduce<Record<string, string | number | boolean>>((result, module) => {
|
||||
if (module.publicHealth) Object.assign(result, module.publicHealth());
|
||||
return result;
|
||||
}, {});
|
||||
res.json({ ok: true, ...health });
|
||||
});
|
||||
app.get("/config", (_req, res) => res.json({
|
||||
ok: true,
|
||||
url: options.endpoint,
|
||||
hasToken: Boolean(options.legacyMasterToken),
|
||||
}));
|
||||
|
||||
app.post(
|
||||
"/runtime/session/challenge",
|
||||
trustedOriginGuard(options.sessionManager),
|
||||
route((req, res) => {
|
||||
const body = strictJsonObject(req);
|
||||
if ("publicKeyJwk" in body) {
|
||||
assertExactKeys(body, ["publicKeyJwk"]);
|
||||
res.json(options.sessionManager.createChallenge(requiredOrigin(req), {
|
||||
publicKeyJwk: body.publicKeyJwk as JsonWebKey,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
assertExactKeys(body, ["keyId"]);
|
||||
if (typeof body.keyId !== "string") throw requestInvalid();
|
||||
res.json(options.sessionManager.createChallenge(requiredOrigin(req), { keyId: body.keyId }));
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
"/runtime/session/exchange",
|
||||
trustedOriginGuard(options.sessionManager),
|
||||
route((req, res) => {
|
||||
const body = strictJsonObject(req);
|
||||
assertExactKeys(body, ["challengeId", "signature"]);
|
||||
if (typeof body.challengeId !== "string" || typeof body.signature !== "string") {
|
||||
throw requestInvalid();
|
||||
}
|
||||
res.json(options.sessionManager.exchange(requiredOrigin(req), {
|
||||
challengeId: body.challengeId,
|
||||
signature: body.signature,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/runtime/status",
|
||||
signedRuntimeGuard(options.sessionManager, "runtime:status"),
|
||||
(_req, res) => res.json({
|
||||
ok: true,
|
||||
runtime: {
|
||||
id: LOCAL_RUNTIME_ID,
|
||||
version: options.version,
|
||||
apiVersion: LOCAL_RUNTIME_API_VERSION,
|
||||
},
|
||||
modules: modules.map((module) => ({ ...module.descriptor })),
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
"/runtime/session/revoke",
|
||||
signedRuntimeGuard(options.sessionManager, "runtime:revoke"),
|
||||
route((req, res) => {
|
||||
assertExactKeys(strictJsonObject(req), []);
|
||||
options.sessionManager.revokeSession(requiredRuntimeSessionId(res));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
app.post(
|
||||
"/runtime/session/registration/revoke",
|
||||
signedRuntimeGuard(options.sessionManager, "runtime:revoke"),
|
||||
route((req, res) => {
|
||||
assertExactKeys(strictJsonObject(req), []);
|
||||
options.sessionManager.revokeRegistration(requiredRuntimeSessionId(res));
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
for (const module of modules) {
|
||||
for (const item of module.routes) {
|
||||
const guard = item.legacy
|
||||
? legacyOrSignedRuntimeGuard(options.sessionManager, item.scope, {
|
||||
queryKeys: item.queryKeys,
|
||||
masterToken: options.legacyMasterToken,
|
||||
origins: options.legacyOrigins,
|
||||
})
|
||||
: signedRuntimeGuard(options.sessionManager, item.scope, {
|
||||
queryKeys: item.queryKeys,
|
||||
});
|
||||
const handlers = [
|
||||
guard,
|
||||
item.handler,
|
||||
];
|
||||
if (item.method === "GET") app.get(item.path, ...handlers);
|
||||
else app.post(item.path, ...handlers);
|
||||
}
|
||||
}
|
||||
|
||||
app.use((_req, res) => res.status(404).json({ ok: false, code: "not_found", message: "未找到本机运行时路由" }));
|
||||
app.use(runtimeErrorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function validateModules(modules: readonly LocalRuntimeModule[]) {
|
||||
const ids = new Set<LocalRuntimeModuleId>();
|
||||
const routes = new Set<string>();
|
||||
for (const module of modules) {
|
||||
if (module.descriptor.id !== "canvas-agent" && module.descriptor.id !== "dreamina") {
|
||||
throw new Error(`Unsupported Local Runtime module id: ${module.descriptor.id}`);
|
||||
}
|
||||
if (ids.has(module.descriptor.id)) throw new Error(`Duplicate Local Runtime module id: ${module.descriptor.id}`);
|
||||
ids.add(module.descriptor.id);
|
||||
for (const item of module.routes) {
|
||||
const key = `${item.method} ${item.path}`;
|
||||
if (routes.has(key)) throw new Error(`Duplicate Local Runtime route: ${key}`);
|
||||
if (!item.path.startsWith("/")
|
||||
|| item.path.includes("?")
|
||||
|| item.path.split("/").some((segment) => segment.includes(":") && !/^:[A-Za-z][A-Za-z0-9]*$/.test(segment))) {
|
||||
throw new Error(`Invalid Local Runtime route: ${key}`);
|
||||
}
|
||||
if (!module.descriptor.scopes.includes(item.scope)) {
|
||||
throw new Error(`Undeclared Local Runtime route scope: ${key}`);
|
||||
}
|
||||
routes.add(key);
|
||||
}
|
||||
}
|
||||
return [...modules];
|
||||
}
|
||||
|
||||
function corsPolicies(modules: readonly LocalRuntimeModule[], legacyOrigins: readonly string[]) {
|
||||
const policies = new Map<string, RuntimeCorsPolicy>([
|
||||
["/runtime/info", { methods: ["GET"], headers: [], publicInfo: true }],
|
||||
["/health", { methods: ["GET"], headers: [], legacyOrigins }],
|
||||
["/config", { methods: ["GET"], headers: [], legacyOrigins }],
|
||||
["/runtime/session/challenge", { methods: ["POST"], headers: ["content-type"], trustedOrigin: true }],
|
||||
["/runtime/session/exchange", { methods: ["POST"], headers: ["content-type"], trustedOrigin: true }],
|
||||
["/runtime/status", { methods: ["GET"], headers: protectedCorsHeaders("GET"), trustedOrigin: true }],
|
||||
["/runtime/session/revoke", { methods: ["POST"], headers: protectedCorsHeaders("POST"), trustedOrigin: true }],
|
||||
["/runtime/session/registration/revoke", { methods: ["POST"], headers: protectedCorsHeaders("POST"), trustedOrigin: true }],
|
||||
]);
|
||||
for (const module of modules) {
|
||||
for (const item of module.routes) {
|
||||
policies.set(item.path, {
|
||||
methods: [item.method],
|
||||
headers: protectedCorsHeaders(item.method, item.lastEventId),
|
||||
trustedOrigin: true,
|
||||
...(item.legacy ? {
|
||||
legacyOrigins,
|
||||
legacyHeaders: item.method === "POST" ? ["content-type", "x-canvas-agent-token"] : ["x-canvas-agent-token"],
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return policies;
|
||||
}
|
||||
|
||||
function route(handler: (req: Request, res: Response) => void | Promise<void>): RequestHandler {
|
||||
return (req, res, next) => void Promise.resolve(handler(req, res)).catch(next);
|
||||
}
|
||||
|
||||
function requiredOrigin(req: Request) {
|
||||
const value = req.headers.origin;
|
||||
if (typeof value !== "string") throw requestInvalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredRuntimeSessionId(res: Response) {
|
||||
const value = (res.locals.runtimeSession as { sessionId?: unknown } | undefined)?.sessionId;
|
||||
if (typeof value !== "string" || !value) throw requestInvalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestInvalid() {
|
||||
return new LocalRuntimeSessionError("request_invalid", "请求字段无效", 400);
|
||||
}
|
||||
Reference in New Issue
Block a user