mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +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
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 = [];
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin(Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
test('Host activates channels on its own fiber (injects webServer) so RPC routes can mount', async () => {
|
||||
const calls = [];
|
||||
const deliveryHttp = [];
|
||||
const plugin = createImHostPlugin({
|
||||
...Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
applyName,
|
||||
async () => calls.push(channel),
|
||||
])));
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await plugin.apply(ctx, {});
|
||||
assert.deepEqual(injections[0], expected);
|
||||
assert.deepEqual(calls, CHANNELS.map(([channel]) => channel));
|
||||
}
|
||||
])),
|
||||
installDeliveryHttp: (ctx) => deliveryHttp.push(ctx),
|
||||
installUpdateRpc: () => async () => {},
|
||||
installDeliveryRpc: () => async () => {},
|
||||
});
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
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(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