@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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal/command-executer.ts — 持久会话内命令执行(marker 协议 + 三级缓冲 + 双超时)
|
|
3
|
+
* (净室平移,源锚 8977ff3;剥 SubstrateSecurity——安全策略归消费方;
|
|
4
|
+
* interactionManager 两形态嗅探剥——OSS 只认 PromptResponder 注入)
|
|
5
|
+
*
|
|
6
|
+
* marker 协议:send-keys 命令 + `echo MARKER$?`,capture-pane 解析 exitCode;
|
|
7
|
+
* stdout 从 OutputBuffer 增量累积取(不受 capture 窗口截断)。
|
|
8
|
+
* 双超时:墙钟总限 + 不活动(新输出重置——只杀哑不杀慢)。
|
|
9
|
+
* 修复点 D:shopt 安全前缀防 PS1 注入。二进制嗅探保 stdout 纯净。
|
|
10
|
+
*/
|
|
11
|
+
import { TmuxInvoker } from "./tmux-invoker.js";
|
|
12
|
+
import { OutputBuffer, type BufferStats } from "./output-buffer.js";
|
|
13
|
+
import { compilePromptPatterns, DEFAULT_TERMINAL_CONFIG, type PromptPayload, type PromptResponder, type TerminalConfig } from "./types.js";
|
|
14
|
+
import { noopLogger, type FilaxisLogger } from "../logger.js";
|
|
15
|
+
|
|
16
|
+
export interface ProgressSnapshot {
|
|
17
|
+
tail: string;
|
|
18
|
+
stats: BufferStats;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RunOptions {
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** 交互响应者(密码/确认 Prompt——消费方注入;null=无交互能力) */
|
|
24
|
+
responder?: PromptResponder | null;
|
|
25
|
+
/** 流式回调:每次 capture 到新内容触发(原始 tail 快照,未剥 marker 噪声) */
|
|
26
|
+
onProgress?: (snapshot: ProgressSnapshot) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TerminalExecResult {
|
|
30
|
+
stdout: string;
|
|
31
|
+
exitCode: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 命令失败富错误:partialOutput 携带截至失败时刻的累积输出 */
|
|
35
|
+
export class CommandExecError extends Error {
|
|
36
|
+
readonly partialOutput: string;
|
|
37
|
+
/** 超时/中断:远端终态未核实("unknown")——上层按未知处理,严禁当已清理 */
|
|
38
|
+
outcome?: "unknown";
|
|
39
|
+
constructor(message: string, partialOutput = "", outcome?: "unknown") {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "CommandExecError";
|
|
42
|
+
this.partialOutput = partialOutput;
|
|
43
|
+
this.outcome = outcome;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SAFE_PREFIX = "shopt -u promptvars nullglob extglob nocaseglob 2>/dev/null;";
|
|
48
|
+
const BINARY_SNIFF_BYTES = 4096;
|
|
49
|
+
const BINARY_NULL_RATIO = 0.10;
|
|
50
|
+
|
|
51
|
+
/** 二进制嗅探:前 N 字节 null 占比 >10% 判 binary(PTY 偶发 NUL 容忍) */
|
|
52
|
+
export function isBinaryChunk(text: string): boolean {
|
|
53
|
+
const sample = text.length > BINARY_SNIFF_BYTES ? text.slice(0, BINARY_SNIFF_BYTES) : text;
|
|
54
|
+
if (sample.length === 0) return false;
|
|
55
|
+
let nulls = 0;
|
|
56
|
+
for (let i = 0; i < sample.length; i++) {
|
|
57
|
+
if (sample.charCodeAt(i) === 0) nulls++;
|
|
58
|
+
}
|
|
59
|
+
return nulls / sample.length > BINARY_NULL_RATIO;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
63
|
+
|
|
64
|
+
export interface CommandExecuterOptions {
|
|
65
|
+
config?: Partial<TerminalConfig>;
|
|
66
|
+
logger?: FilaxisLogger;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class CommandExecuter {
|
|
70
|
+
private constructor() {}
|
|
71
|
+
|
|
72
|
+
private static cfg: TerminalConfig = { ...DEFAULT_TERMINAL_CONFIG };
|
|
73
|
+
/** 已关回显的会话(每会话一次性 stty -echo——交互 shell 的命令回显不进 stdout) */
|
|
74
|
+
private static sttyOff = new Set<string>();
|
|
75
|
+
/** per-session 命令链(审查问题 2:同会话命令必须串行——并发 send-keys 交错) */
|
|
76
|
+
private static chains = new Map<string, Promise<unknown>>();
|
|
77
|
+
/** 因终态不可确认被销毁的会话(自愈:重建后 has-session 探活即清除标记) */
|
|
78
|
+
private static deadSessions = new Set<string>();
|
|
79
|
+
private static log: FilaxisLogger = noopLogger;
|
|
80
|
+
|
|
81
|
+
/** 配置注入(SDK 组装期一次性;测试可覆盖) */
|
|
82
|
+
static configure(opts: CommandExecuterOptions = {}): void {
|
|
83
|
+
this.cfg = { ...DEFAULT_TERMINAL_CONFIG, ...opts.config };
|
|
84
|
+
this.log = opts.logger ?? noopLogger;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 在 tmux 会话中执行命令(四阶段 temporal isolation——审查问题 2 修复):
|
|
89
|
+
* - **per-session 串行**:同会话命令排队,绝不并发 send-keys;
|
|
90
|
+
* - 超时(墙钟/不活动)= INTERRUPTING:发 C-c 杀远端前台任务,调用方立即
|
|
91
|
+
* 收 CommandExecError(挂 outcome:"unknown")——但**会话锁不释放**;
|
|
92
|
+
* - DRAINING(仍在链内):等自己的 marker 可信终态(上限 5s)才把会话交还
|
|
93
|
+
* 下一条命令;
|
|
94
|
+
* - 等不到终态 → kill-session 销毁 + 标记 dead(后续调用显式报错指引重建;
|
|
95
|
+
* 重建后探活自愈)——绝不定时强行复用。
|
|
96
|
+
*/
|
|
97
|
+
static async execute(id: string, command: string, opts: RunOptions = {}): Promise<TerminalExecResult> {
|
|
98
|
+
// 自愈探活:dead 标记在但会话已被重建(同 id respawn)→ 清标记放行
|
|
99
|
+
if (this.deadSessions.has(id)) {
|
|
100
|
+
try {
|
|
101
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
102
|
+
if (r.exitCode === 0) {
|
|
103
|
+
this.deadSessions.delete(id);
|
|
104
|
+
this.sttyOff.delete(id);
|
|
105
|
+
} else {
|
|
106
|
+
throw new CommandExecError(
|
|
107
|
+
`tmux session '${id}' 已因超时终态不可确认被销毁——请重建层(同 id respawn 后自动恢复)再执行`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e instanceof CommandExecError) throw e;
|
|
112
|
+
throw new CommandExecError(`tmux session '${id}' 已销毁且探活失败——请重建层`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const prev = this.chains.get(id) ?? Promise.resolve();
|
|
116
|
+
const run = prev.catch(() => undefined).then(() => this.executeLocked(id, command, opts));
|
|
117
|
+
this.chains.set(id, run.catch(() => undefined));
|
|
118
|
+
return run;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private static async executeLocked(id: string, command: string, opts: RunOptions = {}): Promise<TerminalExecResult> {
|
|
122
|
+
const cfg = this.cfg;
|
|
123
|
+
const timeoutMs = opts.timeoutMs ?? cfg.command_timeout_ms;
|
|
124
|
+
|
|
125
|
+
let alive: boolean;
|
|
126
|
+
try {
|
|
127
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
128
|
+
alive = r.exitCode === 0;
|
|
129
|
+
} catch {
|
|
130
|
+
alive = false;
|
|
131
|
+
}
|
|
132
|
+
if (!alive) {
|
|
133
|
+
throw new CommandExecError(`tmux session '${id}' does not exist. Spawn a new session first.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]);
|
|
137
|
+
|
|
138
|
+
// ── 会话首次执行:关交互回显 + 清提示符(CI 实证:tmux 非登录 shell 的
|
|
139
|
+
// PS1 带 hostname 前缀裸奔,命令回显与提示符行全进 capture-pane)。
|
|
140
|
+
// stty/PS1 自身的回显经二次 clear 清除 ──
|
|
141
|
+
if (!this.sttyOff.has(id)) {
|
|
142
|
+
await this.sendKeys(id, "stty -echo 2>/dev/null; export PS1= PS2=;");
|
|
143
|
+
await sleep(400);
|
|
144
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]);
|
|
145
|
+
this.sttyOff.add(id);
|
|
146
|
+
}
|
|
147
|
+
// 每命令发送前清屏:clear-history 只清滚回区;可见 pane 的残留(CI 六发
|
|
148
|
+
// 实证 PRE_TASK_OUT 滞留)必须经 shell 命令 `clear`(清终端+光标归位;
|
|
149
|
+
// C-l 按键在非交互 bash 不被处理,实测无效)——两者一起做
|
|
150
|
+
try {
|
|
151
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "-l", "clear"]);
|
|
152
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-m"]);
|
|
153
|
+
await sleep(200);
|
|
154
|
+
} catch { /* best-effort */ }
|
|
155
|
+
await TmuxInvoker.invoke(["clear-history", "-t", id]).catch(() => undefined);
|
|
156
|
+
|
|
157
|
+
const markerToken = Math.random().toString(36).slice(2, 10);
|
|
158
|
+
const markerPrefix = `---CMD_DONE_${markerToken}_`;
|
|
159
|
+
const markerEchoNeedle = `echo ${markerPrefix}`;
|
|
160
|
+
const safeCommand = `${SAFE_PREFIX} ${command}`;
|
|
161
|
+
|
|
162
|
+
await this.sendKeys(id, safeCommand);
|
|
163
|
+
await this.sendKeys(id, `echo ${markerPrefix}$?`);
|
|
164
|
+
|
|
165
|
+
const buffer = new OutputBuffer({
|
|
166
|
+
pendingChars: cfg.buffer_pending_chars,
|
|
167
|
+
aggregatedChars: cfg.buffer_aggregated_chars,
|
|
168
|
+
tailChars: cfg.buffer_tail_chars,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const wallDeadline = Date.now() + timeoutMs;
|
|
172
|
+
let lastActivityAt = Date.now();
|
|
173
|
+
let lastPaneText = "";
|
|
174
|
+
let binaryDetected = false;
|
|
175
|
+
let binaryBytes = 0;
|
|
176
|
+
|
|
177
|
+
while (true) {
|
|
178
|
+
if (Date.now() >= wallDeadline || Date.now() - lastActivityAt >= cfg.no_output_timeout_ms) {
|
|
179
|
+
// ★ INTERRUPTING(审查问题 2:此前裸 throw——前台任务还在跑,下一条
|
|
180
|
+
// 命令的 send-keys 会打进去。现在:发 C-c → DRAINING 等自己的
|
|
181
|
+
// marker 可信终态(仍在链内,串行保证下一条命令等这里完成)→
|
|
182
|
+
// 等不到则销毁会话标 dead,绝不强行复用)
|
|
183
|
+
const reason = Date.now() >= wallDeadline
|
|
184
|
+
? `Timeout waiting for command to finish in terminal ${id}`
|
|
185
|
+
: `Timeout waiting for command to finish in terminal ${id} (no output for ${cfg.no_output_timeout_ms}ms)`;
|
|
186
|
+
const partial = this.extractPartialOutput(buffer, markerPrefix, markerEchoNeedle, binaryDetected, binaryBytes);
|
|
187
|
+
try { await TmuxInvoker.invoke(["send-keys", "-t", id, "C-c"]); } catch { /* 会话可能已死 */ }
|
|
188
|
+
// ★ 交互 bash 中 ^C 杀整行 job(含本命令尾部的 marker echo)——原命令的
|
|
189
|
+
// marker 可能永不出现。DRAINING 改用**独立探测哨兵**:发一条新的
|
|
190
|
+
// echo(自身 ^C 幸存——它是 ^C 之后的新行),等它出现在 pane 即
|
|
191
|
+
// shell 已回提示符(可信终态:可安全接手)。二者任一先到即算。
|
|
192
|
+
const drainToken = `__FX_DRAIN_${Math.random().toString(36).slice(2, 8)}`;
|
|
193
|
+
try {
|
|
194
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "-l", `echo ${drainToken}`]);
|
|
195
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-m"]);
|
|
196
|
+
} catch { /* 会话可能已死 */ }
|
|
197
|
+
const drained = await this.waitForDrain(id, markerPrefix, drainToken, 5_000);
|
|
198
|
+
if (drained) {
|
|
199
|
+
try { await TmuxInvoker.invoke(["clear-history", "-t", id]); } catch { /* best-effort */ }
|
|
200
|
+
throw new CommandExecError(
|
|
201
|
+
`${reason}(已发 ^C,远端 shell 已确认回提示符——outcome=unknown)`,
|
|
202
|
+
partial, "unknown",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
try { await TmuxInvoker.invoke(["kill-session", "-t", id]); } catch { /* 已死 */ }
|
|
206
|
+
this.deadSessions.add(id);
|
|
207
|
+
this.sttyOff.delete(id);
|
|
208
|
+
throw new CommandExecError(
|
|
209
|
+
`${reason}(已发 ^C 但 5s 内未见可信终态——会话已销毁防残影串台;同 id respawn 后自动恢复)`,
|
|
210
|
+
partial, "unknown",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const paneText = await this.capturePane(id, cfg.capture_scrollback);
|
|
215
|
+
|
|
216
|
+
if (paneText !== lastPaneText) {
|
|
217
|
+
lastActivityAt = Date.now();
|
|
218
|
+
if (paneText.length > 0) {
|
|
219
|
+
if (!binaryDetected && isBinaryChunk(paneText)) {
|
|
220
|
+
binaryDetected = true;
|
|
221
|
+
binaryBytes = paneText.length;
|
|
222
|
+
this.log.warn(`[${id}] 检测到二进制输出,切换字节计数模式`);
|
|
223
|
+
} else if (binaryDetected) {
|
|
224
|
+
binaryBytes = paneText.length;
|
|
225
|
+
} else {
|
|
226
|
+
const delta = this.computeDelta(lastPaneText, paneText);
|
|
227
|
+
if (delta.length > 0) buffer.append(delta);
|
|
228
|
+
if (opts.onProgress && !paneText.includes(markerPrefix)) {
|
|
229
|
+
try {
|
|
230
|
+
opts.onProgress({ tail: buffer.readTail(), stats: buffer.stats });
|
|
231
|
+
} catch { /* 进度回调失败不阻断 */ }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
lastPaneText = paneText;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (paneText.includes(markerPrefix)) {
|
|
239
|
+
const exitCode = this.parseExitCode(paneText, markerPrefix);
|
|
240
|
+
if (exitCode !== null) {
|
|
241
|
+
if (binaryDetected) {
|
|
242
|
+
return { stdout: `[binary output: ${binaryBytes} bytes omitted]`, exitCode };
|
|
243
|
+
}
|
|
244
|
+
const stdout = this.stripMarkerNoise(buffer.readLogText(), markerPrefix, markerEchoNeedle);
|
|
245
|
+
return { stdout, exitCode };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 交互 Prompt 检测(无 marker 时;仅文本末尾匹配)
|
|
250
|
+
if (opts.responder && !paneText.includes(markerPrefix)) {
|
|
251
|
+
const payload = this.detectPrompt(paneText, id);
|
|
252
|
+
if (payload) {
|
|
253
|
+
this.log.info(`[${id}] 检测到交互 Prompt:${payload.metadata.prompt}`);
|
|
254
|
+
const reply = await opts.responder.ask(payload);
|
|
255
|
+
if (reply) {
|
|
256
|
+
await this.sendKeys(id, reply);
|
|
257
|
+
await sleep(800);
|
|
258
|
+
lastPaneText = "";
|
|
259
|
+
lastActivityAt = Date.now();
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 中途死亡检测(存活=心跳,重置不活动定时器)
|
|
266
|
+
try {
|
|
267
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
268
|
+
alive = r.exitCode === 0;
|
|
269
|
+
} catch {
|
|
270
|
+
alive = false;
|
|
271
|
+
}
|
|
272
|
+
if (!alive) {
|
|
273
|
+
throw new CommandExecError(
|
|
274
|
+
`tmux session '${id}' died while waiting for command to complete.`,
|
|
275
|
+
this.extractPartialOutput(buffer, markerPrefix, markerEchoNeedle, binaryDetected, binaryBytes),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
lastActivityAt = Date.now();
|
|
279
|
+
await sleep(cfg.poll_interval_ms);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 交互 Prompt 检测(末尾锚定,避免输出中段字样误判) */
|
|
284
|
+
static detectPrompt(paneText: string, terminalId: string): PromptPayload | null {
|
|
285
|
+
if (!paneText) return null;
|
|
286
|
+
const re = compilePromptPatterns(this.cfg.prompt_patterns);
|
|
287
|
+
const m = paneText.match(re);
|
|
288
|
+
if (!m) return null;
|
|
289
|
+
const prompt = m[0].trim();
|
|
290
|
+
return {
|
|
291
|
+
type: "CONFIRM",
|
|
292
|
+
title: "Shell Interaction Needed",
|
|
293
|
+
message: `The command is requesting input: "${prompt}"`,
|
|
294
|
+
metadata: { terminalId, prompt, platform: "terminal" },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private static parseExitCode(paneText: string, markerPrefix: string): number | null {
|
|
299
|
+
for (const line of paneText.split("\n")) {
|
|
300
|
+
if (!line.includes(markerPrefix)) continue;
|
|
301
|
+
if (line.includes(`echo ${markerPrefix}`)) continue;
|
|
302
|
+
const match = line.match(new RegExp(`${markerPrefix}(\\d+)`));
|
|
303
|
+
if (match) return parseInt(match[1] ?? "0", 10);
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 增量 delta:新快照以上次为前缀 → 尾部;归一 \r 后再试;漂移则整屏(宁重复不丢) */
|
|
309
|
+
private static computeDelta(prev: string, curr: string): string {
|
|
310
|
+
if (prev.length === 0) return curr;
|
|
311
|
+
if (curr.startsWith(prev)) return curr.slice(prev.length);
|
|
312
|
+
const normPrev = prev.replace(/\r/g, "");
|
|
313
|
+
const normCurr = curr.replace(/\r/g, "");
|
|
314
|
+
if (normCurr.startsWith(normPrev)) return curr.slice(prev.length);
|
|
315
|
+
return curr;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 剥 marker/echo 噪声得纯净 stdout(marker 行起全停) */
|
|
319
|
+
private static stripMarkerNoise(log: string, markerPrefix: string, markerEchoNeedle: string): string {
|
|
320
|
+
const resultLines: string[] = [];
|
|
321
|
+
for (const line of log.split("\n")) {
|
|
322
|
+
if (line.includes(markerPrefix)) break;
|
|
323
|
+
if (line.includes(markerEchoNeedle)) continue;
|
|
324
|
+
if (/__FX_DRAIN_[0-9a-z]+/.test(line)) continue; // DRAINING 探测哨兵行
|
|
325
|
+
if (/stty -echo/.test(line)) continue; // 初始化残章
|
|
326
|
+
if (/\$\s*$/.test(line) && /debian_chroot|@\S+[:~]/.test(line)) continue; // hostname 提示符行(PS1 清理竞态兜底)
|
|
327
|
+
if (/^\$\s*$/.test(line)) continue; // 清空后的空提示符
|
|
328
|
+
resultLines.push(line);
|
|
329
|
+
}
|
|
330
|
+
while (resultLines.length > 0 && resultLines[resultLines.length - 1]!.trim() === "") {
|
|
331
|
+
resultLines.pop();
|
|
332
|
+
}
|
|
333
|
+
return resultLines.join("\n").trim();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private static extractPartialOutput(
|
|
337
|
+
buffer: OutputBuffer, markerPrefix: string, markerEchoNeedle: string,
|
|
338
|
+
binaryDetected: boolean, binaryBytes: number,
|
|
339
|
+
): string {
|
|
340
|
+
if (binaryDetected) return `[binary output: ${binaryBytes} bytes omitted]`;
|
|
341
|
+
return this.stripMarkerNoise(buffer.readLogText(), markerPrefix, markerEchoNeedle);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private static async sendKeys(id: string, text: string): Promise<void> {
|
|
345
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "-l", text]);
|
|
346
|
+
await TmuxInvoker.invoke(["send-keys", "-t", id, "C-m"]);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* DRAINING(审查问题 2):超时 ^C 后等可信回提示符证据(有上限)。
|
|
351
|
+
* 两凭证任一先到:① 原命令 marker(^C 后 trap/echo 幸存时)② 独立探测哨兵
|
|
352
|
+
* drainToken(^C 之后新发的 echo——^C 杀整行 job 也不影响它)。
|
|
353
|
+
* 链内调用——串行保证下一条命令在本方法返回/超时后才接手会话。
|
|
354
|
+
*/
|
|
355
|
+
private static async waitForDrain(id: string, markerPrefix: string, drainToken: string, deadlineMs: number): Promise<boolean> {
|
|
356
|
+
const deadline = Date.now() + deadlineMs;
|
|
357
|
+
while (Date.now() < deadline) {
|
|
358
|
+
const pane = await this.capturePane(id, 80);
|
|
359
|
+
if (pane.includes(drainToken)) return true;
|
|
360
|
+
if (pane.includes(markerPrefix) && this.parseExitCode(pane, markerPrefix) !== null) return true;
|
|
361
|
+
await sleep(120);
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private static async capturePane(id: string, scrollback: number): Promise<string> {
|
|
367
|
+
try {
|
|
368
|
+
const r = await TmuxInvoker.invoke(["capture-pane", "-t", id, "-p", "-S", `-${scrollback}`]);
|
|
369
|
+
return r.stdout;
|
|
370
|
+
} catch {
|
|
371
|
+
return "";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal/output-buffer.ts — 三级输出缓冲(净室平移,源锚 8977ff3,纯逻辑零 I/O)
|
|
3
|
+
*
|
|
4
|
+
* pending(增量数组)/ aggregated(留尾串)/ tail(快照)三级;游标读取以
|
|
5
|
+
* UTF-16 码元绝对偏移计,封顶裁头显式报断档(validFrom/lostChars)——
|
|
6
|
+
* 拒绝静默返回不连续内容。
|
|
7
|
+
*/
|
|
8
|
+
import type { OutputStream } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface BufferStats {
|
|
11
|
+
total: number;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ReadLogResult = {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
text: string;
|
|
18
|
+
nextOffset: number;
|
|
19
|
+
validFrom?: number;
|
|
20
|
+
lostChars?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface BufferCaps {
|
|
24
|
+
pendingChars: number;
|
|
25
|
+
aggregatedChars: number;
|
|
26
|
+
tailChars: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class OutputBuffer {
|
|
30
|
+
private pendingStdout: string[] = [];
|
|
31
|
+
private pendingStderr: string[] = [];
|
|
32
|
+
private aggregated = "";
|
|
33
|
+
private tail = "";
|
|
34
|
+
private totalOutputChars = 0;
|
|
35
|
+
private truncated = false;
|
|
36
|
+
private baseOffset = 0;
|
|
37
|
+
|
|
38
|
+
private readonly pendingCap: number;
|
|
39
|
+
private readonly aggregatedCap: number;
|
|
40
|
+
private readonly tailCap: number;
|
|
41
|
+
|
|
42
|
+
constructor(caps: BufferCaps) {
|
|
43
|
+
this.pendingCap = caps.pendingChars;
|
|
44
|
+
this.aggregatedCap = caps.aggregatedChars;
|
|
45
|
+
this.tailCap = caps.tailChars;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
append(chunk: string, stream: OutputStream = "stdout"): void {
|
|
49
|
+
if (chunk.length === 0) return;
|
|
50
|
+
this.totalOutputChars += chunk.length;
|
|
51
|
+
const buf = stream === "stderr" ? this.pendingStderr : this.pendingStdout;
|
|
52
|
+
buf.push(chunk);
|
|
53
|
+
this.trimPending(buf);
|
|
54
|
+
this.aggregated += chunk;
|
|
55
|
+
if (this.aggregated.length > this.aggregatedCap) {
|
|
56
|
+
const dropped = this.aggregated.length - this.aggregatedCap;
|
|
57
|
+
this.aggregated = this.aggregated.slice(-this.aggregatedCap);
|
|
58
|
+
this.baseOffset += dropped;
|
|
59
|
+
this.truncated = true;
|
|
60
|
+
}
|
|
61
|
+
this.tail = this.aggregated.slice(-this.tailCap);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 取走并清空未读增量 */
|
|
65
|
+
drainPending(): string {
|
|
66
|
+
const out = this.pendingStdout.join("");
|
|
67
|
+
const err = this.pendingStderr.join("");
|
|
68
|
+
this.pendingStdout = [];
|
|
69
|
+
this.pendingStderr = [];
|
|
70
|
+
return out + (err ? `\n[stderr]\n${err}` : "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
readTail(): string {
|
|
74
|
+
return this.tail;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 分页读取(绝对偏移游标;游标过期显式报断档) */
|
|
78
|
+
readLog(offset = 0, limit?: number): ReadLogResult {
|
|
79
|
+
if (offset < this.baseOffset) {
|
|
80
|
+
const lostChars = this.baseOffset - offset;
|
|
81
|
+
const text = limit === undefined ? this.aggregated : this.aggregated.slice(0, limit);
|
|
82
|
+
return { ok: false, text, nextOffset: this.baseOffset + text.length, validFrom: this.baseOffset, lostChars };
|
|
83
|
+
}
|
|
84
|
+
const start = offset - this.baseOffset;
|
|
85
|
+
const text = limit === undefined
|
|
86
|
+
? this.aggregated.slice(start)
|
|
87
|
+
: this.aggregated.slice(start, start + limit);
|
|
88
|
+
return { ok: true, text, nextOffset: offset + text.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 便捷视图:丢失时前置断档提示行 */
|
|
92
|
+
readLogText(offset = 0, limit?: number): string {
|
|
93
|
+
const r = this.readLog(offset, limit);
|
|
94
|
+
if (!r.ok) {
|
|
95
|
+
return `[游标已过期] 此前约 ${r.lostChars} 字符已被裁掉,以下从绝对偏移 ${r.validFrom} 继续\n` + r.text;
|
|
96
|
+
}
|
|
97
|
+
return r.text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get stats(): BufferStats {
|
|
101
|
+
return { total: this.totalOutputChars, truncated: this.truncated };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
hasPending(): boolean {
|
|
105
|
+
return this.pendingStdout.length > 0 || this.pendingStderr.length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
reset(): void {
|
|
109
|
+
this.pendingStdout = [];
|
|
110
|
+
this.pendingStderr = [];
|
|
111
|
+
this.aggregated = "";
|
|
112
|
+
this.tail = "";
|
|
113
|
+
this.totalOutputChars = 0;
|
|
114
|
+
this.baseOffset = 0;
|
|
115
|
+
this.truncated = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private trimPending(buf: string[]): void {
|
|
119
|
+
let len = buf.reduce((s, c) => s + c.length, 0);
|
|
120
|
+
while (len > this.pendingCap && buf.length > 1) {
|
|
121
|
+
const dropped = buf.shift()!;
|
|
122
|
+
len -= dropped.length;
|
|
123
|
+
this.truncated = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal/session-launcher.ts — 持久 tmux 会话启动器
|
|
3
|
+
* (净室平移,源锚 8977ff3;TerminalConfig 注入化)
|
|
4
|
+
*
|
|
5
|
+
* FR-1:customId 命中存活会话 → 复用返回同 id(跨进程重启恢复连接的语义);
|
|
6
|
+
* 未知 driver 且无 launchCmd → 抛 /Unknown driver/;会话立即死亡 → 清 profile 抛带 hint 错。
|
|
7
|
+
* FR-9 第一道防线:launchCmd 前置 env_hardening_prefix 关交互 Prompt。
|
|
8
|
+
*/
|
|
9
|
+
import { SessionRegistry, type TerminalProfile } from "./session-registry.js";
|
|
10
|
+
import { TmuxInvoker } from "./tmux-invoker.js";
|
|
11
|
+
import { DEFAULT_TERMINAL_CONFIG, type TerminalConfig } from "./types.js";
|
|
12
|
+
import { noopLogger, type FilaxisLogger } from "../logger.js";
|
|
13
|
+
|
|
14
|
+
export interface SessionLauncherOptions {
|
|
15
|
+
config?: Partial<TerminalConfig>;
|
|
16
|
+
logger?: FilaxisLogger;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
20
|
+
|
|
21
|
+
export class SessionLauncher {
|
|
22
|
+
private readonly cfg: TerminalConfig;
|
|
23
|
+
private readonly log: FilaxisLogger;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly registry: SessionRegistry,
|
|
27
|
+
opts: SessionLauncherOptions = {},
|
|
28
|
+
) {
|
|
29
|
+
this.cfg = { ...DEFAULT_TERMINAL_CONFIG, ...opts.config };
|
|
30
|
+
this.log = opts.logger ?? noopLogger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 启动(或复用)一个持久 tmux 会话;返回存活会话 id */
|
|
34
|
+
async launch(
|
|
35
|
+
driver: string,
|
|
36
|
+
target: string | undefined,
|
|
37
|
+
config?: Record<string, unknown>,
|
|
38
|
+
customId?: string,
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
const id = customId || ("term_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 6));
|
|
41
|
+
const launchCmd = this.resolveLaunchCmd(driver, target, config);
|
|
42
|
+
|
|
43
|
+
// customId 复用:会话仍存活直接返回(profile 按当前入参刷新)
|
|
44
|
+
try {
|
|
45
|
+
const alive = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
46
|
+
if (alive.exitCode === 0) {
|
|
47
|
+
this.log.info(`会话 [${id}] 已存在于 tmux,复用`);
|
|
48
|
+
const profile: TerminalProfile = { id, driver, target: target ?? "", config, createdAt: Date.now() };
|
|
49
|
+
await this.registry.save(profile);
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
} catch { /* tmux 不可用——继续走 new-session 由其报错 */ }
|
|
53
|
+
|
|
54
|
+
const finalCmd = this.cfg.env_hardening_prefix
|
|
55
|
+
? `${this.cfg.env_hardening_prefix}; ${launchCmd}`
|
|
56
|
+
: launchCmd;
|
|
57
|
+
this.log.info(`启动持久会话 [${id}] (driver: ${driver})`);
|
|
58
|
+
// -x 220:pane 宽列——输出长行不被 80 列折断(marker 解析/stout 纯净度契约依赖整行)
|
|
59
|
+
await TmuxInvoker.invoke(["new-session", "-d", "-x", "220", "-s", id, finalCmd]);
|
|
60
|
+
|
|
61
|
+
const ok = await this.waitForAlive(id, driver);
|
|
62
|
+
if (!ok) {
|
|
63
|
+
await this.registry.remove(id);
|
|
64
|
+
throw new Error(this.diedHint(id, driver, target));
|
|
65
|
+
}
|
|
66
|
+
const profile: TerminalProfile = { id, driver, target: target ?? "", config, createdAt: Date.now() };
|
|
67
|
+
await this.registry.save(profile);
|
|
68
|
+
return id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 已知 driver 快捷方式渲染(与 RuntimeDescriptor 注释一致) */
|
|
72
|
+
resolveLaunchCmd(driver: string, target: string | undefined, config?: Record<string, unknown>): string {
|
|
73
|
+
const c = config ?? {};
|
|
74
|
+
if (typeof c.launchCmd === "string" && c.launchCmd) return c.launchCmd;
|
|
75
|
+
if (driver === "ssh" && target) {
|
|
76
|
+
const sshOpts = typeof c.sshOptions === "string" ? c.sshOptions : "";
|
|
77
|
+
return `ssh ${sshOpts} -t ${target}`;
|
|
78
|
+
}
|
|
79
|
+
if (driver === "docker" && target) {
|
|
80
|
+
const runtime = typeof c.runtime === "string" ? c.runtime : "docker";
|
|
81
|
+
const shell = typeof c.shell === "string" ? c.shell : "sh";
|
|
82
|
+
return `${runtime} exec -i ${target} ${shell} -i`;
|
|
83
|
+
}
|
|
84
|
+
if (driver === "k8s" && target) {
|
|
85
|
+
const runtime = typeof c.runtime === "string" ? c.runtime : "kubectl";
|
|
86
|
+
const ns = typeof c.namespace === "string" ? `-n ${c.namespace}` : "";
|
|
87
|
+
const shell = typeof c.shell === "string" ? c.shell : "sh";
|
|
88
|
+
return `${runtime} exec -i ${ns} ${target} -- ${shell} -i`;
|
|
89
|
+
}
|
|
90
|
+
if (driver === "teleport" && target) {
|
|
91
|
+
const cluster = typeof c.cluster === "string" ? `--cluster=${c.cluster} ` : "";
|
|
92
|
+
const login = typeof c.login === "string" ? `${c.login}@` : "";
|
|
93
|
+
return `tsh ssh ${cluster}${login}${target}`;
|
|
94
|
+
}
|
|
95
|
+
if (driver === "local") return "bash";
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Unknown driver '${driver}' and no config.launchCmd provided. ` +
|
|
98
|
+
`Supply config.launchCmd with the full command to enter the environment ` +
|
|
99
|
+
`(e.g. "nix develop", "conda run -n myenv bash", "distrobox enter my-box").`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 存活轮询(远端 driver 更长 deadline + dead-streak 容忍瞬时抖动) */
|
|
104
|
+
private async waitForAlive(id: string, driver: string): Promise<boolean> {
|
|
105
|
+
const isLocal = driver === "local";
|
|
106
|
+
const maxWaitMs = isLocal ? this.cfg.spawn_max_wait_ms_local : this.cfg.spawn_max_wait_ms_remote;
|
|
107
|
+
const maxDeadStreak = isLocal ? 1 : 3;
|
|
108
|
+
const deadline = Date.now() + maxWaitMs;
|
|
109
|
+
let deadStreak = 0;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
await sleep(this.cfg.spawn_poll_ms);
|
|
112
|
+
let alive: boolean;
|
|
113
|
+
try {
|
|
114
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
115
|
+
alive = r.exitCode === 0;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (!alive) {
|
|
120
|
+
deadStreak++;
|
|
121
|
+
if (deadStreak >= maxDeadStreak) return false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
deadStreak = 0;
|
|
125
|
+
const paneOut = await TmuxInvoker.tryInvoke(["capture-pane", "-t", id, "-p"]);
|
|
126
|
+
if (paneOut.stdout !== "") return true;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const r = await TmuxInvoker.tryInvoke(["has-session", "-t", id]);
|
|
130
|
+
return r.exitCode === 0;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private diedHint(id: string, driver: string, target: string | undefined): string {
|
|
137
|
+
const hint = driver === "docker"
|
|
138
|
+
? `Make sure the container '${target}' is running ('docker ps'). Use the container name/ID, not the image name.`
|
|
139
|
+
: driver === "ssh"
|
|
140
|
+
? `Check that '${target}' is reachable and SSH credentials are valid.`
|
|
141
|
+
: driver === "teleport"
|
|
142
|
+
? `Check 'tsh status' — a valid Teleport login is required.`
|
|
143
|
+
: "The shell exited immediately after launch.";
|
|
144
|
+
return `Session '${id}' (driver: ${driver}) died immediately after spawn. ${hint}`;
|
|
145
|
+
}
|
|
146
|
+
}
|