@syrkos/filaxis 0.1.0-alpha.10
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/AGENTS.md +46 -0
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +61 -0
- package/bin/filaxis.ts +112 -0
- package/dist/agent-toolkit.d.ts +99 -0
- package/dist/dialect.d.ts +31 -0
- package/dist/driver.d.ts +130 -0
- package/dist/events.d.ts +26 -0
- package/dist/factory.d.ts +16 -0
- package/dist/file-edit.d.ts +43 -0
- package/dist/file-transfer.d.ts +44 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +4200 -0
- package/dist/layer-discovery.d.ts +30 -0
- package/dist/layer-env.d.ts +95 -0
- package/dist/local-driver.d.ts +47 -0
- package/dist/local-session-manager.d.ts +98 -0
- package/dist/logger.d.ts +12 -0
- package/dist/remote-session-manager.d.ts +64 -0
- package/dist/runtime-stack.d.ts +52 -0
- package/dist/session-buffer.d.ts +73 -0
- package/dist/session-types.d.ts +74 -0
- package/dist/shell-discovery.d.ts +4 -0
- package/dist/ssh-helpers.d.ts +88 -0
- package/dist/ssh-pty-driver.d.ts +74 -0
- package/dist/stream-session-manager.d.ts +83 -0
- package/dist/stream-window.d.ts +124 -0
- package/dist/terminal/command-executer.d.ts +73 -0
- package/dist/terminal/output-buffer.d.ts +49 -0
- package/dist/terminal/session-launcher.d.ts +28 -0
- package/dist/terminal/session-registry.d.ts +29 -0
- package/dist/terminal/tmux-invoker.d.ts +8 -0
- package/dist/terminal/types.d.ts +56 -0
- package/dist/terminal-manager.d.ts +30 -0
- package/dist/tmux-driver.d.ts +49 -0
- package/dist/vt100-screen.d.ts +46 -0
- package/docs/DEVELOPMENT.md +38 -0
- package/docs/KNOWN-ISSUES.md +35 -0
- package/docs/implementation-notes.md +57 -0
- package/docs/skills/cross-host-diagnosis.md +21 -0
- package/docs/skills/file-sync.md +22 -0
- package/docs/skills/longrun-observe.md +20 -0
- package/docs/skills/stream-window.md +28 -0
- package/docs//345/256/214/346/225/264/345/267/241/346/243/200/346/212/245/345/221/212.md +141 -0
- package/docs//346/265/213/350/257/2252.md +112 -0
- package/docs//346/265/213/350/257/2253.md +73 -0
- package/docs//346/265/213/350/257/2254.md +138 -0
- package/docs//346/265/213/350/257/2255.md +124 -0
- package/docs//347/254/254/344/272/214/344/273/275/345/267/241/346/243/200/346/212/245/345/221/212.md +103 -0
- package/examples/agent-tools.ts +74 -0
- package/examples/cross-host-diagnosis.ts +49 -0
- package/examples/longrun-observe.ts +46 -0
- package/package.json +47 -0
- package/src/agent-toolkit.ts +247 -0
- package/src/dialect.ts +100 -0
- package/src/driver.ts +142 -0
- package/src/events.ts +96 -0
- package/src/factory.ts +51 -0
- package/src/file-edit.ts +195 -0
- package/src/file-transfer.ts +196 -0
- package/src/index.ts +38 -0
- package/src/layer-discovery.ts +79 -0
- package/src/layer-env.ts +331 -0
- package/src/local-driver.ts +325 -0
- package/src/local-session-manager.ts +397 -0
- package/src/logger.ts +19 -0
- package/src/remote-session-manager.ts +286 -0
- package/src/runtime-stack.ts +128 -0
- package/src/session-buffer.ts +183 -0
- package/src/session-types.ts +78 -0
- package/src/shell-discovery.ts +52 -0
- package/src/ssh-helpers.ts +225 -0
- package/src/ssh-pty-driver.ts +655 -0
- package/src/stream-session-manager.ts +296 -0
- package/src/stream-window.ts +309 -0
- package/src/terminal/command-executer.ts +374 -0
- package/src/terminal/output-buffer.ts +126 -0
- package/src/terminal/session-launcher.ts +146 -0
- package/src/terminal/session-registry.ts +98 -0
- package/src/terminal/tmux-invoker.ts +65 -0
- package/src/terminal/types.ts +85 -0
- package/src/terminal-manager.ts +83 -0
- package/src/tmux-driver.ts +167 -0
- package/src/vt100-screen.ts +166 -0
package/src/layer-env.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layer-env.ts — 层环境规格与自发现(FEAT-16 / AC-17)
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/layer-env.ts` Phase A 段
|
|
5
|
+
* (源锚 8977ff3),OSS 化:
|
|
6
|
+
* - yaml 依赖剥 → JSON 持久化(零 npm 依赖红线;语义等价,格式记实施偏差)
|
|
7
|
+
* - scrubber 剥 → 探测只落蒸馏值(版本/有无/型号),全量输出不落盘(hook 面=exec 回调在消费方)
|
|
8
|
+
* - 观察投影/行为契约(源 Phase B/C 轴)不随——属消费方策略层
|
|
9
|
+
*
|
|
10
|
+
* 语义(生产锤炼):两段式探测(快亚秒 + 慢包导入);PTY 回显防御(值含 $( 一律
|
|
11
|
+
* 丢弃);探测真实性红线(nvidia-smi 失败文案不得记为 GPU 型号);环境身份键
|
|
12
|
+
* hops 不进键(同落点经不同路径=同一环境)。
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { rename, stat, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import type { RuntimeDescriptor } from "./driver.js";
|
|
18
|
+
import { enterStepsOf, type SshConnParams } from "./ssh-helpers.js";
|
|
19
|
+
|
|
20
|
+
// ════════════════ 数据模型 ════════════════
|
|
21
|
+
|
|
22
|
+
/** 层环境规格(蒸馏快照——版本号/有无/型号,非全量 dump) */
|
|
23
|
+
export interface LayerEnvironmentSpec {
|
|
24
|
+
os?: string;
|
|
25
|
+
kernel_arch?: string;
|
|
26
|
+
python?: string;
|
|
27
|
+
node?: string;
|
|
28
|
+
bun?: string;
|
|
29
|
+
java?: string;
|
|
30
|
+
cuda?: string;
|
|
31
|
+
gpu?: { model: string; vram_mb?: number };
|
|
32
|
+
docker?: boolean;
|
|
33
|
+
/** 关键包(present-only,"torch@2.4.0" 形态;匹配按 @ 前名) */
|
|
34
|
+
packages?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SshHopLite {
|
|
38
|
+
host: string;
|
|
39
|
+
conn?: SshConnParams;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 重进路径投影(agent 据此零试错重进该层;环境身份不含 hops,重进路径含) */
|
|
43
|
+
export interface LayerConnectionInfo {
|
|
44
|
+
driver: string;
|
|
45
|
+
target?: string;
|
|
46
|
+
conn?: SshConnParams;
|
|
47
|
+
hops?: SshHopLite[];
|
|
48
|
+
enters?: Array<{ cmd: string; label?: string }>;
|
|
49
|
+
launch_cmd?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 单层条目 */
|
|
53
|
+
export interface LayerEnvEntry {
|
|
54
|
+
connection: LayerConnectionInfo;
|
|
55
|
+
environment: LayerEnvironmentSpec;
|
|
56
|
+
discovered_at: string;
|
|
57
|
+
/** 连接拓扑+环境快照 hash(漂移检测锚点:命中不一致 → stale 供重探,不自动覆盖) */
|
|
58
|
+
fingerprint: string;
|
|
59
|
+
/** 探测部分失败的说明(诚实标注,不假装完整) */
|
|
60
|
+
probe_note?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LayerEnvConfigFile {
|
|
64
|
+
layers: Record<string, LayerEnvEntry>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ════════════════ 环境身份键 ════════════════
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 环境身份键(两键两职责之一:driverKey 管连接复用,本键管规格归属):
|
|
71
|
+
* 落点[+端口]/enter 标签链——hops 不进键(同落点经不同路径=同一环境规格)。
|
|
72
|
+
*/
|
|
73
|
+
export function layerEnvKey(desc: RuntimeDescriptor): string {
|
|
74
|
+
if (desc.driver === "local") return "local";
|
|
75
|
+
const c = (desc.config ?? {}) as Record<string, unknown>;
|
|
76
|
+
if (c.pty === true) {
|
|
77
|
+
const conn = c.connParams as SshConnParams | undefined;
|
|
78
|
+
const hostPart = `${desc.target ?? "?"}${conn?.port ? `:${conn.port}` : ""}`;
|
|
79
|
+
const enters = enterStepsOf(c).map((s) => s.label ?? slugify(s.cmd));
|
|
80
|
+
return [hostPart, ...enters].join("/");
|
|
81
|
+
}
|
|
82
|
+
return `${desc.driver}:${desc.target ?? "default"}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function slugify(s: string): string {
|
|
86
|
+
return s.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40) || "enter";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ════════════════ 两段式探测命令 ════════════════
|
|
90
|
+
|
|
91
|
+
/** stage 1(快:亚秒级)——OS/架构/语言运行时/GPU/CUDA/docker;输出行 `fx_key=value` */
|
|
92
|
+
export function buildProbeStage1(): string {
|
|
93
|
+
return [
|
|
94
|
+
// 引号原样直书——'\\"' 会发出两字符 cut 分隔符(vm-homelab PTY 实证)
|
|
95
|
+
// os-release 缺席时的 fallback 链(报告 #4:群晖 DSM 用 /etc.defaults/VERSION)
|
|
96
|
+
`echo "fx_os=$(grep PRETTY_NAME /etc/os-release 2>/dev/null | head -1 | cut -d'"' -f2)"`,
|
|
97
|
+
`echo "fx_os_dsm=$(grep -E '^productversion=' /etc.defaults/VERSION 2>/dev/null | sed 's/productversion=/Synology DSM v/;s/"//g')"`,
|
|
98
|
+
`echo "fx_kernel_arch=$(uname -s -m 2>/dev/null)"`,
|
|
99
|
+
`echo "fx_python=$(if command -v python3 >/dev/null 2>&1; then python3 --version 2>&1; elif command -v python >/dev/null 2>&1; then python --version 2>&1; fi | head -1)"`,
|
|
100
|
+
`echo "fx_node=$(node --version 2>&1 | head -1)"`,
|
|
101
|
+
`echo "fx_bun=$(bun --version 2>&1 | head -1)"`,
|
|
102
|
+
`echo "fx_java=$(java -version 2>&1 | head -1)"`,
|
|
103
|
+
`echo "fx_gpu=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | head -1)"`,
|
|
104
|
+
`echo "fx_cuda=$(nvidia-smi 2>/dev/null | grep -o 'CUDA Version: [0-9.][0-9.]*' | head -1 | grep -o '[0-9][0-9.]*')"`,
|
|
105
|
+
`echo "fx_docker=$(command -v docker >/dev/null 2>&1 && echo yes || echo no)"`,
|
|
106
|
+
].join("; ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** stage 2(慢:python 冷导入 10s+)——关键包版本;有 GNU timeout 逐项 15s 兜底 */
|
|
110
|
+
export function buildProbeStage2(): string {
|
|
111
|
+
const py = `command -v python3 >/dev/null 2>&1 && echo python3 || echo python`;
|
|
112
|
+
const parts = [
|
|
113
|
+
`__FX_PY=$(${py}); __FX_TO=""; command -v timeout >/dev/null 2>&1 && __FX_TO="timeout 15"`,
|
|
114
|
+
];
|
|
115
|
+
for (const pkg of PROBE_PACKAGES) {
|
|
116
|
+
if (pkg.kind === "python") {
|
|
117
|
+
parts.push(`echo "fx_pkg_${pkg.key}=$($__FX_TO "$__FX_PY" -c '${pkg.probe}' 2>/dev/null)"`);
|
|
118
|
+
} else {
|
|
119
|
+
parts.push(`echo "fx_tool_${pkg.key}=$($__FX_TO ${pkg.probe} 2>/dev/null | head -1)"`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return parts.join("; ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 关键包探测清单(扩展在此加行) */
|
|
126
|
+
export const PROBE_PACKAGES: Array<{ key: string; kind: "python" | "tool"; probe: string; matchNames: string[] }> = [
|
|
127
|
+
{ key: "torch", kind: "python", probe: "import torch;print(torch.__version__)", matchNames: ["torch", "pytorch"] },
|
|
128
|
+
{ key: "transformers", kind: "python", probe: "import transformers;print(transformers.__version__)", matchNames: ["transformers", "huggingface"] },
|
|
129
|
+
{ key: "onnxruntime", kind: "python", probe: "import onnxruntime;print(onnxruntime.__version__)", matchNames: ["onnxruntime", "onnx"] },
|
|
130
|
+
{ key: "numpy", kind: "python", probe: "import numpy;print(numpy.__version__)", matchNames: ["numpy"] },
|
|
131
|
+
{ key: "ffmpeg", kind: "tool", probe: "ffmpeg -version", matchNames: ["ffmpeg"] },
|
|
132
|
+
{ key: "git", kind: "tool", probe: "git --version", matchNames: ["git"] },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 探测输出解析(纯函数):`fx_key=value` 行 → 蒸馏规格。
|
|
137
|
+
* PTY 回显防御:回显行是未展开原文(值含 `$(`)——一律丢弃。
|
|
138
|
+
* 真实性红线:nvidia-smi 失败文案(驱动不通信)不得记为 GPU 型号。
|
|
139
|
+
*/
|
|
140
|
+
export function parseProbeOutput(stdout: string): LayerEnvironmentSpec {
|
|
141
|
+
const spec: LayerEnvironmentSpec = {};
|
|
142
|
+
const pkgs: string[] = [];
|
|
143
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
144
|
+
const m = rawLine.trim().match(/^fx_([a-z0-9_]+)=(.*)$/);
|
|
145
|
+
if (!m) continue;
|
|
146
|
+
const val = m[2]!.trim();
|
|
147
|
+
if (!val || val.includes("$(") || val.includes("__FX")) continue; // 回显防御
|
|
148
|
+
switch (m[1]) {
|
|
149
|
+
case "os": spec.os = val || undefined; break;
|
|
150
|
+
case "os_dsm": if (val) spec.os = val; break; // DSM fallback 覆盖空 os
|
|
151
|
+
case "kernel_arch": spec.kernel_arch = val; break;
|
|
152
|
+
case "python": if (/Python \d/.test(val)) spec.python = val; break;
|
|
153
|
+
case "node": if (/^v?\d/.test(val)) spec.node = val.replace(/^v/, ""); break;
|
|
154
|
+
case "bun": if (/^\d+\./.test(val)) spec.bun = val; break;
|
|
155
|
+
case "java": if (/version/i.test(val)) spec.java = val; break;
|
|
156
|
+
case "gpu": {
|
|
157
|
+
const gm = val.match(/^(.+?),\s*(\d+)\s*MiB$/);
|
|
158
|
+
if (gm) {
|
|
159
|
+
spec.gpu = { model: gm[1]!.trim(), vram_mb: parseInt(gm[2]!, 10) };
|
|
160
|
+
} else if (val && looksLikeGpuModel(val)) {
|
|
161
|
+
spec.gpu = { model: val };
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "cuda": if (/^\d+(\.\d+)*$/.test(val)) spec.cuda = val; break;
|
|
166
|
+
case "docker": spec.docker = val === "yes"; break;
|
|
167
|
+
default: {
|
|
168
|
+
const pm = m[1]!.match(/^(?:pkg|tool)_(.+)$/);
|
|
169
|
+
if (pm) {
|
|
170
|
+
const key = pm[1]!;
|
|
171
|
+
const def = PROBE_PACKAGES.find((p) => p.key === key);
|
|
172
|
+
const version = extractVersion(val);
|
|
173
|
+
const name = def?.key ?? key;
|
|
174
|
+
pkgs.push(version ? `${name}@${version}` : name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!spec.os && spec.kernel_arch) spec.os = spec.kernel_arch.split(/\s+/)[0];
|
|
180
|
+
if (pkgs.length > 0) spec.packages = [...new Set(pkgs)];
|
|
181
|
+
return spec;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractVersion(raw: string): string | undefined {
|
|
185
|
+
if (!raw) return undefined;
|
|
186
|
+
const m = raw.match(/(\d+(\.\d+)+[a-z0-9._-]*)/i) || raw.match(/version\s+(\S+)/i) || raw.match(/(\d+\.\d+)/);
|
|
187
|
+
return m?.[1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 裸 GPU 型号形态收窄:短、无句子气、非报错文案(生产实证:失败文案曾被记为型号) */
|
|
191
|
+
function looksLikeGpuModel(val: string): boolean {
|
|
192
|
+
if (val.length > 60) return false;
|
|
193
|
+
if (/failed|error|couldn|can't|unable|no device|not found|driver/i.test(val)) return false;
|
|
194
|
+
if (/[.!?]$/.test(val)) return false;
|
|
195
|
+
return /^[A-Za-z0-9 ()+.-]+$/.test(val);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ════════════════ 持久化(JSON + 原子写 + mtime 缓存) ════════════════
|
|
199
|
+
|
|
200
|
+
export class LayerEnvStore {
|
|
201
|
+
private cache: { mtimeMs: number; cfg: LayerEnvConfigFile } | null = null;
|
|
202
|
+
|
|
203
|
+
constructor(public readonly path: string) {}
|
|
204
|
+
|
|
205
|
+
async load(force = false): Promise<LayerEnvConfigFile> {
|
|
206
|
+
let mtimeMs = 0;
|
|
207
|
+
try {
|
|
208
|
+
mtimeMs = (await stat(this.path)).mtimeMs;
|
|
209
|
+
} catch {
|
|
210
|
+
const cfg: LayerEnvConfigFile = { layers: {} };
|
|
211
|
+
this.cache = { mtimeMs: 0, cfg };
|
|
212
|
+
return cfg;
|
|
213
|
+
}
|
|
214
|
+
if (!force && this.cache?.mtimeMs === mtimeMs) return this.cache.cfg;
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(await readFile(this.path, "utf-8")) as LayerEnvConfigFile;
|
|
217
|
+
const cfg: LayerEnvConfigFile = parsed?.layers && typeof parsed.layers === "object"
|
|
218
|
+
? { layers: parsed.layers }
|
|
219
|
+
: { layers: {} };
|
|
220
|
+
this.cache = { mtimeMs, cfg };
|
|
221
|
+
return cfg;
|
|
222
|
+
} catch {
|
|
223
|
+
return this.cache?.cfg ?? { layers: {} }; // 坏文件:不崩,返回空
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 原子写:tmp + rename */
|
|
228
|
+
async save(cfg: LayerEnvConfigFile): Promise<void> {
|
|
229
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
230
|
+
const tmp = `${this.path}.tmp`;
|
|
231
|
+
await writeFile(tmp, JSON.stringify(cfg, null, 2), "utf-8");
|
|
232
|
+
await rename(tmp, this.path);
|
|
233
|
+
this.cache = null; // 失效缓存(下次 load 重 stat)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 写入/更新一层规格;漂移检测:指纹不一致标 stale 不自动覆盖(显式 force 才覆写) */
|
|
237
|
+
async upsert(key: string, entry: LayerEnvEntry, force = false): Promise<{ stale: boolean }> {
|
|
238
|
+
const cfg = await this.load();
|
|
239
|
+
const existing = cfg.layers[key];
|
|
240
|
+
if (existing && !force && existing.fingerprint !== entry.fingerprint) {
|
|
241
|
+
return { stale: true }; // 环境可能已漂移——不覆盖,供重探决策
|
|
242
|
+
}
|
|
243
|
+
cfg.layers[key] = entry;
|
|
244
|
+
await this.save(cfg);
|
|
245
|
+
return { stale: false };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 连接投影(descriptor → LayerConnectionInfo;重进路径含 hops) */
|
|
250
|
+
export function projectConnection(desc: RuntimeDescriptor): LayerConnectionInfo {
|
|
251
|
+
const c = (desc.config ?? {}) as Record<string, unknown>;
|
|
252
|
+
const hops: SshHopLite[] = Array.isArray(c.sshHops)
|
|
253
|
+
? (c.sshHops as SshHopLite[]).map((h) => (typeof h === "string" ? { host: h } : h))
|
|
254
|
+
: [];
|
|
255
|
+
return {
|
|
256
|
+
driver: desc.driver,
|
|
257
|
+
target: desc.target,
|
|
258
|
+
conn: c.connParams as SshConnParams | undefined,
|
|
259
|
+
hops: hops.length ? hops : undefined,
|
|
260
|
+
enters: enterStepsOf(c).map((s) => ({ cmd: s.cmd, label: s.label })),
|
|
261
|
+
launch_cmd: typeof c.launchCmd === "string" ? c.launchCmd : undefined,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 指纹:连接投影 + 环境快照 hash */
|
|
266
|
+
export function layerFingerprint(conn: LayerConnectionInfo, spec: LayerEnvironmentSpec): string {
|
|
267
|
+
return "sha256:" + createHash("sha256")
|
|
268
|
+
.update(JSON.stringify({ conn: { driver: conn.driver, target: conn.target, enters: conn.enters }, spec }))
|
|
269
|
+
.digest("hex").slice(0, 24);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ════════════════ 匹配(needs → 层键命中) ════════════════
|
|
273
|
+
|
|
274
|
+
export interface LayerMatchResult {
|
|
275
|
+
key: string;
|
|
276
|
+
entry: LayerEnvEntry;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 按需求匹配层:needs 形如 "gpu" / "torch"(包名) / "python>=3.12"(版本比较)。
|
|
281
|
+
* 全部 need 命中才入选;结果不排序(调用方按需排)。
|
|
282
|
+
*/
|
|
283
|
+
export function matchLayers(cfg: LayerEnvConfigFile, needs: string[]): LayerMatchResult[] {
|
|
284
|
+
const out: LayerMatchResult[] = [];
|
|
285
|
+
for (const [key, entry] of Object.entries(cfg.layers)) {
|
|
286
|
+
const spec = entry.environment;
|
|
287
|
+
const ok = needs.every((need) => {
|
|
288
|
+
const vm = need.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(>=|<=|>|<|=)?\s*([\d.]+)?$/);
|
|
289
|
+
if (!vm) return false;
|
|
290
|
+
const name = vm[1]!.toLowerCase();
|
|
291
|
+
const op = vm[2];
|
|
292
|
+
const want = vm[3];
|
|
293
|
+
if (name === "gpu") return spec.gpu != null;
|
|
294
|
+
if (name === "docker") return spec.docker === true;
|
|
295
|
+
if (name === "cuda") return spec.cuda != null;
|
|
296
|
+
if (name === "python" || name === "node" || name === "bun" || name === "java") {
|
|
297
|
+
const have = spec[name];
|
|
298
|
+
if (!have) return false;
|
|
299
|
+
if (!op || !want) return true;
|
|
300
|
+
return compareVersion(extractVersion(have) ?? "0", want, op);
|
|
301
|
+
}
|
|
302
|
+
// 包名匹配(matchNames 别名 + @ 版本)
|
|
303
|
+
const pkg = (spec.packages ?? []).find((p) => {
|
|
304
|
+
const base = p.split("@")[0]!;
|
|
305
|
+
const def = PROBE_PACKAGES.find((d) => base === d.key || d.matchNames.includes(base));
|
|
306
|
+
return base === name || def?.matchNames.includes(name);
|
|
307
|
+
});
|
|
308
|
+
if (!pkg) return false;
|
|
309
|
+
if (!op || !want) return true;
|
|
310
|
+
return compareVersion(pkg.split("@")[1] ?? "0", want, op);
|
|
311
|
+
});
|
|
312
|
+
if (ok) out.push({ key, entry });
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function compareVersion(a: string, b: string, op: string): boolean {
|
|
318
|
+
const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
|
|
319
|
+
const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
|
|
320
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
321
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
322
|
+
if (d !== 0) {
|
|
323
|
+
if (op === ">=") return d > 0;
|
|
324
|
+
if (op === "<=") return d < 0;
|
|
325
|
+
if (op === ">") return d > 0;
|
|
326
|
+
if (op === "<") return d < 0;
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return op === ">=" || op === "<=" || op === "="; // 相等
|
|
331
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-driver.ts — 本地 Transport(进程内执行 + 原生 fs + 会话挂接)
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/local-driver.ts`
|
|
5
|
+
* (源锚点:Aethelum-Nexus dev @ 8977ff3),按 REQ-0001 OSS 化改造:
|
|
6
|
+
* - 剥 SubstrateSecurity(执行网/沙箱属消费方策略)
|
|
7
|
+
* - exec 改造(FEAT-03):stdout/stderr 分离返回(源版混流 resolve);
|
|
8
|
+
* effectiveUser 经 nonce marker 尾缀实测(子 shell 包裹 + $? 变量锁——
|
|
9
|
+
* 用户命令内写 exit 也不丢退出码与 marker,见 docs/implementation-notes.md 锦囊 3);
|
|
10
|
+
* channel:"pty" 显式拒绝(local 无 PTY 层——pty 通道语义属远端交互 Transport)
|
|
11
|
+
* - exec 审计留痕保留(status/log 落盘 tempDir——「执行有迹」)
|
|
12
|
+
* - shell 可配(缺省 bash;Windows 需 Git Bash/WSL——平台差异显式声明)
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import { createWriteStream } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import * as crypto from "node:crypto";
|
|
20
|
+
import type {
|
|
21
|
+
RuntimeDriver,
|
|
22
|
+
FileSystemDriver,
|
|
23
|
+
FileEntry,
|
|
24
|
+
ExecOptions,
|
|
25
|
+
ExecResult,
|
|
26
|
+
RuntimeDescriptor,
|
|
27
|
+
} from "./driver.js";
|
|
28
|
+
import {
|
|
29
|
+
LocalSessionManager,
|
|
30
|
+
} from "./local-session-manager.js";
|
|
31
|
+
import type {
|
|
32
|
+
SessionStartOptions,
|
|
33
|
+
SessionHandleInfo,
|
|
34
|
+
WatchOptions,
|
|
35
|
+
ReadLogOptions,
|
|
36
|
+
} from "./session-types.js";
|
|
37
|
+
import type { FilaxisLogger } from "./logger.js";
|
|
38
|
+
import { noopLogger } from "./logger.js";
|
|
39
|
+
import { resolveLocalShell } from "./shell-discovery.js";
|
|
40
|
+
|
|
41
|
+
export interface LocalDriverOptions {
|
|
42
|
+
/** 缺省工作目录 */
|
|
43
|
+
cwd?: string;
|
|
44
|
+
/** exec 状态/日志目录(缺省 os.tmpdir()/filaxis-exec) */
|
|
45
|
+
execDir?: string;
|
|
46
|
+
/** 执行 shell(缺省 bash) */
|
|
47
|
+
shell?: string;
|
|
48
|
+
logger?: FilaxisLogger;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 本地 Transport:进程内执行 + 原生 fs */
|
|
52
|
+
export class LocalRuntimeDriver implements RuntimeDriver {
|
|
53
|
+
descriptor: RuntimeDescriptor = { driver: "local", target: "localhost" };
|
|
54
|
+
fs: FileSystemDriver;
|
|
55
|
+
|
|
56
|
+
/** 会话式 exec 引擎(长跑 + 双文件落盘 + 热窗口 + watch) */
|
|
57
|
+
readonly sessions: LocalSessionManager;
|
|
58
|
+
|
|
59
|
+
private readonly defaultCwd?: string;
|
|
60
|
+
private readonly execDir: string;
|
|
61
|
+
private readonly shell: string;
|
|
62
|
+
private readonly log: FilaxisLogger;
|
|
63
|
+
|
|
64
|
+
constructor(opts: LocalDriverOptions = {}) {
|
|
65
|
+
this.defaultCwd = opts.cwd;
|
|
66
|
+
this.execDir = opts.execDir ?? path.join(tmpdir(), "filaxis-exec");
|
|
67
|
+
this.log = opts.logger ?? noopLogger;
|
|
68
|
+
this.shell = resolveLocalShell(opts.shell, this.log);
|
|
69
|
+
this.fs = new LocalFileSystem();
|
|
70
|
+
this.sessions = new LocalSessionManager({
|
|
71
|
+
logDir: path.join(this.execDir, "sessions"),
|
|
72
|
+
shell: this.shell,
|
|
73
|
+
logger: this.log,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 从 descriptor 构建(descriptor.cwd 初始化缺省工作目录) */
|
|
78
|
+
static fromDescriptor(descriptor: RuntimeDescriptor, fallbackCwd?: string): LocalRuntimeDriver {
|
|
79
|
+
const drv = new LocalRuntimeDriver({ cwd: descriptor.cwd ?? fallbackCwd });
|
|
80
|
+
(drv as { descriptor: RuntimeDescriptor }).descriptor = descriptor;
|
|
81
|
+
return drv;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async init() {
|
|
85
|
+
// No-op for local
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async destroy() {
|
|
89
|
+
// 会话引擎句柄保留 TTL 自回收;此处无强制清理面(processMgr 属后续批次)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getCwd(): Promise<string> {
|
|
93
|
+
return this.defaultCwd || process.cwd();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getEnv(key: string): Promise<string | undefined> {
|
|
97
|
+
return process.env[key];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 会话式 exec(委托 LocalSessionManager)──
|
|
101
|
+
async execSession(command: string, opts?: SessionStartOptions): Promise<SessionHandleInfo> {
|
|
102
|
+
return this.sessions.start(command, opts);
|
|
103
|
+
}
|
|
104
|
+
async tailSession(sessionId: string, lines = 50): Promise<string[]> {
|
|
105
|
+
return this.sessions.tail(sessionId, lines);
|
|
106
|
+
}
|
|
107
|
+
async readSessionLog(sessionId: string, opts?: ReadLogOptions): Promise<string> {
|
|
108
|
+
return this.sessions.readLog(sessionId, opts);
|
|
109
|
+
}
|
|
110
|
+
watchSession(sessionId: string, opts: WatchOptions): { cancel(): void } {
|
|
111
|
+
return this.sessions.watch(sessionId, opts);
|
|
112
|
+
}
|
|
113
|
+
async stopSession(sessionId: string): Promise<{ exitCode?: number | null }> {
|
|
114
|
+
return this.sessions.stop(sessionId);
|
|
115
|
+
}
|
|
116
|
+
listSessions(): Array<{ sessionId: string; command: string; running: boolean; startedAt: number }> {
|
|
117
|
+
return this.sessions.list();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
|
|
121
|
+
if (options?.channel === "pty") {
|
|
122
|
+
throw new Error('local Transport 不支持 channel:"pty"(PTY 交互语义属远端 Transport;本地 exec 通道天然分离且恒可用)');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await fs.mkdir(this.execDir, { recursive: true });
|
|
126
|
+
const taskId = crypto.randomBytes(8).toString("hex");
|
|
127
|
+
const logPath = path.join(this.execDir, `${taskId}.log`);
|
|
128
|
+
const statusPath = path.join(this.execDir, `${taskId}.status.json`);
|
|
129
|
+
|
|
130
|
+
// FEAT-03 marker:子 shell 包裹业务命令,$? 变量锁 + nonce 防撞——
|
|
131
|
+
// 用户命令内写 exit 42 也不丢退出码与 effectiveUser
|
|
132
|
+
const nonce = crypto.randomBytes(6).toString("hex");
|
|
133
|
+
const marker = `__FILAXIS_META_${nonce}__`;
|
|
134
|
+
const wrapped =
|
|
135
|
+
`( ${command} ); __filaxis_ec=$?; ` +
|
|
136
|
+
`printf '\\n${marker}:%d:%s\\n' "$__filaxis_ec" "$(id -un 2>/dev/null)"; ` +
|
|
137
|
+
`exit $__filaxis_ec`;
|
|
138
|
+
|
|
139
|
+
return new Promise<ExecResult>((resolve, reject) => {
|
|
140
|
+
let stdout = "";
|
|
141
|
+
let stderr = "";
|
|
142
|
+
|
|
143
|
+
const env = { ...process.env, ...options?.env };
|
|
144
|
+
const cwd = options?.cwd || this.defaultCwd || process.cwd();
|
|
145
|
+
|
|
146
|
+
// 不使用 shell: true——Node 会再包一层 sh -c,stdin 读取类命令(wc、无参 cat)挂死
|
|
147
|
+
const p = spawn(this.shell, ["-c", wrapped], {
|
|
148
|
+
env,
|
|
149
|
+
cwd,
|
|
150
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const startTime = Date.now();
|
|
154
|
+
const writeStatus = async (status: string, extra: Record<string, unknown> = {}) => {
|
|
155
|
+
try {
|
|
156
|
+
await fs.writeFile(statusPath, JSON.stringify({
|
|
157
|
+
pid: p.pid,
|
|
158
|
+
command,
|
|
159
|
+
status,
|
|
160
|
+
startTime,
|
|
161
|
+
endTime: Date.now(),
|
|
162
|
+
...extra,
|
|
163
|
+
}, null, 2), "utf-8");
|
|
164
|
+
} catch { /* 状态文件写入失败不影响主流程 */ }
|
|
165
|
+
};
|
|
166
|
+
void writeStatus("running");
|
|
167
|
+
|
|
168
|
+
const logStream = createWriteStream(logPath);
|
|
169
|
+
p.stdout.on("data", (d: Buffer) => { stdout += d; logStream.write(d); });
|
|
170
|
+
p.stderr.on("data", (d: Buffer) => { stderr += d; logStream.write(d); });
|
|
171
|
+
|
|
172
|
+
let settled = false;
|
|
173
|
+
let finished = false;
|
|
174
|
+
const cleanupProcess = (exitStatus: string, extra: Record<string, unknown> = {}) => {
|
|
175
|
+
if (finished) return;
|
|
176
|
+
finished = true;
|
|
177
|
+
logStream.end();
|
|
178
|
+
return writeStatus(exitStatus, extra);
|
|
179
|
+
};
|
|
180
|
+
const detachLifecycle = () => {
|
|
181
|
+
if (timeout) clearTimeout(timeout);
|
|
182
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const timeout = options?.timeoutMs ? setTimeout(() => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
finished = true; // 审计终态封口(审查问题 3:后台进程晚死时 close 不得把 timeout 覆写成 completed)
|
|
189
|
+
detachLifecycle();
|
|
190
|
+
// 超时=放弃该进程(继续后台写日志)。此时才 unref:被放弃的子进程
|
|
191
|
+
// 不应撑事件循环。⚠️ 不能 spawn 后立即 unref——Bun 下 unref 子进程
|
|
192
|
+
// 不保活事件循环,低活动场景 close 事件永不触发,exec 静默挂死。
|
|
193
|
+
p.unref();
|
|
194
|
+
logStream.end();
|
|
195
|
+
void writeStatus("timeout", { error: "Command timeout", exitCode: null });
|
|
196
|
+
const e = new Error(`Command timeout after ${options.timeoutMs}ms (process continues in background, log: ${logPath}, status: ${statusPath})`) as Error & { outcome: "unknown" };
|
|
197
|
+
e.outcome = "unknown"; // 远端/本地进程终态未核实——审查问题 3:对齐 ssh 侧语义
|
|
198
|
+
reject(e);
|
|
199
|
+
}, options.timeoutMs) : null;
|
|
200
|
+
|
|
201
|
+
const signal = options?.signal;
|
|
202
|
+
const onAbort = signal ? () => {
|
|
203
|
+
if (settled) return;
|
|
204
|
+
settled = true;
|
|
205
|
+
finished = true; // 审计终态封口(close 不得覆写 aborted)
|
|
206
|
+
detachLifecycle();
|
|
207
|
+
try { p.kill("SIGTERM"); } catch { /* 已退出 */ }
|
|
208
|
+
logStream.end();
|
|
209
|
+
void writeStatus("aborted", { error: "Aborted by AbortSignal", exitCode: null });
|
|
210
|
+
const abortErr = new Error("Command aborted by AbortSignal(本地进程已 SIGTERM,终态未核实——outcome=unknown)") as Error & { name: "AbortError"; outcome: "unknown" };
|
|
211
|
+
abortErr.name = "AbortError";
|
|
212
|
+
abortErr.outcome = "unknown";
|
|
213
|
+
reject(abortErr);
|
|
214
|
+
} : null;
|
|
215
|
+
if (signal && onAbort) {
|
|
216
|
+
if (signal.aborted) onAbort();
|
|
217
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
p.on("close", (code) => {
|
|
221
|
+
detachLifecycle();
|
|
222
|
+
if (settled) return;
|
|
223
|
+
settled = true;
|
|
224
|
+
const finish = () => {
|
|
225
|
+
// 完成哨兵原则(故障注入 1B 同源收口):marker 尾缀是「跑完」唯一
|
|
226
|
+
// 凭证——未见哨兵(外部 kill -9/信号杀,code===null)reject 附半截
|
|
227
|
+
// 现场;哨兵在场退出码以哨兵为准。
|
|
228
|
+
let out = stdout;
|
|
229
|
+
let effectiveUser: string | undefined;
|
|
230
|
+
let sentinelCode: number | undefined;
|
|
231
|
+
const mIdx = out.lastIndexOf(`\n${marker}:`);
|
|
232
|
+
if (mIdx !== -1) {
|
|
233
|
+
const metaLine = out.slice(mIdx + 1).split("\n", 1)[0] ?? "";
|
|
234
|
+
const m = metaLine.match(new RegExp(`^${marker}:(-?\\d+):(.*)$`));
|
|
235
|
+
if (m) {
|
|
236
|
+
effectiveUser = m[2] === "" ? undefined : m[2];
|
|
237
|
+
sentinelCode = parseInt(m[1]!, 10);
|
|
238
|
+
out = out.slice(0, mIdx); // mIdx 处的 \n 是 printf 的,连同剥掉
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ★ 审计与事实对齐(审查问题 3):先验哨兵,后写审计——
|
|
242
|
+
// completed 只在有哨兵时落盘(exitCode 用哨兵真实码);
|
|
243
|
+
// 无哨兵落 killed + exitCode null(绝不把 null ?? 0 伪成完成)
|
|
244
|
+
logStream.end();
|
|
245
|
+
if (sentinelCode === undefined) {
|
|
246
|
+
void writeStatus("killed", { exitCode: null, closeCode: code ?? null });
|
|
247
|
+
const e = new Error(
|
|
248
|
+
`命令未完成(无完成哨兵——进程被信号终止,close code=${code})。半截现场 stdout 尾 500:\n${out.slice(-500)}\nstderr 尾 300:\n${stderr.slice(-300)}`,
|
|
249
|
+
) as Error & { outcome: "unknown" };
|
|
250
|
+
e.outcome = "unknown";
|
|
251
|
+
reject(e);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
void writeStatus("completed", { exitCode: sentinelCode });
|
|
255
|
+
resolve({ stdout: out, stderr, exitCode: sentinelCode, effectiveUser });
|
|
256
|
+
};
|
|
257
|
+
finish();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
p.on("error", (err) => {
|
|
261
|
+
detachLifecycle();
|
|
262
|
+
const pWrite = cleanupProcess("failed", { error: err.message, exitCode: -1 });
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true;
|
|
265
|
+
if (pWrite) void pWrite.then(() => reject(err), () => reject(err));
|
|
266
|
+
else reject(err);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
class LocalFileSystem implements FileSystemDriver {
|
|
273
|
+
async readFile(filePath: string): Promise<string> {
|
|
274
|
+
return await fs.readFile(filePath, "utf-8");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 二进制安全读取(裸 base64——readFile 的 UTF-8 会破坏二进制字节) */
|
|
278
|
+
async readBase64(filePath: string): Promise<string> {
|
|
279
|
+
const buf = await fs.readFile(filePath);
|
|
280
|
+
return Buffer.from(buf).toString("base64");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async writeFile(filePath: string, content: string): Promise<void> {
|
|
284
|
+
await fs.writeFile(filePath, content, "utf-8");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async exists(filePath: string): Promise<boolean> {
|
|
288
|
+
try {
|
|
289
|
+
await fs.access(filePath);
|
|
290
|
+
return true;
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async stat(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
|
|
297
|
+
try {
|
|
298
|
+
const st = await fs.stat(filePath);
|
|
299
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async readdir(dirPath: string): Promise<FileEntry[]> {
|
|
306
|
+
const dirents = await fs.readdir(dirPath, { withFileTypes: true });
|
|
307
|
+
return await Promise.all(dirents.map(async (d) => {
|
|
308
|
+
const stats = await fs.stat(path.join(dirPath, d.name)).catch(() => null);
|
|
309
|
+
return {
|
|
310
|
+
name: d.name,
|
|
311
|
+
isDirectory: d.isDirectory(),
|
|
312
|
+
size: stats?.size,
|
|
313
|
+
mtime: stats?.mtimeMs,
|
|
314
|
+
};
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async rm(filePath: string, options?: { recursive?: boolean; force?: boolean}): Promise<void> {
|
|
319
|
+
await fs.rm(filePath, options);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async mkdir(dirPath: string, options?: {recursive?: boolean}): Promise<void> {
|
|
323
|
+
await fs.mkdir(dirPath, options);
|
|
324
|
+
}
|
|
325
|
+
}
|