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:
oliver 2026-09-14 21:32:43 +08:00
parent dd2365407e
commit 6107692418
18 changed files with 666 additions and 330 deletions

View file

@ -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",

File diff suppressed because one or more lines are too long

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View file

@ -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) },
);
}

View 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`,
);
}

View file

@ -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) },
);
}

View file

@ -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);
}

View file

@ -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;
}

View 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/,
);
});

View file

@ -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 = [