diff --git a/LianSyn-platform/styles.css b/LianSyn-platform/styles.css
index 9332713..9afde2a 100644
--- a/LianSyn-platform/styles.css
+++ b/LianSyn-platform/styles.css
@@ -638,6 +638,38 @@ textarea {
color: var(--ink) !important;
}
+.account-extension-release {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 10px 16px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface-muted);
+}
+
+.account-extension-release h3 {
+ font-size: 14px;
+}
+
+.account-extension-release .actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.account-extension-release input[type='file'] {
+ max-width: 280px;
+ font-size: 11px;
+}
+
+.account-extension-release .channel-message {
+ flex-basis: 100%;
+ margin: 0;
+}
+
.account-authorization-panel {
display: grid;
gap: 12px;
diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md
index 29803f7..7a29679 100644
--- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md
+++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md
@@ -2,13 +2,14 @@
## 当前基线
-- Chrome 插件:`0.5.166`
+- Chrome 插件:`0.5.167`
- Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories`
- 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08`
- 五个 Skill:`0.5.125`;运营 DOCX:`0.5.125`
- 发布状态:核心窄能力已有真实证据;一笔名单附件 Program-only 任务已把 25 行及唯一领队联系人正确保存到 ERP,后续只读逐字段核验全等。两种写后逐行读取方式都曾在真实成功写入后产生假阴性;名单在全部写前门禁通过后,以父表单 ERP 明确成功响应作为终结证据,不再执行写后逐行回查或自动名单对账。`0.5.155` 规定确认覆盖的指定序号同值也写;`0.5.156` 把原生游客位不足统一映射为包含名单人数和 ERP 实际上限的业务提示,原技术 blocker 仅留技术详情。`0.5.157` 新增散拼母团“整团游客信息”的 `shared_plan + visitor-list + tid-only` 窄分支,静态契约与回归已通过,仍待授权后的新版运行态只读复测。`0.5.158` 把正式控制面 `https://lwlt.nianxx.cn` 同步加入 Popup、后台、Manifest 权限和 content script 精确白名单;其他 HTTPS origin 仍保持阻断。`0.5.159` 修复 MV3 后台恢复被中断后持久化 `running` 永久阻断插件派发的问题:仅在内存恢复 Promise 已不存在时收敛为 `interrupted`,真实活动恢复仍保持阻断,下一次成功只读保活把状态归零为 `idle`。`0.5.160` 为独立团单个下单写前漂移增加仅字段名诊断,并把数字后缀动态应收字段纳入保护。`0.5.161` 等待客户、跟单人和销售人原生 lookup 数据就绪后才开始单团预检;生产反馈证明仅检查 lookup 数据仍不足。`0.5.162` 进一步要求页面 jQuery Ajax 在预检前归零,并在 `GetProduct` 后等待原生 `ajaxStop` 再应用最终业务字段。`0.5.163` 按生产操作方明确要求恢复 `0.5.156` 的实际校验范围:核心订单字段继续写前阻断,带编号的动态 `ys_*` 应收行不再纳入保护哈希,允许 ERP 原生回调在预检后调整这些字段。`0.5.164` 新增平台账号、AgentBus 渠道、唯一云电脑 worker 与 ERP 登录身份四方一致门禁;旧未绑定渠道和历史未归属 AgentBus 任务默认不执行。`0.5.165` 让散拼新增计划和独立团批量下单在本地候选未命中时先使用表单原生非空产品检索,实测两类表单均能由完整关键词唯一返回目标产品;本轮未勾选或保存,自动选择与写入仍由原门禁保护。新增独立团房型/人数映射仍待实写;未验证宽能力继续失败关闭。
- `0.5.166` 在散拼母团列表入口加入立即检查、100ms 条件轮询和 15 秒总超时;每次探测都重新取得当前主 iframe document,按钮一旦可用便立即继续。该等待只发生在点击前,不固定拖慢快速页面,也不触发业务写入重试。
+- `0.5.167` 新增中央服务编排的私有 OSS 插件发布、账号到 ECS 实例绑定、整机空闲门禁、阿里云 ECS 云助手一次性 PowerShell 部署、SHA-256/Manifest 双重校验、目录替换回滚和浏览器安全重载协议。任何同机在线账号仍在执行、已跨写边界或待回查时都不更新;只要浏览器版本低于服务端活动版本,任务领取在等待、部署、待重载、配置缺失或最终失败时都保持关闭,直到目标版本心跳验版。该版本是自动更新引导版本,首次迁移到共享 ProgramData 目录仍需人工完成。
运行中的浏览器版本必须实时握手确认。当前制品文件名和 SHA-256 只看 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json),不从历史日志推断。
diff --git a/archive/releases/2026-09-03/README.md b/archive/releases/2026-09-03/README.md
index 5be4585..0ceedf9 100644
--- a/archive/releases/2026-09-03/README.md
+++ b/archive/releases/2026-09-03/README.md
@@ -4,3 +4,5 @@
- `release-manifest-0.5.165.json`:加入散拼母团列表入口自适应就绪等待前的历史清单快照。
- `ltjt-order-assistant-0.5.165.zip`:被 `0.5.166` 的散拼母团“新增计划”按钮自适应条件等待替代;保留用于恢复和追溯。
+- `release-manifest-0.5.166.json`:加入服务端 OSS 发布、ECS 云助手部署和浏览器空闲重载协议前的历史清单快照。
+- `ltjt-order-assistant-0.5.166.zip`:被 `0.5.167` 的中央服务编排更新能力替代;仍保留自适应就绪等待修复,可用于恢复和追溯。
diff --git a/dist/ltjt-order-assistant-0.5.166.zip b/archive/releases/2026-09-03/ltjt-order-assistant-0.5.166.zip
similarity index 100%
rename from dist/ltjt-order-assistant-0.5.166.zip
rename to archive/releases/2026-09-03/ltjt-order-assistant-0.5.166.zip
diff --git a/archive/releases/2026-09-03/release-manifest-0.5.166.json b/archive/releases/2026-09-03/release-manifest-0.5.166.json
new file mode 100644
index 0000000..a8fe92b
--- /dev/null
+++ b/archive/releases/2026-09-03/release-manifest-0.5.166.json
@@ -0,0 +1,69 @@
+{
+ "manifest_version": 1,
+ "generated_on": "2026-09-02",
+ "baselines": {
+ "chrome_extension": "0.5.166",
+ "skills": "0.5.125",
+ "business_instruction_docx": "0.5.125",
+ "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories"
+ },
+ "artifacts": [
+ {
+ "kind": "chrome_extension",
+ "version": "0.5.166",
+ "path": "dist/ltjt-order-assistant-0.5.166.zip",
+ "source": "chrome-extension/ltjt-order-assistant",
+ "sha256": "68aeda857f0bf596eed3c5012fb37c6292977935ff215ce1d133983a8dffe259"
+ },
+ {
+ "kind": "skill",
+ "name": "lwlt-arrangement",
+ "version": "0.5.125",
+ "path": "dist/lwlt-arrangement-0.5.125.skill",
+ "source": "agent设计规范/skills/lwlt-arrangement",
+ "sha256": "c729bebec84695b8642a6d31cb4dc0d963135b6c6620c2173be095826cd3976d"
+ },
+ {
+ "kind": "skill",
+ "name": "lwlt-confirmation",
+ "version": "0.5.125",
+ "path": "dist/lwlt-confirmation-0.5.125.skill",
+ "source": "agent设计规范/skills/lwlt-confirmation",
+ "sha256": "659f6aca7918a03a29fc78f0b4b938dfa9084ccdbb44ebd71f6399556cfa4609"
+ },
+ {
+ "kind": "skill",
+ "name": "lwlt-lifecycle",
+ "version": "0.5.125",
+ "path": "dist/lwlt-lifecycle-0.5.125.skill",
+ "source": "agent设计规范/skills/lwlt-lifecycle",
+ "sha256": "d964c0a9e482f82757fea25d4713f0c81feb2704486583dfa84f2336ecd930c0"
+ },
+ {
+ "kind": "skill",
+ "name": "lwlt-newbooking",
+ "version": "0.5.125",
+ "path": "dist/lwlt-newbooking-0.5.125.skill",
+ "source": "agent设计规范/skills/lwlt-newbooking",
+ "sha256": "bb610c50dd659fc172aabfa469b3028de98704c249cfd4890463deea0c88cd0c"
+ },
+ {
+ "kind": "skill",
+ "name": "lwlt-updating",
+ "version": "0.5.125",
+ "path": "dist/lwlt-updating-0.5.125.skill",
+ "source": "agent设计规范/skills/lwlt-updating",
+ "sha256": "c6f5bc9008e2f63d170f3f140140003d99411ce5232632c48b38891da1ceee33"
+ },
+ {
+ "kind": "business_instruction_docx",
+ "version": "0.5.125",
+ "path": "dist/老挝联泰AI指令表-0.5.125.docx",
+ "source": "agent设计规范/templates/business-input-templates.md",
+ "builder": "tools/build_business_instruction_docx.py",
+ "sha256": "a0c2fd5f808e3d3f2ac211a652a59f2e435d3824df49c51f763ca4beb7d9b97d",
+ "source_sha256": "27600fe9bcb0de3898a99550428a9890dd350c701ba704fb3940ca966088dcd9",
+ "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1"
+ }
+ ]
+}
diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md
index 0576995..234b64c 100644
--- a/chrome-extension/ltjt-order-assistant/README.md
+++ b/chrome-extension/ltjt-order-assistant/README.md
@@ -11,9 +11,15 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过
## 当前版本
-当前源码版本为 `0.5.166`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。
+当前源码版本为 `0.5.167`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。
-0.5.166 当前重点:
+0.5.167 当前重点:
+
+- 插件 PING 会返回当前运行任务、持久化写入边界和待回查结果汇总后的 `extension_update.safe`。只有内存与持久化状态都完全空闲,服务端才允许同一 ECS 实例进入文件部署。
+- 中央服务完成共享目录替换后,平台通过受控消息请求后台 `chrome.runtime.reload()`,并刷新平台页以重新注入 content script;执行中、已跨写边界或待回查状态会拒绝重载。
+- 这是自动更新协议的引导版本。每个 Windows Server/Chrome profile 仍需最后一次人工把插件加载到统一的 `C:\ProgramData\LTJT\chrome-extension\ltjt-order-assistant`;后续版本才由现有服务配合阿里云 ECS 云助手更新,不新增 LTJT 常驻更新器。
+
+继续保留 0.5.166 的自适应等待:
- 散拼新增计划进入计划表后立即检查“新增计划”按钮;若同路径 iframe 尚在渲染,则每 100ms 重新取得当前 document 并检查按钮是否已连接且可用,出现后立即继续,最长 15 秒后才在写前阻断。快速页面没有固定等待,也不会自动重试任何 ERP 写入。
diff --git a/chrome-extension/ltjt-order-assistant/background.js b/chrome-extension/ltjt-order-assistant/background.js
index 07b7dac..ce60ae7 100644
--- a/chrome-extension/ltjt-order-assistant/background.js
+++ b/chrome-extension/ltjt-order-assistant/background.js
@@ -917,6 +917,64 @@ async function hasActiveErpExecution() {
));
}
+async function extensionUpdateSafetyStatus() {
+ if (runningTasks.size > 0) {
+ return {
+ ok: true,
+ safe: false,
+ reason: 'running_task',
+ active_task_count: runningTasks.size
+ };
+ }
+ const saved = await chrome.storage.local.get(['businessTaskExecutions', 'businessTaskResults']);
+ const executions = Object.values(saved.businessTaskExecutions || {});
+ if (executions.some((execution) => ['running', 'write_started', 'submitted', 'uncertain'].includes(String(execution?.state || '')))) {
+ return { ok: true, safe: false, reason: 'persisted_execution_active', active_task_count: 0 };
+ }
+ const results = Object.values(saved.businessTaskResults || {});
+ if (results.some((result) => /^(?:saved_unverified|execution_uncertain|reconciliation_pending)$/.test(String(result?.status || '')))) {
+ return { ok: true, safe: false, reason: 'reconciliation_pending', active_task_count: 0 };
+ }
+ return { ok: true, safe: true, reason: 'idle', active_task_count: 0 };
+}
+
+async function applyPreparedExtensionUpdate(targetVersion) {
+ const safety = await extensionUpdateSafetyStatus();
+ if (!safety.safe) {
+ return {
+ ok: false,
+ accepted: false,
+ status: 'update_reload_blocked',
+ message: '插件仍有执行中或待回查任务,暂不重载。',
+ update_safety: safety
+ };
+ }
+ const currentVersion = String(chrome.runtime.getManifest().version || '');
+ if (!/^\d+\.\d+\.\d+(?:\.\d+)?$/.test(String(targetVersion || ''))) {
+ return { ok: false, accepted: false, status: 'update_version_invalid', message: '目标插件版本无效。' };
+ }
+ const currentParts = currentVersion.split('.').map(Number);
+ const targetParts = String(targetVersion).split('.').map(Number);
+ let comparison = 0;
+ for (let index = 0; index < Math.max(currentParts.length, targetParts.length); index += 1) {
+ const difference = (currentParts[index] || 0) - (targetParts[index] || 0);
+ if (!difference) continue;
+ comparison = difference > 0 ? 1 : -1;
+ break;
+ }
+ if (comparison >= 0) {
+ return { ok: true, accepted: false, status: 'already_current', version: currentVersion };
+ }
+ setTimeout(() => chrome.runtime.reload(), 250);
+ return {
+ ok: true,
+ accepted: true,
+ status: 'update_reload_scheduled',
+ current_version: currentVersion,
+ target_version: String(targetVersion)
+ };
+}
+
async function findExistingErpTabForKeepalive({ allowLoading = false } = {}) {
await requireErpHostPermission();
const tabs = await chrome.tabs.query({ url: `${ERP_ORIGIN}/*` });
@@ -3757,6 +3815,28 @@ chrome.runtime.onConnect.addListener((port) => {
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message?.type === 'LTJT_EXTENSION_UPDATE_STATUS') {
+ extensionUpdateSafetyStatus()
+ .then((result) => sendResponse(result))
+ .catch((error) => sendResponse({
+ ok: false,
+ safe: false,
+ reason: 'status_failed',
+ message: error.message || String(error)
+ }));
+ return true;
+ }
+ if (message?.type === 'LTJT_APPLY_EXTENSION_UPDATE') {
+ applyPreparedExtensionUpdate(message.target_version || '')
+ .then((result) => sendResponse(result))
+ .catch((error) => sendResponse({
+ ok: false,
+ accepted: false,
+ status: 'update_reload_failed',
+ message: error.message || String(error)
+ }));
+ return true;
+ }
if (message?.type === 'LTJT_ERP_SESSION_STATUS') {
readErpSessionStatus(message.expected_erp_account || '')
.then((result) => sendResponse(result))
diff --git a/chrome-extension/ltjt-order-assistant/business-bridge.js b/chrome-extension/ltjt-order-assistant/business-bridge.js
index c3873b3..2362216 100644
--- a/chrome-extension/ltjt-order-assistant/business-bridge.js
+++ b/chrome-extension/ltjt-order-assistant/business-bridge.js
@@ -60,12 +60,21 @@ async function bridgePayload(extra = {}, expectedErpAccount = '') {
} catch (error) {
erpSession = { ...erpSession, message: error.message || String(error) };
}
+ let extensionUpdate = { ok: false, safe: false, reason: 'status_unavailable' };
+ try {
+ extensionUpdate = await chrome.runtime.sendMessage({
+ type: 'LTJT_EXTENSION_UPDATE_STATUS'
+ }) || extensionUpdate;
+ } catch (error) {
+ extensionUpdate = { ...extensionUpdate, message: error.message || String(error) };
+ }
return {
ok: true,
extension: 'ltjt-order-assistant',
version: chrome.runtime.getManifest().version,
bridge_installed_at: window.__LTJT_ORDER_ASSISTANT_BRIDGE_INSTALLED_AT__ || '',
erp_session: erpSession,
+ extension_update: extensionUpdate,
...(await getAutomationState()),
...extra
};
@@ -273,6 +282,19 @@ const bridgeHandler = async (event) => {
postReply(requestId, 'PONG', await bridgePayload({}, message.payload?.expected_erp_account));
return;
}
+ if (message.type === 'APPLY_EXTENSION_UPDATE') {
+ const result = await chrome.runtime.sendMessage({
+ type: 'LTJT_APPLY_EXTENSION_UPDATE',
+ target_version: String(message.payload?.target_version || '')
+ });
+ postReply(requestId, 'EXTENSION_UPDATE_APPLYING', result || {
+ ok: false,
+ accepted: false,
+ status: 'update_reload_failed',
+ message: '插件后台未返回重载结果。'
+ });
+ return;
+ }
if (message.type === 'CREATE_TASK') {
const task = await createTask(message.payload || {});
postReply(requestId, 'TASK_CREATED', {
diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js
index 6d6014b..da2ab9d 100644
--- a/chrome-extension/ltjt-order-assistant/inpage.js
+++ b/chrome-extension/ltjt-order-assistant/inpage.js
@@ -6118,7 +6118,7 @@
}
window.LTJTOrderAssistant = {
- version: '0.5.166',
+ version: '0.5.167',
resolveNativeListSearchValues,
lookupKeywordMatchesText,
inspectLifecycleSearchCriteria: lifecycleSearchCriteria,
diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json
index 4cd1a9a..78131b9 100644
--- a/chrome-extension/ltjt-order-assistant/manifest.json
+++ b/chrome-extension/ltjt-order-assistant/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "联泰下单助手",
- "version": "0.5.166",
+ "version": "0.5.167",
"description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。",
"permissions": [
"activeTab",
diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js
index 4b7a187..c7c6127 100644
--- a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js
+++ b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js
@@ -944,7 +944,7 @@
window.LTJTOrderAssistant = {
...(window.LTJTOrderAssistant || {}),
- version: '0.5.166',
+ version: '0.5.167',
openTeamBatchForm,
pingTeamBatchFrame,
preflightTeamBatchNative,
diff --git a/control-plane/README.md b/control-plane/README.md
index 9562b44..330afe0 100644
--- a/control-plane/README.md
+++ b/control-plane/README.md
@@ -31,6 +31,39 @@
- `user_channels`、`tasks.channel_id` 和 `agentbus_deliveries` 共同保存入站归属、accepted 受理回执和最终 result 回执。回执以 `(channel_id, inbound_frame_id, delivery_kind)` 幂等,发送失败会重试,进程重启或 WebSocket 重连后仍会继续投递;因此不会因为超过原等待时长而丢掉最终回复。
- ERP 插件领取由组织级数据库锁和 FIFO confirmed 队列统一串行化:同一组织/同一 ERP 浏览器会话在任意时刻最多一个 ERP execution,其他任务留在服务端等待;已开始写入但结果不确定的任务会阻塞后续领取,直到人工回查收敛。
+## Chrome 插件自动更新
+
+插件更新由本控制平面统一编排,不在 Windows Server 上增加单独的 LTJT 更新服务。中央 Node 服务把管理员批准的 ZIP 写入 OSS 私有对象,再通过阿里云 ECS 云助手向目标 Windows Server 下发一次性 PowerShell 命令。云助手只负责本次文件部署;版本判断、空闲门禁、重试、状态和验版均保存在控制平面与 PostgreSQL 中。
+
+更新链路为:
+
+```text
+管理员发布版本化 ZIP
+ → 服务端校验 ZIP 边界、Manifest 身份/版本、必需文件和 SHA-256
+ → 私有对象写入 OSS,并把该版本设为组织活动版本
+ → 浏览器心跳上报当前版本与插件内存/持久化空闲证明
+ → 服务端汇总同一 ECS 实例上全部账号、任务、执行尝试与待回查状态
+ → 完全空闲后,以主机级数据库锁启动 ECS 云助手命令
+ → Windows 下载短时、主机绑定的服务端地址并再次校验 SHA-256 与 Manifest
+ → 新目录暂存,旧目录保留为 `.previous`,原子切换失败则回滚
+ → 平台请求插件后台再次检查空闲状态并执行 `chrome.runtime.reload()`
+ → 刷新平台页;目标版本的新心跳到达后标记 `verified`,再开放新 ERP 任务
+```
+
+不存在固定的业务等待时间:已经空闲的主机会立即开始;仍有 `accepted/running/write_started/submitted/uncertain/reconciliation_pending` 边界时持续等待真实状态收敛。更新处于等待、执行、待重载、配置缺失或最终失败时,页面和服务端领取接口都会阻止新的 ERP 写任务。单个主机版本最多自动尝试三次;失败信息经过 URL/令牌脱敏后才写入状态。
+
+多台、多账号 Windows Server 按以下方式配置:
+
+1. 每台 ECS Windows Server 必须安装并正常连接阿里云云助手。中央服务所在机器需要访问 OSS 与 ECS API;每台 Windows Server 需要能访问 `APP_ORIGIN` 的 HTTPS 插件下载接口。该链路不依赖 Google 服务。
+2. 每台 Windows Server 统一使用 `C:\ProgramData\LTJT\chrome-extension\ltjt-order-assistant`。同机的每个 Windows/Chrome profile 都只需在 `chrome://extensions` 中把这个相同目录“加载已解压的扩展程序”一次。
+3. `0.5.167` 是引导版本,包含安全状态与受控重载协议。现有 `0.5.166` 及更旧 profile 必须最后一次人工迁移到上述共享目录;旧代码无法凭服务端单方面获得新协议。完成全部 profile 引导前保持 `EXTENSION_AUTO_UPDATE_ENABLED=false`。
+4. 为中央服务配置仅允许目标 ECS 实例执行命令和读取调用结果的 RAM 身份,并填写 `ALIBABA_CLOUD_ACCESS_KEY_ID`、`ALIBABA_CLOUD_ACCESS_KEY_SECRET`,使用临时身份时同时填写 `ALIBABA_CLOUD_SECURITY_TOKEN`。不要复用宽权限 OSS 身份。
+5. 配置 OSS、`EXTENSION_UPDATE_OSS_KEY_PREFIX`、共享安装目录、包大小与命令超时,再启用 `EXTENSION_AUTO_UPDATE_ENABLED=true` 并重启控制平面。生产 `APP_ORIGIN` 必须是 Windows Server 可达的 HTTPS 地址。
+6. 管理员在 `/accounts` 给每个非管理员账号填写 ECS 地域 ID 与实例 ID。同一 Windows Server 上的多个账号填写完全相同的一组值,服务端会把它们合并成一个主机级空闲门禁和更新状态。
+7. 管理员在 `/accounts` 的“插件版本发布”区域选择版本化 ZIP。服务端只接受比当前活动版本更高的版本;同一版本不同哈希会被拒绝。发布后无需逐台登录,在线浏览器的正常心跳会启动更新并完成验版。
+
+更新命令只允许操作配置的 `ProgramData\LTJT` 子目录,不安装 CRX、不修改 Chrome 策略、不操纵交互式桌面。若某个 profile 长期离线,它不会阻塞在线 profile;再次启动时会从已更新的共享目录加载目标版本并在下一次心跳完成验证。发布、迁移和运行状态由迁移 `019_extension_host_updates` 中的 `extension_releases`、`extension_host_updates` 以及账号 ECS 映射保存。
+
## 任务级会话续接
- `agent_sessions` 将一个业务任务绑定到一个 Superagent 会话;`agent_session_messages` 加密保存每一轮用户/Agent 消息并使用独立幂等键。
diff --git a/control-plane/migrations/019_extension_host_updates.sql b/control-plane/migrations/019_extension_host_updates.sql
new file mode 100644
index 0000000..97ffce2
--- /dev/null
+++ b/control-plane/migrations/019_extension_host_updates.sql
@@ -0,0 +1,77 @@
+-- Bind browser accounts to Alibaba ECS Windows hosts and persist signed-in
+-- browser extension releases/update orchestration. The updater remains part of
+-- the control-plane process; target hosts only run one-shot Cloud Assistant
+-- commands after every browser worker on that host reports an idle state.
+
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS extension_ecs_region_id text,
+ ADD COLUMN IF NOT EXISTS extension_ecs_instance_id text;
+
+ALTER TABLE users
+ DROP CONSTRAINT IF EXISTS users_extension_ecs_binding_pair_check;
+
+ALTER TABLE users
+ ADD CONSTRAINT users_extension_ecs_binding_pair_check
+ CHECK (
+ (extension_ecs_region_id IS NULL AND extension_ecs_instance_id IS NULL)
+ OR (
+ extension_ecs_region_id ~ '^[a-z0-9][a-z0-9-]{0,63}$'
+ AND extension_ecs_instance_id ~ '^i-[A-Za-z0-9]{6,64}$'
+ )
+ );
+
+CREATE INDEX IF NOT EXISTS users_extension_ecs_host_idx
+ ON users (organization_id, extension_ecs_region_id, extension_ecs_instance_id)
+ WHERE extension_ecs_region_id IS NOT NULL AND extension_ecs_instance_id IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS extension_releases (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id uuid NOT NULL REFERENCES organizations(id),
+ version text NOT NULL,
+ object_key text NOT NULL,
+ sha256 text NOT NULL,
+ byte_size integer NOT NULL,
+ extension_manifest jsonb NOT NULL,
+ is_active boolean NOT NULL DEFAULT false,
+ published_by uuid NOT NULL REFERENCES users(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (organization_id, version),
+ UNIQUE (organization_id, object_key),
+ CHECK (version ~ '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$'),
+ CHECK (sha256 ~ '^[a-f0-9]{64}$'),
+ CHECK (byte_size BETWEEN 1 AND 50000000),
+ CHECK (char_length(object_key) BETWEEN 1 AND 500)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS extension_releases_one_active_idx
+ ON extension_releases (organization_id)
+ WHERE is_active = true;
+
+CREATE INDEX IF NOT EXISTS extension_releases_created_idx
+ ON extension_releases (organization_id, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS extension_host_updates (
+ organization_id uuid NOT NULL REFERENCES organizations(id),
+ ecs_region_id text NOT NULL,
+ ecs_instance_id text NOT NULL,
+ release_id uuid NOT NULL REFERENCES extension_releases(id),
+ status text NOT NULL,
+ attempt_count integer NOT NULL DEFAULT 0,
+ invocation_id text,
+ error_code text,
+ error_message text,
+ started_at timestamptz,
+ deployed_at timestamptz,
+ verified_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (organization_id, ecs_region_id, ecs_instance_id),
+ CHECK (status IN ('waiting_for_idle', 'running', 'deployed', 'verified', 'failed')),
+ CHECK (attempt_count BETWEEN 0 AND 3),
+ CHECK (ecs_region_id ~ '^[a-z0-9][a-z0-9-]{0,63}$'),
+ CHECK (ecs_instance_id ~ '^i-[A-Za-z0-9]{6,64}$'),
+ CHECK (error_code IS NULL OR char_length(error_code) <= 120),
+ CHECK (error_message IS NULL OR char_length(error_message) <= 2000)
+);
+
+CREATE INDEX IF NOT EXISTS extension_host_updates_release_status_idx
+ ON extension_host_updates (organization_id, release_id, status, updated_at DESC);
diff --git a/control-plane/src/auth.ts b/control-plane/src/auth.ts
index 7123182..ce5c9eb 100644
--- a/control-plane/src/auth.ts
+++ b/control-plane/src/auth.ts
@@ -28,6 +28,8 @@ export interface PublicAccount {
username: string;
role: AuthRole;
erp_account: string | null;
+ extension_ecs_region_id: string | null;
+ extension_ecs_instance_id: string | null;
is_active: boolean;
authorized_business_route_ids: BusinessRouteId[];
business_authorization_revision: number;
@@ -101,6 +103,42 @@ function validateAccountRouting(role: AuthRole, value: unknown): string | null {
return erpAccount;
}
+function normalizeEcsRegionId(value: unknown): string | null {
+ const normalized = String(value ?? '').trim().toLocaleLowerCase('en-US');
+ if (!normalized) return null;
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(normalized)) {
+ throw new AuthError('extension_ecs_region_invalid', 'ECS 地域 ID 格式无效。', 400);
+ }
+ return normalized;
+}
+
+function normalizeEcsInstanceId(value: unknown): string | null {
+ const normalized = String(value ?? '').trim();
+ if (!normalized) return null;
+ if (!/^i-[A-Za-z0-9]{6,64}$/u.test(normalized)) {
+ throw new AuthError('extension_ecs_instance_invalid', 'ECS 实例 ID 格式无效。', 400);
+ }
+ return normalized;
+}
+
+function validateExtensionHostBinding(
+ role: AuthRole,
+ regionValue: unknown,
+ instanceValue: unknown
+): { regionId: string | null; instanceId: string | null } {
+ const regionId = normalizeEcsRegionId(regionValue);
+ const instanceId = normalizeEcsInstanceId(instanceValue);
+ if (role === 'admin' && (regionId || instanceId)) {
+ throw new AuthError('admin_extension_host_forbidden', '管理员账号不能绑定 ERP 插件云主机。', 409);
+ }
+ if (Boolean(regionId) !== Boolean(instanceId)) {
+ throw new AuthError('extension_ecs_binding_incomplete', 'ECS 地域 ID 与实例 ID 必须同时填写或同时清空。', 400);
+ }
+ return role === 'admin'
+ ? { regionId: null, instanceId: null }
+ : { regionId, instanceId };
+}
+
function normalizeRole(value: unknown): AuthRole {
if (value === 'admin' || value === 'team_lead') return value;
return 'user';
@@ -147,6 +185,8 @@ function mapAccount(row: Record
): PublicAccount {
username: String(row.username),
role,
erp_account: normalizeErpAccount(row.erp_account),
+ extension_ecs_region_id: normalizeEcsRegionId(row.extension_ecs_region_id),
+ extension_ecs_instance_id: normalizeEcsInstanceId(row.extension_ecs_instance_id),
is_active: row.is_active === true || String(row.is_active) === 'true',
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
@@ -162,7 +202,8 @@ async function loadPublicAccount(
userId: string
): Promise {
const result = await client.query(
- `SELECT u.id, u.username, u.role, u.erp_account, u.is_active,
+ `SELECT u.id, u.username, u.role, u.erp_account,
+ u.extension_ecs_region_id, u.extension_ecs_instance_id, u.is_active,
u.business_authorization_revision,
u.last_login_at, u.created_at, u.updated_at,
COALESCE(ARRAY(
@@ -368,7 +409,8 @@ export class AuthService {
async listAccounts(actor: AuthUser): Promise {
this.requireAdmin(actor);
const result = await getPool(this.config).query(
- `SELECT u.id, u.username, u.role, u.erp_account, u.is_active,
+ `SELECT u.id, u.username, u.role, u.erp_account,
+ u.extension_ecs_region_id, u.extension_ecs_instance_id, u.is_active,
u.business_authorization_revision,
u.last_login_at, u.created_at, u.updated_at,
COALESCE(ARRAY(
@@ -392,6 +434,8 @@ export class AuthService {
password: string;
role: AuthRole;
erpAccount?: string;
+ extensionEcsRegionId?: string;
+ extensionEcsInstanceId?: string;
businessRouteIds?: readonly string[];
},
requestId: string
@@ -401,6 +445,11 @@ export class AuthService {
const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id });
const role = normalizeRole(input.role);
const erpAccount = validateAccountRouting(role, input.erpAccount);
+ const extensionHost = validateExtensionHostBinding(
+ role,
+ input.extensionEcsRegionId,
+ input.extensionEcsInstanceId
+ );
const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds);
try {
return await withTransaction(this.config, async (client) => {
@@ -415,10 +464,19 @@ export class AuthService {
if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409);
const created = await client.query(
`INSERT INTO users
- (organization_id, username, password_hash, role, erp_account, password_changed_at)
- VALUES ($1, $2, $3, $4, $5, now())
+ (organization_id, username, password_hash, role, erp_account,
+ extension_ecs_region_id, extension_ecs_instance_id, password_changed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now())
RETURNING id`,
- [actor.organizationId, username, passwordHash, role, erpAccount]
+ [
+ actor.organizationId,
+ username,
+ passwordHash,
+ role,
+ erpAccount,
+ extensionHost.regionId,
+ extensionHost.instanceId
+ ]
);
const accountId = String(created.rows[0].id);
if (businessRouteIds.length) {
@@ -435,6 +493,7 @@ export class AuthService {
await this.accountAudit(client, actor, 'account.created', account.id, requestId, {
role,
erp_account_configured: Boolean(erpAccount),
+ extension_host_configured: Boolean(extensionHost.instanceId),
authorized_business_route_ids: account.authorized_business_route_ids
});
return account;
@@ -453,17 +512,30 @@ export class AuthService {
async updateAccount(
actor: AuthUser,
targetUserId: string,
- input: { role?: AuthRole; isActive?: boolean; erpAccount?: string | null },
+ input: {
+ role?: AuthRole;
+ isActive?: boolean;
+ erpAccount?: string | null;
+ extensionEcsRegionId?: string | null;
+ extensionEcsInstanceId?: string | null;
+ },
requestId: string
): Promise {
this.requireAdmin(actor);
- if (input.role === undefined && input.isActive === undefined && input.erpAccount === undefined) {
+ if (
+ input.role === undefined
+ && input.isActive === undefined
+ && input.erpAccount === undefined
+ && input.extensionEcsRegionId === undefined
+ && input.extensionEcsInstanceId === undefined
+ ) {
throw new AuthError('account_update_empty', '没有需要更新的账号字段。', 400);
}
try {
return await withTransaction(this.config, async (client) => {
const target = await client.query(
- `SELECT id, username, role, erp_account, is_active,
+ `SELECT id, username, role, erp_account,
+ extension_ecs_region_id, extension_ecs_instance_id, is_active,
last_login_at, created_at, updated_at
FROM users
WHERE organization_id = $1 AND id = $2
@@ -480,6 +552,19 @@ export class AuthService {
? null
: input.erpAccount === undefined ? before.erp_account : input.erpAccount
);
+ const extensionHost = validateExtensionHostBinding(
+ role,
+ role === 'admin'
+ ? null
+ : input.extensionEcsRegionId === undefined
+ ? before.extension_ecs_region_id
+ : input.extensionEcsRegionId,
+ role === 'admin'
+ ? null
+ : input.extensionEcsInstanceId === undefined
+ ? before.extension_ecs_instance_id
+ : input.extensionEcsInstanceId
+ );
const removesActiveAdmin = before.role === 'admin' && before.is_active && (role !== 'admin' || !isActive);
if (actor.id === before.id && (role !== 'admin' || !isActive)) {
throw new AuthError('self_lockout_forbidden', '不能停用或降级当前登录的管理员账号。', 409);
@@ -497,12 +582,24 @@ export class AuthService {
}
const updated = await client.query(
`UPDATE users
- SET role = $1, is_active = $2, erp_account = $3, updated_at = now()
- WHERE organization_id = $4 AND id = $5
+ SET role = $1, is_active = $2, erp_account = $3,
+ extension_ecs_region_id = $4, extension_ecs_instance_id = $5,
+ updated_at = now()
+ WHERE organization_id = $6 AND id = $7
RETURNING id`,
- [role, isActive, erpAccount, actor.organizationId, before.id]
+ [
+ role,
+ isActive,
+ erpAccount,
+ extensionHost.regionId,
+ extensionHost.instanceId,
+ actor.organizationId,
+ before.id
+ ]
);
const routingIdentityChanged = before.erp_account !== erpAccount;
+ const extensionHostChanged = before.extension_ecs_region_id !== extensionHost.regionId
+ || before.extension_ecs_instance_id !== extensionHost.instanceId;
if (before.role !== role || before.is_active !== isActive || routingIdentityChanged) {
await client.query(
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
@@ -518,7 +615,7 @@ export class AuthService {
[role === 'admin' ? '绑定账号已变更为管理员,渠道已解除绑定。' : '绑定账号已停用,渠道已解除绑定。', actor.organizationId, before.id]
);
}
- if (!isActive || routingIdentityChanged || before.role !== role) {
+ if (!isActive || routingIdentityChanged || extensionHostChanged || before.role !== role) {
await client.query(
`UPDATE browser_connections
SET status = 'superseded', erp_account_verified = false
@@ -532,6 +629,7 @@ export class AuthService {
previous_active: before.is_active,
active: isActive,
erp_account_changed: routingIdentityChanged,
+ extension_host_changed: extensionHostChanged,
sessions_revoked: before.role !== role || before.is_active !== isActive || routingIdentityChanged
});
const account = await loadPublicAccount(client, actor.organizationId, String(updated.rows[0].id));
diff --git a/control-plane/src/config.ts b/control-plane/src/config.ts
index b99deb3..df21fb2 100644
--- a/control-plane/src/config.ts
+++ b/control-plane/src/config.ts
@@ -32,6 +32,20 @@ const optionalOssEndpoint = z.preprocess(
z.string().regex(/^[a-z0-9.-]+$/i).optional()
);
+const optionalWindowsExtensionPath = z.preprocess(
+ (value) => {
+ const normalized = String(value ?? '').trim().replace(/\/+$/u, '');
+ return normalized || undefined;
+ },
+ z.string()
+ .max(240)
+ .refine(
+ (value) => /^[A-Za-z]:\\ProgramData\\LTJT\\[A-Za-z0-9._\\-]+$/u.test(value) && !value.includes('..'),
+ 'EXTENSION_WINDOWS_INSTALL_PATH must be an absolute path below :\\ProgramData\\LTJT.'
+ )
+ .optional()
+);
+
function parseDurationMs(value: string): number {
const normalized = String(value || '').trim().toLowerCase();
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(normalized);
@@ -97,6 +111,18 @@ const envSchema = z.object({
OSS_BUCKET_NAME: optionalString,
OSS_REGION: optionalString,
OSS_KEY_PREFIX: z.string().trim().min(1).max(200).default('liansyn-platform/attachments'),
+ EXTENSION_AUTO_UPDATE_ENABLED: z.enum(['true', 'false']).default('false').transform((value) => value === 'true'),
+ EXTENSION_UPDATE_OSS_KEY_PREFIX: z.string().trim().min(1).max(200)
+ .default('liansyn-platform/chrome-extension')
+ .transform((value) => value.replace(/^\/+|\/+$/gu, '')),
+ EXTENSION_WINDOWS_INSTALL_PATH: optionalWindowsExtensionPath
+ .default('C:\\ProgramData\\LTJT\\chrome-extension\\ltjt-order-assistant'),
+ EXTENSION_UPDATE_MAX_PACKAGE_BYTES: z.coerce.number().int().positive().max(50_000_000).default(15_000_000),
+ EXTENSION_UPDATE_COMMAND_TIMEOUT_SECONDS: z.coerce.number().int().min(60).max(3_600).default(600),
+ EXTENSION_UPDATE_POLL_INTERVAL_MS: z.coerce.number().int().min(1_000).max(60_000).default(5_000),
+ ALIBABA_CLOUD_ACCESS_KEY_ID: optionalString,
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET: optionalString,
+ ALIBABA_CLOUD_SECURITY_TOKEN: optionalString,
DATA_RETENTION_ENABLED: z.enum(['true', 'false']).default('false').transform((value) => value === 'true'),
DATA_RETENTION_DAYS: z.coerce.number().int().positive().default(180),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']).default('info')
@@ -160,6 +186,23 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
if (parsed.ARTIFACT_STORAGE_BACKEND === 'oss' && !ossRegion) {
throw new Error('OSS_REGION is required when OSS_ENDPOINT does not use the standard oss- endpoint format.');
}
+ if (parsed.EXTENSION_AUTO_UPDATE_ENABLED) {
+ const missing = [
+ ['OSS_ACCESS_KEY_ID', parsed.OSS_ACCESS_KEY_ID],
+ ['OSS_ACCESS_KEY_SECRET', parsed.OSS_ACCESS_KEY_SECRET],
+ ['OSS_ENDPOINT', parsed.OSS_ENDPOINT],
+ ['OSS_BUCKET_NAME', parsed.OSS_BUCKET_NAME],
+ ['OSS_REGION', ossRegion],
+ ['ALIBABA_CLOUD_ACCESS_KEY_ID', parsed.ALIBABA_CLOUD_ACCESS_KEY_ID],
+ ['ALIBABA_CLOUD_ACCESS_KEY_SECRET', parsed.ALIBABA_CLOUD_ACCESS_KEY_SECRET]
+ ].filter(([, value]) => !value).map(([name]) => name);
+ if (missing.length) {
+ throw new Error(`Extension auto-update is enabled but missing configuration: ${missing.join(', ')}`);
+ }
+ if (parsed.NODE_ENV === 'production' && new URL(parsed.APP_ORIGIN).protocol !== 'https:') {
+ throw new Error('APP_ORIGIN must use HTTPS when extension auto-update is enabled in production.');
+ }
+ }
return {
...parsed,
fieldEncryptionKey: decodeEncryptionKey(parsed.FIELD_ENCRYPTION_KEY, parsed.NODE_ENV),
diff --git a/control-plane/src/db.ts b/control-plane/src/db.ts
index fe94de2..c64a9d3 100644
--- a/control-plane/src/db.ts
+++ b/control-plane/src/db.ts
@@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js';
const { Pool } = pg;
let pool: pg.Pool | null = null;
-export const REQUIRED_SCHEMA_VERSION = '018_agentbus_account_workers';
+export const REQUIRED_SCHEMA_VERSION = '019_extension_host_updates';
export interface DatabaseReadiness {
ready: boolean;
diff --git a/control-plane/src/diagnostics.ts b/control-plane/src/diagnostics.ts
index 34ed5a7..f3109ca 100644
--- a/control-plane/src/diagnostics.ts
+++ b/control-plane/src/diagnostics.ts
@@ -97,9 +97,17 @@ export function diagnosticRequestPath(value: unknown): string {
const raw = text(value);
if (!raw) return '/';
try {
- return new URL(raw, 'http://diagnostic.invalid').pathname.slice(0, 500) || '/';
+ const pathname = new URL(raw, 'http://diagnostic.invalid').pathname;
+ if (pathname.startsWith('/api/extension-updates/package/')) {
+ return '/api/extension-updates/package/:token';
+ }
+ return pathname.slice(0, 500) || '/';
} catch {
- return raw.split(/[?#]/u, 1)[0].slice(0, 500) || '/';
+ const pathname = raw.split(/[?#]/u, 1)[0];
+ if (pathname.startsWith('/api/extension-updates/package/')) {
+ return '/api/extension-updates/package/:token';
+ }
+ return pathname.slice(0, 500) || '/';
}
}
diff --git a/control-plane/src/extension-updates.ts b/control-plane/src/extension-updates.ts
new file mode 100644
index 0000000..2e44080
--- /dev/null
+++ b/control-plane/src/extension-updates.ts
@@ -0,0 +1,1276 @@
+import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
+import EcsPackage, {
+ DescribeInvocationResultsRequest,
+ RunCommandRequest
+} from '@alicloud/ecs20140526';
+import { $OpenApiUtil } from '@alicloud/openapi-core';
+import { fromBufferPromise, type Entry, type ZipFile } from 'yauzl';
+import type { AppConfig } from './config.js';
+import { getPool, withTransaction } from './db.js';
+import {
+ AliyunOssClient,
+ type OssObjectClient
+} from './oss-client.js';
+import {
+ noopDiagnosticLogger,
+ type DiagnosticLogger
+} from './diagnostics.js';
+
+const EXTENSION_NAME = '联泰下单助手';
+const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:\.\d+)?$/u;
+const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
+const TERMINAL_FAILURE_STATUSES = new Set([
+ 'invalid',
+ 'aborted',
+ 'failed',
+ 'error',
+ 'timeout',
+ 'cancelled',
+ 'terminated',
+ 'partialfailed',
+ 'stopped'
+]);
+const TERMINAL_SUCCESS_STATUSES = new Set(['success', 'finished']);
+const UPDATE_COMMAND_RESULT_GRACE_MS = 60_000;
+const MAX_ARCHIVE_ENTRIES = 500;
+const MAX_UNCOMPRESSED_BYTES = 50_000_000;
+
+export type ExtensionUpdateClientStatus =
+ | 'disabled'
+ | 'no_release'
+ | 'unconfigured'
+ | 'current'
+ | 'waiting_for_idle'
+ | 'updating'
+ | 'reload_required'
+ | 'failed';
+
+export interface ExtensionRelease {
+ id: string;
+ version: string;
+ sha256: string;
+ byte_size: number;
+ created_at: string;
+ is_active: boolean;
+}
+
+interface StoredExtensionRelease extends ExtensionRelease {
+ organization_id: string;
+ object_key: string;
+ extension_manifest: Record;
+}
+
+export interface ExtensionUpdateHeartbeatTarget {
+ ecsRegionId: string | null;
+ ecsInstanceId: string | null;
+ serverUpdateSafe: boolean;
+}
+
+export interface ExtensionUpdateObservation {
+ organizationId: string;
+ userId: string;
+ connectionId: string;
+ currentVersion: string;
+ extensionUpdateSafe: boolean;
+ target: ExtensionUpdateHeartbeatTarget;
+}
+
+export interface ExtensionUpdateClientState {
+ status: ExtensionUpdateClientStatus;
+ current_version: string;
+ target_version: string;
+ action?: 'reload';
+ message: string;
+}
+
+export interface ExtensionPackageDownload {
+ content: Buffer;
+ fileName: string;
+}
+
+export interface ExtensionHostCommandInput {
+ regionId: string;
+ instanceId: string;
+ script: string;
+ timeoutSeconds: number;
+ clientToken: string;
+}
+
+export interface ExtensionHostCommandResult {
+ status: 'running' | 'success' | 'failed';
+ exitCode?: number;
+ output?: string;
+ errorCode?: string;
+ errorMessage?: string;
+}
+
+export interface ExtensionHostCommandClient {
+ runPowerShell(input: ExtensionHostCommandInput): Promise<{ invocationId: string }>;
+ getInvocation(input: {
+ regionId: string;
+ instanceId: string;
+ invocationId: string;
+ }): Promise;
+}
+
+export function interpretCloudAssistantInvocation(item: Record): ExtensionHostCommandResult {
+ const rawStatus = String(item.invocationStatus || item.invokeRecordStatus || '').toLocaleLowerCase('en-US');
+ const output = String(item.output || '').slice(0, 2_000);
+ const exitCode = Number(item.exitCode);
+ const completionMarkerPresent = /(?:^|\r?\n)LTJT_EXTENSION_(?:UPDATED|NEWER_PRESENT) \d+\.\d+\.\d+(?:\.\d+)?(?:\r?\n|$)/u.test(output);
+ if (TERMINAL_SUCCESS_STATUSES.has(rawStatus) && exitCode === 0 && completionMarkerPresent) {
+ return { status: 'success', exitCode, output };
+ }
+ if (TERMINAL_FAILURE_STATUSES.has(rawStatus) || TERMINAL_SUCCESS_STATUSES.has(rawStatus)) {
+ return {
+ status: 'failed',
+ exitCode,
+ output,
+ errorCode: String(
+ item.errorCode
+ || (exitCode === 0 && !completionMarkerPresent ? 'extension_update_marker_missing' : '')
+ || rawStatus
+ || 'cloud_assistant_failed'
+ ).slice(0, 120),
+ errorMessage: String(
+ item.errorInfo
+ || (exitCode === 0 && !completionMarkerPresent ? '云助手命令缺少插件部署完成标记。' : '')
+ || output
+ || '云助手命令执行失败。'
+ ).slice(0, 2_000)
+ };
+ }
+ return { status: 'running', output };
+}
+
+export interface ExtensionUpdateController {
+ observeHeartbeat(input: ExtensionUpdateObservation): Promise;
+ publishRelease(input: {
+ organizationId: string;
+ actorUserId: string;
+ requestId: string;
+ content: Buffer;
+ }): Promise;
+ listReleases(organizationId: string): Promise;
+ downloadPackage(token: string): Promise;
+ close(): void;
+}
+
+export class ExtensionUpdateError extends Error {
+ constructor(
+ public readonly code: string,
+ message: string,
+ public readonly statusCode = 400
+ ) {
+ super(message);
+ this.name = 'ExtensionUpdateError';
+ }
+}
+
+function normalizeVersion(value: unknown): string {
+ const version = String(value ?? '').trim();
+ if (!VERSION_PATTERN.test(version)) {
+ throw new ExtensionUpdateError('extension_version_invalid', '插件版本号必须是 3—4 段数字。');
+ }
+ const parts = version.split('.').map(Number);
+ if (parts.some((part) => !Number.isSafeInteger(part) || part < 0 || part > 65_535)) {
+ throw new ExtensionUpdateError('extension_version_invalid', '插件版本号每一段必须介于 0—65535。');
+ }
+ return parts.join('.');
+}
+
+export function compareExtensionVersions(left: string, right: string): number {
+ const leftParts = normalizeVersion(left).split('.').map(Number);
+ const rightParts = normalizeVersion(right).split('.').map(Number);
+ const length = Math.max(leftParts.length, rightParts.length);
+ for (let index = 0; index < length; index += 1) {
+ const difference = (leftParts[index] || 0) - (rightParts[index] || 0);
+ if (difference !== 0) return difference > 0 ? 1 : -1;
+ }
+ return 0;
+}
+
+function iso(value: unknown): string {
+ const date = new Date(String(value || ''));
+ return Number.isFinite(date.getTime()) ? date.toISOString() : new Date(0).toISOString();
+}
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : {};
+}
+
+function mapRelease(row: Record): StoredExtensionRelease {
+ return {
+ id: String(row.id),
+ organization_id: String(row.organization_id),
+ version: normalizeVersion(row.version),
+ object_key: String(row.object_key),
+ sha256: String(row.sha256).toLowerCase(),
+ byte_size: Number(row.byte_size),
+ extension_manifest: asRecord(row.extension_manifest),
+ is_active: row.is_active === true || String(row.is_active) === 'true',
+ created_at: iso(row.created_at)
+ };
+}
+
+function publicRelease(release: StoredExtensionRelease): ExtensionRelease {
+ return {
+ id: release.id,
+ version: release.version,
+ sha256: release.sha256,
+ byte_size: release.byte_size,
+ is_active: release.is_active,
+ created_at: release.created_at
+ };
+}
+
+function safeArchivePath(fileName: string): string {
+ const normalized = String(fileName || '').replace(/\\/gu, '/');
+ const parts = normalized.split('/');
+ if (
+ !normalized
+ || normalized.length > 500
+ || normalized.startsWith('/')
+ || normalized.includes('\u0000')
+ || parts.some((part) => (
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/u.test(part)
+ || part === '.'
+ || part === '..'
+ || part.endsWith('.')
+ || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part)
+ ))
+ ) {
+ throw new ExtensionUpdateError('extension_package_path_invalid', '插件包包含不安全的文件路径。');
+ }
+ return normalized;
+}
+
+async function readArchiveEntry(zipFile: ZipFile, entry: Entry, maxBytes: number): Promise {
+ if (entry.uncompressedSize > maxBytes) {
+ throw new ExtensionUpdateError('extension_package_manifest_too_large', '插件 manifest.json 过大。');
+ }
+ const stream = await zipFile.openReadStreamPromise(entry);
+ const chunks: Buffer[] = [];
+ let total = 0;
+ for await (const chunk of stream) {
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ total += buffer.byteLength;
+ if (total > maxBytes) {
+ throw new ExtensionUpdateError('extension_package_manifest_too_large', '插件 manifest.json 过大。');
+ }
+ chunks.push(buffer);
+ }
+ return Buffer.concat(chunks);
+}
+
+export async function inspectExtensionPackage(content: Buffer): Promise<{
+ version: string;
+ sha256: string;
+ manifest: Record;
+ entryCount: number;
+}> {
+ let zipFile: ZipFile;
+ try {
+ zipFile = await fromBufferPromise(content, {
+ lazyEntries: true,
+ decodeStrings: true,
+ validateEntrySizes: true,
+ strictFileNames: true
+ });
+ } catch {
+ throw new ExtensionUpdateError('extension_package_invalid_zip', '上传文件不是有效的插件 ZIP。');
+ }
+
+ const paths = new Set();
+ let manifestSource: Buffer | null = null;
+ let entryCount = 0;
+ let uncompressedBytes = 0;
+ try {
+ for await (const entry of zipFile.eachEntry()) {
+ entryCount += 1;
+ uncompressedBytes += entry.uncompressedSize;
+ if (
+ entryCount > MAX_ARCHIVE_ENTRIES
+ || entry.uncompressedSize > MAX_UNCOMPRESSED_BYTES
+ || uncompressedBytes > MAX_UNCOMPRESSED_BYTES
+ ) {
+ throw new ExtensionUpdateError('extension_package_archive_limit', '插件包展开后超过安全上限。');
+ }
+ if (entry.isEncrypted() || !entry.canDecodeFileData()) {
+ throw new ExtensionUpdateError('extension_package_encrypted', '插件包不能包含加密文件。');
+ }
+ const path = safeArchivePath(entry.fileName);
+ const lowerPath = path.toLocaleLowerCase('en-US');
+ if (paths.has(lowerPath)) {
+ throw new ExtensionUpdateError('extension_package_duplicate_path', '插件包包含重复文件路径。');
+ }
+ paths.add(lowerPath);
+ const unixMode = (entry.externalFileAttributes >>> 16) & 0o170000;
+ if (unixMode === 0o120000) {
+ throw new ExtensionUpdateError('extension_package_symlink_forbidden', '插件包不能包含符号链接。');
+ }
+ if (path === 'manifest.json') manifestSource = await readArchiveEntry(zipFile, entry, 65_536);
+ }
+ } catch (error) {
+ if (error instanceof ExtensionUpdateError) throw error;
+ throw new ExtensionUpdateError('extension_package_invalid_zip', '插件 ZIP 内容无法安全读取。');
+ } finally {
+ zipFile.close();
+ }
+
+ if (!manifestSource || !paths.has('background.js') || !paths.has('business-bridge.js')) {
+ throw new ExtensionUpdateError(
+ 'extension_package_files_missing',
+ '插件包根目录必须包含 manifest.json、background.js 和 business-bridge.js。'
+ );
+ }
+ let manifest: Record;
+ try {
+ manifest = asRecord(JSON.parse(manifestSource.toString('utf8')));
+ } catch {
+ throw new ExtensionUpdateError('extension_manifest_invalid', '插件 manifest.json 不是有效 JSON。');
+ }
+ if (manifest.manifest_version !== 3 || String(manifest.name || '') !== EXTENSION_NAME) {
+ throw new ExtensionUpdateError('extension_manifest_identity_mismatch', '插件包身份与联泰下单助手不匹配。');
+ }
+ const background = asRecord(manifest.background);
+ const contentScripts = Array.isArray(manifest.content_scripts) ? manifest.content_scripts : [];
+ const bridgeDeclared = contentScripts.some((entry) => {
+ const script = asRecord(entry);
+ return Array.isArray(script.js) && script.js.map(String).includes('business-bridge.js');
+ });
+ if (String(background.service_worker || '') !== 'background.js' || !bridgeDeclared) {
+ throw new ExtensionUpdateError(
+ 'extension_manifest_contract_mismatch',
+ '插件 Manifest 未声明预期的后台脚本与平台桥接脚本。'
+ );
+ }
+ const version = normalizeVersion(manifest.version);
+ return {
+ version,
+ manifest,
+ entryCount,
+ sha256: createHash('sha256').update(content).digest('hex')
+ };
+}
+
+interface DownloadTokenPayload {
+ releaseId: string;
+ organizationId: string;
+ regionId: string;
+ instanceId: string;
+ expiresAt: number;
+}
+
+export function createExtensionDownloadToken(payload: DownloadTokenPayload, key: Buffer): string {
+ const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
+ const signature = createHmac('sha256', key)
+ .update('ltjt-extension-download-v1\0')
+ .update(encoded)
+ .digest('base64url');
+ return `${encoded}.${signature}`;
+}
+
+export function verifyExtensionDownloadToken(token: string, key: Buffer, now = Date.now()): DownloadTokenPayload {
+ if (!token || token.length > 4_096) {
+ throw new ExtensionUpdateError('extension_download_token_invalid', '插件下载令牌无效。', 401);
+ }
+ const [encoded, signature, extra] = String(token || '').split('.');
+ if (!encoded || !signature || extra) {
+ throw new ExtensionUpdateError('extension_download_token_invalid', '插件下载令牌无效。', 401);
+ }
+ const expected = createHmac('sha256', key)
+ .update('ltjt-extension-download-v1\0')
+ .update(encoded)
+ .digest();
+ let actual: Buffer;
+ try {
+ actual = Buffer.from(signature, 'base64url');
+ } catch {
+ throw new ExtensionUpdateError('extension_download_token_invalid', '插件下载令牌无效。', 401);
+ }
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
+ throw new ExtensionUpdateError('extension_download_token_invalid', '插件下载令牌无效。', 401);
+ }
+ let payload: DownloadTokenPayload;
+ try {
+ payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as DownloadTokenPayload;
+ } catch {
+ throw new ExtensionUpdateError('extension_download_token_invalid', '插件下载令牌无效。', 401);
+ }
+ if (
+ !payload.releaseId
+ || !payload.organizationId
+ || !payload.regionId
+ || !payload.instanceId
+ || !Number.isSafeInteger(payload.expiresAt)
+ ) {
+ throw new ExtensionUpdateError('extension_download_token_invalid', '插件下载令牌无效。', 401);
+ }
+ if (payload.expiresAt <= now) {
+ throw new ExtensionUpdateError('extension_download_token_expired', '插件下载令牌已过期。', 401);
+ }
+ return payload;
+}
+
+export function buildExtensionUpdatePowerShell(input: {
+ downloadUrl: string;
+ sha256: string;
+ version: string;
+ installPath: string;
+ releaseId: string;
+}): string {
+ if (!SHA256_PATTERN.test(input.sha256)) throw new Error('Invalid extension SHA-256.');
+ const encodedConfiguration = Buffer.from(JSON.stringify({
+ downloadUrl: input.downloadUrl,
+ sha256: input.sha256,
+ version: normalizeVersion(input.version),
+ installPath: input.installPath,
+ releaseId: input.releaseId,
+ extensionName: EXTENSION_NAME
+ }), 'utf8').toString('base64');
+ return String.raw`$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$configJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedConfiguration}'))
+$config = $configJson | ConvertFrom-Json
+$updateRoot = Join-Path $env:ProgramData ('LTJT\ExtensionUpdates\' + $config.releaseId)
+$packagePath = Join-Path $updateRoot 'extension.zip'
+$stagingPath = Join-Path $updateRoot 'staging'
+$installPath = [IO.Path]::GetFullPath([string]$config.installPath)
+$allowedRoot = [IO.Path]::GetFullPath((Join-Path $env:ProgramData 'LTJT'))
+$allowedPrefix = $allowedRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
+if (-not $installPath.StartsWith($allowedPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'InstallPathOutsideAllowedRoot' }
+$installedManifestPath = Join-Path $installPath 'manifest.json'
+if (Test-Path -LiteralPath $installedManifestPath -PathType Leaf) {
+ try {
+ $installedManifest = Get-Content -LiteralPath $installedManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
+ $installedVersion = [Version]::Parse([string]$installedManifest.version)
+ $targetVersion = [Version]::Parse([string]$config.version)
+ if ($installedVersion -gt $targetVersion) {
+ Write-Output ('LTJT_EXTENSION_NEWER_PRESENT ' + [string]$installedManifest.version)
+ exit 0
+ }
+ } catch {
+ # Invalid existing files are replaced only after the downloaded package
+ # passes the server-bound hash and manifest checks below.
+ }
+}
+if (Test-Path -LiteralPath $updateRoot) { Remove-Item -LiteralPath $updateRoot -Recurse -Force }
+New-Item -ItemType Directory -Path $stagingPath -Force | Out-Null
+Invoke-WebRequest -Uri ([string]$config.downloadUrl) -OutFile $packagePath -UseBasicParsing
+$actualHash = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant()
+if ($actualHash -ne ([string]$config.sha256).ToLowerInvariant()) { throw 'PackageHashMismatch' }
+Expand-Archive -LiteralPath $packagePath -DestinationPath $stagingPath -Force
+$manifestPath = Join-Path $stagingPath 'manifest.json'
+if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw 'ManifestMissing' }
+$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
+if ([string]$manifest.name -ne [string]$config.extensionName) { throw 'ExtensionIdentityMismatch' }
+if ([string]$manifest.version -ne [string]$config.version) { throw 'ExtensionVersionMismatch' }
+$backupPath = $installPath + '.previous'
+$hadPrevious = Test-Path -LiteralPath $installPath
+if (Test-Path -LiteralPath $backupPath) { Remove-Item -LiteralPath $backupPath -Recurse -Force }
+if ($hadPrevious) { Move-Item -LiteralPath $installPath -Destination $backupPath -Force }
+try {
+ Move-Item -LiteralPath $stagingPath -Destination $installPath -Force
+} catch {
+ if ($hadPrevious -and (Test-Path -LiteralPath $backupPath) -and -not (Test-Path -LiteralPath $installPath)) {
+ Move-Item -LiteralPath $backupPath -Destination $installPath -Force
+ }
+ throw
+}
+$statusPath = Join-Path $allowedRoot 'extension-update-status.json'
+@{
+ ok = $true
+ version = [string]$config.version
+ sha256 = [string]$config.sha256
+ release_id = [string]$config.releaseId
+ installed_at = [DateTime]::UtcNow.ToString('o')
+} | ConvertTo-Json -Compress | Set-Content -LiteralPath $statusPath -Encoding UTF8
+Remove-Item -LiteralPath $updateRoot -Recurse -Force -ErrorAction SilentlyContinue
+Write-Output ('LTJT_EXTENSION_UPDATED ' + [string]$config.version)`;
+}
+
+export class AliyunEcsCloudAssistantClient implements ExtensionHostCommandClient {
+ private readonly clients = new Map>();
+
+ constructor(private readonly config: AppConfig) {}
+
+ private client(regionId: string): InstanceType<(typeof EcsPackage)['default']> {
+ let client = this.clients.get(regionId);
+ if (client) return client;
+ const sdkConfig = new $OpenApiUtil.Config({
+ accessKeyId: this.config.ALIBABA_CLOUD_ACCESS_KEY_ID,
+ accessKeySecret: this.config.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
+ securityToken: this.config.ALIBABA_CLOUD_SECURITY_TOKEN,
+ regionId,
+ endpoint: `ecs.${regionId}.aliyuncs.com`,
+ connectTimeout: 10_000,
+ readTimeout: 20_000
+ });
+ client = new EcsPackage.default(sdkConfig);
+ this.clients.set(regionId, client);
+ return client;
+ }
+
+ async runPowerShell(input: ExtensionHostCommandInput): Promise<{ invocationId: string }> {
+ const commandContent = Buffer.from(input.script, 'utf8').toString('base64');
+ if (Buffer.byteLength(commandContent, 'utf8') > 24 * 1024) {
+ throw new Error('Cloud Assistant command exceeds the 24 KB encoded limit.');
+ }
+ const response = await this.client(input.regionId).runCommand(new RunCommandRequest({
+ regionId: input.regionId,
+ instanceId: [input.instanceId],
+ type: 'RunPowerShellScript',
+ commandContent,
+ contentEncoding: 'Base64',
+ repeatMode: 'Once',
+ keepCommand: false,
+ terminationMode: 'ProcessTree',
+ timeout: input.timeoutSeconds,
+ clientToken: input.clientToken,
+ name: 'LTJT Chrome extension update',
+ description: 'Controlled LTJT extension package replacement after browser idle gate.'
+ }));
+ const invocationId = String(response.body?.invokeId || '');
+ if (!invocationId) throw new Error('Alibaba ECS RunCommand did not return an invocation ID.');
+ return { invocationId };
+ }
+
+ async getInvocation(input: {
+ regionId: string;
+ instanceId: string;
+ invocationId: string;
+ }): Promise {
+ const response = await this.client(input.regionId).describeInvocationResults(
+ new DescribeInvocationResultsRequest({
+ regionId: input.regionId,
+ instanceId: input.instanceId,
+ invokeId: input.invocationId,
+ contentEncoding: 'PlainText',
+ maxResults: 10
+ })
+ );
+ const item = response.body?.invocation?.invocationResults?.invocationResult?.[0];
+ if (!item) return { status: 'running' };
+ return interpretCloudAssistantInvocation(item as unknown as Record);
+ }
+}
+
+function delay(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
+function hostKey(input: { organizationId: string; regionId: string; instanceId: string }): string {
+ return `${input.organizationId}:${input.regionId}:${input.instanceId}`;
+}
+
+function sanitizedFailure(error: unknown): { code: string; message: string } {
+ const candidate = error && typeof error === 'object' ? error as Record : {};
+ const code = String(candidate.code || candidate.name || 'extension_update_failed')
+ .replace(/[^A-Za-z0-9._-]/gu, '_')
+ .slice(0, 120) || 'extension_update_failed';
+ const message = String(candidate.message || error || '插件更新失败。')
+ .replace(/https?:\/\/\S+/giu, '[URL_REDACTED]')
+ .replace(/[A-Za-z0-9_-]{32,}\.[A-Za-z0-9_-]{32,}/gu, '[TOKEN_REDACTED]')
+ .slice(0, 2_000);
+ return { code, message };
+}
+
+function definiteUpdateFailure(error: T): T {
+ return Object.assign(error, { extensionUpdateFailureDefinite: true });
+}
+
+function cloudApiDefinitelyRejected(error: unknown): boolean {
+ const candidate = error && typeof error === 'object' ? error as Record : {};
+ const response = asRecord(candidate.response);
+ const statusCode = Number(candidate.statusCode || response.statusCode);
+ const code = String(candidate.code || '').toLocaleLowerCase('en-US');
+ if (/(?:idempotent|request.?timeout)/u.test(code) || statusCode === 408 || statusCode === 409) return false;
+ if (Number.isSafeInteger(statusCode) && statusCode >= 400 && statusCode < 500) return true;
+ return /(?:invalid|forbidden|unauthorized|notfound|not_found|unsupported|permission)/u.test(code);
+}
+
+export class ExtensionUpdateService implements ExtensionUpdateController {
+ private readonly inFlight = new Map>();
+ private closed = false;
+
+ constructor(
+ private readonly config: AppConfig,
+ private readonly objectClient: OssObjectClient,
+ private readonly commandClient: ExtensionHostCommandClient,
+ private readonly logger: DiagnosticLogger = noopDiagnosticLogger
+ ) {}
+
+ close(): void {
+ this.closed = true;
+ }
+
+ async listReleases(organizationId: string): Promise {
+ const result = await getPool(this.config).query(
+ `SELECT id, organization_id, version, object_key, sha256, byte_size,
+ extension_manifest, is_active, created_at
+ FROM extension_releases
+ WHERE organization_id = $1
+ ORDER BY created_at DESC
+ LIMIT 50`,
+ [organizationId]
+ );
+ return (result.rows as Record[]).map(mapRelease).map(publicRelease);
+ }
+
+ async publishRelease(input: {
+ organizationId: string;
+ actorUserId: string;
+ requestId: string;
+ content: Buffer;
+ }): Promise {
+ this.requireEnabled();
+ if (!input.content.byteLength || input.content.byteLength > this.config.EXTENSION_UPDATE_MAX_PACKAGE_BYTES) {
+ throw new ExtensionUpdateError(
+ 'extension_package_size_invalid',
+ `插件包必须小于 ${this.config.EXTENSION_UPDATE_MAX_PACKAGE_BYTES} 字节。`,
+ 413
+ );
+ }
+ const inspected = await inspectExtensionPackage(input.content);
+ const objectKey = `${this.config.EXTENSION_UPDATE_OSS_KEY_PREFIX}/${inspected.version}/${inspected.sha256}/ltjt-order-assistant-${inspected.version}.zip`;
+ const existing = await getPool(this.config).query(
+ `SELECT id, organization_id, version, object_key, sha256, byte_size,
+ extension_manifest, is_active, created_at
+ FROM extension_releases
+ WHERE organization_id = $1 AND version = $2`,
+ [input.organizationId, inspected.version]
+ );
+ if (existing.rowCount) {
+ const release = mapRelease(existing.rows[0] as Record);
+ if (release.sha256 !== inspected.sha256) {
+ throw new ExtensionUpdateError(
+ 'extension_release_version_conflict',
+ '该版本已经发布且文件哈希不同;请递增插件版本。',
+ 409
+ );
+ }
+ return publicRelease(release);
+ }
+ const active = await this.activeRelease(input.organizationId);
+ if (active && compareExtensionVersions(inspected.version, active.version) <= 0) {
+ throw new ExtensionUpdateError(
+ 'extension_release_not_newer',
+ `新版本必须高于当前活动版本 ${active.version}。`,
+ 409
+ );
+ }
+
+ await this.objectClient.putObject(objectKey, {
+ content: input.content,
+ contentType: 'application/zip',
+ contentDisposition: `attachment; filename="ltjt-order-assistant-${inspected.version}.zip"`,
+ acl: 'private'
+ });
+ try {
+ const release = await withTransaction(this.config, async (client) => {
+ await client.query(
+ `SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':extension-release', 0))`,
+ [input.organizationId]
+ );
+ await client.query('SELECT id FROM organizations WHERE id = $1 FOR UPDATE', [input.organizationId]);
+ const current = await client.query(
+ `SELECT version FROM extension_releases
+ WHERE organization_id = $1 AND is_active = true
+ FOR UPDATE`,
+ [input.organizationId]
+ );
+ if (current.rowCount && compareExtensionVersions(inspected.version, String(current.rows[0].version)) <= 0) {
+ throw new ExtensionUpdateError(
+ 'extension_release_not_newer',
+ `新版本必须高于当前活动版本 ${String(current.rows[0].version)}。`,
+ 409
+ );
+ }
+ await client.query(
+ 'UPDATE extension_releases SET is_active = false WHERE organization_id = $1 AND is_active = true',
+ [input.organizationId]
+ );
+ const inserted = await client.query(
+ `INSERT INTO extension_releases
+ (organization_id, version, object_key, sha256, byte_size, extension_manifest, is_active, published_by)
+ VALUES ($1, $2, $3, $4, $5, $6, true, $7)
+ RETURNING id, organization_id, version, object_key, sha256, byte_size,
+ extension_manifest, is_active, created_at`,
+ [
+ input.organizationId,
+ inspected.version,
+ objectKey,
+ inspected.sha256,
+ input.content.byteLength,
+ inspected.manifest,
+ input.actorUserId
+ ]
+ );
+ const mapped = mapRelease(inserted.rows[0] as Record);
+ await client.query(
+ `INSERT INTO audit_events
+ (organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata)
+ VALUES ($1, $2, 'extension.release.published', 'extension_release', $3, $4, $5)`,
+ [input.organizationId, input.actorUserId, mapped.id, input.requestId, {
+ version: mapped.version,
+ sha256: mapped.sha256,
+ byte_size: mapped.byte_size,
+ archive_entries: inspected.entryCount
+ }]
+ );
+ return mapped;
+ });
+ this.logger.info({
+ diagnostic_event: 'extension.release.published',
+ diagnostic_stage: 'extension_update',
+ version: release.version,
+ byte_size: release.byte_size
+ }, 'extension release published');
+ return publicRelease(release);
+ } catch (error) {
+ const persisted = await getPool(this.config).query(
+ `SELECT 1 FROM extension_releases
+ WHERE organization_id = $1 AND version = $2 AND sha256 = $3`,
+ [input.organizationId, inspected.version, inspected.sha256]
+ ).catch(() => ({ rowCount: 0 }));
+ if (!persisted.rowCount) await this.objectClient.deleteObject(objectKey).catch(() => {});
+ throw error;
+ }
+ }
+
+ async observeHeartbeat(input: ExtensionUpdateObservation): Promise {
+ const currentVersion = String(input.currentVersion || '').trim();
+ if (!this.config.EXTENSION_AUTO_UPDATE_ENABLED) {
+ const regionId = input.target.ecsRegionId;
+ const instanceId = input.target.ecsInstanceId;
+ const row = regionId && instanceId
+ ? await this.hostUpdate(input.organizationId, regionId, instanceId)
+ : null;
+ const pendingRelease = row
+ ? await this.releaseById(input.organizationId, String(row.release_id || ''))
+ : null;
+ if (row && pendingRelease && String(row.status) !== 'running') {
+ try {
+ if (compareExtensionVersions(currentVersion, pendingRelease.version) >= 0) {
+ await this.markVerified(input, pendingRelease);
+ return this.state('disabled', currentVersion, '', '插件自动更新未启用;当前浏览器已完成遗留版本验版。');
+ }
+ } catch {
+ // The normal disabled response below remains fail-closed against the
+ // static minimum extension version enforced by the platform.
+ }
+ }
+ if (row && pendingRelease && String(row.status) === 'deployed') {
+ return {
+ ...this.state('reload_required', currentVersion, pendingRelease.version, '自动更新已停用,但已部署的插件文件仍需安全重载完成收尾。'),
+ action: 'reload'
+ };
+ }
+ if (row && pendingRelease && String(row.status) === 'running') {
+ const invocationId = String(row.invocation_id || '');
+ if (
+ invocationId
+ && this.config.ALIBABA_CLOUD_ACCESS_KEY_ID
+ && this.config.ALIBABA_CLOUD_ACCESS_KEY_SECRET
+ ) {
+ this.ensureUpdateRun(input, pendingRelease, invocationId);
+ return this.state('updating', currentVersion, pendingRelease.version, '自动更新已停用;正在只读收敛已经启动的云助手命令。');
+ }
+ return this.state('failed', currentVersion, pendingRelease.version, '自动更新已停用,但云主机仍有未收敛的更新命令;请恢复原配置完成收尾。');
+ }
+ if (row && String(row.status) === 'waiting_for_idle') {
+ return this.state('failed', currentVersion, pendingRelease?.version || '', '自动更新已停用,但云主机仍有等待中的更新;请恢复原配置或人工处置。');
+ }
+ return this.state('disabled', currentVersion, '', '插件自动更新未启用。');
+ }
+ const release = await this.activeRelease(input.organizationId);
+ if (!release) return this.state('no_release', currentVersion, '', '服务端尚未发布插件版本。');
+ const regionId = input.target.ecsRegionId;
+ const instanceId = input.target.ecsInstanceId;
+ const row = regionId && instanceId
+ ? await this.hostUpdate(input.organizationId, regionId, instanceId)
+ : null;
+ if (row && String(row.status) === 'running') {
+ const runningRelease = String(row.release_id) === release.id
+ ? release
+ : await this.releaseById(input.organizationId, String(row.release_id));
+ if (!runningRelease) {
+ return this.state('failed', currentVersion, release.version, '云主机存在无法识别的旧版本部署记录,已停止自动更新。');
+ }
+ this.ensureUpdateRun(input, runningRelease, String(row.invocation_id || '') || undefined);
+ return this.state(
+ 'updating',
+ currentVersion,
+ release.version,
+ runningRelease.id === release.id
+ ? '云助手正在部署插件更新。'
+ : '云主机正在安全完成已启动的上一版本部署,随后会直接更新到最新版本。'
+ );
+ }
+
+ try {
+ if (compareExtensionVersions(currentVersion, release.version) >= 0) {
+ if (regionId && instanceId) {
+ await this.markVerified(input, release);
+ }
+ return this.state('current', currentVersion, release.version, '插件已是最新版本。');
+ }
+ } catch {
+ return this.state('failed', currentVersion, release.version, '插件上报的版本号无效,已停止自动更新。');
+ }
+
+ if (!regionId || !instanceId) {
+ return this.state('unconfigured', currentVersion, release.version, '当前平台账号尚未绑定 ECS 云主机。');
+ }
+ if (row && String(row.release_id) === release.id) {
+ const status = String(row.status || '');
+ if (status === 'deployed' || status === 'verified') {
+ return {
+ ...this.state('reload_required', currentVersion, release.version, '新插件文件已部署,等待当前浏览器安全重载。'),
+ action: 'reload'
+ };
+ }
+ if (status === 'failed') {
+ const attempts = Math.max(0, Number(row.attempt_count || 0));
+ if (attempts < 3 && input.extensionUpdateSafe && input.target.serverUpdateSafe) {
+ const restarted = await this.markRunning(input, release);
+ if (restarted) {
+ this.ensureUpdateRun(input, release);
+ return this.state('updating', currentVersion, release.version, `正在自动重试插件部署(第 ${attempts + 1}/3 次)。`);
+ }
+ }
+ if (attempts < 3) {
+ await this.markWaiting(input, release);
+ return this.state('waiting_for_idle', currentVersion, release.version, '上次部署未成功;本机空闲后会自动重试。');
+ }
+ return this.state(
+ 'failed',
+ currentVersion,
+ release.version,
+ `插件更新失败:${String(row.error_message || row.error_code || '未知错误').slice(0, 500)}`
+ );
+ }
+ }
+
+ if (!input.extensionUpdateSafe || !input.target.serverUpdateSafe) {
+ await this.markWaiting(input, release);
+ return this.state('waiting_for_idle', currentVersion, release.version, '检测到本机仍有 ERP 执行或待回查记录,更新将在完全空闲后开始。');
+ }
+ const started = await this.markRunning(input, release);
+ if (!started) {
+ return this.state('waiting_for_idle', currentVersion, release.version, '检测到本机仍有 ERP 执行或待回查记录,更新将在完全空闲后开始。');
+ }
+ this.ensureUpdateRun(input, release);
+ return this.state('updating', currentVersion, release.version, '已通过空闲门禁,正在启动云助手更新。');
+ }
+
+ async downloadPackage(token: string): Promise {
+ this.requireEnabled();
+ const payload = verifyExtensionDownloadToken(token, this.config.fieldEncryptionKey);
+ const result = await getPool(this.config).query(
+ `SELECT id, organization_id, version, object_key, sha256, byte_size,
+ extension_manifest, is_active, created_at
+ FROM extension_releases
+ WHERE id = $1 AND organization_id = $2`,
+ [payload.releaseId, payload.organizationId]
+ );
+ if (!result.rowCount) {
+ throw new ExtensionUpdateError('extension_release_not_found', '插件发布版本不存在。', 404);
+ }
+ const release = mapRelease(result.rows[0] as Record);
+ if (!this.objectClient.getObject) {
+ throw new ExtensionUpdateError('extension_package_store_unreadable', '插件包存储不支持读取。', 503);
+ }
+ const content = await this.objectClient.getObject(release.object_key);
+ if (
+ content.byteLength !== release.byte_size
+ || createHash('sha256').update(content).digest('hex') !== release.sha256
+ ) {
+ throw new ExtensionUpdateError('extension_package_integrity_failed', 'OSS 插件包完整性校验失败。', 503);
+ }
+ return {
+ content,
+ fileName: `ltjt-order-assistant-${release.version}.zip`
+ };
+ }
+
+ private requireEnabled(): void {
+ if (!this.config.EXTENSION_AUTO_UPDATE_ENABLED) {
+ throw new ExtensionUpdateError('extension_auto_update_disabled', '插件自动更新未启用。', 409);
+ }
+ }
+
+ private state(
+ status: ExtensionUpdateClientStatus,
+ currentVersion: string,
+ targetVersion: string,
+ message: string
+ ): ExtensionUpdateClientState {
+ return {
+ status,
+ current_version: currentVersion,
+ target_version: targetVersion,
+ message
+ };
+ }
+
+ private async activeRelease(organizationId: string): Promise {
+ const result = await getPool(this.config).query(
+ `SELECT id, organization_id, version, object_key, sha256, byte_size,
+ extension_manifest, is_active, created_at
+ FROM extension_releases
+ WHERE organization_id = $1 AND is_active = true`,
+ [organizationId]
+ );
+ return result.rowCount ? mapRelease(result.rows[0] as Record) : null;
+ }
+
+ private async releaseById(organizationId: string, releaseId: string): Promise {
+ const result = await getPool(this.config).query(
+ `SELECT id, organization_id, version, object_key, sha256, byte_size,
+ extension_manifest, is_active, created_at
+ FROM extension_releases
+ WHERE organization_id = $1 AND id = $2`,
+ [organizationId, releaseId]
+ );
+ return result.rowCount ? mapRelease(result.rows[0] as Record) : null;
+ }
+
+ private async hostUpdate(organizationId: string, regionId: string, instanceId: string): Promise | null> {
+ const result = await getPool(this.config).query(
+ `SELECT release_id, status, attempt_count, invocation_id, error_code, error_message, updated_at
+ FROM extension_host_updates
+ WHERE organization_id = $1 AND ecs_region_id = $2 AND ecs_instance_id = $3`,
+ [organizationId, regionId, instanceId]
+ );
+ return result.rowCount ? result.rows[0] as Record : null;
+ }
+
+ private async markWaiting(input: ExtensionUpdateObservation, release: StoredExtensionRelease): Promise {
+ await getPool(this.config).query(
+ `INSERT INTO extension_host_updates
+ (organization_id, ecs_region_id, ecs_instance_id, release_id, status, attempt_count)
+ VALUES ($1, $2, $3, $4, 'waiting_for_idle', 0)
+ ON CONFLICT (organization_id, ecs_region_id, ecs_instance_id)
+ DO UPDATE SET release_id = EXCLUDED.release_id,
+ status = CASE
+ WHEN extension_host_updates.release_id = EXCLUDED.release_id
+ AND extension_host_updates.status IN ('running', 'deployed', 'verified')
+ THEN extension_host_updates.status
+ ELSE 'waiting_for_idle'
+ END,
+ attempt_count = CASE
+ WHEN extension_host_updates.release_id = EXCLUDED.release_id
+ THEN extension_host_updates.attempt_count
+ ELSE 0
+ END,
+ error_code = NULL, error_message = NULL, updated_at = now()`,
+ [input.organizationId, input.target.ecsRegionId, input.target.ecsInstanceId, release.id]
+ );
+ }
+
+ private async markRunning(input: ExtensionUpdateObservation, release: StoredExtensionRelease): Promise {
+ return withTransaction(this.config, async (client) => {
+ await client.query('SELECT id FROM organizations WHERE id = $1 FOR UPDATE', [input.organizationId]);
+ const activeRelease = await client.query(
+ `SELECT 1 FROM extension_releases
+ WHERE organization_id = $1 AND id = $2 AND is_active = true`,
+ [input.organizationId, release.id]
+ );
+ if (!activeRelease.rowCount) return false;
+ const unsafe = await client.query(
+ `SELECT (
+ EXISTS (
+ SELECT 1
+ FROM users host_account
+ JOIN tasks task
+ ON task.organization_id = host_account.organization_id
+ AND task.assigned_user_id = host_account.id
+ WHERE host_account.organization_id = $1
+ AND host_account.extension_ecs_region_id = $2
+ AND host_account.extension_ecs_instance_id = $3
+ AND (
+ task.status = 'reconciliation_pending'
+ OR (task.lease_expires_at IS NOT NULL AND task.lease_expires_at > now())
+ OR EXISTS (
+ SELECT 1 FROM task_attempts attempt
+ WHERE attempt.task_id = task.id
+ AND attempt.phase = 'erp'
+ AND attempt.status IN ('accepted', 'running', 'reconciliation_pending')
+ )
+ )
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM users host_account
+ JOIN browser_connections connection
+ ON connection.organization_id = host_account.organization_id
+ AND connection.user_id = host_account.id
+ WHERE host_account.organization_id = $1
+ AND host_account.extension_ecs_region_id = $2
+ AND host_account.extension_ecs_instance_id = $3
+ AND connection.status = 'connected'
+ AND connection.last_seen_at >= now() - interval '90 seconds'
+ AND lower(COALESCE(connection.metadata->>'extension_update_safe', 'false')) <> 'true'
+ )
+ ) AS unsafe`,
+ [input.organizationId, input.target.ecsRegionId, input.target.ecsInstanceId]
+ );
+ if (unsafe.rows[0]?.unsafe === true || String(unsafe.rows[0]?.unsafe) === 'true') {
+ await client.query(
+ `INSERT INTO extension_host_updates
+ (organization_id, ecs_region_id, ecs_instance_id, release_id, status, attempt_count)
+ VALUES ($1, $2, $3, $4, 'waiting_for_idle', 0)
+ ON CONFLICT (organization_id, ecs_region_id, ecs_instance_id)
+ DO UPDATE SET release_id = EXCLUDED.release_id, status = 'waiting_for_idle',
+ attempt_count = CASE
+ WHEN extension_host_updates.release_id = EXCLUDED.release_id
+ THEN extension_host_updates.attempt_count
+ ELSE 0
+ END,
+ invocation_id = NULL, error_code = NULL, error_message = NULL,
+ updated_at = now()`,
+ [input.organizationId, input.target.ecsRegionId, input.target.ecsInstanceId, release.id]
+ );
+ return false;
+ }
+ const existing = await client.query(
+ `SELECT release_id, status, attempt_count
+ FROM extension_host_updates
+ WHERE organization_id = $1 AND ecs_region_id = $2 AND ecs_instance_id = $3
+ FOR UPDATE`,
+ [input.organizationId, input.target.ecsRegionId, input.target.ecsInstanceId]
+ );
+ if (
+ existing.rowCount
+ && String(existing.rows[0]?.release_id) === release.id
+ && String(existing.rows[0]?.status) === 'running'
+ ) {
+ return true;
+ }
+ if (
+ existing.rowCount
+ && String(existing.rows[0]?.release_id) === release.id
+ && ['failed', 'waiting_for_idle'].includes(String(existing.rows[0]?.status))
+ && Number(existing.rows[0]?.attempt_count) >= 3
+ ) {
+ return false;
+ }
+ await client.query(
+ `INSERT INTO extension_host_updates
+ (organization_id, ecs_region_id, ecs_instance_id, release_id, status, attempt_count, started_at)
+ VALUES ($1, $2, $3, $4, 'running', 1, now())
+ ON CONFLICT (organization_id, ecs_region_id, ecs_instance_id)
+ DO UPDATE SET release_id = EXCLUDED.release_id, status = 'running',
+ attempt_count = CASE
+ WHEN extension_host_updates.release_id = EXCLUDED.release_id
+ THEN LEAST(extension_host_updates.attempt_count + 1, 3)
+ ELSE 1
+ END,
+ invocation_id = NULL,
+ error_code = NULL, error_message = NULL, started_at = now(),
+ deployed_at = NULL, verified_at = NULL, updated_at = now()`,
+ [input.organizationId, input.target.ecsRegionId, input.target.ecsInstanceId, release.id]
+ );
+ return true;
+ });
+ }
+
+ private async markVerified(input: ExtensionUpdateObservation, release: StoredExtensionRelease): Promise {
+ await getPool(this.config).query(
+ `UPDATE extension_host_updates
+ SET status = 'verified', verified_at = now(), updated_at = now(),
+ error_code = NULL, error_message = NULL
+ WHERE organization_id = $1 AND ecs_region_id = $2 AND ecs_instance_id = $3
+ AND release_id = $4 AND status IN ('waiting_for_idle', 'deployed', 'verified', 'failed')`,
+ [input.organizationId, input.target.ecsRegionId, input.target.ecsInstanceId, release.id]
+ );
+ }
+
+ private ensureUpdateRun(
+ input: ExtensionUpdateObservation,
+ release: StoredExtensionRelease,
+ existingInvocationId?: string
+ ): void {
+ const regionId = String(input.target.ecsRegionId || '');
+ const instanceId = String(input.target.ecsInstanceId || '');
+ const key = hostKey({ organizationId: input.organizationId, regionId, instanceId });
+ if (this.closed || this.inFlight.has(key)) return;
+ const promise = this.runUpdate(input.organizationId, regionId, instanceId, release, existingInvocationId)
+ .catch((error) => {
+ const failure = sanitizedFailure(error);
+ this.logger.error({
+ diagnostic_event: 'extension.update.failed',
+ diagnostic_stage: 'extension_update',
+ ecs_region_id: regionId,
+ ecs_instance_id: instanceId,
+ target_version: release.version,
+ error_code: failure.code
+ }, 'extension host update failed');
+ })
+ .finally(() => this.inFlight.delete(key));
+ this.inFlight.set(key, promise);
+ }
+
+ private async runUpdate(
+ organizationId: string,
+ regionId: string,
+ instanceId: string,
+ release: StoredExtensionRelease,
+ existingInvocationId?: string
+ ): Promise {
+ let invocationId = existingInvocationId || '';
+ try {
+ const attempt = await getPool(this.config).query(
+ `SELECT attempt_count, started_at
+ FROM extension_host_updates
+ WHERE organization_id = $1 AND ecs_region_id = $2 AND ecs_instance_id = $3
+ AND release_id = $4 AND status = 'running'`,
+ [organizationId, regionId, instanceId, release.id]
+ );
+ if (!attempt.rowCount) {
+ throw definiteUpdateFailure(Object.assign(new Error('插件更新尝试记录不存在或已失效。'), {
+ code: 'extension_update_attempt_missing'
+ }));
+ }
+ const attemptCount = Number(attempt.rows[0]?.attempt_count);
+ const startedAt = new Date(String(attempt.rows[0]?.started_at || '')).getTime();
+ if (!Number.isSafeInteger(attemptCount) || attemptCount < 1 || !Number.isFinite(startedAt)) {
+ throw definiteUpdateFailure(Object.assign(new Error('插件更新尝试记录无效。'), {
+ code: 'extension_update_attempt_invalid'
+ }));
+ }
+ const deadline = startedAt
+ + this.config.EXTENSION_UPDATE_COMMAND_TIMEOUT_SECONDS * 1_000
+ + UPDATE_COMMAND_RESULT_GRACE_MS;
+ if (deadline <= Date.now()) {
+ throw definiteUpdateFailure(Object.assign(
+ new Error('等待云助手更新结果超时。'),
+ { code: 'cloud_assistant_timeout' }
+ ));
+ }
+ if (!invocationId) {
+ // Both values are derived from the persisted attempt. If the service stops
+ // after RunCommand succeeds but before invocation_id is stored, the next
+ // heartbeat reconstructs byte-for-byte identical command parameters and
+ // Alibaba Cloud can safely apply ClientToken idempotency.
+ const expiresAt = deadline;
+ if (expiresAt <= Date.now()) {
+ throw definiteUpdateFailure(Object.assign(new Error('云助手命令受理状态在安全窗口内仍无法确定。'), {
+ code: 'cloud_assistant_dispatch_timeout'
+ }));
+ }
+ const token = createExtensionDownloadToken({
+ releaseId: release.id,
+ organizationId,
+ regionId,
+ instanceId,
+ expiresAt
+ }, this.config.fieldEncryptionKey);
+ const downloadUrl = new URL(`/api/extension-updates/package/${encodeURIComponent(token)}`, this.config.APP_ORIGIN).toString();
+ const script = buildExtensionUpdatePowerShell({
+ downloadUrl,
+ sha256: release.sha256,
+ version: release.version,
+ installPath: this.config.EXTENSION_WINDOWS_INSTALL_PATH,
+ releaseId: release.id
+ });
+ let started: { invocationId: string };
+ try {
+ started = await this.commandClient.runPowerShell({
+ regionId,
+ instanceId,
+ script,
+ timeoutSeconds: this.config.EXTENSION_UPDATE_COMMAND_TIMEOUT_SECONDS,
+ clientToken: createHash('sha256')
+ .update(`${organizationId}:${regionId}:${instanceId}:${release.id}:${attemptCount}:${startedAt}`)
+ .digest('hex')
+ });
+ } catch (error) {
+ if (cloudApiDefinitelyRejected(error)) {
+ throw definiteUpdateFailure(error instanceof Error ? error : new Error(String(error)));
+ }
+ throw error;
+ }
+ invocationId = started.invocationId;
+ await getPool(this.config).query(
+ `UPDATE extension_host_updates
+ SET invocation_id = $1, updated_at = now()
+ WHERE organization_id = $2 AND ecs_region_id = $3 AND ecs_instance_id = $4
+ AND release_id = $5 AND status = 'running'`,
+ [invocationId, organizationId, regionId, instanceId, release.id]
+ );
+ }
+
+ while (!this.closed && Date.now() < deadline) {
+ const result = await this.commandClient.getInvocation({ regionId, instanceId, invocationId });
+ if (result.status === 'success') {
+ await getPool(this.config).query(
+ `UPDATE extension_host_updates
+ SET status = 'deployed', deployed_at = now(), updated_at = now(),
+ error_code = NULL, error_message = NULL
+ WHERE organization_id = $1 AND ecs_region_id = $2 AND ecs_instance_id = $3
+ AND release_id = $4 AND status = 'running'`,
+ [organizationId, regionId, instanceId, release.id]
+ );
+ this.logger.info({
+ diagnostic_event: 'extension.update.deployed',
+ diagnostic_stage: 'extension_update',
+ ecs_region_id: regionId,
+ ecs_instance_id: instanceId,
+ target_version: release.version
+ }, 'extension package deployed; browser reload pending');
+ return;
+ }
+ if (result.status === 'failed') {
+ throw definiteUpdateFailure(Object.assign(new Error(result.errorMessage || '云助手命令执行失败。'), {
+ code: result.errorCode || 'cloud_assistant_failed'
+ }));
+ }
+ await delay(this.config.EXTENSION_UPDATE_POLL_INTERVAL_MS);
+ }
+ if (this.closed) return;
+ throw definiteUpdateFailure(Object.assign(
+ new Error('等待云助手更新结果超时。'),
+ { code: 'cloud_assistant_timeout' }
+ ));
+ } catch (error) {
+ const failure = sanitizedFailure(error);
+ const candidate = error && typeof error === 'object' ? error as Record : {};
+ if (candidate.extensionUpdateFailureDefinite !== true) {
+ await getPool(this.config).query(
+ `UPDATE extension_host_updates
+ SET error_code = 'cloud_assistant_dispatch_uncertain', error_message = $1, updated_at = now()
+ WHERE organization_id = $2 AND ecs_region_id = $3 AND ecs_instance_id = $4
+ AND release_id = $5 AND status = 'running'`,
+ [failure.message, organizationId, regionId, instanceId, release.id]
+ ).catch(() => {});
+ throw error;
+ }
+ await getPool(this.config).query(
+ `UPDATE extension_host_updates
+ SET status = 'failed', error_code = $1, error_message = $2, updated_at = now()
+ WHERE organization_id = $3 AND ecs_region_id = $4 AND ecs_instance_id = $5
+ AND release_id = $6 AND status = 'running'`,
+ [failure.code, failure.message, organizationId, regionId, instanceId, release.id]
+ ).catch(() => {});
+ throw error;
+ }
+ }
+}
+
+export function createExtensionUpdateService(
+ config: AppConfig,
+ logger: DiagnosticLogger = noopDiagnosticLogger
+): ExtensionUpdateService {
+ return new ExtensionUpdateService(
+ config,
+ new AliyunOssClient(config),
+ new AliyunEcsCloudAssistantClient(config),
+ logger
+ );
+}
diff --git a/control-plane/src/oss-client.ts b/control-plane/src/oss-client.ts
index 7ccfeb5..28e8543 100644
--- a/control-plane/src/oss-client.ts
+++ b/control-plane/src/oss-client.ts
@@ -9,6 +9,7 @@ export interface OssPutObjectInput {
content: Buffer;
contentType: string;
contentDisposition?: string;
+ acl?: 'private' | 'public-read';
}
export interface OssObjectClient {
@@ -168,7 +169,7 @@ export class AliyunOssClient implements OssObjectClient {
const headers: Record = {
'content-type': input.contentType,
'x-oss-content-sha256': UNSIGNED_PAYLOAD,
- 'x-oss-object-acl': 'public-read'
+ 'x-oss-object-acl': input.acl || 'public-read'
};
if (input.contentDisposition) headers['content-disposition'] = input.contentDisposition;
const additionalHeaders = input.contentDisposition ? ['content-disposition'] : [];
diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts
index 947b194..a8ea58b 100644
--- a/control-plane/src/server.ts
+++ b/control-plane/src/server.ts
@@ -44,6 +44,11 @@ import {
normalizeRequestId,
writeEmergencyDiagnostic
} from './diagnostics.js';
+import {
+ ExtensionUpdateError,
+ createExtensionUpdateService,
+ type ExtensionUpdateController
+} from './extension-updates.js';
export function aiServiceConnected(databaseIsReady: boolean, probe: Record): boolean {
return databaseIsReady && probe.configured === true && probe.reachable === true;
@@ -64,6 +69,8 @@ const accountCreateSchema = z.object({
password: z.string().min(1),
role: z.enum(['admin', 'team_lead', 'user']).default('user'),
erp_account: z.string().trim().max(200).optional(),
+ extension_ecs_region_id: z.string().trim().max(64).optional(),
+ extension_ecs_instance_id: z.string().trim().max(80).optional(),
business_route_ids: z.array(
z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。')
).max(BUSINESS_ROUTES.length).default([])
@@ -73,8 +80,16 @@ const accountCreateSchema = z.object({
const accountUpdateSchema = z.object({
role: z.enum(['admin', 'team_lead', 'user']).optional(),
is_active: z.boolean().optional(),
- erp_account: z.string().trim().max(200).nullable().optional()
-}).refine((body) => body.role !== undefined || body.is_active !== undefined || body.erp_account !== undefined, {
+ erp_account: z.string().trim().max(200).nullable().optional(),
+ extension_ecs_region_id: z.string().trim().max(64).nullable().optional(),
+ extension_ecs_instance_id: z.string().trim().max(80).nullable().optional()
+}).refine((body) => (
+ body.role !== undefined
+ || body.is_active !== undefined
+ || body.erp_account !== undefined
+ || body.extension_ecs_region_id !== undefined
+ || body.extension_ecs_instance_id !== undefined
+), {
message: '至少提供一个账号更新字段。'
});
@@ -127,8 +142,12 @@ const heartbeatSchema = z.object({
extension_version: z.string().max(80).optional(),
erp_account: z.string().trim().max(200).optional(),
erp_account_matched: z.boolean().optional(),
+ extension_update_safe: z.boolean().optional(),
metadata: z.record(z.unknown()).optional()
});
+const extensionReleasePublishSchema = z.object({
+ package_base64: z.string().min(4).max(70_000_000)
+});
const automationSettingsSchema = z.object({ enabled: z.boolean() });
const parserRoutingUpdateSchema = z.object({
mode: z.enum(['ai', 'shadow', 'auto', 'program']),
@@ -385,12 +404,14 @@ export async function buildServer({
config = loadConfig(),
parser,
startParserLoop = true,
- loggerDestination
+ loggerDestination,
+ extensionUpdates: extensionUpdatesOverride
}: {
config?: AppConfig;
parser?: ExternalParser;
startParserLoop?: boolean;
loggerDestination?: DestinationStream;
+ extensionUpdates?: ExtensionUpdateController;
} = {}) {
const requestStartedAt = new WeakMap();
const app = Fastify({
@@ -398,7 +419,11 @@ export async function buildServer({
logController: new LogController({ disableRequestLogging: true }),
genReqId: (rawRequest) => normalizeRequestId(rawRequest.headers['x-request-id']),
trustProxy: true,
- bodyLimit: Math.min(75_000_000, Math.max(2_000_000, Math.ceil(config.ARTIFACT_MAX_BYTES * 1.4) + 1_000_000))
+ bodyLimit: Math.min(75_000_000, Math.max(
+ 2_000_000,
+ Math.ceil(config.ARTIFACT_MAX_BYTES * 1.4) + 1_000_000,
+ Math.ceil(config.EXTENSION_UPDATE_MAX_PACKAGE_BYTES * 1.4) + 1_000_000
+ ))
});
await app.register(cookie);
await app.register(helmet, { contentSecurityPolicy: false });
@@ -500,6 +525,11 @@ export async function buildServer({
warn: (metadata, message) => app.log.warn(metadata, message),
error: (metadata, message) => app.log.error(metadata, message)
});
+ const extensionUpdates = extensionUpdatesOverride || createExtensionUpdateService(config, {
+ info: (metadata, message) => app.log.info(metadata, message),
+ warn: (metadata, message) => app.log.warn(metadata, message),
+ error: (metadata, message) => app.log.error(metadata, message)
+ });
const externalParser = parser || await loadExternalParser();
const parserOrchestrator = new ParserOrchestrator(externalParser);
const activeParseWorkers = new Map();
@@ -972,6 +1002,8 @@ export async function buildServer({
password: body.password,
role: body.role,
erpAccount: body.erp_account,
+ extensionEcsRegionId: body.extension_ecs_region_id,
+ extensionEcsInstanceId: body.extension_ecs_instance_id,
businessRouteIds: body.business_route_ids
}, requestId(request));
return { ok: true, account };
@@ -985,7 +1017,9 @@ export async function buildServer({
const account = await auth.updateAccount(session.user, userId, {
role: body.role,
isActive: body.is_active,
- erpAccount: body.erp_account
+ erpAccount: body.erp_account,
+ extensionEcsRegionId: body.extension_ecs_region_id,
+ extensionEcsInstanceId: body.extension_ecs_instance_id
}, requestId(request));
await agentBus?.reload();
return { ok: true, account };
@@ -1352,13 +1386,77 @@ export async function buildServer({
contextFor(session, request),
body.connection_id,
body.extension_version || '',
- body.metadata || {},
+ {
+ ...(body.metadata || {}),
+ extension_update_safe: body.extension_update_safe === true
+ },
{
erpAccount: body.erp_account || '',
erpAccountMatched: body.erp_account_matched === true
}
);
- return { ok: true, connected: true, ...worker };
+ const { extension_update_target: updateTarget, ...publicWorker } = worker;
+ const extensionUpdate = await extensionUpdates.observeHeartbeat({
+ organizationId: session.user.organizationId,
+ userId: session.user.id,
+ connectionId: body.connection_id,
+ currentVersion: body.extension_version || '',
+ extensionUpdateSafe: body.extension_update_safe === true,
+ target: {
+ ecsRegionId: updateTarget.ecs_region_id,
+ ecsInstanceId: updateTarget.ecs_instance_id,
+ serverUpdateSafe: updateTarget.server_update_safe
+ }
+ });
+ return { ok: true, connected: true, ...publicWorker, extension_update: extensionUpdate };
+ });
+
+ app.get('/api/extension-updates/releases', async (request) => {
+ const session = await requireAdminSession(request);
+ return {
+ ok: true,
+ enabled: config.EXTENSION_AUTO_UPDATE_ENABLED,
+ releases: await extensionUpdates.listReleases(session.user.organizationId)
+ };
+ });
+
+ app.post('/api/extension-updates/releases', {
+ config: { rateLimit: { max: 5, timeWindow: '1 minute' } }
+ }, async (request) => {
+ const session = await requireAdminMutationSession(request);
+ const body = extensionReleasePublishSchema.parse(request.body);
+ const normalized = body.package_base64.replace(/\s+/gu, '');
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(normalized)) {
+ throw new ExtensionUpdateError('extension_package_base64_invalid', '插件包编码无效。');
+ }
+ const content = Buffer.from(normalized, 'base64');
+ const canonical = content.toString('base64').replace(/=+$/u, '');
+ if (canonical !== normalized.replace(/=+$/u, '')) {
+ throw new ExtensionUpdateError('extension_package_base64_invalid', '插件包编码无效。');
+ }
+ return {
+ ok: true,
+ release: await extensionUpdates.publishRelease({
+ organizationId: session.user.organizationId,
+ actorUserId: session.user.id,
+ requestId: requestId(request),
+ content
+ })
+ };
+ });
+
+ app.get('/api/extension-updates/package/:token', {
+ // A fleet may share one outbound NAT address and start together after a
+ // release. The HMAC token is already release/organization/host/expiry
+ // scoped, so keep only a generous abuse ceiling here.
+ config: { rateLimit: { max: 300, timeWindow: '1 minute' } }
+ }, async (request, reply) => {
+ const params = request.params as { token: string };
+ const download = await extensionUpdates.downloadPackage(params.token);
+ reply.header('Cache-Control', 'private, no-store, max-age=0');
+ reply.header('Content-Type', 'application/zip');
+ reply.header('Content-Disposition', attachmentContentDisposition(download.fileName));
+ return reply.send(download.content);
});
app.get('/api/audit', async (request) => {
@@ -1465,7 +1563,7 @@ export async function buildServer({
});
app.setErrorHandler((error, request, reply) => {
- if (error instanceof AuthError || error instanceof TaskError) {
+ if (error instanceof AuthError || error instanceof TaskError || error instanceof ExtensionUpdateError) {
request.log.warn({
diagnostic_event: 'http.request.rejected',
diagnostic_stage: 'http',
@@ -1512,13 +1610,14 @@ export async function buildServer({
}
app.addHook('onClose', async () => {
+ extensionUpdates.close();
app.log.info({
diagnostic_event: 'service.closing',
diagnostic_stage: 'shutdown'
}, 'control plane closing');
await closePool();
});
- return { app, auth, tasks, agentBus, channelService };
+ return { app, auth, tasks, agentBus, channelService, extensionUpdates };
}
function installProcessDiagnostics(app: Awaited>['app']): void {
diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts
index f7ed34c..c78381f 100644
--- a/control-plane/src/task-service.ts
+++ b/control-plane/src/task-service.ts
@@ -49,6 +49,7 @@ import {
createAgentBusAcceptedDeliveryPayload,
type AgentBusAcceptedDeliveryOptions
} from './agentbus-delivery.js';
+import { compareExtensionVersions } from './extension-updates.js';
export interface TaskEvent {
id: number;
@@ -2712,6 +2713,84 @@ export class TaskService {
}
}
+ private async requireExtensionHostAvailable(
+ client: import('pg').PoolClient,
+ context: TaskContext,
+ connectionId: string
+ ): Promise {
+ const update = await client.query(
+ `SELECT host_update.status
+ FROM users account
+ JOIN extension_host_updates host_update
+ ON host_update.organization_id = account.organization_id
+ AND host_update.ecs_region_id = account.extension_ecs_region_id
+ AND host_update.ecs_instance_id = account.extension_ecs_instance_id
+ WHERE account.organization_id = $1
+ AND account.id = $2
+ AND host_update.status IN ('waiting_for_idle', 'running', 'deployed')
+ FOR UPDATE OF host_update`,
+ [context.organizationId, context.userId]
+ );
+ if (update.rowCount) {
+ throw new TaskError(
+ 'extension_host_update_in_progress',
+ '当前云主机正在等待或执行插件更新;新 ERP 任务会在更新并验版后继续。',
+ 409,
+ { update_status: text(update.rows[0].status) }
+ );
+ }
+ if (!this.config.EXTENSION_AUTO_UPDATE_ENABLED) return;
+ const requiredVersion = await client.query(
+ `SELECT release.version AS target_version,
+ connection.extension_version AS current_version,
+ account.extension_ecs_region_id,
+ account.extension_ecs_instance_id,
+ host_update.status AS update_status
+ FROM extension_releases release
+ JOIN browser_connections connection
+ ON connection.organization_id = release.organization_id
+ AND connection.user_id = $2
+ AND connection.connection_id = $3
+ JOIN users account
+ ON account.organization_id = connection.organization_id
+ AND account.id = connection.user_id
+ LEFT JOIN extension_host_updates host_update
+ ON host_update.organization_id = account.organization_id
+ AND host_update.ecs_region_id = account.extension_ecs_region_id
+ AND host_update.ecs_instance_id = account.extension_ecs_instance_id
+ AND host_update.release_id = release.id
+ WHERE release.organization_id = $1
+ AND release.is_active = true`,
+ [context.organizationId, context.userId, connectionId]
+ );
+ if (!requiredVersion.rowCount) return;
+ const versionRow = requiredVersion.rows[0] as Record;
+ const currentVersion = text(versionRow.current_version);
+ const targetVersion = text(versionRow.target_version);
+ let isCurrent = false;
+ try {
+ isCurrent = compareExtensionVersions(currentVersion, targetVersion) >= 0;
+ } catch {
+ isCurrent = false;
+ }
+ if (!isCurrent) {
+ throw new TaskError(
+ 'extension_update_required',
+ `当前插件版本 ${currentVersion || '未知'} 尚未更新到 ${targetVersion};新 ERP 任务会在更新并验版后继续。`,
+ 409,
+ {
+ current_version: currentVersion || null,
+ target_version: targetVersion,
+ update_status: text(versionRow.update_status) || (
+ versionRow.extension_ecs_region_id && versionRow.extension_ecs_instance_id
+ ? 'not_started'
+ : 'unconfigured'
+ )
+ }
+ );
+ }
+ }
+
private log(
level: 'info' | 'warn' | 'error',
metadata: Record,
@@ -6183,6 +6262,7 @@ export class TaskService {
}
await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId);
await this.requireBrowserConnection(client, context, connectionId);
+ await this.requireExtensionHostAvailable(client, context, connectionId);
const existingAttempt = await client.query(
`SELECT id, status, response_hash
FROM task_attempts
@@ -7116,10 +7196,20 @@ export class TaskService {
extensionVersion: string,
metadata: Record = {},
routing: { erpAccount?: string; erpAccountMatched?: boolean } = {}
- ): Promise<{ execution_ready: boolean; erp_account_matched: boolean; worker_connection_id: string }> {
+ ): Promise<{
+ execution_ready: boolean;
+ erp_account_matched: boolean;
+ worker_connection_id: string;
+ extension_update_target: {
+ ecs_region_id: string | null;
+ ecs_instance_id: string | null;
+ server_update_safe: boolean;
+ };
+ }> {
return withTransaction(this.config, async (client) => {
const account = await client.query(
- `SELECT id, role, is_active, erp_account
+ `SELECT id, role, is_active, erp_account,
+ extension_ecs_region_id, extension_ecs_instance_id
FROM users
WHERE organization_id = $1 AND id = $2
FOR UPDATE`,
@@ -7205,10 +7295,59 @@ export class TaskService {
if (!result.rowCount) {
throw new TaskError('browser_connection_not_owned', '浏览器连接已绑定其他账号,请重新生成连接标识。', 403);
}
+ const ecsRegionId = text(accountRow.extension_ecs_region_id) || null;
+ const ecsInstanceId = text(accountRow.extension_ecs_instance_id) || null;
+ let serverUpdateSafe = false;
+ if (ecsRegionId && ecsInstanceId) {
+ const unsafe = await client.query(
+ `SELECT (
+ EXISTS (
+ SELECT 1
+ FROM users host_account
+ JOIN tasks task
+ ON task.organization_id = host_account.organization_id
+ AND task.assigned_user_id = host_account.id
+ WHERE host_account.organization_id = $1
+ AND host_account.extension_ecs_region_id = $2
+ AND host_account.extension_ecs_instance_id = $3
+ AND (
+ task.status = 'reconciliation_pending'
+ OR (task.lease_expires_at IS NOT NULL AND task.lease_expires_at > now())
+ OR EXISTS (
+ SELECT 1 FROM task_attempts attempt
+ WHERE attempt.task_id = task.id
+ AND attempt.phase = 'erp'
+ AND attempt.status IN ('accepted', 'running', 'reconciliation_pending')
+ )
+ )
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM users host_account
+ JOIN browser_connections connection
+ ON connection.organization_id = host_account.organization_id
+ AND connection.user_id = host_account.id
+ WHERE host_account.organization_id = $1
+ AND host_account.extension_ecs_region_id = $2
+ AND host_account.extension_ecs_instance_id = $3
+ AND connection.status = 'connected'
+ AND connection.last_seen_at >= now() - interval '90 seconds'
+ AND lower(COALESCE(connection.metadata->>'extension_update_safe', 'false')) <> 'true'
+ )
+ ) AS unsafe`,
+ [context.organizationId, ecsRegionId, ecsInstanceId]
+ );
+ serverUpdateSafe = !databaseBoolean(unsafe.rows[0]?.unsafe);
+ }
return {
execution_ready: executionReady,
erp_account_matched: erpAccountMatched,
- worker_connection_id: connectionId
+ worker_connection_id: connectionId,
+ extension_update_target: {
+ ecs_region_id: ecsRegionId,
+ ecs_instance_id: ecsInstanceId,
+ server_update_safe: serverUpdateSafe
+ }
};
});
}
diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts
index 232f836..73514af 100644
--- a/control-plane/test/account-authorization.test.ts
+++ b/control-plane/test/account-authorization.test.ts
@@ -71,6 +71,37 @@ test('AgentBus account-worker migration adds fail-closed channel, task, browser,
assert.match(sql, /owner_user_id IS NULL[\s\S]+enabled = true/);
});
+test('extension host migration binds accounts to ECS and persists release/update state without a host daemon', async () => {
+ const [sql, server, tasks, extensionUpdates] = await Promise.all([
+ source('../migrations/019_extension_host_updates.sql'),
+ source('../src/server.ts'),
+ source('../src/task-service.ts'),
+ source('../src/extension-updates.ts')
+ ]);
+ assert.match(sql, /extension_ecs_region_id/);
+ assert.match(sql, /extension_ecs_instance_id/);
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS extension_releases/);
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS extension_host_updates/);
+ assert.match(sql, /waiting_for_idle/);
+ assert.doesNotMatch(sql, /DELETE\s+FROM/i);
+ assert.match(server, /\/api\/extension-updates\/releases/);
+ assert.match(server, /extension_update_safe/);
+ assert.match(tasks, /requireExtensionHostAvailable/);
+ assert.match(tasks, /extension_update_required/);
+ assert.match(tasks, /compareExtensionVersions/);
+ assert.match(tasks, /attempt\.status IN \('accepted', 'running', 'reconciliation_pending'\)/);
+ assert.match(extensionUpdates, /class AliyunEcsCloudAssistantClient/);
+ assert.match(extensionUpdates, /type: 'RunPowerShellScript'/);
+ assert.match(extensionUpdates, /Get-FileHash/);
+ assert.match(extensionUpdates, /EXTENSION_WINDOWS_INSTALL_PATH/);
+ assert.match(extensionUpdates, /安全完成已启动的上一版本部署/);
+ assert.match(extensionUpdates, /id = \$2 AND is_active = true/);
+ assert.match(extensionUpdates, /\$\{inspected\.sha256\}/);
+ assert.match(extensionUpdates, /clientToken: createHash\('sha256'\)/);
+ assert.doesNotMatch(extensionUpdates, /clientToken: randomUUID\(\)/);
+ assert.doesNotMatch(extensionUpdates, /setInterval\(/);
+});
+
test('AgentBus channel keys and owners are unique so one inbound identity cannot fan out to multiple employees', async () => {
const channels = await source('../src/agentbus-channels.ts');
assert.match(channels, /requireAssignableOwner/);
diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts
index 904e50d..3e4c303 100644
--- a/control-plane/test/control-plane.test.ts
+++ b/control-plane/test/control-plane.test.ts
@@ -317,7 +317,7 @@ test('control plane requires the latest durable task-outcome migration before re
const { readFile } = await import('node:fs/promises');
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
- assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers');
+ assert.equal(REQUIRED_SCHEMA_VERSION, '019_extension_host_updates');
assert.match(db, /schema_migrations/);
assert.match(db, /databaseReadiness/);
assert.match(db, /assertDatabaseSchema/);
@@ -1364,8 +1364,10 @@ test('operator page has a login gate and uses the durable task API', async () =>
assert.match(app, /window\.addEventListener\('focus',[\s\S]+refreshBackgroundState\(\)/);
assert.match(app, /async function autoDispatchReadyTasks\(\{ force = false \} = \{\}\)/);
assert.match(app, /if \(!force && retryAt > Date\.now\(\)\) continue/);
- assert.match(app, /const bridgeReadyForDispatch = !wasBridgeConnected/);
- assert.match(app, /autoDispatchReadyTasks\(\{ force: bridgeReadyForDispatch \}\)/);
+ assert.match(app, /extensionUpdateBlocksExecution = extensionUpdateIsBlocking\(extensionUpdate\)/);
+ assert.match(app, /function extensionUpdateIsBlocking\(update = \{\}\) \{[\s\S]+!\['current', 'disabled', 'no_release'\]\.includes/);
+ assert.match(app, /if \(!authUser \|\| autoHandoffInProgress \|\| extensionUpdateBlocksExecution\) return/);
+ assert.match(app, /autoDispatchReadyTasks\(\{ force: true \}\)/);
const autoDispatchSource = app.slice(
app.indexOf('async function autoDispatchReadyTasks'),
app.indexOf('function taskStateClass')
diff --git a/control-plane/test/diagnostics.test.ts b/control-plane/test/diagnostics.test.ts
index ff0b7cb..ace8f55 100644
--- a/control-plane/test/diagnostics.test.ts
+++ b/control-plane/test/diagnostics.test.ts
@@ -65,6 +65,10 @@ test('diagnostic request identifiers and paths are stable and query-safe', () =>
assert.match(generated, /^[a-f0-9-]{36}$/u);
assert.doesNotMatch(generated, /token/u);
assert.equal(diagnosticRequestPath('/api/tasks/TASK-1?token=secret#fragment'), '/api/tasks/TASK-1');
+ assert.equal(
+ diagnosticRequestPath('/api/extension-updates/package/secret-bearer-token'),
+ '/api/extension-updates/package/:token'
+ );
const startedAt = process.hrtime.bigint() - 2_000_000n;
assert.ok(diagnosticDurationMs(startedAt) >= 1);
});
diff --git a/control-plane/test/extension-updates.test.ts b/control-plane/test/extension-updates.test.ts
new file mode 100644
index 0000000..daa1868
--- /dev/null
+++ b/control-plane/test/extension-updates.test.ts
@@ -0,0 +1,170 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import test from 'node:test';
+import JSZip from 'jszip';
+import { loadConfig } from '../src/config.js';
+import {
+ ExtensionUpdateError,
+ buildExtensionUpdatePowerShell,
+ compareExtensionVersions,
+ createExtensionDownloadToken,
+ inspectExtensionPackage,
+ interpretCloudAssistantInvocation,
+ verifyExtensionDownloadToken
+} from '../src/extension-updates.js';
+
+async function extensionZip(version = '0.5.167'): Promise {
+ const zip = new JSZip();
+ zip.file('manifest.json', JSON.stringify({
+ manifest_version: 3,
+ name: '联泰下单助手',
+ version,
+ background: { service_worker: 'background.js' },
+ content_scripts: [{ matches: ['https://business.example.test/*'], js: ['business-bridge.js'] }]
+ }));
+ zip.file('background.js', 'chrome.runtime.onMessage.addListener(() => {});');
+ zip.file('business-bridge.js', 'window.postMessage({ ok: true });');
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
+}
+
+test('extension versions compare numerically rather than lexically', () => {
+ assert.equal(compareExtensionVersions('0.5.167', '0.5.166'), 1);
+ assert.equal(compareExtensionVersions('0.10.0', '0.9.99'), 1);
+ assert.equal(compareExtensionVersions('1.0.0', '1.0.0.0'), 0);
+ assert.equal(compareExtensionVersions('0.5.166', '0.5.167'), -1);
+ assert.throws(
+ () => compareExtensionVersions('latest', '0.5.167'),
+ (error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_version_invalid'
+ );
+});
+
+test('extension release inspection binds identity, version, required files, and SHA-256', async () => {
+ const content = await extensionZip();
+ const inspected = await inspectExtensionPackage(content);
+ assert.equal(inspected.version, '0.5.167');
+ assert.equal(inspected.manifest.name, '联泰下单助手');
+ assert.equal(inspected.entryCount, 3);
+ assert.equal(inspected.sha256, createHash('sha256').update(content).digest('hex'));
+
+ const invalid = new JSZip();
+ invalid.file('manifest.json', JSON.stringify({
+ manifest_version: 3,
+ name: '其他插件',
+ version: '0.5.167',
+ background: { service_worker: 'background.js' },
+ content_scripts: [{ matches: ['https://business.example.test/*'], js: ['business-bridge.js'] }]
+ }));
+ invalid.file('background.js', '');
+ invalid.file('business-bridge.js', '');
+ await assert.rejects(
+ inspectExtensionPackage(await invalid.generateAsync({ type: 'nodebuffer' })),
+ (error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_manifest_identity_mismatch'
+ );
+
+ const unsafePath = await extensionZip();
+ const unsafeZip = await JSZip.loadAsync(unsafePath);
+ unsafeZip.file('asset.js:alternate-stream', 'forbidden');
+ await assert.rejects(
+ inspectExtensionPackage(await unsafeZip.generateAsync({ type: 'nodebuffer' })),
+ (error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_package_path_invalid'
+ );
+});
+
+test('short-lived host-scoped package tokens reject tampering and expiry', () => {
+ const key = Buffer.alloc(32, 7);
+ const payload = {
+ releaseId: 'release-a',
+ organizationId: 'organization-a',
+ regionId: 'cn-hangzhou',
+ instanceId: 'i-12345678',
+ expiresAt: 2_000
+ };
+ const token = createExtensionDownloadToken(payload, key);
+ assert.deepEqual(verifyExtensionDownloadToken(token, key, 1_999), payload);
+ assert.throws(
+ () => verifyExtensionDownloadToken(`${token}x`, key, 1_999),
+ (error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_download_token_invalid'
+ );
+ assert.throws(
+ () => verifyExtensionDownloadToken(token, key, 2_001),
+ (error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_download_token_expired'
+ );
+});
+
+test('PowerShell updater carries encoded values, enforces ProgramData, hash, staging, and rollback', () => {
+ const script = buildExtensionUpdatePowerShell({
+ downloadUrl: 'https://business.example.test/api/extension-updates/package/token',
+ sha256: 'a'.repeat(64),
+ version: '0.5.167',
+ installPath: 'C:\\ProgramData\\LTJT\\chrome-extension\\ltjt-order-assistant',
+ releaseId: 'release-a'
+ });
+ assert.match(script, /Get-FileHash/);
+ assert.match(script, /PackageHashMismatch/);
+ assert.match(script, /InstallPathOutsideAllowedRoot/);
+ assert.match(script, /LTJT_EXTENSION_NEWER_PRESENT/);
+ assert.match(script, /\.previous/);
+ assert.match(script, /Move-Item -LiteralPath \$backupPath -Destination \$installPath/);
+ assert.ok(Buffer.byteLength(Buffer.from(script, 'utf8').toString('base64')) < 24 * 1024);
+ assert.doesNotMatch(script, /business\.example\.test/);
+});
+
+test('Cloud Assistant success requires exit zero and the updater completion marker', () => {
+ assert.deepEqual(
+ interpretCloudAssistantInvocation({
+ invocationStatus: 'Success',
+ exitCode: 0,
+ output: 'LTJT_EXTENSION_UPDATED 0.5.167\n'
+ }),
+ {
+ status: 'success',
+ exitCode: 0,
+ output: 'LTJT_EXTENSION_UPDATED 0.5.167\n'
+ }
+ );
+ assert.equal(
+ interpretCloudAssistantInvocation({ invocationStatus: 'Success', exitCode: 0, output: '' }).errorCode,
+ 'extension_update_marker_missing'
+ );
+ assert.equal(
+ interpretCloudAssistantInvocation({ invocationStatus: 'Running', output: '' }).status,
+ 'running'
+ );
+ assert.equal(
+ interpretCloudAssistantInvocation({
+ invocationStatus: 'Success',
+ exitCode: 0,
+ output: 'LTJT_EXTENSION_NEWER_PRESENT 0.5.168\r\n'
+ }).status,
+ 'success'
+ );
+ assert.equal(
+ interpretCloudAssistantInvocation({ invocationStatus: 'Aborted', errorCode: 'ClientNotRunning' }).status,
+ 'failed'
+ );
+});
+
+test('extension updater is off by default and enabled production config requires HTTPS plus OSS and ECS credentials', () => {
+ const disabled = loadConfig({ NODE_ENV: 'test' });
+ assert.equal(disabled.EXTENSION_AUTO_UPDATE_ENABLED, false);
+ assert.equal(disabled.EXTENSION_WINDOWS_INSTALL_PATH, 'C:\\ProgramData\\LTJT\\chrome-extension\\ltjt-order-assistant');
+ assert.throws(() => loadConfig({
+ NODE_ENV: 'production',
+ FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
+ APP_ORIGIN: 'https://business.example.test',
+ EXTENSION_AUTO_UPDATE_ENABLED: 'true'
+ }), /missing configuration/);
+ assert.throws(() => loadConfig({
+ NODE_ENV: 'production',
+ FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
+ APP_ORIGIN: 'http://business.example.test',
+ EXTENSION_AUTO_UPDATE_ENABLED: 'true',
+ OSS_ACCESS_KEY_ID: 'oss-id',
+ OSS_ACCESS_KEY_SECRET: 'oss-secret',
+ OSS_ENDPOINT: 'oss-cn-hangzhou.aliyuncs.com',
+ OSS_BUCKET_NAME: 'bucket',
+ OSS_REGION: 'cn-hangzhou',
+ ALIBABA_CLOUD_ACCESS_KEY_ID: 'ecs-id',
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET: 'ecs-secret'
+ }), /must use HTTPS/);
+});
diff --git a/dist/ltjt-order-assistant-0.5.167.zip b/dist/ltjt-order-assistant-0.5.167.zip
new file mode 100644
index 0000000..77b51cf
Binary files /dev/null and b/dist/ltjt-order-assistant-0.5.167.zip differ
diff --git a/dist/release-manifest.json b/dist/release-manifest.json
index a8fe92b..a67efa6 100644
--- a/dist/release-manifest.json
+++ b/dist/release-manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 1,
- "generated_on": "2026-09-02",
+ "generated_on": "2026-09-03",
"baselines": {
- "chrome_extension": "0.5.166",
+ "chrome_extension": "0.5.167",
"skills": "0.5.125",
"business_instruction_docx": "0.5.125",
"agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories"
@@ -10,10 +10,10 @@
"artifacts": [
{
"kind": "chrome_extension",
- "version": "0.5.166",
- "path": "dist/ltjt-order-assistant-0.5.166.zip",
+ "version": "0.5.167",
+ "path": "dist/ltjt-order-assistant-0.5.167.zip",
"source": "chrome-extension/ltjt-order-assistant",
- "sha256": "68aeda857f0bf596eed3c5012fb37c6292977935ff215ce1d133983a8dffe259"
+ "sha256": "6822ec660097a8889da8ea4fa22d4091467aa022067bff3bee2ac5406864b679"
},
{
"kind": "skill",
diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json
index e7ce215..68e11b4 100644
--- a/mappings/lifecycle.mapping.json
+++ b/mappings/lifecycle.mapping.json
@@ -1,7 +1,7 @@
{
"contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08",
"current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories",
- "current_extension_version": "0.5.166",
+ "current_extension_version": "0.5.167",
"historical_test_marker": "TEST-202609",
"scope": "/System/Business/",
"erp_session_keepalive": {
diff --git a/mappings/plan_add.mapping.json b/mappings/plan_add.mapping.json
index 93a392f..2281cb3 100644
--- a/mappings/plan_add.mapping.json
+++ b/mappings/plan_add.mapping.json
@@ -1,7 +1,7 @@
{
"$schema": "https://local.lwltapi/schemas/ltjt_form_mapping.schema.json",
"mapping_version": "2026-09-03.shared_plan_create.plan_add.v0.10",
- "status": "adaptive_list_entry_readiness_release_gated_0.5.166",
+ "status": "adaptive_list_entry_readiness_release_gated_0.5.167",
"source": {
"schema": "schemas/standard_system_operation.schema.json",
"action": "shared_plan_create"
diff --git a/package-lock.json b/package-lock.json
index 92df8be..71b16db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "liansyn-platform",
"version": "1.0.0",
"dependencies": {
+ "@alicloud/ecs20140526": "^7.11.1",
+ "@alicloud/openapi-core": "^1.0.8",
"@fastify/cookie": "^10.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/rate-limit": "^10.2.2",
@@ -36,6 +38,192 @@
"node": ">=22"
}
},
+ "node_modules/@alicloud/credentials": {
+ "version": "2.4.7",
+ "resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.7.tgz",
+ "integrity": "sha512-pVeLM4Mjdh2PSs+h36CDBOCsvXyZTBG3tgT7chzH8jqWgVGU4UIgYw2h76ZviBpqADFPs3QReYg2AX/gzmqwOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.8.0",
+ "httpx": "^2.3.3",
+ "ini": "^1.3.5",
+ "kitx": "^2.0.0"
+ }
+ },
+ "node_modules/@alicloud/darabonba-array": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz",
+ "integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.7.1"
+ }
+ },
+ "node_modules/@alicloud/darabonba-encode-util": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz",
+ "integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==",
+ "license": "ISC",
+ "dependencies": {
+ "moment": "^2.29.1"
+ }
+ },
+ "node_modules/@alicloud/darabonba-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz",
+ "integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.7.1"
+ }
+ },
+ "node_modules/@alicloud/darabonba-signature-util": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz",
+ "integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/darabonba-encode-util": "^0.0.1"
+ }
+ },
+ "node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz",
+ "integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.7.1",
+ "moment": "^2.29.1"
+ }
+ },
+ "node_modules/@alicloud/darabonba-string": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz",
+ "integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.5.1"
+ }
+ },
+ "node_modules/@alicloud/ecs20140526": {
+ "version": "7.11.1",
+ "resolved": "https://registry.npmjs.org/@alicloud/ecs20140526/-/ecs20140526-7.11.1.tgz",
+ "integrity": "sha512-d8iTPH4hBXmMPOZQon7oroZGsj4DxZHY2ta3kbSqumBMFYHnRI7SJNTGErQQC8tZgKNo9OOdHb7M64MyQf7erw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@alicloud/openapi-core": "^1.0.8",
+ "@darabonba/typescript": "^1.0.0"
+ }
+ },
+ "node_modules/@alicloud/endpoint-util": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
+ "integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.5.1",
+ "kitx": "^2.0.0"
+ }
+ },
+ "node_modules/@alicloud/gateway-pop": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz",
+ "integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/credentials": "^2",
+ "@alicloud/darabonba-array": "^0.1.0",
+ "@alicloud/darabonba-encode-util": "^0.0.2",
+ "@alicloud/darabonba-map": "^0.0.1",
+ "@alicloud/darabonba-signature-util": "^0.0.4",
+ "@alicloud/darabonba-string": "^1.0.2",
+ "@alicloud/endpoint-util": "^0.0.1",
+ "@alicloud/gateway-spi": "^0.0.8",
+ "@alicloud/openapi-util": "^0.3.2",
+ "@alicloud/tea-typescript": "^1.7.1",
+ "@alicloud/tea-util": "^1.4.8"
+ }
+ },
+ "node_modules/@alicloud/gateway-spi": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
+ "integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/credentials": "^2",
+ "@alicloud/tea-typescript": "^1.7.1"
+ }
+ },
+ "node_modules/@alicloud/openapi-core": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.8.tgz",
+ "integrity": "sha512-xs8LdgMDcEUqv13kZ4nl+Vd+Fc1mixTF0g1lm5QSrNWDaLUUD5vqpuMtvUZnKNnq9PITfUQ+8KunAvNQJpFo8g==",
+ "hasInstallScript": true,
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/credentials": "^2.4.2",
+ "@alicloud/gateway-pop": "0.0.6",
+ "@alicloud/gateway-spi": "^0.0.8",
+ "@darabonba/typescript": "^1.0.5"
+ }
+ },
+ "node_modules/@alicloud/openapi-util": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz",
+ "integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==",
+ "license": "ISC",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.7.1",
+ "@alicloud/tea-util": "^1.3.0",
+ "kitx": "^2.1.0",
+ "sm3": "^1.0.3"
+ }
+ },
+ "node_modules/@alicloud/tea-typescript": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
+ "integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@types/node": "^12.0.2",
+ "httpx": "^2.2.6"
+ }
+ },
+ "node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
+ "version": "12.20.55",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
+ "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
+ "license": "MIT"
+ },
+ "node_modules/@alicloud/tea-util": {
+ "version": "1.4.11",
+ "resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz",
+ "integrity": "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.5.1",
+ "@darabonba/typescript": "^1.0.0",
+ "kitx": "^2.0.0"
+ }
+ },
+ "node_modules/@darabonba/typescript": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.5.tgz",
+ "integrity": "sha512-pfxHFVM8I3h8K2o8skpDQLMmR5iAeQ2eNpP1HrKDEm/9ZPF8aKwDKPwwEszT1NnIo0Y3++E7x1YsVkVpGjA/xw==",
+ "license": "Apache License 2.0",
+ "dependencies": {
+ "@alicloud/tea-typescript": "^1.5.1",
+ "http-proxy-agent": "^5.0.0",
+ "https-proxy-agent": "^5.0.1",
+ "httpx": "^2.3.2",
+ "lodash": "^4.17.21",
+ "moment": "^2.30.1",
+ "moment-timezone": "^0.5.45",
+ "socks-proxy-agent": "^6.2.1",
+ "ws": "^8.18.0",
+ "xml2js": "^0.6.2"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -801,11 +989,19 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
+ "node_modules/@tootallnate/once": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
+ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -849,6 +1045,18 @@
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1285,6 +1493,23 @@
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1679,6 +1904,52 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+ "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/once": "2",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/httpx": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
+ "integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^20",
+ "debug": "^4.1.1"
+ }
+ },
+ "node_modules/httpx/node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -1722,6 +1993,21 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -1804,6 +2090,15 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/kitx": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz",
+ "integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^22.5.4"
+ }
+ },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -1898,6 +2193,12 @@
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
"license": "ISC"
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -2043,6 +2344,33 @@
"mkdirp": "bin/cmd.js"
}
},
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/moment-timezone": {
+ "version": "0.5.48",
+ "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
+ "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
+ "license": "MIT",
+ "dependencies": {
+ "moment": "^2.29.4"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
@@ -2526,6 +2854,15 @@
"node": ">=10"
}
},
+ "node_modules/sax": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/saxes": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
@@ -2584,6 +2921,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sm3": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz",
+ "integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
+ "license": "MIT"
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.10",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
+ "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz",
+ "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^6.0.2",
+ "debug": "^4.3.3",
+ "socks": "^2.6.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -2718,7 +3099,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unzipper": {
@@ -2800,6 +3180,28 @@
"node": ">=10.0.0"
}
},
+ "node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
diff --git a/package.json b/package.json
index 75de2d0..df6fd87 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"test": "node --run check && node --run test:control-plane && node --run test:legacy"
},
"dependencies": {
+ "@alicloud/ecs20140526": "^7.11.1",
+ "@alicloud/openapi-core": "^1.0.8",
"@fastify/cookie": "^10.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/rate-limit": "^10.2.2",
diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs
index ba60d20..aa72f42 100644
--- a/tools/lifecycle-contract.test.mjs
+++ b/tools/lifecycle-contract.test.mjs
@@ -1318,7 +1318,7 @@ test('passenger explicit server success is terminal without a post-save row requ
assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/);
assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/);
assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/);
- assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.166'/);
+ assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.167'/);
});
test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => {
@@ -1525,6 +1525,25 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett
assert.match(background, /arrangement_resource_candidate_data_missing/);
});
+test('extension update reload requires a fully idle durable execution state', async () => {
+ const [background, bridge, platform] = await Promise.all([
+ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'),
+ readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8'),
+ readFile(new URL('../LianSyn-platform/app.js', import.meta.url), 'utf8')
+ ]);
+ assert.match(background, /async function extensionUpdateSafetyStatus\(\)/);
+ assert.match(background, /\['running', 'write_started', 'submitted', 'uncertain'\]/);
+ assert.match(background, /saved_unverified\|execution_uncertain\|reconciliation_pending/);
+ assert.match(background, /message\?\.type === 'LTJT_EXTENSION_UPDATE_STATUS'/);
+ assert.match(background, /message\?\.type === 'LTJT_APPLY_EXTENSION_UPDATE'/);
+ assert.match(background, /if \(comparison >= 0\)/);
+ assert.match(background, /chrome\.runtime\.reload\(\)/);
+ assert.match(bridge, /message\.type === 'APPLY_EXTENSION_UPDATE'/);
+ assert.match(bridge, /type: 'LTJT_APPLY_EXTENSION_UPDATE'/);
+ assert.match(platform, /extension_update_safe: result\.extension_update\?\.safe === true/);
+ assert.match(platform, /extensionUpdateBlocksExecution/);
+});
+
test('ERP browser injection is host-permission gated and fail-closed with diagnostics', async () => {
const [manifestText, background, platform] = await Promise.all([
readFile(new URL('../chrome-extension/ltjt-order-assistant/manifest.json', import.meta.url), 'utf8'),
@@ -1948,7 +1967,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro
for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; '));
const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8'));
- assert.equal(mapping.current_extension_version, '0.5.166');
+ assert.equal(mapping.current_extension_version, '0.5.167');
assert.equal(
mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'],
'ertrenshu'
@@ -2331,10 +2350,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov
assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/);
assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/);
assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/);
- assert.equal(extensionManifest.version, '0.5.166');
- assert.match(inpage, /version: '0\.5\.166'/);
- assert.match(teamBatchInpage, /version: '0\.5\.166'/);
- assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.166'/);
+ assert.equal(extensionManifest.version, '0.5.167');
+ assert.match(inpage, /version: '0\.5\.167'/);
+ assert.match(teamBatchInpage, /version: '0\.5\.167'/);
+ assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.167'/);
assert.match(inpage, /function strictIsoDate\(value\)/);
assert.match(inpage, /const startDate = strictIsoDate\(controlCanonicalValue\(form, 'riqi0'\)\)/);
assert.match(inpage, /const endDate = strictIsoDate\(controlCanonicalValue\(form, 'riqis0'\)\)/);