@ai-workbench/network 0.4.1 → 0.4.4

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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@ai-workbench/net-helper-darwin-x64",
3
+ "version": "0.4.4",
4
+ "description": "远程主机网络助手(macOS x64,仅随network主包内嵌分发)",
5
+ "os": [
6
+ "darwin"
7
+ ],
8
+ "cpu": [
9
+ "x64"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/yzsnstotz/ai-workbench-network.git"
14
+ },
15
+ "private": true
16
+ }
@@ -1,6 +1,8 @@
1
1
  import { createNetwork } from './network.ts';
2
2
  import type { Executable } from './network.ts';
3
3
  import type { NetworkApi, NetworkState, PluginSettings } from './api.ts';
4
- export declare function createActivationService(initial: PluginSettings, executable: Executable, emit?: (s: NetworkState) => void, factory?: typeof createNetwork): NetworkApi & {
4
+ import type { EmployeeConnectSource } from './employee-endpoint.ts';
5
+ export declare function createActivationService(initial: PluginSettings, executable: Executable, emit?: (s: NetworkState) => void, factory?: typeof createNetwork, getConnect?: () => EmployeeConnectSource | undefined): NetworkApi & {
5
6
  start(): Promise<void>;
7
+ refreshEmployeeSource(): void;
6
8
  };
package/lib/activation.js CHANGED
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
2
2
  import { isAbsolute } from 'node:path';
3
3
  import { createNetwork } from "./network.js";
4
4
  import { fail, ok } from "./api.js";
5
- const fields = ['configFile', 'helperLogFile', 'startupObservationSeconds', 'shutdownObservationSeconds', 'controlResponseMaxBytes', 'downloadMaxRedirects'];
5
+ import { settingsFields as fields, validHelperPolicy, mergedSettings } from "./settings.js";
6
+ import { createEmployeeEndpointController } from "./employee-endpoint.js";
6
7
  function normalized(s) {
7
8
  if (!s || typeof s !== 'object' || Object.keys(s).some(k => !fields.includes(k)))
8
9
  return;
@@ -13,15 +14,18 @@ function normalized(s) {
13
14
  return;
14
15
  if (!Number.isSafeInteger(s.controlResponseMaxBytes) || s.controlResponseMaxBytes <= 0 || !Number.isSafeInteger(s.downloadMaxRedirects) || s.downloadMaxRedirects < 0)
15
16
  return;
16
- return Object.fromEntries(fields.map(k => [k, s[k]]));
17
+ if (!validHelperPolicy(s))
18
+ return;
19
+ return Object.fromEntries(fields.filter(k => s[k] !== undefined).map(k => [k, s[k]]));
17
20
  }
18
21
  // One stable Cordis service; callers never own, restart or borrow helper processes.
19
- export function createActivationService(initial, executable, emit = () => { }, factory = createNetwork) {
22
+ export function createActivationService(initial, executable, emit = () => { }, factory = createNetwork, getConnect = () => undefined) {
20
23
  let network = { phase: 'blocked', error: { code: 'SETTINGS_REQUIRED', message: '我需要导入连接资料。', needs: '在设置 → 远程主机连接 导入连接资料', actor: 'network' } };
21
24
  let activation = { instanceId: randomUUID(), revision: 0, phase: 'unconfigured', receipt: null, error: network.error };
22
25
  let runtime, selected, disposed = false, disposing, handoff, generation = 0, handedOff = false, consumerOnline = false;
23
26
  const listeners = new Set(), edges = new Set(), updates = new Set();
24
27
  const snapshot = () => structuredClone(activation);
28
+ const employee = createEmployeeEndpointController({ getConnect, getRuntime: () => activation.phase === 'active' ? runtime : undefined, getState: () => network, connect: (target, signal) => api.connect(target, signal) });
25
29
  function publish(phase, error, receipt = activation.receipt) {
26
30
  activation = { ...activation, revision: activation.revision + 1, phase, error, receipt };
27
31
  for (const listener of listeners)
@@ -80,6 +84,8 @@ export function createActivationService(initial, executable, emit = () => { }, f
80
84
  return;
81
85
  const was = consumerOnline;
82
86
  consumerOnline = ready && next.phase === 'online';
87
+ if (was && !consumerOnline)
88
+ employee.networkLost();
83
89
  if (was !== consumerOnline)
84
90
  for (const listener of edges)
85
91
  try {
@@ -103,8 +109,9 @@ export function createActivationService(initial, executable, emit = () => { }, f
103
109
  observed({ phase: 'blocked', error: { code: 'HELPER_START_FAILED', message: '网络组件未能启动,根因未查明。', needs: '管理员检查私有日志', actor: 'network' } }); });
104
110
  }
105
111
  const api = {
112
+ prepareEmployeeEndpoint: employee.prepare, refreshEmployeeSource: employee.refreshSource,
106
113
  async start() { if (disposed)
107
- return; if (Object.keys(initial).length)
114
+ return; if (initial.configFile || initial.helperLogFile)
108
115
  await api.activate(initial, new AbortController().signal);
109
116
  else
110
117
  emit(structuredClone(network)); },
@@ -114,9 +121,9 @@ export function createActivationService(initial, executable, emit = () => { }, f
114
121
  async activate(settings, signal) {
115
122
  if (disposed || signal.aborted)
116
123
  return fail('ACTIVATION_CANCELLED', '连接激活等待已取消。', '查询当前激活结果;不要重复提交密钥');
117
- const config = normalized(settings);
124
+ const config = normalized(mergedSettings(initial, settings));
118
125
  if (!config)
119
- return fail('SETTINGS_REQUIRED', '我需要有效的连接配置。', 'L提供六项具名网络设置');
126
+ return fail('SETTINGS_REQUIRED', '我需要有效的连接配置。', '管理员核对网络插件具名设置及已导入的连接资料');
120
127
  const key = JSON.stringify(config);
121
128
  if (selected !== undefined && selected !== key)
122
129
  return fail('ACTIVATION_CONFLICT', '已有连接激活,不能替换身份。', '管理员核对当前开通上下文');
@@ -145,7 +152,19 @@ export function createActivationService(initial, executable, emit = () => { }, f
145
152
  async download(source, signal) { if (disposed)
146
153
  return fail('DISPOSED', '网络服务已停止。', '重新加载插件'); return activation.phase === 'active' && runtime ? runtime.download(source, signal) : { ok: false, error: network.error ?? { code: 'NETWORK_NOT_READY', message: '连接尚未就绪。', needs: '等待N身份交接完成', actor: 'network' } }; },
147
154
  dispose() { if (disposing)
148
- return disposing; disposed = true; return disposing = Promise.resolve().then(async () => { publish('stopped', activation.error); await runtime?.dispose(); await handoff; network = runtime?.getState() ?? { phase: 'stopped' }; emit(structuredClone(network)); publish('stopped', network.error ?? null); listeners.clear(); edges.clear(); updates.clear(); }); },
155
+ return disposing; disposed = true; return disposing = Promise.resolve().then(async () => { publish('stopped', activation.error); try {
156
+ await employee.dispose();
157
+ }
158
+ finally {
159
+ await runtime?.dispose();
160
+ await handoff;
161
+ network = runtime?.getState() ?? { phase: 'stopped' };
162
+ emit(structuredClone(network));
163
+ publish('stopped', network.error ?? null);
164
+ listeners.clear();
165
+ edges.clear();
166
+ updates.clear();
167
+ } }); },
149
168
  };
150
169
  return api;
151
170
  }
package/lib/api.d.ts CHANGED
@@ -69,10 +69,49 @@ export interface ActivationState {
69
69
  error: Need | null;
70
70
  }
71
71
  export interface NetworkApi extends NetworkChannels {
72
+ prepareEmployeeEndpoint(request: EmployeeEndpointRequest, signal: AbortSignal): Promise<Result<EmployeeEndpointTarget>>;
72
73
  activate(settings: PluginSettings, signal: AbortSignal): Promise<Result<ActivationState>>;
73
74
  getActivationState(): ActivationState;
74
75
  onActivationState(listener: (state: ActivationState) => void): () => void;
75
76
  }
77
+ export interface EmployeeEndpointRequest {
78
+ instanceId: string;
79
+ epoch: number;
80
+ workspaceId: string;
81
+ expectedBindingId: string;
82
+ }
83
+ export interface EmployeeEndpointTarget {
84
+ version: 1;
85
+ local: {
86
+ instanceId: string;
87
+ epoch: number;
88
+ workspaceId: string;
89
+ bindingId: string;
90
+ directory: {
91
+ path: string;
92
+ device: number;
93
+ inode: number;
94
+ };
95
+ };
96
+ endpoint: {
97
+ deviceId: string;
98
+ endpointInstanceId: string;
99
+ executionContextId: string;
100
+ remoteUser: string;
101
+ remoteUid: number;
102
+ remoteHome: string;
103
+ endpointCwd: string;
104
+ endpointShell: string;
105
+ };
106
+ bridge: {
107
+ protocolVersion: 1;
108
+ command: '/usr/local/aw-host/bin/aw-employee-bridge';
109
+ settingsPath: string;
110
+ bridgeInstanceId: string;
111
+ generation: number;
112
+ };
113
+ }
114
+ export type EmployeeEndpointReady = Pick<EmployeeEndpointTarget, 'endpoint' | 'bridge'>;
76
115
  export interface NetworkChannels {
77
116
  getState(): NetworkState;
78
117
  connect(target: EnrollmentTarget, signal: AbortSignal): Promise<Result<{
@@ -90,6 +129,12 @@ export interface PluginSettings {
90
129
  shutdownObservationSeconds?: number;
91
130
  controlResponseMaxBytes?: number;
92
131
  downloadMaxRedirects?: number;
132
+ statusRefreshIntervalSeconds?: number;
133
+ dialTimeoutSeconds?: number;
134
+ headerTimeoutSeconds?: number;
135
+ enrollmentObservationSeconds?: number;
136
+ httpMaxHeaderBytes?: number;
137
+ httpMaxResponseHeaderBytes?: number;
93
138
  }
94
139
  export interface HelperConfig {
95
140
  apiVersion: 1;
package/lib/control.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
- export declare function control(socket: string, method: string, path: string, signal: AbortSignal, seconds: number, maxHeaderBytes: number): Promise<IncomingMessage>;
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
4
  export declare function firstFrame(response: IncomingMessage, maxBytes: number): Promise<unknown>;
package/lib/control.js CHANGED
@@ -1,13 +1,22 @@
1
1
  import http from 'node:http';
2
2
  import { privateSocket } from "./private.js";
3
- export async function control(socket, method, path, signal, seconds, maxHeaderBytes) {
3
+ import { ObservationExpired } from "./observation.js";
4
+ export async function control(socket, method, path, signal, seconds, maxHeaderBytes, body) {
4
5
  await privateSocket(socket);
5
6
  signal.throwIfAborted();
6
7
  return new Promise((resolve, reject) => {
7
8
  const req = http.request({ socketPath: socket, method, path, agent: false, signal, maxHeaderSize: maxHeaderBytes }, res => { req.setTimeout(0); resolve(res); });
8
- req.setTimeout(seconds * 1000, () => req.destroy(new Error('CONTROL_OBSERVATION_EXPIRED')));
9
+ const started = performance.now();
10
+ req.setTimeout(seconds * 1000, () => req.destroy(new ObservationExpired('CONTROL_OBSERVATION_EXPIRED', 'dialTimeoutSeconds', seconds, started, path === '/v1/status' ? '网络组件状态响应头' : '本机控制响应头', '检查自有组件私有日志、控制socket和当前网络状态;不要重交开通材料')));
9
11
  req.on('error', reject);
10
- req.end();
12
+ if (body === undefined)
13
+ req.end();
14
+ else {
15
+ const data = JSON.stringify(body);
16
+ req.setHeader('Content-Type', 'application/json');
17
+ req.setHeader('Content-Length', Buffer.byteLength(data));
18
+ req.end(data);
19
+ }
11
20
  });
12
21
  }
13
22
  export async function jsonResponse(response, maxBytes) {
package/lib/download.js CHANGED
@@ -4,6 +4,7 @@ import tls from 'node:tls';
4
4
  import { isIP } from 'node:net';
5
5
  import { Readable } from 'node:stream';
6
6
  import { fail, ok } from "./api.js";
7
+ import { ObservationExpired, observationNeed } from "./observation.js";
7
8
  function sourceURL(source) { const u = new URL(source); if (u.protocol !== 'https:' || u.username || u.password)
8
9
  throw new Error('DOWNLOAD_SOURCE_INVALID'); return u; }
9
10
  async function requestViaProxy(target, lease, signal, seconds, maxHeaderBytes) {
@@ -14,7 +15,8 @@ async function requestViaProxy(target, lease, signal, seconds, maxHeaderBytes) {
14
15
  const authority = (isIP(hostname) === 6 ? '[' + hostname + ']' : hostname) + ':' + (target.port || '443');
15
16
  const tunnel = await new Promise((resolve, reject) => {
16
17
  const req = http.request({ hostname: proxy.hostname, port: proxy.port, method: 'CONNECT', path: authority, agent: false, signal, maxHeaderSize: maxHeaderBytes, headers: { 'Proxy-Authorization': 'Basic ' + Buffer.from(lease.credentials.username + ':' + lease.credentials.password).toString('base64') } });
17
- req.setTimeout(seconds * 1000, () => req.destroy(new Error('DOWNLOAD_CONNECT_EXPIRED')));
18
+ const started = performance.now();
19
+ req.setTimeout(seconds * 1000, () => req.destroy(new ObservationExpired('DOWNLOAD_CONNECT_EXPIRED', 'dialTimeoutSeconds', seconds, started, '主机代理CONNECT响应头', '检查网络组件私有日志、主机SSH状态及代理握手;不重试或改走本机出口')));
18
20
  req.on('connect', (response, socket, head) => { req.setTimeout(0); if (response.statusCode !== 200) {
19
21
  socket.destroy();
20
22
  reject(new Error('HOST_PROXY_REJECTED'));
@@ -31,7 +33,8 @@ async function requestViaProxy(target, lease, signal, seconds, maxHeaderBytes) {
31
33
  try {
32
34
  return await new Promise((resolve, reject) => {
33
35
  const req = https.request(target, { agent, signal, maxHeaderSize: maxHeaderBytes }, response => { req.setTimeout(0); response.once('close', () => agent.destroy()); resolve(response); });
34
- req.setTimeout(seconds * 1000, () => req.destroy(new Error('DOWNLOAD_HEADERS_EXPIRED')));
36
+ const started = performance.now();
37
+ req.setTimeout(seconds * 1000, () => req.destroy(new ObservationExpired('DOWNLOAD_HEADERS_EXPIRED', 'dialTimeoutSeconds', seconds, started, '经主机下载的TLS握手及HTTP响应头', '检查主机出口、TLS与下载源响应;不重试或静默换源')));
35
38
  req.on('error', reject);
36
39
  req.end();
37
40
  });
@@ -116,6 +119,9 @@ export async function downloadThroughProxy(source, lease, signal, maxRedirects,
116
119
  }
117
120
  catch (error) {
118
121
  await lease.release();
122
+ const observed = observationNeed(error);
123
+ if (observed)
124
+ return { ok: false, error: observed };
119
125
  const code = error instanceof Error && ['DOWNLOAD_REDIRECT_LIMIT', 'DOWNLOAD_SOURCE_INVALID'].includes(error.message) ? error.message : 'HOST_DOWNLOAD_FAILED';
120
126
  return fail(code, '经主机下载失败,根因未查明', '检查主机出口、下载源和具名重定向上限;无本机直出替代');
121
127
  }
@@ -0,0 +1,102 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { EmployeeEndpointRequest, EmployeeEndpointTarget, EmployeeEndpointReady, EnrollmentTarget, NetworkState, Result } from './api.ts';
3
+ export interface EmployeeProcess {
4
+ pid: number;
5
+ ppid: number;
6
+ platform: string;
7
+ arch: string;
8
+ executable: string;
9
+ realUid: number;
10
+ effectiveUid: number;
11
+ realGid: number;
12
+ effectiveGid: number;
13
+ account: {
14
+ name: string;
15
+ home: string;
16
+ shell: string | null;
17
+ };
18
+ environment: {
19
+ HOME: string | null;
20
+ PATH: string | null;
21
+ SHELL: string | null;
22
+ };
23
+ }
24
+ interface LocalContext {
25
+ instanceId: string;
26
+ epoch: number;
27
+ workspaceId: string;
28
+ version: 1;
29
+ bindingId: string;
30
+ source: 'local';
31
+ provenance: 'official-workspace-registry';
32
+ directory: {
33
+ path: string;
34
+ device: number;
35
+ inode: number;
36
+ };
37
+ process: EmployeeProcess;
38
+ }
39
+ interface LocationState {
40
+ instanceId: string;
41
+ epoch: number;
42
+ phase: string;
43
+ items: {
44
+ workspaceId: string;
45
+ bindingId: string | null;
46
+ source: string | null;
47
+ status: string;
48
+ }[];
49
+ }
50
+ interface Connection {
51
+ instanceId: string;
52
+ epoch: number;
53
+ phase: string;
54
+ }
55
+ interface SshTarget extends EnrollmentTarget {
56
+ session: {
57
+ settingsPath: string;
58
+ protocolVersion: 1;
59
+ };
60
+ }
61
+ type ForeignResult<T> = {
62
+ ok: true;
63
+ value: T;
64
+ } | {
65
+ ok: false;
66
+ error: unknown;
67
+ };
68
+ export interface EmployeeConnectSource {
69
+ getContractInfo(): {
70
+ localWorkspaceContext?: number;
71
+ };
72
+ getLocalWorkspaceContext?(request: EmployeeEndpointRequest): Promise<ForeignResult<LocalContext>>;
73
+ getWorkspaceLocationState?(): LocationState;
74
+ onWorkspaceLocationState?(listener: (state: LocationState) => void): () => void;
75
+ getConnectionState(): Connection;
76
+ onConnectionState(listener: (state: Connection) => void): () => void;
77
+ getSshTarget(): Promise<ForeignResult<SshTarget>>;
78
+ }
79
+ export interface EmployeeRuntime {
80
+ prepareEmployeeEndpoint(settingsPath: string, signal: AbortSignal): Promise<Result<EmployeeEndpointReady>>;
81
+ closeEmployeeEndpoint(): Promise<void>;
82
+ }
83
+ interface Dependencies {
84
+ getConnect(): EmployeeConnectSource | undefined;
85
+ getRuntime(): EmployeeRuntime | undefined;
86
+ getState(): NetworkState;
87
+ connect(target: EnrollmentTarget, signal: AbortSignal): Promise<Result<{
88
+ proxyCommand: string;
89
+ }>>;
90
+ }
91
+ export declare function readEmployeeProcess(): Result<EmployeeProcess>;
92
+ export declare function employeeReady(value: unknown, p: EmployeeProcess, settingsPath: string): Result<EmployeeEndpointReady>;
93
+ export declare function bindEmployeeSourceLifecycle(ctx: Context, controller: {
94
+ refreshSource(): void;
95
+ }): void;
96
+ export declare function createEmployeeEndpointController(d: Dependencies): {
97
+ prepare: (r: EmployeeEndpointRequest, signal: AbortSignal) => Promise<Result<EmployeeEndpointTarget>>;
98
+ refreshSource: () => void;
99
+ networkLost: () => void;
100
+ dispose(): Promise<void>;
101
+ };
102
+ export {};
@@ -0,0 +1,230 @@
1
+ import { userInfo } from 'node:os';
2
+ import { isAbsolute, normalize } from 'node:path';
3
+ import { realpath, stat } from 'node:fs/promises';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { fail, ok } from "./api.js";
6
+ const absolute = (v) => typeof v === 'string' && isAbsolute(v) && normalize(v) === v && !/[\0\r\n]/.test(v);
7
+ const nonempty = (v) => typeof v === 'string' && v.length > 0 && !/[\0\r\n]/.test(v);
8
+ const uuid = (v) => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
9
+ const same = isDeepStrictEqual;
10
+ function shape(v) {
11
+ const s = v;
12
+ return !!s && ['getContractInfo', 'getLocalWorkspaceContext', 'getWorkspaceLocationState', 'onWorkspaceLocationState', 'getConnectionState', 'onConnectionState', 'getSshTarget'].every(k => typeof s[k] === 'function') && s.getContractInfo()?.localWorkspaceContext === 1;
13
+ }
14
+ export function readEmployeeProcess() {
15
+ try {
16
+ if (process.platform !== 'darwin' || !process.getuid || !process.geteuid || !process.getgid || !process.getegid)
17
+ return fail('HELPER_PLATFORM_UNSUPPORTED', '当前系统不能提供员工端点。', '使用受支持的Mac客户端');
18
+ const account = userInfo(), uid = process.getuid(), euid = process.geteuid(), gid = process.getgid(), egid = process.getegid();
19
+ if (uid < 1 || uid > 4294967294 || uid !== euid || gid !== egid || account.uid !== euid || !nonempty(account.username) || !absolute(account.homedir) || !absolute(account.shell))
20
+ return fail('EMPLOYEE_ACCOUNT_UNAVAILABLE', '我需要当前员工的真实系统账户与shell。', '核对官方客户端的同用户进程环境');
21
+ return ok({ pid: process.pid, ppid: process.ppid, platform: process.platform, arch: process.arch, executable: process.execPath, realUid: uid, effectiveUid: euid, realGid: gid, effectiveGid: egid, account: { name: account.username, home: account.homedir, shell: account.shell }, environment: { HOME: process.env.HOME ?? null, PATH: process.env.PATH ?? null, SHELL: process.env.SHELL ?? null } });
22
+ }
23
+ catch {
24
+ return fail('EMPLOYEE_ACCOUNT_UNAVAILABLE', '员工账户信息暂不可用。', '核对官方客户端进程环境');
25
+ }
26
+ }
27
+ function requestValid(r) {
28
+ return !!r && Object.keys(r).length === 4 && ['instanceId', 'workspaceId', 'expectedBindingId'].every(k => nonempty(r[k])) && Number.isSafeInteger(r.epoch) && r.epoch >= 0;
29
+ }
30
+ export function employeeReady(value, p, settingsPath) {
31
+ const v = value, e = v?.endpoint, b = v?.bridge;
32
+ if (!e || !b || Object.keys(v).sort().join() !== 'bridge,endpoint' || Object.keys(e).sort().join() !== 'deviceId,endpointCwd,endpointInstanceId,endpointShell,executionContextId,remoteHome,remoteUid,remoteUser' || Object.keys(b).sort().join() !== 'bridgeInstanceId,command,generation,protocolVersion,settingsPath' || !/^[0-9a-f]{64}$/.test(e.deviceId) || !uuid(e.endpointInstanceId) || !uuid(e.executionContextId) || e.remoteUid !== p.effectiveUid || e.remoteUser !== p.account.name || e.remoteHome !== p.account.home || e.endpointCwd !== p.account.home || e.endpointShell !== p.account.shell || b.protocolVersion !== 1 || b.command !== '/usr/local/aw-host/bin/aw-employee-bridge' || b.settingsPath !== settingsPath || !uuid(b.bridgeInstanceId) || !Number.isSafeInteger(b.generation) || b.generation < 1)
33
+ return fail('EMPLOYEE_BRIDGE_PROTOCOL_INVALID', '员工端点返回的身份或代际不符。', '检查同一员工端点与主机桥合同');
34
+ return ok(structuredClone(v));
35
+ }
36
+ const cancelled = () => fail('EMPLOYEE_ENDPOINT_PREPARE_CANCELLED', '员工端点准备等待已取消。', '再次派发前重新核验当前来源;不要重放旧操作');
37
+ // L can withdraw an optional getter while its ordinary service object remains.
38
+ // Observe the public Cordis service lifecycle, including dependency disposal in
39
+ // the same turn; no polling timer, dependency on L startup, or command retry.
40
+ export function bindEmployeeSourceLifecycle(ctx, controller) {
41
+ let disposed = false, scheduled;
42
+ ctx.on('internal/service', () => {
43
+ controller.refreshSource();
44
+ scheduled ??= setImmediate(() => { scheduled = undefined; if (!disposed)
45
+ controller.refreshSource(); });
46
+ });
47
+ ctx.effect(() => () => { disposed = true; if (scheduled)
48
+ clearImmediate(scheduled); });
49
+ controller.refreshSource();
50
+ }
51
+ export function createEmployeeEndpointController(d) {
52
+ let source, disposed = false, revision = 0, held;
53
+ let unsubscribers = [], lifetime = new AbortController(), closing = Promise.resolve(), closeFailed = false;
54
+ let pending;
55
+ const detached = () => { for (const off of unsubscribers)
56
+ off(); unsubscribers = []; };
57
+ function invalidate() {
58
+ revision++;
59
+ lifetime.abort();
60
+ lifetime = new AbortController();
61
+ pending = undefined;
62
+ const old = held;
63
+ held = undefined;
64
+ if (old)
65
+ closing = closing.then(() => old.closeEmployeeEndpoint()).catch(() => { closeFailed = true; console.error('EMPLOYEE_ENDPOINT_CLOSE_FAILED'); });
66
+ }
67
+ function localRemains() {
68
+ const s = source?.getWorkspaceLocationState?.();
69
+ return !!s && s.phase === 'ready' && s.items.some(i => i.source === 'local' && i.status === 'selected' && nonempty(i.bindingId));
70
+ }
71
+ function refreshSource() {
72
+ if (disposed)
73
+ return;
74
+ let current;
75
+ try {
76
+ const value = d.getConnect();
77
+ if (shape(value))
78
+ current = value;
79
+ }
80
+ catch {
81
+ current = undefined;
82
+ }
83
+ if (current === source) {
84
+ try {
85
+ if (source && !localRemains())
86
+ invalidate();
87
+ }
88
+ catch {
89
+ invalidate();
90
+ }
91
+ return;
92
+ }
93
+ detached();
94
+ invalidate();
95
+ source = current;
96
+ if (!source)
97
+ return;
98
+ try {
99
+ const own = source;
100
+ let connection = own.getConnectionState();
101
+ unsubscribers.push(own.onWorkspaceLocationState(() => { if (source === own) {
102
+ try {
103
+ if (!localRemains())
104
+ invalidate();
105
+ }
106
+ catch {
107
+ invalidate();
108
+ }
109
+ } }));
110
+ unsubscribers.push(own.onConnectionState(next => { if (source !== own)
111
+ return; const changed = next.phase !== 'connected' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch; connection = next; if (changed)
112
+ invalidate(); }));
113
+ }
114
+ catch {
115
+ detached();
116
+ invalidate();
117
+ source = undefined;
118
+ }
119
+ }
120
+ async function read(r, s) {
121
+ try {
122
+ if (d.getConnect() !== s || !shape(s))
123
+ return fail('EMPLOYEE_CONTEXT_UNAVAILABLE', '本地工作区上下文已撤回。', '等待当前连接组件提供上下文');
124
+ const connection = s.getConnectionState(), locations = s.getWorkspaceLocationState();
125
+ if (connection.instanceId !== r.instanceId || connection.epoch !== r.epoch || connection.phase !== 'connected' || locations.instanceId !== r.instanceId || locations.epoch !== r.epoch || locations.phase !== 'ready' || !locations.items.some(i => i.workspaceId === r.workspaceId && i.bindingId === r.expectedBindingId && i.source === 'local' && i.status === 'selected'))
126
+ return fail('EMPLOYEE_CONTEXT_CHANGED', '工作区来源或绑定已变化。', '使用当前工作区重新派发');
127
+ const result = await s.getLocalWorkspaceContext(r);
128
+ if (!result.ok)
129
+ return fail('EMPLOYEE_CONTEXT_UNAVAILABLE', '本地工作区上下文暂不可用。', '检查当前工作区来源与目录状态');
130
+ const c = result.value, actual = readEmployeeProcess();
131
+ if (!actual.ok)
132
+ return actual;
133
+ if (c?.version !== 1 || c.instanceId !== r.instanceId || c.epoch !== r.epoch || c.workspaceId !== r.workspaceId || c.bindingId !== r.expectedBindingId || c.source !== 'local' || c.provenance !== 'official-workspace-registry' || !c.directory || !absolute(c.directory.path) || !Number.isSafeInteger(c.directory.device) || !Number.isSafeInteger(c.directory.inode) || !same(c.process, actual.value))
134
+ return fail('EMPLOYEE_CONTEXT_INVALID', '工作区上下文与当前客户端进程不一致。', '重新核对同一客户端的来源与账户');
135
+ const canonical = await realpath(c.directory.path), st = await stat(canonical);
136
+ if (canonical !== c.directory.path || !st.isDirectory() || st.dev !== c.directory.device || st.ino !== c.directory.inode)
137
+ return fail('EMPLOYEE_CONTEXT_CHANGED', '工作区目录身份已变化。', '重新选择当前真实工作区');
138
+ if (d.getConnect() !== s || !shape(s))
139
+ return fail('EMPLOYEE_CONTEXT_UNAVAILABLE', '工作区上下文已撤回。', '等待当前连接组件');
140
+ const after = s.getConnectionState(), ls = s.getWorkspaceLocationState();
141
+ if (after.instanceId !== r.instanceId || after.epoch !== r.epoch || after.phase !== 'connected' || ls.instanceId !== r.instanceId || ls.epoch !== r.epoch || ls.phase !== 'ready' || !ls.items.some(i => i.workspaceId === r.workspaceId && i.bindingId === r.expectedBindingId && i.source === 'local' && i.status === 'selected'))
142
+ return fail('EMPLOYEE_CONTEXT_CHANGED', '工作区在读取期间已变化。', '重新核验当前来源后派发');
143
+ return ok(structuredClone(c));
144
+ }
145
+ catch {
146
+ return fail('EMPLOYEE_CONTEXT_INVALID', '本地工作区信息无法核验。', '检查当前目录与连接组件');
147
+ }
148
+ }
149
+ async function prepare(r, signal) {
150
+ if (signal.aborted)
151
+ return cancelled();
152
+ if (disposed)
153
+ return fail('DISPOSED', '网络服务已停止。', '重新加载插件');
154
+ if (!requestValid(r))
155
+ return fail('EMPLOYEE_CONTEXT_INVALID', '工作区上下文请求无效。', '由当前工作区提供完整绑定');
156
+ refreshSource();
157
+ const s = source;
158
+ if (!s)
159
+ return fail('EMPLOYEE_CONTEXT_UNAVAILABLE', '我需要当前本地工作区上下文。', '等待连接组件提供本地来源');
160
+ const before = await read(r, s);
161
+ if (!before.ok)
162
+ return before;
163
+ if (signal.aborted)
164
+ return cancelled();
165
+ const token = revision, owner = lifetime.signal;
166
+ const runtime = d.getRuntime();
167
+ if (!runtime || d.getState().phase !== 'online')
168
+ return fail('NETWORK_NOT_READY', '远程主机尚未连接。', '等待原网络连接就绪');
169
+ let remote;
170
+ try {
171
+ remote = await s.getSshTarget();
172
+ }
173
+ catch {
174
+ return fail('EMPLOYEE_CONTEXT_UNAVAILABLE', '主机连接上下文暂不可用。', '检查连接组件状态');
175
+ }
176
+ if (!remote.ok)
177
+ return fail('EMPLOYEE_CONTEXT_UNAVAILABLE', '主机连接目标尚未就绪。', '等待当前主机连接');
178
+ const target = remote.value;
179
+ if (!target?.session || target.session.protocolVersion !== 1 || !absolute(target.session.settingsPath))
180
+ return fail('EMPLOYEE_CONTEXT_INVALID', '主机桥设置来源无效。', '检查已开通主机设置');
181
+ if (signal.aborted)
182
+ return cancelled();
183
+ const trusted = await d.connect(target, owner);
184
+ if (!trusted.ok)
185
+ return trusted;
186
+ if (signal.aborted)
187
+ return cancelled();
188
+ if (disposed || owner.aborted || revision !== token || d.getConnect() !== s)
189
+ return fail('EMPLOYEE_CONTEXT_CHANGED', '工作区连接已变化。', '重新核验后派发');
190
+ await closing;
191
+ if (closeFailed)
192
+ return fail('EMPLOYEE_ENDPOINT_UNAVAILABLE', '旧员工端点关闭未能确认。', '检查组件私有日志,正常停止旧实例后再继续;不要重放旧操作');
193
+ if (disposed || owner.aborted || revision !== token || d.getRuntime() !== runtime)
194
+ return fail('EMPLOYEE_CONTEXT_CHANGED', '员工端点上下文已变化。', '重新核验后派发');
195
+ // One shared preparation per actual device context, never per workspace. Each caller revalidates its own L CAS.
196
+ let work = pending;
197
+ if (!work || work.runtime !== runtime || work.settingsPath !== target.session.settingsPath || work.revision !== token) {
198
+ held = runtime;
199
+ const promise = runtime.prepareEmployeeEndpoint(target.session.settingsPath, owner).catch(() => fail('EMPLOYEE_ENDPOINT_UNAVAILABLE', '员工端点准备失败,根因未查明。', '检查组件私有诊断'));
200
+ work = { runtime, settingsPath: target.session.settingsPath, revision: token, promise };
201
+ pending = work;
202
+ void promise.finally(() => { if (pending === work)
203
+ pending = undefined; });
204
+ }
205
+ const ready = await new Promise(resolve => {
206
+ const abort = () => { signal.removeEventListener('abort', abort); owner.removeEventListener('abort', withdraw); resolve(cancelled()); };
207
+ const withdraw = () => { signal.removeEventListener('abort', abort); owner.removeEventListener('abort', withdraw); resolve(fail('EMPLOYEE_CONTEXT_CHANGED', '员工端点来源已撤回。', '重新核验后派发')); };
208
+ signal.addEventListener('abort', abort, { once: true });
209
+ owner.addEventListener('abort', withdraw, { once: true });
210
+ work.promise.then(v => { signal.removeEventListener('abort', abort); owner.removeEventListener('abort', withdraw); resolve(v); });
211
+ if (signal.aborted)
212
+ abort();
213
+ else if (owner.aborted)
214
+ withdraw();
215
+ });
216
+ if (!ready.ok)
217
+ return ready;
218
+ const verified = employeeReady(ready.value, before.value.process, target.session.settingsPath);
219
+ if (!verified.ok)
220
+ return verified;
221
+ const after = await read(r, s);
222
+ if (disposed || owner.aborted || revision !== token || d.getRuntime() !== runtime || !after.ok || !same(before.value, after.value))
223
+ return fail('EMPLOYEE_CONTEXT_CHANGED', '工作区在端点准备期间已变化。', '重新核验当前来源后派发,不重放旧操作');
224
+ if (signal.aborted)
225
+ return cancelled();
226
+ return ok({ version: 1, local: { instanceId: r.instanceId, epoch: r.epoch, workspaceId: r.workspaceId, bindingId: r.expectedBindingId, directory: structuredClone(after.value.directory) }, ...verified.value });
227
+ }
228
+ return { prepare, refreshSource, networkLost: invalidate, async dispose() { if (disposed)
229
+ return; disposed = true; detached(); invalidate(); await closing; } };
230
+ }
package/lib/index.js CHANGED
@@ -1,16 +1,24 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { createActivationService } from "./activation.js";
3
+ import { bindEmployeeSourceLifecycle } from "./employee-endpoint.js";
3
4
  import { resolvePackagedHelper } from "./packaged-helper.js";
4
5
  export const name = 'ai-workbench-network';
5
- const positive = () => z.number().min(0.001);
6
+ const positive = () => z.number().min(0.001).max(2147483.647);
7
+ const bytes = () => z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER);
6
8
  export const Config = z.object({
7
9
  configFile: z.string().description('远程主机连接配置文件(由插件提供;无默认值)'),
8
10
  helperLogFile: z.string().description('远程主机连接诊断日志文件(无默认值)'),
9
- startupObservationSeconds: positive().description('连接组件启动状态的最长观察时间(秒,无默认值)'),
10
- shutdownObservationSeconds: positive().description('连接组件正常退出的等待时间(秒,无默认值);超时后强制结束并报告'),
11
- controlResponseMaxBytes: z.number().step(1).min(1).description('本机连接控制响应的最大字节数(无默认值)'),
12
- downloadMaxRedirects: z.number().step(1).min(0).description('通过远程主机下载时允许的最大重定向次数(无默认值)'),
13
- });
11
+ startupObservationSeconds: positive().description('启动观察 startupObservationSeconds(秒):默认未设,沿开通值(现值35);等待组件ready仅为可观测性;增大等更久,减小可能提前报未知;无关闭值,超时给等待事项、已等时长和排查步骤'),
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
+ statusRefreshIntervalSeconds: positive().description('状态刷新间隔 statusRefreshIntervalSeconds(秒):默认沿已开通配置(现值2);影响组件状态查询及插件轮询;增大提示更迟,减小增加查询开销;无关闭值,不代表失败重试已修复'),
16
+ dialTimeoutSeconds: positive().description('拨号观察 dialTimeoutSeconds(秒):默认沿已开通配置(现值20);远端TCP/SSH、控制响应头、下载CONNECT/响应头;Node请求采用阶段空闲观察,仅为可观测性,增大等更久,减小可能提前中止;无关闭值,超时说明等待事项、已等时长、下一步检查,不是修复'),
17
+ headerTimeoutSeconds: z.number().min(1e-9).max(9223372036.854774).description('请求头观察 headerTimeoutSeconds(秒):默认沿已开通配置(现值10);本机控制与登录代理请求头;仅为可观测性,增大保留连接更久,减小可能拒绝慢请求头;无关闭值,超时在私有日志说明等待事项、已等时长、下一步检查'),
18
+ enrollmentObservationSeconds: z.number().min(1e-9).max(9223372036.854774).description('首次入网观察 enrollmentObservationSeconds(秒):默认沿已开通配置(现值30);仅观察首次开通结果,不用于删除已有身份;增大等更久,减小可能更早报未知;无关闭值,超时说明等待事项、已等时长、下一步检查,不重交开通材料'),
19
+ httpMaxHeaderBytes: bytes().description('本机HTTP头上限 httpMaxHeaderBytes(字节):默认沿已开通配置(现值1048576);控制/登录代理请求头及控制响应头;增大增加解析内存,减小可能拒绝合法头;无关闭或无限值,不是正文上限'),
20
+ httpMaxResponseHeaderBytes: bytes().description('下载HTTP响应头上限 httpMaxResponseHeaderBytes(字节):默认沿已开通配置(现值1048576);主机代理及下载响应头/CONNECT头;增大增加解析内存,减小可能拒绝合法头;无关闭或无限值,不是正文上限'),
21
+ }).description('运行策略优先级:本插件显式值 > 开通方传入值 > 原私有配置;未设置沿用原值,不注入新数值默认。活动连接改策略须正常重载插件,不重交开通材料。固定正确性约束不可关闭:macOS控制socket路径≤103字节、端口1–65535、计时器/整数可表示范围、私有700/600权限、当前用户与单身份隔离、SHA256及加密随机凭据强度。');
14
22
  export function register(ctx, api) {
15
23
  ctx.provide('aiWorkbenchNetwork', api);
16
24
  ctx.effect(() => { void api.start(); return () => api.dispose(); });
@@ -20,8 +28,10 @@ export function apply(ctx, config = {}) {
20
28
  if (!executable.ok) {
21
29
  const error = executable;
22
30
  const state = { phase: 'blocked', ...!error.ok ? { error: error.error } : {} };
23
- register(ctx, { 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() { } });
31
+ 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() { } });
24
32
  return;
25
33
  }
26
- register(ctx, createActivationService(config, executable.value, state => ctx.emit('ai-workbench/network-state', state)));
34
+ const api = createActivationService(config, executable.value, state => ctx.emit('ai-workbench/network-state', state), undefined, () => ctx.get('aiWorkbenchConnect', false));
35
+ register(ctx, api);
36
+ bindEmployeeSourceLifecycle(ctx, { refreshSource: api.refreshEmployeeSource });
27
37
  }