mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-15 02:13:17 +08:00
fix: improve WhatsApp settings and isolate IM host authentication
Some checks are pending
CI / Test, build, and verify package (push) Waiting to run
Some checks are pending
CI / Test, build, and verify package (push) Waiting to run
This commit is contained in:
parent
bcbe85fdb1
commit
bf73b0c0af
28 changed files with 848 additions and 116 deletions
|
|
@ -49,7 +49,7 @@ test('all legacy channel clients now use the shared Harness RPC transport', asyn
|
|||
}
|
||||
});
|
||||
|
||||
test('shared Harness health checks expose precise safe availability codes', async () => {
|
||||
test('shared Harness health checks expose precise safe availability codes', async (t) => {
|
||||
const clientWithFetch = (fetchImpl, baseUrl = 'http://127.0.0.1:3080') => new HarnessClient({
|
||||
baseUrl,
|
||||
workspace: '/tmp/default-workspace',
|
||||
|
|
@ -73,6 +73,9 @@ test('shared Harness health checks expose precise safe availability codes', asyn
|
|||
if (signal.aborted) rejectTimeout();
|
||||
else signal.addEventListener('abort', rejectTimeout, { once: true });
|
||||
}));
|
||||
// AbortSignal.timeout is unref'ed; the mock has no socket to keep Node alive.
|
||||
const keepAlive = setTimeout(() => {}, 100);
|
||||
t.after(() => clearTimeout(keepAlive));
|
||||
await assert.rejects(timeoutClient.rpc('host.describe', {}, 1), (error) => {
|
||||
assert.ok(error instanceof HarnessTransportError);
|
||||
assert.equal(error.code, 'harness-timeout');
|
||||
|
|
@ -191,6 +194,29 @@ test('HarnessClient forwards a per-session agent preset override', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('HarnessClient retries only verified preset creation failures', async () => {
|
||||
for (const code of ['agent-preset/not-found', 'internal', 'gateway/internal', 'harness-http-failed']) {
|
||||
const client = new HarnessClient({ baseUrl: 'http://127.0.0.1:3080', agentPreset: 'broken' });
|
||||
client.ensureRunning = async () => true;
|
||||
client.workspaceId = async () => 'workspace';
|
||||
const calls = [];
|
||||
client.rpc = async (_method, payload) => {
|
||||
calls.push(payload);
|
||||
if (calls.length === 1) throw Object.assign(new Error('create failed'), { code });
|
||||
return { sessionId: 'recovered' };
|
||||
};
|
||||
if (code.startsWith('agent-preset/')) {
|
||||
assert.equal(await client.createSession(), 'recovered');
|
||||
assert.deepEqual(calls, [
|
||||
{ workspaceId: 'workspace', agentPreset: 'broken' }, { workspaceId: 'workspace' },
|
||||
]);
|
||||
} else {
|
||||
await assert.rejects(client.createSession(), { code });
|
||||
assert.equal(calls.length, 1, code);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('HarnessClient lists only absolute workspace paths', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
|
|
|
|||
178
test/channels/whatsapp/access-grant-settings.test.mjs
Normal file
178
test/channels/whatsapp/access-grant-settings.test.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { AccessGrantSettingsPage } from '../../../plugin-src/client/access-grant-settings.js';
|
||||
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const groupJid = '120363111111111111@g.us';
|
||||
const otherGroupJid = '120363222222222222@g.us';
|
||||
const ownerPhone = '8618111111111';
|
||||
const textOf = node => typeof node === 'string' || typeof node === 'number'
|
||||
? String(node) : node?.children?.map(textOf).join('') ?? '';
|
||||
const button = (root, text) => root.findAllByType('button').find(node => textOf(node) === text);
|
||||
const tab = (root, label) => root.findAllByProps({ role: 'tab' })
|
||||
.find(node => textOf(node).startsWith(label));
|
||||
async function flush() { for (let i = 0; i < 12; i += 1) await Promise.resolve(); }
|
||||
|
||||
function fixture() {
|
||||
return {
|
||||
version: 1, globalAdmins: [ownerPhone],
|
||||
directMembers: [{ phone: '8618222222222', canExecuteCommands: true }],
|
||||
groups: {
|
||||
[groupJid]: { title: 'Ops', agentPreset: 'netxops', admins: [], members: [] },
|
||||
[otherGroupJid]: { title: 'Support', admins: [], members: [] },
|
||||
},
|
||||
pending: [{ id: 'p_test01', kind: 'direct', phone: '8618444444444',
|
||||
createdAt: '2026-10-11T00:00:00.000Z', status: 'pending', notifyRefs: [] }],
|
||||
contacts: Array.from({ length: 35 }, (_, index) => ({
|
||||
phone: `861900000${String(index).padStart(4, '0')}`,
|
||||
pushName: index === 34 ? 'Alice' : `Contact ${index}`,
|
||||
scenes: ['direct'], groupJids: [], lids: [], lastSeenAt: '2026-10-11T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(t, options = {}) {
|
||||
const calls = [];
|
||||
let savedGrant = fixture();
|
||||
let savedScope = 'user_in_chat';
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status' && options.load) return options.load;
|
||||
if (endpoint === 'bot.access-grant.set') {
|
||||
if (options.failSave) throw new Error('Save rejected');
|
||||
savedGrant = payload.grant;
|
||||
}
|
||||
if (endpoint === 'bot.group-session-scope.set') savedScope = payload.groupSessionScope;
|
||||
return { ok: true, value: {
|
||||
bots: [{ botId: 'wa-test', accessGrant: savedGrant, groupSessionScope: savedScope }],
|
||||
agentPresetCatalog: { defaultId: 'netxops', items: [{ id: 'netxops' }, { id: 'safe' }] },
|
||||
} };
|
||||
};
|
||||
function Page() {
|
||||
const [accessGrant, setAccessGrant] = React.useState(savedGrant);
|
||||
return React.createElement(AccessGrantSettingsPage, {
|
||||
channel: 'whatsapp', account: {
|
||||
botId: 'wa-test', phone: ownerPhone, accessGrant, groupSessionScope: 'user_in_chat',
|
||||
}, rpcCall, onSaved: setAccessGrant,
|
||||
});
|
||||
}
|
||||
let renderer;
|
||||
await act(async () => { renderer = create(React.createElement(Page)); await flush(); });
|
||||
t.after(async () => { await act(async () => renderer.unmount()); });
|
||||
return { renderer, calls };
|
||||
}
|
||||
|
||||
test('access sections support keyboard navigation and preserve edits across sections', async t => {
|
||||
const { renderer, calls } = await mount(t);
|
||||
const root = renderer.root;
|
||||
assert.equal(root.findAllByProps({ role: 'tabpanel' }).filter(node => !node.props.hidden).length, 1);
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, true);
|
||||
await act(async () => tab(root, '私聊与管理员').props.onKeyDown({ key: 'ArrowRight', preventDefault() {} }));
|
||||
assert.equal(tab(root, '群授权').props['aria-selected'], true);
|
||||
const preset = root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset');
|
||||
await act(async () => preset.props.onChange({ target: { value: 'safe' } }));
|
||||
await act(async () => tab(root, '最近联系人').props.onClick());
|
||||
await act(async () => tab(root, '群授权').props.onClick());
|
||||
assert.equal(root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset').props.value, 'safe');
|
||||
assert.match(textOf(root.findByProps({ className: 'dim-waAccessSaveState' })), /未保存/);
|
||||
assert.equal(calls.filter(call => call.endpoint !== 'connection.status').length, 0);
|
||||
});
|
||||
|
||||
test('saving retains the group preset and session scope when the parent updates its grant', async t => {
|
||||
const { renderer, calls } = await mount(t);
|
||||
const root = renderer.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset')
|
||||
.props.onChange({ target: { value: 'safe' } });
|
||||
root.findAllByType('select').find(node => node.props.value === 'user_in_chat')
|
||||
.props.onChange({ target: { value: 'chat' } });
|
||||
});
|
||||
await act(async () => { root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); });
|
||||
const grantCall = calls.find(call => call.endpoint === 'bot.access-grant.set');
|
||||
assert.equal(grantCall.payload.grant.groups[groupJid].agentPreset, 'safe');
|
||||
assert.deepEqual(calls.find(call => call.endpoint === 'bot.group-session-scope.set').payload,
|
||||
{ botId: 'wa-test', groupSessionScope: 'chat' });
|
||||
assert.ok(root.findAllByType('select').some(node => node.props.value === 'chat'));
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, true);
|
||||
assert.match(textOf(root.findByProps({ className: 'dim-waAccessSaveState' })), /所有修改已保存/);
|
||||
});
|
||||
|
||||
test('unsaved edits block approval and group sync; discard restores the last saved configuration', async t => {
|
||||
const { renderer, calls } = await mount(t);
|
||||
const root = renderer.root;
|
||||
await act(async () => root.findAllByType('input').find(node => node.props.value === 'Ops')
|
||||
.props.onChange({ target: { value: 'Draft title' } }));
|
||||
assert.equal(button(root, '批准').props.disabled, true);
|
||||
assert.equal(button(root, '同步群名').props.disabled, true);
|
||||
await act(async () => { button(root, '批准').props.onClick(); button(root, '同步群名').props.onClick(); await flush(); });
|
||||
assert.equal(calls.filter(call => call.endpoint !== 'connection.status').length, 0);
|
||||
await act(async () => button(root, '撤销修改').props.onClick());
|
||||
assert.ok(root.findAllByType('input').some(node => node.props.value === 'Ops'));
|
||||
assert.equal(button(root, '批准').props.disabled, false);
|
||||
assert.equal(button(root, '同步群名').props.disabled, false);
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, true);
|
||||
});
|
||||
|
||||
test('contact search reaches beyond the first thirty contacts and groups match names or JIDs', async t => {
|
||||
const { renderer } = await mount(t);
|
||||
const root = renderer.root;
|
||||
const contactTable = () => root.findByProps({ 'data-kind': 'contact' });
|
||||
assert.doesNotMatch(textOf(contactTable()), /Alice/);
|
||||
await act(async () => button(root, '显示更多联系人').props.onClick());
|
||||
assert.match(textOf(contactTable()), /Alice/);
|
||||
const contactSearch = root.findAllByType('input').find(node => node.props.placeholder === '搜索昵称、电话或群名称');
|
||||
await act(async () => contactSearch.props.onChange({ target: { value: 'alice' } }));
|
||||
assert.match(textOf(contactTable()), /Alice/);
|
||||
assert.doesNotMatch(textOf(contactTable()), /Contact 0/);
|
||||
const groupSearch = root.findAllByType('input').find(node => node.props.placeholder === '搜索群名称或 JID');
|
||||
await act(async () => groupSearch.props.onChange({ target: { value: 'Support' } }));
|
||||
assert.equal(root.findAllByType('details').length, 1);
|
||||
assert.match(textOf(root.findByType('details')), /Support/);
|
||||
await act(async () => groupSearch.props.onChange({ target: { value: groupJid } }));
|
||||
assert.match(textOf(root.findByType('details')), /Ops/);
|
||||
});
|
||||
|
||||
test('failed saves retain the draft, keep it dirty and expose the error', async t => {
|
||||
const { renderer } = await mount(t, { failSave: true });
|
||||
const root = renderer.root;
|
||||
await act(async () => root.findAllByType('input').find(node => node.props.value === 'Ops')
|
||||
.props.onChange({ target: { value: 'Unsaved title' } }));
|
||||
await act(async () => { root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); });
|
||||
assert.equal(textOf(root.findByProps({ role: 'alert' })), 'Save rejected');
|
||||
assert.ok(root.findAllByType('input').some(node => node.props.value === 'Unsaved title'));
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, false);
|
||||
});
|
||||
|
||||
test('phone suggestions filter names without matching every contact through an empty digit query', async t => {
|
||||
const { renderer } = await mount(t);
|
||||
const input = renderer.root.findAllByType('input').find(node => node.props.value === '8618222222222');
|
||||
await act(async () => {
|
||||
input.props.onFocus();
|
||||
input.props.onChange({ target: { value: 'Alice' } });
|
||||
});
|
||||
const suggestions = renderer.root.findByProps({ role: 'listbox' });
|
||||
assert.match(textOf(suggestions), /Alice/);
|
||||
assert.doesNotMatch(textOf(suggestions), /Contact 0/);
|
||||
});
|
||||
|
||||
test('access editing waits for status and new section labels translate into English', async t => {
|
||||
let resolveLoad;
|
||||
const load = new Promise(resolve => { resolveLoad = resolve; });
|
||||
setImTranslator(key => en[key] ?? key);
|
||||
t.after(() => setImTranslator(null));
|
||||
const { renderer, calls } = await mount(t, { load });
|
||||
assert.equal(renderer.root.findByProps({ className: 'dim-waAccessEditor' }).props.disabled, true);
|
||||
assert.doesNotMatch(textOf(renderer.root.findByProps({ role: 'tablist' })), /[\p{Script=Han}]/u);
|
||||
await act(async () => { renderer.root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); });
|
||||
assert.equal(calls.length, 1);
|
||||
await act(async () => {
|
||||
resolveLoad({ ok: true, value: { bots: [{ botId: 'wa-test', accessGrant: fixture(), groupSessionScope: 'user_in_chat' }] } });
|
||||
await flush();
|
||||
});
|
||||
assert.equal(renderer.root.findByProps({ className: 'dim-waAccessEditor' }).props.disabled, false);
|
||||
assert.equal(textOf(renderer.root.findByProps({ className: 'dim-waAccessSaveState' })), 'All changes saved');
|
||||
});
|
||||
|
|
@ -73,11 +73,27 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
|
|||
assert.match(markup, /class="dim-presetSelect"/);
|
||||
assert.match(markup, /默认模型/);
|
||||
assert.match(markup, /Agent Preset/);
|
||||
assert.match(markup, /会话默认设置/);
|
||||
assert.match(markup, /修改后自动保存/);
|
||||
assert.match(markup, /dwa-settingsGrid/);
|
||||
assert.match(markup, /<code>\/new<\/code>/);
|
||||
assert.match(markup, /aria-label="更多机器人设置"/);
|
||||
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('a connection check shows progress even when the last snapshot was connected', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WhatsappAccountCard, {
|
||||
account: {
|
||||
botId: 'wa-checking', connected: true, state: 'connected',
|
||||
bot: { name: 'Bot', idMasked: '1650••••0123' }, health: {},
|
||||
}, busy: 'reconnect',
|
||||
}));
|
||||
assert.match(markup, /正在检查连接/);
|
||||
assert.doesNotMatch(markup, /运行正常/);
|
||||
assert.match(markup, /data-tone="warning"/);
|
||||
});
|
||||
|
||||
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/whatsapp/index.js',
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
|
|||
await flush();
|
||||
});
|
||||
assert.ok(renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' }));
|
||||
assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true);
|
||||
assert.equal(renderer.root.findByProps({ className: 'dim-channelPickerSelect' }).props.value, 'weixin');
|
||||
});
|
||||
|
||||
test('access settings preserve independent mode drafts and save direct and group atomically', async (t) => {
|
||||
|
|
@ -287,7 +287,8 @@ test('access settings preserve independent mode drafts and save direct and group
|
|||
});
|
||||
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'tab' }).length, 2);
|
||||
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }).length, 2);
|
||||
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' })
|
||||
.filter(node => ['direct', 'group'].includes(node.props['data-scene'])).length, 2);
|
||||
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessOwnerNotice' }).length, 0);
|
||||
assert.equal(accessHelpButtons(renderer.root).length, 2);
|
||||
for (const [scene, title] of [['direct', '私聊'], ['group', '群聊']]) {
|
||||
|
|
@ -457,9 +458,12 @@ test('access settings preserve independent mode drafts and save direct and group
|
|||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
endpoint: 'bot.group-session-scope.set',
|
||||
payload: { botId: connectedAccount.botId, groupSessionScope: 'user_in_chat' },
|
||||
}]);
|
||||
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
|
||||
assert.match(textOf(feedback), /访问设置已保存/);
|
||||
assert.match(textOf(feedback), /访问与群会话设置已保存/);
|
||||
assert.equal(feedback.props['data-tone'], 'success');
|
||||
assert.equal(feedback.props.role, 'status');
|
||||
|
||||
|
|
|
|||
200
test/host-auth-context.test.mjs
Normal file
200
test/host-auth-context.test.mjs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import test from 'node:test';
|
||||
|
||||
import { harnessConnection } from '../plugin-src/host/harness-connection.mjs';
|
||||
import { createHarnessCommandExecutor } from '../plugin-src/host/harness-command-executor.mjs';
|
||||
import { HarnessClient } from '../src/channels/shared/harness-client.mjs';
|
||||
import { watchHarnessMux } from '../src/channels/shared/harness-mux.mjs';
|
||||
import { classifyMessageFailure } from '../src/channels/shared/message-failure.mjs';
|
||||
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
|
||||
import { runHarnessHostOperation } from '../plugin-src/host/harness-host-context.mjs';
|
||||
import { WhatsappHarnessClient } from '../src/channels/whatsapp/harness-client.mjs';
|
||||
import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs';
|
||||
|
||||
function authFixture() {
|
||||
const storage = new AsyncLocalStorage();
|
||||
return { storage, auth: { runAsHost: (operation) => storage.run(undefined, operation) } };
|
||||
}
|
||||
|
||||
test('optional UDS lookup never reads a service property that requires Cordis inject', async () => {
|
||||
const { storage, auth } = authFixture();
|
||||
let service;
|
||||
let propertyReads = 0;
|
||||
const ctx = {
|
||||
root: {},
|
||||
get(name) {
|
||||
if (name === 'apiProxy') return undefined;
|
||||
assert.equal(name, 'udsAuth');
|
||||
return service;
|
||||
},
|
||||
get udsAuth() {
|
||||
propertyReads += 1;
|
||||
throw new Error('cannot get property "udsAuth" without inject');
|
||||
},
|
||||
typertGateway: { invoke() { assert.fail('health must not call the gateway'); }, stream() {} },
|
||||
};
|
||||
// host.describe is local in the modern adapter, but must survive the auth lookup.
|
||||
for (const Client of [WeixinHarnessClient, WhatsappHarnessClient]) {
|
||||
const client = new Client(harnessConnection(ctx));
|
||||
assert.equal(await client.health(), true, 'Host without UDS must remain reachable');
|
||||
service = auth;
|
||||
await storage.run(null, async () => {
|
||||
assert.equal(await client.health(), true, 'Host with UDS must remain reachable');
|
||||
assert.equal(await runHarnessHostOperation(ctx, async () => {
|
||||
await Promise.resolve();
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
return 'host';
|
||||
}), 'host');
|
||||
assert.equal(storage.getStore(), null);
|
||||
});
|
||||
service = undefined;
|
||||
}
|
||||
assert.equal(propertyReads, 0);
|
||||
});
|
||||
|
||||
test('modern Host session creation and model selection ignore inherited browser identities', async () => {
|
||||
const { storage, auth } = authFixture();
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
root: {}, udsAuth: auth,
|
||||
typertGateway: {
|
||||
async invoke({ namespace, method, args }) {
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
await Promise.resolve();
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
calls.push({ namespace, method, args });
|
||||
if (method === 'create') return { sessionId: `session-${calls.length}` };
|
||||
if (method === 'selectModel') return { selected: args.request };
|
||||
return { accepted: true };
|
||||
},
|
||||
stream() {
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
return (async function* () {
|
||||
await Promise.resolve();
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
yield { type: 'baseline', value: { items: [{
|
||||
workspaceId: 'bot-workspace', path: process.cwd(), sessionIds: [],
|
||||
}], archivedSessionIds: [] } };
|
||||
})();
|
||||
},
|
||||
},
|
||||
};
|
||||
const client = new HarnessClient({ ...harnessConnection(ctx), workspace: process.cwd() });
|
||||
const browser = { empNo: 'browser-user' };
|
||||
await Promise.all([null, browser].map((identity) => storage.run(identity, async () => {
|
||||
const sessionId = await client.createSession({ agentPreset: 'netxops' });
|
||||
await client.selectSessionModel(sessionId, { provider: 'provider', model: 'model' });
|
||||
await client.rpc('session.prompt', { sessionId, content: [{ type: 'text', text: 'hello' }] });
|
||||
assert.equal(storage.getStore(), identity);
|
||||
})));
|
||||
assert.equal(calls.filter(({ method }) => method === 'create').length, 2);
|
||||
assert.deepEqual(calls.find(({ method }) => method === 'create').args.request, {
|
||||
workspaceId: 'bot-workspace', agentPreset: 'netxops',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy Host calls preserve receivers and use auth services that become ready later', async () => {
|
||||
const { storage, auth } = authFixture();
|
||||
let service;
|
||||
const api = { history() {
|
||||
assert.equal(this, api);
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
const { rpcId } = arguments[0];
|
||||
return { rpcId, result: { ok: true, value: { events: [] } } };
|
||||
} };
|
||||
const client = new HarnessClient({ ...harnessConnection({
|
||||
apiProxy: { sessions: api }, get: () => service,
|
||||
}) });
|
||||
service = auth;
|
||||
await storage.run(null, () => client.rpc('session.history', { sessionId: 'existing' }));
|
||||
});
|
||||
|
||||
test('lazy IM event streams and cleanup run in Host context and restore the caller', async () => {
|
||||
const { storage, auth } = authFixture();
|
||||
const controller = new AbortController();
|
||||
let closed = false;
|
||||
const apiProxy = { events: { mux() {
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
return (async function* () {
|
||||
try {
|
||||
await Promise.resolve();
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
yield { rpcId: 'event', payload: { type: 'session/event' } };
|
||||
} finally {
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
closed = true;
|
||||
}
|
||||
})();
|
||||
} } };
|
||||
await storage.run(null, async () => {
|
||||
await watchHarnessMux({
|
||||
apiProxy, signal: controller.signal, rpcId: 'mux', runHostOperation: auth.runAsHost,
|
||||
onEnvelope() { assert.equal(storage.getStore(), null); controller.abort(); },
|
||||
});
|
||||
assert.equal(storage.getStore(), null);
|
||||
});
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test('IM commands use the optional Host auth runner without affecting browser context', async () => {
|
||||
const { storage, auth } = authFixture();
|
||||
const execute = createHarnessCommandExecutor({ udsAuth: auth, typertGateway: {
|
||||
async invoke(request) {
|
||||
await Promise.resolve();
|
||||
assert.equal(storage.getStore(), undefined);
|
||||
assert.equal(request.args.agentId, 'session');
|
||||
return 'done';
|
||||
},
|
||||
} });
|
||||
await storage.run({ empNo: 'browser' }, async () => {
|
||||
assert.equal(await execute('session', '/compact'), 'done');
|
||||
assert.equal(storage.getStore().empNo, 'browser');
|
||||
});
|
||||
});
|
||||
|
||||
test('UDS RemoteError details survive the modern adapter and prevent preset retry', async () => {
|
||||
let creations = 0;
|
||||
const ctx = { root: {}, typertGateway: {
|
||||
async invoke() {
|
||||
creations += 1;
|
||||
const error = new Error('登录后才能创建会话');
|
||||
Object.assign(error, { name: 'RemoteError', isDSHRemoteError: true,
|
||||
code: 'gateway/bad-request', details: { udsError: 'login_required_create_session' } });
|
||||
throw error;
|
||||
},
|
||||
stream() { return (async function* () {
|
||||
yield { type: 'baseline', value: { items: [{
|
||||
workspaceId: 'workspace', path: process.cwd(), sessionIds: [],
|
||||
}], archivedSessionIds: [] } };
|
||||
})(); },
|
||||
} };
|
||||
const client = new HarnessClient({ ...harnessConnection(ctx), workspace: process.cwd() });
|
||||
await assert.rejects(client.createSession({ agentPreset: 'netxops' }), (error) => {
|
||||
assert.equal(error.code, 'gateway/bad-request');
|
||||
assert.equal(error.details.udsError, 'login_required_create_session');
|
||||
assert.equal(classifyMessageFailure(error).code, 'HARNESS_LOGIN_REQUIRED');
|
||||
return true;
|
||||
});
|
||||
assert.equal(creations, 1);
|
||||
});
|
||||
|
||||
test('ACL rejections preserve existing chat bindings without creating replacement Sessions', async () => {
|
||||
for (const stage of ['history', 'membership']) {
|
||||
const error = Object.assign(new Error('denied'), {
|
||||
code: 'gateway/bad-request', details: { udsError: 'session_forbidden' },
|
||||
});
|
||||
const harness = {
|
||||
currentWorkspace: () => process.cwd(),
|
||||
async sessionExists() { if (stage === 'history') throw error; return true; },
|
||||
async sessionBelongsToWorkspace() { throw error; },
|
||||
createSession: () => assert.fail('ACL failures must not recreate'),
|
||||
};
|
||||
await assert.rejects(askInWorkspaceSession({
|
||||
harness, key: 'chat', text: 'hi', state: {
|
||||
sessionFor: () => 'existing',
|
||||
clearSession: () => assert.fail('ACL failures must preserve the binding'),
|
||||
},
|
||||
}), (actual) => actual === error);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { harnessConnection } from '../plugin-src/host/harness-connection.mjs';
|
||||
|
|
@ -8,13 +9,16 @@ const IM_CHANNELS = [
|
|||
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
|
||||
'slack', 'telegram', 'discord', 'whatsapp',
|
||||
];
|
||||
const TEST_WORKSPACE = resolve('test-workspace');
|
||||
|
||||
test('Host connections share the current Cordis root without depending on a webServer', () => {
|
||||
const root = {};
|
||||
const apiProxy = {};
|
||||
const first = harnessConnection({ root, apiProxy });
|
||||
const second = harnessConnection({ root, apiProxy });
|
||||
assert.deepEqual(first, { apiProxy, interactionScope: root });
|
||||
assert.equal(first.apiProxy, apiProxy);
|
||||
assert.equal(first.interactionScope, root);
|
||||
assert.equal(first.runHostOperation(() => 'result'), 'result');
|
||||
assert.equal(first.interactionScope, second.interactionScope);
|
||||
assert.notEqual(first.interactionScope, harnessConnection({ root: {}, apiProxy }).interactionScope);
|
||||
|
||||
|
|
@ -37,9 +41,9 @@ test('a Host with neither legacy apiProxy nor a modern gateway fails clearly', (
|
|||
);
|
||||
});
|
||||
|
||||
test('Host and all IM channel plugins require only services shared by old and new Harness', async () => {
|
||||
test('Host injects webServer for RPC mounts while channel Harness clients use shared gateway services', async () => {
|
||||
assert.equal(hostInject.includes('apiProxy'), false);
|
||||
assert.equal(hostInject.includes('webServer'), false);
|
||||
assert.equal(hostInject.includes('webServer'), true);
|
||||
assert.ok(hostInject.includes('typertGateway'));
|
||||
for (const channel of IM_CHANNELS) {
|
||||
const { inject } = await import(`../plugin-src/host/channels/${channel}/index.mjs`);
|
||||
|
|
@ -71,7 +75,7 @@ async function assembledHarness(channel, ctx, config = {}) {
|
|||
constructor(options) { constructed.runtime = options; }
|
||||
}
|
||||
const production = await createProductionController(ctx, {
|
||||
workspace: '/test/workspace',
|
||||
workspace: TEST_WORKSPACE,
|
||||
...config,
|
||||
}, {
|
||||
ConfigStore,
|
||||
|
|
@ -98,7 +102,7 @@ async function assembledHarness(channel, ctx, config = {}) {
|
|||
try {
|
||||
if (channel === 'office') {
|
||||
constructed.controller.createRuntime({});
|
||||
constructed.runtime.createHarness({ workspace: '/test/workspace' });
|
||||
constructed.runtime.createHarness({ workspace: TEST_WORKSPACE });
|
||||
}
|
||||
return constructed.harness;
|
||||
} finally {
|
||||
|
|
@ -114,7 +118,7 @@ for (const channel of [...IM_CHANNELS, 'office']) {
|
|||
assert.equal(options.apiProxy, apiProxy);
|
||||
assert.equal(options.interactionScope, root);
|
||||
assert.equal(Object.hasOwn(options, 'baseUrl'), false);
|
||||
assert.equal(options.workspace, '/test/workspace');
|
||||
assert.equal(options.workspace, TEST_WORKSPACE);
|
||||
assert.equal(options.autostart, false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -108,13 +108,31 @@ test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL
|
|||
assert.equal(plain.reason, 'ERROR');
|
||||
});
|
||||
|
||||
test('session.create failures tell operators to check the bot workspace path', () => {
|
||||
test('generic session.create failures refer operators to Host logs and configuration', () => {
|
||||
const failure = classifyMessageFailure({
|
||||
code: 'internal', method: 'session.create',
|
||||
}, options);
|
||||
assert.equal(failure.code, 'SESSION_CREATE');
|
||||
assert.equal(failure.reason, 'SESSION_CREATE');
|
||||
assert.match(failure.message, /工作区/);
|
||||
assert.match(failure.message, /Agent Preset.*认证/);
|
||||
});
|
||||
|
||||
test('login rejections are distinguished from workspace, preset and model failures', () => {
|
||||
for (const error of [
|
||||
{ code: 'internal', method: 'session.create', message: 'session.create: 登录后才能创建会话' },
|
||||
{ code: 'internal', method: 'session.create', message: 'session.create: Sign in to create a session' },
|
||||
{ code: 'gateway/bad-request', method: 'session.create',
|
||||
details: { udsError: 'login_required_create_session' }, message: 'private diagnostic' },
|
||||
]) {
|
||||
const failure = classifyMessageFailure(error, options);
|
||||
assert.equal(failure.code, 'HARNESS_LOGIN_REQUIRED');
|
||||
assert.match(failure.message, /认证插件/);
|
||||
assert.doesNotMatch(failure.message, /工作区|private/);
|
||||
}
|
||||
assert.equal(classifyMessageFailure({ code: 'gateway/bad-request',
|
||||
details: { udsError: 'session_forbidden' }, method: 'session.history',
|
||||
}, options).code, 'HARNESS_ACCESS');
|
||||
});
|
||||
|
||||
test('Host branded gateway/internal on session.create maps to SESSION_CREATE', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue