@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/dist/index.js
ADDED
|
@@ -0,0 +1,4200 @@
|
|
|
1
|
+
var __esm = (fn, res, err) => () => {
|
|
2
|
+
if (fn)
|
|
3
|
+
try {
|
|
4
|
+
res = fn(fn = 0);
|
|
5
|
+
} catch (e) {
|
|
6
|
+
err = [e];
|
|
7
|
+
}
|
|
8
|
+
if (err)
|
|
9
|
+
throw err[0];
|
|
10
|
+
return res;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/ssh-helpers.ts
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
function renderForwardArg(f) {
|
|
17
|
+
if (f.kind === "D")
|
|
18
|
+
return String(f.port);
|
|
19
|
+
return `${f.bind ? `${f.bind}:` : ""}${f.port}:${f.target ?? ""}`;
|
|
20
|
+
}
|
|
21
|
+
function hopHost(hop) {
|
|
22
|
+
return isHopObj(hop) ? hop.host : hop;
|
|
23
|
+
}
|
|
24
|
+
function hopLabel(hop) {
|
|
25
|
+
if (!isHopObj(hop) || !hop.conn)
|
|
26
|
+
return hopHost(hop);
|
|
27
|
+
const { user, port, jump, forwards } = hop.conn;
|
|
28
|
+
let s = `${user ? `${user}@` : ""}${hopHost(hop)}${port ? `:${port}` : ""}`;
|
|
29
|
+
if (jump)
|
|
30
|
+
s += ` -J ${jump}`;
|
|
31
|
+
if (forwards?.length) {
|
|
32
|
+
s += ` ${forwards.map((f) => f.kind === "D" ? `-D${f.port}` : `-${f.kind}${f.port}→${f.target}`).join(" ")}`;
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
function sshArgsFor(hop) {
|
|
37
|
+
if (!isHopObj(hop) || !hop.conn)
|
|
38
|
+
return [hopHost(hop)];
|
|
39
|
+
const { user, port, keyFile, jump, forwards } = hop.conn;
|
|
40
|
+
const args = [];
|
|
41
|
+
if (jump)
|
|
42
|
+
args.push("-J", jump);
|
|
43
|
+
if (keyFile)
|
|
44
|
+
args.push("-i", expandHomePath(keyFile));
|
|
45
|
+
if (port)
|
|
46
|
+
args.push("-p", String(port));
|
|
47
|
+
for (const f of forwards ?? []) {
|
|
48
|
+
args.push(f.kind === "L" ? "-L" : f.kind === "R" ? "-R" : "-D", renderForwardArg(f));
|
|
49
|
+
}
|
|
50
|
+
args.push(user ? `${user}@${hopHost(hop)}` : hopHost(hop));
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
53
|
+
function sshArgsQuoted(hop) {
|
|
54
|
+
return sshArgsFor(hop).map((a) => /^[A-Za-z0-9_@:./-]+$/.test(a) ? a : `"${a.replace(/"/g, "\\\"")}"`).join(" ");
|
|
55
|
+
}
|
|
56
|
+
function expandHomePath(p) {
|
|
57
|
+
if (p === "~")
|
|
58
|
+
return homedir();
|
|
59
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
60
|
+
return join(homedir(), p.slice(2));
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
function enterStepsOf(config) {
|
|
64
|
+
if (Array.isArray(config?.enterPipeline)) {
|
|
65
|
+
return config.enterPipeline.filter((s) => s && typeof s.cmd === "string" && s.cmd).map((s) => ({ cmd: String(s.cmd), label: s.label ? String(s.label) : undefined }));
|
|
66
|
+
}
|
|
67
|
+
const legacy = config?.enterCmd ? String(config.enterCmd) : "";
|
|
68
|
+
return legacy ? [{ cmd: legacy, label: config?.enterLabel ? String(config.enterLabel) : undefined }] : [];
|
|
69
|
+
}
|
|
70
|
+
function autonomousSshOpts(conn) {
|
|
71
|
+
const opts = ["-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3"];
|
|
72
|
+
if (conn?.hostKeyPolicy === "strict") {
|
|
73
|
+
opts.push("-o", "StrictHostKeyChecking=yes");
|
|
74
|
+
if (conn.knownHostsFile) {
|
|
75
|
+
opts.push("-o", `UserKnownHostsFile=${expandHomePath(conn.knownHostsFile)}`);
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
opts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
79
|
+
}
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
function rootSshSpawnArgs(destArgs, conn) {
|
|
83
|
+
return ["-tt", ...autonomousSshOpts(conn), ...destArgs];
|
|
84
|
+
}
|
|
85
|
+
function hopSshCommand(hop) {
|
|
86
|
+
const conn = typeof hop === "object" ? hop.conn : undefined;
|
|
87
|
+
return `ssh ${autonomousSshOpts(conn).join(" ")} ${sshArgsQuoted(hop)}`;
|
|
88
|
+
}
|
|
89
|
+
async function resolveSshIdentity(hop) {
|
|
90
|
+
const cacheKey = typeof hop === "string" ? hop : JSON.stringify(hop);
|
|
91
|
+
const cached = identityCache.get(cacheKey);
|
|
92
|
+
if (cached)
|
|
93
|
+
return cached;
|
|
94
|
+
try {
|
|
95
|
+
const { execFile } = await import("node:child_process");
|
|
96
|
+
const { promisify } = await import("node:util");
|
|
97
|
+
const X = promisify(execFile);
|
|
98
|
+
const { stdout } = await X("ssh", ["-G", ...sshArgsFor(hop)], { timeout: 8000 });
|
|
99
|
+
const h = stdout.match(/^hostname (\S+)$/m)?.[1] ?? hopHost(hop);
|
|
100
|
+
const p = stdout.match(/^port (\d+)$/m)?.[1] ?? "22";
|
|
101
|
+
const u = stdout.match(/^user (\S+)$/m)?.[1] ?? "";
|
|
102
|
+
const id = `${u ? u + "@" : ""}${h}:${p}`;
|
|
103
|
+
identityCache.set(cacheKey, id);
|
|
104
|
+
return id;
|
|
105
|
+
} catch {
|
|
106
|
+
return `unresolved:${hopHost(hop)}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function layerPathIdentities(desc) {
|
|
110
|
+
const hops = Array.isArray(desc.config?.sshHops) ? desc.config.sshHops : [];
|
|
111
|
+
const connParams = desc.config?.connParams;
|
|
112
|
+
const root = desc.target ? connParams ? { host: desc.target, conn: connParams } : desc.target : "";
|
|
113
|
+
return [root, ...hops].filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
function layerDisplayName(desc) {
|
|
116
|
+
const hops = (Array.isArray(desc.config?.sshHops) ? desc.config.sshHops : []).map((h) => hopLabel(h));
|
|
117
|
+
const connParams = desc.config?.connParams;
|
|
118
|
+
const root = hopLabel(connParams ? { host: desc.target ?? "?", conn: connParams } : desc.target ?? "?");
|
|
119
|
+
const parts = [root, ...hops];
|
|
120
|
+
parts.push(...enterStepsOf(desc.config).map((s) => s.label ?? s.cmd.slice(0, 24)));
|
|
121
|
+
return parts.join(" → ");
|
|
122
|
+
}
|
|
123
|
+
function layerDisplayLabel(d) {
|
|
124
|
+
const base = layerDisplayName(d.descriptor);
|
|
125
|
+
return d.containerId ? `${base}(${d.containerId})` : base;
|
|
126
|
+
}
|
|
127
|
+
function renderExecChannel(steps, command, opts) {
|
|
128
|
+
const remoteArgs = [];
|
|
129
|
+
for (const s of steps) {
|
|
130
|
+
const cleaned = s.cmd.replace(/(^|\s)-(it|ti)\b/g, "$1-i").replace(/(^|\s)-t\b/g, "$1-i").trim();
|
|
131
|
+
const m = SHELL_TAIL_RE.exec(cleaned);
|
|
132
|
+
if (!m) {
|
|
133
|
+
return { kind: "unreachable", reason: "进入命令非「… shell」形态(无法非交互重放)", step: s.cmd };
|
|
134
|
+
}
|
|
135
|
+
remoteArgs.push(...cleaned.split(/\s+/), "-s");
|
|
136
|
+
}
|
|
137
|
+
remoteArgs.push("sh", "-s");
|
|
138
|
+
const cd = opts.cwd ? `cd ${JSON.stringify(opts.cwd)} 2>/dev/null || true; ` : "";
|
|
139
|
+
return { kind: "ok", remoteArgs, stdinCmd: `${cd}${opts.nonceWrapped}` };
|
|
140
|
+
}
|
|
141
|
+
var isHopObj = (h) => typeof h === "object", SSH_INPUT_WAIT_RE, identityCache, SHELL_TAIL_RE;
|
|
142
|
+
var init_ssh_helpers = __esm(() => {
|
|
143
|
+
SSH_INPUT_WAIT_RE = /(?:password|passphrase|verification\s+code|login|密码|口令)[^\n]*[::]\s*$|yes\s*\/\s*no|are you sure|continue connecting/i;
|
|
144
|
+
identityCache = new Map;
|
|
145
|
+
SHELL_TAIL_RE = /(?:^|\s)(bash|sh|zsh|ash|dash)\s*$/;
|
|
146
|
+
});
|
|
147
|
+
// src/session-buffer.ts
|
|
148
|
+
var MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
|
149
|
+
|
|
150
|
+
class LineAssembler {
|
|
151
|
+
pending = "";
|
|
152
|
+
feed(chunk) {
|
|
153
|
+
this.pending += chunk;
|
|
154
|
+
const lines = [];
|
|
155
|
+
let idx;
|
|
156
|
+
while ((idx = this.pending.indexOf(`
|
|
157
|
+
`)) !== -1) {
|
|
158
|
+
let line = this.pending.slice(0, idx);
|
|
159
|
+
if (line.endsWith("\r"))
|
|
160
|
+
line = line.slice(0, -1);
|
|
161
|
+
lines.push(line);
|
|
162
|
+
this.pending = this.pending.slice(idx + 1);
|
|
163
|
+
}
|
|
164
|
+
if (this.pending.length > MAX_PENDING_BYTES) {
|
|
165
|
+
lines.push(this.pending.slice(0, MAX_PENDING_BYTES) + "[TRUNCATED_CHUNK]");
|
|
166
|
+
this.pending = "";
|
|
167
|
+
}
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
flush() {
|
|
171
|
+
if (this.pending === "")
|
|
172
|
+
return [];
|
|
173
|
+
const rest = this.pending.endsWith("\r") ? this.pending.slice(0, -1) : this.pending;
|
|
174
|
+
this.pending = "";
|
|
175
|
+
return rest === "" ? [] : [rest];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
var TRUNC_SUFFIX = "[…truncated, see session log]";
|
|
179
|
+
|
|
180
|
+
class OutputRingBuffer {
|
|
181
|
+
maxLines;
|
|
182
|
+
maxLineBytes;
|
|
183
|
+
lines = [];
|
|
184
|
+
evictedLines = 0;
|
|
185
|
+
constructor(opts = {}) {
|
|
186
|
+
this.maxLines = opts.maxLines ?? 50;
|
|
187
|
+
this.maxLineBytes = opts.maxLineBytes ?? 512;
|
|
188
|
+
}
|
|
189
|
+
push(line) {
|
|
190
|
+
let text = line.text;
|
|
191
|
+
if (text.length > this.maxLineBytes) {
|
|
192
|
+
const keep = Math.max(0, this.maxLineBytes - TRUNC_SUFFIX.length);
|
|
193
|
+
text = text.slice(0, keep) + TRUNC_SUFFIX;
|
|
194
|
+
}
|
|
195
|
+
this.lines.push({ stream: line.stream, text });
|
|
196
|
+
if (this.lines.length > this.maxLines) {
|
|
197
|
+
const overflow = this.lines.length - this.maxLines;
|
|
198
|
+
this.lines = this.lines.slice(-this.maxLines);
|
|
199
|
+
this.evictedLines += overflow;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
tail(n) {
|
|
203
|
+
return this.lines.slice(-Math.max(0, n)).map((l) => l.text);
|
|
204
|
+
}
|
|
205
|
+
tailLines(n) {
|
|
206
|
+
return this.lines.slice(-Math.max(0, n));
|
|
207
|
+
}
|
|
208
|
+
get evictedCount() {
|
|
209
|
+
return this.evictedLines;
|
|
210
|
+
}
|
|
211
|
+
contextAround(index, contextLines) {
|
|
212
|
+
const from = Math.max(0, index - contextLines);
|
|
213
|
+
const to = Math.min(this.lines.length, index + contextLines + 1);
|
|
214
|
+
return this.lines.slice(from, to);
|
|
215
|
+
}
|
|
216
|
+
indexOfLine(text) {
|
|
217
|
+
const i = this.lines.findIndex((l) => l.text === text);
|
|
218
|
+
return i === -1 ? this.lines.length - 1 : i;
|
|
219
|
+
}
|
|
220
|
+
get size() {
|
|
221
|
+
return this.lines.length;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
var MAX_WATCHERS_PER_SESSION = 8;
|
|
225
|
+
|
|
226
|
+
class WatchRegistry {
|
|
227
|
+
watchers = new Map;
|
|
228
|
+
add(spec) {
|
|
229
|
+
if (this.watchers.size >= MAX_WATCHERS_PER_SESSION) {
|
|
230
|
+
throw new Error(`watcher 数量已达上限(${MAX_WATCHERS_PER_SESSION}/会话),先 cancel 旧的再注册`);
|
|
231
|
+
}
|
|
232
|
+
this.watchers.set(spec.id, { ...spec, regex: this.toRegex(spec.pattern) });
|
|
233
|
+
}
|
|
234
|
+
match(line) {
|
|
235
|
+
const hits = [];
|
|
236
|
+
for (const [id, w] of this.watchers) {
|
|
237
|
+
if (w.regex.test(line)) {
|
|
238
|
+
hits.push(id);
|
|
239
|
+
if (w.once !== false)
|
|
240
|
+
this.watchers.delete(id);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return hits;
|
|
244
|
+
}
|
|
245
|
+
cancel(id) {
|
|
246
|
+
return this.watchers.delete(id);
|
|
247
|
+
}
|
|
248
|
+
get size() {
|
|
249
|
+
return this.watchers.size;
|
|
250
|
+
}
|
|
251
|
+
toRegex(pattern) {
|
|
252
|
+
if (pattern instanceof RegExp)
|
|
253
|
+
return pattern;
|
|
254
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
255
|
+
return new RegExp(escaped);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// src/logger.ts
|
|
259
|
+
var noopLogger = {
|
|
260
|
+
debug: () => {
|
|
261
|
+
return;
|
|
262
|
+
},
|
|
263
|
+
info: () => {
|
|
264
|
+
return;
|
|
265
|
+
},
|
|
266
|
+
warn: () => {
|
|
267
|
+
return;
|
|
268
|
+
},
|
|
269
|
+
error: () => {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
// src/local-session-manager.ts
|
|
274
|
+
import { spawn } from "node:child_process";
|
|
275
|
+
import fs from "node:fs/promises";
|
|
276
|
+
import { createWriteStream } from "node:fs";
|
|
277
|
+
import path from "node:path";
|
|
278
|
+
import { tmpdir } from "node:os";
|
|
279
|
+
import * as crypto from "node:crypto";
|
|
280
|
+
|
|
281
|
+
// src/shell-discovery.ts
|
|
282
|
+
import { existsSync } from "node:fs";
|
|
283
|
+
import { execSync } from "node:child_process";
|
|
284
|
+
var GIT_BASH_CANDIDATES = [
|
|
285
|
+
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
286
|
+
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
|
287
|
+
"C:\\Program Files (x86)\\Git\\bin\\bash.exe"
|
|
288
|
+
];
|
|
289
|
+
function bashOnPath() {
|
|
290
|
+
try {
|
|
291
|
+
return execSync("where bash", { stdio: ["ignore", "pipe", "ignore"] }).toString().split(`
|
|
292
|
+
`).map((l) => l.trim()).filter(Boolean)[0];
|
|
293
|
+
} catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function resolveLocalShell(preferred, log) {
|
|
298
|
+
if (preferred)
|
|
299
|
+
return preferred;
|
|
300
|
+
if (process.platform !== "win32")
|
|
301
|
+
return "bash";
|
|
302
|
+
const fromEnv = process.env.FILAXIS_SHELL;
|
|
303
|
+
if (fromEnv)
|
|
304
|
+
return fromEnv;
|
|
305
|
+
const p = bashOnPath();
|
|
306
|
+
if (p && !/System32/i.test(p))
|
|
307
|
+
return "bash";
|
|
308
|
+
for (const c of GIT_BASH_CANDIDATES) {
|
|
309
|
+
if (existsSync(c))
|
|
310
|
+
return c;
|
|
311
|
+
}
|
|
312
|
+
log?.warn("[Filaxis] Windows 未找到可用 bash(PATH " + (p ? "上是 WSL 转发入口" : "上无 bash") + "且未嗅探到 Git Bash)——请安装 Git(https://git-scm.com)或设 FILAXIS_SHELL 指向 bash.exe 完整路径");
|
|
313
|
+
return "bash";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/local-session-manager.ts
|
|
317
|
+
function escapeRegExp(p) {
|
|
318
|
+
return p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
319
|
+
}
|
|
320
|
+
var MAX_FILE_LINE_BYTES = 1e6;
|
|
321
|
+
var MAX_SESSION_LOG_BYTES = 64 * 1024 * 1024;
|
|
322
|
+
var KILL_GRACE_MS = 1500;
|
|
323
|
+
|
|
324
|
+
class LocalSessionManager {
|
|
325
|
+
onEvent = () => {
|
|
326
|
+
return;
|
|
327
|
+
};
|
|
328
|
+
entries = new Map;
|
|
329
|
+
maxLines;
|
|
330
|
+
maxLineBytes;
|
|
331
|
+
ttlMs;
|
|
332
|
+
logDir;
|
|
333
|
+
shell;
|
|
334
|
+
log;
|
|
335
|
+
spawnImpl;
|
|
336
|
+
constructor(opts = {}) {
|
|
337
|
+
this.maxLines = opts.maxLines ?? 50;
|
|
338
|
+
this.maxLineBytes = opts.maxLineBytes ?? 512;
|
|
339
|
+
this.ttlMs = opts.ttlMs ?? 10 * 60000;
|
|
340
|
+
this.logDir = opts.logDir ?? path.join(tmpdir(), "filaxis-sessions");
|
|
341
|
+
this.log = opts.logger ?? noopLogger;
|
|
342
|
+
this.shell = resolveLocalShell(opts.shell, this.log);
|
|
343
|
+
this.spawnImpl = opts.spawnImpl ?? ((cmd, argv, o) => spawn(cmd, argv, o));
|
|
344
|
+
}
|
|
345
|
+
async start(command, opts = {}) {
|
|
346
|
+
const sessionId = `${opts.name ? opts.name.replace(/[^a-zA-Z0-9_-]/g, "-") + "-" : "sess-"}` + crypto.randomBytes(4).toString("hex");
|
|
347
|
+
await fs.mkdir(this.logDir, { recursive: true });
|
|
348
|
+
const logBase = path.join(this.logDir, sessionId);
|
|
349
|
+
const entry = {
|
|
350
|
+
sessionId,
|
|
351
|
+
command,
|
|
352
|
+
startedAt: Date.now(),
|
|
353
|
+
ownerSessionId: opts.ownerSessionId,
|
|
354
|
+
ring: new OutputRingBuffer({ maxLines: this.maxLines, maxLineBytes: this.maxLineBytes }),
|
|
355
|
+
watchers: new WatchRegistry,
|
|
356
|
+
watcherMeta: new Map,
|
|
357
|
+
assemblers: { stdout: new LineAssembler, stderr: new LineAssembler },
|
|
358
|
+
logStreams: {
|
|
359
|
+
stdout: createWriteStream(`${logBase}.out`),
|
|
360
|
+
stderr: createWriteStream(`${logBase}.err`)
|
|
361
|
+
},
|
|
362
|
+
logBase,
|
|
363
|
+
logBytes: 0,
|
|
364
|
+
logCapped: false,
|
|
365
|
+
running: true
|
|
366
|
+
};
|
|
367
|
+
this.entries.set(sessionId, entry);
|
|
368
|
+
const env = { ...process.env, ...opts.env };
|
|
369
|
+
const cwd = opts.cwd || process.cwd();
|
|
370
|
+
const proc = this.spawnImpl(this.shell, ["-c", command], {
|
|
371
|
+
env,
|
|
372
|
+
cwd,
|
|
373
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
374
|
+
});
|
|
375
|
+
entry.proc = proc;
|
|
376
|
+
proc.stdout?.on("data", (buf) => this.ingestChunk(entry, buf.toString("utf-8"), "stdout"));
|
|
377
|
+
proc.stderr?.on("data", (buf) => this.ingestChunk(entry, buf.toString("utf-8"), "stderr"));
|
|
378
|
+
proc.on("close", (code) => this.finalize(entry, code));
|
|
379
|
+
proc.on("error", (err) => {
|
|
380
|
+
this.ingestLine(entry, `[session-error] ${err.message}`, "stderr");
|
|
381
|
+
this.finalize(entry, null);
|
|
382
|
+
});
|
|
383
|
+
this.log.info(`[Session ${sessionId}] started: ${command.slice(0, 80)} (pid=${proc.pid})`);
|
|
384
|
+
return { sessionId, logBase, pid: proc.pid };
|
|
385
|
+
}
|
|
386
|
+
tail(sessionId, n = 50) {
|
|
387
|
+
return this.require(sessionId).ring.tail(n);
|
|
388
|
+
}
|
|
389
|
+
tailLines(sessionId, n = 50) {
|
|
390
|
+
return this.require(sessionId).ring.tailLines(n);
|
|
391
|
+
}
|
|
392
|
+
async readLog(sessionId, opts = {}) {
|
|
393
|
+
const entry = this.require(sessionId);
|
|
394
|
+
const file = opts.stream === "stderr" ? `${entry.logBase}.err` : `${entry.logBase}.out`;
|
|
395
|
+
let content;
|
|
396
|
+
try {
|
|
397
|
+
content = await fs.readFile(file, "utf-8");
|
|
398
|
+
} catch {
|
|
399
|
+
return "";
|
|
400
|
+
}
|
|
401
|
+
let lines = content.split(`
|
|
402
|
+
`);
|
|
403
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
404
|
+
lines.pop();
|
|
405
|
+
if (opts.offsetLines)
|
|
406
|
+
lines = lines.slice(opts.offsetLines);
|
|
407
|
+
if (opts.lines)
|
|
408
|
+
lines = lines.slice(-opts.lines);
|
|
409
|
+
return lines.join(`
|
|
410
|
+
`);
|
|
411
|
+
}
|
|
412
|
+
watch(sessionId, opts) {
|
|
413
|
+
const entry = this.require(sessionId);
|
|
414
|
+
const watcherId = `w-${crypto.randomBytes(3).toString("hex")}`;
|
|
415
|
+
entry.watchers.add({ id: watcherId, pattern: opts.pattern, once: opts.once });
|
|
416
|
+
entry.watcherMeta.set(watcherId, opts);
|
|
417
|
+
const regex = opts.pattern instanceof RegExp ? opts.pattern : new RegExp(escapeRegExp(String(opts.pattern)));
|
|
418
|
+
for (const l of entry.ring.tailLines(Infinity)) {
|
|
419
|
+
if (regex.test(l.text)) {
|
|
420
|
+
if (opts.once !== false) {
|
|
421
|
+
entry.watchers.cancel(watcherId);
|
|
422
|
+
entry.watcherMeta.delete(watcherId);
|
|
423
|
+
}
|
|
424
|
+
if (opts.thenStop)
|
|
425
|
+
this.stop(sessionId).catch(() => {
|
|
426
|
+
return;
|
|
427
|
+
});
|
|
428
|
+
this.emit({
|
|
429
|
+
type: "match",
|
|
430
|
+
sessionId,
|
|
431
|
+
ownerSessionId: entry.ownerSessionId,
|
|
432
|
+
watcherId,
|
|
433
|
+
pattern: String(opts.pattern),
|
|
434
|
+
matchedLine: l.text,
|
|
435
|
+
stream: l.stream,
|
|
436
|
+
context: entry.ring.contextAround(entry.ring.size - 1, opts.contextLines ?? 5).map((c) => c.text)
|
|
437
|
+
});
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
cancel: () => {
|
|
443
|
+
entry.watchers.cancel(watcherId);
|
|
444
|
+
entry.watcherMeta.delete(watcherId);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
async ready(sessionId, opts) {
|
|
449
|
+
const entry = this.require(sessionId);
|
|
450
|
+
const regex = opts.pattern instanceof RegExp ? opts.pattern : new RegExp(opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
451
|
+
for (const l of entry.ring.tailLines(Infinity)) {
|
|
452
|
+
if (regex.test(l.text))
|
|
453
|
+
return { matchedLine: l.text };
|
|
454
|
+
}
|
|
455
|
+
if (entry.ring.evictedCount > 0) {
|
|
456
|
+
throw new Error(`就绪标记可能已滚出热窗口(evicted=${entry.ring.evictedCount},历史不完整)——` + `可经 readLog 回查 ${entry.logBase}.out/.err,或加大窗口后重启会话`);
|
|
457
|
+
}
|
|
458
|
+
return await new Promise((resolve, reject) => {
|
|
459
|
+
const w = this.watch(sessionId, { pattern: opts.pattern, once: true });
|
|
460
|
+
let poll;
|
|
461
|
+
const timer = setTimeout(() => {
|
|
462
|
+
if (poll)
|
|
463
|
+
clearInterval(poll);
|
|
464
|
+
w.cancel();
|
|
465
|
+
this.stop(sessionId).catch(() => {
|
|
466
|
+
return;
|
|
467
|
+
});
|
|
468
|
+
reject(new Error(`ready() 超时(${opts.timeoutMs}ms)未见到就绪标记——会话已自动停止`));
|
|
469
|
+
}, opts.timeoutMs);
|
|
470
|
+
poll = setInterval(() => {
|
|
471
|
+
const e = this.entries.get(sessionId);
|
|
472
|
+
if (!e) {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
clearInterval(poll);
|
|
475
|
+
reject(new Error(`会话 ${sessionId} 已消失(终态回收)`));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
for (const l of e.ring.tailLines(5)) {
|
|
479
|
+
if (regex.test(l.text)) {
|
|
480
|
+
clearTimeout(timer);
|
|
481
|
+
clearInterval(poll);
|
|
482
|
+
w.cancel();
|
|
483
|
+
resolve({ matchedLine: l.text });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}, 30);
|
|
488
|
+
poll.unref?.();
|
|
489
|
+
if (!entry.running) {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
clearInterval(poll);
|
|
492
|
+
reject(new Error(`会话 ${sessionId} 已退出(exitCode=${entry.exitCode ?? "?"}),就绪标记未出现`));
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async stop(sessionId) {
|
|
497
|
+
const entry = this.require(sessionId);
|
|
498
|
+
if (!entry.running) {
|
|
499
|
+
return { status: "exited", exitCode: entry.exitCode };
|
|
500
|
+
}
|
|
501
|
+
if (entry.proc?.pid) {
|
|
502
|
+
try {
|
|
503
|
+
entry.proc.kill("SIGTERM");
|
|
504
|
+
} catch {}
|
|
505
|
+
const pid = entry.proc.pid;
|
|
506
|
+
setTimeout(() => {
|
|
507
|
+
try {
|
|
508
|
+
process.kill(pid, "SIGKILL");
|
|
509
|
+
} catch {}
|
|
510
|
+
}, KILL_GRACE_MS).unref();
|
|
511
|
+
}
|
|
512
|
+
for (let i = 0;i < 20 && entry.running; i++) {
|
|
513
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
514
|
+
}
|
|
515
|
+
if (!entry.running)
|
|
516
|
+
return { status: "exited", exitCode: entry.exitCode };
|
|
517
|
+
return { status: "stopped" };
|
|
518
|
+
}
|
|
519
|
+
list() {
|
|
520
|
+
return Array.from(this.entries.values()).map((e) => ({
|
|
521
|
+
sessionId: e.sessionId,
|
|
522
|
+
command: e.command,
|
|
523
|
+
running: e.running,
|
|
524
|
+
startedAt: e.startedAt
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
require(sessionId) {
|
|
528
|
+
const e = this.entries.get(sessionId);
|
|
529
|
+
if (!e)
|
|
530
|
+
throw new Error(`session '${sessionId}' 不存在(可能已终态被 TTL 回收)`);
|
|
531
|
+
return e;
|
|
532
|
+
}
|
|
533
|
+
ingestChunk(entry, text, stream) {
|
|
534
|
+
for (const line of entry.assemblers[stream].feed(text))
|
|
535
|
+
this.ingestLine(entry, line, stream);
|
|
536
|
+
}
|
|
537
|
+
ingestLine(entry, fullLine, stream) {
|
|
538
|
+
const fileChunk = fullLine.length > MAX_FILE_LINE_BYTES ? fullLine.slice(0, MAX_FILE_LINE_BYTES) + `
|
|
539
|
+
[…file-line-truncated]` : fullLine;
|
|
540
|
+
if (!entry.logCapped) {
|
|
541
|
+
entry.logBytes += fileChunk.length + 1;
|
|
542
|
+
if (entry.logBytes > MAX_SESSION_LOG_BYTES) {
|
|
543
|
+
entry.logCapped = true;
|
|
544
|
+
for (const s of ["stdout", "stderr"]) {
|
|
545
|
+
entry.logStreams[s].end(s === stream ? `[log-capped: session log exceeded ${MAX_SESSION_LOG_BYTES} bytes, file writes suppressed; ring buffer continues]
|
|
546
|
+
` : `[log-capped]
|
|
547
|
+
`);
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
entry.logStreams[stream].write(fileChunk + `
|
|
551
|
+
`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
entry.ring.push({ stream, text: fullLine });
|
|
555
|
+
const anchor = entry.ring.size - 1;
|
|
556
|
+
for (const watcherId of entry.watchers.match(fullLine)) {
|
|
557
|
+
const meta = entry.watcherMeta.get(watcherId);
|
|
558
|
+
entry.watcherMeta.delete(watcherId);
|
|
559
|
+
if (meta?.thenStop) {
|
|
560
|
+
this.stop(entry.sessionId).catch(() => {
|
|
561
|
+
return;
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
this.emit({
|
|
565
|
+
type: "match",
|
|
566
|
+
sessionId: entry.sessionId,
|
|
567
|
+
ownerSessionId: entry.ownerSessionId,
|
|
568
|
+
watcherId,
|
|
569
|
+
pattern: String(meta?.pattern ?? "?"),
|
|
570
|
+
matchedLine: entry.ring.tail(1)[0] ?? fullLine,
|
|
571
|
+
stream,
|
|
572
|
+
context: entry.ring.contextAround(anchor, meta?.contextLines ?? 5).map((l) => l.text)
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
finalize(entry, exitCode) {
|
|
577
|
+
if (!entry.running)
|
|
578
|
+
return;
|
|
579
|
+
entry.running = false;
|
|
580
|
+
entry.exitCode = exitCode;
|
|
581
|
+
for (const s of ["stdout", "stderr"]) {
|
|
582
|
+
for (const line of entry.assemblers[s].flush())
|
|
583
|
+
this.ingestLine(entry, line, s);
|
|
584
|
+
if (!entry.logCapped)
|
|
585
|
+
entry.logStreams[s].end();
|
|
586
|
+
}
|
|
587
|
+
entry.watcherMeta.clear();
|
|
588
|
+
this.emit({
|
|
589
|
+
type: "exit",
|
|
590
|
+
sessionId: entry.sessionId,
|
|
591
|
+
ownerSessionId: entry.ownerSessionId,
|
|
592
|
+
exitCode,
|
|
593
|
+
lastLines: entry.ring.tail(20)
|
|
594
|
+
});
|
|
595
|
+
this.log.info(`[Session ${entry.sessionId}] exited code=${exitCode}`);
|
|
596
|
+
entry.ttlTimer = setTimeout(() => this.entries.delete(entry.sessionId), this.ttlMs).unref();
|
|
597
|
+
}
|
|
598
|
+
emit(ev) {
|
|
599
|
+
try {
|
|
600
|
+
this.onEvent(ev);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this.log.warn(`session event sink threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// src/local-driver.ts
|
|
607
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
608
|
+
import fs2 from "node:fs/promises";
|
|
609
|
+
import { createWriteStream as createWriteStream2 } from "node:fs";
|
|
610
|
+
import path2 from "node:path";
|
|
611
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
612
|
+
import * as crypto2 from "node:crypto";
|
|
613
|
+
class LocalRuntimeDriver {
|
|
614
|
+
descriptor = { driver: "local", target: "localhost" };
|
|
615
|
+
fs;
|
|
616
|
+
sessions;
|
|
617
|
+
defaultCwd;
|
|
618
|
+
execDir;
|
|
619
|
+
shell;
|
|
620
|
+
log;
|
|
621
|
+
constructor(opts = {}) {
|
|
622
|
+
this.defaultCwd = opts.cwd;
|
|
623
|
+
this.execDir = opts.execDir ?? path2.join(tmpdir2(), "filaxis-exec");
|
|
624
|
+
this.log = opts.logger ?? noopLogger;
|
|
625
|
+
this.shell = resolveLocalShell(opts.shell, this.log);
|
|
626
|
+
this.fs = new LocalFileSystem;
|
|
627
|
+
this.sessions = new LocalSessionManager({
|
|
628
|
+
logDir: path2.join(this.execDir, "sessions"),
|
|
629
|
+
shell: this.shell,
|
|
630
|
+
logger: this.log
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
static fromDescriptor(descriptor, fallbackCwd) {
|
|
634
|
+
const drv = new LocalRuntimeDriver({ cwd: descriptor.cwd ?? fallbackCwd });
|
|
635
|
+
drv.descriptor = descriptor;
|
|
636
|
+
return drv;
|
|
637
|
+
}
|
|
638
|
+
async init() {}
|
|
639
|
+
async destroy() {}
|
|
640
|
+
async getCwd() {
|
|
641
|
+
return this.defaultCwd || process.cwd();
|
|
642
|
+
}
|
|
643
|
+
async getEnv(key) {
|
|
644
|
+
return process.env[key];
|
|
645
|
+
}
|
|
646
|
+
async execSession(command, opts) {
|
|
647
|
+
return this.sessions.start(command, opts);
|
|
648
|
+
}
|
|
649
|
+
async tailSession(sessionId, lines = 50) {
|
|
650
|
+
return this.sessions.tail(sessionId, lines);
|
|
651
|
+
}
|
|
652
|
+
async readSessionLog(sessionId, opts) {
|
|
653
|
+
return this.sessions.readLog(sessionId, opts);
|
|
654
|
+
}
|
|
655
|
+
watchSession(sessionId, opts) {
|
|
656
|
+
return this.sessions.watch(sessionId, opts);
|
|
657
|
+
}
|
|
658
|
+
async stopSession(sessionId) {
|
|
659
|
+
return this.sessions.stop(sessionId);
|
|
660
|
+
}
|
|
661
|
+
listSessions() {
|
|
662
|
+
return this.sessions.list();
|
|
663
|
+
}
|
|
664
|
+
async exec(command, options) {
|
|
665
|
+
if (options?.channel === "pty") {
|
|
666
|
+
throw new Error('local Transport 不支持 channel:"pty"(PTY 交互语义属远端 Transport;本地 exec 通道天然分离且恒可用)');
|
|
667
|
+
}
|
|
668
|
+
await fs2.mkdir(this.execDir, { recursive: true });
|
|
669
|
+
const taskId = crypto2.randomBytes(8).toString("hex");
|
|
670
|
+
const logPath = path2.join(this.execDir, `${taskId}.log`);
|
|
671
|
+
const statusPath = path2.join(this.execDir, `${taskId}.status.json`);
|
|
672
|
+
const nonce = crypto2.randomBytes(6).toString("hex");
|
|
673
|
+
const marker = `__FILAXIS_META_${nonce}__`;
|
|
674
|
+
const wrapped = `( ${command} ); __filaxis_ec=$?; ` + `printf '\\n${marker}:%d:%s\\n' "$__filaxis_ec" "$(id -un 2>/dev/null)"; ` + `exit $__filaxis_ec`;
|
|
675
|
+
return new Promise((resolve, reject) => {
|
|
676
|
+
let stdout = "";
|
|
677
|
+
let stderr = "";
|
|
678
|
+
const env = { ...process.env, ...options?.env };
|
|
679
|
+
const cwd = options?.cwd || this.defaultCwd || process.cwd();
|
|
680
|
+
const p = spawn2(this.shell, ["-c", wrapped], {
|
|
681
|
+
env,
|
|
682
|
+
cwd,
|
|
683
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
684
|
+
});
|
|
685
|
+
const startTime = Date.now();
|
|
686
|
+
const writeStatus = async (status, extra = {}) => {
|
|
687
|
+
try {
|
|
688
|
+
await fs2.writeFile(statusPath, JSON.stringify({
|
|
689
|
+
pid: p.pid,
|
|
690
|
+
command,
|
|
691
|
+
status,
|
|
692
|
+
startTime,
|
|
693
|
+
endTime: Date.now(),
|
|
694
|
+
...extra
|
|
695
|
+
}, null, 2), "utf-8");
|
|
696
|
+
} catch {}
|
|
697
|
+
};
|
|
698
|
+
writeStatus("running");
|
|
699
|
+
const logStream = createWriteStream2(logPath);
|
|
700
|
+
p.stdout.on("data", (d) => {
|
|
701
|
+
stdout += d;
|
|
702
|
+
logStream.write(d);
|
|
703
|
+
});
|
|
704
|
+
p.stderr.on("data", (d) => {
|
|
705
|
+
stderr += d;
|
|
706
|
+
logStream.write(d);
|
|
707
|
+
});
|
|
708
|
+
let settled = false;
|
|
709
|
+
let finished = false;
|
|
710
|
+
const cleanupProcess = (exitStatus, extra = {}) => {
|
|
711
|
+
if (finished)
|
|
712
|
+
return;
|
|
713
|
+
finished = true;
|
|
714
|
+
logStream.end();
|
|
715
|
+
return writeStatus(exitStatus, extra);
|
|
716
|
+
};
|
|
717
|
+
const detachLifecycle = () => {
|
|
718
|
+
if (timeout)
|
|
719
|
+
clearTimeout(timeout);
|
|
720
|
+
if (onAbort)
|
|
721
|
+
signal?.removeEventListener("abort", onAbort);
|
|
722
|
+
};
|
|
723
|
+
const timeout = options?.timeoutMs ? setTimeout(() => {
|
|
724
|
+
if (settled)
|
|
725
|
+
return;
|
|
726
|
+
settled = true;
|
|
727
|
+
finished = true;
|
|
728
|
+
detachLifecycle();
|
|
729
|
+
p.unref();
|
|
730
|
+
logStream.end();
|
|
731
|
+
writeStatus("timeout", { error: "Command timeout", exitCode: null });
|
|
732
|
+
const e = new Error(`Command timeout after ${options.timeoutMs}ms (process continues in background, log: ${logPath}, status: ${statusPath})`);
|
|
733
|
+
e.outcome = "unknown";
|
|
734
|
+
reject(e);
|
|
735
|
+
}, options.timeoutMs) : null;
|
|
736
|
+
const signal = options?.signal;
|
|
737
|
+
const onAbort = signal ? () => {
|
|
738
|
+
if (settled)
|
|
739
|
+
return;
|
|
740
|
+
settled = true;
|
|
741
|
+
finished = true;
|
|
742
|
+
detachLifecycle();
|
|
743
|
+
try {
|
|
744
|
+
p.kill("SIGTERM");
|
|
745
|
+
} catch {}
|
|
746
|
+
logStream.end();
|
|
747
|
+
writeStatus("aborted", { error: "Aborted by AbortSignal", exitCode: null });
|
|
748
|
+
const abortErr = new Error("Command aborted by AbortSignal(本地进程已 SIGTERM,终态未核实——outcome=unknown)");
|
|
749
|
+
abortErr.name = "AbortError";
|
|
750
|
+
abortErr.outcome = "unknown";
|
|
751
|
+
reject(abortErr);
|
|
752
|
+
} : null;
|
|
753
|
+
if (signal && onAbort) {
|
|
754
|
+
if (signal.aborted)
|
|
755
|
+
onAbort();
|
|
756
|
+
else
|
|
757
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
758
|
+
}
|
|
759
|
+
p.on("close", (code) => {
|
|
760
|
+
detachLifecycle();
|
|
761
|
+
if (settled)
|
|
762
|
+
return;
|
|
763
|
+
settled = true;
|
|
764
|
+
const finish = () => {
|
|
765
|
+
let out = stdout;
|
|
766
|
+
let effectiveUser;
|
|
767
|
+
let sentinelCode;
|
|
768
|
+
const mIdx = out.lastIndexOf(`
|
|
769
|
+
${marker}:`);
|
|
770
|
+
if (mIdx !== -1) {
|
|
771
|
+
const metaLine = out.slice(mIdx + 1).split(`
|
|
772
|
+
`, 1)[0] ?? "";
|
|
773
|
+
const m = metaLine.match(new RegExp(`^${marker}:(-?\\d+):(.*)$`));
|
|
774
|
+
if (m) {
|
|
775
|
+
effectiveUser = m[2] === "" ? undefined : m[2];
|
|
776
|
+
sentinelCode = parseInt(m[1], 10);
|
|
777
|
+
out = out.slice(0, mIdx);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
logStream.end();
|
|
781
|
+
if (sentinelCode === undefined) {
|
|
782
|
+
writeStatus("killed", { exitCode: null, closeCode: code ?? null });
|
|
783
|
+
const e = new Error(`命令未完成(无完成哨兵——进程被信号终止,close code=${code})。半截现场 stdout 尾 500:
|
|
784
|
+
${out.slice(-500)}
|
|
785
|
+
stderr 尾 300:
|
|
786
|
+
${stderr.slice(-300)}`);
|
|
787
|
+
e.outcome = "unknown";
|
|
788
|
+
reject(e);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
writeStatus("completed", { exitCode: sentinelCode });
|
|
792
|
+
resolve({ stdout: out, stderr, exitCode: sentinelCode, effectiveUser });
|
|
793
|
+
};
|
|
794
|
+
finish();
|
|
795
|
+
});
|
|
796
|
+
p.on("error", (err) => {
|
|
797
|
+
detachLifecycle();
|
|
798
|
+
const pWrite = cleanupProcess("failed", { error: err.message, exitCode: -1 });
|
|
799
|
+
if (settled)
|
|
800
|
+
return;
|
|
801
|
+
settled = true;
|
|
802
|
+
if (pWrite)
|
|
803
|
+
pWrite.then(() => reject(err), () => reject(err));
|
|
804
|
+
else
|
|
805
|
+
reject(err);
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
class LocalFileSystem {
|
|
812
|
+
async readFile(filePath) {
|
|
813
|
+
return await fs2.readFile(filePath, "utf-8");
|
|
814
|
+
}
|
|
815
|
+
async readBase64(filePath) {
|
|
816
|
+
const buf = await fs2.readFile(filePath);
|
|
817
|
+
return Buffer.from(buf).toString("base64");
|
|
818
|
+
}
|
|
819
|
+
async writeFile(filePath, content) {
|
|
820
|
+
await fs2.writeFile(filePath, content, "utf-8");
|
|
821
|
+
}
|
|
822
|
+
async exists(filePath) {
|
|
823
|
+
try {
|
|
824
|
+
await fs2.access(filePath);
|
|
825
|
+
return true;
|
|
826
|
+
} catch {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async stat(filePath) {
|
|
831
|
+
try {
|
|
832
|
+
const st = await fs2.stat(filePath);
|
|
833
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
834
|
+
} catch {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
async readdir(dirPath) {
|
|
839
|
+
const dirents = await fs2.readdir(dirPath, { withFileTypes: true });
|
|
840
|
+
return await Promise.all(dirents.map(async (d) => {
|
|
841
|
+
const stats = await fs2.stat(path2.join(dirPath, d.name)).catch(() => null);
|
|
842
|
+
return {
|
|
843
|
+
name: d.name,
|
|
844
|
+
isDirectory: d.isDirectory(),
|
|
845
|
+
size: stats?.size,
|
|
846
|
+
mtime: stats?.mtimeMs
|
|
847
|
+
};
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
async rm(filePath, options) {
|
|
851
|
+
await fs2.rm(filePath, options);
|
|
852
|
+
}
|
|
853
|
+
async mkdir(dirPath, options) {
|
|
854
|
+
await fs2.mkdir(dirPath, options);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// src/events.ts
|
|
858
|
+
import { EventEmitter } from "node:events";
|
|
859
|
+
var DEFAULT_ITER_QUEUE_MAX = 1024;
|
|
860
|
+
|
|
861
|
+
class EventBus {
|
|
862
|
+
emitter = new EventEmitter;
|
|
863
|
+
queueMax;
|
|
864
|
+
iterables = new Set;
|
|
865
|
+
constructor(opts = {}) {
|
|
866
|
+
this.queueMax = opts.queueMax ?? DEFAULT_ITER_QUEUE_MAX;
|
|
867
|
+
this.emitter.setMaxListeners(0);
|
|
868
|
+
}
|
|
869
|
+
on(type, handler) {
|
|
870
|
+
const wrapped = (ev) => {
|
|
871
|
+
try {
|
|
872
|
+
handler(ev);
|
|
873
|
+
} catch {}
|
|
874
|
+
};
|
|
875
|
+
this.emitter.on(type === "*" ? "__all" : type, wrapped);
|
|
876
|
+
return () => this.emitter.off(type === "*" ? "__all" : type, wrapped);
|
|
877
|
+
}
|
|
878
|
+
once(type, handler) {
|
|
879
|
+
try {
|
|
880
|
+
this.emitter.once(type, handler);
|
|
881
|
+
} catch {}
|
|
882
|
+
}
|
|
883
|
+
off(type, handler) {
|
|
884
|
+
this.emitter.off(type === "*" ? "__all" : type, handler);
|
|
885
|
+
}
|
|
886
|
+
emit(ev) {
|
|
887
|
+
this.emitter.emit(ev.type, ev);
|
|
888
|
+
this.emitter.emit("__all", ev);
|
|
889
|
+
for (const it of this.iterables) {
|
|
890
|
+
if (!it.active)
|
|
891
|
+
continue;
|
|
892
|
+
if (it.queue.length >= this.queueMax) {
|
|
893
|
+
it.queue.shift();
|
|
894
|
+
it.dropped++;
|
|
895
|
+
}
|
|
896
|
+
it.queue.push(ev);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
iterable(type) {
|
|
900
|
+
const entry = { queue: [], dropped: 0, active: true };
|
|
901
|
+
this.iterables.add(entry);
|
|
902
|
+
const handle = {
|
|
903
|
+
[Symbol.asyncIterator]: async function* () {
|
|
904
|
+
try {
|
|
905
|
+
while (entry.active) {
|
|
906
|
+
if (entry.queue.length === 0) {
|
|
907
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const ev = entry.queue.shift();
|
|
911
|
+
if (type && ev.type !== type)
|
|
912
|
+
continue;
|
|
913
|
+
yield ev;
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
entry.active = false;
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
dropped: () => entry.dropped
|
|
920
|
+
};
|
|
921
|
+
handle.__entry = entry;
|
|
922
|
+
return handle;
|
|
923
|
+
}
|
|
924
|
+
listenerCount(type) {
|
|
925
|
+
return this.emitter.listenerCount(type === "*" || !type ? "__all" : type);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// src/ssh-pty-driver.ts
|
|
929
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
930
|
+
import * as crypto4 from "node:crypto";
|
|
931
|
+
init_ssh_helpers();
|
|
932
|
+
|
|
933
|
+
// src/remote-session-manager.ts
|
|
934
|
+
import * as crypto3 from "node:crypto";
|
|
935
|
+
var q = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
936
|
+
|
|
937
|
+
class RemoteSessionManager {
|
|
938
|
+
onEvent = () => {
|
|
939
|
+
return;
|
|
940
|
+
};
|
|
941
|
+
entries = new Map;
|
|
942
|
+
exec;
|
|
943
|
+
logDir;
|
|
944
|
+
pollMs;
|
|
945
|
+
idleReapMs;
|
|
946
|
+
log;
|
|
947
|
+
constructor(opts) {
|
|
948
|
+
this.exec = opts.exec;
|
|
949
|
+
this.logDir = opts.logDir ?? "/tmp/filaxis-sessions";
|
|
950
|
+
this.pollMs = opts.pollMs ?? 1500;
|
|
951
|
+
this.idleReapMs = opts.idleReapMs;
|
|
952
|
+
this.log = opts.logger ?? noopLogger;
|
|
953
|
+
}
|
|
954
|
+
async start(command, opts = {}) {
|
|
955
|
+
const sessionId = `${opts.name ? opts.name.replace(/[^a-zA-Z0-9_-]/g, "-") + "-" : "rsess-"}` + crypto3.randomBytes(4).toString("hex");
|
|
956
|
+
const base = `${this.logDir}/${sessionId}`;
|
|
957
|
+
const wrapped = `{ ${command}; }; echo $? > ${base}.code`;
|
|
958
|
+
const cd = opts.cwd ? `cd ${q(opts.cwd)} && ` : "";
|
|
959
|
+
const envPrefix = opts.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${q(v)}`).join(" ") + " " : "";
|
|
960
|
+
const r = await this.exec(`mkdir -p ${q(this.logDir)}; ${cd}${envPrefix}nohup sh -c ${q(wrapped)} > ${base}.out 2> ${base}.err < /dev/null & echo $!`);
|
|
961
|
+
const pid = parseInt((r.stdout || "").trim().split(`
|
|
962
|
+
`).pop() ?? "", 10);
|
|
963
|
+
const entry = {
|
|
964
|
+
sessionId,
|
|
965
|
+
command,
|
|
966
|
+
base,
|
|
967
|
+
pid: Number.isFinite(pid) ? pid : undefined,
|
|
968
|
+
startedAt: Date.now(),
|
|
969
|
+
ownerSessionId: opts.ownerSessionId,
|
|
970
|
+
running: true,
|
|
971
|
+
outOffset: 0,
|
|
972
|
+
assembler: new LineAssembler,
|
|
973
|
+
watchers: new Map,
|
|
974
|
+
lastAttentionAt: Date.now()
|
|
975
|
+
};
|
|
976
|
+
this.entries.set(sessionId, entry);
|
|
977
|
+
this.startPolling(entry);
|
|
978
|
+
this.log.info(`[RemoteSession ${sessionId}] started (pid=${entry.pid}): ${command.slice(0, 60)}`);
|
|
979
|
+
return { sessionId, logBase: base, pid: entry.pid };
|
|
980
|
+
}
|
|
981
|
+
async tail(sessionId, n = 50) {
|
|
982
|
+
const e = this.require(sessionId);
|
|
983
|
+
e.lastAttentionAt = Date.now();
|
|
984
|
+
const r = await this.exec(`tail -n ${n} ${e.base}.out 2>/dev/null || true`);
|
|
985
|
+
return (r.stdout || "").split(`
|
|
986
|
+
`).filter((l, i, arr) => l !== "" || i < arr.length - 1).filter((l) => l !== "");
|
|
987
|
+
}
|
|
988
|
+
async readLog(sessionId, opts = {}) {
|
|
989
|
+
const e = this.require(sessionId);
|
|
990
|
+
const file = opts.stream === "stderr" ? `${e.base}.err` : `${e.base}.out`;
|
|
991
|
+
let cmd = `cat ${file} 2>/dev/null || true`;
|
|
992
|
+
if (opts.offsetLines)
|
|
993
|
+
cmd = `tail -n +${opts.offsetLines + 1} ${file} 2>/dev/null || true`;
|
|
994
|
+
if (opts.lines)
|
|
995
|
+
cmd += ` | tail -n ${opts.lines}`;
|
|
996
|
+
const r = await this.exec(cmd);
|
|
997
|
+
return (r.stdout || "").replace(/\n$/, "");
|
|
998
|
+
}
|
|
999
|
+
watch(sessionId, opts) {
|
|
1000
|
+
const e = this.require(sessionId);
|
|
1001
|
+
e.lastAttentionAt = Date.now();
|
|
1002
|
+
const id = `rw-${crypto3.randomBytes(3).toString("hex")}`;
|
|
1003
|
+
const regex = opts.pattern instanceof RegExp ? opts.pattern : new RegExp(opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
1004
|
+
e.watchers.set(id, { ...opts, regex });
|
|
1005
|
+
return {
|
|
1006
|
+
cancel: () => {
|
|
1007
|
+
e.watchers.delete(id);
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
async tailLines(sessionId, n = 50) {
|
|
1012
|
+
const e = this.require(sessionId);
|
|
1013
|
+
e.lastAttentionAt = Date.now();
|
|
1014
|
+
const r = await this.exec(`tail -n ${n} ${e.base}.out 2>/dev/null; echo __FX_TL__; tail -n ${n} ${e.base}.err 2>/dev/null`);
|
|
1015
|
+
const parts = (r.stdout || "").split("__FX_TL__");
|
|
1016
|
+
const out = [];
|
|
1017
|
+
for (const l of (parts[0] ?? "").split(`
|
|
1018
|
+
`)) {
|
|
1019
|
+
if (l.trim() !== "")
|
|
1020
|
+
out.push({ stream: "stdout", text: l });
|
|
1021
|
+
}
|
|
1022
|
+
for (const l of (parts[1] ?? "").split(`
|
|
1023
|
+
`)) {
|
|
1024
|
+
if (l.trim() !== "")
|
|
1025
|
+
out.push({ stream: "stderr", text: l });
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
async stop(sessionId) {
|
|
1030
|
+
const e = this.require(sessionId);
|
|
1031
|
+
if (!e.running)
|
|
1032
|
+
return { status: e.exitCode != null ? "exited" : "stopped", exitCode: e.exitCode };
|
|
1033
|
+
if (e.pid == null)
|
|
1034
|
+
return { status: "unknown" };
|
|
1035
|
+
try {
|
|
1036
|
+
await this.exec(`pkill -TERM -P ${e.pid} 2>/dev/null; kill -TERM ${e.pid} 2>/dev/null; sleep 1; kill -0 ${e.pid} 2>/dev/null && echo ALIVE || echo DEAD`);
|
|
1037
|
+
const probe = await this.exec(`kill -0 ${e.pid} 2>/dev/null && echo ALIVE || echo DEAD`);
|
|
1038
|
+
if (probe.stdout.includes("ALIVE")) {
|
|
1039
|
+
await this.exec(`pkill -KILL -P ${e.pid} 2>/dev/null; kill -KILL ${e.pid} 2>/dev/null; true`);
|
|
1040
|
+
}
|
|
1041
|
+
return { status: "stopped" };
|
|
1042
|
+
} catch {
|
|
1043
|
+
return { status: "unknown" };
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
list() {
|
|
1047
|
+
return Array.from(this.entries.values()).map((e) => ({
|
|
1048
|
+
sessionId: e.sessionId,
|
|
1049
|
+
command: e.command,
|
|
1050
|
+
running: e.running,
|
|
1051
|
+
startedAt: e.startedAt
|
|
1052
|
+
}));
|
|
1053
|
+
}
|
|
1054
|
+
reapIdle(now = Date.now(), ttlMs) {
|
|
1055
|
+
const ttl = ttlMs ?? this.idleReapMs ?? 30 * 60 * 1000;
|
|
1056
|
+
for (const e of Array.from(this.entries.values())) {
|
|
1057
|
+
if (!e.running)
|
|
1058
|
+
continue;
|
|
1059
|
+
if (e.watchers.size > 0)
|
|
1060
|
+
continue;
|
|
1061
|
+
if (now - e.lastAttentionAt < ttl)
|
|
1062
|
+
continue;
|
|
1063
|
+
this.emit({
|
|
1064
|
+
type: "exit",
|
|
1065
|
+
sessionId: e.sessionId,
|
|
1066
|
+
ownerSessionId: e.ownerSessionId,
|
|
1067
|
+
exitCode: null,
|
|
1068
|
+
lastLines: []
|
|
1069
|
+
});
|
|
1070
|
+
this.stop(e.sessionId).catch(() => {
|
|
1071
|
+
return;
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
require(sessionId) {
|
|
1076
|
+
const e = this.entries.get(sessionId);
|
|
1077
|
+
if (!e)
|
|
1078
|
+
throw new Error(`remote session '${sessionId}' 不存在`);
|
|
1079
|
+
return e;
|
|
1080
|
+
}
|
|
1081
|
+
startPolling(entry) {
|
|
1082
|
+
entry.pollTimer = setInterval(() => void this.poll(entry).catch(() => {
|
|
1083
|
+
return;
|
|
1084
|
+
}), this.pollMs);
|
|
1085
|
+
entry.pollTimer.unref?.();
|
|
1086
|
+
}
|
|
1087
|
+
async poll(entry) {
|
|
1088
|
+
if (!entry.running)
|
|
1089
|
+
return;
|
|
1090
|
+
try {
|
|
1091
|
+
const r = await this.exec(`tail -n +${entry.outOffset + 1} ${entry.base}.out 2>/dev/null || true`);
|
|
1092
|
+
const text = r.stdout || "";
|
|
1093
|
+
if (text) {
|
|
1094
|
+
entry.outOffset += text.split(`
|
|
1095
|
+
`).filter((l, i, a) => i < a.length - 1 || l !== "").length;
|
|
1096
|
+
for (const line of entry.assembler.feed(text))
|
|
1097
|
+
this.onLine(entry, line);
|
|
1098
|
+
}
|
|
1099
|
+
const code = await this.exec(`cat ${entry.base}.code 2>/dev/null || true`);
|
|
1100
|
+
const codeStr = (code.stdout || "").trim();
|
|
1101
|
+
if (codeStr !== "" && /^\d+$/.test(codeStr)) {
|
|
1102
|
+
this.finalize(entry, parseInt(codeStr, 10));
|
|
1103
|
+
} else if (entry.pid != null) {
|
|
1104
|
+
const alive = await this.exec(`kill -0 ${entry.pid} 2>/dev/null && echo Y || echo N`);
|
|
1105
|
+
if (alive.stdout.includes("N"))
|
|
1106
|
+
this.finalize(entry, null);
|
|
1107
|
+
}
|
|
1108
|
+
} catch {}
|
|
1109
|
+
}
|
|
1110
|
+
onLine(entry, line) {
|
|
1111
|
+
for (const [id, w] of entry.watchers) {
|
|
1112
|
+
if (w.regex.test(line)) {
|
|
1113
|
+
if (w.once !== false)
|
|
1114
|
+
entry.watchers.delete(id);
|
|
1115
|
+
if (w.thenStop)
|
|
1116
|
+
this.stop(entry.sessionId).catch(() => {
|
|
1117
|
+
return;
|
|
1118
|
+
});
|
|
1119
|
+
this.emit({
|
|
1120
|
+
type: "match",
|
|
1121
|
+
sessionId: entry.sessionId,
|
|
1122
|
+
ownerSessionId: entry.ownerSessionId,
|
|
1123
|
+
watcherId: id,
|
|
1124
|
+
pattern: String(w.pattern),
|
|
1125
|
+
matchedLine: line,
|
|
1126
|
+
stream: "stdout",
|
|
1127
|
+
context: [line]
|
|
1128
|
+
});
|
|
1129
|
+
entry.lastAttentionAt = Date.now();
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
finalize(entry, exitCode) {
|
|
1134
|
+
if (!entry.running)
|
|
1135
|
+
return;
|
|
1136
|
+
entry.running = false;
|
|
1137
|
+
entry.exitCode = exitCode;
|
|
1138
|
+
if (entry.pollTimer)
|
|
1139
|
+
clearInterval(entry.pollTimer);
|
|
1140
|
+
entry.watchers.clear();
|
|
1141
|
+
this.emit({
|
|
1142
|
+
type: "exit",
|
|
1143
|
+
sessionId: entry.sessionId,
|
|
1144
|
+
ownerSessionId: entry.ownerSessionId,
|
|
1145
|
+
exitCode,
|
|
1146
|
+
lastLines: []
|
|
1147
|
+
});
|
|
1148
|
+
this.log.info(`[RemoteSession ${entry.sessionId}] exited code=${exitCode}`);
|
|
1149
|
+
}
|
|
1150
|
+
emit(ev) {
|
|
1151
|
+
try {
|
|
1152
|
+
this.onEvent(ev);
|
|
1153
|
+
} catch (err) {
|
|
1154
|
+
this.log.warn(`remote session event sink threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/ssh-pty-driver.ts
|
|
1160
|
+
var ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[=>]|\r(?!\n)/g;
|
|
1161
|
+
var stripAnsi = (s) => s.replace(ANSI_RE, "");
|
|
1162
|
+
|
|
1163
|
+
class PtyLink {
|
|
1164
|
+
proc;
|
|
1165
|
+
fallbackNonce = crypto4.randomBytes(16).toString("hex");
|
|
1166
|
+
fallbackSteps = new Set;
|
|
1167
|
+
assembler = new LineAssembler;
|
|
1168
|
+
lineBuffer = [];
|
|
1169
|
+
ready = false;
|
|
1170
|
+
readyWaiter;
|
|
1171
|
+
readyRejecter;
|
|
1172
|
+
rawArrived = false;
|
|
1173
|
+
firstOutWaiter;
|
|
1174
|
+
chain = Promise.resolve();
|
|
1175
|
+
handshakeTail = [];
|
|
1176
|
+
handshakeUser;
|
|
1177
|
+
handshakeWhat = "";
|
|
1178
|
+
pendingExitFailure;
|
|
1179
|
+
onUnexpectedExit;
|
|
1180
|
+
onLayerFallback;
|
|
1181
|
+
fallbackSeen;
|
|
1182
|
+
registerFallbackStep() {
|
|
1183
|
+
const step = String(this.fallbackSteps.size);
|
|
1184
|
+
this.fallbackSteps.add(step);
|
|
1185
|
+
return `; echo __FILAXIS_FB_${this.fallbackNonce}_${step}_$?_$RANDOM`;
|
|
1186
|
+
}
|
|
1187
|
+
attach(args) {
|
|
1188
|
+
this.fallbackNonce = crypto4.randomBytes(16).toString("hex");
|
|
1189
|
+
this.fallbackSteps.clear();
|
|
1190
|
+
this.fallbackSeen = undefined;
|
|
1191
|
+
this.proc = spawn3("ssh", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
1192
|
+
this.proc.stdout.on("data", (d) => this.onData(d.toString("utf-8")));
|
|
1193
|
+
this.proc.stderr.on("data", (d) => this.onData(d.toString("utf-8")));
|
|
1194
|
+
this.proc.on("exit", (code) => {
|
|
1195
|
+
if (!this.ready) {
|
|
1196
|
+
const err = new Error(this.egressError(`ssh 进程建链期间退出(code=${code}): ${this.handshakeWhat}`));
|
|
1197
|
+
if (this.readyRejecter)
|
|
1198
|
+
this.readyRejecter(err);
|
|
1199
|
+
else
|
|
1200
|
+
this.pendingExitFailure = err;
|
|
1201
|
+
}
|
|
1202
|
+
this.onUnexpectedExit?.();
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
send(s) {
|
|
1206
|
+
this.proc?.stdin.write(s);
|
|
1207
|
+
}
|
|
1208
|
+
queue(fn) {
|
|
1209
|
+
const run = this.chain.then(fn);
|
|
1210
|
+
this.chain = run.catch(() => {
|
|
1211
|
+
return;
|
|
1212
|
+
});
|
|
1213
|
+
return run;
|
|
1214
|
+
}
|
|
1215
|
+
egressError(reason) {
|
|
1216
|
+
const raw = this.handshakeTail.join(`
|
|
1217
|
+
`).trim();
|
|
1218
|
+
return raw ? `${reason}
|
|
1219
|
+
[ssh 原始输出·直交]
|
|
1220
|
+
${raw.slice(-1500)}` : `${reason}
|
|
1221
|
+
[ssh 原始输出·空(对端无任何回显)]`;
|
|
1222
|
+
}
|
|
1223
|
+
handshakeEvidence() {
|
|
1224
|
+
const raw = this.handshakeTail.join(`
|
|
1225
|
+
`).trim();
|
|
1226
|
+
return raw ? raw.slice(-1500) : "(无原始输出)";
|
|
1227
|
+
}
|
|
1228
|
+
async handshake(what) {
|
|
1229
|
+
this.ready = false;
|
|
1230
|
+
this.handshakeTail = [];
|
|
1231
|
+
this.handshakeWhat = what;
|
|
1232
|
+
this.fallbackSeen = undefined;
|
|
1233
|
+
if (!this.pendingExitFailure)
|
|
1234
|
+
await this.waitQuiet();
|
|
1235
|
+
this.send(`stty -echo -ixon cols 500 rows 50; export PS1= PS2=; echo PTYRDY_$RANDOM$RANDOM_$(id -un 2>/dev/null)
|
|
1236
|
+
`);
|
|
1237
|
+
try {
|
|
1238
|
+
await new Promise((resolve, reject) => {
|
|
1239
|
+
const timer = setTimeout(() => reject(new Error(this.egressError(`PTYRDY 握手超时(20s): ${what}`))), 20000);
|
|
1240
|
+
this.readyWaiter = () => {
|
|
1241
|
+
clearTimeout(timer);
|
|
1242
|
+
resolve();
|
|
1243
|
+
};
|
|
1244
|
+
this.readyRejecter = (e) => {
|
|
1245
|
+
clearTimeout(timer);
|
|
1246
|
+
reject(e);
|
|
1247
|
+
};
|
|
1248
|
+
if (this.pendingExitFailure) {
|
|
1249
|
+
const e = this.pendingExitFailure;
|
|
1250
|
+
this.pendingExitFailure = undefined;
|
|
1251
|
+
this.readyRejecter(e);
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
} finally {
|
|
1255
|
+
this.readyWaiter = undefined;
|
|
1256
|
+
this.readyRejecter = undefined;
|
|
1257
|
+
}
|
|
1258
|
+
this.lineBuffer = [];
|
|
1259
|
+
this.ready = true;
|
|
1260
|
+
}
|
|
1261
|
+
async execMarker(command, timeoutMs, abortSignal) {
|
|
1262
|
+
if (!this.ready)
|
|
1263
|
+
throw new Error("PTY 未就绪(层切换中)");
|
|
1264
|
+
const marker = `__FX_M_${crypto4.randomBytes(4).toString("hex")}`;
|
|
1265
|
+
const hasRedirect = /(\d>|>>|2>&1|>&)/.test(command);
|
|
1266
|
+
const cmdLine = hasRedirect ? command : `{ ${command}; } 2>&1`;
|
|
1267
|
+
const wrapped = `trap 'echo "${marker}_130_$(id -un)"' INT; ${cmdLine}; echo "${marker}_$?_$(id -un)"`;
|
|
1268
|
+
const callerP = new Promise((resolve, reject) => {
|
|
1269
|
+
this.queue(() => this.markerCycle(marker, wrapped, command, timeoutMs, abortSignal, { resolve, reject })).catch(() => {
|
|
1270
|
+
return;
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
1273
|
+
return callerP;
|
|
1274
|
+
}
|
|
1275
|
+
markerCycle(marker, wrapped, command, timeoutMs, abortSignal, caller) {
|
|
1276
|
+
return new Promise((cycleDone) => {
|
|
1277
|
+
let notified = false;
|
|
1278
|
+
let drained = false;
|
|
1279
|
+
this.lineBuffer = [];
|
|
1280
|
+
const markerRe = new RegExp(`${marker}_(\\d+)(?:_(\\S+))?`);
|
|
1281
|
+
const notify = (fn) => {
|
|
1282
|
+
if (!notified) {
|
|
1283
|
+
notified = true;
|
|
1284
|
+
fn();
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
const cleanupAbort = () => abortSignal?.removeEventListener("abort", onAbort);
|
|
1288
|
+
const enterDraining = () => {
|
|
1289
|
+
if (drained)
|
|
1290
|
+
return;
|
|
1291
|
+
drained = true;
|
|
1292
|
+
deadline = setTimeout(() => {
|
|
1293
|
+
clearInterval(poll);
|
|
1294
|
+
cleanupAbort();
|
|
1295
|
+
this.kill();
|
|
1296
|
+
cycleDone();
|
|
1297
|
+
}, 5000);
|
|
1298
|
+
};
|
|
1299
|
+
let deadline = null;
|
|
1300
|
+
const onAbort = () => {
|
|
1301
|
+
if (notified)
|
|
1302
|
+
return;
|
|
1303
|
+
this.send("\x03");
|
|
1304
|
+
notify(() => {
|
|
1305
|
+
const e = new DOMException(`SshPty exec aborted by AbortSignal(已发 ^C,远端终态未核实——outcome=unknown)`, "AbortError");
|
|
1306
|
+
e.outcome = "unknown";
|
|
1307
|
+
caller.reject(e);
|
|
1308
|
+
});
|
|
1309
|
+
enterDraining();
|
|
1310
|
+
};
|
|
1311
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
1312
|
+
const timer = setTimeout(() => {
|
|
1313
|
+
if (notified)
|
|
1314
|
+
return;
|
|
1315
|
+
this.send("\x03");
|
|
1316
|
+
notify(() => {
|
|
1317
|
+
const e = new Error(`SshPty exec 超时(${timeoutMs}ms,已发 ^C,远端终态未核实——outcome=unknown): ${command.slice(0, 60)}`);
|
|
1318
|
+
e.outcome = "unknown";
|
|
1319
|
+
caller.reject(e);
|
|
1320
|
+
});
|
|
1321
|
+
enterDraining();
|
|
1322
|
+
}, timeoutMs);
|
|
1323
|
+
const poll = setInterval(() => {
|
|
1324
|
+
const buf = this.lineBuffer;
|
|
1325
|
+
for (let i = 0;i < buf.length; i++) {
|
|
1326
|
+
const m = buf[i].match(markerRe);
|
|
1327
|
+
if (m) {
|
|
1328
|
+
const prefix = m.index ? buf[i].slice(0, m.index) : "";
|
|
1329
|
+
this.lineBuffer = buf.slice(i + 1);
|
|
1330
|
+
clearInterval(poll);
|
|
1331
|
+
clearTimeout(timer);
|
|
1332
|
+
if (deadline)
|
|
1333
|
+
clearTimeout(deadline);
|
|
1334
|
+
cleanupAbort();
|
|
1335
|
+
if (!notified) {
|
|
1336
|
+
const out = [...buf.slice(0, i), ...prefix ? [prefix] : []].join(`
|
|
1337
|
+
`);
|
|
1338
|
+
notify(() => caller.resolve({
|
|
1339
|
+
stdout: out,
|
|
1340
|
+
stderr: "",
|
|
1341
|
+
exitCode: parseInt(m[1], 10),
|
|
1342
|
+
effectiveUser: m[2]
|
|
1343
|
+
}));
|
|
1344
|
+
}
|
|
1345
|
+
cycleDone();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}, 25);
|
|
1350
|
+
this.send(`${wrapped}
|
|
1351
|
+
`);
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
kill() {
|
|
1355
|
+
this.proc?.kill();
|
|
1356
|
+
this.proc = undefined;
|
|
1357
|
+
this.ready = false;
|
|
1358
|
+
}
|
|
1359
|
+
async waitQuiet() {
|
|
1360
|
+
const firstOut = new Promise((resolve) => {
|
|
1361
|
+
if (this.rawArrived)
|
|
1362
|
+
resolve();
|
|
1363
|
+
else
|
|
1364
|
+
this.firstOutWaiter = resolve;
|
|
1365
|
+
});
|
|
1366
|
+
await Promise.race([firstOut, new Promise((r) => setTimeout(r, 8000))]);
|
|
1367
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1368
|
+
}
|
|
1369
|
+
onData(text) {
|
|
1370
|
+
if (this.firstOutWaiter) {
|
|
1371
|
+
this.firstOutWaiter();
|
|
1372
|
+
this.firstOutWaiter = undefined;
|
|
1373
|
+
}
|
|
1374
|
+
this.rawArrived = true;
|
|
1375
|
+
for (const line of this.assembler.feed(stripAnsi(text))) {
|
|
1376
|
+
const fb = [...line.matchAll(/__FILAXIS_FB_([a-f0-9]{32})_(\d+)_(\d+)_(\d+)(?!\w)/g)].find((m) => m[1] === this.fallbackNonce && this.fallbackSteps.has(m[2]));
|
|
1377
|
+
if (fb) {
|
|
1378
|
+
this.fallbackSeen = line.trim().slice(0, 200);
|
|
1379
|
+
if (this.ready)
|
|
1380
|
+
this.onLayerFallback?.(this.fallbackSeen);
|
|
1381
|
+
}
|
|
1382
|
+
if (!this.ready) {
|
|
1383
|
+
const m = line.match(/PTYRDY_(\d+)(?:_([A-Za-z0-9._-]+))?/);
|
|
1384
|
+
if (m) {
|
|
1385
|
+
if (m[2])
|
|
1386
|
+
this.handshakeUser = m[2];
|
|
1387
|
+
this.readyWaiter?.();
|
|
1388
|
+
this.readyWaiter = undefined;
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
if (line.trim()) {
|
|
1392
|
+
this.handshakeTail.push(line.length > 200 ? line.slice(0, 200) + "…" : line);
|
|
1393
|
+
if (this.handshakeTail.length > 30)
|
|
1394
|
+
this.handshakeTail.shift();
|
|
1395
|
+
}
|
|
1396
|
+
if (SSH_INPUT_WAIT_RE.test(line)) {
|
|
1397
|
+
this.readyRejecter?.(new Error(this.egressError(`建链卡在等待输入(交互提示): ${this.handshakeWhat}`)));
|
|
1398
|
+
}
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
this.lineBuffer.push(line);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
var LAYER_IDLE_THRESHOLD_MS = 1e4;
|
|
1406
|
+
function resolveLayerState(d, now = Date.now()) {
|
|
1407
|
+
if (!d.alive)
|
|
1408
|
+
return "dead";
|
|
1409
|
+
if (d.shellGone)
|
|
1410
|
+
return "degraded";
|
|
1411
|
+
if (d.reconnecting)
|
|
1412
|
+
return "connecting";
|
|
1413
|
+
if (d.execInFlight)
|
|
1414
|
+
return "active";
|
|
1415
|
+
if (d.lastActivityAt && now - d.lastActivityAt >= LAYER_IDLE_THRESHOLD_MS)
|
|
1416
|
+
return "idle";
|
|
1417
|
+
return d.lastActivityAt ? "active" : "connecting";
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
class SshPtyDriver {
|
|
1421
|
+
descriptor;
|
|
1422
|
+
fs;
|
|
1423
|
+
sessions;
|
|
1424
|
+
link = new PtyLink;
|
|
1425
|
+
alive = true;
|
|
1426
|
+
effectiveUser;
|
|
1427
|
+
layerShellGone;
|
|
1428
|
+
layerState = "connecting";
|
|
1429
|
+
lastActivityAt = 0;
|
|
1430
|
+
execInFlight = false;
|
|
1431
|
+
reconnecting = false;
|
|
1432
|
+
reconnectAttempts = 0;
|
|
1433
|
+
log;
|
|
1434
|
+
static MAX_RECONNECT = 3;
|
|
1435
|
+
static RECONNECT_DELAYS_MS = [2000, 5000, 1e4];
|
|
1436
|
+
constructor(descriptor, opts = {}) {
|
|
1437
|
+
this.descriptor = descriptor;
|
|
1438
|
+
this.log = opts.logger ?? noopLogger;
|
|
1439
|
+
const self = this;
|
|
1440
|
+
const fsExec = async (cmd) => self.execChannel(cmd, undefined);
|
|
1441
|
+
const statOf = async (p) => {
|
|
1442
|
+
const r = await fsExec(`stat -c '%s %Y' ${q2(p)} 2>/dev/null || stat -f '%z %m' ${q2(p)} 2>/dev/null || true`);
|
|
1443
|
+
const m = r.stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
1444
|
+
return m ? { size: parseInt(m[1], 10), mtimeMs: parseInt(m[2], 10) * 1000 } : null;
|
|
1445
|
+
};
|
|
1446
|
+
this.fs = {
|
|
1447
|
+
async readFile(p) {
|
|
1448
|
+
return (await fsExec(`base64 ${q2(p)} | base64 -d 2>/dev/null`)).stdout;
|
|
1449
|
+
},
|
|
1450
|
+
async writeFile(p, content) {
|
|
1451
|
+
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
|
1452
|
+
await fsExec(`printf %s ${q2(b64)} | base64 -d > ${q2(p)}`);
|
|
1453
|
+
},
|
|
1454
|
+
async exists(p) {
|
|
1455
|
+
const r = await fsExec(`test -e ${q2(p)} && echo Y || echo N`);
|
|
1456
|
+
return r.stdout.trim() === "Y";
|
|
1457
|
+
},
|
|
1458
|
+
stat: statOf,
|
|
1459
|
+
async readdir(p) {
|
|
1460
|
+
const r = await fsExec(`ls -1A ${q2(p)} 2>/dev/null || true`);
|
|
1461
|
+
const names = r.stdout.split(`
|
|
1462
|
+
`).filter(Boolean);
|
|
1463
|
+
return Promise.all(names.map(async (name) => {
|
|
1464
|
+
const full = `${p.replace(/\/$/, "")}/${name}`;
|
|
1465
|
+
const d = await fsExec(`test -d ${q2(full)} && echo Y || echo N`);
|
|
1466
|
+
const st = await statOf(full);
|
|
1467
|
+
return { name, isDirectory: d.stdout.trim() === "Y", size: st?.size, mtime: st?.mtimeMs };
|
|
1468
|
+
}));
|
|
1469
|
+
},
|
|
1470
|
+
async rm(p, options) {
|
|
1471
|
+
await fsExec(`rm ${options?.recursive ? "-r " : ""}${options?.force ? "-f " : ""}${q2(p)}`);
|
|
1472
|
+
},
|
|
1473
|
+
async mkdir(p, options) {
|
|
1474
|
+
await fsExec(`mkdir ${options?.recursive ? "-p " : ""}${q2(p)}`);
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
this.sessions = new RemoteSessionManager({ exec: (cmd) => this.exec(cmd), logger: this.log });
|
|
1478
|
+
this.bindLink();
|
|
1479
|
+
}
|
|
1480
|
+
currentState() {
|
|
1481
|
+
return resolveLayerState({
|
|
1482
|
+
alive: this.alive,
|
|
1483
|
+
reconnecting: this.reconnecting,
|
|
1484
|
+
execInFlight: this.execInFlight,
|
|
1485
|
+
lastActivityAt: this.lastActivityAt,
|
|
1486
|
+
shellGone: this.layerShellGone
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
async init() {
|
|
1490
|
+
const target = this.descriptor.target ?? "";
|
|
1491
|
+
if (!target)
|
|
1492
|
+
throw new Error("SshPtyDriver 需要 descriptor.target(user@host 或 ssh config 别名)");
|
|
1493
|
+
const hops = Array.isArray(this.descriptor.config?.sshHops) ? this.descriptor.config.sshHops : [];
|
|
1494
|
+
const enterSteps = enterStepsOf(this.descriptor.config);
|
|
1495
|
+
const connParams = this.descriptor.config?.connParams;
|
|
1496
|
+
const rootHop = connParams ? { host: target, conn: connParams } : target;
|
|
1497
|
+
this.layerShellGone = undefined;
|
|
1498
|
+
this.link.onLayerFallback = (evidence) => {
|
|
1499
|
+
this.layerShellGone = evidence;
|
|
1500
|
+
this.layerState = "degraded";
|
|
1501
|
+
this.log.warn(`[SshPty] 内层 shell 已退出,层退化:${evidence}——连接仍在但已非本层环境`);
|
|
1502
|
+
};
|
|
1503
|
+
this.link.attach(rootSshSpawnArgs(sshArgsFor(rootHop), connParams));
|
|
1504
|
+
await this.link.handshake(`ssh ${hopLabel(rootHop)}`);
|
|
1505
|
+
for (const hop of hops) {
|
|
1506
|
+
await Promise.resolve().then(() => init_ssh_helpers());
|
|
1507
|
+
this.link.send(`${hopSshCommand(hop)}${this.link.registerFallbackStep()}
|
|
1508
|
+
`);
|
|
1509
|
+
try {
|
|
1510
|
+
await this.link.handshake(`ssh ${hopLabel(hop)}`);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
this.link.kill();
|
|
1513
|
+
throw err;
|
|
1514
|
+
}
|
|
1515
|
+
this.assertNotFallenBack(`ssh ${hopLabel(hop)}`);
|
|
1516
|
+
}
|
|
1517
|
+
for (const step of enterSteps) {
|
|
1518
|
+
this.link.send(`${step.cmd}${this.link.registerFallbackStep()}
|
|
1519
|
+
`);
|
|
1520
|
+
try {
|
|
1521
|
+
await this.link.handshake(`enter: ${step.cmd.slice(0, 40)}`);
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
this.link.kill();
|
|
1524
|
+
throw err;
|
|
1525
|
+
}
|
|
1526
|
+
this.assertNotFallenBack(`enter: ${step.cmd.slice(0, 40)}`);
|
|
1527
|
+
}
|
|
1528
|
+
if (this.descriptor.cwd) {
|
|
1529
|
+
await this.execChannel(`cd ${JSON.stringify(this.descriptor.cwd)} 2>/dev/null || true`, undefined);
|
|
1530
|
+
}
|
|
1531
|
+
if (this.link.handshakeUser)
|
|
1532
|
+
this.effectiveUser = this.link.handshakeUser;
|
|
1533
|
+
this.layerState = "active";
|
|
1534
|
+
this.lastActivityAt = Date.now();
|
|
1535
|
+
}
|
|
1536
|
+
assertNotFallenBack(what) {
|
|
1537
|
+
if (this.link.fallbackSeen) {
|
|
1538
|
+
throw new Error(`层进入命令未驻留(${what})——命令执行后立即退出,目标可能不存在或命令有误
|
|
1539
|
+
[哨兵证据] ${this.link.fallbackSeen}
|
|
1540
|
+
[原始输出·直交]
|
|
1541
|
+
${this.link.handshakeEvidence()}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
scheduleReconnect() {
|
|
1545
|
+
if (this.reconnecting || !this.alive)
|
|
1546
|
+
return;
|
|
1547
|
+
this.reconnecting = true;
|
|
1548
|
+
const attempt = () => {
|
|
1549
|
+
if (!this.alive) {
|
|
1550
|
+
this.reconnecting = false;
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (this.reconnectAttempts >= SshPtyDriver.MAX_RECONNECT) {
|
|
1554
|
+
this.reconnecting = false;
|
|
1555
|
+
this.layerState = "dead";
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
this.reconnectAttempts++;
|
|
1559
|
+
const delay = SshPtyDriver.RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempts - 1, SshPtyDriver.RECONNECT_DELAYS_MS.length - 1)];
|
|
1560
|
+
const timer = setTimeout(() => {
|
|
1561
|
+
(async () => {
|
|
1562
|
+
try {
|
|
1563
|
+
this.link = new PtyLink;
|
|
1564
|
+
this.bindLink();
|
|
1565
|
+
await this.init();
|
|
1566
|
+
this.reconnecting = false;
|
|
1567
|
+
this.reconnectAttempts = 0;
|
|
1568
|
+
this.lastActivityAt = Date.now();
|
|
1569
|
+
this.layerState = "active";
|
|
1570
|
+
} catch {
|
|
1571
|
+
attempt();
|
|
1572
|
+
}
|
|
1573
|
+
})();
|
|
1574
|
+
}, delay);
|
|
1575
|
+
timer.unref?.();
|
|
1576
|
+
};
|
|
1577
|
+
attempt();
|
|
1578
|
+
}
|
|
1579
|
+
bindLink() {
|
|
1580
|
+
this.link.onUnexpectedExit = () => {
|
|
1581
|
+
if (this.alive && !this.reconnecting) {
|
|
1582
|
+
this.layerState = "connecting";
|
|
1583
|
+
this.scheduleReconnect();
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
async pushChild(hop) {
|
|
1588
|
+
const myHops = Array.isArray(this.descriptor.config?.sshHops) ? this.descriptor.config.sshHops : [];
|
|
1589
|
+
const child = new SshPtyDriver({
|
|
1590
|
+
driver: "ssh",
|
|
1591
|
+
target: this.descriptor.target,
|
|
1592
|
+
config: { ...this.descriptor.config, sshHops: [...myHops, hop], enterPipeline: undefined, enterCmd: undefined, enterLabel: undefined }
|
|
1593
|
+
}, { logger: this.log });
|
|
1594
|
+
await child.init();
|
|
1595
|
+
return child;
|
|
1596
|
+
}
|
|
1597
|
+
async pushInto(enterCmd, enterLabel) {
|
|
1598
|
+
const myHops = Array.isArray(this.descriptor.config?.sshHops) ? this.descriptor.config.sshHops : [];
|
|
1599
|
+
const enterPipeline = [...enterStepsOf(this.descriptor.config), { cmd: enterCmd, label: enterLabel }];
|
|
1600
|
+
const child = new SshPtyDriver({
|
|
1601
|
+
driver: "ssh",
|
|
1602
|
+
target: this.descriptor.target,
|
|
1603
|
+
config: { ...this.descriptor.config, sshHops: myHops, enterPipeline, enterCmd: undefined, enterLabel: undefined }
|
|
1604
|
+
}, { logger: this.log });
|
|
1605
|
+
await child.init();
|
|
1606
|
+
return child;
|
|
1607
|
+
}
|
|
1608
|
+
async exit() {
|
|
1609
|
+
if (!this.alive)
|
|
1610
|
+
return;
|
|
1611
|
+
this.alive = false;
|
|
1612
|
+
this.reconnecting = false;
|
|
1613
|
+
this.layerState = "dead";
|
|
1614
|
+
this.link.kill();
|
|
1615
|
+
}
|
|
1616
|
+
async destroy() {
|
|
1617
|
+
await this.exit();
|
|
1618
|
+
}
|
|
1619
|
+
async getCwd() {
|
|
1620
|
+
return (await this.exec("pwd")).stdout.trim();
|
|
1621
|
+
}
|
|
1622
|
+
async getEnv(key) {
|
|
1623
|
+
return (await this.exec(`printf %s "\\$${key}"`)).stdout;
|
|
1624
|
+
}
|
|
1625
|
+
async exec(command, options) {
|
|
1626
|
+
if (!this.alive)
|
|
1627
|
+
throw new Error("层已退出");
|
|
1628
|
+
if (this.layerShellGone)
|
|
1629
|
+
throw new Error(`层已退化:内层 shell 已退出(${this.layerShellGone.slice(0, 60)})——连接仍在但已非本层环境`);
|
|
1630
|
+
if (this.reconnecting)
|
|
1631
|
+
throw new Error("连接意外断开,自动重连中——请稍后重试");
|
|
1632
|
+
if (options?.channel === "pty") {
|
|
1633
|
+
this.execInFlight = true;
|
|
1634
|
+
try {
|
|
1635
|
+
const r = await this.link.execMarker(command, options?.timeoutMs ?? 120000, options?.signal);
|
|
1636
|
+
if (r.effectiveUser)
|
|
1637
|
+
this.effectiveUser = r.effectiveUser;
|
|
1638
|
+
return r;
|
|
1639
|
+
} finally {
|
|
1640
|
+
this.execInFlight = false;
|
|
1641
|
+
this.lastActivityAt = Date.now();
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return this.execChannel(command, options);
|
|
1645
|
+
}
|
|
1646
|
+
async execChannel(command, options) {
|
|
1647
|
+
const hops = Array.isArray(this.descriptor.config?.sshHops) ? this.descriptor.config.sshHops : [];
|
|
1648
|
+
const enterSteps = enterStepsOf(this.descriptor.config);
|
|
1649
|
+
const connParams = this.descriptor.config?.connParams;
|
|
1650
|
+
const rootHop = connParams ? { host: this.descriptor.target ?? "", conn: connParams } : this.descriptor.target ?? "";
|
|
1651
|
+
const nonce = crypto4.randomBytes(6).toString("hex");
|
|
1652
|
+
const marker = `__FILAXIS_META_${nonce}`;
|
|
1653
|
+
const wrapped = `( ${command} ); __filaxis_ec=$?; printf '\\n${marker}:%d:%s\\n' "$__filaxis_ec" "$(id -un 2>/dev/null)"; exit $__filaxis_ec`;
|
|
1654
|
+
const render = renderExecChannel(enterSteps, command, {
|
|
1655
|
+
cwd: options?.cwd ?? this.descriptor.cwd,
|
|
1656
|
+
marker,
|
|
1657
|
+
nonceWrapped: wrapped
|
|
1658
|
+
});
|
|
1659
|
+
if (render.kind === "unreachable") {
|
|
1660
|
+
throw new Error(`不可直达层(exec 通道):${render.reason}[${render.step}]——该层含 PTY 内交互二次跳转,请用 channel:"pty"(stderr 将混流,终端语义)`);
|
|
1661
|
+
}
|
|
1662
|
+
if (hops.length > 0) {
|
|
1663
|
+
throw new Error(`不可直达层(exec 通道):多跳 PTY 登录链(${hops.map(hopLabel).join(" → ")})无法以单条非 PTY 进程复现——请用 channel:"pty",或将途经机收敛为 -J jump(ProxyJump)`);
|
|
1664
|
+
}
|
|
1665
|
+
return new Promise((resolve, reject) => {
|
|
1666
|
+
const argv = [...autonomousSshOpts(connParams), ...sshArgsFor(rootHop), "--", ...render.remoteArgs];
|
|
1667
|
+
const p = spawn3("ssh", argv, {
|
|
1668
|
+
env: { ...process.env, ...options?.env },
|
|
1669
|
+
cwd: options?.cwd,
|
|
1670
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1671
|
+
});
|
|
1672
|
+
let stdout = "";
|
|
1673
|
+
let stderr = "";
|
|
1674
|
+
let settled = false;
|
|
1675
|
+
p.stdout.on("data", (d) => {
|
|
1676
|
+
stdout += d;
|
|
1677
|
+
});
|
|
1678
|
+
p.stderr.on("data", (d) => {
|
|
1679
|
+
stderr += d;
|
|
1680
|
+
});
|
|
1681
|
+
p.stdin.write(render.stdinCmd);
|
|
1682
|
+
p.stdin.end();
|
|
1683
|
+
const onAbort = () => {
|
|
1684
|
+
p.kill("SIGTERM");
|
|
1685
|
+
const e = new DOMException("exec aborted by AbortSignal(本地 ssh 已终止,远端进程状态未核实——outcome=unknown)", "AbortError");
|
|
1686
|
+
e.outcome = "unknown";
|
|
1687
|
+
e.reason = "aborted";
|
|
1688
|
+
finish(() => reject(e));
|
|
1689
|
+
};
|
|
1690
|
+
const timer = options?.timeoutMs ? setTimeout(() => {
|
|
1691
|
+
p.kill("SIGTERM");
|
|
1692
|
+
const e = new Error(`exec 通道超时(${options.timeoutMs}ms,远端进程状态未核实): ${command.slice(0, 60)}`);
|
|
1693
|
+
e.outcome = "unknown";
|
|
1694
|
+
finish(() => reject(e));
|
|
1695
|
+
}, options.timeoutMs) : null;
|
|
1696
|
+
const cleanupAbort = () => options?.signal?.removeEventListener("abort", onAbort);
|
|
1697
|
+
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1698
|
+
const finish = (fn) => {
|
|
1699
|
+
if (!settled) {
|
|
1700
|
+
settled = true;
|
|
1701
|
+
if (timer)
|
|
1702
|
+
clearTimeout(timer);
|
|
1703
|
+
cleanupAbort();
|
|
1704
|
+
fn();
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
if (options?.signal?.aborted)
|
|
1708
|
+
onAbort();
|
|
1709
|
+
p.on("close", (code) => {
|
|
1710
|
+
let out = stdout;
|
|
1711
|
+
let effectiveUser;
|
|
1712
|
+
let sentinelCode;
|
|
1713
|
+
const mIdx = out.lastIndexOf(`
|
|
1714
|
+
${marker}:`);
|
|
1715
|
+
if (mIdx !== -1) {
|
|
1716
|
+
const metaLine = out.slice(mIdx + 1).split(`
|
|
1717
|
+
`, 1)[0] ?? "";
|
|
1718
|
+
const m = metaLine.match(new RegExp(`^${marker}:(-?\\d+):(.*)$`));
|
|
1719
|
+
if (m) {
|
|
1720
|
+
effectiveUser = m[2] === "" ? undefined : m[2];
|
|
1721
|
+
sentinelCode = parseInt(m[1], 10);
|
|
1722
|
+
out = out.slice(0, mIdx);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (effectiveUser)
|
|
1726
|
+
this.effectiveUser = effectiveUser;
|
|
1727
|
+
if (sentinelCode === undefined) {
|
|
1728
|
+
finish(() => {
|
|
1729
|
+
const e = new Error(`命令未完成(无完成哨兵——通道中断/进程被信号终止,close code=${code})。半截现场 stdout 尾 500 字符:
|
|
1730
|
+
${out.slice(-500)}
|
|
1731
|
+
stderr 尾 300:
|
|
1732
|
+
${stderr.slice(-300)}`);
|
|
1733
|
+
e.outcome = "unknown";
|
|
1734
|
+
reject(e);
|
|
1735
|
+
});
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
finish(() => resolve({ stdout: out, stderr, exitCode: sentinelCode, effectiveUser }));
|
|
1739
|
+
});
|
|
1740
|
+
p.on("error", (err) => finish(() => reject(err)));
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
async execSession(command, opts) {
|
|
1744
|
+
if (this.layerShellGone)
|
|
1745
|
+
throw new Error("层已退化——后台任务会落在父环境,请重建层后再启动");
|
|
1746
|
+
return this.sessions.start(command, opts);
|
|
1747
|
+
}
|
|
1748
|
+
async tailSession(sessionId, lines) {
|
|
1749
|
+
return this.sessions.tail(sessionId, lines);
|
|
1750
|
+
}
|
|
1751
|
+
async readSessionLog(sessionId, opts) {
|
|
1752
|
+
return this.sessions.readLog(sessionId, opts);
|
|
1753
|
+
}
|
|
1754
|
+
watchSession(sessionId, opts) {
|
|
1755
|
+
return this.sessions.watch(sessionId, opts);
|
|
1756
|
+
}
|
|
1757
|
+
async stopSession(sessionId) {
|
|
1758
|
+
return this.sessions.stop(sessionId);
|
|
1759
|
+
}
|
|
1760
|
+
listSessions() {
|
|
1761
|
+
return this.sessions.list();
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
function q2(s) {
|
|
1765
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// src/terminal-manager.ts
|
|
1769
|
+
import path3 from "node:path";
|
|
1770
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
1771
|
+
|
|
1772
|
+
// src/terminal/session-registry.ts
|
|
1773
|
+
import fs3 from "node:fs/promises";
|
|
1774
|
+
|
|
1775
|
+
// src/terminal/tmux-invoker.ts
|
|
1776
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
1777
|
+
|
|
1778
|
+
// src/terminal/types.ts
|
|
1779
|
+
class TmuxError extends Error {
|
|
1780
|
+
args;
|
|
1781
|
+
exitCode;
|
|
1782
|
+
stderr;
|
|
1783
|
+
constructor(message, args, exitCode, stderr) {
|
|
1784
|
+
super(message);
|
|
1785
|
+
this.args = args;
|
|
1786
|
+
this.exitCode = exitCode;
|
|
1787
|
+
this.stderr = stderr;
|
|
1788
|
+
this.name = "TmuxError";
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
var DEFAULT_TERMINAL_CONFIG = {
|
|
1792
|
+
env_hardening_prefix: "export GIT_TERMINAL_PROMPT=0 SSH_ASKPASS=/bin/false PAGER=cat",
|
|
1793
|
+
spawn_max_wait_ms_local: 5000,
|
|
1794
|
+
spawn_max_wait_ms_remote: 20000,
|
|
1795
|
+
spawn_poll_ms: 200,
|
|
1796
|
+
command_timeout_ms: 600000,
|
|
1797
|
+
no_output_timeout_ms: 120000,
|
|
1798
|
+
poll_interval_ms: 500,
|
|
1799
|
+
capture_scrollback: 500,
|
|
1800
|
+
buffer_pending_chars: 1e5,
|
|
1801
|
+
buffer_aggregated_chars: 500000,
|
|
1802
|
+
buffer_tail_chars: 4000,
|
|
1803
|
+
prompt_patterns: [
|
|
1804
|
+
"(?:password|passphrase|密码|口令)[^\\n]*[::]\\s*$",
|
|
1805
|
+
"\\(yes\\/no[^)]*\\)\\s*\\??\\s*$",
|
|
1806
|
+
"press any key to continue[^\\n]*$"
|
|
1807
|
+
]
|
|
1808
|
+
};
|
|
1809
|
+
function compilePromptPatterns(patterns) {
|
|
1810
|
+
const joined = patterns.length === 1 ? patterns[0] : `(?:${patterns.join("|")})`;
|
|
1811
|
+
return new RegExp(joined, "mi");
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// src/terminal/tmux-invoker.ts
|
|
1815
|
+
class TmuxInvoker {
|
|
1816
|
+
static invoke(args) {
|
|
1817
|
+
return new Promise((resolve, reject) => {
|
|
1818
|
+
let stdout = "";
|
|
1819
|
+
let stderr = "";
|
|
1820
|
+
let settled = false;
|
|
1821
|
+
const p = spawn4("tmux", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1822
|
+
p.stdout.on("data", (d) => {
|
|
1823
|
+
stdout += d;
|
|
1824
|
+
});
|
|
1825
|
+
p.stderr.on("data", (d) => {
|
|
1826
|
+
stderr += d;
|
|
1827
|
+
});
|
|
1828
|
+
const settle = (exitCode) => {
|
|
1829
|
+
if (settled)
|
|
1830
|
+
return;
|
|
1831
|
+
settled = true;
|
|
1832
|
+
const code = exitCode ?? 0;
|
|
1833
|
+
if (code === 0) {
|
|
1834
|
+
resolve({ stdout, stderr, exitCode: 0 });
|
|
1835
|
+
} else {
|
|
1836
|
+
reject(new TmuxError(`tmux ${args.join(" ")} failed with exit code ${code}` + (stderr ? `: ${stderr.trim()}` : ""), args, code, stderr));
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
p.once("exit", (code) => settle(code));
|
|
1840
|
+
p.once("close", (code) => settle(code));
|
|
1841
|
+
p.once("error", (err) => {
|
|
1842
|
+
if (settled)
|
|
1843
|
+
return;
|
|
1844
|
+
settled = true;
|
|
1845
|
+
reject(err);
|
|
1846
|
+
});
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
static async tryInvoke(args) {
|
|
1850
|
+
try {
|
|
1851
|
+
return await this.invoke(args);
|
|
1852
|
+
} catch (e) {
|
|
1853
|
+
if (e instanceof TmuxError) {
|
|
1854
|
+
return { stdout: "", stderr: e.stderr, exitCode: e.exitCode };
|
|
1855
|
+
}
|
|
1856
|
+
throw e;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
static async isAvailable() {
|
|
1860
|
+
try {
|
|
1861
|
+
await this.tryInvoke(["list-sessions"]);
|
|
1862
|
+
return true;
|
|
1863
|
+
} catch {
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// src/terminal/session-registry.ts
|
|
1870
|
+
class SessionRegistry {
|
|
1871
|
+
dirResolver;
|
|
1872
|
+
log;
|
|
1873
|
+
reconciledDirs = new Set;
|
|
1874
|
+
constructor(dirResolver, log = noopLogger) {
|
|
1875
|
+
this.dirResolver = dirResolver;
|
|
1876
|
+
this.log = log;
|
|
1877
|
+
}
|
|
1878
|
+
async ensureDir() {
|
|
1879
|
+
await fs3.mkdir(this.dirResolver(), { recursive: true });
|
|
1880
|
+
}
|
|
1881
|
+
async init() {
|
|
1882
|
+
await this.ensureDir();
|
|
1883
|
+
const dir = this.dirResolver();
|
|
1884
|
+
if (this.reconciledDirs.has(dir))
|
|
1885
|
+
return;
|
|
1886
|
+
this.reconciledDirs.add(dir);
|
|
1887
|
+
await this.reconcileOnStartup();
|
|
1888
|
+
}
|
|
1889
|
+
async save(profile) {
|
|
1890
|
+
await fs3.writeFile(`${this.dirResolver()}/${profile.id}.json`, JSON.stringify(profile, null, 2));
|
|
1891
|
+
}
|
|
1892
|
+
async load(id) {
|
|
1893
|
+
try {
|
|
1894
|
+
const raw = await fs3.readFile(`${this.dirResolver()}/${id}.json`, "utf-8");
|
|
1895
|
+
return JSON.parse(raw);
|
|
1896
|
+
} catch {
|
|
1897
|
+
return null;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
async remove(id) {
|
|
1901
|
+
try {
|
|
1902
|
+
await fs3.unlink(`${this.dirResolver()}/${id}.json`);
|
|
1903
|
+
} catch {}
|
|
1904
|
+
}
|
|
1905
|
+
async reconcileOnStartup() {
|
|
1906
|
+
const dir = this.dirResolver();
|
|
1907
|
+
let files;
|
|
1908
|
+
try {
|
|
1909
|
+
files = await fs3.readdir(dir);
|
|
1910
|
+
} catch {
|
|
1911
|
+
return { scanned: 0, kept: 0, removed: 0 };
|
|
1912
|
+
}
|
|
1913
|
+
const profiles = files.filter((f) => f.endsWith(".json"));
|
|
1914
|
+
let kept = 0;
|
|
1915
|
+
let removed = 0;
|
|
1916
|
+
await Promise.all(profiles.map(async (file) => {
|
|
1917
|
+
const id = file.slice(0, -5);
|
|
1918
|
+
const profile = await this.load(id);
|
|
1919
|
+
if (!profile || profile.id !== id) {
|
|
1920
|
+
await this.remove(id);
|
|
1921
|
+
removed++;
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
1926
|
+
if (r.exitCode === 0) {
|
|
1927
|
+
kept++;
|
|
1928
|
+
this.log.info(`[reconcile] 会话 [${id}] 仍存活,保留(已重连)`);
|
|
1929
|
+
} else {
|
|
1930
|
+
await this.remove(id);
|
|
1931
|
+
removed++;
|
|
1932
|
+
this.log.warn(`[reconcile] 僵尸会话 [${id}] 已清理`);
|
|
1933
|
+
}
|
|
1934
|
+
} catch {
|
|
1935
|
+
kept++;
|
|
1936
|
+
}
|
|
1937
|
+
}));
|
|
1938
|
+
return { scanned: profiles.length, kept, removed };
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// src/terminal/session-launcher.ts
|
|
1943
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1944
|
+
|
|
1945
|
+
class SessionLauncher {
|
|
1946
|
+
registry;
|
|
1947
|
+
cfg;
|
|
1948
|
+
log;
|
|
1949
|
+
constructor(registry, opts = {}) {
|
|
1950
|
+
this.registry = registry;
|
|
1951
|
+
this.cfg = { ...DEFAULT_TERMINAL_CONFIG, ...opts.config };
|
|
1952
|
+
this.log = opts.logger ?? noopLogger;
|
|
1953
|
+
}
|
|
1954
|
+
async launch(driver, target, config, customId) {
|
|
1955
|
+
const id = customId || "term_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 6);
|
|
1956
|
+
const launchCmd = this.resolveLaunchCmd(driver, target, config);
|
|
1957
|
+
try {
|
|
1958
|
+
const alive = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
1959
|
+
if (alive.exitCode === 0) {
|
|
1960
|
+
this.log.info(`会话 [${id}] 已存在于 tmux,复用`);
|
|
1961
|
+
const profile = { id, driver, target: target ?? "", config, createdAt: Date.now() };
|
|
1962
|
+
await this.registry.save(profile);
|
|
1963
|
+
return id;
|
|
1964
|
+
}
|
|
1965
|
+
} catch {}
|
|
1966
|
+
const finalCmd = this.cfg.env_hardening_prefix ? `${this.cfg.env_hardening_prefix}; ${launchCmd}` : launchCmd;
|
|
1967
|
+
this.log.info(`启动持久会话 [${id}] (driver: ${driver})`);
|
|
1968
|
+
await TmuxInvoker.invoke(["new-session", "-d", "-x", "220", "-s", id, finalCmd]);
|
|
1969
|
+
const ok = await this.waitForAlive(id, driver);
|
|
1970
|
+
if (!ok) {
|
|
1971
|
+
await this.registry.remove(id);
|
|
1972
|
+
throw new Error(this.diedHint(id, driver, target));
|
|
1973
|
+
}
|
|
1974
|
+
const profile = { id, driver, target: target ?? "", config, createdAt: Date.now() };
|
|
1975
|
+
await this.registry.save(profile);
|
|
1976
|
+
return id;
|
|
1977
|
+
}
|
|
1978
|
+
resolveLaunchCmd(driver, target, config) {
|
|
1979
|
+
const c = config ?? {};
|
|
1980
|
+
if (typeof c.launchCmd === "string" && c.launchCmd)
|
|
1981
|
+
return c.launchCmd;
|
|
1982
|
+
if (driver === "ssh" && target) {
|
|
1983
|
+
const sshOpts = typeof c.sshOptions === "string" ? c.sshOptions : "";
|
|
1984
|
+
return `ssh ${sshOpts} -t ${target}`;
|
|
1985
|
+
}
|
|
1986
|
+
if (driver === "docker" && target) {
|
|
1987
|
+
const runtime = typeof c.runtime === "string" ? c.runtime : "docker";
|
|
1988
|
+
const shell = typeof c.shell === "string" ? c.shell : "sh";
|
|
1989
|
+
return `${runtime} exec -i ${target} ${shell} -i`;
|
|
1990
|
+
}
|
|
1991
|
+
if (driver === "k8s" && target) {
|
|
1992
|
+
const runtime = typeof c.runtime === "string" ? c.runtime : "kubectl";
|
|
1993
|
+
const ns = typeof c.namespace === "string" ? `-n ${c.namespace}` : "";
|
|
1994
|
+
const shell = typeof c.shell === "string" ? c.shell : "sh";
|
|
1995
|
+
return `${runtime} exec -i ${ns} ${target} -- ${shell} -i`;
|
|
1996
|
+
}
|
|
1997
|
+
if (driver === "teleport" && target) {
|
|
1998
|
+
const cluster = typeof c.cluster === "string" ? `--cluster=${c.cluster} ` : "";
|
|
1999
|
+
const login = typeof c.login === "string" ? `${c.login}@` : "";
|
|
2000
|
+
return `tsh ssh ${cluster}${login}${target}`;
|
|
2001
|
+
}
|
|
2002
|
+
if (driver === "local")
|
|
2003
|
+
return "bash";
|
|
2004
|
+
throw new Error(`Unknown driver '${driver}' and no config.launchCmd provided. ` + `Supply config.launchCmd with the full command to enter the environment ` + `(e.g. "nix develop", "conda run -n myenv bash", "distrobox enter my-box").`);
|
|
2005
|
+
}
|
|
2006
|
+
async waitForAlive(id, driver) {
|
|
2007
|
+
const isLocal = driver === "local";
|
|
2008
|
+
const maxWaitMs = isLocal ? this.cfg.spawn_max_wait_ms_local : this.cfg.spawn_max_wait_ms_remote;
|
|
2009
|
+
const maxDeadStreak = isLocal ? 1 : 3;
|
|
2010
|
+
const deadline = Date.now() + maxWaitMs;
|
|
2011
|
+
let deadStreak = 0;
|
|
2012
|
+
while (Date.now() < deadline) {
|
|
2013
|
+
await sleep(this.cfg.spawn_poll_ms);
|
|
2014
|
+
let alive;
|
|
2015
|
+
try {
|
|
2016
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
2017
|
+
alive = r.exitCode === 0;
|
|
2018
|
+
} catch {
|
|
2019
|
+
return false;
|
|
2020
|
+
}
|
|
2021
|
+
if (!alive) {
|
|
2022
|
+
deadStreak++;
|
|
2023
|
+
if (deadStreak >= maxDeadStreak)
|
|
2024
|
+
return false;
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
deadStreak = 0;
|
|
2028
|
+
const paneOut = await TmuxInvoker.tryInvoke(["capture-pane", "-t", id, "-p"]);
|
|
2029
|
+
if (paneOut.stdout !== "")
|
|
2030
|
+
return true;
|
|
2031
|
+
}
|
|
2032
|
+
try {
|
|
2033
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
2034
|
+
return r.exitCode === 0;
|
|
2035
|
+
} catch {
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
diedHint(id, driver, target) {
|
|
2040
|
+
const hint = driver === "docker" ? `Make sure the container '${target}' is running ('docker ps'). Use the container name/ID, not the image name.` : driver === "ssh" ? `Check that '${target}' is reachable and SSH credentials are valid.` : driver === "teleport" ? `Check 'tsh status' — a valid Teleport login is required.` : "The shell exited immediately after launch.";
|
|
2041
|
+
return `Session '${id}' (driver: ${driver}) died immediately after spawn. ${hint}`;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// src/terminal/output-buffer.ts
|
|
2046
|
+
class OutputBuffer {
|
|
2047
|
+
pendingStdout = [];
|
|
2048
|
+
pendingStderr = [];
|
|
2049
|
+
aggregated = "";
|
|
2050
|
+
tail = "";
|
|
2051
|
+
totalOutputChars = 0;
|
|
2052
|
+
truncated = false;
|
|
2053
|
+
baseOffset = 0;
|
|
2054
|
+
pendingCap;
|
|
2055
|
+
aggregatedCap;
|
|
2056
|
+
tailCap;
|
|
2057
|
+
constructor(caps) {
|
|
2058
|
+
this.pendingCap = caps.pendingChars;
|
|
2059
|
+
this.aggregatedCap = caps.aggregatedChars;
|
|
2060
|
+
this.tailCap = caps.tailChars;
|
|
2061
|
+
}
|
|
2062
|
+
append(chunk, stream = "stdout") {
|
|
2063
|
+
if (chunk.length === 0)
|
|
2064
|
+
return;
|
|
2065
|
+
this.totalOutputChars += chunk.length;
|
|
2066
|
+
const buf = stream === "stderr" ? this.pendingStderr : this.pendingStdout;
|
|
2067
|
+
buf.push(chunk);
|
|
2068
|
+
this.trimPending(buf);
|
|
2069
|
+
this.aggregated += chunk;
|
|
2070
|
+
if (this.aggregated.length > this.aggregatedCap) {
|
|
2071
|
+
const dropped = this.aggregated.length - this.aggregatedCap;
|
|
2072
|
+
this.aggregated = this.aggregated.slice(-this.aggregatedCap);
|
|
2073
|
+
this.baseOffset += dropped;
|
|
2074
|
+
this.truncated = true;
|
|
2075
|
+
}
|
|
2076
|
+
this.tail = this.aggregated.slice(-this.tailCap);
|
|
2077
|
+
}
|
|
2078
|
+
drainPending() {
|
|
2079
|
+
const out = this.pendingStdout.join("");
|
|
2080
|
+
const err = this.pendingStderr.join("");
|
|
2081
|
+
this.pendingStdout = [];
|
|
2082
|
+
this.pendingStderr = [];
|
|
2083
|
+
return out + (err ? `
|
|
2084
|
+
[stderr]
|
|
2085
|
+
${err}` : "");
|
|
2086
|
+
}
|
|
2087
|
+
readTail() {
|
|
2088
|
+
return this.tail;
|
|
2089
|
+
}
|
|
2090
|
+
readLog(offset = 0, limit) {
|
|
2091
|
+
if (offset < this.baseOffset) {
|
|
2092
|
+
const lostChars = this.baseOffset - offset;
|
|
2093
|
+
const text = limit === undefined ? this.aggregated : this.aggregated.slice(0, limit);
|
|
2094
|
+
return { ok: false, text, nextOffset: this.baseOffset + text.length, validFrom: this.baseOffset, lostChars };
|
|
2095
|
+
}
|
|
2096
|
+
const start = offset - this.baseOffset;
|
|
2097
|
+
const text = limit === undefined ? this.aggregated.slice(start) : this.aggregated.slice(start, start + limit);
|
|
2098
|
+
return { ok: true, text, nextOffset: offset + text.length };
|
|
2099
|
+
}
|
|
2100
|
+
readLogText(offset = 0, limit) {
|
|
2101
|
+
const r = this.readLog(offset, limit);
|
|
2102
|
+
if (!r.ok) {
|
|
2103
|
+
return `[游标已过期] 此前约 ${r.lostChars} 字符已被裁掉,以下从绝对偏移 ${r.validFrom} 继续
|
|
2104
|
+
` + r.text;
|
|
2105
|
+
}
|
|
2106
|
+
return r.text;
|
|
2107
|
+
}
|
|
2108
|
+
get stats() {
|
|
2109
|
+
return { total: this.totalOutputChars, truncated: this.truncated };
|
|
2110
|
+
}
|
|
2111
|
+
hasPending() {
|
|
2112
|
+
return this.pendingStdout.length > 0 || this.pendingStderr.length > 0;
|
|
2113
|
+
}
|
|
2114
|
+
reset() {
|
|
2115
|
+
this.pendingStdout = [];
|
|
2116
|
+
this.pendingStderr = [];
|
|
2117
|
+
this.aggregated = "";
|
|
2118
|
+
this.tail = "";
|
|
2119
|
+
this.totalOutputChars = 0;
|
|
2120
|
+
this.baseOffset = 0;
|
|
2121
|
+
this.truncated = false;
|
|
2122
|
+
}
|
|
2123
|
+
trimPending(buf) {
|
|
2124
|
+
let len = buf.reduce((s, c) => s + c.length, 0);
|
|
2125
|
+
while (len > this.pendingCap && buf.length > 1) {
|
|
2126
|
+
const dropped = buf.shift();
|
|
2127
|
+
len -= dropped.length;
|
|
2128
|
+
this.truncated = true;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// src/terminal/command-executer.ts
|
|
2134
|
+
class CommandExecError extends Error {
|
|
2135
|
+
partialOutput;
|
|
2136
|
+
outcome;
|
|
2137
|
+
constructor(message, partialOutput = "", outcome) {
|
|
2138
|
+
super(message);
|
|
2139
|
+
this.name = "CommandExecError";
|
|
2140
|
+
this.partialOutput = partialOutput;
|
|
2141
|
+
this.outcome = outcome;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
var SAFE_PREFIX = "shopt -u promptvars nullglob extglob nocaseglob 2>/dev/null;";
|
|
2145
|
+
var BINARY_SNIFF_BYTES = 4096;
|
|
2146
|
+
var BINARY_NULL_RATIO = 0.1;
|
|
2147
|
+
function isBinaryChunk(text) {
|
|
2148
|
+
const sample = text.length > BINARY_SNIFF_BYTES ? text.slice(0, BINARY_SNIFF_BYTES) : text;
|
|
2149
|
+
if (sample.length === 0)
|
|
2150
|
+
return false;
|
|
2151
|
+
let nulls = 0;
|
|
2152
|
+
for (let i = 0;i < sample.length; i++) {
|
|
2153
|
+
if (sample.charCodeAt(i) === 0)
|
|
2154
|
+
nulls++;
|
|
2155
|
+
}
|
|
2156
|
+
return nulls / sample.length > BINARY_NULL_RATIO;
|
|
2157
|
+
}
|
|
2158
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2159
|
+
|
|
2160
|
+
class CommandExecuter {
|
|
2161
|
+
constructor() {}
|
|
2162
|
+
static cfg = { ...DEFAULT_TERMINAL_CONFIG };
|
|
2163
|
+
static sttyOff = new Set;
|
|
2164
|
+
static chains = new Map;
|
|
2165
|
+
static deadSessions = new Set;
|
|
2166
|
+
static log = noopLogger;
|
|
2167
|
+
static configure(opts = {}) {
|
|
2168
|
+
this.cfg = { ...DEFAULT_TERMINAL_CONFIG, ...opts.config };
|
|
2169
|
+
this.log = opts.logger ?? noopLogger;
|
|
2170
|
+
}
|
|
2171
|
+
static async execute(id, command, opts = {}) {
|
|
2172
|
+
if (this.deadSessions.has(id)) {
|
|
2173
|
+
try {
|
|
2174
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
2175
|
+
if (r.exitCode === 0) {
|
|
2176
|
+
this.deadSessions.delete(id);
|
|
2177
|
+
this.sttyOff.delete(id);
|
|
2178
|
+
} else {
|
|
2179
|
+
throw new CommandExecError(`tmux session '${id}' 已因超时终态不可确认被销毁——请重建层(同 id respawn 后自动恢复)再执行`);
|
|
2180
|
+
}
|
|
2181
|
+
} catch (e) {
|
|
2182
|
+
if (e instanceof CommandExecError)
|
|
2183
|
+
throw e;
|
|
2184
|
+
throw new CommandExecError(`tmux session '${id}' 已销毁且探活失败——请重建层`);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
const prev = this.chains.get(id) ?? Promise.resolve();
|
|
2188
|
+
const run = prev.catch(() => {
|
|
2189
|
+
return;
|
|
2190
|
+
}).then(() => this.executeLocked(id, command, opts));
|
|
2191
|
+
this.chains.set(id, run.catch(() => {
|
|
2192
|
+
return;
|
|
2193
|
+
}));
|
|
2194
|
+
return run;
|
|
2195
|
+
}
|
|
2196
|
+
static async executeLocked(id, command, opts = {}) {
|
|
2197
|
+
const cfg = this.cfg;
|
|
2198
|
+
const timeoutMs = opts.timeoutMs ?? cfg.command_timeout_ms;
|
|
2199
|
+
let alive;
|
|
2200
|
+
try {
|
|
2201
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
2202
|
+
alive = r.exitCode === 0;
|
|
2203
|
+
} catch {
|
|
2204
|
+
alive = false;
|
|
2205
|
+
}
|
|
2206
|
+
if (!alive) {
|
|
2207
|
+
throw new CommandExecError(`tmux session '${id}' does not exist. Spawn a new session first.`);
|
|
2208
|
+
}
|
|
2209
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]);
|
|
2210
|
+
if (!this.sttyOff.has(id)) {
|
|
2211
|
+
await this.sendKeys(id, "stty -echo 2>/dev/null; export PS1= PS2=;");
|
|
2212
|
+
await sleep2(400);
|
|
2213
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]);
|
|
2214
|
+
this.sttyOff.add(id);
|
|
2215
|
+
}
|
|
2216
|
+
try {
|
|
2217
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "-l", "clear"]);
|
|
2218
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-m"]);
|
|
2219
|
+
await sleep2(200);
|
|
2220
|
+
} catch {}
|
|
2221
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]).catch(() => {
|
|
2222
|
+
return;
|
|
2223
|
+
});
|
|
2224
|
+
const markerToken = Math.random().toString(36).slice(2, 10);
|
|
2225
|
+
const markerPrefix = `---CMD_DONE_${markerToken}_`;
|
|
2226
|
+
const markerEchoNeedle = `echo ${markerPrefix}`;
|
|
2227
|
+
const safeCommand = `${SAFE_PREFIX} ${command}`;
|
|
2228
|
+
await this.sendKeys(id, safeCommand);
|
|
2229
|
+
await this.sendKeys(id, `echo ${markerPrefix}$?`);
|
|
2230
|
+
const buffer = new OutputBuffer({
|
|
2231
|
+
pendingChars: cfg.buffer_pending_chars,
|
|
2232
|
+
aggregatedChars: cfg.buffer_aggregated_chars,
|
|
2233
|
+
tailChars: cfg.buffer_tail_chars
|
|
2234
|
+
});
|
|
2235
|
+
const wallDeadline = Date.now() + timeoutMs;
|
|
2236
|
+
let lastActivityAt = Date.now();
|
|
2237
|
+
let lastPaneText = "";
|
|
2238
|
+
let binaryDetected = false;
|
|
2239
|
+
let binaryBytes = 0;
|
|
2240
|
+
while (true) {
|
|
2241
|
+
if (Date.now() >= wallDeadline || Date.now() - lastActivityAt >= cfg.no_output_timeout_ms) {
|
|
2242
|
+
const reason = Date.now() >= wallDeadline ? `Timeout waiting for command to finish in terminal ${id}` : `Timeout waiting for command to finish in terminal ${id} (no output for ${cfg.no_output_timeout_ms}ms)`;
|
|
2243
|
+
const partial = this.extractPartialOutput(buffer, markerPrefix, markerEchoNeedle, binaryDetected, binaryBytes);
|
|
2244
|
+
try {
|
|
2245
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-c"]);
|
|
2246
|
+
} catch {}
|
|
2247
|
+
const drainToken = `__FX_DRAIN_${Math.random().toString(36).slice(2, 8)}`;
|
|
2248
|
+
try {
|
|
2249
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "-l", `echo ${drainToken}`]);
|
|
2250
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-m"]);
|
|
2251
|
+
} catch {}
|
|
2252
|
+
const drained = await this.waitForDrain(id, markerPrefix, drainToken, 5000);
|
|
2253
|
+
if (drained) {
|
|
2254
|
+
try {
|
|
2255
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]);
|
|
2256
|
+
} catch {}
|
|
2257
|
+
throw new CommandExecError(`${reason}(已发 ^C,远端 shell 已确认回提示符——outcome=unknown)`, partial, "unknown");
|
|
2258
|
+
}
|
|
2259
|
+
try {
|
|
2260
|
+
await TmuxInvoker.invoke(["kill-session", "-t", id]);
|
|
2261
|
+
} catch {}
|
|
2262
|
+
this.deadSessions.add(id);
|
|
2263
|
+
this.sttyOff.delete(id);
|
|
2264
|
+
throw new CommandExecError(`${reason}(已发 ^C 但 5s 内未见可信终态——会话已销毁防残影串台;同 id respawn 后自动恢复)`, partial, "unknown");
|
|
2265
|
+
}
|
|
2266
|
+
const paneText = await this.capturePane(id, cfg.capture_scrollback);
|
|
2267
|
+
if (paneText !== lastPaneText) {
|
|
2268
|
+
lastActivityAt = Date.now();
|
|
2269
|
+
if (paneText.length > 0) {
|
|
2270
|
+
if (!binaryDetected && isBinaryChunk(paneText)) {
|
|
2271
|
+
binaryDetected = true;
|
|
2272
|
+
binaryBytes = paneText.length;
|
|
2273
|
+
this.log.warn(`[${id}] 检测到二进制输出,切换字节计数模式`);
|
|
2274
|
+
} else if (binaryDetected) {
|
|
2275
|
+
binaryBytes = paneText.length;
|
|
2276
|
+
} else {
|
|
2277
|
+
const delta = this.computeDelta(lastPaneText, paneText);
|
|
2278
|
+
if (delta.length > 0)
|
|
2279
|
+
buffer.append(delta);
|
|
2280
|
+
if (opts.onProgress && !paneText.includes(markerPrefix)) {
|
|
2281
|
+
try {
|
|
2282
|
+
opts.onProgress({ tail: buffer.readTail(), stats: buffer.stats });
|
|
2283
|
+
} catch {}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
lastPaneText = paneText;
|
|
2288
|
+
}
|
|
2289
|
+
if (paneText.includes(markerPrefix)) {
|
|
2290
|
+
const exitCode = this.parseExitCode(paneText, markerPrefix);
|
|
2291
|
+
if (exitCode !== null) {
|
|
2292
|
+
if (binaryDetected) {
|
|
2293
|
+
return { stdout: `[binary output: ${binaryBytes} bytes omitted]`, exitCode };
|
|
2294
|
+
}
|
|
2295
|
+
const stdout = this.stripMarkerNoise(buffer.readLogText(), markerPrefix, markerEchoNeedle);
|
|
2296
|
+
return { stdout, exitCode };
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (opts.responder && !paneText.includes(markerPrefix)) {
|
|
2300
|
+
const payload = this.detectPrompt(paneText, id);
|
|
2301
|
+
if (payload) {
|
|
2302
|
+
this.log.info(`[${id}] 检测到交互 Prompt:${payload.metadata.prompt}`);
|
|
2303
|
+
const reply = await opts.responder.ask(payload);
|
|
2304
|
+
if (reply) {
|
|
2305
|
+
await this.sendKeys(id, reply);
|
|
2306
|
+
await sleep2(800);
|
|
2307
|
+
lastPaneText = "";
|
|
2308
|
+
lastActivityAt = Date.now();
|
|
2309
|
+
}
|
|
2310
|
+
continue;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
try {
|
|
2314
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
2315
|
+
alive = r.exitCode === 0;
|
|
2316
|
+
} catch {
|
|
2317
|
+
alive = false;
|
|
2318
|
+
}
|
|
2319
|
+
if (!alive) {
|
|
2320
|
+
throw new CommandExecError(`tmux session '${id}' died while waiting for command to complete.`, this.extractPartialOutput(buffer, markerPrefix, markerEchoNeedle, binaryDetected, binaryBytes));
|
|
2321
|
+
}
|
|
2322
|
+
lastActivityAt = Date.now();
|
|
2323
|
+
await sleep2(cfg.poll_interval_ms);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
static detectPrompt(paneText, terminalId) {
|
|
2327
|
+
if (!paneText)
|
|
2328
|
+
return null;
|
|
2329
|
+
const re = compilePromptPatterns(this.cfg.prompt_patterns);
|
|
2330
|
+
const m = paneText.match(re);
|
|
2331
|
+
if (!m)
|
|
2332
|
+
return null;
|
|
2333
|
+
const prompt = m[0].trim();
|
|
2334
|
+
return {
|
|
2335
|
+
type: "CONFIRM",
|
|
2336
|
+
title: "Shell Interaction Needed",
|
|
2337
|
+
message: `The command is requesting input: "${prompt}"`,
|
|
2338
|
+
metadata: { terminalId, prompt, platform: "terminal" }
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
static parseExitCode(paneText, markerPrefix) {
|
|
2342
|
+
for (const line of paneText.split(`
|
|
2343
|
+
`)) {
|
|
2344
|
+
if (!line.includes(markerPrefix))
|
|
2345
|
+
continue;
|
|
2346
|
+
if (line.includes(`echo ${markerPrefix}`))
|
|
2347
|
+
continue;
|
|
2348
|
+
const match = line.match(new RegExp(`${markerPrefix}(\\d+)`));
|
|
2349
|
+
if (match)
|
|
2350
|
+
return parseInt(match[1] ?? "0", 10);
|
|
2351
|
+
}
|
|
2352
|
+
return null;
|
|
2353
|
+
}
|
|
2354
|
+
static computeDelta(prev, curr) {
|
|
2355
|
+
if (prev.length === 0)
|
|
2356
|
+
return curr;
|
|
2357
|
+
if (curr.startsWith(prev))
|
|
2358
|
+
return curr.slice(prev.length);
|
|
2359
|
+
const normPrev = prev.replace(/\r/g, "");
|
|
2360
|
+
const normCurr = curr.replace(/\r/g, "");
|
|
2361
|
+
if (normCurr.startsWith(normPrev))
|
|
2362
|
+
return curr.slice(prev.length);
|
|
2363
|
+
return curr;
|
|
2364
|
+
}
|
|
2365
|
+
static stripMarkerNoise(log, markerPrefix, markerEchoNeedle) {
|
|
2366
|
+
const resultLines = [];
|
|
2367
|
+
for (const line of log.split(`
|
|
2368
|
+
`)) {
|
|
2369
|
+
if (line.includes(markerPrefix))
|
|
2370
|
+
break;
|
|
2371
|
+
if (line.includes(markerEchoNeedle))
|
|
2372
|
+
continue;
|
|
2373
|
+
if (/__FX_DRAIN_[0-9a-z]+/.test(line))
|
|
2374
|
+
continue;
|
|
2375
|
+
if (/stty -echo/.test(line))
|
|
2376
|
+
continue;
|
|
2377
|
+
if (/\$\s*$/.test(line) && /debian_chroot|@\S+[:~]/.test(line))
|
|
2378
|
+
continue;
|
|
2379
|
+
if (/^\$\s*$/.test(line))
|
|
2380
|
+
continue;
|
|
2381
|
+
resultLines.push(line);
|
|
2382
|
+
}
|
|
2383
|
+
while (resultLines.length > 0 && resultLines[resultLines.length - 1].trim() === "") {
|
|
2384
|
+
resultLines.pop();
|
|
2385
|
+
}
|
|
2386
|
+
return resultLines.join(`
|
|
2387
|
+
`).trim();
|
|
2388
|
+
}
|
|
2389
|
+
static extractPartialOutput(buffer, markerPrefix, markerEchoNeedle, binaryDetected, binaryBytes) {
|
|
2390
|
+
if (binaryDetected)
|
|
2391
|
+
return `[binary output: ${binaryBytes} bytes omitted]`;
|
|
2392
|
+
return this.stripMarkerNoise(buffer.readLogText(), markerPrefix, markerEchoNeedle);
|
|
2393
|
+
}
|
|
2394
|
+
static async sendKeys(id, text) {
|
|
2395
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "-l", text]);
|
|
2396
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-m"]);
|
|
2397
|
+
}
|
|
2398
|
+
static async waitForDrain(id, markerPrefix, drainToken, deadlineMs) {
|
|
2399
|
+
const deadline = Date.now() + deadlineMs;
|
|
2400
|
+
while (Date.now() < deadline) {
|
|
2401
|
+
const pane = await this.capturePane(id, 80);
|
|
2402
|
+
if (pane.includes(drainToken))
|
|
2403
|
+
return true;
|
|
2404
|
+
if (pane.includes(markerPrefix) && this.parseExitCode(pane, markerPrefix) !== null)
|
|
2405
|
+
return true;
|
|
2406
|
+
await sleep2(120);
|
|
2407
|
+
}
|
|
2408
|
+
return false;
|
|
2409
|
+
}
|
|
2410
|
+
static async capturePane(id, scrollback) {
|
|
2411
|
+
try {
|
|
2412
|
+
const r = await TmuxInvoker.invoke(["capture-pane", "-t", id, "-p", "-S", `-${scrollback}`]);
|
|
2413
|
+
return r.stdout;
|
|
2414
|
+
} catch {
|
|
2415
|
+
return "";
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// src/terminal-manager.ts
|
|
2421
|
+
class TerminalManager {
|
|
2422
|
+
registry;
|
|
2423
|
+
launcher;
|
|
2424
|
+
terminalDir;
|
|
2425
|
+
constructor(opts = {}) {
|
|
2426
|
+
this.terminalDir = opts.terminalDir ?? path3.join(tmpdir3(), "filaxis-terminals");
|
|
2427
|
+
const log = opts.logger ?? noopLogger;
|
|
2428
|
+
this.registry = new SessionRegistry(() => this.terminalDir, log);
|
|
2429
|
+
this.launcher = new SessionLauncher(this.registry, { config: opts.config, logger: log });
|
|
2430
|
+
CommandExecuter.configure({ config: opts.config, logger: log });
|
|
2431
|
+
}
|
|
2432
|
+
async init() {
|
|
2433
|
+
await this.registry.init();
|
|
2434
|
+
}
|
|
2435
|
+
async spawnSession(driver, target, config, customId) {
|
|
2436
|
+
await this.init();
|
|
2437
|
+
return this.launcher.launch(driver, target, config, customId);
|
|
2438
|
+
}
|
|
2439
|
+
async runCommand(id, command, timeoutMsOrOptions = 600000) {
|
|
2440
|
+
const opts = typeof timeoutMsOrOptions === "number" ? { timeoutMs: timeoutMsOrOptions } : timeoutMsOrOptions;
|
|
2441
|
+
return CommandExecuter.execute(id, command, opts);
|
|
2442
|
+
}
|
|
2443
|
+
async destroySession(id) {
|
|
2444
|
+
try {
|
|
2445
|
+
await TmuxInvoker.invoke(["kill-session", "-t", id]);
|
|
2446
|
+
} catch {}
|
|
2447
|
+
await this.registry.remove(id);
|
|
2448
|
+
}
|
|
2449
|
+
async getSession(id) {
|
|
2450
|
+
return this.registry.load(id);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// src/tmux-driver.ts
|
|
2455
|
+
var q3 = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
2456
|
+
|
|
2457
|
+
class TmuxSessionDriver {
|
|
2458
|
+
descriptor;
|
|
2459
|
+
fs;
|
|
2460
|
+
terminal;
|
|
2461
|
+
log;
|
|
2462
|
+
constructor(descriptor, opts = {}) {
|
|
2463
|
+
this.descriptor = descriptor;
|
|
2464
|
+
this.terminal = opts.terminalManager ?? new TerminalManager;
|
|
2465
|
+
this.log = opts.logger ?? noopLogger;
|
|
2466
|
+
const self = this;
|
|
2467
|
+
this.fs = {
|
|
2468
|
+
async readFile(p) {
|
|
2469
|
+
return (await self.exec(`base64 ${q3(p)} | base64 -d 2>/dev/null`)).stdout;
|
|
2470
|
+
},
|
|
2471
|
+
async writeFile(p, content) {
|
|
2472
|
+
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
|
2473
|
+
await self.exec(`printf %s ${q3(b64)} | base64 -d > ${q3(p)}`);
|
|
2474
|
+
},
|
|
2475
|
+
async exists(p) {
|
|
2476
|
+
const r = await self.exec(`test -e ${q3(p)} && echo Y || echo N`);
|
|
2477
|
+
return r.stdout.trim() === "Y";
|
|
2478
|
+
},
|
|
2479
|
+
async stat(p) {
|
|
2480
|
+
const r = await self.exec(`stat -c '%s %Y' ${q3(p)} 2>/dev/null || stat -f '%z %m' ${q3(p)} 2>/dev/null || true`);
|
|
2481
|
+
const m = r.stdout.trim().match(/^(\d+)\s+(\d+)$/);
|
|
2482
|
+
return m ? { size: parseInt(m[1], 10), mtimeMs: parseInt(m[2], 10) * 1000 } : null;
|
|
2483
|
+
},
|
|
2484
|
+
async readdir(p) {
|
|
2485
|
+
const r = await self.exec(`ls -1A ${q3(p)} 2>/dev/null || true`);
|
|
2486
|
+
const names = r.stdout.split(`
|
|
2487
|
+
`).filter(Boolean);
|
|
2488
|
+
const out = [];
|
|
2489
|
+
for (const name of names) {
|
|
2490
|
+
const full = `${p.replace(/\/$/, "")}/${name}`;
|
|
2491
|
+
const d = await self.exec(`test -d ${q3(full)} && echo Y || echo N`);
|
|
2492
|
+
const st = await this.stat(full);
|
|
2493
|
+
out.push({ name, isDirectory: d.stdout.trim() === "Y", size: st?.size, mtime: st?.mtimeMs });
|
|
2494
|
+
}
|
|
2495
|
+
return out;
|
|
2496
|
+
},
|
|
2497
|
+
async rm(p, options) {
|
|
2498
|
+
await self.exec(`rm ${options?.recursive ? "-r " : ""}${options?.force ? "-f " : ""}${q3(p)}`);
|
|
2499
|
+
},
|
|
2500
|
+
async mkdir(p, options) {
|
|
2501
|
+
await self.exec(`mkdir ${options?.recursive ? "-p " : ""}${q3(p)}`);
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
async init() {
|
|
2506
|
+
if (!this.descriptor.terminalId) {
|
|
2507
|
+
this.descriptor.terminalId = await this.terminal.spawnSession(this.descriptor.driver, this.descriptor.target, this.descriptor.config);
|
|
2508
|
+
}
|
|
2509
|
+
if (this.descriptor.cwd) {
|
|
2510
|
+
try {
|
|
2511
|
+
await this.terminal.runCommand(this.descriptor.terminalId, `cd "${this.descriptor.cwd}" 2>/dev/null || true`, 1e4);
|
|
2512
|
+
} catch {}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
ownedSessions = new Set;
|
|
2516
|
+
async destroy() {
|
|
2517
|
+
if (this.descriptor.terminalId) {
|
|
2518
|
+
for (const sid of this.ownedSessions) {
|
|
2519
|
+
const base = `/tmp/filaxis-sessions/${sid}`;
|
|
2520
|
+
try {
|
|
2521
|
+
await this.terminal.runCommand(this.descriptor.terminalId, `kill $(cat ${q3(base)}.pid 2>/dev/null) 2>/dev/null; rm -f ${q3(base)}.pid; true`, 5000);
|
|
2522
|
+
} catch {}
|
|
2523
|
+
}
|
|
2524
|
+
this.ownedSessions.clear();
|
|
2525
|
+
await this.terminal.destroySession(this.descriptor.terminalId);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
async exec(command, options) {
|
|
2529
|
+
if (options?.channel === "pty") {
|
|
2530
|
+
const r = await this.terminal.runCommand(this.descriptor.terminalId, command, {
|
|
2531
|
+
timeoutMs: options?.timeoutMs ?? 120000
|
|
2532
|
+
});
|
|
2533
|
+
return { stdout: r.stdout, stderr: "", exitCode: r.exitCode };
|
|
2534
|
+
}
|
|
2535
|
+
const r = await this.terminal.runCommand(this.descriptor.terminalId, command, {
|
|
2536
|
+
timeoutMs: options?.timeoutMs ?? 120000
|
|
2537
|
+
});
|
|
2538
|
+
return { stdout: r.stdout, stderr: "", exitCode: r.exitCode };
|
|
2539
|
+
}
|
|
2540
|
+
async getCwd() {
|
|
2541
|
+
const r = await this.exec("pwd");
|
|
2542
|
+
return r.stdout.trim();
|
|
2543
|
+
}
|
|
2544
|
+
async getEnv(key) {
|
|
2545
|
+
return (await this.exec(`printf %s "\\$${key}"`)).stdout;
|
|
2546
|
+
}
|
|
2547
|
+
async execSession(command, opts) {
|
|
2548
|
+
const sessionId = `${opts?.name ?? "tsess"}-${Math.random().toString(16).slice(2, 8)}`;
|
|
2549
|
+
const base = `/tmp/filaxis-sessions/${sessionId}`;
|
|
2550
|
+
const wrapped = `{ ${command}; }; echo $? > ${base}.code`;
|
|
2551
|
+
const cd = opts?.cwd ? `cd ${q3(opts.cwd)} && ` : "";
|
|
2552
|
+
await this.exec(`mkdir -p /tmp/filaxis-sessions; ${cd}nohup bash -c ${q3(wrapped)} > ${base}.out 2> ${base}.err < /dev/null & echo $! > ${base}.pid; echo started`);
|
|
2553
|
+
this.ownedSessions.add(sessionId);
|
|
2554
|
+
return { sessionId, logBase: base };
|
|
2555
|
+
}
|
|
2556
|
+
async tailSession(sessionId, lines = 50) {
|
|
2557
|
+
const r = await this.exec(`tail -n ${lines} /tmp/filaxis-sessions/${sessionId}.out 2>/dev/null || true`);
|
|
2558
|
+
return r.stdout.split(`
|
|
2559
|
+
`).filter(Boolean);
|
|
2560
|
+
}
|
|
2561
|
+
async readSessionLog(sessionId, opts) {
|
|
2562
|
+
const suffix = opts?.stream === "stderr" ? ".err" : ".out";
|
|
2563
|
+
let cmd = `cat /tmp/filaxis-sessions/${sessionId}${suffix} 2>/dev/null || true`;
|
|
2564
|
+
if (opts?.lines)
|
|
2565
|
+
cmd += ` | tail -n ${opts.lines}`;
|
|
2566
|
+
return (await this.exec(cmd)).stdout.replace(/\n$/, "");
|
|
2567
|
+
}
|
|
2568
|
+
watchSession(_sessionId, _opts) {
|
|
2569
|
+
throw new Error("tmux 后端会话 watcher 属 M2 观察流批次(stream-session 落位后接入)");
|
|
2570
|
+
}
|
|
2571
|
+
async stopSession(sessionId) {
|
|
2572
|
+
await this.exec(`kill $(cat /tmp/filaxis-sessions/${sessionId}.pid 2>/dev/null) 2>/dev/null; true`);
|
|
2573
|
+
const code = await this.exec(`cat /tmp/filaxis-sessions/${sessionId}.code 2>/dev/null || true`);
|
|
2574
|
+
const c = code.stdout.trim();
|
|
2575
|
+
return { exitCode: /^\d+$/.test(c) ? parseInt(c, 10) : null };
|
|
2576
|
+
}
|
|
2577
|
+
listSessions() {
|
|
2578
|
+
return [];
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// src/factory.ts
|
|
2583
|
+
class RuntimeFactory {
|
|
2584
|
+
custom = new Map;
|
|
2585
|
+
registerDriver(type, factory) {
|
|
2586
|
+
if (this.custom.has(type) || ["local", "ssh", "tmux"].includes(type)) {
|
|
2587
|
+
throw new Error(`driver '${type}' 已注册,拒绝覆盖——先 unregister 或换类型名`);
|
|
2588
|
+
}
|
|
2589
|
+
this.custom.set(type, factory);
|
|
2590
|
+
}
|
|
2591
|
+
unregisterDriver(type) {
|
|
2592
|
+
return this.custom.delete(type);
|
|
2593
|
+
}
|
|
2594
|
+
registeredTypes() {
|
|
2595
|
+
return ["local", "ssh", "tmux", ...this.custom.keys()];
|
|
2596
|
+
}
|
|
2597
|
+
create(descriptor, fallbackCwd) {
|
|
2598
|
+
if (descriptor.driver === "local") {
|
|
2599
|
+
return LocalRuntimeDriver.fromDescriptor(descriptor, fallbackCwd);
|
|
2600
|
+
}
|
|
2601
|
+
if (descriptor.driver === "ssh") {
|
|
2602
|
+
return new SshPtyDriver(descriptor);
|
|
2603
|
+
}
|
|
2604
|
+
if (descriptor.driver === "tmux") {
|
|
2605
|
+
return new TmuxSessionDriver(descriptor);
|
|
2606
|
+
}
|
|
2607
|
+
const f = this.custom.get(descriptor.driver);
|
|
2608
|
+
if (f)
|
|
2609
|
+
return f(descriptor);
|
|
2610
|
+
throw new Error(`未知 driver '${descriptor.driver}'——经 registerDriver() 注册,或在 config.launchCmd 提供完整启动命令`);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
// src/runtime-stack.ts
|
|
2614
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
2615
|
+
class RuntimeStack {
|
|
2616
|
+
static driverCache = new Map;
|
|
2617
|
+
events = new EventBus;
|
|
2618
|
+
factory;
|
|
2619
|
+
log;
|
|
2620
|
+
featuresCache = null;
|
|
2621
|
+
constructor(opts = {}) {
|
|
2622
|
+
this.factory = opts.factory ?? new RuntimeFactory;
|
|
2623
|
+
this.log = opts.logger ?? noopLogger;
|
|
2624
|
+
}
|
|
2625
|
+
static driverKey(d) {
|
|
2626
|
+
const c = d.config ?? {};
|
|
2627
|
+
return JSON.stringify({
|
|
2628
|
+
driver: d.driver,
|
|
2629
|
+
target: d.target,
|
|
2630
|
+
terminalId: d.terminalId,
|
|
2631
|
+
cwd: d.cwd,
|
|
2632
|
+
capabilities: d.capabilities,
|
|
2633
|
+
cfg: {
|
|
2634
|
+
launchCmd: c.launchCmd,
|
|
2635
|
+
runtime: c.runtime,
|
|
2636
|
+
shell: c.shell,
|
|
2637
|
+
namespace: c.namespace,
|
|
2638
|
+
sshOptions: c.sshOptions,
|
|
2639
|
+
connParams: c.connParams
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
async connect(descriptor, fallbackCwd) {
|
|
2644
|
+
const key = RuntimeStack.driverKey(descriptor);
|
|
2645
|
+
const cached = RuntimeStack.driverCache.get(key);
|
|
2646
|
+
if (cached) {
|
|
2647
|
+
this.log.debug(`[Filaxis] 指纹命中复用连接: ${descriptor.driver}/${descriptor.target ?? "local"}`);
|
|
2648
|
+
return cached;
|
|
2649
|
+
}
|
|
2650
|
+
const drv = this.factory.create(descriptor, fallbackCwd);
|
|
2651
|
+
await drv.init();
|
|
2652
|
+
RuntimeStack.driverCache.set(key, drv);
|
|
2653
|
+
this.log.info(`[Filaxis] 新建连接: ${descriptor.driver}/${descriptor.target ?? "local"}`);
|
|
2654
|
+
return drv;
|
|
2655
|
+
}
|
|
2656
|
+
static evictDriver(d) {
|
|
2657
|
+
const key = RuntimeStack.driverKey(d);
|
|
2658
|
+
const drv = RuntimeStack.driverCache.get(key);
|
|
2659
|
+
RuntimeStack.driverCache.delete(key);
|
|
2660
|
+
return drv;
|
|
2661
|
+
}
|
|
2662
|
+
registerDriver(type, f) {
|
|
2663
|
+
this.factory.registerDriver(type, f);
|
|
2664
|
+
this.featuresCache = null;
|
|
2665
|
+
}
|
|
2666
|
+
async features() {
|
|
2667
|
+
if (this.featuresCache)
|
|
2668
|
+
return this.featuresCache;
|
|
2669
|
+
const has = (bin) => new Promise((resolve) => {
|
|
2670
|
+
const probe = process.platform === "win32" ? spawn5("where", [bin], { stdio: "ignore" }) : spawn5("which", [bin], { stdio: "ignore" });
|
|
2671
|
+
probe.on("close", (code) => resolve(code === 0));
|
|
2672
|
+
probe.on("error", () => resolve(false));
|
|
2673
|
+
});
|
|
2674
|
+
const [sshOk, sftpOk, tmuxOk] = await Promise.all([has("ssh"), has("sftp"), has("tmux")]);
|
|
2675
|
+
this.featuresCache = {
|
|
2676
|
+
mux: process.platform !== "win32" && sshOk,
|
|
2677
|
+
sftp: sftpOk,
|
|
2678
|
+
persistent: tmuxOk,
|
|
2679
|
+
transports: this.factory.registeredTypes(),
|
|
2680
|
+
stderrSeparated: true
|
|
2681
|
+
};
|
|
2682
|
+
return this.featuresCache;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
// src/dialect.ts
|
|
2686
|
+
var NULL_DIALECT = {
|
|
2687
|
+
family: "unknown",
|
|
2688
|
+
base64Encode: [],
|
|
2689
|
+
base64Decode: [],
|
|
2690
|
+
sha256: []
|
|
2691
|
+
};
|
|
2692
|
+
async function tryCmd(exec, cmd) {
|
|
2693
|
+
try {
|
|
2694
|
+
const r = await exec(cmd);
|
|
2695
|
+
return { ok: r.exitCode === 0, stdout: r.stdout };
|
|
2696
|
+
} catch {
|
|
2697
|
+
return { ok: false, stdout: "" };
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
async function probeDialect(exec) {
|
|
2701
|
+
const gnuEnc = await tryCmd(exec, "printf filaxis | base64 -w 0");
|
|
2702
|
+
if (gnuEnc.ok && gnuEnc.stdout.includes("ZmlsYXhpcw==")) {
|
|
2703
|
+
const gnuDec = await tryCmd(exec, "printf ZmlsYXhpcw== | base64 -d");
|
|
2704
|
+
if (gnuDec.ok && gnuDec.stdout.includes("filaxis")) {
|
|
2705
|
+
const sha = await detectSha256(exec);
|
|
2706
|
+
return { family: sha.family, base64Encode: ["base64", "-w", "0"], base64Decode: ["base64", "-d"], sha256: sha.argv };
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
const bsdEnc = await tryCmd(exec, "printf filaxis | base64");
|
|
2710
|
+
if (bsdEnc.ok && bsdEnc.stdout.replace(/\s+/g, "").includes("ZmlsYXhpcw==")) {
|
|
2711
|
+
const decD = await tryCmd(exec, "printf ZmlsYXhpcw== | base64 -d");
|
|
2712
|
+
const decFlag = decD.ok && decD.stdout.includes("filaxis") ? "-d" : "-D";
|
|
2713
|
+
const sha = await detectSha256(exec);
|
|
2714
|
+
return { family: sha.family, base64Encode: ["base64"], base64Decode: ["base64", decFlag], sha256: sha.argv };
|
|
2715
|
+
}
|
|
2716
|
+
return { ...NULL_DIALECT, sha256: (await detectSha256(exec)).argv };
|
|
2717
|
+
}
|
|
2718
|
+
var HEX64 = /[0-9a-f]{64}/;
|
|
2719
|
+
async function detectSha256(exec) {
|
|
2720
|
+
const coreutils = await tryCmd(exec, "printf filaxis | sha256sum");
|
|
2721
|
+
if (coreutils.ok && HEX64.test(coreutils.stdout)) {
|
|
2722
|
+
return { family: "gnu", argv: ["sha256sum"] };
|
|
2723
|
+
}
|
|
2724
|
+
const bsd = await tryCmd(exec, "printf filaxis | sha256 -q");
|
|
2725
|
+
if (bsd.ok && HEX64.test(bsd.stdout.trim())) {
|
|
2726
|
+
return { family: "bsd", argv: ["sha256", "-q"] };
|
|
2727
|
+
}
|
|
2728
|
+
const openssl = await tryCmd(exec, "printf filaxis | openssl dgst -sha256");
|
|
2729
|
+
if (openssl.ok && HEX64.test(openssl.stdout)) {
|
|
2730
|
+
return { family: "openssl-fallback", argv: ["openssl", "dgst", "-sha256"] };
|
|
2731
|
+
}
|
|
2732
|
+
return { family: "unknown", argv: [] };
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
class DialectCache {
|
|
2736
|
+
cache = new Map;
|
|
2737
|
+
async get(probeKey, exec, force = false) {
|
|
2738
|
+
if (!force && this.cache.has(probeKey))
|
|
2739
|
+
return this.cache.get(probeKey);
|
|
2740
|
+
const d = await probeDialect(exec);
|
|
2741
|
+
this.cache.set(probeKey, d);
|
|
2742
|
+
return d;
|
|
2743
|
+
}
|
|
2744
|
+
invalidate(probeKey) {
|
|
2745
|
+
if (probeKey)
|
|
2746
|
+
this.cache.delete(probeKey);
|
|
2747
|
+
else
|
|
2748
|
+
this.cache.clear();
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// src/index.ts
|
|
2753
|
+
init_ssh_helpers();
|
|
2754
|
+
|
|
2755
|
+
// src/stream-session-manager.ts
|
|
2756
|
+
import * as crypto5 from "node:crypto";
|
|
2757
|
+
import { existsSync as existsSync2, mkdirSync, rmSync } from "node:fs";
|
|
2758
|
+
import { join as join2 } from "node:path";
|
|
2759
|
+
import { tmpdir as tmpdir4 } from "node:os";
|
|
2760
|
+
import { spawn as spawn6 } from "node:child_process";
|
|
2761
|
+
|
|
2762
|
+
// src/vt100-screen.ts
|
|
2763
|
+
var ESC = "\x1B";
|
|
2764
|
+
var BEL = "\x07";
|
|
2765
|
+
|
|
2766
|
+
class Vt100Screen {
|
|
2767
|
+
rows;
|
|
2768
|
+
curRow = 0;
|
|
2769
|
+
curCol = 0;
|
|
2770
|
+
state = 0;
|
|
2771
|
+
csiBuf = "";
|
|
2772
|
+
oscBuf = "";
|
|
2773
|
+
_frames = 0;
|
|
2774
|
+
constructor(opts = {}) {
|
|
2775
|
+
const rows = opts.rows ?? 24;
|
|
2776
|
+
const cols = opts.cols ?? 220;
|
|
2777
|
+
this.rows = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
|
2778
|
+
}
|
|
2779
|
+
feed(chunk) {
|
|
2780
|
+
for (const ch of chunk)
|
|
2781
|
+
this.put(ch);
|
|
2782
|
+
this._frames++;
|
|
2783
|
+
}
|
|
2784
|
+
snapshot() {
|
|
2785
|
+
return this.rows.map((r) => r.join("").replace(/ +$/, "")).join(`
|
|
2786
|
+
`);
|
|
2787
|
+
}
|
|
2788
|
+
get cursor() {
|
|
2789
|
+
return { row: this.curRow, col: this.curCol };
|
|
2790
|
+
}
|
|
2791
|
+
get frames() {
|
|
2792
|
+
return this._frames;
|
|
2793
|
+
}
|
|
2794
|
+
put(ch) {
|
|
2795
|
+
switch (this.state) {
|
|
2796
|
+
case 0: {
|
|
2797
|
+
if (ch === ESC) {
|
|
2798
|
+
this.state = 1;
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
this.writeChar(ch);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
case 1: {
|
|
2805
|
+
if (ch === "[") {
|
|
2806
|
+
this.state = 2;
|
|
2807
|
+
this.csiBuf = "";
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
if (ch === "]") {
|
|
2811
|
+
this.state = 3;
|
|
2812
|
+
this.oscBuf = "";
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
this.state = 0;
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
case 2: {
|
|
2819
|
+
if (ch >= "0" && ch <= "9" || ch === ";" || ch === "?") {
|
|
2820
|
+
this.csiBuf += ch;
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2823
|
+
this.state = 0;
|
|
2824
|
+
this.csiDispatch(ch);
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
default: {
|
|
2828
|
+
if (ch === BEL || ch === ESC) {
|
|
2829
|
+
this.state = 0;
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
if (this.oscBuf.length < 256)
|
|
2833
|
+
this.oscBuf += ch;
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
csiDispatch(final) {
|
|
2839
|
+
const parts = this.csiBuf.replace(/\?/g, "").split(";");
|
|
2840
|
+
const n = (i, d) => {
|
|
2841
|
+
const v = parts[i] === undefined || parts[i] === "" ? d : parseInt(parts[i], 10);
|
|
2842
|
+
return Number.isFinite(v) ? v : d;
|
|
2843
|
+
};
|
|
2844
|
+
switch (final) {
|
|
2845
|
+
case "H":
|
|
2846
|
+
case "f":
|
|
2847
|
+
this.moveTo(Math.max(0, n(0, 1) - 1), Math.max(0, n(1, 1) - 1));
|
|
2848
|
+
return;
|
|
2849
|
+
case "A":
|
|
2850
|
+
this.moveTo(this.curRow - Math.max(1, n(0, 1)), this.curCol);
|
|
2851
|
+
return;
|
|
2852
|
+
case "B":
|
|
2853
|
+
this.moveTo(this.curRow + Math.max(1, n(0, 1)), this.curCol);
|
|
2854
|
+
return;
|
|
2855
|
+
case "C":
|
|
2856
|
+
this.moveTo(this.curRow, this.curCol + Math.max(1, n(0, 1)));
|
|
2857
|
+
return;
|
|
2858
|
+
case "D":
|
|
2859
|
+
this.moveTo(this.curRow, this.curCol - Math.max(1, n(0, 1)));
|
|
2860
|
+
return;
|
|
2861
|
+
case "J": {
|
|
2862
|
+
const mode = n(0, 0);
|
|
2863
|
+
if (mode === 2)
|
|
2864
|
+
this.clearAll();
|
|
2865
|
+
else if (mode === 0)
|
|
2866
|
+
this.clearRegion(this.curRow, this.curCol, this.rows.length - 1, -1);
|
|
2867
|
+
else if (mode === 1)
|
|
2868
|
+
this.clearRegion(0, 0, this.curRow, this.curCol);
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
case "K": {
|
|
2872
|
+
const mode = n(0, 0);
|
|
2873
|
+
if (mode === 0)
|
|
2874
|
+
this.clearRegion(this.curRow, this.curCol, this.curRow, -1);
|
|
2875
|
+
else if (mode === 1)
|
|
2876
|
+
this.clearRegion(this.curRow, 0, this.curRow, this.curCol);
|
|
2877
|
+
else
|
|
2878
|
+
this.clearRegion(this.curRow, 0, this.curRow, -1);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
default:
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
writeChar(ch) {
|
|
2886
|
+
if (ch === `
|
|
2887
|
+
`) {
|
|
2888
|
+
this.lineFeed();
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
if (ch === "\r") {
|
|
2892
|
+
this.curCol = 0;
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
if (ch === "\b") {
|
|
2896
|
+
this.curCol = Math.max(0, this.curCol - 1);
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
if (ch === "\t") {
|
|
2900
|
+
const cols = this.rows[0].length;
|
|
2901
|
+
this.curCol = Math.min(cols - 1, (Math.floor(this.curCol / 8) + 1) * 8);
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
if (ch < " ")
|
|
2905
|
+
return;
|
|
2906
|
+
const cols = this.rows[0].length;
|
|
2907
|
+
if (this.curCol >= cols) {
|
|
2908
|
+
this.curCol = 0;
|
|
2909
|
+
this.lineFeed();
|
|
2910
|
+
}
|
|
2911
|
+
this.rows[this.curRow][this.curCol] = ch;
|
|
2912
|
+
this.curCol++;
|
|
2913
|
+
}
|
|
2914
|
+
lineFeed() {
|
|
2915
|
+
if (this.curRow >= this.rows.length - 1) {
|
|
2916
|
+
this.rows.shift();
|
|
2917
|
+
const cols = this.rows[0].length;
|
|
2918
|
+
this.rows.push(Array.from({ length: cols }, () => " "));
|
|
2919
|
+
this.curRow = this.rows.length - 1;
|
|
2920
|
+
} else {
|
|
2921
|
+
this.curRow++;
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
moveTo(row, col) {
|
|
2925
|
+
const cols = this.rows[0].length;
|
|
2926
|
+
this.curRow = Math.max(0, Math.min(this.rows.length - 1, row));
|
|
2927
|
+
this.curCol = Math.max(0, Math.min(cols - 1, col));
|
|
2928
|
+
}
|
|
2929
|
+
clearRegion(row1, col1, row2, col2) {
|
|
2930
|
+
const cols = this.rows[0].length;
|
|
2931
|
+
for (let r = Math.max(0, row1);r <= Math.min(this.rows.length - 1, row2); r++) {
|
|
2932
|
+
const from = r === row1 ? Math.max(0, col1) : 0;
|
|
2933
|
+
const to = col2 === -1 || r < row2 ? cols - 1 : Math.min(cols - 1, col2);
|
|
2934
|
+
for (let c = from;c <= to; c++)
|
|
2935
|
+
this.rows[r][c] = " ";
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
clearAll() {
|
|
2939
|
+
const cols = this.rows[0].length;
|
|
2940
|
+
this.rows = Array.from({ length: this.rows.length }, () => Array.from({ length: cols }, () => " "));
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// src/stream-session-manager.ts
|
|
2945
|
+
var masters = new Map;
|
|
2946
|
+
|
|
2947
|
+
class StreamSessionManager {
|
|
2948
|
+
onEvent = () => {
|
|
2949
|
+
return;
|
|
2950
|
+
};
|
|
2951
|
+
entries = new Map;
|
|
2952
|
+
destKey;
|
|
2953
|
+
destArgs;
|
|
2954
|
+
muxEnabled;
|
|
2955
|
+
idleReapMs;
|
|
2956
|
+
spawnImpl;
|
|
2957
|
+
muxDir;
|
|
2958
|
+
log;
|
|
2959
|
+
static DEFAULT_IDLE_REAP_MS = 30 * 60 * 1000;
|
|
2960
|
+
constructor(destArgs, opts = {}) {
|
|
2961
|
+
this.destArgs = destArgs;
|
|
2962
|
+
this.destKey = crypto5.createHash("sha256").update(destArgs.join("\x00")).digest("hex").slice(0, 16);
|
|
2963
|
+
this.muxEnabled = !opts.disableMux && process.platform !== "win32";
|
|
2964
|
+
this.idleReapMs = opts.idleReapMs;
|
|
2965
|
+
this.spawnImpl = opts.spawnImpl ?? ((cmd, argv, o) => spawn6(cmd, argv, o));
|
|
2966
|
+
this.muxDir = opts.muxDir ?? join2(tmpdir4(), "filaxis-stream-mux");
|
|
2967
|
+
this.log = opts.logger ?? noopLogger;
|
|
2968
|
+
}
|
|
2969
|
+
muxFlags() {
|
|
2970
|
+
if (!this.muxEnabled)
|
|
2971
|
+
return [];
|
|
2972
|
+
let m = masters.get(this.destKey);
|
|
2973
|
+
if (!m) {
|
|
2974
|
+
m = { path: join2(this.ensureMuxDir(), `cm-${this.destKey}`), refCount: 0 };
|
|
2975
|
+
masters.set(this.destKey, m);
|
|
2976
|
+
}
|
|
2977
|
+
return [
|
|
2978
|
+
"-o",
|
|
2979
|
+
`ControlPath=${m.path}`,
|
|
2980
|
+
"-o",
|
|
2981
|
+
"ControlMaster=auto",
|
|
2982
|
+
"-o",
|
|
2983
|
+
"ControlPersist=600"
|
|
2984
|
+
];
|
|
2985
|
+
}
|
|
2986
|
+
ensureMuxDir() {
|
|
2987
|
+
if (!existsSync2(this.muxDir))
|
|
2988
|
+
mkdirSync(this.muxDir, { recursive: true });
|
|
2989
|
+
return this.muxDir;
|
|
2990
|
+
}
|
|
2991
|
+
async start(command, opts = {}) {
|
|
2992
|
+
const sessionId = `${opts.name ?? "stream"}-${crypto5.randomBytes(3).toString("hex")}`;
|
|
2993
|
+
const argv = [
|
|
2994
|
+
"-o",
|
|
2995
|
+
"StrictHostKeyChecking=accept-new",
|
|
2996
|
+
"-o",
|
|
2997
|
+
"ConnectTimeout=10",
|
|
2998
|
+
...this.muxFlags(),
|
|
2999
|
+
...this.destArgs,
|
|
3000
|
+
"--",
|
|
3001
|
+
command
|
|
3002
|
+
];
|
|
3003
|
+
const child = this.spawnImpl("ssh", argv, { stdio: ["ignore", "pipe", "pipe"] });
|
|
3004
|
+
const entry = {
|
|
3005
|
+
sessionId,
|
|
3006
|
+
name: opts.name,
|
|
3007
|
+
command,
|
|
3008
|
+
destKey: this.destKey,
|
|
3009
|
+
child,
|
|
3010
|
+
ring: new OutputRingBuffer({ maxLines: 50, maxLineBytes: 512 }),
|
|
3011
|
+
watchers: new WatchRegistry,
|
|
3012
|
+
watcherMeta: new Map,
|
|
3013
|
+
startedAt: Date.now(),
|
|
3014
|
+
running: true,
|
|
3015
|
+
lastAttentionAt: Date.now(),
|
|
3016
|
+
ownerSessionId: opts.ownerSessionId,
|
|
3017
|
+
opts
|
|
3018
|
+
};
|
|
3019
|
+
this.entries.set(sessionId, entry);
|
|
3020
|
+
const m = masters.get(this.destKey);
|
|
3021
|
+
if (m)
|
|
3022
|
+
m.refCount++;
|
|
3023
|
+
child.stdout?.on("data", (d) => {
|
|
3024
|
+
const text = d.toString("utf-8");
|
|
3025
|
+
if (entry.opts?.onScreen) {
|
|
3026
|
+
if (!entry.screen)
|
|
3027
|
+
entry.screen = new Vt100Screen;
|
|
3028
|
+
entry.screen.feed(text);
|
|
3029
|
+
try {
|
|
3030
|
+
entry.opts.onScreen(entry.screen.snapshot(), entry.screen.frames);
|
|
3031
|
+
} catch {}
|
|
3032
|
+
}
|
|
3033
|
+
this.onChunk(entry, text, "stdout");
|
|
3034
|
+
});
|
|
3035
|
+
child.stderr?.on("data", (d) => this.onChunk(entry, d.toString("utf-8"), "stderr"));
|
|
3036
|
+
child.on("exit", (code) => {
|
|
3037
|
+
if (!entry.running)
|
|
3038
|
+
return;
|
|
3039
|
+
entry.running = false;
|
|
3040
|
+
entry.exitCode = code;
|
|
3041
|
+
try {
|
|
3042
|
+
opts.onExit?.(code);
|
|
3043
|
+
} catch {}
|
|
3044
|
+
this.emit({
|
|
3045
|
+
type: "exit",
|
|
3046
|
+
sessionId,
|
|
3047
|
+
ownerSessionId: opts.ownerSessionId,
|
|
3048
|
+
exitCode: code,
|
|
3049
|
+
lastLines: entry.ring.tail(10)
|
|
3050
|
+
});
|
|
3051
|
+
this.releaseMaster();
|
|
3052
|
+
});
|
|
3053
|
+
child.on("error", (err) => {
|
|
3054
|
+
entry.running = false;
|
|
3055
|
+
this.emit({
|
|
3056
|
+
type: "exit",
|
|
3057
|
+
sessionId,
|
|
3058
|
+
ownerSessionId: opts.ownerSessionId,
|
|
3059
|
+
exitCode: null,
|
|
3060
|
+
lastLines: [err.message]
|
|
3061
|
+
});
|
|
3062
|
+
this.releaseMaster();
|
|
3063
|
+
});
|
|
3064
|
+
if (opts.watchPattern) {
|
|
3065
|
+
this.watch(sessionId, {
|
|
3066
|
+
pattern: opts.watchPattern,
|
|
3067
|
+
once: opts.watchOnce ?? true,
|
|
3068
|
+
contextLines: opts.contextLines
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
this.log.info(`[StreamSession ${sessionId}] 流建立(mux=${this.muxEnabled})`);
|
|
3072
|
+
return { sessionId, name: opts.name };
|
|
3073
|
+
}
|
|
3074
|
+
watch(sessionId, opts) {
|
|
3075
|
+
const e = this.require(sessionId);
|
|
3076
|
+
e.lastAttentionAt = Date.now();
|
|
3077
|
+
const watcherId = `sw-${crypto5.randomBytes(3).toString("hex")}`;
|
|
3078
|
+
e.watchers.add({ id: watcherId, pattern: opts.pattern, once: opts.once });
|
|
3079
|
+
e.watcherMeta.set(watcherId, { pattern: opts.pattern, contextLines: opts.contextLines, once: opts.once });
|
|
3080
|
+
return {
|
|
3081
|
+
cancel: () => {
|
|
3082
|
+
e.watchers.cancel(watcherId);
|
|
3083
|
+
e.watcherMeta.delete(watcherId);
|
|
3084
|
+
}
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
tail(sessionId, n = 50) {
|
|
3088
|
+
const e = this.require(sessionId);
|
|
3089
|
+
e.lastAttentionAt = Date.now();
|
|
3090
|
+
return e.ring.tail(n);
|
|
3091
|
+
}
|
|
3092
|
+
tailLines(sessionId, n = 50) {
|
|
3093
|
+
const e = this.require(sessionId);
|
|
3094
|
+
e.lastAttentionAt = Date.now();
|
|
3095
|
+
return e.ring.tailLines(n);
|
|
3096
|
+
}
|
|
3097
|
+
stop(sessionId) {
|
|
3098
|
+
const e = this.require(sessionId);
|
|
3099
|
+
e.running = false;
|
|
3100
|
+
e.child.kill("SIGTERM");
|
|
3101
|
+
this.entries.delete(sessionId);
|
|
3102
|
+
this.log.info(`[StreamSession ${sessionId}] 已收摊`);
|
|
3103
|
+
}
|
|
3104
|
+
list() {
|
|
3105
|
+
const now = Date.now();
|
|
3106
|
+
return Array.from(this.entries.values()).map((e) => ({
|
|
3107
|
+
sessionId: e.sessionId,
|
|
3108
|
+
name: e.name,
|
|
3109
|
+
command: e.command,
|
|
3110
|
+
running: e.running,
|
|
3111
|
+
idleMs: now - e.lastAttentionAt,
|
|
3112
|
+
armedWatchers: e.watcherMeta.size
|
|
3113
|
+
}));
|
|
3114
|
+
}
|
|
3115
|
+
reapIdle(now = Date.now(), ttlMs) {
|
|
3116
|
+
const ttl = ttlMs ?? this.idleReapMs ?? StreamSessionManager.DEFAULT_IDLE_REAP_MS;
|
|
3117
|
+
for (const e of Array.from(this.entries.values())) {
|
|
3118
|
+
if (!e.running)
|
|
3119
|
+
continue;
|
|
3120
|
+
if (e.watcherMeta.size > 0)
|
|
3121
|
+
continue;
|
|
3122
|
+
if (now - e.lastAttentionAt < ttl)
|
|
3123
|
+
continue;
|
|
3124
|
+
this.log.warn(`[StreamSession ${e.sessionId}] 注意力衰减:自动回收`);
|
|
3125
|
+
this.emit({
|
|
3126
|
+
type: "exit",
|
|
3127
|
+
sessionId: e.sessionId,
|
|
3128
|
+
ownerSessionId: e.ownerSessionId,
|
|
3129
|
+
exitCode: null,
|
|
3130
|
+
lastLines: e.ring.tail(5)
|
|
3131
|
+
});
|
|
3132
|
+
this.stop(e.sessionId);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
onChunk(entry, text, stream) {
|
|
3136
|
+
for (const raw of text.split(`
|
|
3137
|
+
`)) {
|
|
3138
|
+
if (raw === "")
|
|
3139
|
+
continue;
|
|
3140
|
+
const clean = raw.replace(/\r$/, "");
|
|
3141
|
+
entry.ring.push({ stream, text: clean });
|
|
3142
|
+
try {
|
|
3143
|
+
entry.opts?.onLine?.({ stream, text: clean });
|
|
3144
|
+
} catch {}
|
|
3145
|
+
const anchor = entry.ring.size - 1;
|
|
3146
|
+
for (const watcherId of entry.watchers.match(clean)) {
|
|
3147
|
+
entry.lastAttentionAt = Date.now();
|
|
3148
|
+
const meta = entry.watcherMeta.get(watcherId);
|
|
3149
|
+
entry.watcherMeta.delete(watcherId);
|
|
3150
|
+
this.emit({
|
|
3151
|
+
type: "match",
|
|
3152
|
+
sessionId: entry.sessionId,
|
|
3153
|
+
ownerSessionId: entry.ownerSessionId,
|
|
3154
|
+
watcherId,
|
|
3155
|
+
pattern: String(meta?.pattern ?? "?"),
|
|
3156
|
+
matchedLine: clean,
|
|
3157
|
+
stream,
|
|
3158
|
+
context: entry.ring.contextAround(anchor, meta?.contextLines ?? 5).map((l) => l.text)
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
releaseMaster() {
|
|
3164
|
+
const m = masters.get(this.destKey);
|
|
3165
|
+
if (!m)
|
|
3166
|
+
return;
|
|
3167
|
+
m.refCount = Math.max(0, m.refCount - 1);
|
|
3168
|
+
if (m.refCount === 0 && this.entries.size === 0 && existsSync2(m.path)) {
|
|
3169
|
+
try {
|
|
3170
|
+
rmSync(m.path, { force: true });
|
|
3171
|
+
} catch {}
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
require(sessionId) {
|
|
3175
|
+
const e = this.entries.get(sessionId);
|
|
3176
|
+
if (!e)
|
|
3177
|
+
throw new Error(`stream 会话 '${sessionId}' 不存在`);
|
|
3178
|
+
return e;
|
|
3179
|
+
}
|
|
3180
|
+
emit(ev) {
|
|
3181
|
+
try {
|
|
3182
|
+
this.onEvent(ev);
|
|
3183
|
+
} catch (err) {
|
|
3184
|
+
this.log.warn(`stream event sink threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
// src/file-transfer.ts
|
|
3189
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
3190
|
+
import fs4 from "node:fs/promises";
|
|
3191
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
3192
|
+
var CHUNK_B64 = 48 * 1024;
|
|
3193
|
+
|
|
3194
|
+
class TransferVerificationError extends Error {
|
|
3195
|
+
constructor(op, detail) {
|
|
3196
|
+
super(`文件传输校验失败(${op}): ${detail}`);
|
|
3197
|
+
this.name = "TransferVerificationError";
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
var q4 = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
3201
|
+
async function putFile(exec, dialect, localPath, remotePath) {
|
|
3202
|
+
if (!dialect.base64Decode.length) {
|
|
3203
|
+
throw new Error("远端 base64 原语不可用(方言探测 unknown)——该路径不支持文件传输");
|
|
3204
|
+
}
|
|
3205
|
+
const content = await fs4.readFile(localPath);
|
|
3206
|
+
const b64 = content.toString("base64");
|
|
3207
|
+
const tmp = `${remotePath}.filaxis-tmp`;
|
|
3208
|
+
await exec(`rm -f ${q4(tmp)}`);
|
|
3209
|
+
for (let i = 0;i < b64.length; i += CHUNK_B64) {
|
|
3210
|
+
const chunk = b64.slice(i, i + CHUNK_B64);
|
|
3211
|
+
const r = await exec(`printf %s ${q4(chunk)} >> ${q4(tmp)}`);
|
|
3212
|
+
if (r.exitCode !== 0)
|
|
3213
|
+
throw new Error(`put 分块写入失败(chunk ${i / CHUNK_B64}): ${r.stdout}`);
|
|
3214
|
+
}
|
|
3215
|
+
const dec = dialect.base64Decode.join(" ");
|
|
3216
|
+
const mv = await exec(`${dec} < ${q4(tmp)} > ${q4(remotePath)} && rm -f ${q4(tmp)}`);
|
|
3217
|
+
if (mv.exitCode !== 0) {
|
|
3218
|
+
await exec(`rm -f ${q4(tmp)}`).catch(() => {
|
|
3219
|
+
return;
|
|
3220
|
+
});
|
|
3221
|
+
throw new Error(`put 落位失败: ${mv.stdout}`);
|
|
3222
|
+
}
|
|
3223
|
+
const localSha = createHash2("sha256").update(content).digest("hex");
|
|
3224
|
+
const v = await verify(exec, dialect, remotePath, content.length, localSha);
|
|
3225
|
+
if (!v.verified)
|
|
3226
|
+
throw new TransferVerificationError("put", v.detail);
|
|
3227
|
+
return { bytes: content.length, sha256: localSha, verified: true };
|
|
3228
|
+
}
|
|
3229
|
+
async function getFile(exec, dialect, remotePath, localPath) {
|
|
3230
|
+
if (!dialect.base64Encode.length) {
|
|
3231
|
+
throw new Error("远端 base64 原语不可用(方言探测 unknown)——该路径不支持文件传输");
|
|
3232
|
+
}
|
|
3233
|
+
const enc = dialect.base64Encode.join(" ");
|
|
3234
|
+
const r = await exec(`${enc} < ${q4(remotePath)}`);
|
|
3235
|
+
if (r.exitCode !== 0)
|
|
3236
|
+
throw new Error(`get 编码回传失败: ${r.stdout}`);
|
|
3237
|
+
const content = Buffer.from(r.stdout.replace(/\s+/g, ""), "base64");
|
|
3238
|
+
await fs4.writeFile(localPath, content);
|
|
3239
|
+
const localSha = createHash2("sha256").update(content).digest("hex");
|
|
3240
|
+
const v = await verify(exec, dialect, remotePath, content.length, localSha);
|
|
3241
|
+
if (!v.verified)
|
|
3242
|
+
throw new TransferVerificationError("get", v.detail);
|
|
3243
|
+
return { bytes: content.length, sha256: localSha, verified: true };
|
|
3244
|
+
}
|
|
3245
|
+
async function verify(exec, dialect, remotePath, expectSize, expectSha) {
|
|
3246
|
+
const sz = await exec(`wc -c < ${q4(remotePath)}`);
|
|
3247
|
+
const size = parseInt((sz.stdout || "").trim(), 10);
|
|
3248
|
+
if (size !== expectSize)
|
|
3249
|
+
return { verified: false, detail: `size 不一致(远端 ${size} ≠ 本地 ${expectSize})` };
|
|
3250
|
+
if (!dialect.sha256.length) {
|
|
3251
|
+
return { verified: true, detail: "size-only(sha256 原语缺席)" };
|
|
3252
|
+
}
|
|
3253
|
+
const sh = await exec(`${dialect.sha256.join(" ")} < ${q4(remotePath)}`);
|
|
3254
|
+
const remoteSha = (sh.stdout || "").replace(/[^0-9a-f]/g, "");
|
|
3255
|
+
if (!remoteSha.includes(expectSha)) {
|
|
3256
|
+
return { verified: false, detail: `sha256 不一致(远端 ${remoteSha.slice(0, 16)}… ≠ 本地 ${expectSha.slice(0, 16)}…)` };
|
|
3257
|
+
}
|
|
3258
|
+
return { verified: true, detail: "size+sha256" };
|
|
3259
|
+
}
|
|
3260
|
+
async function defaultSftpSpawn(argv, batch) {
|
|
3261
|
+
return new Promise((resolve, reject) => {
|
|
3262
|
+
const p = defaultSpawn("sftp", argv, { stdio: ["pipe", "pipe", "pipe"] });
|
|
3263
|
+
let stderr = "";
|
|
3264
|
+
p.stderr?.on("data", (d) => {
|
|
3265
|
+
stderr += d;
|
|
3266
|
+
});
|
|
3267
|
+
p.stdin?.write(batch);
|
|
3268
|
+
p.stdin?.end();
|
|
3269
|
+
p.on("close", (code) => resolve({ exitCode: code, stderr }));
|
|
3270
|
+
p.on("error", reject);
|
|
3271
|
+
});
|
|
3272
|
+
}
|
|
3273
|
+
async function sftpPutFile(exec, dialect, localPath, remotePath, opts) {
|
|
3274
|
+
const doSpawn = opts.spawnImpl ?? defaultSftpSpawn;
|
|
3275
|
+
const batch = `put ${q4(localPath)} ${q4(remotePath)}
|
|
3276
|
+
`;
|
|
3277
|
+
const r = await doSpawn([...opts.connArgs, "-b", "-"], batch);
|
|
3278
|
+
if (r.exitCode !== 0) {
|
|
3279
|
+
throw new Error(`sftp put 失败(exit=${r.exitCode})——可降级 base64 管道: ${r.stderr.trim().slice(0, 200)}`);
|
|
3280
|
+
}
|
|
3281
|
+
const content = await fs4.readFile(localPath);
|
|
3282
|
+
const localSha = createHash2("sha256").update(content).digest("hex");
|
|
3283
|
+
const v = await verify(exec, dialect, remotePath, content.length, localSha);
|
|
3284
|
+
if (!v.verified)
|
|
3285
|
+
throw new TransferVerificationError("put", v.detail);
|
|
3286
|
+
return { bytes: content.length, sha256: localSha, verified: true };
|
|
3287
|
+
}
|
|
3288
|
+
async function sftpGetFile(exec, dialect, remotePath, localPath, opts) {
|
|
3289
|
+
const doSpawn = opts.spawnImpl ?? defaultSftpSpawn;
|
|
3290
|
+
const batch = `get ${q4(remotePath)} ${q4(localPath)}
|
|
3291
|
+
`;
|
|
3292
|
+
const r = await doSpawn([...opts.connArgs, "-b", "-"], batch);
|
|
3293
|
+
if (r.exitCode !== 0) {
|
|
3294
|
+
throw new Error(`sftp get 失败(exit=${r.exitCode})——可降级 base64 管道: ${r.stderr.trim().slice(0, 200)}`);
|
|
3295
|
+
}
|
|
3296
|
+
const content = await fs4.readFile(localPath);
|
|
3297
|
+
const localSha = createHash2("sha256").update(content).digest("hex");
|
|
3298
|
+
const v = await verify(exec, dialect, remotePath, content.length, localSha);
|
|
3299
|
+
if (!v.verified)
|
|
3300
|
+
throw new TransferVerificationError("get", v.detail);
|
|
3301
|
+
return { bytes: content.length, sha256: localSha, verified: true };
|
|
3302
|
+
}
|
|
3303
|
+
// src/layer-env.ts
|
|
3304
|
+
init_ssh_helpers();
|
|
3305
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3306
|
+
import { rename, stat, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
3307
|
+
import { dirname } from "node:path";
|
|
3308
|
+
function layerEnvKey(desc) {
|
|
3309
|
+
if (desc.driver === "local")
|
|
3310
|
+
return "local";
|
|
3311
|
+
const c = desc.config ?? {};
|
|
3312
|
+
if (c.pty === true) {
|
|
3313
|
+
const conn = c.connParams;
|
|
3314
|
+
const hostPart = `${desc.target ?? "?"}${conn?.port ? `:${conn.port}` : ""}`;
|
|
3315
|
+
const enters = enterStepsOf(c).map((s) => s.label ?? slugify(s.cmd));
|
|
3316
|
+
return [hostPart, ...enters].join("/");
|
|
3317
|
+
}
|
|
3318
|
+
return `${desc.driver}:${desc.target ?? "default"}`;
|
|
3319
|
+
}
|
|
3320
|
+
function slugify(s) {
|
|
3321
|
+
return s.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40) || "enter";
|
|
3322
|
+
}
|
|
3323
|
+
function buildProbeStage1() {
|
|
3324
|
+
return [
|
|
3325
|
+
`echo "fx_os=$(grep PRETTY_NAME /etc/os-release 2>/dev/null | head -1 | cut -d'"' -f2)"`,
|
|
3326
|
+
`echo "fx_os_dsm=$(grep -E '^productversion=' /etc.defaults/VERSION 2>/dev/null | sed 's/productversion=/Synology DSM v/;s/"//g')"`,
|
|
3327
|
+
`echo "fx_kernel_arch=$(uname -s -m 2>/dev/null)"`,
|
|
3328
|
+
`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)"`,
|
|
3329
|
+
`echo "fx_node=$(node --version 2>&1 | head -1)"`,
|
|
3330
|
+
`echo "fx_bun=$(bun --version 2>&1 | head -1)"`,
|
|
3331
|
+
`echo "fx_java=$(java -version 2>&1 | head -1)"`,
|
|
3332
|
+
`echo "fx_gpu=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | head -1)"`,
|
|
3333
|
+
`echo "fx_cuda=$(nvidia-smi 2>/dev/null | grep -o 'CUDA Version: [0-9.][0-9.]*' | head -1 | grep -o '[0-9][0-9.]*')"`,
|
|
3334
|
+
`echo "fx_docker=$(command -v docker >/dev/null 2>&1 && echo yes || echo no)"`
|
|
3335
|
+
].join("; ");
|
|
3336
|
+
}
|
|
3337
|
+
function buildProbeStage2() {
|
|
3338
|
+
const py = `command -v python3 >/dev/null 2>&1 && echo python3 || echo python`;
|
|
3339
|
+
const parts = [
|
|
3340
|
+
`__FX_PY=$(${py}); __FX_TO=""; command -v timeout >/dev/null 2>&1 && __FX_TO="timeout 15"`
|
|
3341
|
+
];
|
|
3342
|
+
for (const pkg of PROBE_PACKAGES) {
|
|
3343
|
+
if (pkg.kind === "python") {
|
|
3344
|
+
parts.push(`echo "fx_pkg_${pkg.key}=$($__FX_TO "$__FX_PY" -c '${pkg.probe}' 2>/dev/null)"`);
|
|
3345
|
+
} else {
|
|
3346
|
+
parts.push(`echo "fx_tool_${pkg.key}=$($__FX_TO ${pkg.probe} 2>/dev/null | head -1)"`);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
return parts.join("; ");
|
|
3350
|
+
}
|
|
3351
|
+
var PROBE_PACKAGES = [
|
|
3352
|
+
{ key: "torch", kind: "python", probe: "import torch;print(torch.__version__)", matchNames: ["torch", "pytorch"] },
|
|
3353
|
+
{ key: "transformers", kind: "python", probe: "import transformers;print(transformers.__version__)", matchNames: ["transformers", "huggingface"] },
|
|
3354
|
+
{ key: "onnxruntime", kind: "python", probe: "import onnxruntime;print(onnxruntime.__version__)", matchNames: ["onnxruntime", "onnx"] },
|
|
3355
|
+
{ key: "numpy", kind: "python", probe: "import numpy;print(numpy.__version__)", matchNames: ["numpy"] },
|
|
3356
|
+
{ key: "ffmpeg", kind: "tool", probe: "ffmpeg -version", matchNames: ["ffmpeg"] },
|
|
3357
|
+
{ key: "git", kind: "tool", probe: "git --version", matchNames: ["git"] }
|
|
3358
|
+
];
|
|
3359
|
+
function parseProbeOutput(stdout) {
|
|
3360
|
+
const spec = {};
|
|
3361
|
+
const pkgs = [];
|
|
3362
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
3363
|
+
const m = rawLine.trim().match(/^fx_([a-z0-9_]+)=(.*)$/);
|
|
3364
|
+
if (!m)
|
|
3365
|
+
continue;
|
|
3366
|
+
const val = m[2].trim();
|
|
3367
|
+
if (!val || val.includes("$(") || val.includes("__FX"))
|
|
3368
|
+
continue;
|
|
3369
|
+
switch (m[1]) {
|
|
3370
|
+
case "os":
|
|
3371
|
+
spec.os = val || undefined;
|
|
3372
|
+
break;
|
|
3373
|
+
case "os_dsm":
|
|
3374
|
+
if (val)
|
|
3375
|
+
spec.os = val;
|
|
3376
|
+
break;
|
|
3377
|
+
case "kernel_arch":
|
|
3378
|
+
spec.kernel_arch = val;
|
|
3379
|
+
break;
|
|
3380
|
+
case "python":
|
|
3381
|
+
if (/Python \d/.test(val))
|
|
3382
|
+
spec.python = val;
|
|
3383
|
+
break;
|
|
3384
|
+
case "node":
|
|
3385
|
+
if (/^v?\d/.test(val))
|
|
3386
|
+
spec.node = val.replace(/^v/, "");
|
|
3387
|
+
break;
|
|
3388
|
+
case "bun":
|
|
3389
|
+
if (/^\d+\./.test(val))
|
|
3390
|
+
spec.bun = val;
|
|
3391
|
+
break;
|
|
3392
|
+
case "java":
|
|
3393
|
+
if (/version/i.test(val))
|
|
3394
|
+
spec.java = val;
|
|
3395
|
+
break;
|
|
3396
|
+
case "gpu": {
|
|
3397
|
+
const gm = val.match(/^(.+?),\s*(\d+)\s*MiB$/);
|
|
3398
|
+
if (gm) {
|
|
3399
|
+
spec.gpu = { model: gm[1].trim(), vram_mb: parseInt(gm[2], 10) };
|
|
3400
|
+
} else if (val && looksLikeGpuModel(val)) {
|
|
3401
|
+
spec.gpu = { model: val };
|
|
3402
|
+
}
|
|
3403
|
+
break;
|
|
3404
|
+
}
|
|
3405
|
+
case "cuda":
|
|
3406
|
+
if (/^\d+(\.\d+)*$/.test(val))
|
|
3407
|
+
spec.cuda = val;
|
|
3408
|
+
break;
|
|
3409
|
+
case "docker":
|
|
3410
|
+
spec.docker = val === "yes";
|
|
3411
|
+
break;
|
|
3412
|
+
default: {
|
|
3413
|
+
const pm = m[1].match(/^(?:pkg|tool)_(.+)$/);
|
|
3414
|
+
if (pm) {
|
|
3415
|
+
const key = pm[1];
|
|
3416
|
+
const def = PROBE_PACKAGES.find((p) => p.key === key);
|
|
3417
|
+
const version = extractVersion(val);
|
|
3418
|
+
const name = def?.key ?? key;
|
|
3419
|
+
pkgs.push(version ? `${name}@${version}` : name);
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
if (!spec.os && spec.kernel_arch)
|
|
3425
|
+
spec.os = spec.kernel_arch.split(/\s+/)[0];
|
|
3426
|
+
if (pkgs.length > 0)
|
|
3427
|
+
spec.packages = [...new Set(pkgs)];
|
|
3428
|
+
return spec;
|
|
3429
|
+
}
|
|
3430
|
+
function extractVersion(raw) {
|
|
3431
|
+
if (!raw)
|
|
3432
|
+
return;
|
|
3433
|
+
const m = raw.match(/(\d+(\.\d+)+[a-z0-9._-]*)/i) || raw.match(/version\s+(\S+)/i) || raw.match(/(\d+\.\d+)/);
|
|
3434
|
+
return m?.[1];
|
|
3435
|
+
}
|
|
3436
|
+
function looksLikeGpuModel(val) {
|
|
3437
|
+
if (val.length > 60)
|
|
3438
|
+
return false;
|
|
3439
|
+
if (/failed|error|couldn|can't|unable|no device|not found|driver/i.test(val))
|
|
3440
|
+
return false;
|
|
3441
|
+
if (/[.!?]$/.test(val))
|
|
3442
|
+
return false;
|
|
3443
|
+
return /^[A-Za-z0-9 ()+.-]+$/.test(val);
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
class LayerEnvStore {
|
|
3447
|
+
path;
|
|
3448
|
+
cache = null;
|
|
3449
|
+
constructor(path) {
|
|
3450
|
+
this.path = path;
|
|
3451
|
+
}
|
|
3452
|
+
async load(force = false) {
|
|
3453
|
+
let mtimeMs = 0;
|
|
3454
|
+
try {
|
|
3455
|
+
mtimeMs = (await stat(this.path)).mtimeMs;
|
|
3456
|
+
} catch {
|
|
3457
|
+
const cfg = { layers: {} };
|
|
3458
|
+
this.cache = { mtimeMs: 0, cfg };
|
|
3459
|
+
return cfg;
|
|
3460
|
+
}
|
|
3461
|
+
if (!force && this.cache?.mtimeMs === mtimeMs)
|
|
3462
|
+
return this.cache.cfg;
|
|
3463
|
+
try {
|
|
3464
|
+
const parsed = JSON.parse(await readFile(this.path, "utf-8"));
|
|
3465
|
+
const cfg = parsed?.layers && typeof parsed.layers === "object" ? { layers: parsed.layers } : { layers: {} };
|
|
3466
|
+
this.cache = { mtimeMs, cfg };
|
|
3467
|
+
return cfg;
|
|
3468
|
+
} catch {
|
|
3469
|
+
return this.cache?.cfg ?? { layers: {} };
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
async save(cfg) {
|
|
3473
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
3474
|
+
const tmp = `${this.path}.tmp`;
|
|
3475
|
+
await writeFile(tmp, JSON.stringify(cfg, null, 2), "utf-8");
|
|
3476
|
+
await rename(tmp, this.path);
|
|
3477
|
+
this.cache = null;
|
|
3478
|
+
}
|
|
3479
|
+
async upsert(key, entry, force = false) {
|
|
3480
|
+
const cfg = await this.load();
|
|
3481
|
+
const existing = cfg.layers[key];
|
|
3482
|
+
if (existing && !force && existing.fingerprint !== entry.fingerprint) {
|
|
3483
|
+
return { stale: true };
|
|
3484
|
+
}
|
|
3485
|
+
cfg.layers[key] = entry;
|
|
3486
|
+
await this.save(cfg);
|
|
3487
|
+
return { stale: false };
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
function projectConnection(desc) {
|
|
3491
|
+
const c = desc.config ?? {};
|
|
3492
|
+
const hops = Array.isArray(c.sshHops) ? c.sshHops.map((h) => typeof h === "string" ? { host: h } : h) : [];
|
|
3493
|
+
return {
|
|
3494
|
+
driver: desc.driver,
|
|
3495
|
+
target: desc.target,
|
|
3496
|
+
conn: c.connParams,
|
|
3497
|
+
hops: hops.length ? hops : undefined,
|
|
3498
|
+
enters: enterStepsOf(c).map((s) => ({ cmd: s.cmd, label: s.label })),
|
|
3499
|
+
launch_cmd: typeof c.launchCmd === "string" ? c.launchCmd : undefined
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
function layerFingerprint(conn, spec) {
|
|
3503
|
+
return "sha256:" + createHash3("sha256").update(JSON.stringify({ conn: { driver: conn.driver, target: conn.target, enters: conn.enters }, spec })).digest("hex").slice(0, 24);
|
|
3504
|
+
}
|
|
3505
|
+
function matchLayers(cfg, needs) {
|
|
3506
|
+
const out = [];
|
|
3507
|
+
for (const [key, entry] of Object.entries(cfg.layers)) {
|
|
3508
|
+
const spec = entry.environment;
|
|
3509
|
+
const ok = needs.every((need) => {
|
|
3510
|
+
const vm = need.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(>=|<=|>|<|=)?\s*([\d.]+)?$/);
|
|
3511
|
+
if (!vm)
|
|
3512
|
+
return false;
|
|
3513
|
+
const name = vm[1].toLowerCase();
|
|
3514
|
+
const op = vm[2];
|
|
3515
|
+
const want = vm[3];
|
|
3516
|
+
if (name === "gpu")
|
|
3517
|
+
return spec.gpu != null;
|
|
3518
|
+
if (name === "docker")
|
|
3519
|
+
return spec.docker === true;
|
|
3520
|
+
if (name === "cuda")
|
|
3521
|
+
return spec.cuda != null;
|
|
3522
|
+
if (name === "python" || name === "node" || name === "bun" || name === "java") {
|
|
3523
|
+
const have = spec[name];
|
|
3524
|
+
if (!have)
|
|
3525
|
+
return false;
|
|
3526
|
+
if (!op || !want)
|
|
3527
|
+
return true;
|
|
3528
|
+
return compareVersion(extractVersion(have) ?? "0", want, op);
|
|
3529
|
+
}
|
|
3530
|
+
const pkg = (spec.packages ?? []).find((p) => {
|
|
3531
|
+
const base = p.split("@")[0];
|
|
3532
|
+
const def = PROBE_PACKAGES.find((d) => base === d.key || d.matchNames.includes(base));
|
|
3533
|
+
return base === name || def?.matchNames.includes(name);
|
|
3534
|
+
});
|
|
3535
|
+
if (!pkg)
|
|
3536
|
+
return false;
|
|
3537
|
+
if (!op || !want)
|
|
3538
|
+
return true;
|
|
3539
|
+
return compareVersion(pkg.split("@")[1] ?? "0", want, op);
|
|
3540
|
+
});
|
|
3541
|
+
if (ok)
|
|
3542
|
+
out.push({ key, entry });
|
|
3543
|
+
}
|
|
3544
|
+
return out;
|
|
3545
|
+
}
|
|
3546
|
+
function compareVersion(a, b, op) {
|
|
3547
|
+
const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
|
|
3548
|
+
const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
|
|
3549
|
+
for (let i = 0;i < Math.max(pa.length, pb.length); i++) {
|
|
3550
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
3551
|
+
if (d !== 0) {
|
|
3552
|
+
if (op === ">=")
|
|
3553
|
+
return d > 0;
|
|
3554
|
+
if (op === "<=")
|
|
3555
|
+
return d < 0;
|
|
3556
|
+
if (op === ">")
|
|
3557
|
+
return d > 0;
|
|
3558
|
+
if (op === "<")
|
|
3559
|
+
return d < 0;
|
|
3560
|
+
return false;
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
return op === ">=" || op === "<=" || op === "=";
|
|
3564
|
+
}
|
|
3565
|
+
// src/file-edit.ts
|
|
3566
|
+
class EditConflictError extends Error {
|
|
3567
|
+
constructor(message) {
|
|
3568
|
+
super(message);
|
|
3569
|
+
this.name = "EditConflictError";
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
var q5 = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
3573
|
+
var shellSafe = (s) => !s.includes("'") && !s.includes(`
|
|
3574
|
+
`);
|
|
3575
|
+
var statCmdOf = (f) => `stat -c '%Y %s' ${f} 2>/dev/null || stat -f '%m %z' ${f} 2>/dev/null`;
|
|
3576
|
+
var dialectCache = new Map;
|
|
3577
|
+
async function dialectOf(drv) {
|
|
3578
|
+
const key = JSON.stringify(drv.descriptor);
|
|
3579
|
+
const hit = dialectCache.get(key);
|
|
3580
|
+
if (hit)
|
|
3581
|
+
return hit;
|
|
3582
|
+
const probe = await drv.exec("echo FX2=$(printf filaxis | base64 -w 0 2>/dev/null | head -c 8);" + "echo FX3=$(printf ZmlsYXhpcw== | base64 -d 2>/dev/null | head -c 7);" + "echo FX4=$(printf ZmlsYXhpcw== | base64 -D 2>/dev/null | head -c 7)", { timeoutMs: 15000 });
|
|
3583
|
+
const out = probe.stdout || "";
|
|
3584
|
+
const b64Gnu = /FX2=ZmlsYXhp/.test(out);
|
|
3585
|
+
const decD = /FX4=filaxis/.test(out);
|
|
3586
|
+
const d = {
|
|
3587
|
+
statCmd: "",
|
|
3588
|
+
enc: b64Gnu ? "base64 -w 0" : "base64",
|
|
3589
|
+
dec: decD ? "base64 -D" : "base64 -d",
|
|
3590
|
+
shaCmd: `{ sha256sum ${"{f}"} 2>/dev/null || sha256 -q ${"{f}"} 2>/dev/null || openssl dgst -sha256 ${"{f}"} 2>/dev/null | tail -1; } | head -c 12`
|
|
3591
|
+
};
|
|
3592
|
+
dialectCache.set(key, d);
|
|
3593
|
+
return d;
|
|
3594
|
+
}
|
|
3595
|
+
async function editFile(drv, path, oldStr, newStr, opts = {}) {
|
|
3596
|
+
const dialect = await dialectOf(drv);
|
|
3597
|
+
const qp = q5(path);
|
|
3598
|
+
const readCmd = `${statCmdOf(qp)}; ${dialect.shaCmd.replace(/\{f\}/g, qp)}; echo __FXS__; ${dialect.enc} ${qp} 2>/dev/null`;
|
|
3599
|
+
const r = await drv.exec(readCmd, { timeoutMs: 30000 });
|
|
3600
|
+
const [stampPart, b64Part] = (r.stdout || "").split("__FXS__");
|
|
3601
|
+
const originStampRaw = (stampPart ?? "").trim();
|
|
3602
|
+
const originLines = originStampRaw.split(/\n/);
|
|
3603
|
+
const statStamp = (originLines[0] ?? "").trim();
|
|
3604
|
+
const shaStamp = (originLines[1] ?? "").trim();
|
|
3605
|
+
if (!shellSafe(statStamp) || !shellSafe(shaStamp)) {
|
|
3606
|
+
throw new Error(`远端戳异常(stat/sha256 输出非单行): ${path}`);
|
|
3607
|
+
}
|
|
3608
|
+
const originParts = statStamp.split(/\s+/);
|
|
3609
|
+
const origin = {
|
|
3610
|
+
mtimeMs: parseInt(originParts[0] ?? "0", 10) * 1000,
|
|
3611
|
+
size: parseInt(originParts[1] ?? "0", 10)
|
|
3612
|
+
};
|
|
3613
|
+
if (!origin.size || !shaStamp)
|
|
3614
|
+
throw new Error(`文件不存在或指纹异常: ${path}(${originStampRaw})`);
|
|
3615
|
+
const content0 = Buffer.from((b64Part ?? "").replace(/\s+/g, ""), "base64").toString("utf-8");
|
|
3616
|
+
const occurrences = content0.split(oldStr).length - 1;
|
|
3617
|
+
if (occurrences === 0) {
|
|
3618
|
+
throw new Error(`未命中待替换片段(0 处): ${path}(oldStr 长度 ${oldStr.length},文件 ${content0.length} 字符)`);
|
|
3619
|
+
}
|
|
3620
|
+
if (occurrences > 1 && !opts.all) {
|
|
3621
|
+
throw new Error(`多处命中(${occurrences} 处)——精确替换语义拒绝;确认全替换请传 { all: true }: ${path}`);
|
|
3622
|
+
}
|
|
3623
|
+
const edited = opts.all ? content0.split(oldStr).join(newStr) : content0.replace(oldStr, newStr);
|
|
3624
|
+
const newB64 = Buffer.from(edited, "utf-8").toString("base64");
|
|
3625
|
+
if (newB64.length > 30000) {
|
|
3626
|
+
return editFileViaFs(drv, path, edited, occurrences);
|
|
3627
|
+
}
|
|
3628
|
+
const cmd = `if [ "$(${statCmdOf(qp)})" = "${statStamp}" ] && ` + `[ "$(${dialect.shaCmd.replace(/\{f\}/g, qp)})" = "${shaStamp}" ]; then ` + `printf %s ${q5(newB64)} | ${dialect.dec} > ${qp}.fx-tmp && mv ${qp}.fx-tmp ${qp} && ` + `echo "FX_OK $(stat -c '%Y %s' ${qp} 2>/dev/null || stat -f '%m %z' ${qp} 2>/dev/null)"; ` + `else echo FX_CAS_FAIL; fi`;
|
|
3629
|
+
const cas = await drv.exec(cmd, { timeoutMs: 30000 });
|
|
3630
|
+
const out = (cas.stdout || "").trim();
|
|
3631
|
+
if (out.startsWith("FX_CAS_FAIL")) {
|
|
3632
|
+
throw new EditConflictError(`TOCTOU 冲突:CAS 戳不符(他人先写): ${path}(读时 stat ${statStamp} sha ${shaStamp})`);
|
|
3633
|
+
}
|
|
3634
|
+
if (!out.startsWith("FX_OK")) {
|
|
3635
|
+
throw new Error(`远端 CAS 写回异常: ${path}(输出 ${out.slice(0, 80)})`);
|
|
3636
|
+
}
|
|
3637
|
+
return {
|
|
3638
|
+
path,
|
|
3639
|
+
occurrences,
|
|
3640
|
+
bytesBefore: origin.size,
|
|
3641
|
+
bytesAfter: edited.length,
|
|
3642
|
+
lockedAtMtimeMs: origin.mtimeMs
|
|
3643
|
+
};
|
|
3644
|
+
}
|
|
3645
|
+
async function editFileViaFs(drv, path, edited, occurrences) {
|
|
3646
|
+
if (!drv.fs.stat) {
|
|
3647
|
+
throw new Error(`该层不支持受控编辑(fs.stat 缺席——TOCTOU 锁依赖): ${path}`);
|
|
3648
|
+
}
|
|
3649
|
+
const before = await drv.fs.stat(path);
|
|
3650
|
+
if (!before)
|
|
3651
|
+
throw new Error(`文件不存在: ${path}`);
|
|
3652
|
+
const lockCheck = await drv.fs.stat(path);
|
|
3653
|
+
if (!lockCheck || lockCheck.mtimeMs !== before.mtimeMs || lockCheck.size !== before.size) {
|
|
3654
|
+
throw new EditConflictError(`TOCTOU 冲突:读取后文件已被他人修改(mtime ${before.mtimeMs}→${lockCheck?.mtimeMs}): ${path}`);
|
|
3655
|
+
}
|
|
3656
|
+
await drv.fs.writeFile(path, edited);
|
|
3657
|
+
const after = await drv.fs.stat(path);
|
|
3658
|
+
if (after && before.mtimeMs === after.mtimeMs && before.size === after.size) {
|
|
3659
|
+
if (edited.length !== after.size) {
|
|
3660
|
+
throw new Error(`写后回读异常(size 未随写入变化): ${path}`);
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
return {
|
|
3664
|
+
path,
|
|
3665
|
+
occurrences,
|
|
3666
|
+
bytesBefore: before.size,
|
|
3667
|
+
bytesAfter: edited.length,
|
|
3668
|
+
lockedAtMtimeMs: before.mtimeMs
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
// src/agent-toolkit.ts
|
|
3673
|
+
var TOOL_DEFS = [
|
|
3674
|
+
{
|
|
3675
|
+
type: "function",
|
|
3676
|
+
function: {
|
|
3677
|
+
name: "run_command",
|
|
3678
|
+
description: "执行一次性命令。退出码如实返回(非零不是异常——是数据,供条件分支);" + "只有无法执行(超时/中断/连接死)才报错。stdout 与 stderr 分离返回。",
|
|
3679
|
+
parameters: {
|
|
3680
|
+
type: "object",
|
|
3681
|
+
properties: {
|
|
3682
|
+
command: { type: "string", description: "shell 命令" },
|
|
3683
|
+
timeout_ms: { type: "number", description: "超时毫秒(缺省 120000)" }
|
|
3684
|
+
},
|
|
3685
|
+
required: ["command"]
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
},
|
|
3689
|
+
{
|
|
3690
|
+
type: "function",
|
|
3691
|
+
function: {
|
|
3692
|
+
name: "spawn_and_observe",
|
|
3693
|
+
description: "启动长跑任务(立即返回句柄不阻塞)。可选就绪标记(ready_marker——回溯热窗口," + "快输出进程竞态安全)与错误模式(error_pattern——命中即自动收摊防遗忘泄漏)。" + "后续用 stop_and_verify 停止并核实终态。",
|
|
3694
|
+
parameters: {
|
|
3695
|
+
type: "object",
|
|
3696
|
+
properties: {
|
|
3697
|
+
command: { type: "string", description: "长跑命令" },
|
|
3698
|
+
ready_marker: { type: "string", description: "就绪标记(如 'listening on')" },
|
|
3699
|
+
error_pattern: { type: "string", description: "错误模式(命中即收摊)" },
|
|
3700
|
+
name: { type: "string", description: "会话名(人读前缀)" }
|
|
3701
|
+
},
|
|
3702
|
+
required: ["command"]
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
},
|
|
3706
|
+
{
|
|
3707
|
+
type: "function",
|
|
3708
|
+
function: {
|
|
3709
|
+
name: "stop_and_verify",
|
|
3710
|
+
description: "停止任务并核实终态。status 三态:exited=拿到退出码;stopped=终止已核实;" + "unknown=核实不可达(非成功非失败——重试核实或显式上报,严禁当已清理)。",
|
|
3711
|
+
parameters: {
|
|
3712
|
+
type: "object",
|
|
3713
|
+
properties: {
|
|
3714
|
+
session_id: { type: "string", description: "spawn_and_observe 返回的 session_id" }
|
|
3715
|
+
},
|
|
3716
|
+
required: ["session_id"]
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
},
|
|
3720
|
+
{
|
|
3721
|
+
type: "function",
|
|
3722
|
+
function: {
|
|
3723
|
+
name: "read_file",
|
|
3724
|
+
description: "读取远端层文件(UTF-8 文本)。返回内容与 (size, mtime_ms)。",
|
|
3725
|
+
parameters: {
|
|
3726
|
+
type: "object",
|
|
3727
|
+
properties: {
|
|
3728
|
+
path: { type: "string" }
|
|
3729
|
+
},
|
|
3730
|
+
required: ["path"]
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
},
|
|
3734
|
+
{
|
|
3735
|
+
type: "function",
|
|
3736
|
+
function: {
|
|
3737
|
+
name: "edit_file",
|
|
3738
|
+
description: "受控编辑远端文件:精确替换(唯一命中校验,all=true 全替换)+ TOCTOU 乐观锁(他人先写则拒绝)" + "+ base64 保真写回(零 Shell 转义——绝不 sed -i)。未命中/多处命中/冲突均报错不盲写。",
|
|
3739
|
+
parameters: {
|
|
3740
|
+
type: "object",
|
|
3741
|
+
properties: {
|
|
3742
|
+
path: { type: "string" },
|
|
3743
|
+
old: { type: "string", description: "待替换片段(含足够上下文唯一)" },
|
|
3744
|
+
new: { type: "string" },
|
|
3745
|
+
all: { type: "boolean", description: "多处命中全替换(缺省报错)" }
|
|
3746
|
+
},
|
|
3747
|
+
required: ["path", "old", "new"]
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
},
|
|
3751
|
+
{
|
|
3752
|
+
type: "function",
|
|
3753
|
+
function: {
|
|
3754
|
+
name: "observe_stream",
|
|
3755
|
+
description: "长跑观察(限时快照):启动长跑会话并观察一段时间(缺省 8s)按流过滤(stderr/stdout/both)," + "返回行对象(带 stream 标记)。跨流序为到达序。",
|
|
3756
|
+
parameters: {
|
|
3757
|
+
type: "object",
|
|
3758
|
+
properties: {
|
|
3759
|
+
command: { type: "string" },
|
|
3760
|
+
duration_ms: { type: "number", description: "观察时长(缺省 8000)" },
|
|
3761
|
+
stream: { type: "string", enum: ["stdout", "stderr", "both"], description: "流过滤(缺省 both)" },
|
|
3762
|
+
name: { type: "string" }
|
|
3763
|
+
},
|
|
3764
|
+
required: ["command"]
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
}
|
|
3768
|
+
];
|
|
3769
|
+
function createAgentToolkit(stack, opts = {}) {
|
|
3770
|
+
const layer = opts.driver ? Promise.resolve(opts.driver) : stack.connect(opts.descriptor ?? { driver: "local" });
|
|
3771
|
+
const readyTimeoutMs = opts.readyTimeoutMs ?? 15000;
|
|
3772
|
+
return {
|
|
3773
|
+
tools: TOOL_DEFS,
|
|
3774
|
+
layer,
|
|
3775
|
+
handlers: {
|
|
3776
|
+
async run_command({ command, timeout_ms }) {
|
|
3777
|
+
const drv = await layer;
|
|
3778
|
+
const r = await drv.exec(command, { timeoutMs: timeout_ms });
|
|
3779
|
+
return {
|
|
3780
|
+
stdout: r.stdout,
|
|
3781
|
+
stderr: r.stderr,
|
|
3782
|
+
exit_code: r.exitCode,
|
|
3783
|
+
effective_user: r.effectiveUser
|
|
3784
|
+
};
|
|
3785
|
+
},
|
|
3786
|
+
async spawn_and_observe({ command, ready_marker, error_pattern, name }) {
|
|
3787
|
+
const drv = await layer;
|
|
3788
|
+
const h = await drv.execSession(command, name ? { name } : undefined);
|
|
3789
|
+
if (error_pattern) {
|
|
3790
|
+
drv.watchSession(h.sessionId, { pattern: error_pattern, once: false, thenStop: true });
|
|
3791
|
+
}
|
|
3792
|
+
let note;
|
|
3793
|
+
if (ready_marker) {
|
|
3794
|
+
const t0 = Date.now();
|
|
3795
|
+
for (;; ) {
|
|
3796
|
+
const lines = await drv.tailSession(h.sessionId, 50);
|
|
3797
|
+
if (lines.some((l) => l.includes(ready_marker))) {
|
|
3798
|
+
note = "ready";
|
|
3799
|
+
break;
|
|
3800
|
+
}
|
|
3801
|
+
if (Date.now() - t0 > readyTimeoutMs) {
|
|
3802
|
+
await drv.stopSession(h.sessionId).catch(() => {
|
|
3803
|
+
return;
|
|
3804
|
+
});
|
|
3805
|
+
throw new Error(`ready 超时(${readyTimeoutMs}ms 未见 '${ready_marker}'——已自动停止)`);
|
|
3806
|
+
}
|
|
3807
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
return { session_id: h.sessionId, log_base: h.logBase, note };
|
|
3811
|
+
},
|
|
3812
|
+
async read_file({ path }) {
|
|
3813
|
+
const drv = await layer;
|
|
3814
|
+
if (!drv.fs.stat)
|
|
3815
|
+
throw new Error("该层不支持 read_file 的元数据(fs.stat 缺席)");
|
|
3816
|
+
const st = await drv.fs.stat(path);
|
|
3817
|
+
if (!st)
|
|
3818
|
+
throw new Error(`文件不存在: ${path}`);
|
|
3819
|
+
const content = await drv.fs.readFile(path);
|
|
3820
|
+
return { path, content, size: st.size, mtime_ms: st.mtimeMs };
|
|
3821
|
+
},
|
|
3822
|
+
async edit_file({ path, old, new: newStr, all }) {
|
|
3823
|
+
const drv = await layer;
|
|
3824
|
+
const r = await editFile(drv, path, old, newStr, { all });
|
|
3825
|
+
return { path: r.path, occurrences: r.occurrences, bytes_before: r.bytesBefore, bytes_after: r.bytesAfter };
|
|
3826
|
+
},
|
|
3827
|
+
async observe_stream({ command, duration_ms, stream, name }) {
|
|
3828
|
+
const drv = await layer;
|
|
3829
|
+
const h = await drv.execSession(command, name ? { name } : undefined);
|
|
3830
|
+
const dur = duration_ms ?? 8000;
|
|
3831
|
+
await new Promise((r) => setTimeout(r, dur));
|
|
3832
|
+
const anyDrv = drv;
|
|
3833
|
+
const lines = anyDrv.sessions?.tailLines ? anyDrv.sessions.tailLines(h.sessionId, 500) : (await drv.tailSession(h.sessionId, 500)).map((t) => ({ stream: "stdout", text: t }));
|
|
3834
|
+
const filtered = stream && stream !== "both" ? lines.filter((l) => l.stream === stream) : lines;
|
|
3835
|
+
await drv.stopSession(h.sessionId).catch(() => {
|
|
3836
|
+
return;
|
|
3837
|
+
});
|
|
3838
|
+
return { session_id: h.sessionId, lines: filtered };
|
|
3839
|
+
},
|
|
3840
|
+
async stop_and_verify({ session_id }) {
|
|
3841
|
+
const drv = await layer;
|
|
3842
|
+
const r = await drv.stopSession(session_id);
|
|
3843
|
+
if (r.status === "exited")
|
|
3844
|
+
return { status: "exited", exit_code: r.exitCode };
|
|
3845
|
+
if (r.status === "stopped")
|
|
3846
|
+
return { status: "stopped", note: "终止已核实(SIGKILL 升级在场)" };
|
|
3847
|
+
if (r.status === "unknown")
|
|
3848
|
+
return { status: "unknown", note: "核实不可达——重连后核实或显式上报" };
|
|
3849
|
+
return r.exitCode != null ? { status: "exited", exit_code: r.exitCode } : { status: "stopped", note: "已发终止(旧形态 driver——建议升级内核取三态)" };
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
// src/layer-discovery.ts
|
|
3855
|
+
async function discoverLayer(driver, opts = {}) {
|
|
3856
|
+
const key = layerEnvKey(driver.descriptor);
|
|
3857
|
+
const r1 = await driver.exec(buildProbeStage1(), { timeoutMs: 30000 });
|
|
3858
|
+
const spec = parseProbeOutput(r1.stdout);
|
|
3859
|
+
let probeNote;
|
|
3860
|
+
if (!opts.skipStage2) {
|
|
3861
|
+
try {
|
|
3862
|
+
const r2 = await driver.exec(buildProbeStage2(), {
|
|
3863
|
+
timeoutMs: opts.stage2TimeoutMs ?? 120000
|
|
3864
|
+
});
|
|
3865
|
+
const spec2 = parseProbeOutput(r2.stdout);
|
|
3866
|
+
spec.packages = spec2.packages;
|
|
3867
|
+
} catch (err) {
|
|
3868
|
+
probeNote = `stage2 失败(${err instanceof Error ? err.message : String(err)})——packages 可能缺席`;
|
|
3869
|
+
opts.logger?.warn(`[discoverLayer ${key}] ${probeNote}`);
|
|
3870
|
+
}
|
|
3871
|
+
} else {
|
|
3872
|
+
probeNote = "stage2 skipped";
|
|
3873
|
+
}
|
|
3874
|
+
const entry = {
|
|
3875
|
+
connection: projectConnection(driver.descriptor),
|
|
3876
|
+
environment: spec,
|
|
3877
|
+
discovered_at: new Date().toISOString(),
|
|
3878
|
+
fingerprint: layerFingerprint(projectConnection(driver.descriptor), spec),
|
|
3879
|
+
probe_note: probeNote
|
|
3880
|
+
};
|
|
3881
|
+
let stale;
|
|
3882
|
+
if (opts.store) {
|
|
3883
|
+
const r = await opts.store.upsert(key, entry);
|
|
3884
|
+
stale = r.stale;
|
|
3885
|
+
}
|
|
3886
|
+
return { key, spec, probeNote, stale, entry: opts.store ? undefined : entry };
|
|
3887
|
+
}
|
|
3888
|
+
// src/stream-window.ts
|
|
3889
|
+
import { spawn as spawn7 } from "node:child_process";
|
|
3890
|
+
var TRUNC_SUFFIX2 = "…[truncated]";
|
|
3891
|
+
|
|
3892
|
+
class StreamWindow {
|
|
3893
|
+
name;
|
|
3894
|
+
layerAlias;
|
|
3895
|
+
source;
|
|
3896
|
+
command;
|
|
3897
|
+
maxFrames;
|
|
3898
|
+
maxLineBytes;
|
|
3899
|
+
frames = [];
|
|
3900
|
+
nextSeq = 1;
|
|
3901
|
+
_stale = false;
|
|
3902
|
+
_closed = false;
|
|
3903
|
+
consumers = new Map;
|
|
3904
|
+
transport = null;
|
|
3905
|
+
lastActivityAt = Date.now();
|
|
3906
|
+
constructor(name, layerAlias, source, command, maxFrames = 200, maxLineBytes = 1024) {
|
|
3907
|
+
this.name = name;
|
|
3908
|
+
this.layerAlias = layerAlias;
|
|
3909
|
+
this.source = source;
|
|
3910
|
+
this.command = command;
|
|
3911
|
+
this.maxFrames = maxFrames;
|
|
3912
|
+
this.maxLineBytes = maxLineBytes;
|
|
3913
|
+
}
|
|
3914
|
+
get stale() {
|
|
3915
|
+
return this._stale;
|
|
3916
|
+
}
|
|
3917
|
+
get closed() {
|
|
3918
|
+
return this._closed;
|
|
3919
|
+
}
|
|
3920
|
+
get frameCount() {
|
|
3921
|
+
return this.frames.length;
|
|
3922
|
+
}
|
|
3923
|
+
get oldestSeq() {
|
|
3924
|
+
return this.frames.length ? this.frames[0].seq : 0;
|
|
3925
|
+
}
|
|
3926
|
+
get newestSeq() {
|
|
3927
|
+
return this.frames.length ? this.frames[this.frames.length - 1].seq : 0;
|
|
3928
|
+
}
|
|
3929
|
+
get lastActiveAt() {
|
|
3930
|
+
return this.lastActivityAt;
|
|
3931
|
+
}
|
|
3932
|
+
push(text) {
|
|
3933
|
+
if (this._closed)
|
|
3934
|
+
return;
|
|
3935
|
+
let line = text;
|
|
3936
|
+
if (line.length > this.maxLineBytes) {
|
|
3937
|
+
line = line.slice(0, this.maxLineBytes - TRUNC_SUFFIX2.length) + TRUNC_SUFFIX2;
|
|
3938
|
+
}
|
|
3939
|
+
this.frames.push({ seq: this.nextSeq++, ts: Date.now(), text: line });
|
|
3940
|
+
if (this.frames.length > this.maxFrames) {
|
|
3941
|
+
this.frames = this.frames.slice(-this.maxFrames);
|
|
3942
|
+
}
|
|
3943
|
+
this._stale = false;
|
|
3944
|
+
this.lastActivityAt = Date.now();
|
|
3945
|
+
}
|
|
3946
|
+
peekLast(n = 20) {
|
|
3947
|
+
return this.snapshot(this.frames.slice(-Math.max(0, n)));
|
|
3948
|
+
}
|
|
3949
|
+
peekWindow(ms) {
|
|
3950
|
+
const cutoff = Date.now() - ms;
|
|
3951
|
+
const frames = this.frames.filter((f) => f.ts >= cutoff);
|
|
3952
|
+
return this.snapshot(frames);
|
|
3953
|
+
}
|
|
3954
|
+
peekSince(sinceSeq, n = 50, consumerId) {
|
|
3955
|
+
const frames = this.frames.filter((f) => f.seq > sinceSeq).slice(0, Math.max(0, n));
|
|
3956
|
+
const result = this.snapshot(frames);
|
|
3957
|
+
if (this.oldestSeq > sinceSeq + 1 && sinceSeq > 0)
|
|
3958
|
+
result.gap = true;
|
|
3959
|
+
if (consumerId) {
|
|
3960
|
+
const lastSeq = frames.length ? frames[frames.length - 1].seq : sinceSeq;
|
|
3961
|
+
this.consumers.set(consumerId, Math.max(lastSeq, sinceSeq));
|
|
3962
|
+
this.lastActivityAt = Date.now();
|
|
3963
|
+
}
|
|
3964
|
+
return result;
|
|
3965
|
+
}
|
|
3966
|
+
consumerSeq(consumerId) {
|
|
3967
|
+
return this.consumers.get(consumerId) ?? 0;
|
|
3968
|
+
}
|
|
3969
|
+
markStale() {
|
|
3970
|
+
if (this._closed)
|
|
3971
|
+
return;
|
|
3972
|
+
this._stale = true;
|
|
3973
|
+
}
|
|
3974
|
+
attachTransport(t) {
|
|
3975
|
+
this.transport = t;
|
|
3976
|
+
}
|
|
3977
|
+
close() {
|
|
3978
|
+
if (this._closed)
|
|
3979
|
+
return;
|
|
3980
|
+
this._closed = true;
|
|
3981
|
+
try {
|
|
3982
|
+
if (this.transport?.kind === "local") {
|
|
3983
|
+
this.transport.child.kill("SIGTERM");
|
|
3984
|
+
} else if (this.transport?.kind === "remote") {
|
|
3985
|
+
Promise.resolve(this.transport.manager.stop(this.transport.sessionId)).catch(() => {});
|
|
3986
|
+
}
|
|
3987
|
+
} catch {}
|
|
3988
|
+
this.frames = [];
|
|
3989
|
+
this.consumers.clear();
|
|
3990
|
+
}
|
|
3991
|
+
snapshot(frames) {
|
|
3992
|
+
this.lastActivityAt = Date.now();
|
|
3993
|
+
return {
|
|
3994
|
+
name: this.name,
|
|
3995
|
+
oldestSeq: this.oldestSeq,
|
|
3996
|
+
newestSeq: this.newestSeq,
|
|
3997
|
+
stale: this._stale,
|
|
3998
|
+
frames
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
class LineSplitter {
|
|
4004
|
+
buf = "";
|
|
4005
|
+
push(chunk, onLine) {
|
|
4006
|
+
this.buf += chunk;
|
|
4007
|
+
const lines = this.buf.split(`
|
|
4008
|
+
`);
|
|
4009
|
+
this.buf = lines.pop() ?? "";
|
|
4010
|
+
for (const line of lines) {
|
|
4011
|
+
const clean = line.replace(/\r$/, "");
|
|
4012
|
+
if (clean !== "")
|
|
4013
|
+
onLine(clean);
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
flush(onLine) {
|
|
4017
|
+
const rest = this.buf.replace(/\r$/, "");
|
|
4018
|
+
this.buf = "";
|
|
4019
|
+
if (rest !== "")
|
|
4020
|
+
onLine(rest);
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
class RemoteBridge {
|
|
4025
|
+
window;
|
|
4026
|
+
manager;
|
|
4027
|
+
constructor(window, manager) {
|
|
4028
|
+
this.window = window;
|
|
4029
|
+
this.manager = manager;
|
|
4030
|
+
}
|
|
4031
|
+
async start(command) {
|
|
4032
|
+
const { sessionId } = await this.manager.start(command, {
|
|
4033
|
+
name: this.window.name.replace(/[^A-Za-z0-9_-]/g, "_"),
|
|
4034
|
+
onLine: (line) => this.window.push(line.text),
|
|
4035
|
+
onExit: () => this.window.markStale()
|
|
4036
|
+
});
|
|
4037
|
+
this.window.attachTransport({ kind: "remote", manager: this.manager, sessionId });
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
class StreamWindowRegistry {
|
|
4042
|
+
windows = new Map;
|
|
4043
|
+
sweeper;
|
|
4044
|
+
idleTtlMs;
|
|
4045
|
+
shell;
|
|
4046
|
+
log;
|
|
4047
|
+
constructor(opts = {}) {
|
|
4048
|
+
this.idleTtlMs = opts.idleTtlMs ?? 30 * 60000;
|
|
4049
|
+
this.shell = opts.shell ?? resolveLocalShell(undefined, opts.logger);
|
|
4050
|
+
this.log = opts.logger ?? noopLogger;
|
|
4051
|
+
this.sweeper = setInterval(() => this.sweep(), 60000);
|
|
4052
|
+
this.sweeper.unref();
|
|
4053
|
+
}
|
|
4054
|
+
async open(opts) {
|
|
4055
|
+
const existing = this.windows.get(opts.name);
|
|
4056
|
+
if (existing && !existing.closed) {
|
|
4057
|
+
throw new Error(`观察窗 '${opts.name}' 已存在(stop 后可重建)`);
|
|
4058
|
+
}
|
|
4059
|
+
const layerAlias = opts.layerAlias ?? "local";
|
|
4060
|
+
const source = opts.name.split(":").slice(-1)[0] ?? opts.name;
|
|
4061
|
+
const win = new StreamWindow(opts.name, layerAlias, source, opts.command, opts.maxFrames, opts.maxLineBytes);
|
|
4062
|
+
if (layerAlias === "local") {
|
|
4063
|
+
const child = spawn7(this.shell, ["-c", opts.command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
4064
|
+
const splitter = new LineSplitter;
|
|
4065
|
+
child.stdout?.on("data", (d) => splitter.push(d.toString("utf-8"), (l) => win.push(l)));
|
|
4066
|
+
child.stderr?.on("data", (d) => splitter.push(d.toString("utf-8"), (l) => win.push(l)));
|
|
4067
|
+
child.on("exit", () => {
|
|
4068
|
+
splitter.flush((l) => win.push(l));
|
|
4069
|
+
win.markStale();
|
|
4070
|
+
});
|
|
4071
|
+
win.attachTransport({ kind: "local", child });
|
|
4072
|
+
} else {
|
|
4073
|
+
if (!opts.manager)
|
|
4074
|
+
throw new Error(`remote 窗需要 StreamSessionManager(layer='${layerAlias}')`);
|
|
4075
|
+
await new RemoteBridge(win, opts.manager).start(opts.command);
|
|
4076
|
+
}
|
|
4077
|
+
this.windows.set(opts.name, win);
|
|
4078
|
+
this.log.info(`[StreamWindow] open '${opts.name}' (layer=${layerAlias})`);
|
|
4079
|
+
return win;
|
|
4080
|
+
}
|
|
4081
|
+
stop(name) {
|
|
4082
|
+
const win = this.require(name);
|
|
4083
|
+
win.close();
|
|
4084
|
+
this.windows.delete(name);
|
|
4085
|
+
}
|
|
4086
|
+
peek(pattern) {
|
|
4087
|
+
return this.matches(pattern);
|
|
4088
|
+
}
|
|
4089
|
+
get(name) {
|
|
4090
|
+
return this.windows.get(name);
|
|
4091
|
+
}
|
|
4092
|
+
list() {
|
|
4093
|
+
return [...this.windows.values()].map((w) => ({
|
|
4094
|
+
name: w.name,
|
|
4095
|
+
layer: w.layerAlias,
|
|
4096
|
+
frames: w.frameCount,
|
|
4097
|
+
stale: w.stale,
|
|
4098
|
+
command: w.command
|
|
4099
|
+
}));
|
|
4100
|
+
}
|
|
4101
|
+
require(name) {
|
|
4102
|
+
const w = this.windows.get(name);
|
|
4103
|
+
if (!w)
|
|
4104
|
+
throw new Error(`观察窗 '${name}' 不存在`);
|
|
4105
|
+
return w;
|
|
4106
|
+
}
|
|
4107
|
+
matches(pattern) {
|
|
4108
|
+
const hasWildcard = pattern.includes("*") || pattern.includes("?");
|
|
4109
|
+
if (!hasWildcard) {
|
|
4110
|
+
const w = this.windows.get(pattern);
|
|
4111
|
+
return w ? [w] : [];
|
|
4112
|
+
}
|
|
4113
|
+
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
4114
|
+
return [...this.windows.values()].filter((w) => re.test(w.name));
|
|
4115
|
+
}
|
|
4116
|
+
sweep() {
|
|
4117
|
+
const now = Date.now();
|
|
4118
|
+
for (const [name, w] of this.windows) {
|
|
4119
|
+
if (now - w.lastActiveAt > this.idleTtlMs) {
|
|
4120
|
+
this.log.info(`[StreamWindow] '${name}' 空引用超时 → 销毁`);
|
|
4121
|
+
this.stop(name);
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
dispose() {
|
|
4126
|
+
clearInterval(this.sweeper);
|
|
4127
|
+
for (const name of [...this.windows.keys()])
|
|
4128
|
+
this.stop(name);
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
var _registry = null;
|
|
4132
|
+
function getStreamWindowRegistry() {
|
|
4133
|
+
if (!_registry)
|
|
4134
|
+
_registry = new StreamWindowRegistry;
|
|
4135
|
+
return _registry;
|
|
4136
|
+
}
|
|
4137
|
+
export {
|
|
4138
|
+
DEFAULT_TERMINAL_CONFIG,
|
|
4139
|
+
DialectCache,
|
|
4140
|
+
EditConflictError,
|
|
4141
|
+
EventBus,
|
|
4142
|
+
LAYER_IDLE_THRESHOLD_MS,
|
|
4143
|
+
LayerEnvStore,
|
|
4144
|
+
LineAssembler,
|
|
4145
|
+
LocalRuntimeDriver,
|
|
4146
|
+
LocalSessionManager,
|
|
4147
|
+
NULL_DIALECT,
|
|
4148
|
+
OutputBuffer,
|
|
4149
|
+
OutputRingBuffer,
|
|
4150
|
+
PROBE_PACKAGES,
|
|
4151
|
+
RemoteSessionManager,
|
|
4152
|
+
RuntimeFactory,
|
|
4153
|
+
RuntimeStack,
|
|
4154
|
+
SSH_INPUT_WAIT_RE,
|
|
4155
|
+
SshPtyDriver,
|
|
4156
|
+
StreamSessionManager,
|
|
4157
|
+
StreamWindow,
|
|
4158
|
+
StreamWindowRegistry,
|
|
4159
|
+
TerminalManager,
|
|
4160
|
+
TmuxError,
|
|
4161
|
+
TmuxSessionDriver,
|
|
4162
|
+
TransferVerificationError,
|
|
4163
|
+
Vt100Screen,
|
|
4164
|
+
WatchRegistry,
|
|
4165
|
+
autonomousSshOpts,
|
|
4166
|
+
buildProbeStage1,
|
|
4167
|
+
buildProbeStage2,
|
|
4168
|
+
compilePromptPatterns,
|
|
4169
|
+
createAgentToolkit,
|
|
4170
|
+
discoverLayer,
|
|
4171
|
+
editFile,
|
|
4172
|
+
enterStepsOf,
|
|
4173
|
+
expandHomePath,
|
|
4174
|
+
getFile,
|
|
4175
|
+
getStreamWindowRegistry,
|
|
4176
|
+
hopHost,
|
|
4177
|
+
hopLabel,
|
|
4178
|
+
hopSshCommand,
|
|
4179
|
+
layerDisplayLabel,
|
|
4180
|
+
layerDisplayName,
|
|
4181
|
+
layerEnvKey,
|
|
4182
|
+
layerFingerprint,
|
|
4183
|
+
layerPathIdentities,
|
|
4184
|
+
matchLayers,
|
|
4185
|
+
noopLogger,
|
|
4186
|
+
parseProbeOutput,
|
|
4187
|
+
probeDialect,
|
|
4188
|
+
projectConnection,
|
|
4189
|
+
putFile,
|
|
4190
|
+
renderExecChannel,
|
|
4191
|
+
renderForwardArg,
|
|
4192
|
+
resolveLayerState,
|
|
4193
|
+
resolveLocalShell,
|
|
4194
|
+
resolveSshIdentity,
|
|
4195
|
+
rootSshSpawnArgs,
|
|
4196
|
+
sftpGetFile,
|
|
4197
|
+
sftpPutFile,
|
|
4198
|
+
sshArgsFor,
|
|
4199
|
+
sshArgsQuoted
|
|
4200
|
+
};
|