@ai-workbench/network 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.d.ts CHANGED
@@ -43,17 +43,6 @@ export interface EnrollmentTarget {
43
43
  port: number;
44
44
  knownHostsFile: string;
45
45
  }
46
- export interface ProxyCredentials {
47
- apiVersion: 1;
48
- id: string;
49
- url: string;
50
- username: string;
51
- password: string;
52
- }
53
- export interface LoginProxyLease {
54
- credentials: ProxyCredentials;
55
- release(): Promise<void>;
56
- }
57
46
  export interface EnrollmentReceipt {
58
47
  apiVersion: 1;
59
48
  session: 'key' | 'identity';
@@ -118,8 +107,6 @@ export interface NetworkChannels {
118
107
  proxyCommand: string;
119
108
  }>>;
120
109
  onState(listener: (event: NetworkEvent) => void): () => void;
121
- openLoginProxy(signal: AbortSignal): Promise<Result<LoginProxyLease>>;
122
- download(source: string, signal: AbortSignal): Promise<Result<Response>>;
123
110
  dispose(): Promise<void>;
124
111
  }
125
112
  export interface PluginSettings {
@@ -128,7 +115,6 @@ export interface PluginSettings {
128
115
  startupObservationSeconds?: number;
129
116
  shutdownObservationSeconds?: number;
130
117
  controlResponseMaxBytes?: number;
131
- downloadMaxRedirects?: number;
132
118
  helperRecoveryMaxAttempts?: number;
133
119
  helperRecoveryDelaySeconds?: number;
134
120
  helperRecoveryStableSeconds?: number;
@@ -137,7 +123,6 @@ export interface PluginSettings {
137
123
  headerTimeoutSeconds?: number;
138
124
  enrollmentObservationSeconds?: number;
139
125
  httpMaxHeaderBytes?: number;
140
- httpMaxResponseHeaderBytes?: number;
141
126
  }
142
127
  export interface HelperConfig {
143
128
  apiVersion: 1;
@@ -151,13 +136,11 @@ export interface HelperConfig {
151
136
  employee: string;
152
137
  knownHostsFile?: string;
153
138
  };
154
- proxyListen: string;
155
139
  statusRefreshIntervalSeconds: number;
156
140
  dialTimeoutSeconds: number;
157
141
  headerTimeoutSeconds: number;
158
142
  enrollmentObservationSeconds: number;
159
143
  httpMaxHeaderBytes: number;
160
- httpMaxResponseHeaderBytes: number;
161
144
  logUpload: false;
162
145
  }
163
146
  export declare const ok: <T>(value: T) => Result<T>;
package/lib/control.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  export declare function control(socket: string, method: string, path: string, signal: AbortSignal, seconds: number, maxHeaderBytes: number, body?: unknown): Promise<IncomingMessage>;
3
3
  export declare function jsonResponse(response: IncomingMessage, maxBytes: number): Promise<unknown>;
4
- export declare function firstFrame(response: IncomingMessage, maxBytes: number): Promise<unknown>;
package/lib/control.js CHANGED
@@ -32,35 +32,3 @@ export async function jsonResponse(response, maxBytes) {
32
32
  }
33
33
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
34
34
  }
35
- export async function firstFrame(response, maxBytes) {
36
- return new Promise((resolve, reject) => {
37
- let data = Buffer.alloc(0);
38
- const cleanup = () => { response.off('data', read); response.off('error', error); response.off('end', end); };
39
- const error = () => { cleanup(); reject(new Error('CONTROL_STREAM_INVALID')); };
40
- const end = () => error();
41
- const read = (chunk) => {
42
- data = Buffer.concat([data, chunk]);
43
- if (data.length > maxBytes) {
44
- cleanup();
45
- response.destroy();
46
- reject(new Error('CONTROL_RESPONSE_LIMIT'));
47
- return;
48
- }
49
- const nl = data.indexOf(10);
50
- if (nl < 0)
51
- return;
52
- cleanup();
53
- response.pause();
54
- try {
55
- resolve(JSON.parse(data.subarray(0, nl).toString('utf8')));
56
- }
57
- catch {
58
- response.destroy();
59
- reject(new Error('CONTROL_FRAME_INVALID'));
60
- }
61
- };
62
- response.on('data', read);
63
- response.once('error', error);
64
- response.once('end', end);
65
- });
66
- }
@@ -50,7 +50,7 @@ export function bindEmployeeSourceLifecycle(ctx, controller) {
50
50
  }
51
51
  export function createEmployeeEndpointController(d) {
52
52
  let source, disposed = false, revision = 0, held;
53
- let unsubscribers = [], lifetime = new AbortController(), closing = Promise.resolve(), closeFailed = false;
53
+ let unsubscribers = [], lifetime = new AbortController(), closing = Promise.resolve();
54
54
  let pending;
55
55
  const detached = () => { for (const off of unsubscribers)
56
56
  off(); unsubscribers = []; };
@@ -62,7 +62,7 @@ export function createEmployeeEndpointController(d) {
62
62
  const old = held;
63
63
  held = undefined;
64
64
  if (old)
65
- closing = closing.then(() => old.closeEmployeeEndpoint()).catch(() => { closeFailed = true; console.error('EMPLOYEE_ENDPOINT_CLOSE_FAILED'); });
65
+ closing = closing.then(() => old.closeEmployeeEndpoint()).catch(() => { console.error('EMPLOYEE_ENDPOINT_CLOSE_FAILED'); });
66
66
  }
67
67
  function localRemains() {
68
68
  const s = source?.getWorkspaceLocationState?.();
@@ -188,9 +188,9 @@ export function createEmployeeEndpointController(d) {
188
188
  if (disposed || owner.aborted || revision !== token || d.getConnect() !== s)
189
189
  return fail('EMPLOYEE_CONTEXT_CHANGED', '工作区连接已变化。', '重新核验后派发');
190
190
  await closing;
191
- if (closeFailed)
192
- return fail('EMPLOYEE_ENDPOINT_UNAVAILABLE', '旧员工端点关闭未能确认。', '检查组件私有日志,正常停止旧实例后再继续;不要重放旧操作');
193
- if (disposed || owner.aborted || revision !== token || d.getRuntime() !== runtime)
191
+ if (signal.aborted)
192
+ return cancelled();
193
+ if (disposed || owner.aborted || revision !== token || d.getConnect() !== s || d.getRuntime() !== runtime)
194
194
  return fail('EMPLOYEE_CONTEXT_CHANGED', '员工端点上下文已变化。', '重新核验后派发');
195
195
  // One shared preparation per actual device context, never per workspace. Each caller revalidates its own L CAS.
196
196
  let work = pending;
package/lib/index.js CHANGED
@@ -10,18 +10,16 @@ export const Config = z.object({
10
10
  helperLogFile: z.string().description('远程主机连接诊断日志文件(无默认值)'),
11
11
  startupObservationSeconds: positive().description('启动观察 startupObservationSeconds(秒):默认未设,沿开通值(现值35);等待组件ready仅为可观测性;增大等更久,减小可能提前报未知;无关闭值,超时给等待事项、已等时长和排查步骤'),
12
12
  shutdownObservationSeconds: positive().description('退出观察 shutdownObservationSeconds(秒):默认未设,沿开通值(现值10);等待自有组件正常退出,仅为可观测性;增大清理等更久,减小可能强制结束并报告;无关闭值,不终止未知进程'),
13
- controlResponseMaxBytes: bytes().description('控制响应上限 controlResponseMaxBytes(字节):默认未设,沿开通值(现值65536);控制JSON、ready及代理首帧,也约束员工端点私有控制请求/响应和主机桥首帧;增大增加内存,减小可能拒绝合法帧;无关闭或无限值,不限制下载或SFTP正文'),
14
- downloadMaxRedirects: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).description('下载重定向上限 downloadMaxRedirects:默认未设,沿开通值(现值3);增大允许更多HTTPS跳转,减小可能拒绝下载,0关闭跳转;不自动换源'),
15
- helperRecoveryMaxAttempts: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).description('组件自动恢复次数 helperRecoveryMaxAttempts:默认3;仅复用已有身份恢复意外退出的自有helper,不重放SSH或模型请求。增大允许更多连续恢复,减小更早停止;0关闭自动恢复。次数耗尽停止并显示退出原因,需要管理员排查后正常重载插件;不重交密钥'),
13
+ controlResponseMaxBytes: bytes().description('控制响应上限 controlResponseMaxBytes(字节):默认未设,沿开通值(现值65536);控制JSON、ready帧,也约束员工端点私有控制请求/响应和主机桥首帧;增大增加内存,减小可能拒绝合法帧;无关闭或无限值,不限制SFTP正文'),
14
+ helperRecoveryMaxAttempts: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).description('组件自动恢复次数 helperRecoveryMaxAttempts:默认3;仅复用已有身份恢复自有网络组件退出或控制通道中断,不重放SSH或模型请求。增大允许更多连续恢复,减小更早停止;0关闭自动恢复。次数耗尽停止并显示原因;网络恢复后可关闭再启用网络组件,正常重载后发送下一轮;不重交密钥'),
16
15
  helperRecoveryDelaySeconds: positive().description('组件恢复间隔 helperRecoveryDelaySeconds(秒):默认1;增大减少频繁拉起但恢复更慢,减小恢复更快且增加连续启动开销;无关闭值,关闭自动恢复请将helperRecoveryMaxAttempts设为0'),
17
16
  helperRecoveryStableSeconds: positive().description('组件恢复计数清零 helperRecoveryStableSeconds(秒):默认60;连续在线达到此时长才清零。增大更严格识别反复崩溃,减小更快清零但可能把短暂在线当成稳定;无关闭值,不以刚收到ready立即清零'),
18
17
  statusRefreshIntervalSeconds: positive().description('状态刷新间隔 statusRefreshIntervalSeconds(秒):默认沿已开通配置(现值2);影响组件状态查询及插件轮询;增大提示更迟,减小增加查询开销;无关闭值,不代表失败重试已修复'),
19
- dialTimeoutSeconds: positive().description('拨号观察 dialTimeoutSeconds(秒):默认沿已开通配置(现值20);远端TCP/SSH、控制响应头、下载CONNECT/响应头;Node请求采用阶段空闲观察,仅为可观测性,增大等更久,减小可能提前中止;无关闭值,超时说明等待事项、已等时长、下一步检查,不是修复'),
20
- headerTimeoutSeconds: z.number().min(1e-9).max(9223372036.854774).description('请求头观察 headerTimeoutSeconds(秒):默认沿已开通配置(现值10);本机控制与登录代理请求头;仅为可观测性,增大保留连接更久,减小可能拒绝慢请求头;无关闭值,超时在私有日志说明等待事项、已等时长、下一步检查'),
21
- enrollmentObservationSeconds: z.number().min(1e-9).max(9223372036.854774).description('首次入网观察 enrollmentObservationSeconds(秒):默认沿已开通配置(现值30);仅观察首次开通结果,不用于删除已有身份;增大等更久,减小可能更早报未知;无关闭值,超时说明等待事项、已等时长、下一步检查,不重交开通材料'),
22
- httpMaxHeaderBytes: bytes().description('本机HTTP头上限 httpMaxHeaderBytes(字节):默认沿已开通配置(现值1048576);控制/登录代理请求头及控制响应头;增大增加解析内存,减小可能拒绝合法头;无关闭或无限值,不是正文上限'),
23
- httpMaxResponseHeaderBytes: bytes().description('下载HTTP响应头上限 httpMaxResponseHeaderBytes(字节):默认沿已开通配置(现值1048576);主机代理及下载响应头/CONNECT头;增大增加解析内存,减小可能拒绝合法头;无关闭或无限值,不是正文上限'),
24
- }).description('运行策略优先级:本插件显式值 > 开通方传入值 > 原私有配置;既有策略未设置沿用原值。新增组件恢复策略未设时分别为3次/1秒/连续在线60秒,均在本页可改;不写回私有配置。活动连接改策略须正常重载插件,不重交开通材料。固定正确性约束不可关闭:macOS控制socket路径≤103字节、端口1–65535、计时器/整数可表示范围、私有700/600权限、当前用户与单身份隔离、SHA256及加密随机凭据强度。');
18
+ dialTimeoutSeconds: positive().description('拨号观察 dialTimeoutSeconds(秒):默认沿已开通配置(现值20);远端TCP/SSH、控制响应头;Node请求采用阶段空闲观察,仅为可观测性,增大等更久,减小可能提前中止;无关闭值,超时说明等待事项、已等时长、下一步检查,不是修复'),
19
+ headerTimeoutSeconds: z.number().min(1e-9).max(9223372036.854774).description('请求头观察 headerTimeoutSeconds(秒):默认沿已开通配置(现值10);本机控制请求头;仅为可观测性,增大保留连接更久,减小可能拒绝慢请求头;无关闭值,超时在私有日志说明等待事项、已等时长、下一步检查'),
20
+ enrollmentObservationSeconds: z.number().min(1e-9).max(9223372036.854774).description('首次入网观察 enrollmentObservationSeconds(秒):默认沿已开通配置(现值30);仅观察首次开通结果,不用于删除已有身份;增大等更久,减小可能更早报未知;无关闭值,超时说明等待事项、已等时长、下一步检查;未知结果先查持久身份,无身份时仅用原材料补救一次'),
21
+ httpMaxHeaderBytes: bytes().description('本机HTTP头上限 httpMaxHeaderBytes(字节):默认沿已开通配置(现值1048576);控制请求头及控制响应头;增大增加解析内存,减小可能拒绝合法头;无关闭或无限值,不是正文上限'),
22
+ }).description('运行策略优先级:本插件显式值 > 开通方传入值 > 原私有配置;既有策略未设置沿用原值。新增组件恢复策略未设时分别为3次/1秒/连续在线60秒,均在本页可改;不写回私有配置。活动连接改策略须正常重载插件,不重交开通材料。固定正确性约束不可关闭:macOS控制socket路径≤103字节、端口1–65535、计时器/整数可表示范围、私有700/600权限、当前用户与单身份隔离、SHA256。');
25
23
  export function register(ctx, api) {
26
24
  ctx.provide('aiWorkbenchNetwork', api);
27
25
  ctx.effect(() => { void api.start(); return () => api.dispose(); });
@@ -31,7 +29,7 @@ export function apply(ctx, config = {}) {
31
29
  if (!executable.ok) {
32
30
  const error = executable;
33
31
  const state = { phase: 'blocked', ...!error.ok ? { error: error.error } : {} };
34
- register(ctx, { async prepareEmployeeEndpoint() { return error; }, async activate() { return error; }, getActivationState: () => ({ instanceId: 'unpackaged', revision: 0, phase: 'blocked', receipt: null, error: !error.ok ? error.error : null }), onActivationState: () => () => { }, async start() { ctx.emit('ai-workbench/network-state', state); }, getState: () => structuredClone(state), async connect() { return error; }, onState: () => () => { }, async openLoginProxy() { return error; }, async download() { return error; }, async dispose() { } });
32
+ register(ctx, { async prepareEmployeeEndpoint() { return error; }, async activate() { return error; }, getActivationState: () => ({ instanceId: 'unpackaged', revision: 0, phase: 'blocked', receipt: null, error: !error.ok ? error.error : null }), onActivationState: () => () => { }, async start() { ctx.emit('ai-workbench/network-state', state); }, getState: () => structuredClone(state), async connect() { return error; }, onState: () => () => { }, async dispose() { } });
35
33
  return;
36
34
  }
37
35
  const api = createActivationService(config, executable.value, state => ctx.emit('ai-workbench/network-state', state), undefined, () => ctx.get('aiWorkbenchConnect', false));
package/lib/network.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { control } from './control.ts';
2
- import type { PluginSettings, NetworkChannels, NetworkState } from './api.ts';
2
+ import type { HelperConfig, PluginSettings, NetworkChannels, NetworkState } from './api.ts';
3
3
  import type { EmployeeRuntime } from './employee-endpoint.ts';
4
4
  export interface Executable {
5
5
  path: string;
@@ -7,7 +7,9 @@ export interface Executable {
7
7
  platform: string;
8
8
  arch: string;
9
9
  }
10
- export declare function createNetwork(settings: PluginSettings, executable: Executable, emit?: (state: NetworkState) => void, controlIO?: typeof control, resumeIdentityOnly?: boolean, expectedConfigSHA256?: string): NetworkChannels & EmployeeRuntime & {
10
+ export declare function createNetwork(settings: PluginSettings, executable: Executable, emit?: (state: NetworkState) => void, controlIO?: typeof control, resumeIdentityOnly?: boolean, expectedConfigSHA256?: string, expectedConfigSnapshot?: HelperConfig): NetworkChannels & EmployeeRuntime & {
11
11
  start(): Promise<void>;
12
12
  getRecoveryConfigSHA256?(): string | undefined;
13
+ getRecoveryConfigSnapshot?(): HelperConfig | undefined;
14
+ isRunning?(): boolean;
13
15
  };
package/lib/network.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { writeSync } from 'node:fs';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
  import { randomUUID, createHash } from 'node:crypto';
4
5
  import { join, isAbsolute, normalize, dirname, basename, relative, sep } from 'node:path';
5
6
  import { realpath, lstat } from 'node:fs/promises';
6
7
  import { privateFile, verifyExecutable } from "./private.js";
7
- import { control, jsonResponse, firstFrame } from "./control.js";
8
+ import { control, jsonResponse } from "./control.js";
8
9
  import { fail, ok } from "./api.js";
9
- import { downloadThroughProxy } from "./download.js";
10
10
  import { effectiveConfig, helperPolicyArgs, validHelperPolicy } from "./settings.js";
11
11
  import { ObservationExpired, observationNeed } from "./observation.js";
12
12
  import { readEmployeeProcess, employeeReady } from "./employee-endpoint.js";
@@ -40,16 +40,36 @@ function statusFrame(v) {
40
40
  const receipt = receiptFrame(v.receipt);
41
41
  return { ...receipt ? { receipt } : {}, apiVersion: 1, enrollment: v.enrollment, node: { name: v.node.name, ips: v.node.ips }, host: { name: v.host.name, online: v.host.online, lastSeenAt: v.host.lastSeenAt, observedAt: v.host.observedAt }, error };
42
42
  }
43
- export function createNetwork(settings, executable, emit = () => { }, controlIO = control, resumeIdentityOnly = false, expectedConfigSHA256) {
43
+ // Only the enrollment key reference may disappear. Go also omits these empty
44
+ // optional fields when rewriting the same Config; their values cannot change.
45
+ function clearedEnrollmentConfig(before, after) {
46
+ if (!before || typeof before.authKeyFile !== 'string' || !before.authKeyFile || Object.hasOwn(after, 'authKeyFile'))
47
+ return false;
48
+ const expected = structuredClone(before);
49
+ delete expected.authKeyFile;
50
+ if (isDeepStrictEqual(expected, after))
51
+ return true;
52
+ if (expected.httpMaxResponseHeaderBytes === 0)
53
+ delete expected.httpMaxResponseHeaderBytes;
54
+ if (expected.proxyListen === '')
55
+ delete expected.proxyListen;
56
+ if (expected.host?.knownHostsFile === '')
57
+ delete expected.host.knownHostsFile;
58
+ return isDeepStrictEqual(expected, after);
59
+ }
60
+ export function createNetwork(settings, executable, emit = () => { }, controlIO = control, resumeIdentityOnly = false, expectedConfigSHA256, expectedConfigSnapshot) {
44
61
  let state = { phase: 'starting' }, cfg, child;
45
62
  let starting, disposing, disposed = false, online = false;
46
63
  let poll;
47
64
  let processFailed = false, startupReceipt, terminalNeed, stoppingChild, shutdownNeed;
48
- let logClosed, lifecycleNeed;
49
- let configSHA256;
65
+ let journalWarned = false;
66
+ const journalFailed = () => { if (!journalWarned) {
67
+ journalWarned = true;
68
+ console.error('网络连接诊断记录未完整写入。');
69
+ } };
70
+ let configSHA256, configSnapshot;
50
71
  const childGone = new AbortController();
51
72
  const lifetime = new AbortController(), listeners = new Set(), updates = new Set();
52
- const leases = new Set();
53
73
  let employeeParent, employeeUmask;
54
74
  const snapshot = () => structuredClone(state);
55
75
  const publish = (next) => {
@@ -75,10 +95,6 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
75
95
  const terminal = (need) => {
76
96
  processFailed = true;
77
97
  terminalNeed ??= need;
78
- if (lifecycleNeed) {
79
- lifecycleNeed.cause ??= [terminalNeed.code, terminalNeed.message, terminalNeed.cause].filter(Boolean).join(';');
80
- terminalNeed = lifecycleNeed;
81
- }
82
98
  childGone.abort();
83
99
  if (poll)
84
100
  clearTimeout(poll);
@@ -129,12 +145,10 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
129
145
  const start = () => starting ??= (async () => {
130
146
  let startupNeed, handshakeComplete = false;
131
147
  try {
132
- if (!settings.configFile || !settings.helperLogFile || !seconds(settings.startupObservationSeconds) || !seconds(settings.shutdownObservationSeconds) || !integer(settings.controlResponseMaxBytes) || !Number.isSafeInteger(settings.downloadMaxRedirects) || settings.downloadMaxRedirects < 0) {
133
- block('SETTINGS_REQUIRED', '我需要管理员提供网络安装配置和具名设置', '安装期交付N私有config、日志路径、观察期限、响应上限和重定向次数');
148
+ if (!settings.configFile || !seconds(settings.startupObservationSeconds) || !seconds(settings.shutdownObservationSeconds) || !integer(settings.controlResponseMaxBytes)) {
149
+ block('SETTINGS_REQUIRED', '我需要管理员提供网络安装配置和具名设置', '安装期交付N私有config、日志路径、观察期限及响应上限');
134
150
  return;
135
151
  }
136
- if (!isAbsolute(settings.helperLogFile) || settings.helperLogFile === settings.configFile)
137
- throw new Error('CONFIG_INVALID');
138
152
  const file = await privateFile(settings.configFile);
139
153
  let configStat;
140
154
  try {
@@ -142,22 +156,21 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
142
156
  const bytes = await file.readFile();
143
157
  configSHA256 = createHash('sha256').update(bytes).digest('hex');
144
158
  cfg = JSON.parse(bytes.toString('utf8'));
159
+ configSnapshot = structuredClone(cfg);
145
160
  }
146
161
  finally {
147
162
  await file.close();
148
163
  }
149
- if (resumeIdentityOnly && Object.hasOwn(cfg, 'authKeyFile')) {
150
- startupNeed = needs('HELPER_RECOVERY_IDENTITY_REQUIRED', '无法安全恢复既有身份,连接配置仍含首次开通材料。', '管理员核对原身份与消费记录;不会读取或重新提交旧密钥');
151
- throw new Error(startupNeed.code);
152
- }
153
- if (expectedConfigSHA256 !== undefined && configSHA256 !== expectedConfigSHA256) {
164
+ if (expectedConfigSHA256 !== undefined && configSHA256 !== expectedConfigSHA256 && !clearedEnrollmentConfig(expectedConfigSnapshot, cfg)) {
154
165
  startupNeed = needs('HELPER_RECOVERY_CONFIG_CHANGED', '网络配置在组件运行后发生变化,已停止自动恢复。', '管理员核对原连接配置与身份;不启动其他身份、不重交密钥');
155
166
  throw new Error(startupNeed.code);
156
167
  }
168
+ if (expectedConfigSHA256 !== undefined)
169
+ expectedConfigSHA256 = configSHA256;
157
170
  if (!validHelperPolicy(settings))
158
171
  throw new Error('CONFIG_INVALID');
159
172
  cfg = effectiveConfig(cfg, settings);
160
- if (normalize(settings.helperLogFile) !== settings.helperLogFile || normalize(settings.configFile) !== settings.configFile)
173
+ if (normalize(settings.configFile) !== settings.configFile)
161
174
  throw new Error('CONFIG_INVALID');
162
175
  const canonical = async (p) => { try {
163
176
  return await realpath(p);
@@ -170,23 +183,27 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
170
183
  throw error;
171
184
  return join(await canonical(parent), basename(p));
172
185
  } };
173
- const logPath = await canonical(settings.helperLogFile);
174
- if (logPath === await canonical(settings.configFile) || (cfg?.authKeyFile && logPath === await canonical(cfg.authKeyFile)) || (cfg?.host?.knownHostsFile && logPath === await canonical(cfg.host.knownHostsFile)))
175
- throw new Error('CONFIG_INVALID');
176
- for (const dir of [cfg?.stateDir, cfg?.runtimeDir]) {
177
- if (typeof dir !== 'string' || !isAbsolute(dir))
178
- throw new Error('CONFIG_INVALID');
179
- const rel = relative(await canonical(dir), logPath);
180
- if (rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)))
181
- throw new Error('CONFIG_INVALID');
182
- }
183
- if (cfg?.apiVersion !== 1 || !isAbsolute(cfg.runtimeDir) || !cfg.host || !seconds(cfg.statusRefreshIntervalSeconds) || !seconds(cfg.dialTimeoutSeconds) || !integer(cfg.httpMaxHeaderBytes) || !integer(cfg.httpMaxResponseHeaderBytes))
186
+ if (cfg?.apiVersion !== 1 || !isAbsolute(cfg.runtimeDir) || !cfg.host || !seconds(cfg.statusRefreshIntervalSeconds) || !seconds(cfg.dialTimeoutSeconds) || !integer(cfg.httpMaxHeaderBytes))
184
187
  throw new Error('CONFIG_INVALID');
185
188
  if (executable.platform !== process.platform || executable.arch !== process.arch)
186
189
  throw new Error('HELPER_PLATFORM_UNSUPPORTED');
187
190
  await verifyExecutable(executable.path, executable.sha256);
188
- const log = await privateFile(settings.helperLogFile, true);
191
+ let log;
189
192
  try {
193
+ const logFile = settings.helperLogFile;
194
+ if (typeof logFile !== 'string' || !isAbsolute(logFile) || normalize(logFile) !== logFile)
195
+ throw new Error('CONFIG_INVALID');
196
+ const logPath = await canonical(logFile);
197
+ if (logPath === await canonical(settings.configFile) || (cfg?.authKeyFile && logPath === await canonical(cfg.authKeyFile)) || (cfg?.host?.knownHostsFile && logPath === await canonical(cfg.host.knownHostsFile)))
198
+ throw new Error('CONFIG_INVALID');
199
+ for (const dir of [cfg?.stateDir, cfg?.runtimeDir]) {
200
+ if (typeof dir !== 'string' || !isAbsolute(dir))
201
+ throw new Error('CONFIG_INVALID');
202
+ const rel = relative(await canonical(dir), logPath);
203
+ if (rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)))
204
+ throw new Error('CONFIG_INVALID');
205
+ }
206
+ log = await privateFile(logFile, true, journalFailed);
190
207
  const st = await log.stat();
191
208
  if (st.nlink !== 1)
192
209
  throw new Error('CONFIG_INVALID');
@@ -204,12 +221,15 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
204
221
  }
205
222
  }
206
223
  }
207
- catch (error) {
208
- await log.close();
209
- throw error;
224
+ catch {
225
+ if (log)
226
+ void log.close().catch(journalFailed);
227
+ log = undefined;
228
+ journalFailed();
210
229
  }
211
230
  if (disposed) {
212
- await log.close();
231
+ if (log)
232
+ void log.close().catch(journalFailed);
213
233
  return;
214
234
  }
215
235
  const parent = readEmployeeProcess();
@@ -225,19 +245,20 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
225
245
  environment.AW_NET_PARENT_UMASK = String(employeeUmask);
226
246
  }
227
247
  try {
228
- child = spawn(executable.path, ['serve', '--config', settings.configFile, ...resumeIdentityOnly ? ['--resumeIdentityOnly'] : [], ...expectedConfigSHA256 ? ['--resumeConfigSHA256', expectedConfigSHA256] : [], ...helperPolicyArgs(settings), '--controlResponseMaxBytes', String(settings.controlResponseMaxBytes)], { stdio: ['ignore', 'pipe', log.fd], env: environment });
248
+ child = spawn(executable.path, ['serve', '--config', settings.configFile, ...resumeIdentityOnly ? ['--resumeIdentityOnly'] : [], ...expectedConfigSHA256 ? ['--resumeConfigSHA256', expectedConfigSHA256] : [], ...helperPolicyArgs(settings), '--controlResponseMaxBytes', String(settings.controlResponseMaxBytes)], { stdio: ['ignore', 'pipe', log?.fd ?? 'ignore'], env: environment });
229
249
  }
230
250
  catch (error) {
231
- await log.close();
251
+ if (log)
252
+ void log.close().catch(journalFailed);
232
253
  throw error;
233
254
  }
234
255
  // Install process handlers before yielding: spawn errors can arrive on nextTick.
235
256
  const processChild = child;
236
257
  const runtimeId = randomUUID(), processStarted = performance.now();
237
- let journalClosing = false, journalDone;
238
- logClosed = new Promise(resolve => journalDone = resolve);
239
- const journalFailed = () => { lifecycleNeed ??= needs('HELPER_LIFECYCLE_LOG_FAILED', '网络组件进程诊断无法完整写入,已停止自动恢复。', '管理员检查诊断日志权限、磁盘及退出记录;不重交密钥'); };
258
+ let journalClosing = false;
240
259
  const record = (operation, exitCode = null, signal = null) => {
260
+ if (!log)
261
+ return;
241
262
  try {
242
263
  const line = Buffer.from(JSON.stringify({ event: 'HELPER_PROCESS', operation, runtimeId, at: new Date().toISOString(), pid: processChild.pid ?? null, parentPid: process.pid, expected: disposed || !!stoppingChild, exitCode, signal, elapsedMs: performance.now() - processStarted }) + '\n');
243
264
  if (writeSync(log.fd, line) !== line.length)
@@ -248,10 +269,10 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
248
269
  }
249
270
  };
250
271
  const closeJournal = () => { if (journalClosing)
251
- return; journalClosing = true; void log.close().catch(() => { journalFailed(); terminal(lifecycleNeed); }).finally(journalDone); };
252
- processChild.once('spawn', () => { record('spawn'); if (lifecycleNeed)
253
- terminal(lifecycleNeed); });
272
+ return; journalClosing = true; if (log)
273
+ void log.close().catch(journalFailed); };
254
274
  processChild.on('error', () => { record('spawn-error'); terminal(needs('HELPER_PROCESS_FAILED', '网络组件进程启动或运行失败,根因未查明', '管理员检查helper完整性和进程诊断;不重交密钥')); });
275
+ processChild.once('spawn', () => record('spawn'));
255
276
  const exited = (exitCode, signal) => {
256
277
  record('exit', exitCode, signal);
257
278
  processFailed = true;
@@ -333,7 +354,7 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
333
354
  }
334
355
  })();
335
356
  const api = {
336
- start, getState: snapshot, getRecoveryConfigSHA256: () => configSHA256,
357
+ start, getState: snapshot, getRecoveryConfigSHA256: () => configSHA256, getRecoveryConfigSnapshot: () => configSnapshot ? structuredClone(configSnapshot) : undefined, isRunning: alive,
337
358
  onState(listener) { listeners.add(listener); return () => listeners.delete(listener); },
338
359
  async connect(target, signal) {
339
360
  await start();
@@ -350,60 +371,6 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
350
371
  return { ok: false, error: state.error ?? needs('NETWORK_NOT_READY', '远程主机:未连接', '等待网络在线') };
351
372
  return ok({ proxyCommand: [executable.path, 'ssh-proxy', '--runtime-dir', cfg.runtimeDir].map(quote).join(' ') + ' %h %p' });
352
373
  },
353
- async openLoginProxy(signal) {
354
- await start();
355
- if (disposed || signal.aborted)
356
- return fail('DISPOSED', '登录代理已取消', '重新发起登录');
357
- if (state.phase !== 'online' || !alive())
358
- return { ok: false, error: state.error ?? needs('NETWORK_NOT_READY', '远程主机:未连接', '等待网络在线') };
359
- const owner = new AbortController();
360
- const cancel = () => owner.abort();
361
- signal.addEventListener('abort', cancel, { once: true });
362
- lifetime.signal.addEventListener('abort', cancel, { once: true });
363
- childGone.signal.addEventListener('abort', cancel, { once: true });
364
- let lease, diagnostic;
365
- try {
366
- const res = await request('POST', '/v1/login-proxy', owner.signal);
367
- if (res.statusCode !== 201) {
368
- const value = await jsonResponse(res, settings.controlResponseMaxBytes);
369
- if (value?.apiVersion === 1 && value.error && ['code', 'message', 'needs', 'actor'].every(k => typeof value.error[k] === 'string'))
370
- diagnostic = { code: value.error.code, message: value.error.message, needs: value.error.needs, actor: value.error.actor, cause: typeof value.error.cause === 'string' ? value.error.cause : null };
371
- throw new Error('PROXY_UNAVAILABLE');
372
- }
373
- if (res.headers['content-type'] !== 'application/x-ndjson' || res.headers['cache-control'] !== 'no-store') {
374
- res.destroy();
375
- throw new Error('CONTROL_PROTOCOL_INVALID');
376
- }
377
- const frame = await firstFrame(res, settings.controlResponseMaxBytes);
378
- const url = new URL(frame.url);
379
- if (frame.apiVersion !== 1 || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.username || url.password || !['id', 'username', 'password'].every(k => typeof frame[k] === 'string' && frame[k]))
380
- throw new Error('CONTROL_PROTOCOL_INVALID');
381
- const credentials = { apiVersion: 1, id: frame.id, url: url.origin, username: frame.username, password: frame.password };
382
- let released = false;
383
- lease = { credentials, async release() { if (released)
384
- return; released = true; owner.abort(); res.destroy(); signal.removeEventListener('abort', cancel); lifetime.signal.removeEventListener('abort', cancel); childGone.signal.removeEventListener('abort', cancel); leases.delete(lease); credentials.username = ''; credentials.password = ''; } };
385
- const current = lease;
386
- owner.signal.addEventListener('abort', () => void current.release(), { once: true });
387
- res.on('error', () => void current.release());
388
- res.on('close', () => void current.release());
389
- leases.add(current);
390
- if (owner.signal.aborted)
391
- await current.release();
392
- if (owner.signal.aborted)
393
- return fail('DISPOSED', '登录代理已取消', '重新发起登录');
394
- return ok(current);
395
- }
396
- catch (error) {
397
- owner.abort();
398
- signal.removeEventListener('abort', cancel);
399
- lifetime.signal.removeEventListener('abort', cancel);
400
- childGone.signal.removeEventListener('abort', cancel);
401
- diagnostic ??= observationNeed(error);
402
- return diagnostic ? { ok: false, error: diagnostic } : fail('PROXY_UNAVAILABLE', '登录代理未能建立,根因未查明', '查看主机网络与信任材料');
403
- }
404
- },
405
- async download(source, signal) { const lease = await api.openLoginProxy(signal); if (!lease.ok)
406
- return lease; return downloadThroughProxy(source, lease.value, signal, settings.downloadMaxRedirects, cfg.dialTimeoutSeconds, cfg.httpMaxResponseHeaderBytes); },
407
374
  async prepareEmployeeEndpoint(settingsPath, signal) {
408
375
  if (disposed)
409
376
  return fail('DISPOSED', '网络服务已停止。', '重新加载插件');
@@ -453,10 +420,8 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
453
420
  for (const update of updates)
454
421
  update();
455
422
  await starting;
456
- await Promise.all([...leases].map(x => x.release()));
457
423
  const forced = await stopOwned();
458
- await logClosed;
459
- publish(forced ? { phase: 'blocked', error: shutdownNeed } : lifecycleNeed ? { phase: 'blocked', error: lifecycleNeed } : { phase: 'stopped' });
424
+ publish(forced ? { phase: 'blocked', error: shutdownNeed } : { phase: 'stopped' });
460
425
  listeners.clear();
461
426
  updates.clear();
462
427
  })();
@@ -17,7 +17,6 @@ export interface PolicyConfigEditor {
17
17
  inherited: Raw;
18
18
  override: Raw;
19
19
  }[];
20
- edit(entry: Entry, change: (current: Raw, inherited: Raw) => Raw): Promise<void>;
21
20
  }
22
21
  interface PolicyObservation {
23
22
  network?: Pick<NetworkState, 'phase' | 'error'>;
@@ -37,7 +36,6 @@ export declare function createPolicyEditor(editor: PolicyConfigEditor, observe?:
37
36
  } | {
38
37
  ok: true;
39
38
  value: {
40
- revision: string;
41
39
  runtime: {
42
40
  observedAt: string;
43
41
  phase: string;
@@ -63,41 +61,6 @@ export declare function createPolicyEditor(editor: PolicyConfigEditor, observe?:
63
61
  }>;
64
62
  };
65
63
  };
66
- save: (params: URLSearchParams) => Promise<{
67
- ok: false;
68
- error: {
69
- code: string;
70
- message: string;
71
- };
72
- } | {
73
- ok: true;
74
- value: {
75
- revision: string;
76
- runtime: {
77
- observedAt: string;
78
- phase: string;
79
- need: {
80
- code: string;
81
- message: string;
82
- needs: string;
83
- exit: {
84
- exitCode: number | null;
85
- signal: string | null;
86
- } | null;
87
- } | null;
88
- connection: {
89
- instanceId: string;
90
- epoch: number;
91
- phase: string;
92
- } | null;
93
- };
94
- fields: Record<Key, {
95
- value: number | null;
96
- explicit: boolean;
97
- defaultValue: number;
98
- }>;
99
- };
100
- }>;
101
64
  };
102
65
  interface Web {
103
66
  register(route: {
@@ -1,10 +1,7 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { helperRecoveryDefaults, recoveryPolicyFields, validRecoveryPolicy } from "./settings.js";
3
- const fields = (value) => Object.fromEntries(recoveryPolicyFields.filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]));
4
- const revision = (current, inherited) => createHash('sha256').update(JSON.stringify([fields(current), fields(inherited)])).digest('hex');
5
2
  const failure = (code, message) => ({ ok: false, error: { code, message } });
6
- const unavailable = () => failure('RECOVERY_SETTINGS_UNAVAILABLE', '我需要官方配置编辑服务和唯一且已启用的网络组件,才能读取或保存恢复设置。');
7
- const recoveryNeeds = new Set(['HELPER_RECOVERING', 'HELPER_RECOVERY_EXHAUSTED', 'HELPER_RECOVERY_DISABLED', 'HELPER_RECOVERY_FAILED', 'HELPER_RECOVERY_IDENTITY_REQUIRED', 'HELPER_RECOVERY_CONFIG_CHANGED', 'HELPER_LIFECYCLE_LOG_FAILED', 'HELPER_EXITED', 'HELPER_PROCESS_FAILED', 'HELPER_START_FAILED', 'HELPER_STARTUP_EXPIRED', 'HELPER_SHUTDOWN_FORCED', 'HELPER_HANDOFF_FAILED']);
3
+ const unavailable = () => failure('RECOVERY_SETTINGS_UNAVAILABLE', '我需要官方配置编辑服务和唯一且已启用的网络组件,才能读取恢复设置。');
4
+ const recoveryNeeds = new Set(['CONTROL_UNAVAILABLE', 'ENROLLMENT_KEY_REJECTED', 'ENROLLMENT_REJECTED', 'HELPER_RECOVERING', 'HELPER_RECOVERY_EXHAUSTED', 'HELPER_RECOVERY_DISABLED', 'HELPER_RECOVERY_FAILED', 'HELPER_RECOVERY_IDENTITY_REQUIRED', 'HELPER_RECOVERY_CONFIG_CHANGED', 'HELPER_EXITED', 'HELPER_PROCESS_FAILED', 'HELPER_START_FAILED', 'HELPER_STARTUP_EXPIRED', 'HELPER_SHUTDOWN_FORCED', 'HELPER_HANDOFF_FAILED']);
8
5
  function runtimeObservation(observe) {
9
6
  const observedAt = new Date().toISOString();
10
7
  try {
@@ -36,58 +33,13 @@ export function createPolicyEditor(editor, observe = () => ({})) {
36
33
  const current = found.entry.options.config ?? {};
37
34
  if (!validRecoveryPolicy(current))
38
35
  return failure('RECOVERY_SETTINGS_INVALID', '恢复设置包含非法值,请通过官方配置编辑流程修正。');
39
- return { ok: true, value: { revision: revision(current, found.inherited), runtime: runtimeObservation(observe), fields: Object.fromEntries(recoveryPolicyFields.map(key => [key, { value: current[key] ?? null, explicit: Object.hasOwn(current, key), defaultValue: helperRecoveryDefaults[key] }])) } };
36
+ return { ok: true, value: { runtime: runtimeObservation(observe), fields: Object.fromEntries(recoveryPolicyFields.map(key => [key, { value: current[key] ?? null, explicit: Object.hasOwn(current, key), defaultValue: helperRecoveryDefaults[key] }])) } };
40
37
  }
41
38
  catch {
42
39
  return unavailable();
43
40
  }
44
41
  }
45
- async function save(params) {
46
- const allowed = ['revision', ...recoveryPolicyFields];
47
- if ([...params.keys()].some(k => !allowed.includes(k)) || allowed.some(k => params.getAll(k).length !== 1) || !/^[a-f0-9]{64}$/.test(params.get('revision') ?? ''))
48
- return failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:请只提交当前页面中的三项恢复设置。');
49
- const values = {};
50
- for (const key of recoveryPolicyFields) {
51
- const raw = params.get(key);
52
- if (raw === 'unset') {
53
- values[key] = 'unset';
54
- continue;
55
- }
56
- if (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(raw))
57
- return failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:次数须为非负安全整数;秒数须在0.001到2147483.647之间。');
58
- const value = Number(raw);
59
- if (!validRecoveryPolicy({ [key]: value }))
60
- return failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:次数须为非负安全整数;秒数须在0.001到2147483.647之间。');
61
- values[key] = value;
62
- }
63
- try {
64
- const found = target();
65
- if (!found)
66
- return unavailable();
67
- await editor.edit(found.entry, (current, inherited) => {
68
- if (revision(current, inherited) !== params.get('revision'))
69
- throw new PolicyConflict();
70
- const next = { ...current };
71
- for (const key of recoveryPolicyFields) {
72
- if (values[key] === 'unset') {
73
- delete next[key];
74
- }
75
- else
76
- next[key] = values[key];
77
- }
78
- if (!validRecoveryPolicy(next))
79
- throw new Error('invalid-policy');
80
- return next;
81
- });
82
- return describe();
83
- }
84
- catch (error) {
85
- return error instanceof PolicyConflict ? failure('RECOVERY_SETTINGS_CONFLICT', '设置未保存:配置已被其他操作修改,请重新打开本页核对后保存。') : failure('RECOVERY_SETTINGS_SAVE_FAILED', '官方配置保存或重载失败,请检查插件管理中的错误;本页未确认修改生效。');
86
- }
87
- }
88
- return { describe, save };
89
- }
90
- class PolicyConflict extends Error {
42
+ return { describe };
91
43
  }
92
44
  export function registerPolicyRoutes(web, connection, service) {
93
45
  return web.register({ kind: 'prefix', path: '/ai-workbench-network/policy', async handler(req, res) {
@@ -109,13 +61,9 @@ export function registerPolicyRoutes(web, connection, service) {
109
61
  reply(result.ok ? 200 : 503, result);
110
62
  return;
111
63
  }
112
- if (req.method === 'POST') {
113
- if (req.headers['transfer-encoding'] || req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0') {
114
- reply(400, failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:本操作不接受请求正文。'));
115
- return;
116
- }
117
- const result = await service.save(url.searchParams);
118
- reply(result.ok ? 200 : result.error.code === 'RECOVERY_SETTINGS_INPUT_INVALID' ? 400 : 409, result);
64
+ if (req.method !== 'GET') {
65
+ res.setHeader('Allow', 'GET');
66
+ reply(405, failure('RECOVERY_SETTINGS_READ_ONLY', '恢复参数只读,请经官方 Config 修改。'));
119
67
  return;
120
68
  }
121
69
  reply(400, failure('RECOVERY_SETTINGS_INPUT_INVALID', '不支持此设置操作。'));