fix: improve WhatsApp settings and isolate IM host authentication
Some checks are pending
CI / Test, build, and verify package (push) Waiting to run

This commit is contained in:
oliver 2026-10-11 17:03:38 +08:00
parent bcbe85fdb1
commit bf73b0c0af
28 changed files with 848 additions and 116 deletions

View file

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

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

View file

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

View file

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

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

View file

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

View file

@ -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', () => {