@syrkos/filaxis 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +46 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +10 -0
  4. package/README.md +61 -0
  5. package/bin/filaxis.ts +112 -0
  6. package/dist/agent-toolkit.d.ts +99 -0
  7. package/dist/dialect.d.ts +31 -0
  8. package/dist/driver.d.ts +130 -0
  9. package/dist/events.d.ts +26 -0
  10. package/dist/factory.d.ts +16 -0
  11. package/dist/file-edit.d.ts +43 -0
  12. package/dist/file-transfer.d.ts +44 -0
  13. package/dist/index.d.ts +38 -0
  14. package/dist/index.js +4200 -0
  15. package/dist/layer-discovery.d.ts +30 -0
  16. package/dist/layer-env.d.ts +95 -0
  17. package/dist/local-driver.d.ts +47 -0
  18. package/dist/local-session-manager.d.ts +98 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/remote-session-manager.d.ts +64 -0
  21. package/dist/runtime-stack.d.ts +52 -0
  22. package/dist/session-buffer.d.ts +73 -0
  23. package/dist/session-types.d.ts +74 -0
  24. package/dist/shell-discovery.d.ts +4 -0
  25. package/dist/ssh-helpers.d.ts +88 -0
  26. package/dist/ssh-pty-driver.d.ts +74 -0
  27. package/dist/stream-session-manager.d.ts +83 -0
  28. package/dist/stream-window.d.ts +124 -0
  29. package/dist/terminal/command-executer.d.ts +73 -0
  30. package/dist/terminal/output-buffer.d.ts +49 -0
  31. package/dist/terminal/session-launcher.d.ts +28 -0
  32. package/dist/terminal/session-registry.d.ts +29 -0
  33. package/dist/terminal/tmux-invoker.d.ts +8 -0
  34. package/dist/terminal/types.d.ts +56 -0
  35. package/dist/terminal-manager.d.ts +30 -0
  36. package/dist/tmux-driver.d.ts +49 -0
  37. package/dist/vt100-screen.d.ts +46 -0
  38. package/docs/DEVELOPMENT.md +38 -0
  39. package/docs/KNOWN-ISSUES.md +35 -0
  40. package/docs/implementation-notes.md +57 -0
  41. package/docs/skills/cross-host-diagnosis.md +21 -0
  42. package/docs/skills/file-sync.md +22 -0
  43. package/docs/skills/longrun-observe.md +20 -0
  44. package/docs/skills/stream-window.md +28 -0
  45. package/docs//345/256/214/346/225/264/345/267/241/346/243/200/346/212/245/345/221/212.md +141 -0
  46. package/docs//346/265/213/350/257/2252.md +112 -0
  47. package/docs//346/265/213/350/257/2253.md +73 -0
  48. package/docs//346/265/213/350/257/2254.md +138 -0
  49. package/docs//346/265/213/350/257/2255.md +124 -0
  50. package/docs//347/254/254/344/272/214/344/273/275/345/267/241/346/243/200/346/212/245/345/221/212.md +103 -0
  51. package/examples/agent-tools.ts +74 -0
  52. package/examples/cross-host-diagnosis.ts +49 -0
  53. package/examples/longrun-observe.ts +46 -0
  54. package/package.json +47 -0
  55. package/src/agent-toolkit.ts +247 -0
  56. package/src/dialect.ts +100 -0
  57. package/src/driver.ts +142 -0
  58. package/src/events.ts +96 -0
  59. package/src/factory.ts +51 -0
  60. package/src/file-edit.ts +195 -0
  61. package/src/file-transfer.ts +196 -0
  62. package/src/index.ts +38 -0
  63. package/src/layer-discovery.ts +79 -0
  64. package/src/layer-env.ts +331 -0
  65. package/src/local-driver.ts +325 -0
  66. package/src/local-session-manager.ts +397 -0
  67. package/src/logger.ts +19 -0
  68. package/src/remote-session-manager.ts +286 -0
  69. package/src/runtime-stack.ts +128 -0
  70. package/src/session-buffer.ts +183 -0
  71. package/src/session-types.ts +78 -0
  72. package/src/shell-discovery.ts +52 -0
  73. package/src/ssh-helpers.ts +225 -0
  74. package/src/ssh-pty-driver.ts +655 -0
  75. package/src/stream-session-manager.ts +296 -0
  76. package/src/stream-window.ts +309 -0
  77. package/src/terminal/command-executer.ts +374 -0
  78. package/src/terminal/output-buffer.ts +126 -0
  79. package/src/terminal/session-launcher.ts +146 -0
  80. package/src/terminal/session-registry.ts +98 -0
  81. package/src/terminal/tmux-invoker.ts +65 -0
  82. package/src/terminal/types.ts +85 -0
  83. package/src/terminal-manager.ts +83 -0
  84. package/src/tmux-driver.ts +167 -0
  85. package/src/vt100-screen.ts +166 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * file-edit.ts — 受控文件编辑(远端 CAS 原子写回 + 合成 SSH 往返)
3
+ *
4
+ * 第六季盲测实锤:SKILL 教了受控编辑但 SDK 无一级导出——Agent 读 7 轮源码自研
5
+ * 20 行样板(50 万 cache tokens);裸 SSH 组则 sed -i 两翻车并写坏远端配置。
6
+ *
7
+ * 第七季盲测反馈(B2 逐秒拆解):旧实现一次 editFile = 5 次串行 SSH 往返
8
+ * (stat/read/stat/write/stat),Windows 无 ControlMaster 下放大到 15s+。
9
+ * 本版:合成 **2 次往返**(读+戳 1 次;**远端 CAS 原子写回** 1 次——GPT 并发
10
+ * 安全意见采纳:「最后一次校验与写入放进同一个远端受控操作」——戳比对与写回
11
+ * 在远端一条命令内原子完成,杜绝 A/B 丢失更新)+ 方言适配(stat/base64 三族,
12
+ * 每 driver 缓存一次探测)。大文件(base64>30KB)自动回落旧 5 次路径(fs 层)。
13
+ *
14
+ * **仅适用 UTF-8 文本**(readFile 返回 UTF-8 字符串;二进制文件的编辑走
15
+ * readBase64 + 消费方自行改写 + writeFile)。
16
+ */
17
+ import type { RuntimeDriver } from "./driver.js";
18
+
19
+ export interface EditFileOptions {
20
+ /** 多处命中时全替换(缺省 false——多处命中报错,精确替换语义) */
21
+ all?: boolean;
22
+ }
23
+
24
+ export interface EditFileResult {
25
+ path: string;
26
+ occurrences: number;
27
+ bytesBefore: number;
28
+ bytesAfter: number;
29
+ /** 写前锁核验的 mtimeMs(CAS 远端原子比对用) */
30
+ lockedAtMtimeMs: number;
31
+ }
32
+
33
+ export class EditConflictError extends Error {
34
+ constructor(message: string) {
35
+ super(message);
36
+ this.name = "EditConflictError";
37
+ }
38
+ }
39
+
40
+ const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
41
+
42
+ /** 单引号安全检查(base64/stamp 等嵌入 shell 引号前的红线) */
43
+ const shellSafe = (s: string) => !s.includes("'") && !s.includes("\n");
44
+
45
+ interface ShellDialect {
46
+ statCmd: string; // 戳命令(输出 "mtime size")
47
+ enc: string; // base64 编码 argv(无换行)
48
+ dec: string; // base64 解码 argv
49
+ /** 内容指纹命令模板({f}=文件——三族 fallback 链:sha256sum → sha256 -q → openssl;
50
+ * CAS 戳的内容分量:stat %Y 秒级粒度有同秒同 size 盲区——内容指纹封死) */
51
+ shaCmd: string;
52
+ }
53
+
54
+ /**
55
+ * stat 戳命令模板({f}=文件——行为 fallback 链,不探版本:GNU -c 优先、
56
+ * 失败自动 BSD -f——CI 实证版本探测在 Ubuntu 误判走 BSD 分支)。
57
+ */
58
+ const statCmdOf = (f: string) => `stat -c '%Y %s' ${f} 2>/dev/null || stat -f '%m %z' ${f} 2>/dev/null`;
59
+
60
+ /** 每 driver 的方言缓存(key=driver 描述符投影——仅 base64 族) */
61
+ const dialectCache = new Map<string, ShellDialect>();
62
+
63
+ async function dialectOf(drv: RuntimeDriver): Promise<ShellDialect> {
64
+ const key = JSON.stringify(drv.descriptor);
65
+ const hit = dialectCache.get(key);
66
+ if (hit) return hit;
67
+ // 一次往返探测 base64 族(-w 0 GNU / 解码 -d vs -D)
68
+ const probe = await drv.exec(
69
+ "echo FX2=$(printf filaxis | base64 -w 0 2>/dev/null | head -c 8);" +
70
+ "echo FX3=$(printf ZmlsYXhpcw== | base64 -d 2>/dev/null | head -c 7);" +
71
+ "echo FX4=$(printf ZmlsYXhpcw== | base64 -D 2>/dev/null | head -c 7)",
72
+ { timeoutMs: 15_000 },
73
+ );
74
+ const out = probe.stdout || "";
75
+ const b64Gnu = /FX2=ZmlsYXhp/.test(out);
76
+ const decD = /FX4=filaxis/.test(out);
77
+ const d: ShellDialect = {
78
+ statCmd: "", // 行为 fallback(statCmdOf)——不走探测
79
+ enc: b64Gnu ? "base64 -w 0" : "base64",
80
+ dec: decD ? "base64 -D" : "base64 -d",
81
+ shaCmd: `{ sha256sum ${"{f}"} 2>/dev/null || sha256 -q ${"{f}"} 2>/dev/null || openssl dgst -sha256 ${"{f}"} 2>/dev/null | tail -1; } | head -c 12`,
82
+ };
83
+ dialectCache.set(key, d);
84
+ return d;
85
+ }
86
+
87
+ /**
88
+ * 受控编辑(合成 2 次往返 + 远端 CAS 原子写回):
89
+ * ① 读+戳(1 次往返):`stat 戳; __FXS__; base64 读` —— 拿 (mtime,size)+全文
90
+ * ② 精确替换(本地):0 命中/多处命中(未开 all)拒绝
91
+ * ③ CAS 原子写回(1 次往返):远端一条命令内完成「戳比对 + tmp 写 + mv 原子替换 + 新戳回执」
92
+ * —— 戳不符(他人先写)→ EditConflictError;写后回执新戳
93
+ *
94
+ * @throws EditConflictError TOCTOU 冲突(CAS 戳不符)
95
+ * @throws Error 未命中 / 多处命中(未开 all)/ 远端层无 stat 能力
96
+ */
97
+ export async function editFile(
98
+ drv: RuntimeDriver,
99
+ path: string,
100
+ oldStr: string,
101
+ newStr: string,
102
+ opts: EditFileOptions = {},
103
+ ): Promise<EditFileResult> {
104
+ const dialect = await dialectOf(drv);
105
+ // 读+戳 合成一次往返(读时戳与读到的内容同窗——读后他人写 → CAS 必不符)
106
+ const qp = q(path);
107
+ const readCmd = `${statCmdOf(qp)}; ${dialect.shaCmd.replace(/\{f\}/g, qp)}; echo __FXS__; ${dialect.enc} ${qp} 2>/dev/null`;
108
+ const r = await drv.exec(readCmd, { timeoutMs: 30_000 });
109
+ const [stampPart, b64Part] = (r.stdout || "").split("__FXS__");
110
+ const originStampRaw = (stampPart ?? "").trim();
111
+ const originLines = originStampRaw.split(/\n/);
112
+ const statStamp = (originLines[0] ?? "").trim();
113
+ const shaStamp = (originLines[1] ?? "").trim();
114
+ if (!shellSafe(statStamp) || !shellSafe(shaStamp)) {
115
+ throw new Error(`远端戳异常(stat/sha256 输出非单行): ${path}`);
116
+ }
117
+ const originParts = statStamp.split(/\s+/);
118
+ const origin = {
119
+ mtimeMs: parseInt(originParts[0] ?? "0", 10) * 1000,
120
+ size: parseInt(originParts[1] ?? "0", 10),
121
+ };
122
+ if (!origin.size || !shaStamp) throw new Error(`文件不存在或指纹异常: ${path}(${originStampRaw})`);
123
+ const content0 = Buffer.from((b64Part ?? "").replace(/\s+/g, ""), "base64").toString("utf-8");
124
+
125
+ const occurrences = content0.split(oldStr).length - 1;
126
+ if (occurrences === 0) {
127
+ // 安全红线:oldStr 可能含敏感信息(token/密码)——错误信息不嵌入原文,只给长度与行数线索
128
+ throw new Error(`未命中待替换片段(0 处): ${path}(oldStr 长度 ${oldStr.length},文件 ${content0.length} 字符)`);
129
+ }
130
+ if (occurrences > 1 && !opts.all) {
131
+ throw new Error(`多处命中(${occurrences} 处)——精确替换语义拒绝;确认全替换请传 { all: true }: ${path}`);
132
+ }
133
+ const edited = opts.all ? content0.split(oldStr).join(newStr) : content0.replace(oldStr, newStr);
134
+
135
+ const newB64 = Buffer.from(edited, "utf-8").toString("base64");
136
+ if (newB64.length > 30_000) {
137
+ // 大文件回落 fs 层旧路径(CAS 单命令超过远端 ARG_MAX 安全线)
138
+ return editFileViaFs(drv, path, edited, occurrences);
139
+ }
140
+
141
+ const cmd =
142
+ `if [ "$(${statCmdOf(qp)})" = "${statStamp}" ] && ` +
143
+ `[ "$(${dialect.shaCmd.replace(/\{f\}/g, qp)})" = "${shaStamp}" ]; then ` +
144
+ `printf %s ${q(newB64)} | ${dialect.dec} > ${qp}.fx-tmp && mv ${qp}.fx-tmp ${qp} && ` +
145
+ `echo "FX_OK $(stat -c '%Y %s' ${qp} 2>/dev/null || stat -f '%m %z' ${qp} 2>/dev/null)"; ` +
146
+ `else echo FX_CAS_FAIL; fi`;
147
+
148
+ const cas = await drv.exec(cmd, { timeoutMs: 30_000 });
149
+ const out = (cas.stdout || "").trim();
150
+ if (out.startsWith("FX_CAS_FAIL")) {
151
+ throw new EditConflictError(
152
+ `TOCTOU 冲突:CAS 戳不符(他人先写): ${path}(读时 stat ${statStamp} sha ${shaStamp})`,
153
+ );
154
+ }
155
+ if (!out.startsWith("FX_OK")) {
156
+ throw new Error(`远端 CAS 写回异常: ${path}(输出 ${out.slice(0, 80)})`);
157
+ }
158
+ return {
159
+ path,
160
+ occurrences,
161
+ bytesBefore: origin.size,
162
+ bytesAfter: edited.length,
163
+ lockedAtMtimeMs: origin.mtimeMs,
164
+ };
165
+ }
166
+
167
+ /** 大文件回落路径(fs 层 5 次往返——语义同 CAS:精确替换+TOCTOU+base64 写回) */
168
+ async function editFileViaFs(
169
+ drv: RuntimeDriver,
170
+ path: string,
171
+ edited: string,
172
+ occurrences: number,
173
+ ): Promise<EditFileResult> {
174
+ if (!drv.fs.stat) {
175
+ throw new Error(`该层不支持受控编辑(fs.stat 缺席——TOCTOU 锁依赖): ${path}`);
176
+ }
177
+ const before = await drv.fs.stat(path);
178
+ if (!before) throw new Error(`文件不存在: ${path}`);
179
+ const lockCheck = await drv.fs.stat(path);
180
+ if (!lockCheck || lockCheck.mtimeMs !== before.mtimeMs || lockCheck.size !== before.size) {
181
+ throw new EditConflictError(
182
+ `TOCTOU 冲突:读取后文件已被他人修改(mtime ${before.mtimeMs}→${lockCheck?.mtimeMs}): ${path}`,
183
+ );
184
+ }
185
+ await drv.fs.writeFile(path, edited);
186
+ const after = await drv.fs.stat(path);
187
+ if (after && before.mtimeMs === after.mtimeMs && before.size === after.size) {
188
+ if (edited.length !== after.size) {
189
+ throw new Error(`写后回读异常(size 未随写入变化): ${path}`);
190
+ }
191
+ }
192
+ return {
193
+ path, occurrences, bytesBefore: before.size, bytesAfter: edited.length, lockedAtMtimeMs: before.mtimeMs,
194
+ };
195
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * file-transfer.ts — 跨机文件传输 get/put(FEAT-14 / AC-15)
3
+ *
4
+ * 实现分级降级(PRD FEAT-14):
5
+ * ① base64 管道(本文件主路径)——非 PTY exec 通道,跨 Transport 一致
6
+ * (docker/k8s 无 sftp;分块 append + decode 落位 + size/sha256 双校验)
7
+ * ② sftp 子进程快路径——仅 SSH Transport 大文件(CI sshd 矩阵验证;此处留
8
+ * 扩展点,调用方经 stack.features().sftp 判定后自行接入)
9
+ *
10
+ * 方言适配(dialect.ts 接线):base64/sha256 原语按连接探测缓存渲染——
11
+ * OPNsense(BSD 无 -w)/Alpine(busybox)不再必错(Aethelum tmux-driver
12
+ * base64 -w 0 生产实锤的根治件)。
13
+ *
14
+ * exec 注入回调——零 I/O 假设,mock 全测。
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import fs from "node:fs/promises";
18
+ import type { RemoteDialect } from "./dialect.js";
19
+
20
+ /** 远端执行回调(通常是 driver 的 exec 通道) */
21
+ export type TransferExec = (command: string) => Promise<{ stdout: string; exitCode: number }>;
22
+
23
+ /** put 分块大小(base64 字符数——单条命令行安全长度内) */
24
+ const CHUNK_B64 = 48 * 1024;
25
+
26
+ export interface TransferResult {
27
+ bytes: number;
28
+ /** 远端回读核实的 sha256(与本地一致才返回成功) */
29
+ sha256: string;
30
+ /** 校验是否通过 */
31
+ verified: boolean;
32
+ }
33
+
34
+ export class TransferVerificationError extends Error {
35
+ constructor(op: "put" | "get", detail: string) {
36
+ super(`文件传输校验失败(${op}): ${detail}`);
37
+ this.name = "TransferVerificationError";
38
+ }
39
+ }
40
+
41
+ const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
42
+
43
+ /**
44
+ * 上行 put:本地 → 远端。分块 base64 append 到远端临时文件 → 方言 decode 落位
45
+ * → size + sha256 回读双校验。任何一步退出码非零 → 抛错(落位原子性:先临时
46
+ * 文件后 mv 语义——失败不污染目标)。
47
+ */
48
+ export async function putFile(
49
+ exec: TransferExec,
50
+ dialect: RemoteDialect,
51
+ localPath: string,
52
+ remotePath: string,
53
+ ): Promise<TransferResult> {
54
+ if (!dialect.base64Decode.length) {
55
+ throw new Error("远端 base64 原语不可用(方言探测 unknown)——该路径不支持文件传输");
56
+ }
57
+ const content = await fs.readFile(localPath);
58
+ const b64 = content.toString("base64");
59
+ const tmp = `${remotePath}.filaxis-tmp`;
60
+
61
+ await exec(`rm -f ${q(tmp)}`);
62
+ for (let i = 0; i < b64.length; i += CHUNK_B64) {
63
+ const chunk = b64.slice(i, i + CHUNK_B64);
64
+ const r = await exec(`printf %s ${q(chunk)} >> ${q(tmp)}`);
65
+ if (r.exitCode !== 0) throw new Error(`put 分块写入失败(chunk ${i / CHUNK_B64}): ${r.stdout}`);
66
+ }
67
+ // 落位:方言 decode → 目标(原子替换语义)
68
+ const dec = dialect.base64Decode.join(" ");
69
+ const mv = await exec(`${dec} < ${q(tmp)} > ${q(remotePath)} && rm -f ${q(tmp)}`);
70
+ if (mv.exitCode !== 0) {
71
+ await exec(`rm -f ${q(tmp)}`).catch(() => undefined);
72
+ throw new Error(`put 落位失败: ${mv.stdout}`);
73
+ }
74
+ // 双校验:size + sha256(方言渲染)
75
+ const localSha = createHash("sha256").update(content).digest("hex");
76
+ const v = await verify(exec, dialect, remotePath, content.length, localSha);
77
+ if (!v.verified) throw new TransferVerificationError("put", v.detail);
78
+ return { bytes: content.length, sha256: localSha, verified: true };
79
+ }
80
+
81
+ /**
82
+ * 下行 get:远端 → 本地。方言 base64 编码回传 → 本地落盘 → sha256 对核。
83
+ */
84
+ export async function getFile(
85
+ exec: TransferExec,
86
+ dialect: RemoteDialect,
87
+ remotePath: string,
88
+ localPath: string,
89
+ ): Promise<TransferResult> {
90
+ if (!dialect.base64Encode.length) {
91
+ throw new Error("远端 base64 原语不可用(方言探测 unknown)——该路径不支持文件传输");
92
+ }
93
+ const enc = dialect.base64Encode.join(" ");
94
+ const r = await exec(`${enc} < ${q(remotePath)}`);
95
+ if (r.exitCode !== 0) throw new Error(`get 编码回传失败: ${r.stdout}`);
96
+ const content = Buffer.from(r.stdout.replace(/\s+/g, ""), "base64");
97
+ await fs.writeFile(localPath, content);
98
+ const localSha = createHash("sha256").update(content).digest("hex");
99
+ const v = await verify(exec, dialect, remotePath, content.length, localSha);
100
+ if (!v.verified) throw new TransferVerificationError("get", v.detail);
101
+ return { bytes: content.length, sha256: localSha, verified: true };
102
+ }
103
+
104
+ /** size + sha256 回读双校验(sha256 原语缺席时降级为仅 size——诚实标注 verified 语义) */
105
+ async function verify(
106
+ exec: TransferExec,
107
+ dialect: RemoteDialect,
108
+ remotePath: string,
109
+ expectSize: number,
110
+ expectSha: string,
111
+ ): Promise<{ verified: boolean; detail: string }> {
112
+ const sz = await exec(`wc -c < ${q(remotePath)}`);
113
+ const size = parseInt((sz.stdout || "").trim(), 10);
114
+ if (size !== expectSize) return { verified: false, detail: `size 不一致(远端 ${size} ≠ 本地 ${expectSize})` };
115
+ if (!dialect.sha256.length) {
116
+ // sha256 原语缺席:size 单校验通过即通过(方言 unknown 的诚实降级)
117
+ return { verified: true, detail: "size-only(sha256 原语缺席)" };
118
+ }
119
+ const sh = await exec(`${dialect.sha256.join(" ")} < ${q(remotePath)}`);
120
+ const remoteSha = (sh.stdout || "").replace(/[^0-9a-f]/g, "");
121
+ if (!remoteSha.includes(expectSha)) {
122
+ return { verified: false, detail: `sha256 不一致(远端 ${remoteSha.slice(0, 16)}… ≠ 本地 ${expectSha.slice(0, 16)}…)` };
123
+ }
124
+ return { verified: true, detail: "size+sha256" };
125
+ }
126
+
127
+ // ════════════════ sftp 快路径(FEAT-14 ②:仅 SSH Transport 大文件) ════════════════
128
+
129
+ import { spawn as defaultSpawn, type ChildProcess } from "node:child_process";
130
+
131
+ export type SftpSpawnImpl = (argv: string[], batch: string) => Promise<{ exitCode: number | null; stderr: string }>;
132
+
133
+ async function defaultSftpSpawn(argv: string[], batch: string): Promise<{ exitCode: number | null; stderr: string }> {
134
+ return new Promise((resolve, reject) => {
135
+ const p: ChildProcess = defaultSpawn("sftp", argv, { stdio: ["pipe", "pipe", "pipe"] });
136
+ let stderr = "";
137
+ p.stderr?.on("data", (d: Buffer) => { stderr += d; });
138
+ p.stdin?.write(batch);
139
+ p.stdin?.end();
140
+ p.on("close", (code) => resolve({ exitCode: code, stderr }));
141
+ p.on("error", reject);
142
+ });
143
+ }
144
+
145
+ export interface SftpOptions {
146
+ /** 连接参数(host key 策略/端口/密钥——与 ssh 通道同源) */
147
+ connArgs: string[];
148
+ /** spawn 注入(缺省真实 sftp 子进程 + `-b -` batch 模式)——mock 全测关键 */
149
+ spawnImpl?: SftpSpawnImpl;
150
+ }
151
+
152
+ /**
153
+ * sftp 快路径 put:`sftp -b -` batch 模式(stdin 喂 `put` 命令)。
154
+ * 成功后仍走 size/sha256 双校验(与 base64 路径同一信任标准——快路径不豁免校验)。
155
+ * 缺席/失败由调用方捕获后降级 base64 管道(PRD FEAT-14 降级链)。
156
+ */
157
+ export async function sftpPutFile(
158
+ exec: TransferExec,
159
+ dialect: RemoteDialect,
160
+ localPath: string,
161
+ remotePath: string,
162
+ opts: SftpOptions,
163
+ ): Promise<TransferResult> {
164
+ const doSpawn = opts.spawnImpl ?? defaultSftpSpawn;
165
+ const batch = `put ${q(localPath)} ${q(remotePath)}\n`;
166
+ const r = await doSpawn([...opts.connArgs, "-b", "-"], batch);
167
+ if (r.exitCode !== 0) {
168
+ throw new Error(`sftp put 失败(exit=${r.exitCode})——可降级 base64 管道: ${r.stderr.trim().slice(0, 200)}`);
169
+ }
170
+ const content = await fs.readFile(localPath);
171
+ const localSha = createHash("sha256").update(content).digest("hex");
172
+ const v = await verify(exec, dialect, remotePath, content.length, localSha);
173
+ if (!v.verified) throw new TransferVerificationError("put", v.detail);
174
+ return { bytes: content.length, sha256: localSha, verified: true };
175
+ }
176
+
177
+ /** sftp 快路径 get(batch `get`;校验同 put) */
178
+ export async function sftpGetFile(
179
+ exec: TransferExec,
180
+ dialect: RemoteDialect,
181
+ remotePath: string,
182
+ localPath: string,
183
+ opts: SftpOptions,
184
+ ): Promise<TransferResult> {
185
+ const doSpawn = opts.spawnImpl ?? defaultSftpSpawn;
186
+ const batch = `get ${q(remotePath)} ${q(localPath)}\n`;
187
+ const r = await doSpawn([...opts.connArgs, "-b", "-"], batch);
188
+ if (r.exitCode !== 0) {
189
+ throw new Error(`sftp get 失败(exit=${r.exitCode})——可降级 base64 管道: ${r.stderr.trim().slice(0, 200)}`);
190
+ }
191
+ const content = await fs.readFile(localPath);
192
+ const localSha = createHash("sha256").update(content).digest("hex");
193
+ const v = await verify(exec, dialect, remotePath, content.length, localSha);
194
+ if (!v.verified) throw new TransferVerificationError("get", v.detail);
195
+ return { bytes: content.length, sha256: localSha, verified: true };
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 牵丝 Filaxis — 面向自动化与 Agent 的远程执行运行时 SDK
3
+ * 千里一线,执行有迹。—— Connect · Execute · Observe · Reattach
4
+ *
5
+ * M1 第一批出口:契约与纯逻辑件。驱动实现(ssh/local/tmux)、会话管理器、
6
+ * 事件总线按里程碑分批落位(REQ-0001 M1-M4)。
7
+ */
8
+ export * from "./driver.js";
9
+ export * from "./session-types.js";
10
+ export * from "./session-buffer.js";
11
+ export * from "./logger.js";
12
+ export * from "./local-session-manager.js";
13
+ export * from "./local-driver.js";
14
+ export * from "./events.js";
15
+ export * from "./factory.js";
16
+ export * from "./runtime-stack.js";
17
+ export * from "./dialect.js";
18
+ export * from "./ssh-helpers.js";
19
+ export * from "./remote-session-manager.js";
20
+ export * from "./ssh-pty-driver.js";
21
+ export * from "./tmux-driver.js";
22
+ export * from "./terminal-manager.js";
23
+ export { DEFAULT_TERMINAL_CONFIG, compilePromptPatterns, TmuxError } from "./terminal/types.js";
24
+ export type { TerminalConfig, PromptPayload, PromptResponder, TmuxResult } from "./terminal/types.js";
25
+ export * from "./terminal/output-buffer.js";
26
+ export * from "./stream-session-manager.js";
27
+ export * from "./file-transfer.js";
28
+ export * from "./layer-env.js";
29
+ export { resolveLocalShell } from "./shell-discovery.js";
30
+ export { createAgentToolkit } from "./agent-toolkit.js";
31
+ export type { AgentToolkit, AgentToolDef, AgentToolHandlers, AgentToolkitOptions } from "./agent-toolkit.js";
32
+ export { discoverLayer } from "./layer-discovery.js";
33
+ export type { DiscoverLayerOptions, DiscoverLayerResult } from "./layer-discovery.js";
34
+ export * from "./stream-window.js";
35
+ export { Vt100Screen } from "./vt100-screen.js";
36
+ export type { Vt100ScreenOptions } from "./vt100-screen.js";
37
+ export { editFile, EditConflictError } from "./file-edit.js";
38
+ export type { EditFileOptions, EditFileResult } from "./file-edit.js";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * layer-discovery.ts — 异步层环境探测助手(冷启动锦囊之异步侧)
3
+ *
4
+ * 探测不阻塞建层:`discoverLayer(driver)` 返回 Promise,消费方(自发现编排/
5
+ * 观察窗)自行选择 fire-and-forget 或 await——建层与探测解耦。
6
+ * 两段式(stage1 快 / stage2 慢包导入)+ 蒸馏 + 落盘(stale 防漂移)+ 可选跳过 stage2。
7
+ */
8
+ import {
9
+ buildProbeStage1,
10
+ buildProbeStage2,
11
+ parseProbeOutput,
12
+ layerEnvKey,
13
+ layerFingerprint,
14
+ projectConnection,
15
+ LayerEnvStore,
16
+ type LayerEnvEntry,
17
+ type LayerEnvironmentSpec,
18
+ } from "./layer-env.js";
19
+ import type { RuntimeDriver } from "./driver.js";
20
+
21
+ export interface DiscoverLayerOptions {
22
+ /** 落盘 Store(缺省内存态——探测结果仍返回不落盘) */
23
+ store?: LayerEnvStore;
24
+ /** 跳过 stage2(慢包导入——10s+;快路径/离线探测) */
25
+ skipStage2?: boolean;
26
+ /** stage2 独立超时(缺省 120s——由调用方驱动 exec 超时) */
27
+ stage2TimeoutMs?: number;
28
+ logger?: { warn(msg: string): void };
29
+ }
30
+
31
+ export interface DiscoverLayerResult {
32
+ key: string;
33
+ spec: LayerEnvironmentSpec;
34
+ /** stage2 失败/跳过时如实标注(诚实不假装完整) */
35
+ probeNote?: string;
36
+ stale?: boolean;
37
+ entry?: LayerEnvEntry;
38
+ }
39
+
40
+ /** 异步探测一层环境:两段式 → 蒸馏 → (可选)落盘(漂移标 stale 不覆盖) */
41
+ export async function discoverLayer(
42
+ driver: RuntimeDriver,
43
+ opts: DiscoverLayerOptions = {},
44
+ ): Promise<DiscoverLayerResult> {
45
+ const key = layerEnvKey(driver.descriptor);
46
+
47
+ const r1 = await driver.exec(buildProbeStage1(), { timeoutMs: 30_000 });
48
+ const spec = parseProbeOutput(r1.stdout);
49
+ let probeNote: string | undefined;
50
+ if (!opts.skipStage2) {
51
+ try {
52
+ const r2 = await driver.exec(buildProbeStage2(), {
53
+ timeoutMs: opts.stage2TimeoutMs ?? 120_000,
54
+ });
55
+ const spec2 = parseProbeOutput(r2.stdout);
56
+ spec.packages = spec2.packages; // stage2 增量合并(包)
57
+ } catch (err) {
58
+ probeNote = `stage2 失败(${err instanceof Error ? err.message : String(err)})——packages 可能缺席`;
59
+ opts.logger?.warn(`[discoverLayer ${key}] ${probeNote}`);
60
+ }
61
+ } else {
62
+ probeNote = "stage2 skipped";
63
+ }
64
+
65
+ const entry: LayerEnvEntry = {
66
+ connection: projectConnection(driver.descriptor),
67
+ environment: spec,
68
+ discovered_at: new Date().toISOString(),
69
+ fingerprint: layerFingerprint(projectConnection(driver.descriptor), spec),
70
+ probe_note: probeNote,
71
+ };
72
+
73
+ let stale: boolean | undefined;
74
+ if (opts.store) {
75
+ const r = await opts.store.upsert(key, entry);
76
+ stale = r.stale;
77
+ }
78
+ return { key, spec, probeNote, stale, entry: opts.store ? undefined : entry };
79
+ }