mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Mount IM RPC channels via plugin webServer to fix HTTP 405.
connection.rpc.handle looks up webServer on a Cordis fiber that does not inject it, so channel routes never registered after host restart. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dd2365407e
commit
6107692418
18 changed files with 666 additions and 330 deletions
|
|
@ -571,7 +571,7 @@ var React25 = __toESM(require("react"), 1);
|
|||
// package.json
|
||||
var package_default = {
|
||||
name: "dsh-im-ops",
|
||||
version: "4.9.1-ops.30",
|
||||
version: "4.9.1-ops.31",
|
||||
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
|
||||
keywords: [
|
||||
"deepseek-harness",
|
||||
|
|
@ -637,6 +637,7 @@ var package_default = {
|
|||
"plugin-src",
|
||||
"scripts",
|
||||
"src",
|
||||
".npmrc",
|
||||
"PROACTIVE_DELIVERY.md",
|
||||
"PROACTIVE_DELIVERY.en.md",
|
||||
"LICENSE",
|
||||
|
|
@ -676,7 +677,6 @@ var package_default = {
|
|||
undici: "7.29.0"
|
||||
},
|
||||
devDependencies: {
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||
esbuild: "0.25.9",
|
||||
|
|
|
|||
462
lib/index.js
462
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
|
|||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
import {
|
||||
|
|
@ -336,12 +337,10 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
}
|
||||
|
||||
export function installDingtalkRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
createDingtalkRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { publicConnectionTestResult } from '../../../../src/channels/shared/conn
|
|||
import { publicMessageFailure } from '../../../../src/channels/shared/message-failure.mjs';
|
||||
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
import { validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
|
|
@ -738,12 +739,10 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
|
||||
/** Register the `/feishu` logical channel with its configured browser authority. */
|
||||
export function installFeishuRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
createFeishuRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS } from '../../../../src/channels/office/protocol.mjs';
|
||||
|
||||
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
||||
|
|
@ -40,9 +41,10 @@ export function createOfficeRpcHandler(controller) {
|
|||
}
|
||||
|
||||
export function installOfficeRpc(ctx, controller, authority) {
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
OFFICE_RPC_CHANNEL,
|
||||
createOfficeRpcHandler(controller),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
|
|||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
||||
|
|
@ -227,12 +228,10 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|||
}
|
||||
|
||||
export function installQqRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
QQ_RPC_CHANNEL,
|
||||
createQqRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from './context-enhancement-rpc.mjs';
|
||||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from './access-policy-rpc.mjs';
|
||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from './group-session-scope-rpc.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
|
|
@ -197,12 +198,10 @@ export function createTokenBotRpcHandler(controller, { channel }) {
|
|||
}
|
||||
|
||||
export function installTokenBotRpc(ctx, controller, { channel, rpcChannel, authority }) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
rpcChannel,
|
||||
createTokenBotRpcHandler(controller, { channel }),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
|
|||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
publicWorkspaceError,
|
||||
|
|
@ -201,12 +202,10 @@ export function createSlackRpcHandler(controller) {
|
|||
}
|
||||
|
||||
export function installSlackRpc(ctx, controller, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
SLACK_RPC_CHANNEL,
|
||||
createSlackRpcHandler(controller),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
createTokenBotRpcHandler,
|
||||
} from '../shared/rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
|
||||
export const TELEGRAM_RPC_CHANNEL = '/telegram';
|
||||
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
|
||||
|
|
@ -13,12 +14,10 @@ export function createTelegramRpcHandler(controller) {
|
|||
}
|
||||
|
||||
export function installTelegramRpc(ctx, controller, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
TELEGRAM_RPC_CHANNEL,
|
||||
createTelegramRpcHandler(controller),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
|
|||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
import {
|
||||
|
|
@ -228,12 +229,10 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
|
|||
}
|
||||
|
||||
export function installWecomRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
WECOM_RPC_CHANNEL,
|
||||
createWecomRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import QRCode from 'qrcode';
|
|||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
||||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import {
|
||||
publicWorkspaceError,
|
||||
|
|
@ -260,12 +261,10 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
|
|||
}
|
||||
|
||||
export function installWeixinRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
WEIXIN_RPC_CHANNEL,
|
||||
createWeixinRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { SET_GROUP_SESSION_SCOPE_ENDPOINT, validGroupSessionScopePayload } from '../shared/group-session-scope-rpc.mjs';
|
||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
||||
|
|
@ -282,12 +283,10 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
}
|
||||
|
||||
export function installWhatsappRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
WHATSAPP_RPC_CHANNEL,
|
||||
createWhatsappRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
200
plugin-src/host/connection-rpc-mount.mjs
Normal file
200
plugin-src/host/connection-rpc-mount.mjs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Mount a Connection-compatible unary RPC channel via webServer.
|
||||
*
|
||||
* DSH `connection.rpc.handle` registers with `owner.webServer`, but Cordis
|
||||
* rebinds `connection.ctx` onto the Connection fiber (which does not inject
|
||||
* webServer). That throws "cannot get property webServer without inject" and
|
||||
* the browser sees empty HTTP 405 from frontend-static.
|
||||
*
|
||||
* This helper registers the same POST JSON envelope on the plugin fiber that
|
||||
* already injects `webServer`, and still uses `connection.requestRejection`
|
||||
* for Host/Origin + browser auth.
|
||||
*/
|
||||
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
||||
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
const INVALID_REQUEST_RPC_ID = 'invalid-request';
|
||||
|
||||
function assertChannel(channel) {
|
||||
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
|
||||
throw new TypeError(`dsh-im: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromPath(channel, pathname) {
|
||||
if (!pathname.startsWith(`${channel}/`)) return undefined;
|
||||
const endpoint = pathname.slice(channel.length + 1);
|
||||
const segments = endpoint.split('/');
|
||||
if (segments.some((segment) => (
|
||||
segment === ''
|
||||
|| segment === '.'
|
||||
|| segment === '..'
|
||||
|| !ENDPOINT_SEGMENT_PATTERN.test(segment)
|
||||
))) {
|
||||
return undefined;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function isJsonContentType(value) {
|
||||
return typeof value === 'string'
|
||||
&& value.split(';', 1)[0].trim().toLowerCase() === 'application/json';
|
||||
}
|
||||
|
||||
function writeRaw(res, status, body, headers = {}) {
|
||||
if (res.destroyed || res.writableEnded) return;
|
||||
res.writeHead(status, headers);
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function writeJson(res, status, value) {
|
||||
const body = JSON.stringify(value);
|
||||
writeRaw(res, status, body, {
|
||||
'cache-control': 'no-store',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
||||
const error = new Error('payload-too-large');
|
||||
error.status = 413;
|
||||
throw error;
|
||||
}
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
length += buffer.length;
|
||||
if (length > MAX_BODY_BYTES) {
|
||||
const error = new Error('payload-too-large');
|
||||
error.status = 413;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
|
||||
} catch {
|
||||
const error = new Error('body is not JSON');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isClientRequest(value) {
|
||||
return value !== null
|
||||
&& typeof value === 'object'
|
||||
&& !Array.isArray(value)
|
||||
&& value.type === 'client-request'
|
||||
&& typeof value.rpcId === 'string'
|
||||
&& typeof value.method === 'string'
|
||||
&& Object.prototype.hasOwnProperty.call(value, 'payload');
|
||||
}
|
||||
|
||||
function serverResponse(rpcId, result) {
|
||||
return { type: 'server-response', rpcId, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ctx Cordis context with webServer + connection
|
||||
* @param {string} channel e.g. `/weixin`
|
||||
* @param {(endpoint: string, payload: unknown, signal?: AbortSignal) => Promise<{ok: boolean, value?: unknown, error?: object}>} handler
|
||||
* @returns {() => Promise<void>} disposer
|
||||
*/
|
||||
export function installConnectionRpcChannel(ctx, channel, handler) {
|
||||
assertChannel(channel);
|
||||
if (typeof handler !== 'function') {
|
||||
throw new TypeError('dsh-im RPC handler must be a function');
|
||||
}
|
||||
if (!ctx?.webServer || typeof ctx.webServer.register !== 'function') {
|
||||
throw new TypeError('DSH Host webServer is required to mount IM RPC channels');
|
||||
}
|
||||
if (typeof ctx?.effect !== 'function') {
|
||||
throw new TypeError('DSH Host ctx.effect is required to mount IM RPC channels');
|
||||
}
|
||||
const connection = ctx.connection;
|
||||
if (!connection || typeof connection.requestRejection !== 'function') {
|
||||
throw new TypeError('DSH Host connection.requestRejection is required to mount IM RPC channels');
|
||||
}
|
||||
|
||||
const route = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
const rejection = connection.requestRejection(req);
|
||||
if (rejection !== undefined) {
|
||||
writeRaw(res, rejection, rejection === 401 ? 'unauthorized' : 'forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
||||
const endpoint = endpointFromPath(channel, pathname);
|
||||
if (req.method !== 'POST' || endpoint === undefined) {
|
||||
writeRaw(res, 404, 'not found');
|
||||
return;
|
||||
}
|
||||
if (!isJsonContentType(req.headers['content-type'])) {
|
||||
writeRaw(res, 415, 'content type must be application/json');
|
||||
return;
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
const onClose = () => {
|
||||
if (!res.writableEnded) abort.abort();
|
||||
};
|
||||
res.once('close', onClose);
|
||||
try {
|
||||
let body;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch (error) {
|
||||
writeRaw(res, error.status === 413 ? 413 : 400, error.message || 'bad request');
|
||||
return;
|
||||
}
|
||||
if (!isClientRequest(body)) {
|
||||
writeJson(res, 200, serverResponse(
|
||||
typeof body?.rpcId === 'string' ? body.rpcId : INVALID_REQUEST_RPC_ID,
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'gateway/bad-request',
|
||||
message: 'invalid client-request message',
|
||||
details: { issues: [] },
|
||||
},
|
||||
},
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (body.method !== endpoint) {
|
||||
writeJson(res, 200, serverResponse(body.rpcId, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'gateway/bad-request',
|
||||
message: `method ${JSON.stringify(body.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
||||
details: { issues: [] },
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, body.payload, abort.signal);
|
||||
writeJson(res, 200, serverResponse(body.rpcId, result));
|
||||
} catch (error) {
|
||||
writeRaw(res, 500, `handler failure: ${String(error)}`);
|
||||
}
|
||||
} finally {
|
||||
res.off('close', onClose);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return ctx.effect(
|
||||
() => ctx.webServer.register(route),
|
||||
`dsh-im: ${channel} rpc channel`,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { resolveRpcAuthority } from './rpc-authority.mjs';
|
||||
import { installConnectionRpcChannel } from './connection-rpc-mount.mjs';
|
||||
|
||||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||||
export const DELIVERY_TEST_MESSAGE = 'DSH-IM 主动投递测试成功。';
|
||||
|
|
@ -147,12 +148,10 @@ export function createDeliveryRpcHandler(service) {
|
|||
}
|
||||
|
||||
export function installDeliveryRpc(ctx, service, { authority } = {}) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
resolveRpcAuthority(authority);
|
||||
return installConnectionRpcChannel(
|
||||
ctx,
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
createDeliveryRpcHandler(service),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const inject = [
|
|||
'connection',
|
||||
'credentials',
|
||||
'typertGateway',
|
||||
// Plugin-side RPC mounts use ctx.webServer.register (see connection-rpc-mount.mjs).
|
||||
'webServer',
|
||||
];
|
||||
|
||||
function channelConfig(config, name, deliveryService) {
|
||||
|
|
@ -188,21 +190,11 @@ export function createImHostPlugin(internals = {}) {
|
|||
},
|
||||
}));
|
||||
}
|
||||
const activate = async (readyCtx) => {
|
||||
await activateChannels(readyCtx, config, deliveryService);
|
||||
};
|
||||
if (typeof ctx?.inject === 'function') {
|
||||
const modern = typeof ctx?.typertGateway?.stream === 'function';
|
||||
await ctx.inject(
|
||||
modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
|
||||
activate,
|
||||
);
|
||||
ctx.inject(['webServer'], (httpCtx) => {
|
||||
startDeliveryHttp(httpCtx, deliveryService);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await activate(ctx);
|
||||
|
||||
// Activate on this fiber (already injects webServer). Do not defer to a
|
||||
// sessionController child fiber — that fiber cannot read webServer, so
|
||||
// every channel rpc.handle throws and the UI sees HTTP 405.
|
||||
await activateChannels(ctx, config, deliveryService);
|
||||
if (ctx?.webServer?.register && typeof ctx?.effect === 'function') {
|
||||
startDeliveryHttp(ctx, deliveryService);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { createUpdateRuntime } from './update-runtime.mjs';
|
||||
import { createUpdateService } from './update-service.mjs';
|
||||
import { installConnectionRpcChannel } from './connection-rpc-mount.mjs';
|
||||
import { resolveRpcAuthority } from './rpc-authority.mjs';
|
||||
|
||||
export const UPDATE_RPC_CHANNEL = '/dsh-im';
|
||||
export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']);
|
||||
|
|
@ -42,9 +44,12 @@ export function createUpdateRpcHandler(service) {
|
|||
export function installUpdateRpc(ctx, options = {}) {
|
||||
const runtime = options.runtime ?? createUpdateRuntime({ ctx, moduleUrl: import.meta.url });
|
||||
const service = options.service ?? createUpdateService({ runtime });
|
||||
const dispose = ctx.connection.rpc.handle(UPDATE_RPC_CHANNEL, createUpdateRpcHandler(service), {
|
||||
authority: 'loopback',
|
||||
});
|
||||
resolveRpcAuthority('loopback');
|
||||
const dispose = installConnectionRpcChannel(
|
||||
ctx,
|
||||
UPDATE_RPC_CHANNEL,
|
||||
createUpdateRpcHandler(service),
|
||||
);
|
||||
ctx.effect(() => () => service.close(), 'dsh-im: close update installer');
|
||||
return dispose;
|
||||
}
|
||||
|
|
|
|||
133
test/connection-rpc-mount.test.mjs
Normal file
133
test/connection-rpc-mount.test.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import { installConnectionRpcChannel } from '../plugin-src/host/connection-rpc-mount.mjs';
|
||||
|
||||
function mockRequest({ method = 'POST', url, headers = {}, body }) {
|
||||
const req = new EventEmitter();
|
||||
req.method = method;
|
||||
req.url = url;
|
||||
req.headers = { 'content-type': 'application/json', ...headers };
|
||||
queueMicrotask(() => {
|
||||
if (body !== undefined) req.emit('data', Buffer.from(body));
|
||||
req.emit('end');
|
||||
});
|
||||
req[Symbol.asyncIterator] = async function* () {
|
||||
if (body !== undefined) yield Buffer.from(body);
|
||||
};
|
||||
return req;
|
||||
}
|
||||
|
||||
function mockResponse() {
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = undefined;
|
||||
res.headers = undefined;
|
||||
res.body = undefined;
|
||||
res.writableEnded = false;
|
||||
res.destroyed = false;
|
||||
res.writeHead = (status, headers) => {
|
||||
res.statusCode = status;
|
||||
res.headers = headers;
|
||||
};
|
||||
res.end = (body) => {
|
||||
res.body = body;
|
||||
res.writableEnded = true;
|
||||
res.emit('finish');
|
||||
};
|
||||
res.off = res.removeListener.bind(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function createCtx({ rejection } = {}) {
|
||||
const routes = [];
|
||||
const effects = [];
|
||||
return {
|
||||
routes,
|
||||
effects,
|
||||
connection: {
|
||||
requestRejection() {
|
||||
return rejection;
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
register(route) {
|
||||
routes.push(route);
|
||||
return () => {
|
||||
const index = routes.indexOf(route);
|
||||
if (index >= 0) routes.splice(index, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
effect(factory, label) {
|
||||
const dispose = factory();
|
||||
effects.push({ label, dispose });
|
||||
return async () => {
|
||||
await dispose?.();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('installConnectionRpcChannel mounts a POST JSON RPC channel on webServer', async () => {
|
||||
const ctx = createCtx();
|
||||
const calls = [];
|
||||
installConnectionRpcChannel(ctx, '/weixin', async (endpoint, payload) => {
|
||||
calls.push([endpoint, payload]);
|
||||
return { ok: true, value: { endpoint, payload } };
|
||||
});
|
||||
|
||||
assert.equal(ctx.routes.length, 1);
|
||||
assert.equal(ctx.routes[0].kind, 'prefix');
|
||||
assert.equal(ctx.routes[0].path, '/weixin');
|
||||
assert.equal(ctx.effects[0].label, 'dsh-im: /weixin rpc channel');
|
||||
|
||||
const req = mockRequest({
|
||||
url: '/weixin/connection.status',
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'rpc-1',
|
||||
method: 'connection.status',
|
||||
payload: { botId: 'b1' },
|
||||
}),
|
||||
});
|
||||
const res = mockResponse();
|
||||
await ctx.routes[0].handler(req, res);
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-1',
|
||||
result: { ok: true, value: { endpoint: 'connection.status', payload: { botId: 'b1' } } },
|
||||
});
|
||||
assert.deepEqual(calls, [['connection.status', { botId: 'b1' }]]);
|
||||
});
|
||||
|
||||
test('installConnectionRpcChannel rejects unauthenticated requests via connection.requestRejection', async () => {
|
||||
const ctx = createCtx({ rejection: 401 });
|
||||
installConnectionRpcChannel(ctx, '/weixin', async () => ({ ok: true, value: {} }));
|
||||
|
||||
const req = mockRequest({
|
||||
url: '/weixin/connection.status',
|
||||
body: '{}',
|
||||
});
|
||||
const res = mockResponse();
|
||||
await ctx.routes[0].handler(req, res);
|
||||
|
||||
assert.equal(res.statusCode, 401);
|
||||
assert.equal(res.body, 'unauthorized');
|
||||
});
|
||||
|
||||
test('installConnectionRpcChannel requires webServer and connection.requestRejection', () => {
|
||||
assert.throws(
|
||||
() => installConnectionRpcChannel({ effect() {} }, '/weixin', async () => ({})),
|
||||
/webServer is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => installConnectionRpcChannel({
|
||||
effect() {},
|
||||
webServer: { register() {} },
|
||||
}, '/weixin', async () => ({})),
|
||||
/requestRejection is required/,
|
||||
);
|
||||
});
|
||||
|
|
@ -43,6 +43,7 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
'connection',
|
||||
'credentials',
|
||||
'typertGateway',
|
||||
'webServer',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
|
|
@ -83,7 +84,10 @@ test('Host provides #65 and installs #84 with the same delivery service', async
|
|||
installDeliveryHttp: (...args) => http.push(args),
|
||||
});
|
||||
const ctx = {
|
||||
connection: { rpc: {} },
|
||||
connection: {
|
||||
rpc: {},
|
||||
requestRejection() {},
|
||||
},
|
||||
webServer: { register() {} },
|
||||
effect() {},
|
||||
provide: (...args) => provided.push(args),
|
||||
|
|
@ -109,9 +113,11 @@ test('#65 activates a real Cordis consumer without crossing the Connection RPC',
|
|||
handle: () => async () => {},
|
||||
call: (...args) => rpcCalls.push(args),
|
||||
},
|
||||
requestRejection() {},
|
||||
});
|
||||
ctx.provide('credentials', {});
|
||||
ctx.provide('typertGateway', { stream() {} });
|
||||
ctx.provide('webServer', { register() { return () => {}; } });
|
||||
ctx.provide('sessionController', {});
|
||||
ctx.provide('workspaceController', {});
|
||||
|
||||
|
|
@ -131,6 +137,7 @@ test('#65 activates a real Cordis consumer without crossing the Connection RPC',
|
|||
createDeliveryService: () => deliveryService,
|
||||
installUpdateRpc: () => async () => {},
|
||||
installDeliveryRpc: () => async () => {},
|
||||
installDeliveryHttp: () => async () => {},
|
||||
});
|
||||
|
||||
const host = ctx.plugin(createImHostPlugin(internals));
|
||||
|
|
@ -153,34 +160,41 @@ test('#65 activates a real Cordis consumer without crossing the Connection RPC',
|
|||
assert.equal(rpcCalls.length, 0);
|
||||
});
|
||||
|
||||
test('Host waits for apiProxy on legacy Harness and Controllers on modern Harness', async () => {
|
||||
for (const [modern, expected] of [
|
||||
[false, ['apiProxy']],
|
||||
[true, ['sessionController', 'workspaceController']],
|
||||
]) {
|
||||
const injections = [];
|
||||
test('Host activates channels on its own fiber (injects webServer) so RPC routes can mount', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin(Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
const deliveryHttp = [];
|
||||
const plugin = createImHostPlugin({
|
||||
...Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
applyName,
|
||||
async () => calls.push(channel),
|
||||
])));
|
||||
])),
|
||||
installDeliveryHttp: (ctx) => deliveryHttp.push(ctx),
|
||||
installUpdateRpc: () => async () => {},
|
||||
installDeliveryRpc: () => async () => {},
|
||||
});
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
typertGateway: modern ? { stream() {} } : { invoke() {} },
|
||||
inject(dependencies, callback) {
|
||||
injections.push(dependencies);
|
||||
if (dependencies.includes('tools') || dependencies.includes('webServer')) return {};
|
||||
return {
|
||||
then(resolve, reject) {
|
||||
Promise.resolve(callback(ctx)).then(resolve, reject);
|
||||
typertGateway: { stream() {}, invoke() {} },
|
||||
connection: {
|
||||
rpc: { handle: () => async () => {} },
|
||||
requestRejection() {},
|
||||
},
|
||||
};
|
||||
webServer: { register() {} },
|
||||
effect() {},
|
||||
inject(dependencies, callback) {
|
||||
if (dependencies.includes('tools') || dependencies.includes('systemPrompt')) return {};
|
||||
return callback(ctx);
|
||||
},
|
||||
};
|
||||
await plugin.apply(ctx, {});
|
||||
assert.deepEqual(injections[0], expected);
|
||||
assert.deepEqual(plugin.inject, [
|
||||
'connection',
|
||||
'credentials',
|
||||
'typertGateway',
|
||||
'webServer',
|
||||
]);
|
||||
assert.equal(deliveryHttp.length, 1);
|
||||
assert.deepEqual(calls, CHANNELS.map(([channel]) => channel));
|
||||
}
|
||||
});
|
||||
|
||||
const CHANNELS = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue