merge: integrate extension auto-update
This commit is contained in:
@@ -31,6 +31,39 @@
|
||||
- `user_channels`、`tasks.channel_id` 和 `agentbus_deliveries` 共同保存入站归属、accepted 受理回执和最终 result 回执。回执以 `(channel_id, inbound_frame_id, delivery_kind)` 幂等,发送失败会重试,进程重启或 WebSocket 重连后仍会继续投递;因此不会因为超过原等待时长而丢掉最终回复。
|
||||
- ERP 插件领取按任务 `assigned_user_id` 使用账户级数据库锁和 FIFO confirmed 队列:同一平台/ERP 账户在任意时刻最多一个 ERP execution,该账户的其他任务留在服务端等待;不同账户的活跃或待执行任务互不占用队列位置、可独立领取执行。管理员的组织级查看权限与执行权限完全分离:实时执行事件、插件领取、执行回执以及强制删除后的浏览器清理命令都只发送或接受任务 `assigned_user_id` 对应的登录账号,管理员不会因为能查看员工任务而收到或处理该员工的插件任务。已开始写入但结果不确定的任务只阻塞同一账户的后续领取,直到人工回查收敛或任务被明确强制删除。
|
||||
|
||||
## 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 消息并使用独立幂等键。
|
||||
|
||||
77
control-plane/migrations/019_extension_host_updates.sql
Normal file
77
control-plane/migrations/019_extension_host_updates.sql
Normal file
@@ -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);
|
||||
@@ -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<string, unknown>): 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<PublicAccount | null> {
|
||||
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<PublicAccount[]> {
|
||||
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<PublicAccount> {
|
||||
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));
|
||||
|
||||
@@ -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 <drive>:\\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-<region> 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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1276
control-plane/src/extension-updates.ts
Normal file
1276
control-plane/src/extension-updates.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<string, string> = {
|
||||
'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'] : [];
|
||||
|
||||
@@ -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<string, unknown>): 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']),
|
||||
@@ -396,12 +415,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<FastifyRequest, bigint>();
|
||||
const app = Fastify({
|
||||
@@ -409,7 +430,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 });
|
||||
@@ -511,6 +536,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<string, string>();
|
||||
@@ -983,6 +1013,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 };
|
||||
@@ -996,7 +1028,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 };
|
||||
@@ -1361,13 +1395,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) => {
|
||||
@@ -1488,7 +1586,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',
|
||||
@@ -1535,13 +1633,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<ReturnType<typeof buildServer>>['app']): void {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
createAgentBusAcceptedDeliveryPayload,
|
||||
type AgentBusAcceptedDeliveryOptions
|
||||
} from './agentbus-delivery.js';
|
||||
import { compareExtensionVersions } from './extension-updates.js';
|
||||
|
||||
export interface TaskEvent {
|
||||
id: number;
|
||||
@@ -2720,6 +2721,84 @@ export class TaskService {
|
||||
}
|
||||
}
|
||||
|
||||
private async requireExtensionHostAvailable(
|
||||
client: import('pg').PoolClient,
|
||||
context: TaskContext,
|
||||
connectionId: string
|
||||
): Promise<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
@@ -6213,6 +6292,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
|
||||
@@ -7269,10 +7349,20 @@ export class TaskService {
|
||||
extensionVersion: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
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`,
|
||||
@@ -7358,10 +7448,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
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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/);
|
||||
@@ -1365,8 +1365,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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
170
control-plane/test/extension-updates.test.ts
Normal file
170
control-plane/test/extension-updates.test.ts
Normal file
@@ -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<Buffer> {
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user