@ai-workbench/network 0.0.0-stage → 0.4.1
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/NOTICE.md +18 -0
- package/README.md +16 -2
- package/cordis.patch.yml +3 -0
- package/lib/activation.d.ts +6 -0
- package/lib/activation.js +151 -0
- package/lib/api.d.ts +116 -0
- package/lib/api.js +2 -0
- package/lib/control.d.ts +4 -0
- package/lib/control.js +57 -0
- package/lib/cordis.d.ts +10 -0
- package/lib/cordis.js +1 -0
- package/lib/download.d.ts +2 -0
- package/lib/download.js +122 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +27 -0
- package/lib/network.d.ts +11 -0
- package/lib/network.js +356 -0
- package/lib/packaged-helper.d.ts +3 -0
- package/lib/packaged-helper.js +25 -0
- package/lib/private.d.ts +3 -0
- package/lib/private.js +47 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -4
package/NOTICE.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# 依赖与来源(N 第 1 轮)
|
|
2
|
+
|
|
3
|
+
N2 helper 已接入固定 tsnet 并可通过 Makefile 构建;未部署、未发包。认证HTTP/CONNECT代理及Electron44组件诊断已交;DSH插件0.3.0及新消费者Cordis组件诊断已交。真实证据见 N 回执。
|
|
4
|
+
|
|
5
|
+
| 组件 | 固定版本/来源提交 | 协议 | 用途及当前证据 |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| tailscale.com/tsnet | v1.104.1 / `9a522a9786c97eb7910c01ccb7bd66557b04c910` | BSD-3-Clause | [上游源码](https://github.com/tailscale/tailscale/tree/v1.104.1/tsnet),实际 helper 依赖;完整闭包固定于 go.mod/go.sum |
|
|
8
|
+
| tailscaled HTTP proxy | 同上,`cmd/tailscaled/proxy.go` | BSD-3-Clause | [CONNECT 处理源码](https://github.com/tailscale/tailscale/blob/v1.104.1/cmd/tailscaled/proxy.go),只读参考;本实现使用net/http.ReverseProxy及SSH direct-tcpip,未复制该文件 |
|
|
9
|
+
| DeepSeek Harness | 0.2.0-rc.2 / `639ed015397290b3745d163aafe02ffee4aa3f84` | MIT(已复核固定源码package.json),不复制核心代码 | [官方上游](https://github.com/deepseek-ai/deepseek-harness/tree/dsh-v0.2.0-rc.2),只读参考;实现只在本仓,浅克隆验证完成后精确清理 |
|
|
10
|
+
| Go | 构建固定1.27.3;目标tsnet要求至少1.27.1 | BSD-3-Clause | `go mod download tailscale.com@v1.104.1` 自动选择 1.27.3。已用自动选择的1.27.3通过普通/race/vet/build |
|
|
11
|
+
|
|
12
|
+
模块下载校验:`h1:9GNOm9Izqll9RH5jA5NWcLiCzXi/WtqY4EHQY2Dy2zk=`;go.mod 校验:`h1:Cb1XjScRgSOeRD7NW6uCEznme782WiOU9IxqyTWoRf0=`。原始下载元数据来自 Go 模块校验,不是构建/真实联网证明。
|
|
13
|
+
|
|
14
|
+
升级注意:N2 强制用户态 netstack 拨号使用公开但不稳定 Server.Sys/Dialer API,避开 Server.Dial 的系统回退;状态恢复预检和测试依赖同版本的 StateStore/Prefs/LoginProfile 与官方启动选择逻辑。升级必须复验这些边界,不能只改版本号。
|
|
15
|
+
|
|
16
|
+
N3 SSH使用 golang.org/x/crypto/ssh v0.57.0(BSD-3-Clause;实际锁定版本见go.mod);只使用none认证与可信known_hosts。Electron44.0.0官方darwin-arm64 ZIP为诊断工具,MIT及其随附第三方许可;SHA256与引擎版本见 `docs/evidence/n3-2026-10-20/electron-artifact.json`。独立进程/内存session,没有复制进发布包。
|
|
17
|
+
|
|
18
|
+
N4插件peer:@deepseek-ai/cordis4.0.4、@deepseek-ai/schemastery3.18.4,均MIT,来源为固定DSH提交的vendor目录及官方npm包。TS5.9.3/Node24类型是构建依赖;实际闭包固定package-lock.json,新消费者闭包另存证据。主机下载只用Node内置http/https/tls/stream,经helper认证CONNECT,不新增外部代理库。源码/API升级需要在自有仓库适配,不提上游PR/issue或等待上游修改。
|
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
安装:在 DSH「插件 → 添加插件」填写 `@ai-workbench/network@0.4.1`,或运行 `dsh plugin --profile desktop add @ai-workbench/network@0.4.1`。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# 远程主机网络
|
|
4
|
+
|
|
5
|
+
0.4.1 当前为待发布版本,由编排统一发布公开 npm;工人不执行 publish。发布前可由官方添加插件入口安装本轮固定主包 tgz 的绝对路径,平台依赖也须可解析。
|
|
6
|
+
|
|
7
|
+
插件通过官方 bundle/Cordis 接口提供远程主机网络、设备身份、SSH 通道、认证登录代理及主机下载能力。不替换 DSH 服务、布局或默认设置,不增加工作区/登录界面。L 负责可信连接资料导入和登录入口,员工不手填主机、用户名、路径或密钥。没有连接资料时明确说明需要导入,不把插件 Running 当作网络已连接。
|
|
8
|
+
|
|
9
|
+
主包不携带 Go 二进制;精确依赖同版本的 `@ai-workbench/net-helper-darwin-arm64`、`@ai-workbench/net-helper-darwin-x64`。npm/pnpm 按 os/cpu 安装当前平台包,插件只解析该包,检查版本、平台和 manifest,启动前验证助手 SHA256。缺包、损坏或平台不支持时明确说明“我需要 X”,不使用 PATH、旧助手、另一架构或安装时下载。三个包均不需要 postinstall/prepare 或构建脚本;预编译文件直接入包。平台包同时附实际目标编译闭包的许可证原文及来源摘要(含 Go 标准库内 vendor);构建遇缺失或不可读声明会明确失败。现阶段只提供 macOS arm64/x64,x64 的构建/文件检查不代表 Intel 真机验收。
|
|
10
|
+
|
|
11
|
+
一个默认 profile 只启用一个 N 条目。独立安装与 L 聚合安装不要重复挂载 N。N 的网络身份按系统用户隔离、持久保存;不重交未知结果的一次性 key。公开 `aiWorkbenchNetwork.activate(settings, signal)`、`getActivationState/onActivationState` 和通道 API;公开类型入口 `/api`,Cordis 类型入口 `/cordis`。`active` 表示身份恢复与消费交接完成,不等于主机 SSH 或 AI 登录已通过。六项现有设置必须由 L 按可信材料及具名策略提供,无新默认值。
|
|
12
|
+
|
|
13
|
+
开发:Node >=24.13.1、Go 1.27.3、固定 tsnet v1.104.1。运行 `npm ci --ignore-scripts --omit=optional`、`npm test`、`npm run check`、`make test`、`make test-race`、`make vet`,再执行 `npm run build`。build 显式交叉构建两个 macOS 平台包并编译 TypeScript;Go 参数固定 ts_omit_logtail、禁 VCS 元数据、trimpath、CGO_ENABLED=0。这些是开发命令,员工安装不执行。二进制、manifest 和 NOTICE 副本由构建生成,不提交。
|
|
14
|
+
|
|
15
|
+
发布准备按 arm64 平台包 → x64 平台包 → 主包顺序 pack/dry-run,版本必须一致;固定 tgz、SHA256、文件清单及证据见仓库 `docs/evidence/2026-10-21-N-npm/` 和 N 回执。不要把先前单平台 0.4.0 的旧 lib/bin 混入本轮产物。根盘按当前编排规则低于8GiB停;临时 HOME、依赖、日志和构建缓存放自有 Elements APFS,用后精确清理,不删除公共缓存。
|
|
16
|
+
|
|
17
|
+
完整提供方合同见仓库 `contracts/net-helper.md`,第三方来源见包内 NOTICE.md。只做中文,既有英文名称保留。官方 DSH 与 HanaMesh 分别验证;不使用 owner 当前实例,GUI 加载门不替代真实入网、登录、对话、物理断网或 leo E 验收。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createNetwork } from './network.ts';
|
|
2
|
+
import type { Executable } from './network.ts';
|
|
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 & {
|
|
5
|
+
start(): Promise<void>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { createNetwork } from "./network.js";
|
|
4
|
+
import { fail, ok } from "./api.js";
|
|
5
|
+
const fields = ['configFile', 'helperLogFile', 'startupObservationSeconds', 'shutdownObservationSeconds', 'controlResponseMaxBytes', 'downloadMaxRedirects'];
|
|
6
|
+
function normalized(s) {
|
|
7
|
+
if (!s || typeof s !== 'object' || Object.keys(s).some(k => !fields.includes(k)))
|
|
8
|
+
return;
|
|
9
|
+
if (typeof s.configFile !== 'string' || !isAbsolute(s.configFile) || typeof s.helperLogFile !== 'string' || !isAbsolute(s.helperLogFile) || s.configFile === s.helperLogFile)
|
|
10
|
+
return;
|
|
11
|
+
for (const v of [s.startupObservationSeconds, s.shutdownObservationSeconds])
|
|
12
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0.001 || v * 1000 > 2147483647)
|
|
13
|
+
return;
|
|
14
|
+
if (!Number.isSafeInteger(s.controlResponseMaxBytes) || s.controlResponseMaxBytes <= 0 || !Number.isSafeInteger(s.downloadMaxRedirects) || s.downloadMaxRedirects < 0)
|
|
15
|
+
return;
|
|
16
|
+
return Object.fromEntries(fields.map(k => [k, s[k]]));
|
|
17
|
+
}
|
|
18
|
+
// One stable Cordis service; callers never own, restart or borrow helper processes.
|
|
19
|
+
export function createActivationService(initial, executable, emit = () => { }, factory = createNetwork) {
|
|
20
|
+
let network = { phase: 'blocked', error: { code: 'SETTINGS_REQUIRED', message: '我需要导入连接资料。', needs: '在设置 → 远程主机连接 导入连接资料', actor: 'network' } };
|
|
21
|
+
let activation = { instanceId: randomUUID(), revision: 0, phase: 'unconfigured', receipt: null, error: network.error };
|
|
22
|
+
let runtime, selected, disposed = false, disposing, handoff, generation = 0, handedOff = false, consumerOnline = false;
|
|
23
|
+
const listeners = new Set(), edges = new Set(), updates = new Set();
|
|
24
|
+
const snapshot = () => structuredClone(activation);
|
|
25
|
+
function publish(phase, error, receipt = activation.receipt) {
|
|
26
|
+
activation = { ...activation, revision: activation.revision + 1, phase, error, receipt };
|
|
27
|
+
for (const listener of listeners)
|
|
28
|
+
try {
|
|
29
|
+
listener(snapshot());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
console.error('NETWORK_ACTIVATION_OBSERVER_FAILED');
|
|
33
|
+
}
|
|
34
|
+
for (const update of updates)
|
|
35
|
+
update();
|
|
36
|
+
}
|
|
37
|
+
function observed(next) {
|
|
38
|
+
if (disposed)
|
|
39
|
+
return;
|
|
40
|
+
network = next;
|
|
41
|
+
emit(structuredClone(next));
|
|
42
|
+
if (disposed)
|
|
43
|
+
return;
|
|
44
|
+
const receipt = next.status?.receipt ?? activation.receipt;
|
|
45
|
+
const unknown = next.error?.code === 'ENROLLMENT_RESULT_UNKNOWN';
|
|
46
|
+
const current = unknown ? { apiVersion: 1, session: 'key', key: 'unknown', identity: 'unconfirmed', cleanup: 'pending' } : receipt;
|
|
47
|
+
const operational = !next.error || ['NETWORK_INTERRUPTED', 'HOST_OFFLINE', 'HOST_NOT_VISIBLE'].includes(next.error.code);
|
|
48
|
+
const clean = current?.identity === 'resumable' && (current.cleanup === 'complete' || current.cleanup === 'not-required');
|
|
49
|
+
if (clean && current?.session === 'key' && operational) {
|
|
50
|
+
if (handedOff) {
|
|
51
|
+
observed({ phase: 'blocked', error: { code: 'CONTROL_PROTOCOL_INVALID', message: '身份恢复组件错误地保留密钥。', needs: '管理员核对组件版本', actor: 'network' } });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
publish('activating', null, current);
|
|
55
|
+
if (!disposed && !handoff) {
|
|
56
|
+
const owned = runtime;
|
|
57
|
+
generation++;
|
|
58
|
+
handoff = Promise.resolve().then(async () => {
|
|
59
|
+
await owned.dispose();
|
|
60
|
+
if (disposed)
|
|
61
|
+
return;
|
|
62
|
+
if (owned.getState().error) {
|
|
63
|
+
observed(owned.getState());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (handedOff) {
|
|
67
|
+
observed({ phase: 'blocked', error: { code: 'CONTROL_PROTOCOL_INVALID', message: '网络身份交接状态无效。', needs: '管理员核对helper版本', actor: 'network' } });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
handedOff = true;
|
|
71
|
+
install(JSON.parse(selected));
|
|
72
|
+
launch();
|
|
73
|
+
}).catch(() => observed({ phase: 'blocked', error: { code: 'HELPER_HANDOFF_FAILED', message: '网络身份交接失败,根因未查明。', needs: '保留身份并检查私有日志;不要重交密钥', actor: 'network' } }));
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const ready = clean && current?.session === 'identity' && !!next.status?.receipt;
|
|
78
|
+
publish(ready && operational ? 'active' : next.error ? 'blocked' : 'activating', next.error ?? null, current);
|
|
79
|
+
if (disposed)
|
|
80
|
+
return;
|
|
81
|
+
const was = consumerOnline;
|
|
82
|
+
consumerOnline = ready && next.phase === 'online';
|
|
83
|
+
if (was !== consumerOnline)
|
|
84
|
+
for (const listener of edges)
|
|
85
|
+
try {
|
|
86
|
+
listener({ kind: consumerOnline ? 'restored' : 'lost', state: structuredClone(next) });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
console.error('NETWORK_OBSERVER_FAILED');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function install(config) {
|
|
93
|
+
const ownGeneration = ++generation;
|
|
94
|
+
runtime = factory(config, executable, next => { if (ownGeneration === generation)
|
|
95
|
+
observed(next); });
|
|
96
|
+
}
|
|
97
|
+
function launch() {
|
|
98
|
+
const owned = runtime, ownGeneration = generation;
|
|
99
|
+
void owned.start().then(() => {
|
|
100
|
+
if (!disposed && ownGeneration === generation && !network.error && !network.status?.receipt)
|
|
101
|
+
observed({ phase: 'blocked', error: { code: 'ENROLLMENT_RECEIPT_REQUIRED', message: '网络组件缺少消费确认。', needs: '管理员核对插件与helper版本', actor: 'network' } });
|
|
102
|
+
}).catch(() => { if (ownGeneration === generation)
|
|
103
|
+
observed({ phase: 'blocked', error: { code: 'HELPER_START_FAILED', message: '网络组件未能启动,根因未查明。', needs: '管理员检查私有日志', actor: 'network' } }); });
|
|
104
|
+
}
|
|
105
|
+
const api = {
|
|
106
|
+
async start() { if (disposed)
|
|
107
|
+
return; if (Object.keys(initial).length)
|
|
108
|
+
await api.activate(initial, new AbortController().signal);
|
|
109
|
+
else
|
|
110
|
+
emit(structuredClone(network)); },
|
|
111
|
+
getState: () => structuredClone(network), getActivationState: snapshot,
|
|
112
|
+
onState(listener) { edges.add(listener); return () => edges.delete(listener); },
|
|
113
|
+
onActivationState(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
114
|
+
async activate(settings, signal) {
|
|
115
|
+
if (disposed || signal.aborted)
|
|
116
|
+
return fail('ACTIVATION_CANCELLED', '连接激活等待已取消。', '查询当前激活结果;不要重复提交密钥');
|
|
117
|
+
const config = normalized(settings);
|
|
118
|
+
if (!config)
|
|
119
|
+
return fail('SETTINGS_REQUIRED', '我需要有效的连接配置。', 'L提供六项具名网络设置');
|
|
120
|
+
const key = JSON.stringify(config);
|
|
121
|
+
if (selected !== undefined && selected !== key)
|
|
122
|
+
return fail('ACTIVATION_CONFLICT', '已有连接激活,不能替换身份。', '管理员核对当前开通上下文');
|
|
123
|
+
if (!runtime) {
|
|
124
|
+
selected = key;
|
|
125
|
+
install(config);
|
|
126
|
+
publish('activating', null);
|
|
127
|
+
if (!disposed)
|
|
128
|
+
launch();
|
|
129
|
+
}
|
|
130
|
+
await new Promise(resolve => {
|
|
131
|
+
const done = () => { if (!disposed && !signal.aborted && activation.phase === 'activating')
|
|
132
|
+
return; updates.delete(done); signal.removeEventListener('abort', done); resolve(); };
|
|
133
|
+
updates.add(done);
|
|
134
|
+
signal.addEventListener('abort', done, { once: true });
|
|
135
|
+
done();
|
|
136
|
+
});
|
|
137
|
+
if (disposed || signal.aborted)
|
|
138
|
+
return fail('ACTIVATION_CANCELLED', '连接激活等待已取消。', '查询当前激活结果;不要重复提交密钥');
|
|
139
|
+
return activation.phase === 'active' ? ok(snapshot()) : { ok: false, error: activation.error };
|
|
140
|
+
},
|
|
141
|
+
async connect(target, signal) { if (disposed)
|
|
142
|
+
return fail('DISPOSED', '网络服务已停止。', '重新加载插件'); return activation.phase === 'active' && runtime ? runtime.connect(target, signal) : { ok: false, error: network.error ?? { code: 'NETWORK_NOT_READY', message: '连接尚未就绪。', needs: '等待N身份交接完成', actor: 'network' } }; },
|
|
143
|
+
async openLoginProxy(signal) { if (disposed)
|
|
144
|
+
return fail('DISPOSED', '网络服务已停止。', '重新加载插件'); return activation.phase === 'active' && runtime ? runtime.openLoginProxy(signal) : { ok: false, error: network.error ?? { code: 'NETWORK_NOT_READY', message: '连接尚未就绪。', needs: '等待N身份交接完成', actor: 'network' } }; },
|
|
145
|
+
async download(source, signal) { if (disposed)
|
|
146
|
+
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
|
+
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(); }); },
|
|
149
|
+
};
|
|
150
|
+
return api;
|
|
151
|
+
}
|
package/lib/api.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export interface Need {
|
|
2
|
+
code: string;
|
|
3
|
+
message: string;
|
|
4
|
+
needs: string;
|
|
5
|
+
actor: string;
|
|
6
|
+
cause?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export type Result<T> = {
|
|
9
|
+
ok: true;
|
|
10
|
+
value: T;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: Need;
|
|
14
|
+
};
|
|
15
|
+
export interface HelperStatus {
|
|
16
|
+
apiVersion: 1;
|
|
17
|
+
enrollment: string;
|
|
18
|
+
node: {
|
|
19
|
+
name: string | null;
|
|
20
|
+
ips: string[];
|
|
21
|
+
};
|
|
22
|
+
host: {
|
|
23
|
+
name: string | null;
|
|
24
|
+
online: boolean | null;
|
|
25
|
+
lastSeenAt: string | null;
|
|
26
|
+
observedAt: string | null;
|
|
27
|
+
};
|
|
28
|
+
error: Need | null;
|
|
29
|
+
receipt?: EnrollmentReceipt;
|
|
30
|
+
}
|
|
31
|
+
export interface NetworkState {
|
|
32
|
+
phase: 'starting' | 'online' | 'blocked' | 'stopped';
|
|
33
|
+
status?: HelperStatus;
|
|
34
|
+
error?: Need;
|
|
35
|
+
}
|
|
36
|
+
export type NetworkEvent = {
|
|
37
|
+
kind: 'lost' | 'restored';
|
|
38
|
+
state: NetworkState;
|
|
39
|
+
};
|
|
40
|
+
export interface EnrollmentTarget {
|
|
41
|
+
host: string;
|
|
42
|
+
username: string;
|
|
43
|
+
port: number;
|
|
44
|
+
knownHostsFile: string;
|
|
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
|
+
export interface EnrollmentReceipt {
|
|
58
|
+
apiVersion: 1;
|
|
59
|
+
session: 'key' | 'identity';
|
|
60
|
+
key: 'not-required' | 'unattempted' | 'attempted' | 'confirmed' | 'unknown';
|
|
61
|
+
identity: 'unconfirmed' | 'resumable';
|
|
62
|
+
cleanup: 'not-required' | 'pending' | 'complete';
|
|
63
|
+
}
|
|
64
|
+
export interface ActivationState {
|
|
65
|
+
instanceId: string;
|
|
66
|
+
revision: number;
|
|
67
|
+
phase: 'unconfigured' | 'activating' | 'active' | 'blocked' | 'stopped';
|
|
68
|
+
receipt: EnrollmentReceipt | null;
|
|
69
|
+
error: Need | null;
|
|
70
|
+
}
|
|
71
|
+
export interface NetworkApi extends NetworkChannels {
|
|
72
|
+
activate(settings: PluginSettings, signal: AbortSignal): Promise<Result<ActivationState>>;
|
|
73
|
+
getActivationState(): ActivationState;
|
|
74
|
+
onActivationState(listener: (state: ActivationState) => void): () => void;
|
|
75
|
+
}
|
|
76
|
+
export interface NetworkChannels {
|
|
77
|
+
getState(): NetworkState;
|
|
78
|
+
connect(target: EnrollmentTarget, signal: AbortSignal): Promise<Result<{
|
|
79
|
+
proxyCommand: string;
|
|
80
|
+
}>>;
|
|
81
|
+
onState(listener: (event: NetworkEvent) => void): () => void;
|
|
82
|
+
openLoginProxy(signal: AbortSignal): Promise<Result<LoginProxyLease>>;
|
|
83
|
+
download(source: string, signal: AbortSignal): Promise<Result<Response>>;
|
|
84
|
+
dispose(): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
export interface PluginSettings {
|
|
87
|
+
configFile?: string;
|
|
88
|
+
helperLogFile?: string;
|
|
89
|
+
startupObservationSeconds?: number;
|
|
90
|
+
shutdownObservationSeconds?: number;
|
|
91
|
+
controlResponseMaxBytes?: number;
|
|
92
|
+
downloadMaxRedirects?: number;
|
|
93
|
+
}
|
|
94
|
+
export interface HelperConfig {
|
|
95
|
+
apiVersion: 1;
|
|
96
|
+
stateDir: string;
|
|
97
|
+
runtimeDir: string;
|
|
98
|
+
nodeName: string;
|
|
99
|
+
authKeyFile?: string;
|
|
100
|
+
host: {
|
|
101
|
+
name: string;
|
|
102
|
+
sshPort: number;
|
|
103
|
+
employee: string;
|
|
104
|
+
knownHostsFile?: string;
|
|
105
|
+
};
|
|
106
|
+
proxyListen: string;
|
|
107
|
+
statusRefreshIntervalSeconds: number;
|
|
108
|
+
dialTimeoutSeconds: number;
|
|
109
|
+
headerTimeoutSeconds: number;
|
|
110
|
+
enrollmentObservationSeconds: number;
|
|
111
|
+
httpMaxHeaderBytes: number;
|
|
112
|
+
httpMaxResponseHeaderBytes: number;
|
|
113
|
+
logUpload: false;
|
|
114
|
+
}
|
|
115
|
+
export declare const ok: <T>(value: T) => Result<T>;
|
|
116
|
+
export declare const fail: <T = never>(code: string, message: string, needs: string, actor?: string) => Result<T>;
|
package/lib/api.js
ADDED
package/lib/control.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
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>;
|
|
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
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { privateSocket } from "./private.js";
|
|
3
|
+
export async function control(socket, method, path, signal, seconds, maxHeaderBytes) {
|
|
4
|
+
await privateSocket(socket);
|
|
5
|
+
signal.throwIfAborted();
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
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
|
+
req.on('error', reject);
|
|
10
|
+
req.end();
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function jsonResponse(response, maxBytes) {
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
const chunks = [];
|
|
16
|
+
for await (const chunk of response) {
|
|
17
|
+
bytes += chunk.length;
|
|
18
|
+
if (bytes > maxBytes) {
|
|
19
|
+
response.destroy();
|
|
20
|
+
throw new Error('CONTROL_RESPONSE_LIMIT');
|
|
21
|
+
}
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
}
|
|
24
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
25
|
+
}
|
|
26
|
+
export async function firstFrame(response, maxBytes) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let data = Buffer.alloc(0);
|
|
29
|
+
const cleanup = () => { response.off('data', read); response.off('error', error); response.off('end', end); };
|
|
30
|
+
const error = () => { cleanup(); reject(new Error('CONTROL_STREAM_INVALID')); };
|
|
31
|
+
const end = () => error();
|
|
32
|
+
const read = (chunk) => {
|
|
33
|
+
data = Buffer.concat([data, chunk]);
|
|
34
|
+
if (data.length > maxBytes) {
|
|
35
|
+
cleanup();
|
|
36
|
+
response.destroy();
|
|
37
|
+
reject(new Error('CONTROL_RESPONSE_LIMIT'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const nl = data.indexOf(10);
|
|
41
|
+
if (nl < 0)
|
|
42
|
+
return;
|
|
43
|
+
cleanup();
|
|
44
|
+
response.pause();
|
|
45
|
+
try {
|
|
46
|
+
resolve(JSON.parse(data.subarray(0, nl).toString('utf8')));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
response.destroy();
|
|
50
|
+
reject(new Error('CONTROL_FRAME_INVALID'));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
response.on('data', read);
|
|
54
|
+
response.once('error', error);
|
|
55
|
+
response.once('end', end);
|
|
56
|
+
});
|
|
57
|
+
}
|
package/lib/cordis.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NetworkApi, NetworkState } from './api.ts';
|
|
2
|
+
declare module '@deepseek-ai/cordis' {
|
|
3
|
+
interface Context {
|
|
4
|
+
aiWorkbenchNetwork: NetworkApi;
|
|
5
|
+
}
|
|
6
|
+
interface Events {
|
|
7
|
+
'ai-workbench/network-state': (state: NetworkState) => void;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export {};
|
package/lib/cordis.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { fail, ok } from "./api.js";
|
|
7
|
+
function sourceURL(source) { const u = new URL(source); if (u.protocol !== 'https:' || u.username || u.password)
|
|
8
|
+
throw new Error('DOWNLOAD_SOURCE_INVALID'); return u; }
|
|
9
|
+
async function requestViaProxy(target, lease, signal, seconds, maxHeaderBytes) {
|
|
10
|
+
const proxy = new URL(lease.credentials.url);
|
|
11
|
+
if (proxy.protocol !== 'http:' || proxy.hostname !== '127.0.0.1' || proxy.username || proxy.password)
|
|
12
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
13
|
+
const hostname = target.hostname.replace(/^\[|\]$/g, '');
|
|
14
|
+
const authority = (isIP(hostname) === 6 ? '[' + hostname + ']' : hostname) + ':' + (target.port || '443');
|
|
15
|
+
const tunnel = await new Promise((resolve, reject) => {
|
|
16
|
+
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
|
+
req.on('connect', (response, socket, head) => { req.setTimeout(0); if (response.statusCode !== 200) {
|
|
19
|
+
socket.destroy();
|
|
20
|
+
reject(new Error('HOST_PROXY_REJECTED'));
|
|
21
|
+
return;
|
|
22
|
+
} if (head.length)
|
|
23
|
+
socket.unshift(head); resolve(socket); });
|
|
24
|
+
req.on('error', reject);
|
|
25
|
+
req.end();
|
|
26
|
+
});
|
|
27
|
+
// The only TCP socket supplied to TLS is the authenticated local proxy tunnel.
|
|
28
|
+
const agent = new class extends https.Agent {
|
|
29
|
+
createConnection(options) { return tls.connect({ ...options, socket: tunnel, servername: isIP(hostname) ? undefined : hostname }); }
|
|
30
|
+
}({ keepAlive: false });
|
|
31
|
+
try {
|
|
32
|
+
return await new Promise((resolve, reject) => {
|
|
33
|
+
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')));
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
req.end();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
tunnel.destroy();
|
|
41
|
+
agent.destroy();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function downloadThroughProxy(source, lease, signal, maxRedirects, seconds, maxHeaderBytes) {
|
|
46
|
+
let current;
|
|
47
|
+
try {
|
|
48
|
+
current = sourceURL(source);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
await lease.release();
|
|
52
|
+
return fail('DOWNLOAD_SOURCE_INVALID', '通过远程主机下载只支持不含凭据的HTTPS地址', '管理员检查下载源');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
for (let redirects = 0;; redirects++) {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
const response = await requestViaProxy(current, lease, signal, seconds, maxHeaderBytes);
|
|
58
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
59
|
+
response.destroy();
|
|
60
|
+
if (redirects >= maxRedirects)
|
|
61
|
+
throw new Error('DOWNLOAD_REDIRECT_LIMIT');
|
|
62
|
+
current = sourceURL(new URL(response.headers.location, current).href);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const headers = new Headers();
|
|
66
|
+
for (const [key, value] of Object.entries(response.headers))
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
for (const v of Array.isArray(value) ? value : [value])
|
|
69
|
+
headers.append(key, v);
|
|
70
|
+
}
|
|
71
|
+
response.once('end', () => void lease.release());
|
|
72
|
+
response.once('error', () => void lease.release());
|
|
73
|
+
response.once('close', () => void lease.release());
|
|
74
|
+
const empty = [204, 205, 304].includes(response.statusCode);
|
|
75
|
+
let body = null;
|
|
76
|
+
if (!empty) {
|
|
77
|
+
const reader = Readable.toWeb(response).getReader();
|
|
78
|
+
let closed = false;
|
|
79
|
+
body = new ReadableStream({
|
|
80
|
+
async pull(controller) {
|
|
81
|
+
try {
|
|
82
|
+
const chunk = await reader.read();
|
|
83
|
+
if (closed)
|
|
84
|
+
return;
|
|
85
|
+
if (chunk.done) {
|
|
86
|
+
closed = true;
|
|
87
|
+
await lease.release();
|
|
88
|
+
controller.close();
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
controller.enqueue(chunk.value);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
await lease.release();
|
|
95
|
+
if (!closed) {
|
|
96
|
+
closed = true;
|
|
97
|
+
controller.error(new Error('HOST_DOWNLOAD_STREAM_FAILED'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async cancel() { closed = true; try {
|
|
102
|
+
await reader.cancel();
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await lease.release();
|
|
106
|
+
} },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const result = new Response(body, { status: response.statusCode, headers });
|
|
110
|
+
if (empty) {
|
|
111
|
+
response.resume();
|
|
112
|
+
await lease.release();
|
|
113
|
+
}
|
|
114
|
+
return ok(result);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
await lease.release();
|
|
119
|
+
const code = error instanceof Error && ['DOWNLOAD_REDIRECT_LIMIT', 'DOWNLOAD_SOURCE_INVALID'].includes(error.message) ? error.message : 'HOST_DOWNLOAD_FAILED';
|
|
120
|
+
return fail(code, '经主机下载失败,根因未查明', '检查主机出口、下载源和具名重定向上限;无本机直出替代');
|
|
121
|
+
}
|
|
122
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import type { NetworkApi, PluginSettings } from './api.ts';
|
|
4
|
+
export type * from './api.ts';
|
|
5
|
+
export type * from './cordis.ts';
|
|
6
|
+
export declare const name = "ai-workbench-network";
|
|
7
|
+
export type Config = PluginSettings;
|
|
8
|
+
export declare const Config: z<Config>;
|
|
9
|
+
export declare function register(ctx: Context, api: NetworkApi & {
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
}): void;
|
|
12
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { createActivationService } from "./activation.js";
|
|
3
|
+
import { resolvePackagedHelper } from "./packaged-helper.js";
|
|
4
|
+
export const name = 'ai-workbench-network';
|
|
5
|
+
const positive = () => z.number().min(0.001);
|
|
6
|
+
export const Config = z.object({
|
|
7
|
+
configFile: z.string().description('远程主机连接配置文件(由插件提供;无默认值)'),
|
|
8
|
+
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
|
+
});
|
|
14
|
+
export function register(ctx, api) {
|
|
15
|
+
ctx.provide('aiWorkbenchNetwork', api);
|
|
16
|
+
ctx.effect(() => { void api.start(); return () => api.dispose(); });
|
|
17
|
+
}
|
|
18
|
+
export function apply(ctx, config = {}) {
|
|
19
|
+
const executable = resolvePackagedHelper(import.meta.url);
|
|
20
|
+
if (!executable.ok) {
|
|
21
|
+
const error = executable;
|
|
22
|
+
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() { } });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
register(ctx, createActivationService(config, executable.value, state => ctx.emit('ai-workbench/network-state', state)));
|
|
27
|
+
}
|
package/lib/network.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { control } from './control.ts';
|
|
2
|
+
import type { PluginSettings, NetworkChannels, NetworkState } from './api.ts';
|
|
3
|
+
export interface Executable {
|
|
4
|
+
path: string;
|
|
5
|
+
sha256: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
arch: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function createNetwork(settings: PluginSettings, executable: Executable, emit?: (state: NetworkState) => void, controlIO?: typeof control): NetworkChannels & {
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
};
|
package/lib/network.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { join, isAbsolute, normalize, dirname, basename, relative, sep } from 'node:path';
|
|
3
|
+
import { realpath, lstat } from 'node:fs/promises';
|
|
4
|
+
import { privateFile, verifyExecutable } from "./private.js";
|
|
5
|
+
import { control, jsonResponse, firstFrame } from "./control.js";
|
|
6
|
+
import { fail, ok } from "./api.js";
|
|
7
|
+
import { downloadThroughProxy } from "./download.js";
|
|
8
|
+
const seconds = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0.001 && v * 1000 <= 2147483647;
|
|
9
|
+
const integer = (v) => Number.isSafeInteger(v) && Number(v) > 0;
|
|
10
|
+
const quote = (s) => "'" + s.replaceAll("'", "'\\''") + "'";
|
|
11
|
+
const needs = (code, message, requires) => ({ code, message, needs: requires, actor: 'network', cause: null });
|
|
12
|
+
function receiptFrame(receipt) {
|
|
13
|
+
if (receipt === undefined)
|
|
14
|
+
return;
|
|
15
|
+
if (receipt?.apiVersion !== 1 || !['key', 'identity'].includes(receipt.session) || !['not-required', 'unattempted', 'attempted', 'confirmed', 'unknown'].includes(receipt.key) || !['unconfirmed', 'resumable'].includes(receipt.identity) || !['not-required', 'pending', 'complete'].includes(receipt.cleanup))
|
|
16
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
17
|
+
if ((receipt.cleanup === 'complete' && (receipt.key !== 'confirmed')) || (receipt.cleanup === 'not-required' && receipt.key !== 'not-required'))
|
|
18
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
19
|
+
return { apiVersion: 1, session: receipt.session, key: receipt.key, identity: receipt.identity, cleanup: receipt.cleanup };
|
|
20
|
+
}
|
|
21
|
+
function statusFrame(v) {
|
|
22
|
+
if (v?.apiVersion !== 1 || typeof v.enrollment !== 'string' || !v.node || !v.host || !Array.isArray(v.node.ips) || !v.node.ips.every((x) => typeof x === 'string'))
|
|
23
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
24
|
+
for (const x of [v.node.name, v.host.name, v.host.lastSeenAt, v.host.observedAt])
|
|
25
|
+
if (x !== null && typeof x !== 'string')
|
|
26
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
27
|
+
if (v.host.online !== null && typeof v.host.online !== 'boolean')
|
|
28
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
29
|
+
let error = null;
|
|
30
|
+
if (v.error !== null) {
|
|
31
|
+
if (!v.error || !['code', 'message', 'needs', 'actor'].every(k => typeof v.error[k] === 'string'))
|
|
32
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
33
|
+
error = { code: v.error.code, message: v.error.message, needs: v.error.needs, actor: v.error.actor, cause: typeof v.error.cause === 'string' ? v.error.cause : null };
|
|
34
|
+
}
|
|
35
|
+
const receipt = receiptFrame(v.receipt);
|
|
36
|
+
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 };
|
|
37
|
+
}
|
|
38
|
+
export function createNetwork(settings, executable, emit = () => { }, controlIO = control) {
|
|
39
|
+
let state = { phase: 'starting' }, cfg, child;
|
|
40
|
+
let starting, disposing, disposed = false, online = false;
|
|
41
|
+
let poll;
|
|
42
|
+
let processFailed = false, startupReceipt, terminalNeed, stoppingChild;
|
|
43
|
+
const childGone = new AbortController();
|
|
44
|
+
const lifetime = new AbortController(), listeners = new Set(), updates = new Set();
|
|
45
|
+
const leases = new Set();
|
|
46
|
+
const snapshot = () => structuredClone(state);
|
|
47
|
+
const publish = (next) => {
|
|
48
|
+
state = next;
|
|
49
|
+
const was = online;
|
|
50
|
+
online = next.phase === 'online';
|
|
51
|
+
emit(snapshot());
|
|
52
|
+
for (const update of updates)
|
|
53
|
+
update();
|
|
54
|
+
if (was !== online)
|
|
55
|
+
for (const listener of listeners) {
|
|
56
|
+
try {
|
|
57
|
+
listener({ kind: online ? 'restored' : 'lost', state: snapshot() });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
console.error('NETWORK_OBSERVER_FAILED');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const block = (code, message, requires) => publish({ phase: 'blocked', error: needs(code, message, requires) });
|
|
65
|
+
const socket = () => join(cfg.runtimeDir, 'control.sock');
|
|
66
|
+
const alive = () => !!child?.pid && !processFailed && child.exitCode === null && child.signalCode === null;
|
|
67
|
+
const terminal = (need) => { processFailed = true; terminalNeed ??= need; childGone.abort(); if (poll)
|
|
68
|
+
clearTimeout(poll); if (!disposed)
|
|
69
|
+
publish({ phase: 'blocked', error: terminalNeed, ...state.status ? { status: state.status } : startupReceipt ? { status: { apiVersion: 1, enrollment: 'error', node: { name: null, ips: [] }, host: { name: null, online: null, lastSeenAt: null, observedAt: null }, error: terminalNeed, receipt: startupReceipt } } : {} }); };
|
|
70
|
+
const stopOwned = () => stoppingChild ??= (async () => {
|
|
71
|
+
const owned = child;
|
|
72
|
+
if (!owned?.pid || owned.exitCode !== null || owned.signalCode !== null)
|
|
73
|
+
return false;
|
|
74
|
+
const exit = new Promise(resolve => owned.once('exit', () => resolve()));
|
|
75
|
+
owned.kill('SIGTERM');
|
|
76
|
+
let forced = false, timer;
|
|
77
|
+
await Promise.race([exit, new Promise(resolve => { timer = setTimeout(() => { forced = true; owned.kill('SIGKILL'); resolve(); }, settings.shutdownObservationSeconds * 1000); })]);
|
|
78
|
+
if (timer)
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
if (forced)
|
|
81
|
+
await exit;
|
|
82
|
+
return forced;
|
|
83
|
+
})();
|
|
84
|
+
const request = (method, path, signal) => controlIO(socket(), method, path, AbortSignal.any([signal, childGone.signal]), cfg.dialTimeoutSeconds, cfg.httpMaxHeaderBytes);
|
|
85
|
+
async function observe() {
|
|
86
|
+
if (disposed || !alive())
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
const res = await request('GET', '/v1/status', lifetime.signal);
|
|
90
|
+
const value = await jsonResponse(res, settings.controlResponseMaxBytes);
|
|
91
|
+
if (res.statusCode !== 200)
|
|
92
|
+
throw new Error('CONTROL_UNAVAILABLE');
|
|
93
|
+
const s = statusFrame(value);
|
|
94
|
+
if (!disposed && alive())
|
|
95
|
+
publish({ phase: s.enrollment === 'online' && !s.error ? 'online' : s.error ? 'blocked' : 'starting', status: s, ...s.error ? { error: s.error } : {} });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
if (!disposed && alive())
|
|
99
|
+
block('CONTROL_UNAVAILABLE', '连接中断,正在等待网络组件恢复;根因未查明。', '等待恢复后自动重新连接;持续无响应时联系管理员');
|
|
100
|
+
}
|
|
101
|
+
if (!disposed && alive())
|
|
102
|
+
poll = setTimeout(() => void observe(), cfg.statusRefreshIntervalSeconds * 1000);
|
|
103
|
+
}
|
|
104
|
+
const start = () => starting ??= (async () => {
|
|
105
|
+
let startupNeed, handshakeComplete = false;
|
|
106
|
+
try {
|
|
107
|
+
if (!settings.configFile || !settings.helperLogFile || !seconds(settings.startupObservationSeconds) || !seconds(settings.shutdownObservationSeconds) || !integer(settings.controlResponseMaxBytes) || !Number.isSafeInteger(settings.downloadMaxRedirects) || settings.downloadMaxRedirects < 0) {
|
|
108
|
+
block('SETTINGS_REQUIRED', '我需要管理员提供网络安装配置和具名设置', '安装期交付N私有config、日志路径、观察期限、响应上限和重定向次数');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!isAbsolute(settings.helperLogFile) || settings.helperLogFile === settings.configFile)
|
|
112
|
+
throw new Error('CONFIG_INVALID');
|
|
113
|
+
const file = await privateFile(settings.configFile);
|
|
114
|
+
let configStat;
|
|
115
|
+
try {
|
|
116
|
+
configStat = await file.stat();
|
|
117
|
+
cfg = JSON.parse(await file.readFile('utf8'));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await file.close();
|
|
121
|
+
}
|
|
122
|
+
if (normalize(settings.helperLogFile) !== settings.helperLogFile || normalize(settings.configFile) !== settings.configFile)
|
|
123
|
+
throw new Error('CONFIG_INVALID');
|
|
124
|
+
const canonical = async (p) => { try {
|
|
125
|
+
return await realpath(p);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code !== 'ENOENT')
|
|
129
|
+
throw error;
|
|
130
|
+
const parent = dirname(p);
|
|
131
|
+
if (parent === p)
|
|
132
|
+
throw error;
|
|
133
|
+
return join(await canonical(parent), basename(p));
|
|
134
|
+
} };
|
|
135
|
+
const logPath = await canonical(settings.helperLogFile);
|
|
136
|
+
if (logPath === await canonical(settings.configFile) || (cfg?.authKeyFile && logPath === await canonical(cfg.authKeyFile)) || (cfg?.host?.knownHostsFile && logPath === await canonical(cfg.host.knownHostsFile)))
|
|
137
|
+
throw new Error('CONFIG_INVALID');
|
|
138
|
+
for (const dir of [cfg?.stateDir, cfg?.runtimeDir]) {
|
|
139
|
+
if (typeof dir !== 'string' || !isAbsolute(dir))
|
|
140
|
+
throw new Error('CONFIG_INVALID');
|
|
141
|
+
const rel = relative(await canonical(dir), logPath);
|
|
142
|
+
if (rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)))
|
|
143
|
+
throw new Error('CONFIG_INVALID');
|
|
144
|
+
}
|
|
145
|
+
if (cfg?.apiVersion !== 1 || !isAbsolute(cfg.runtimeDir) || !cfg.host || !seconds(cfg.statusRefreshIntervalSeconds) || !seconds(cfg.dialTimeoutSeconds) || !integer(cfg.httpMaxHeaderBytes) || !integer(cfg.httpMaxResponseHeaderBytes))
|
|
146
|
+
throw new Error('CONFIG_INVALID');
|
|
147
|
+
if (executable.platform !== process.platform || executable.arch !== process.arch)
|
|
148
|
+
throw new Error('HELPER_PLATFORM_UNSUPPORTED');
|
|
149
|
+
await verifyExecutable(executable.path, executable.sha256);
|
|
150
|
+
const log = await privateFile(settings.helperLogFile, true);
|
|
151
|
+
try {
|
|
152
|
+
const st = await log.stat();
|
|
153
|
+
if (st.nlink !== 1)
|
|
154
|
+
throw new Error('CONFIG_INVALID');
|
|
155
|
+
if (st.dev === configStat.dev && st.ino === configStat.ino)
|
|
156
|
+
throw new Error('CONFIG_INVALID');
|
|
157
|
+
if (cfg.authKeyFile) {
|
|
158
|
+
try {
|
|
159
|
+
const key = await lstat(cfg.authKeyFile);
|
|
160
|
+
if (st.dev === key.dev && st.ino === key.ino)
|
|
161
|
+
throw new Error('CONFIG_INVALID');
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code !== 'ENOENT')
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
await log.close();
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
if (disposed) {
|
|
174
|
+
await log.close();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
child = spawn(executable.path, ['serve', '--config', settings.configFile], { stdio: ['ignore', 'pipe', log.fd], env: { ...process.env } });
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
await log.close();
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
// Install process handlers before yielding: spawn errors can arrive on nextTick.
|
|
185
|
+
const logClosed = log.close();
|
|
186
|
+
const processChild = child;
|
|
187
|
+
processChild.on('error', () => terminal(needs('HELPER_PROCESS_FAILED', '网络组件进程失败,根因未查明', '管理员检查helper诊断日志')));
|
|
188
|
+
const exited = () => {
|
|
189
|
+
processFailed = true;
|
|
190
|
+
childGone.abort();
|
|
191
|
+
if (poll)
|
|
192
|
+
clearTimeout(poll);
|
|
193
|
+
if (handshakeComplete)
|
|
194
|
+
terminal(startupNeed ?? needs('HELPER_EXITED', '网络组件已退出,根因未查明', '管理员查看网络组件日志;不自动重启或重交密钥'));
|
|
195
|
+
};
|
|
196
|
+
processChild.once('exit', exited);
|
|
197
|
+
const ready = new Promise((resolve, reject) => {
|
|
198
|
+
let data = Buffer.alloc(0), settled = false;
|
|
199
|
+
const timer = setTimeout(() => finish(new Error('HELPER_STARTUP_EXPIRED')), settings.startupObservationSeconds * 1000);
|
|
200
|
+
const aborted = () => finish(new Error('DISPOSED'));
|
|
201
|
+
const earlyExit = () => finish(new Error('HELPER_EXITED'));
|
|
202
|
+
const errored = () => finish(new Error('HELPER_START_FAILED'));
|
|
203
|
+
const read = (chunk) => {
|
|
204
|
+
data = Buffer.concat([data, chunk]);
|
|
205
|
+
if (data.length > settings.controlResponseMaxBytes) {
|
|
206
|
+
finish(new Error('CONTROL_RESPONSE_LIMIT'));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const nl = data.indexOf(10);
|
|
210
|
+
if (nl < 0)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
const frame = JSON.parse(data.subarray(0, nl).toString());
|
|
214
|
+
if (frame.apiVersion !== 1)
|
|
215
|
+
throw new Error();
|
|
216
|
+
if (frame.event === 'error') {
|
|
217
|
+
const e = frame.error;
|
|
218
|
+
if (!e || !['code', 'message', 'needs', 'actor'].every(k => typeof e[k] === 'string' && e[k]))
|
|
219
|
+
throw new Error();
|
|
220
|
+
startupReceipt = receiptFrame(frame.receipt);
|
|
221
|
+
startupNeed = { code: e.code, message: e.message, needs: e.needs, actor: e.actor, cause: null };
|
|
222
|
+
finish(new Error('HELPER_START_FAILED'));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (frame.event !== 'ready')
|
|
226
|
+
throw new Error();
|
|
227
|
+
if (!alive()) {
|
|
228
|
+
finish(new Error('HELPER_EXITED'));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
finish();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
finish(new Error('CONTROL_PROTOCOL_INVALID'));
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const finish = (error) => { if (settled)
|
|
238
|
+
return; settled = true; handshakeComplete = true; clearTimeout(timer); lifetime.signal.removeEventListener('abort', aborted); processChild.off('error', errored); processChild.off('close', earlyExit); processChild.stdout.off('data', read); if (error)
|
|
239
|
+
reject(error);
|
|
240
|
+
else
|
|
241
|
+
resolve(); };
|
|
242
|
+
processChild.once('error', errored);
|
|
243
|
+
processChild.once('close', earlyExit);
|
|
244
|
+
processChild.stdout.on('data', read);
|
|
245
|
+
lifetime.signal.addEventListener('abort', aborted, { once: true });
|
|
246
|
+
});
|
|
247
|
+
try {
|
|
248
|
+
await ready;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await logClosed;
|
|
252
|
+
}
|
|
253
|
+
processChild.stdout.resume();
|
|
254
|
+
if (!disposed)
|
|
255
|
+
await observe();
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (disposed)
|
|
259
|
+
return;
|
|
260
|
+
const code = error instanceof Error && /^(LOCAL_PERMISSION_DENIED|LOCAL_PATH_INVALID|HELPER_[A-Z_]+|CONTROL_[A-Z_]+|CONFIG_INVALID)$/.test(error.message) ? error.message : 'CONFIG_INVALID';
|
|
261
|
+
terminal(startupNeed ?? needs(code, '网络组件未能启动,根因未查明', '管理员检查安装配置、helper完整性和私有日志'));
|
|
262
|
+
const forced = await stopOwned();
|
|
263
|
+
if (forced && terminalNeed)
|
|
264
|
+
terminalNeed.cause = '自有helper未在shutdownObservationSeconds内退出,已SIGKILL清理';
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
const api = {
|
|
268
|
+
start, getState: snapshot,
|
|
269
|
+
onState(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
270
|
+
async connect(target, signal) {
|
|
271
|
+
await start();
|
|
272
|
+
if (disposed || signal.aborted)
|
|
273
|
+
return fail('DISPOSED', '网络操作已取消', '重新发起连接');
|
|
274
|
+
if (cfg && (target.host !== cfg.host.name || target.username !== cfg.host.employee || target.port !== cfg.host.sshPort || target.knownHostsFile !== cfg.host.knownHostsFile))
|
|
275
|
+
return fail('ENROLLMENT_MISMATCH', '开通目标与网络配置不一致', '管理员重新装配同一开通上下文');
|
|
276
|
+
if (state.phase === 'starting')
|
|
277
|
+
await new Promise(resolve => { const done = () => { if (state.phase === 'starting' && !signal.aborted && !disposed)
|
|
278
|
+
return; updates.delete(done); signal.removeEventListener('abort', done); resolve(); }; updates.add(done); signal.addEventListener('abort', done, { once: true }); done(); });
|
|
279
|
+
if (signal.aborted || disposed)
|
|
280
|
+
return fail('DISPOSED', '网络操作已取消', '重新发起连接');
|
|
281
|
+
if (state.phase !== 'online' || !alive())
|
|
282
|
+
return { ok: false, error: state.error ?? needs('NETWORK_NOT_READY', '远程主机:未连接', '等待网络在线') };
|
|
283
|
+
return ok({ proxyCommand: [executable.path, 'ssh-proxy', '--runtime-dir', cfg.runtimeDir].map(quote).join(' ') + ' %h %p' });
|
|
284
|
+
},
|
|
285
|
+
async openLoginProxy(signal) {
|
|
286
|
+
await start();
|
|
287
|
+
if (disposed || signal.aborted)
|
|
288
|
+
return fail('DISPOSED', '登录代理已取消', '重新发起登录');
|
|
289
|
+
if (state.phase !== 'online' || !alive())
|
|
290
|
+
return { ok: false, error: state.error ?? needs('NETWORK_NOT_READY', '远程主机:未连接', '等待网络在线') };
|
|
291
|
+
const owner = new AbortController();
|
|
292
|
+
const cancel = () => owner.abort();
|
|
293
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
294
|
+
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
|
295
|
+
childGone.signal.addEventListener('abort', cancel, { once: true });
|
|
296
|
+
let lease, diagnostic;
|
|
297
|
+
try {
|
|
298
|
+
const res = await request('POST', '/v1/login-proxy', owner.signal);
|
|
299
|
+
if (res.statusCode !== 201) {
|
|
300
|
+
const value = await jsonResponse(res, settings.controlResponseMaxBytes);
|
|
301
|
+
if (value?.apiVersion === 1 && value.error && ['code', 'message', 'needs', 'actor'].every(k => typeof value.error[k] === 'string'))
|
|
302
|
+
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 };
|
|
303
|
+
throw new Error('PROXY_UNAVAILABLE');
|
|
304
|
+
}
|
|
305
|
+
if (res.headers['content-type'] !== 'application/x-ndjson' || res.headers['cache-control'] !== 'no-store') {
|
|
306
|
+
res.destroy();
|
|
307
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
308
|
+
}
|
|
309
|
+
const frame = await firstFrame(res, settings.controlResponseMaxBytes);
|
|
310
|
+
const url = new URL(frame.url);
|
|
311
|
+
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]))
|
|
312
|
+
throw new Error('CONTROL_PROTOCOL_INVALID');
|
|
313
|
+
const credentials = { apiVersion: 1, id: frame.id, url: url.origin, username: frame.username, password: frame.password };
|
|
314
|
+
let released = false;
|
|
315
|
+
lease = { credentials, async release() { if (released)
|
|
316
|
+
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 = ''; } };
|
|
317
|
+
const current = lease;
|
|
318
|
+
owner.signal.addEventListener('abort', () => void current.release(), { once: true });
|
|
319
|
+
res.on('error', () => void current.release());
|
|
320
|
+
res.on('close', () => void current.release());
|
|
321
|
+
leases.add(current);
|
|
322
|
+
if (owner.signal.aborted)
|
|
323
|
+
await current.release();
|
|
324
|
+
if (owner.signal.aborted)
|
|
325
|
+
return fail('DISPOSED', '登录代理已取消', '重新发起登录');
|
|
326
|
+
return ok(current);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
owner.abort();
|
|
330
|
+
signal.removeEventListener('abort', cancel);
|
|
331
|
+
lifetime.signal.removeEventListener('abort', cancel);
|
|
332
|
+
childGone.signal.removeEventListener('abort', cancel);
|
|
333
|
+
return diagnostic ? { ok: false, error: diagnostic } : fail('PROXY_UNAVAILABLE', '登录代理未能建立,根因未查明', '查看主机网络与信任材料');
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
async download(source, signal) { const lease = await api.openLoginProxy(signal); if (!lease.ok)
|
|
337
|
+
return lease; return downloadThroughProxy(source, lease.value, signal, settings.downloadMaxRedirects, cfg.dialTimeoutSeconds, cfg.httpMaxResponseHeaderBytes); },
|
|
338
|
+
dispose() {
|
|
339
|
+
return disposing ??= (async () => {
|
|
340
|
+
disposed = true;
|
|
341
|
+
lifetime.abort();
|
|
342
|
+
if (poll)
|
|
343
|
+
clearTimeout(poll);
|
|
344
|
+
for (const update of updates)
|
|
345
|
+
update();
|
|
346
|
+
await starting;
|
|
347
|
+
await Promise.all([...leases].map(x => x.release()));
|
|
348
|
+
const forced = await stopOwned();
|
|
349
|
+
publish(forced ? { phase: 'blocked', error: needs('HELPER_SHUTDOWN_FORCED', '网络组件未在配置期限内退出,已终止自有进程', '管理员检查组件退出日志') } : { phase: 'stopped' });
|
|
350
|
+
listeners.clear();
|
|
351
|
+
updates.clear();
|
|
352
|
+
})();
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
return api;
|
|
356
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fail, ok } from "./api.js";
|
|
4
|
+
export function resolvePackagedHelper(moduleURL, platform = process.platform, arch = process.arch) {
|
|
5
|
+
const name = `@ai-workbench/net-helper-${platform}-${arch}`;
|
|
6
|
+
let expected;
|
|
7
|
+
try {
|
|
8
|
+
const root = JSON.parse(readFileSync(new URL('../package.json', moduleURL), 'utf8'));
|
|
9
|
+
expected = root.optionalDependencies?.[name];
|
|
10
|
+
if (expected === undefined)
|
|
11
|
+
return fail('HELPER_PLATFORM_UNSUPPORTED', `我需要支持 ${platform}-${arch} 的联网助手包。`, '当前提供 macOS arm64 和 x64 平台包,请管理员核对设备平台');
|
|
12
|
+
if (expected !== root.version)
|
|
13
|
+
throw new Error('平台助手依赖必须与主包版本精确一致');
|
|
14
|
+
const require = createRequire(moduleURL), metadata = JSON.parse(readFileSync(require.resolve(name + '/package.json'), 'utf8'));
|
|
15
|
+
if (metadata.name !== name || metadata.version !== expected || JSON.stringify(metadata.os) !== JSON.stringify([platform]) || JSON.stringify(metadata.cpu) !== JSON.stringify([arch]))
|
|
16
|
+
throw new Error('平台助手包的名称、版本或平台不匹配');
|
|
17
|
+
const manifest = JSON.parse(readFileSync(require.resolve(name + '/helper-manifest.json'), 'utf8'));
|
|
18
|
+
if (manifest.platform !== platform || manifest.arch !== arch || typeof manifest.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(manifest.sha256))
|
|
19
|
+
throw new Error('平台助手 manifest 的架构或摘要无效');
|
|
20
|
+
return ok({ path: require.resolve(name + '/bin/aw-net'), sha256: manifest.sha256, platform, arch });
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
return { ok: false, error: { code: 'HELPER_NOT_PACKAGED', message: '我需要完整的当前平台联网助手包。', needs: `请管理员安装 ${name}${expected ? '@' + expected : '(与主包同版本)'};不要切换架构或改用旧助手`, actor: 'network', cause: cause instanceof Error ? cause.message : String(cause) } };
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/private.d.ts
ADDED
package/lib/private.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, dirname } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
export async function privateFile(path, write = false) {
|
|
6
|
+
if (!isAbsolute(path))
|
|
7
|
+
throw new Error('LOCAL_PATH_INVALID');
|
|
8
|
+
await privateDirectory(dirname(path));
|
|
9
|
+
const f = await open(path, (write ? constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND : constants.O_RDONLY) | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
10
|
+
try {
|
|
11
|
+
const st = await f.stat();
|
|
12
|
+
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600)
|
|
13
|
+
throw new Error('LOCAL_PERMISSION_DENIED');
|
|
14
|
+
return f;
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
await f.close();
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function privateSocket(path) {
|
|
22
|
+
await privateDirectory(dirname(path));
|
|
23
|
+
const st = await lstat(path);
|
|
24
|
+
if (!st.isSocket() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600)
|
|
25
|
+
throw new Error('LOCAL_PERMISSION_DENIED');
|
|
26
|
+
}
|
|
27
|
+
async function privateDirectory(path) {
|
|
28
|
+
const st = await lstat(path);
|
|
29
|
+
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700)
|
|
30
|
+
throw new Error('LOCAL_PERMISSION_DENIED');
|
|
31
|
+
}
|
|
32
|
+
export async function verifyExecutable(path, sha256) {
|
|
33
|
+
const f = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
34
|
+
try {
|
|
35
|
+
const st = await f.stat();
|
|
36
|
+
if (!st.isFile() || (st.mode & 0o022) !== 0 || (st.mode & 0o100) === 0 || !/^[a-f0-9]{64}$/.test(sha256))
|
|
37
|
+
throw new Error('HELPER_INTEGRITY_INVALID');
|
|
38
|
+
const hash = createHash('sha256');
|
|
39
|
+
for await (const chunk of f.createReadStream({ autoClose: false }))
|
|
40
|
+
hash.update(chunk);
|
|
41
|
+
if (hash.digest('hex') !== sha256)
|
|
42
|
+
throw new Error('HELPER_INTEGRITY_INVALID');
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await f.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-workbench/network",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.13.1"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./api": {
|
|
14
|
+
"types": "./lib/api.d.ts",
|
|
15
|
+
"default": "./lib/api.js"
|
|
16
|
+
},
|
|
17
|
+
"./cordis": {
|
|
18
|
+
"types": "./lib/cordis.d.ts",
|
|
19
|
+
"default": "./lib/cordis.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json",
|
|
22
|
+
"./locale/*.json": "./locale/*.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib",
|
|
26
|
+
"NOTICE.md",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"locale"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test tests/*.test.ts",
|
|
37
|
+
"check": "tsc --noEmit",
|
|
38
|
+
"build": "node scripts/build-platforms.mjs && tsc"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
42
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
46
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
47
|
+
"@types/node": "24.0.0",
|
|
48
|
+
"typescript": "5.9.3"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"dsh",
|
|
55
|
+
"dsh-plugin",
|
|
56
|
+
"deepseek-harness"
|
|
57
|
+
],
|
|
58
|
+
"optionalDependencies": {
|
|
59
|
+
"@ai-workbench/net-helper-darwin-arm64": "0.4.1",
|
|
60
|
+
"@ai-workbench/net-helper-darwin-x64": "0.4.1"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://github.com/yzsnstotz/ai-workbench-network.git"
|
|
65
|
+
}
|
|
66
|
+
}
|