@syrkos/filaxis 0.1.0-alpha.11 → 0.1.0-alpha.13

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.

Potentially problematic release.


This version of @syrkos/filaxis might be problematic. Click here for more details.

@@ -8,9 +8,14 @@
8
8
  * - features() 能力探测(AC-02:降级不靠猜——mux/sftp/persistent 按平台与二进制探测)
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
+ import * as os from "node:os";
11
12
  import type { RuntimeDriver, RuntimeDescriptor } from "./driver.js";
12
13
  import { RuntimeFactory } from "./factory.js";
13
14
  import { EventBus } from "./events.js";
15
+ import {
16
+ enterStepsOf, layerDisplayName, layerPathIdentities, resolveSshIdentity,
17
+ hopHost, type SshHop, type SshConnParams,
18
+ } from "./ssh-helpers.js";
14
19
  import type { SessionEvent } from "./session-types.js";
15
20
  import { noopLogger, type FilaxisLogger } from "./logger.js";
16
21
 
@@ -34,6 +39,22 @@ export interface RuntimeStackOptions {
34
39
  factory?: RuntimeFactory;
35
40
  }
36
41
 
42
+ /** 命名层句柄(Paramiko 式「连接即句柄」的持有与寻址单元) */
43
+ export interface LayerHandle {
44
+ /** 层名(注册表内唯一——显式寻址零歧义) */
45
+ name: string;
46
+ descriptor: RuntimeDescriptor;
47
+ driver: RuntimeDriver;
48
+ /** 落点校验缓存(hostname 实测——宁重连不错主机) */
49
+ actualHostname?: string;
50
+ }
51
+
52
+ /** 层注册表快照(descriptor 即可序列化层身份——可 JSON 落盘跨进程恢复) */
53
+ export interface LayerSnapshot {
54
+ layers: Array<{ name: string; descriptor: RuntimeDescriptor }>;
55
+ active?: string;
56
+ }
57
+
37
58
  export class RuntimeStack {
38
59
  /** 进程级驱动缓存:同连接拓扑指纹复用既有连接(杜绝孤儿/重复建连) */
39
60
  private static driverCache = new Map<string, RuntimeDriver>();
@@ -51,7 +72,10 @@ export class RuntimeStack {
51
72
  /**
52
73
  * 连接拓扑指纹(净室移植自源版 driverKey,身份白名单投影):
53
74
  * 显示标签/运行态缓存等非身份字段不进键——不把既有连接挤出缓存、不误建重复连接。
54
- * 注:ssh 层的 enterSteps 管线属 ssh Transport 批次,届时并入 cfg 投影。
75
+ * 环境身份三列(源版同款):pty 标志 + sshHops 逐跳链 + enters 进入管线(归一化——
76
+ * legacy enterCmd 与等价 enterPipeline 单步同键)。缺列后果:同 target/connParams
77
+ * 但进不同容器的两层指纹相同 → connect 复用错连接 → B 容器的命令跑进 A 容器
78
+ * (「执行环境即 Layer」的身份底线——平移时曾丢列,第十季检查补齐)。
55
79
  */
56
80
  static driverKey(d: RuntimeDescriptor): string {
57
81
  const c = (d.config ?? {}) as Record<string, unknown>;
@@ -62,12 +86,15 @@ export class RuntimeStack {
62
86
  cwd: d.cwd,
63
87
  capabilities: d.capabilities,
64
88
  cfg: {
89
+ pty: c.pty,
90
+ connParams: c.connParams,
91
+ sshHops: c.sshHops,
92
+ enters: enterStepsOf(c).map((s) => s.cmd),
65
93
  launchCmd: c.launchCmd,
66
94
  runtime: c.runtime,
67
95
  shell: c.shell,
68
96
  namespace: c.namespace,
69
97
  sshOptions: c.sshOptions,
70
- connParams: c.connParams,
71
98
  },
72
99
  });
73
100
  }
@@ -101,6 +128,267 @@ export class RuntimeStack {
101
128
  this.featuresCache = null;
102
129
  }
103
130
 
131
+ // ══════════ 命名层注册表(第十季 Layer 渗透定案——零破坏纯增量) ══════════
132
+ // 设计拍板:牵丝出身 Paramiko 式「连接即句柄」——栈/焦点弱化为消费方心智
133
+ //(Aethelum 垫片自带全局焦点栈),SDK 只补「句柄的持有与寻址」:
134
+ // - 命名注册:名字唯一=零歧义(子串匹配/焦点切换留给消费方)
135
+ // - 派生:pushChild/pushInto(ssh PTY 原语,派生即 init)——父子关系由
136
+ // descriptor 继承天然记录(connParams/sshHops/enterPipeline 全量继承)
137
+ // - 回环检测幂等返回:同落点 identity 命中 → 返回既有层 reused:true(原版
138
+ // push_ssh 判重语义的 Paramiko 化——报错是栈时代的脾气)+ 落点校验
139
+ // (hostname 实测 vs 期望,不符=撞了途经节点 → 拒绝,宁重连不错主机)
140
+ // - snapshot/restore:descriptor 即层身份(tmux 层 terminalId 零成本
141
+ // re-attach 现场全保;ssh 层重握手 + enterPipeline 逐条重放=位置确定性恢复)
142
+
143
+ private namedLayers = new Map<string, LayerHandle>();
144
+ private activeName?: string;
145
+
146
+ /** 层注册表只读视图 */
147
+ get layers(): ReadonlyMap<string, LayerHandle> { return this.namedLayers; }
148
+
149
+ /** 默认层 driver(最近注册/setActive 的层;无层时 undefined——消费方自兜底) */
150
+ get active(): RuntimeDriver | undefined {
151
+ return this.activeName ? this.namedLayers.get(this.activeName)?.driver : undefined;
152
+ }
153
+
154
+ /** 默认层句柄(含层名;无层时 undefined) */
155
+ get activeLayer(): LayerHandle | undefined {
156
+ return this.activeName ? this.namedLayers.get(this.activeName) : undefined;
157
+ }
158
+
159
+ /**
160
+ * 注册命名层。descriptor 形态走 connect(指纹复用)+ 回环检测幂等;
161
+ * driver 形态直接登记现成句柄(pushChild/pushInto 派生产物、消费方自建连接)。
162
+ * 新层自动成为默认层。
163
+ */
164
+ async addLayer(
165
+ name: string,
166
+ descriptorOrDriver: RuntimeDescriptor | RuntimeDriver,
167
+ ): Promise<{ handle: LayerHandle; reused: boolean; reusedFrom?: string }> {
168
+ this.assertNotDupName(name);
169
+ if (this.isDriver(descriptorOrDriver)) {
170
+ const driver = descriptorOrDriver;
171
+ const handle: LayerHandle = { name, descriptor: driver.descriptor, driver };
172
+ this.namedLayers.set(name, handle);
173
+ this.activeName = name;
174
+ return { handle, reused: false };
175
+ }
176
+ const descriptor = descriptorOrDriver;
177
+ // 回环检测幂等(descriptor 形态):新落点 identity ∈ 既有层路径 identity 集。
178
+ // 末节点命中=同落点构造性成立(canonical identity 相同)→ 直接复用;
179
+ // 途经节点命中(identity 撞了某层的中间跳)→ hostname 落点校验把关(宁重连不错主机)
180
+ const hit = await this.findIdentityHit(descriptor);
181
+ if (hit) {
182
+ const reused = hit.terminal || await this.verifyLanding(hit.handle, hopHost(layerPathIdentities(descriptor).at(-1) ?? ""));
183
+ if (reused) {
184
+ this.log.info(`[Filaxis] 层 '${name}' 落点已由 '${hit.handle.name}' 持有——幂等复用(不重复建连)`);
185
+ return { handle: hit.handle, reused: true, reusedFrom: hit.handle.name };
186
+ }
187
+ throw new Error(
188
+ `层名 '${name}' 的落点 identity 命中既有层 '${hit.handle.name}' 的途经节点,且落点校验不符` +
189
+ `(期望 ${hopHost(layerPathIdentities(descriptor).at(-1) ?? "?")},实测 ${hit.handle.actualHostname ?? "?"})` +
190
+ `(宁重连不错主机):请检查连接参数后重试`,
191
+ );
192
+ }
193
+ const driver = await this.connect(descriptor);
194
+ const handle: LayerHandle = { name, descriptor: driver.descriptor, driver };
195
+ this.namedLayers.set(name, handle);
196
+ this.activeName = name;
197
+ return { handle, reused: false };
198
+ }
199
+
200
+ /** 从命名层派生进入层(docker exec/kubectl 等——enter 管线全量继承追加) */
201
+ async layerInto(from: string, name: string, enterCmd: string, opts: { label?: string } = {}): Promise<LayerHandle> {
202
+ this.assertNotDupName(name);
203
+ const src = this.requireLayer(from);
204
+ const pushInto = (src.driver as unknown as {
205
+ pushInto?: (cmd: string, label: string) => Promise<RuntimeDriver>;
206
+ }).pushInto;
207
+ if (typeof pushInto !== "function") {
208
+ throw new Error(`层 '${from}'(${src.descriptor.driver})不支持派生进入——需 ssh PTY 形态(pushInto 缺席)`);
209
+ }
210
+ const driver = await pushInto.call(src.driver, enterCmd, opts.label ?? enterCmd.slice(0, 30));
211
+ const handle: LayerHandle = { name, descriptor: driver.descriptor, driver };
212
+ this.namedLayers.set(name, handle);
213
+ this.activeName = name;
214
+ return handle;
215
+ }
216
+
217
+ /** 从命名层链式 ssh 派生子层(本层路径为前缀——途经继承;含回环检测幂等) */
218
+ async sshInto(
219
+ from: string,
220
+ name: string,
221
+ target: string,
222
+ connParams?: SshConnParams,
223
+ ): Promise<{ handle: LayerHandle; reused: boolean; reusedFrom?: string }> {
224
+ this.assertNotDupName(name);
225
+ const src = this.requireLayer(from);
226
+ const hop: SshHop = connParams ? { host: target, conn: connParams } : target;
227
+ // 回环检测幂等(链式派生落点=新 hop;末节点命中直接复用,途经命中 hostname 把关)
228
+ const hit = await this.findIdentityHopHit(hop);
229
+ if (hit) {
230
+ const reused = hit.terminal || await this.verifyLanding(hit.handle, hopHost(hop));
231
+ if (reused) {
232
+ this.log.info(`[Filaxis] 层 '${name}' 落点已由 '${hit.handle.name}' 持有——幂等复用(不重复建连)`);
233
+ return { handle: hit.handle, reused: true, reusedFrom: hit.handle.name };
234
+ }
235
+ throw new Error(
236
+ `层名 '${name}' 的落点 identity 命中既有层 '${hit.handle.name}' 的途经节点,且落点校验不符` +
237
+ `(期望 ${hopHost(hop)},实测 ${hit.handle.actualHostname ?? "?"})——宁重连不错主机:请检查连接参数后重试`,
238
+ );
239
+ }
240
+ const pushChild = (src.driver as unknown as {
241
+ pushChild?: (hop: SshHop) => Promise<RuntimeDriver>;
242
+ }).pushChild;
243
+ if (typeof pushChild !== "function") {
244
+ throw new Error(`层 '${from}'(${src.descriptor.driver})不支持链式 ssh 派生——需 ssh PTY 形态(pushChild 缺席)`);
245
+ }
246
+ const driver = await pushChild.call(src.driver, hop);
247
+ const handle: LayerHandle = { name, descriptor: driver.descriptor, driver };
248
+ this.namedLayers.set(name, handle);
249
+ this.activeName = name;
250
+ return { handle, reused: false };
251
+ }
252
+
253
+ /** 按名取层(不存在时报错并附候选清单) */
254
+ layer(name: string): LayerHandle {
255
+ return this.requireLayer(name);
256
+ }
257
+
258
+ /** 设默认层(显式声明「接下来都在这层干」——无全局焦点指针,最近使用 + 显式声明双轨) */
259
+ setActive(name: string): LayerHandle {
260
+ const h = this.requireLayer(name);
261
+ this.activeName = name;
262
+ return h;
263
+ }
264
+
265
+ /** 移出注册表(缺省连接保留在指纹缓存可复用;destroy=true 才真销毁) */
266
+ async removeLayer(name: string, opts: { destroy?: boolean } = {}): Promise<void> {
267
+ const h = this.requireLayer(name);
268
+ this.namedLayers.delete(name);
269
+ if (opts.destroy) {
270
+ RuntimeStack.evictDriver(h.descriptor);
271
+ await h.driver.destroy().catch(() => undefined);
272
+ }
273
+ if (this.activeName === name) {
274
+ this.activeName = [...this.namedLayers.keys()].at(-1); // 回落最后注册层
275
+ }
276
+ }
277
+
278
+ /** 层清单纯文本(零 ANSI/零表格——npm 零依赖红线;彩色表格留消费方) */
279
+ layerText(): string {
280
+ if (this.namedLayers.size === 0) return "Filaxis 命名层:(空——addLayer/layerInto 注册)";
281
+ const lines: string[] = [];
282
+ for (const h of this.namedLayers.values()) {
283
+ const d = h.driver as unknown as {
284
+ alive?: boolean; layerShellGone?: unknown; reconnecting?: boolean; effectiveUser?: string;
285
+ };
286
+ const base = h.descriptor.driver === "local"
287
+ ? `${os.hostname()}[local]`
288
+ : layerDisplayName(h.descriptor);
289
+ const state = d.reconnecting === true
290
+ ? " ↻连中"
291
+ : d.layerShellGone
292
+ ? " ⚠退化(内层 shell 已退出,勿在此层执行)"
293
+ : d.alive === false
294
+ ? " ⚠死(removeLayer 后按原参数重建)"
295
+ : "";
296
+ const user = d.effectiveUser ? ` · ${d.effectiveUser}$` : "";
297
+ const mark = h.name === this.activeName ? " ◀ 默认" : "";
298
+ lines.push(` ${h.name}: ${base}${user}${state}${mark}`);
299
+ }
300
+ return ["Filaxis 命名层(工具缺省落【默认层】;显式按名寻址任意层):", ...lines].join("\n");
301
+ }
302
+
303
+ /** 层身份快照(可 JSON 落盘——跨进程恢复的凭证) */
304
+ snapshot(): LayerSnapshot {
305
+ return {
306
+ layers: [...this.namedLayers.values()].map((h) => ({ name: h.name, descriptor: h.descriptor })),
307
+ active: this.activeName,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * 按快照重建(幂等:已在层的名字跳过):tmux 层 terminalId 命中=零成本 re-attach
313
+ * (现场全保);ssh 层=重握手 + enterPipeline 逐条重放(位置确定性恢复)。
314
+ */
315
+ async restore(snap: LayerSnapshot): Promise<void> {
316
+ for (const { name, descriptor } of snap.layers) {
317
+ if (this.namedLayers.has(name)) continue;
318
+ const driver = await this.connect(descriptor);
319
+ this.namedLayers.set(name, { name, descriptor: driver.descriptor, driver });
320
+ }
321
+ if (snap.active && this.namedLayers.has(snap.active)) this.activeName = snap.active;
322
+ }
323
+
324
+ // ── 注册表内部 ──
325
+
326
+ private assertNotDupName(name: string): void {
327
+ if (this.namedLayers.has(name)) {
328
+ throw new Error(`层名 '${name}' 已占用(既有:${[...this.namedLayers.keys()].join(", ")})——换名或先 removeLayer`);
329
+ }
330
+ }
331
+
332
+ private requireLayer(name: string): LayerHandle {
333
+ const h = this.namedLayers.get(name);
334
+ if (!h) {
335
+ throw new Error(`层 '${name}' 不存在(既有:${[...this.namedLayers.keys()].join(", ") || "空"})`);
336
+ }
337
+ return h;
338
+ }
339
+
340
+ private isDriver(x: RuntimeDescriptor | RuntimeDriver): x is RuntimeDriver {
341
+ return typeof (x as RuntimeDriver).exec === "function";
342
+ }
343
+
344
+ /**
345
+ * descriptor 落点 identity(路径末节点)比对既有层全部路径节点。
346
+ * 判重边界(原版 push_ssh 语义忠实平移):**只在无 enter 管线的纯 ssh 落点层之间**——
347
+ * enter 层(容器/内层 shell)的落点是容器而非宿主机,hop identity 管不到(enter 管线
348
+ * 不进 layerPathIdentities):同宿主机两个不同容器层会撞 identity + hostname 同为宿主机
349
+ * → 误幂等返回错容器。enter 层的身份与复用归 driverKey(含 enters 三列)。
350
+ */
351
+ private async findIdentityHit(descriptor: RuntimeDescriptor): Promise<{ handle: LayerHandle; terminal: boolean } | undefined> {
352
+ if (enterStepsOf((descriptor.config ?? {}) as Record<string, unknown>).length > 0) return undefined;
353
+ const dest = layerPathIdentities(descriptor).at(-1);
354
+ if (!dest) return undefined;
355
+ return this.findIdentityHopHit(dest);
356
+ }
357
+
358
+ /** terminal=true:identity 命中在该层路径**末节点**(同落点构造性成立,免 hostname 探测);
359
+ * false:命中途经节点(identity 撞了中间跳——hostname 落点校验把关) */
360
+ private async findIdentityHopHit(dest: SshHop): Promise<{ handle: LayerHandle; terminal: boolean } | undefined> {
361
+ const destId = await resolveSshIdentity(dest);
362
+ if (!destId || destId.startsWith("unresolved:")) {
363
+ // 解析不可达(DNS/离线)——identity 不可靠,跳过判重(建层失败由 connect 兜底)
364
+ return undefined;
365
+ }
366
+ for (const h of this.namedLayers.values()) {
367
+ if (enterStepsOf((h.descriptor.config ?? {}) as Record<string, unknown>).length > 0) continue; // 候选也须纯落点层
368
+ const path = layerPathIdentities(h.descriptor);
369
+ for (let i = 0; i < path.length; i++) {
370
+ if ((await resolveSshIdentity(path[i]!)) === destId) {
371
+ return { handle: h, terminal: i === path.length - 1 };
372
+ }
373
+ }
374
+ }
375
+ return undefined;
376
+ }
377
+
378
+ /** 落点校验(宁重连不错主机):命中层实测 hostname vs 期望 host——不符=identity 撞途经节点 */
379
+ private async verifyLanding(hit: LayerHandle, expectedHost: string): Promise<boolean> {
380
+ if (!expectedHost) return true;
381
+ const d = hit.driver as unknown as { exec?: (cmd: string) => Promise<{ stdout: string }> };
382
+ if (typeof d.exec !== "function") return true;
383
+ try {
384
+ hit.actualHostname ??= ((await d.exec("hostname")).stdout || "").trim().split("\n").pop() || "";
385
+ } catch {
386
+ return false; // 探测失败=不可核实——拒绝复用
387
+ }
388
+ const actual = hit.actualHostname;
389
+ return Boolean(actual) && (actual!.includes(expectedHost) || expectedHost.includes(actual!));
390
+ }
391
+
104
392
  /**
105
393
  * 能力探测(AC-02:降级不靠猜)。结果缓存至注册表变化。
106
394
  * Windows 无 ControlMaster → mux=false(自动退化直连为既定行为)。
@@ -54,11 +54,13 @@ class PtyLink {
54
54
  onLayerFallback?: (evidence: string) => void;
55
55
  fallbackSeen?: string;
56
56
 
57
- /** 进入步骤退出哨兵(本物理链独有 nonce + 步骤号;$RANDOM 展开值防回显伪造) */
57
+ /** 进入步骤退出哨兵(本物理链独有 nonce + 步骤号;$$ 展开 PID 防回显伪造——
58
+ * POSIX 全 shell 通吃:$RANDOM 是 bash 特性,dash/busybox ash 下展开为空致哨兵
59
+ * 形态崩坏(CI 实锤:PTY 层 enter sh 握手 20s 超时)) */
58
60
  registerFallbackStep(): string {
59
61
  const step = String(this.fallbackSteps.size);
60
62
  this.fallbackSteps.add(step);
61
- return `; echo __FILAXIS_FB_${this.fallbackNonce}_${step}_$?_$RANDOM`;
63
+ return `; echo __FILAXIS_FB_${this.fallbackNonce}_${step}_$?_$$`;
62
64
  }
63
65
 
64
66
  attach(args: string[]): void {
@@ -107,7 +109,7 @@ class PtyLink {
107
109
  if (!this.pendingExitFailure) await this.waitQuiet();
108
110
  // 握手行顺带身份探测(冷启动锦囊:省一次独立 execChannel("id -un")——
109
111
  // Windows 下那次 exec 是第二次完整 SSH 握手,建层耗时近半在此)
110
- this.send("stty -echo -ixon cols 500 rows 50; export PS1= PS2=; echo PTYRDY_$RANDOM$RANDOM_$(id -un 2>/dev/null)\n");
112
+ this.send("stty -echo -ixon cols 500 rows 50; export PS1= PS2=; echo PTYRDY_$$_$(id -un 2>/dev/null)\n");
111
113
  try {
112
114
  await new Promise<void>((resolve, reject) => {
113
115
  const timer = setTimeout(() => reject(new Error(this.egressError(`PTYRDY 握手超时(20s): ${what}`))), 20_000);
@@ -266,7 +268,8 @@ class PtyLink {
266
268
  }
267
269
  if (!this.ready) {
268
270
  // 非行首锚定(容忍与提示符粘连);字面回显的字面串不含数字与真实用户名,安全
269
- // PTYRDY_$RANDOM 展开值 + 顺带 id -un(冷启动锦囊:握手即身份,省一整次独立 ssh 握手)
271
+ // PTYRDY_$$_ 展开值($$=PID,POSIX 全 shell——$RANDOM 系 bash 特性)+ 顺带
272
+ // id -un(冷启动锦囊:握手即身份,省一整次独立 ssh 握手)
270
273
  const m = line.match(/PTYRDY_(\d+)(?:_([A-Za-z0-9._-]+))?/);
271
274
  if (m) {
272
275
  if (m[2]) this.handshakeUser = m[2]; // 本握手步(hop/enter/终层)的登录身份