Sync bot chat language with DSH system locale.

Localize WhatsApp access-grant approvals via t() and push Host language from the UI on load and locale change.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-14 22:38:16 +08:00
parent 6107692418
commit 45f430fb28
10 changed files with 181 additions and 33 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased] ## [Unreleased]
### Changed / 变更
- 机器人聊天文案(含 WhatsApp 访问审批)跟随 DeepSeek Harness 系统语言:打开 UI 或切换语言时通过 `host.language.set` 同步到 Host;无界面时仍可用 `language` / `DSH_IM_LANGUAGE` 作为启动默认值。
Bot chat copy (including WhatsApp access-grant approvals) now follows the DeepSeek Harness system language: opening the UI or switching language syncs the Host via `host.language.set`. Headless runs can still bootstrap with `language` / `DSH_IM_LANGUAGE`.
## [4.9.1] - 2026-09-04 ## [4.9.1] - 2026-09-04
### Fixed / 修复 ### Fixed / 修复

View file

@ -197,7 +197,7 @@ See the [Proactive Delivery Guide](PROACTIVE_DELIVERY.en.md) ([简体中文](PRO
- Registers one top-level **IM Bot** settings page containing nine IM channels and one AI Office Connector. - Registers one top-level **IM Bot** settings page containing nine IM channels and one AI Office Connector.
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins. - Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the Host's `language` config (Chinese by default; `en` switches them to English), with Chinese always as the fallback so untranslated text is sent verbatim. - Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the same Harness system language (synced when the settings UI loads or the language changes), with Chinese always as the fallback so untranslated text is sent verbatim.
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches. - Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits. - Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
- Returns only QR codes, the public Slack Manifest, redacted status data, and access modes or allowlist identifiers explicitly saved for the current Telegram or WhatsApp bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages. - Returns only QR codes, the public Slack Manifest, redacted status data, and access modes or allowlist identifiers explicitly saved for the current Telegram or WhatsApp bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages.
@ -224,7 +224,9 @@ IM management RPCs accept loopback browsers by default. When a Web profile is de
### Bot chat message language ### Bot chat message language
Bot chat messages are in Chinese by default. To switch them to English, set `language: en` in the plugin config (also accepts `en-US` or `english`), or set the `DSH_IM_LANGUAGE=en` environment variable: Bot chat messages follow the **DeepSeek Harness system language** (Settings → Language): Chinese UI → Chinese bot copy, English UI → English bot copy. Opening the settings UI or switching language syncs the Host automatically.
Before any UI connects (or in headless runs), you can still set a bootstrap default via plugin config or env:
```yaml ```yaml
- id: xmanrui-dsh-im - id: xmanrui-dsh-im
@ -232,7 +234,7 @@ Bot chat messages are in Chinese by default. To switch them to English, set `lan
language: en language: en
``` ```
Without a setting, Chinese is used. Chinese is always the fallback language — any text missing from the English dictionary is sent verbatim in Chinese, so this feature never changes the behavior of existing Chinese users. or `DSH_IM_LANGUAGE=en`. After the UI syncs, the active Harness language wins. Chinese is always the dictionary fallback — any text missing from the English dictionary is sent verbatim in Chinese.
--- ---

View file

@ -200,7 +200,7 @@ dsh web
- Harness 一级设置菜单中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector; - Harness 一级设置菜单中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件; - 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息跟随 Host 的 `language` 配置(默认中文;设为 `en` 即为英文),中文始终为兜底,未收录的文案原样输出; - 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息同样跟随 Harness 系统语言(打开设置页或切换语言时同步到 Host),中文始终为兜底,未收录的文案原样输出;
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关; - 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限; - 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
- 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 或 WhatsApp 机器人主动保存的访问模式和白名单标识;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。 - 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 或 WhatsApp 机器人主动保存的访问模式和白名单标识;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。
@ -227,7 +227,9 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
### 聊天消息语言 ### 聊天消息语言
机器人发出的聊天消息默认使用中文。要切换为英文,在插件配置中设置 `language: en`(也接受 `en-US`、`english`),或设置环境变量 `DSH_IM_LANGUAGE=en`: 机器人发出的聊天消息默认跟随 **DeepSeek Harness 的系统语言**(设置 → 语言):界面为中文时发中文,为 English 时发英文。打开设置页或切换语言时会自动同步到 Host。
在尚未打开过 UI、或纯无界面运行时,仍可用插件配置 / 环境变量作为启动默认值:
```yaml ```yaml
- id: xmanrui-dsh-im - id: xmanrui-dsh-im
@ -235,7 +237,7 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
language: en language: en
``` ```
未设置时保持中文;中文始终是兜底语言,任何未收录到英文词典的文案都会原样以中文输出,因此该功能不会改变现有中文用户的行为。 或 `DSH_IM_LANGUAGE=en`。UI 连上并同步后,以 Harness 当前语言为准。中文始终是词典兜底:未收录的英文文案会原样以中文输出。
--- ---

View file

@ -64,6 +64,18 @@ export const name = 'im-settings';
export const inject = ['slots', 'connection', 'locale', 'workspaces']; export const inject = ['slots', 'connection', 'locale', 'workspaces'];
export const IM_PLUGIN_VERSION = manifest.version; export const IM_PLUGIN_VERSION = manifest.version;
/** Push the active DSH UI locale to the Host so bot chat copy stays aligned. */
function syncHostChatLanguage(ctx, language) {
if (typeof language !== 'string' || !language.trim()) return;
void ctx.connection.rpc.call(
UPDATE_RPC_CHANNEL,
'host.language.set',
{ language },
).catch(() => {
// Host may be offline or on an older build without the endpoint.
});
}
function callWorkspaceDirectoryApi(ctx, method, ...args) { function callWorkspaceDirectoryApi(ctx, method, ...args) {
// Current DSH owns directory operations on uiWorkspace; legacy Hosts keep them on workspaces. // Current DSH owns directory operations on uiWorkspace; legacy Hosts keep them on workspaces.
const uiWorkspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined; const uiWorkspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined;
@ -332,6 +344,13 @@ export function apply(ctx) {
const t = ctx.locale.bind(IM_LOCALE_NAMESPACE); const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
setImTranslator(t); setImTranslator(t);
ctx.effect(() => {
syncHostChatLanguage(ctx, ctx.locale.getLocale().active);
return ctx.on('locale/change', (snapshot) => {
syncHostChatLanguage(ctx, snapshot?.active);
});
}, 'im-settings: sync host chat language with DSH locale');
ctx.effect(() => { ctx.effect(() => {
const disposers = [ const disposers = [
installFeishuStyles(), installFeishuStyles(),

View file

@ -1,3 +1,4 @@
import { getImHostLanguage, setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
import { createUpdateRuntime } from './update-runtime.mjs'; import { createUpdateRuntime } from './update-runtime.mjs';
import { createUpdateService } from './update-service.mjs'; import { createUpdateService } from './update-service.mjs';
import { installConnectionRpcChannel } from './connection-rpc-mount.mjs'; import { installConnectionRpcChannel } from './connection-rpc-mount.mjs';
@ -5,8 +6,20 @@ import { resolveRpcAuthority } from './rpc-authority.mjs';
export const UPDATE_RPC_CHANNEL = '/dsh-im'; export const UPDATE_RPC_CHANNEL = '/dsh-im';
export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']); export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']);
export const HOST_LANGUAGE_SET_ENDPOINT = 'host.language.set';
function validLanguagePayload(payload) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
return keys.length === 1
&& keys[0] === 'language'
&& typeof payload.language === 'string'
&& payload.language.trim().length > 0
&& payload.language.length <= 32;
}
function validPayload(endpoint, payload) { function validPayload(endpoint, payload) {
if (endpoint === HOST_LANGUAGE_SET_ENDPOINT) return validLanguagePayload(payload);
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false; if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload); const keys = Object.keys(payload);
if (endpoint !== 'update.install') return keys.length === 0; if (endpoint !== 'update.install') return keys.length === 0;
@ -25,9 +38,14 @@ const PUBLIC_ERRORS = new Set([
export function createUpdateRpcHandler(service) { export function createUpdateRpcHandler(service) {
return async (endpoint, payload, signal) => { return async (endpoint, payload, signal) => {
if (!UPDATE_ENDPOINTS.includes(endpoint) || !validPayload(endpoint, payload)) { if (endpoint !== HOST_LANGUAGE_SET_ENDPOINT && !UPDATE_ENDPOINTS.includes(endpoint)
|| !validPayload(endpoint, payload)) {
return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } }; return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } };
} }
if (endpoint === HOST_LANGUAGE_SET_ENDPOINT) {
setImHostLanguage(payload.language);
return { ok: true, value: { language: getImHostLanguage() } };
}
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } }; if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
try { try {
// The submitted install belongs to the Host, not the lifetime of this browser request. // The submitted install belongs to the Host, not the lifetime of this browser request.

View file

@ -744,7 +744,10 @@ export function parsePendingIdFromNotifyText(text) {
return match?.[1] ?? null; return match?.[1] ?? null;
} }
/** Notify / ack copy (zh). */ /**
* Notify / ack copy keys (Chinese source literals for host `t()`).
* Callers must pass through `t()` after `setImHostLanguage` — do not send raw.
*/
export const ACCESS_GRANT_COPY = Object.freeze({ export const ACCESS_GRANT_COPY = Object.freeze({
pendingAckDirect: '已提交私聊访问申请,请等待全局管理员审批。', pendingAckDirect: '已提交私聊访问申请,请等待全局管理员审批。',
pendingAckGroup: '已提交本群访问申请,请等待管理员审批。', pendingAckGroup: '已提交本群访问申请,请等待管理员审批。',
@ -755,26 +758,38 @@ export const ACCESS_GRANT_COPY = Object.freeze({
denied: '访问申请未通过。', denied: '访问申请未通过。',
adminApproved: '已批准该访问申请。', adminApproved: '已批准该访问申请。',
adminDenied: '已拒绝该访问申请。', adminDenied: '已拒绝该访问申请。',
processFailed: '无法处理该审批。',
}); });
/** Identity translator that still fills `{name}` placeholders (zh default). */
function fillPlaceholders(text, params) {
if (typeof text !== 'string' || params == null) return text;
return text.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(params, name) ? String(params[name]) : match);
}
/** /**
* @param {object} pending * @param {object} pending
* @param {(text: string, params?: Record<string, unknown>) => string} [translate]
* @returns {string} * @returns {string}
*/ */
export function formatPendingNotifyBody(pending) { export function formatPendingNotifyBody(pending, translate = fillPlaceholders) {
const scene = pending.kind === 'group' const scene = pending.kind === 'group'
? `群聊 ${pending.groupJid ?? ''}` ? translate('群聊 {jid}', { jid: pending.groupJid ?? '' })
: '私聊'; : translate('私聊');
const unknown = translate('未知');
const who = pending.pushName const who = pending.pushName
? `${pending.pushName} (${pending.phone || pending.lid || '未知'})` ? `${pending.pushName} (${pending.phone || pending.lid || unknown})`
: (pending.phone || pending.lid || '未知'); : (pending.phone || pending.lid || unknown);
const text = pending.requestText ? `\n原文:${pending.requestText.slice(0, 200)}` : ''; const text = pending.requestText
? `\n${translate('原文:{text}', { text: pending.requestText.slice(0, 200) })}`
: '';
return [ return [
ACCESS_GRANT_COPY.notifyTitle, translate(ACCESS_GRANT_COPY.notifyTitle),
`场景:${scene}`, translate('场景:{scene}', { scene }),
`申请人:${who}`, translate('申请人:{who}', { who }),
`请求编号:${pending.id}`, translate('请求编号:{id}', { id: pending.id }),
'请引用本条消息回复「同意」或「拒绝」。', translate('请引用本条消息回复「同意」或「拒绝」。'),
text, text,
].filter(Boolean).join('\n'); ].filter(Boolean).join('\n');
} }

View file

@ -14,4 +14,33 @@ export default {
'WhatsApp账号': 'WhatsApp account', 'WhatsApp账号': 'WhatsApp account',
' Web 关联设备': ' Web linked device', ' Web 关联设备': ' Web linked device',
// Access-grant / whitelist approval (WhatsApp DM to admins & requesters)
'已提交私聊访问申请,请等待全局管理员审批。':
'Your DM access request was submitted. Please wait for a global admin to approve.',
'已提交本群访问申请,请等待管理员审批。':
'Your group access request was submitted. Please wait for an admin to approve.',
'已记录访问申请,但尚未解析到电话号码;请管理员在设置中补全后再批准。':
'Access request recorded, but the phone number is unresolved; an admin must complete it in settings before approving.',
'[dsh-im-ops] 访问申请': '[dsh-im-ops] Access request',
'私聊访问已批准,现在可以直接对话。': 'DM access approved. You can chat directly now.',
'本群访问已批准,请在本群 @ 机器人继续。': 'Group access approved. @ the bot in this group to continue.',
'访问申请未通过。': 'Access request denied.',
'已批准该访问申请。': 'Access request approved.',
'已拒绝该访问申请。': 'Access request rejected.',
'无法处理该审批。': 'Unable to process this approval.',
'找不到待批申请。': 'No pending request found.',
'该申请已失效。': 'This request is no longer valid.',
'无权审批该申请。': 'You are not allowed to approve this request.',
'审批动作无效。': 'Invalid approval action.',
'申请人电话尚未解析,请在设置中补全后再批准。':
'Requester phone is unresolved; complete it in settings before approving.',
'私聊': 'Direct chat',
'群聊 {jid}': 'Group {jid}',
'场景:{scene}': 'Scene: {scene}',
'申请人:{who}': 'Requester: {who}',
'请求编号:{id}': 'Request: {id}',
'原文:{text}': 'Original message: {text}',
'请引用本条消息回复「同意」或「拒绝」。':
'Quote this message and reply with 同意 (approve) or 拒绝 (reject). Also: yes / no.',
}; };

View file

@ -22,6 +22,7 @@ import {
upsertAccessContact, upsertAccessContact,
} from '../shared/access-grant.mjs'; } from '../shared/access-grant.mjs';
import { isSharedLocalCommand } from '../shared/command-permission.mjs'; import { isSharedLocalCommand } from '../shared/command-permission.mjs';
import { t } from '../shared/i18n.mjs';
import { isWhatsappLidJid } from './whatsapp-identity.mjs'; import { isWhatsappLidJid } from './whatsapp-identity.mjs';
/** /**
@ -178,17 +179,17 @@ export async function tryHandleWhatsappApprovalReply({
} }
await sendText( await sendText(
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` }, { jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied, t(intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied),
); );
const requesterJid = resolved.phone const requesterJid = resolved.phone
? `${resolved.phone}@s.whatsapp.net` ? `${resolved.phone}@s.whatsapp.net`
: (resolved.lid ?? null); : (resolved.lid ?? null);
if (requesterJid) { if (requesterJid) {
const body = intent === 'approve' const body = t(intent === 'approve'
? (resolved.kind === 'group' ? (resolved.kind === 'group'
? ACCESS_GRANT_COPY.approvedGroup ? ACCESS_GRANT_COPY.approvedGroup
: ACCESS_GRANT_COPY.approvedDirect) : ACCESS_GRANT_COPY.approvedDirect)
: ACCESS_GRANT_COPY.denied; : ACCESS_GRANT_COPY.denied);
try { try {
await sendText({ jid: requesterJid }, body); await sendText({ jid: requesterJid }, body);
} catch { } catch {
@ -201,7 +202,7 @@ export async function tryHandleWhatsappApprovalReply({
|| error?.code === 'pending-unresolved' || error?.code === 'pending-not-found') { || error?.code === 'pending-unresolved' || error?.code === 'pending-not-found') {
await sendText( await sendText(
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` }, { jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
error.message || '无法处理该审批。', t(error.message || ACCESS_GRANT_COPY.processFailed),
); );
return true; return true;
} }
@ -322,11 +323,11 @@ export async function gateWhatsappInbound({
await workspaces.setAccessGrant(botId, current); await workspaces.setAccessGrant(botId, current);
} }
const ack = !phone const ack = t(!phone
? ACCESS_GRANT_COPY.pendingUnresolved ? ACCESS_GRANT_COPY.pendingUnresolved
: (scene === 'group' : (scene === 'group'
? ACCESS_GRANT_COPY.pendingAckGroup ? ACCESS_GRANT_COPY.pendingAckGroup
: ACCESS_GRANT_COPY.pendingAckDirect); : ACCESS_GRANT_COPY.pendingAckDirect));
try { try {
await sendText(message.replyTarget, ack); await sendText(message.replyTarget, ack);
} catch { } catch {
@ -335,7 +336,7 @@ export async function gateWhatsappInbound({
if (created) { if (created) {
const admins = approverPhonesForPending(current, pending); const admins = approverPhonesForPending(current, pending);
const body = formatPendingNotifyBody(pending); const body = formatPendingNotifyBody(pending, t);
const refs = []; const refs = [];
for (const adminPhone of admins) { for (const adminPhone of admins) {
// Never DM the linked bot account as if it were a peer when it is the only self-chat path. // Never DM the linked bot account as if it were a peer when it is the only self-chat path.

View file

@ -191,7 +191,32 @@ test('quote approval helpers parse intent, pending id, and notify refs', () => {
pushName: 'Carol', pushName: 'Carol',
requestText: 'hi', requestText: 'hi',
}); });
assert.match(body, /请求编号:p_abc123/);
assert.equal(parsePendingIdFromNotifyText(body), 'p_abc123'); assert.equal(parsePendingIdFromNotifyText(body), 'p_abc123');
const enBody = formatPendingNotifyBody({
id: 'p_abc123',
kind: 'direct',
phone: '8618888888888',
pushName: 'Carol',
requestText: 'hi',
}, (text, params) => {
const map = {
'[dsh-im-ops] 访问申请': '[dsh-im-ops] Access request',
'私聊': 'Direct chat',
'场景:{scene}': 'Scene: {scene}',
'申请人:{who}': 'Requester: {who}',
'请求编号:{id}': 'Request: {id}',
'原文:{text}': 'Original message: {text}',
'请引用本条消息回复「同意」或「拒绝」。': 'Quote this message…',
'未知': 'unknown',
};
const translated = map[text] ?? text;
if (params == null) return translated;
return translated.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(params, name) ? String(params[name]) : match);
});
assert.match(enBody, /Request: p_abc123/);
assert.equal(parsePendingIdFromNotifyText(enBody), 'p_abc123');
const withRef = { const withRef = {
...grant(), ...grant(),
pending: [{ pending: [{

View file

@ -1,7 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createUpdateRpcHandler, installUpdateRpc } from '../plugin-src/host/update-rpc.mjs'; import { createUpdateRpcHandler, installUpdateRpc, HOST_LANGUAGE_SET_ENDPOINT } from '../plugin-src/host/update-rpc.mjs';
import { createImHostPlugin } from '../plugin-src/host/index.mjs'; import { createImHostPlugin } from '../plugin-src/host/index.mjs';
import { getImHostLanguage, setImHostLanguage } from '../src/channels/shared/i18n.mjs';
test('update RPC rejects arbitrary commands, paths, profiles, sources and invalid request identifiers', async () => { test('update RPC rejects arbitrary commands, paths, profiles, sources and invalid request identifiers', async () => {
const calls = []; const calls = [];
@ -13,22 +14,53 @@ test('update RPC rejects arbitrary commands, paths, profiles, sources and invali
['update.install', { checkId: 'id', requestId: 'request', command: 'anything' }], ['update.install', { checkId: 'id', requestId: 'request', command: 'anything' }],
['update.install', { checkId: 'id', requestId: '; rm -rf /' }], ['update.install', { checkId: 'id', requestId: '; rm -rf /' }],
['update.install', { checkId: 'id', requestId: 'request', version: '9.0.0' }], ['update.install', { checkId: 'id', requestId: 'request', version: '9.0.0' }],
[HOST_LANGUAGE_SET_ENDPOINT, {}],
[HOST_LANGUAGE_SET_ENDPOINT, { language: 'en', extra: true }],
[HOST_LANGUAGE_SET_ENDPOINT, { language: '' }],
]) { ]) {
assert.equal((await handle(endpoint, payload)).error.code, 'bad-request'); assert.equal((await handle(endpoint, payload)).error.code, 'bad-request');
} }
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('update RPC keeps local authority even when other management uses trusted-host', () => { test('host.language.set follows DSH locale ids and returns the normalized host language', async () => {
const calls = []; const handle = createUpdateRpcHandler({});
setImHostLanguage('zh');
try {
assert.deepEqual(await handle(HOST_LANGUAGE_SET_ENDPOINT, { language: 'en' }), {
ok: true, value: { language: 'en' },
});
assert.equal(getImHostLanguage(), 'en');
assert.deepEqual(await handle(HOST_LANGUAGE_SET_ENDPOINT, { language: 'zh-CN' }), {
ok: true, value: { language: 'zh' },
});
assert.equal(getImHostLanguage(), 'zh');
} finally {
setImHostLanguage('zh');
}
});
test('update RPC mounts on /dsh-im through webServer', () => {
const routes = [];
const service = { close: async () => {} }; const service = { close: async () => {} };
const ctx = { const ctx = {
connection: { rpc: { handle: (...args) => calls.push(args) } }, connection: {
effect: () => {}, requestRejection: () => undefined,
},
webServer: {
register: (route) => {
routes.push(route);
return () => {};
},
},
effect: (factory) => {
const dispose = factory();
return typeof dispose === 'function' ? dispose : () => {};
},
}; };
installUpdateRpc(ctx, { service, runtime: {} }); installUpdateRpc(ctx, { service, runtime: {} });
assert.equal(calls[0][0], '/dsh-im'); assert.equal(routes[0]?.path, '/dsh-im');
assert.deepEqual(calls[0][2], { authority: 'loopback' }); assert.equal(routes[0]?.kind, 'prefix');
}); });
test('update RPC returns only safe codes for unanticipated runtime errors', async () => { test('update RPC returns only safe codes for unanticipated runtime errors', async () => {