@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.
Files changed (85) hide show
  1. package/AGENTS.md +46 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +10 -0
  4. package/README.md +61 -0
  5. package/bin/filaxis.ts +112 -0
  6. package/dist/agent-toolkit.d.ts +99 -0
  7. package/dist/dialect.d.ts +31 -0
  8. package/dist/driver.d.ts +130 -0
  9. package/dist/events.d.ts +26 -0
  10. package/dist/factory.d.ts +16 -0
  11. package/dist/file-edit.d.ts +43 -0
  12. package/dist/file-transfer.d.ts +44 -0
  13. package/dist/index.d.ts +38 -0
  14. package/dist/index.js +4200 -0
  15. package/dist/layer-discovery.d.ts +30 -0
  16. package/dist/layer-env.d.ts +95 -0
  17. package/dist/local-driver.d.ts +47 -0
  18. package/dist/local-session-manager.d.ts +98 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/remote-session-manager.d.ts +64 -0
  21. package/dist/runtime-stack.d.ts +52 -0
  22. package/dist/session-buffer.d.ts +73 -0
  23. package/dist/session-types.d.ts +74 -0
  24. package/dist/shell-discovery.d.ts +4 -0
  25. package/dist/ssh-helpers.d.ts +88 -0
  26. package/dist/ssh-pty-driver.d.ts +74 -0
  27. package/dist/stream-session-manager.d.ts +83 -0
  28. package/dist/stream-window.d.ts +124 -0
  29. package/dist/terminal/command-executer.d.ts +73 -0
  30. package/dist/terminal/output-buffer.d.ts +49 -0
  31. package/dist/terminal/session-launcher.d.ts +28 -0
  32. package/dist/terminal/session-registry.d.ts +29 -0
  33. package/dist/terminal/tmux-invoker.d.ts +8 -0
  34. package/dist/terminal/types.d.ts +56 -0
  35. package/dist/terminal-manager.d.ts +30 -0
  36. package/dist/tmux-driver.d.ts +49 -0
  37. package/dist/vt100-screen.d.ts +46 -0
  38. package/docs/DEVELOPMENT.md +38 -0
  39. package/docs/KNOWN-ISSUES.md +35 -0
  40. package/docs/implementation-notes.md +57 -0
  41. package/docs/skills/cross-host-diagnosis.md +21 -0
  42. package/docs/skills/file-sync.md +22 -0
  43. package/docs/skills/longrun-observe.md +20 -0
  44. package/docs/skills/stream-window.md +28 -0
  45. package/docs//345/256/214/346/225/264/345/267/241/346/243/200/346/212/245/345/221/212.md +141 -0
  46. package/docs//346/265/213/350/257/2252.md +112 -0
  47. package/docs//346/265/213/350/257/2253.md +73 -0
  48. package/docs//346/265/213/350/257/2254.md +138 -0
  49. package/docs//346/265/213/350/257/2255.md +124 -0
  50. 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
  51. package/examples/agent-tools.ts +74 -0
  52. package/examples/cross-host-diagnosis.ts +49 -0
  53. package/examples/longrun-observe.ts +46 -0
  54. package/package.json +47 -0
  55. package/src/agent-toolkit.ts +247 -0
  56. package/src/dialect.ts +100 -0
  57. package/src/driver.ts +142 -0
  58. package/src/events.ts +96 -0
  59. package/src/factory.ts +51 -0
  60. package/src/file-edit.ts +195 -0
  61. package/src/file-transfer.ts +196 -0
  62. package/src/index.ts +38 -0
  63. package/src/layer-discovery.ts +79 -0
  64. package/src/layer-env.ts +331 -0
  65. package/src/local-driver.ts +325 -0
  66. package/src/local-session-manager.ts +397 -0
  67. package/src/logger.ts +19 -0
  68. package/src/remote-session-manager.ts +286 -0
  69. package/src/runtime-stack.ts +128 -0
  70. package/src/session-buffer.ts +183 -0
  71. package/src/session-types.ts +78 -0
  72. package/src/shell-discovery.ts +52 -0
  73. package/src/ssh-helpers.ts +225 -0
  74. package/src/ssh-pty-driver.ts +655 -0
  75. package/src/stream-session-manager.ts +296 -0
  76. package/src/stream-window.ts +309 -0
  77. package/src/terminal/command-executer.ts +374 -0
  78. package/src/terminal/output-buffer.ts +126 -0
  79. package/src/terminal/session-launcher.ts +146 -0
  80. package/src/terminal/session-registry.ts +98 -0
  81. package/src/terminal/tmux-invoker.ts +65 -0
  82. package/src/terminal/types.ts +85 -0
  83. package/src/terminal-manager.ts +83 -0
  84. package/src/tmux-driver.ts +167 -0
  85. package/src/vt100-screen.ts +166 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * ssh-pty-driver.ts — SSH Transport driver(PTY 层模型 + 双通道 exec)
3
+ *
4
+ * 净室平移自 Aethelum-Nexus `src/core/tools/runtime/ssh-pty-driver.ts`
5
+ * (源锚 8977ff3),按 REQ-0001 OSS 化改造:
6
+ * - exec 双通道(FEAT-03):缺省 channel:"exec" = 独立非 PTY ssh 进程
7
+ * (stdout/stderr 物理分离 + nonce marker effectiveUser + enterSteps
8
+ * stdin 管道重放——零嵌套引用);channel:"pty" = 层内 PTY marker 协议
9
+ * (继承层内交互状态;stderr 混流为终端语义)
10
+ * - 不可直达层判定:enterSteps 非「… shell」形态 → exec 通道显式拒绝并指引 pty
11
+ * - 剥 SubstrateSecurity/TmuxFileSystem/ProcessManager/FsProxy;
12
+ * fs 经 exec 通道实现(ExecFileSystem,方言适配接线)
13
+ * - 会话族委托 RemoteSessionManager(nohup 双文件 + 终态三态)
14
+ * - logger 注入;自动重连(3 次退避)/层状态机/退化哨兵全平移
15
+ */
16
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
17
+ import * as crypto from "node:crypto";
18
+ import type {
19
+ RuntimeDriver, FileSystemDriver, FileEntry, ExecOptions, ExecResult, RuntimeDescriptor,
20
+ } from "./driver.js";
21
+ import { LineAssembler } from "./session-buffer.js";
22
+ import type {
23
+ SessionStartOptions, SessionHandleInfo, WatchOptions, ReadLogOptions,
24
+ } from "./session-types.js";
25
+ import {
26
+ autonomousSshOpts, SSH_INPUT_WAIT_RE, enterStepsOf, hopLabel, rootSshSpawnArgs,
27
+ sshArgsFor, renderExecChannel, type SshConnParams, type SshHop,
28
+ } from "./ssh-helpers.js";
29
+ import { RemoteSessionManager } from "./remote-session-manager.js";
30
+ import { noopLogger, type FilaxisLogger } from "./logger.js";
31
+
32
+ const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[=>]|\r(?!\n)/g;
33
+ const stripAnsi = (s: string) => s.replace(ANSI_RE, "");
34
+
35
+ /** 单条物理 PTY 链:进程 + 行流 + 握手状态机 + marker 协议(串行锁)+ 退化哨兵 */
36
+ class PtyLink {
37
+ proc?: ChildProcessWithoutNullStreams;
38
+ private fallbackNonce = crypto.randomBytes(16).toString("hex");
39
+ private fallbackSteps = new Set<string>();
40
+ private assembler = new LineAssembler();
41
+ private lineBuffer: string[] = [];
42
+ private ready = false;
43
+ private readyWaiter?: () => void;
44
+ private readyRejecter?: (e: Error) => void;
45
+ private rawArrived = false;
46
+ private firstOutWaiter?: () => void;
47
+ private chain: Promise<unknown> = Promise.resolve();
48
+ private handshakeTail: string[] = [];
49
+ /** 最近一次握手步顺带实测的登录身份(PTYRDY_..._$(id -un)——冷启动锦囊) */
50
+ handshakeUser?: string;
51
+ private handshakeWhat = "";
52
+ private pendingExitFailure?: Error;
53
+ onUnexpectedExit?: () => void;
54
+ onLayerFallback?: (evidence: string) => void;
55
+ fallbackSeen?: string;
56
+
57
+ /** 进入步骤退出哨兵(本物理链独有 nonce + 步骤号;$RANDOM 展开值防回显伪造) */
58
+ registerFallbackStep(): string {
59
+ const step = String(this.fallbackSteps.size);
60
+ this.fallbackSteps.add(step);
61
+ return `; echo __FILAXIS_FB_${this.fallbackNonce}_${step}_$?_$RANDOM`;
62
+ }
63
+
64
+ attach(args: string[]): void {
65
+ this.fallbackNonce = crypto.randomBytes(16).toString("hex");
66
+ this.fallbackSteps.clear();
67
+ this.fallbackSeen = undefined;
68
+ this.proc = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"] }) as ChildProcessWithoutNullStreams;
69
+ this.proc.stdout.on("data", (d: Buffer) => this.onData(d.toString("utf-8")));
70
+ this.proc.stderr.on("data", (d: Buffer) => this.onData(d.toString("utf-8")));
71
+ this.proc.on("exit", (code) => {
72
+ if (!this.ready) {
73
+ const err = new Error(this.egressError(`ssh 进程建链期间退出(code=${code}): ${this.handshakeWhat}`));
74
+ if (this.readyRejecter) this.readyRejecter(err);
75
+ else this.pendingExitFailure = err;
76
+ }
77
+ this.onUnexpectedExit?.();
78
+ });
79
+ }
80
+
81
+ send(s: string): void { this.proc?.stdin.write(s); }
82
+
83
+ /** 串行执行(PTY 单通道) */
84
+ queue<T>(fn: () => Promise<T>): Promise<T> {
85
+ const run = this.chain.then(fn);
86
+ this.chain = run.catch(() => undefined);
87
+ return run;
88
+ }
89
+
90
+ /** 建链失败消息:ssh 原始输出原文直交调用方(网络/认证/交互由人/模型自行判断) */
91
+ private egressError(reason: string): string {
92
+ const raw = this.handshakeTail.join("\n").trim();
93
+ return raw ? `${reason}\n[ssh 原始输出·直交]\n${raw.slice(-1500)}` : `${reason}\n[ssh 原始输出·空(对端无任何回显)]`;
94
+ }
95
+
96
+ handshakeEvidence(): string {
97
+ const raw = this.handshakeTail.join("\n").trim();
98
+ return raw ? raw.slice(-1500) : "(无原始输出)";
99
+ }
100
+
101
+ /** 层切换握手:stty 三防(-ixon 禁流控防 0x13 冻结 / cols 500 防截断 / -echo 关回显)→ 等 PTYRDY */
102
+ async handshake(what: string): Promise<void> {
103
+ this.ready = false;
104
+ this.handshakeTail = [];
105
+ this.handshakeWhat = what;
106
+ this.fallbackSeen = undefined;
107
+ if (!this.pendingExitFailure) await this.waitQuiet();
108
+ // 握手行顺带身份探测(冷启动锦囊:省一次独立 execChannel("id -un")——
109
+ // 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");
111
+ try {
112
+ await new Promise<void>((resolve, reject) => {
113
+ const timer = setTimeout(() => reject(new Error(this.egressError(`PTYRDY 握手超时(20s): ${what}`))), 20_000);
114
+ this.readyWaiter = () => { clearTimeout(timer); resolve(); };
115
+ this.readyRejecter = (e) => { clearTimeout(timer); reject(e); };
116
+ if (this.pendingExitFailure) {
117
+ const e = this.pendingExitFailure;
118
+ this.pendingExitFailure = undefined;
119
+ this.readyRejecter(e);
120
+ }
121
+ });
122
+ } finally {
123
+ this.readyWaiter = undefined;
124
+ this.readyRejecter = undefined;
125
+ }
126
+ this.lineBuffer = [];
127
+ this.ready = true;
128
+ }
129
+
130
+ /**
131
+ * pty 通道 marker 协议 exec(层内交互状态语义;四阶段 temporal isolation):
132
+ *
133
+ * RUNNING → INTERRUPTING → DRAINING → IDLE(GPT 第四季定调 + Gemini 手法 2 实证):
134
+ * - 调用方 Promise 与 PTY 占有权**分离**:超时/abort 只归还调用方(reject + outcome:unknown),
135
+ * PTY 锁不释放——转 DRAINING 等「自己 nonce 的可信终态」(真实 $? 行或 ^C trap 130 行,
136
+ * nonce 完整匹配防输出伪造)才 IDLE 解锁。
137
+ * - DRAINING 超时(5s,shell 卡死/断链)→ kill() 走三层退避自动重连重建——
138
+ * 超时只代表调用方不再等待,不代表 PTY 已可复用;不可确认即销毁重建,绝不定时强行复用。
139
+ * 不变量:当前命令终态未确认前,同一条 PTY 不得交给下一条命令。
140
+ */
141
+ async execMarker(command: string, timeoutMs: number, abortSignal?: AbortSignal): Promise<ExecResult> {
142
+ if (!this.ready) throw new Error("PTY 未就绪(层切换中)");
143
+ const marker = `__FX_M_${crypto.randomBytes(4).toString("hex")}`;
144
+ const hasRedirect = /(\d>|>>|2>&1|>&)/.test(command);
145
+ const cmdLine = hasRedirect ? command : `{ ${command}; } 2>&1`;
146
+ const wrapped = `trap 'echo "${marker}_130_$(id -un)"' INT; ${cmdLine}; echo "${marker}_$?_$(id -un)"`;
147
+
148
+ // 调用方 Promise(超时/abort 即归)——与 PTY 占有权周期解耦
149
+ const callerP = new Promise<ExecResult>((resolve, reject) => {
150
+ void this.queue(() => this.markerCycle(marker, wrapped, command, timeoutMs, abortSignal, { resolve, reject }))
151
+ .catch(() => undefined); // 周期异常由周期内处理(kill 兜底)
152
+ });
153
+ return callerP;
154
+ }
155
+
156
+ /**
157
+ * 单命令完整周期(RUNNING→(INTERRUPTING)→DRAINING→IDLE)。
158
+ * resolve 时机=终态确认(IDLE);caller 已被通知(超时/abort)时同 marker 只作 drain 确认。
159
+ */
160
+ private markerCycle(
161
+ marker: string,
162
+ wrapped: string,
163
+ command: string,
164
+ timeoutMs: number,
165
+ abortSignal: AbortSignal | undefined,
166
+ caller: { resolve: (r: ExecResult) => void; reject: (e: unknown) => void },
167
+ ): Promise<void> {
168
+ return new Promise<void>((cycleDone) => {
169
+ let notified = false; // 调用方已归(超时/abort)——INTERRUPTING 起点标志
170
+ let drained = false; // DRAINING 兜底定时器已布
171
+ this.lineBuffer = [];
172
+ const markerRe = new RegExp(`${marker}_(\\d+)(?:_(\\S+))?`);
173
+
174
+ const notify = (fn: () => void) => { if (!notified) { notified = true; fn(); } };
175
+ const cleanupAbort = () => abortSignal?.removeEventListener("abort", onAbort);
176
+ const enterDraining = () => {
177
+ if (drained) return;
178
+ drained = true;
179
+ // DRAINING 兜底:等不到可信终态=shell 卡死/断链——销毁重建(自动重连)
180
+ deadline = setTimeout(() => {
181
+ clearInterval(poll);
182
+ cleanupAbort();
183
+ this.kill(); // 触发 onUnexpectedExit → 三层退避重连
184
+ cycleDone();
185
+ }, 5000);
186
+ };
187
+ let deadline: ReturnType<typeof setTimeout> | null = null;
188
+ const onAbort = () => {
189
+ if (notified) return;
190
+ this.send("\x03");
191
+ notify(() => {
192
+ const e = new DOMException(
193
+ `SshPty exec aborted by AbortSignal(已发 ^C,远端终态未核实——outcome=unknown)`,
194
+ "AbortError",
195
+ ) as DOMException & { outcome: "unknown" };
196
+ e.outcome = "unknown";
197
+ caller.reject(e);
198
+ });
199
+ enterDraining();
200
+ };
201
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
202
+ const timer = setTimeout(() => {
203
+ if (notified) return;
204
+ this.send("\x03"); // INTERRUPTING:超时先杀远端前台任务再归调用方
205
+ notify(() => {
206
+ const e = new Error(`SshPty exec 超时(${timeoutMs}ms,已发 ^C,远端终态未核实——outcome=unknown): ${command.slice(0, 60)}`) as Error & { outcome: "unknown" };
207
+ e.outcome = "unknown";
208
+ caller.reject(e);
209
+ });
210
+ enterDraining();
211
+ }, timeoutMs);
212
+
213
+ const poll = setInterval(() => {
214
+ const buf = this.lineBuffer;
215
+ for (let i = 0; i < buf.length; i++) {
216
+ const m = buf[i]!.match(markerRe);
217
+ if (m) {
218
+ const prefix = m.index ? buf[i]!.slice(0, m.index) : "";
219
+ this.lineBuffer = buf.slice(i + 1);
220
+ clearInterval(poll);
221
+ clearTimeout(timer);
222
+ if (deadline) clearTimeout(deadline);
223
+ cleanupAbort();
224
+ if (!notified) {
225
+ // RUNNING 正常终态:唯一可信 resolve(nonce 完整匹配 + 可解析 + 尾位)
226
+ const out = [...buf.slice(0, i), ...(prefix ? [prefix] : [])].join("\n");
227
+ notify(() => caller.resolve({
228
+ stdout: out, stderr: "", exitCode: parseInt(m[1]!, 10), effectiveUser: m[2],
229
+ }));
230
+ }
231
+ // 已通知(超时/abort)→ 本 marker 行即可信终态确认 → IDLE
232
+ cycleDone();
233
+ return;
234
+ }
235
+ }
236
+ }, 25);
237
+
238
+ this.send(`${wrapped}\n`);
239
+ });
240
+ }
241
+
242
+ kill(): void {
243
+ this.proc?.kill();
244
+ this.proc = undefined;
245
+ this.ready = false;
246
+ }
247
+
248
+ private async waitQuiet(): Promise<void> {
249
+ const firstOut = new Promise<void>((resolve) => {
250
+ if (this.rawArrived) resolve();
251
+ else this.firstOutWaiter = resolve;
252
+ });
253
+ await Promise.race([firstOut, new Promise((r) => setTimeout(r, 8000))]);
254
+ await new Promise((r) => setTimeout(r, 600));
255
+ }
256
+
257
+ private onData(text: string): void {
258
+ if (this.firstOutWaiter) { this.firstOutWaiter(); this.firstOutWaiter = undefined; }
259
+ this.rawArrived = true;
260
+ for (const line of this.assembler.feed(stripAnsi(text))) {
261
+ const fb = [...line.matchAll(/__FILAXIS_FB_([a-f0-9]{32})_(\d+)_(\d+)_(\d+)(?!\w)/g)]
262
+ .find((m) => m[1] === this.fallbackNonce && this.fallbackSteps.has(m[2]!));
263
+ if (fb) {
264
+ this.fallbackSeen = line.trim().slice(0, 200);
265
+ if (this.ready) this.onLayerFallback?.(this.fallbackSeen);
266
+ }
267
+ if (!this.ready) {
268
+ // 非行首锚定(容忍与提示符粘连);字面回显的字面串不含数字与真实用户名,安全
269
+ // PTYRDY_$RANDOM 展开值 + 顺带 id -un(冷启动锦囊:握手即身份,省一整次独立 ssh 握手)
270
+ const m = line.match(/PTYRDY_(\d+)(?:_([A-Za-z0-9._-]+))?/);
271
+ if (m) {
272
+ if (m[2]) this.handshakeUser = m[2]; // 本握手步(hop/enter/终层)的登录身份
273
+ this.readyWaiter?.();
274
+ this.readyWaiter = undefined;
275
+ continue;
276
+ }
277
+ if (line.trim()) {
278
+ this.handshakeTail.push(line.length > 200 ? line.slice(0, 200) + "…" : line);
279
+ if (this.handshakeTail.length > 30) this.handshakeTail.shift();
280
+ }
281
+ if (SSH_INPUT_WAIT_RE.test(line)) {
282
+ this.readyRejecter?.(new Error(this.egressError(`建链卡在等待输入(交互提示): ${this.handshakeWhat}`)));
283
+ }
284
+ continue;
285
+ }
286
+ this.lineBuffer.push(line);
287
+ }
288
+ }
289
+ }
290
+
291
+ /** 层状态机(净室平移源语义):connecting → active → idle → dead / degraded(退化) */
292
+ export type LayerState = "connecting" | "active" | "idle" | "degraded" | "dead";
293
+ export const LAYER_IDLE_THRESHOLD_MS = 10_000;
294
+
295
+ export function resolveLayerState(
296
+ d: { alive: boolean; reconnecting?: boolean; execInFlight?: boolean; lastActivityAt?: number; shellGone?: string },
297
+ now: number = Date.now(),
298
+ ): LayerState {
299
+ if (!d.alive) return "dead";
300
+ if (d.shellGone) return "degraded";
301
+ if (d.reconnecting) return "connecting";
302
+ if (d.execInFlight) return "active";
303
+ if (d.lastActivityAt && now - d.lastActivityAt >= LAYER_IDLE_THRESHOLD_MS) return "idle";
304
+ return d.lastActivityAt ? "active" : "connecting";
305
+ }
306
+
307
+ export interface SshPtyDriverOptions {
308
+ logger?: FilaxisLogger;
309
+ }
310
+
311
+ /** SSH Transport driver:每层一条独立多跳 PTY + 双通道 exec + 远端会话/文件 */
312
+ export class SshPtyDriver implements RuntimeDriver {
313
+ readonly descriptor: RuntimeDescriptor;
314
+ readonly fs: FileSystemDriver;
315
+ readonly sessions: RemoteSessionManager;
316
+
317
+ private link: PtyLink = new PtyLink();
318
+ private alive = true;
319
+ effectiveUser?: string;
320
+ layerShellGone?: string;
321
+ layerState: LayerState = "connecting";
322
+ lastActivityAt = 0;
323
+ execInFlight = false;
324
+ reconnecting = false;
325
+ private reconnectAttempts = 0;
326
+ private readonly log: FilaxisLogger;
327
+
328
+ private static readonly MAX_RECONNECT = 3;
329
+ private static readonly RECONNECT_DELAYS_MS = [2_000, 5_000, 10_000];
330
+
331
+ constructor(descriptor: RuntimeDescriptor, opts: SshPtyDriverOptions = {}) {
332
+ this.descriptor = descriptor;
333
+ this.log = opts.logger ?? noopLogger;
334
+ const self = this;
335
+ const fsExec = async (cmd: string) => self.execChannel(cmd, undefined);
336
+ const statOf = async (p: string) => {
337
+ const r = await fsExec(`stat -c '%s %Y' ${q(p)} 2>/dev/null || stat -f '%z %m' ${q(p)} 2>/dev/null || true`);
338
+ const m = r.stdout.trim().match(/^(\d+)\s+(\d+)$/);
339
+ return m ? { size: parseInt(m[1]!, 10), mtimeMs: parseInt(m[2]!, 10) * 1000 } : null;
340
+ };
341
+ this.fs = {
342
+ async readFile(p) { return (await fsExec(`base64 ${q(p)} | base64 -d 2>/dev/null`)).stdout; },
343
+ async writeFile(p, content) {
344
+ const b64 = Buffer.from(content, "utf-8").toString("base64");
345
+ await fsExec(`printf %s ${q(b64)} | base64 -d > ${q(p)}`);
346
+ },
347
+ async exists(p) {
348
+ const r = await fsExec(`test -e ${q(p)} && echo Y || echo N`);
349
+ return r.stdout.trim() === "Y";
350
+ },
351
+ stat: statOf,
352
+ async readdir(p): Promise<FileEntry[]> {
353
+ const r = await fsExec(`ls -1A ${q(p)} 2>/dev/null || true`);
354
+ const names = r.stdout.split("\n").filter(Boolean);
355
+ return Promise.all(names.map(async (name) => {
356
+ const full = `${p.replace(/\/$/, "")}/${name}`;
357
+ const d = await fsExec(`test -d ${q(full)} && echo Y || echo N`);
358
+ const st = await statOf(full);
359
+ return { name, isDirectory: d.stdout.trim() === "Y", size: st?.size, mtime: st?.mtimeMs };
360
+ }));
361
+ },
362
+ async rm(p, options) { await fsExec(`rm ${options?.recursive ? "-r " : ""}${options?.force ? "-f " : ""}${q(p)}`); },
363
+ async mkdir(p, options) { await fsExec(`mkdir ${options?.recursive ? "-p " : ""}${q(p)}`); },
364
+ };
365
+ this.sessions = new RemoteSessionManager({ exec: (cmd) => this.exec(cmd), logger: this.log });
366
+ this.bindLink();
367
+ }
368
+
369
+ currentState(): LayerState {
370
+ return resolveLayerState({
371
+ alive: this.alive, reconnecting: this.reconnecting, execInFlight: this.execInFlight,
372
+ lastActivityAt: this.lastActivityAt, shellGone: this.layerShellGone,
373
+ });
374
+ }
375
+
376
+ /** 建链:root PTY + 逐跳登录(sshHops)+ enterSteps 有序管线 + cwd 恢复 + 身份探测 */
377
+ async init(): Promise<void> {
378
+ const target = this.descriptor.target ?? "";
379
+ if (!target) throw new Error("SshPtyDriver 需要 descriptor.target(user@host 或 ssh config 别名)");
380
+ const hops: SshHop[] = Array.isArray(this.descriptor.config?.sshHops) ? (this.descriptor.config.sshHops as SshHop[]) : [];
381
+ const enterSteps = enterStepsOf(this.descriptor.config as Record<string, unknown> | undefined);
382
+ const connParams = this.descriptor.config?.connParams as SshConnParams | undefined;
383
+ const rootHop: SshHop = connParams ? { host: target, conn: connParams } : target;
384
+
385
+ this.layerShellGone = undefined;
386
+ this.link.onLayerFallback = (evidence) => {
387
+ this.layerShellGone = evidence;
388
+ this.layerState = "degraded";
389
+ this.log.warn(`[SshPty] 内层 shell 已退出,层退化:${evidence}——连接仍在但已非本层环境`);
390
+ };
391
+
392
+ this.link.attach(rootSshSpawnArgs(sshArgsFor(rootHop), connParams));
393
+ await this.link.handshake(`ssh ${hopLabel(rootHop)}`);
394
+ for (const hop of hops) {
395
+ const { hopSshCommand } = await import("./ssh-helpers.js");
396
+ this.link.send(`${hopSshCommand(hop)}${this.link.registerFallbackStep()}\n`);
397
+ try {
398
+ await this.link.handshake(`ssh ${hopLabel(hop)}`);
399
+ } catch (err) {
400
+ this.link.kill(); // 失败回收链(报告 #2:残留 ssh 挂事件循环)
401
+ throw err;
402
+ }
403
+ this.assertNotFallenBack(`ssh ${hopLabel(hop)}`);
404
+ }
405
+ for (const step of enterSteps) {
406
+ this.link.send(`${step.cmd}${this.link.registerFallbackStep()}\n`);
407
+ try {
408
+ await this.link.handshake(`enter: ${step.cmd.slice(0, 40)}`);
409
+ } catch (err) {
410
+ this.link.kill();
411
+ throw err;
412
+ }
413
+ this.assertNotFallenBack(`enter: ${step.cmd.slice(0, 40)}`);
414
+ }
415
+ if (this.descriptor.cwd) {
416
+ await this.execChannel(`cd ${JSON.stringify(this.descriptor.cwd)} 2>/dev/null || true`, undefined);
417
+ }
418
+ // 登录身份:握手已顺带实测(冷启动锦囊:省一次独立 ssh 握手——建层耗时近半);
419
+ // 缺席(旧握手形态/极旧对端)时静默跳过——绝不回退独立 exec(回退即冷启动退化)
420
+ if (this.link.handshakeUser) this.effectiveUser = this.link.handshakeUser;
421
+ this.layerState = "active";
422
+ this.lastActivityAt = Date.now();
423
+ }
424
+
425
+ private assertNotFallenBack(what: string): void {
426
+ if (this.link.fallbackSeen) {
427
+ throw new Error(`层进入命令未驻留(${what})——命令执行后立即退出,目标可能不存在或命令有误\n[哨兵证据] ${this.link.fallbackSeen}\n[原始输出·直交]\n${this.link.handshakeEvidence()}`);
428
+ }
429
+ }
430
+
431
+ private scheduleReconnect(): void {
432
+ if (this.reconnecting || !this.alive) return;
433
+ this.reconnecting = true;
434
+ const attempt = () => {
435
+ if (!this.alive) { this.reconnecting = false; return; }
436
+ if (this.reconnectAttempts >= SshPtyDriver.MAX_RECONNECT) {
437
+ this.reconnecting = false;
438
+ this.layerState = "dead";
439
+ return;
440
+ }
441
+ this.reconnectAttempts++;
442
+ const delay = SshPtyDriver.RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempts - 1, SshPtyDriver.RECONNECT_DELAYS_MS.length - 1)]!;
443
+ const timer = setTimeout(() => {
444
+ void (async () => {
445
+ try {
446
+ this.link = new PtyLink();
447
+ this.bindLink();
448
+ await this.init();
449
+ this.reconnecting = false;
450
+ this.reconnectAttempts = 0;
451
+ this.lastActivityAt = Date.now();
452
+ this.layerState = "active";
453
+ } catch {
454
+ attempt();
455
+ }
456
+ })();
457
+ }, delay);
458
+ timer.unref?.();
459
+ };
460
+ attempt();
461
+ }
462
+
463
+ private bindLink(): void {
464
+ this.link.onUnexpectedExit = () => {
465
+ if (this.alive && !this.reconnecting) {
466
+ this.layerState = "connecting";
467
+ this.scheduleReconnect();
468
+ }
469
+ };
470
+ }
471
+
472
+ /** 派生 ssh 子层(独立连接):本层路径为前缀,enter 管线显式清空 */
473
+ async pushChild(hop: SshHop): Promise<SshPtyDriver> {
474
+ const myHops: SshHop[] = Array.isArray(this.descriptor.config?.sshHops) ? (this.descriptor.config.sshHops as SshHop[]) : [];
475
+ const child = new SshPtyDriver({
476
+ driver: "ssh", target: this.descriptor.target,
477
+ config: { ...this.descriptor.config, sshHops: [...myHops, hop], enterPipeline: undefined, enterCmd: undefined, enterLabel: undefined },
478
+ }, { logger: this.log });
479
+ await child.init();
480
+ return child;
481
+ }
482
+
483
+ /** 派生组合层(docker-in-ssh 等):父层管线全量继承再追加本步 */
484
+ async pushInto(enterCmd: string, enterLabel: string): Promise<SshPtyDriver> {
485
+ const myHops: SshHop[] = Array.isArray(this.descriptor.config?.sshHops) ? (this.descriptor.config.sshHops as SshHop[]) : [];
486
+ const enterPipeline = [...enterStepsOf(this.descriptor.config as Record<string, unknown> | undefined), { cmd: enterCmd, label: enterLabel }];
487
+ const child = new SshPtyDriver({
488
+ driver: "ssh", target: this.descriptor.target,
489
+ config: { ...this.descriptor.config, sshHops: myHops, enterPipeline, enterCmd: undefined, enterLabel: undefined },
490
+ }, { logger: this.log });
491
+ await child.init();
492
+ return child;
493
+ }
494
+
495
+ async exit(): Promise<void> {
496
+ if (!this.alive) return;
497
+ this.alive = false;
498
+ this.reconnecting = false;
499
+ this.layerState = "dead";
500
+ this.link.kill();
501
+ }
502
+
503
+ async destroy(): Promise<void> { await this.exit(); }
504
+
505
+ async getCwd(): Promise<string> { return (await this.exec("pwd")).stdout.trim(); }
506
+
507
+ async getEnv(key: string): Promise<string | undefined> {
508
+ return (await this.exec(`printf %s "\\$${key}"`)).stdout;
509
+ }
510
+
511
+ /** 双通道 exec 分发(FEAT-03) */
512
+ async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
513
+ if (!this.alive) throw new Error("层已退出");
514
+ if (this.layerShellGone) throw new Error(`层已退化:内层 shell 已退出(${this.layerShellGone.slice(0, 60)})——连接仍在但已非本层环境`);
515
+ if (this.reconnecting) throw new Error("连接意外断开,自动重连中——请稍后重试");
516
+ if (options?.channel === "pty") {
517
+ this.execInFlight = true;
518
+ try {
519
+ const r = await this.link.execMarker(command, options?.timeoutMs ?? 120_000, options?.signal);
520
+ if (r.effectiveUser) this.effectiveUser = r.effectiveUser;
521
+ return r;
522
+ } finally {
523
+ this.execInFlight = false;
524
+ this.lastActivityAt = Date.now();
525
+ }
526
+ }
527
+ return this.execChannel(command, options);
528
+ }
529
+
530
+ /**
531
+ * exec 通道(非 PTY,FEAT-03):独立 ssh 进程(无 -tt),stdout/stderr 物理分离;
532
+ * enterSteps 以 stdin 管道重放(`… shell -s` 链,零嵌套引用)+ 层 cwd 拼装 +
533
+ * nonce marker 实测 effectiveUser(子 shell + $? 变量锁)。
534
+ */
535
+ private async execChannel(command: string, options?: ExecOptions): Promise<ExecResult> {
536
+ const hops: SshHop[] = Array.isArray(this.descriptor.config?.sshHops) ? (this.descriptor.config.sshHops as SshHop[]) : [];
537
+ const enterSteps = enterStepsOf(this.descriptor.config as Record<string, unknown> | undefined);
538
+ const connParams = this.descriptor.config?.connParams as SshConnParams | undefined;
539
+ const rootHop: SshHop = connParams ? { host: this.descriptor.target ?? "", conn: connParams } : (this.descriptor.target ?? "");
540
+
541
+ const nonce = crypto.randomBytes(6).toString("hex");
542
+ const marker = `__FILAXIS_META_${nonce}`;
543
+ const wrapped =
544
+ `( ${command} ); __filaxis_ec=$?; ` +
545
+ `printf '\\n${marker}:%d:%s\\n' "$__filaxis_ec" "$(id -un 2>/dev/null)"; ` +
546
+ `exit $__filaxis_ec`;
547
+
548
+ const render = renderExecChannel(enterSteps, command, {
549
+ cwd: options?.cwd ?? this.descriptor.cwd, marker, nonceWrapped: wrapped,
550
+ });
551
+ if (render.kind === "unreachable") {
552
+ throw new Error(
553
+ `不可直达层(exec 通道):${render.reason}[${render.step}]——该层含 PTY 内交互二次跳转,请用 channel:"pty"(stderr 将混流,终端语义)`,
554
+ );
555
+ }
556
+
557
+ // 多跳 + 转发的 exec 通道:hops 收敛为 -J(ProxyJump)由 ssh 自代理;逐跳 PTY
558
+ // 登录链无法用单条非 PTY 进程复现 → 该形态层走 pty 通道(不可直达语义)
559
+ if (hops.length > 0) {
560
+ throw new Error(
561
+ `不可直达层(exec 通道):多跳 PTY 登录链(${hops.map(hopLabel).join(" → ")})无法以单条非 PTY 进程复现——请用 channel:"pty",或将途经机收敛为 -J jump(ProxyJump)`,
562
+ );
563
+ }
564
+
565
+ return new Promise<ExecResult>((resolve, reject) => {
566
+ const argv = [...autonomousSshOpts(connParams), ...sshArgsFor(rootHop), "--", ...render.remoteArgs];
567
+ const p = spawn("ssh", argv, {
568
+ env: { ...process.env, ...options?.env },
569
+ cwd: options?.cwd,
570
+ stdio: ["pipe", "pipe", "pipe"],
571
+ });
572
+ let stdout = "";
573
+ let stderr = "";
574
+ let settled = false;
575
+ p.stdout.on("data", (d: Buffer) => { stdout += d; });
576
+ p.stderr.on("data", (d: Buffer) => { stderr += d; });
577
+ p.stdin.write(render.stdinCmd);
578
+ p.stdin.end();
579
+
580
+ // 完成哨兵原则(故障注入实验 1A/1B 实证收口):__FILAXIS_META_ 尾缀是
581
+ // 「命令跑完」的唯一权威凭证——未见哨兵(断链/远端 kill -9/abort/信号杀)
582
+ // 一律 reject 附半截现场,绝不 resolve 伪造退出码。
583
+ const onAbort = () => {
584
+ // 杀的是本地 ssh——远端进程树状态未核实(GPT 加码:AbortError 不得伪装「远端已取消」)
585
+ p.kill("SIGTERM");
586
+ const e = new DOMException(
587
+ "exec aborted by AbortSignal(本地 ssh 已终止,远端进程状态未核实——outcome=unknown)",
588
+ "AbortError",
589
+ ) as DOMException & { outcome: "unknown"; reason: string };
590
+ e.outcome = "unknown";
591
+ e.reason = "aborted";
592
+ finish(() => reject(e));
593
+ };
594
+ const timer: ReturnType<typeof setTimeout> | null = options?.timeoutMs
595
+ ? setTimeout(() => {
596
+ p.kill("SIGTERM");
597
+ const e = new Error(`exec 通道超时(${options.timeoutMs}ms,远端进程状态未核实): ${command.slice(0, 60)}`) as Error & { outcome: "unknown" };
598
+ e.outcome = "unknown";
599
+ finish(() => reject(e));
600
+ }, options.timeoutMs)
601
+ : null;
602
+ const cleanupAbort = () => options?.signal?.removeEventListener("abort", onAbort);
603
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
604
+ const finish = (fn: () => void) => { if (!settled) { settled = true; if (timer) clearTimeout(timer); cleanupAbort(); fn(); } };
605
+ if (options?.signal?.aborted) onAbort(); // race-safe:传入时已 abort
606
+
607
+ p.on("close", (code) => {
608
+ let out = stdout;
609
+ let effectiveUser: string | undefined;
610
+ let sentinelCode: number | undefined;
611
+ const mIdx = out.lastIndexOf(`\n${marker}:`);
612
+ if (mIdx !== -1) {
613
+ const metaLine = out.slice(mIdx + 1).split("\n", 1)[0] ?? "";
614
+ const m = metaLine.match(new RegExp(`^${marker}:(-?\\d+):(.*)$`));
615
+ if (m) {
616
+ effectiveUser = m[2] === "" ? undefined : m[2];
617
+ sentinelCode = parseInt(m[1]!, 10);
618
+ out = out.slice(0, mIdx);
619
+ }
620
+ }
621
+ if (effectiveUser) this.effectiveUser = effectiveUser;
622
+ // 无哨兵=命令未跑完(「无法执行」语义)——reject 附半截现场(outcome=unknown)
623
+ if (sentinelCode === undefined) {
624
+ finish(() => {
625
+ const e = new Error(
626
+ `命令未完成(无完成哨兵——通道中断/进程被信号终止,close code=${code})。半截现场 stdout 尾 500 字符:\n${out.slice(-500)}\nstderr 尾 300:\n${stderr.slice(-300)}`,
627
+ ) as Error & { outcome: "unknown" };
628
+ e.outcome = "unknown";
629
+ reject(e);
630
+ });
631
+ return;
632
+ }
633
+ // 哨兵在场:退出码以哨兵为准(code===null 的信号杀竞态也不伪 0)
634
+ finish(() => resolve({ stdout: out, stderr, exitCode: sentinelCode, effectiveUser }));
635
+ });
636
+ p.on("error", (err) => finish(() => reject(err)));
637
+ });
638
+ }
639
+
640
+ // ── 会话族(委托 RemoteSessionManager)──
641
+ async execSession(command: string, opts?: SessionStartOptions): Promise<SessionHandleInfo> {
642
+ if (this.layerShellGone) throw new Error("层已退化——后台任务会落在父环境,请重建层后再启动");
643
+ return this.sessions.start(command, opts);
644
+ }
645
+ async tailSession(sessionId: string, lines?: number): Promise<string[]> { return this.sessions.tail(sessionId, lines); }
646
+ async readSessionLog(sessionId: string, opts?: ReadLogOptions): Promise<string> { return this.sessions.readLog(sessionId, opts); }
647
+ watchSession(sessionId: string, opts: WatchOptions): { cancel(): void } { return this.sessions.watch(sessionId, opts); }
648
+ async stopSession(sessionId: string): Promise<{ exitCode?: number | null }> { return this.sessions.stop(sessionId); }
649
+ listSessions() { return this.sessions.list(); }
650
+ }
651
+
652
+ /** shell 单引号安全引用 */
653
+ function q(s: string): string {
654
+ return `'${s.replace(/'/g, "'\\''")}'`;
655
+ }