@sema-agent/server 7.81.0 → 7.82.1
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/USAGE.md +44 -14
- package/dist/boot/memory-consolidation.d.ts +38 -0
- package/dist/boot/memory-consolidation.js +14 -0
- package/dist/boot/runner-deps.d.ts +11 -0
- package/dist/boot/runner-deps.js +15 -0
- package/dist/boot/stage-06-runners.js +13 -8
- package/dist/boot/stage-07-capability-layer.d.ts +1 -0
- package/dist/boot/stage-07-capability-layer.js +1 -1
- package/dist/boot/stage-08-reapers.d.ts +1 -0
- package/dist/boot/stage-09-leader.d.ts +1 -0
- package/dist/boot/stage-10-http-server.d.ts +1 -0
- package/dist/boot/stage-10-http-server.js +2 -1
- package/dist/config-catalog.js +3 -2
- package/dist/config-types.d.ts +26 -0
- package/dist/config.js +9 -1
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/server.d.ts +8 -0
- package/dist/observability/metrics.js +1 -0
- package/dist/plugins/posix-shell-fs.d.ts +2 -1
- package/dist/plugins/posix-shell-fs.js +4 -1
- package/dist/plugins/remote-env-adb.d.ts +67 -4
- package/dist/plugins/remote-env-adb.js +30 -15
- package/dist/plugins/remote-env-e2b.d.ts +8 -1
- package/dist/plugins/remote-env-e2b.js +4 -1
- package/dist/plugins/remote-env-host.d.ts +15 -1
- package/dist/plugins/remote-env-host.js +37 -1
- package/dist/plugins/remote-env-k8s.d.ts +5 -1
- package/dist/plugins/remote-env-k8s.js +3 -0
- package/dist/plugins/remote-env-local-docker.d.ts +5 -1
- package/dist/plugins/remote-env-local-docker.js +3 -0
- package/dist/plugins/remote-env-ssh.d.ts +5 -1
- package/dist/plugins/remote-env-ssh.js +3 -0
- package/dist/plugins/remote-shell.d.ts +74 -1
- package/dist/plugins/remote-shell.js +35 -0
- package/dist/plugins/web-search.d.ts +50 -2
- package/dist/plugins/web-search.js +9 -2
- package/dist/trace/core-keyset-guard.d.ts +6 -1
- package/dist/trace/project.d.ts +18 -0
- package/dist/trace/project.js +11 -0
- package/package.json +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
|
|
2
|
+
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
|
|
3
3
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
4
4
|
export interface AdbEnvConfig {
|
|
5
5
|
/** Device serial or `host:port` (TCP). `host:port` triggers `adb connect` on connect()/reconnect(). */
|
|
@@ -41,7 +41,23 @@ export interface AdbEnvDeps {
|
|
|
41
41
|
connectOnce?: (signal?: AbortSignal) => Promise<ConnectResult>;
|
|
42
42
|
}
|
|
43
43
|
/** [R78]#1: adb stderr markers that mean the DEVICE TRANSPORT died mid-op (not a normal command failure) —
|
|
44
|
-
* typed retryable (reconnect + retry per idempotency), never a fake command result. Exported for tests.
|
|
44
|
+
* typed retryable (reconnect + retry per idempotency), never a fake command result. Exported for tests.
|
|
45
|
+
*
|
|
46
|
+
* 🔴 **S-401 把这个判据的输入面改了一半(复审 F12 纠正:原来写的「此前 stderr 恒空 ⇒ 整条是死码」是
|
|
47
|
+
* **overclaim**,被本车自己的真机读数推翻)**。`exec-out` 合流的是**设备**的 stderr;**adb 客户端自己的**
|
|
48
|
+
* 诊断在两种传输下都照样走宿主 stderr。真机实测(同一台设备,拔掉 serial):
|
|
49
|
+
* `adb -s BOGUS exec-out 'true'` ⇒ host exit **255** + stderr `error: device 'BOGUS' not found`;
|
|
50
|
+
* `adb -s BOGUS shell -T -- 'true'` ⇒ host exit **1** + stderr `adb: device 'BOGUS' not found`。
|
|
51
|
+
* ⇒ 本判据在 S-401 之前**对 adb 客户端级的传输故障是工作的**(这也正是原作者把词表写成这些 adb 自家
|
|
52
|
+
* 措辞的原因);S-401 改变的是**设备侧 stderr 从此也进这条通道**。
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ **随之而来的、如实登记的一格**:分流之后落到这里的 stderr **既有 adb 自己的诊断,也有设备命令自己的
|
|
55
|
+
* 输出**(adb 客户端把两者写在同一个 fd 上,真机实测无法在传输层分开)。于是设备上一条自己打印
|
|
56
|
+
* `Connection reset by peer` 的命令(curl/nc)会被判成 `transport_lost`:本次命令结果不返回、`connected`
|
|
57
|
+
* 置假、下一次 exec fail-closed 直到 reconnect。**刻意不收窄词表**——词表收窄的方向是「漏判一次真掉线」,
|
|
58
|
+
* 那是 fail-open;误判的方向是多断一次连接,是 fail-closed。[ref] 轴上后者必须赢(能力探测的失败方向要与
|
|
59
|
+
* 能力语义方向一致)。ssh / k8s 两腿不吃这一格,因为它们的传输层自己给「通道关闭却没有 exit status」这个
|
|
60
|
+
* **结构**信号;adb 的 shell v2 没有等价物,所以这里只能靠标记词,**这条臂就把话说在这里**。 */
|
|
45
61
|
export declare function isAdbTransportLost(stderr: string): boolean;
|
|
46
62
|
export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
|
|
47
63
|
/**
|
|
@@ -123,8 +139,15 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
|
|
|
123
139
|
execStream(command: string, options?: ExecStreamOptions): AsyncIterable<OutputChunk>;
|
|
124
140
|
absolutePath(p: string): Promise<Result<string, FileError>>;
|
|
125
141
|
joinPath(parts: string[]): Promise<Result<string, FileError>>;
|
|
126
|
-
/**
|
|
127
|
-
*
|
|
142
|
+
/** 文件面的门 —— 与 host/local-docker/k8s/e2b 的姿态对齐(它们的文件面在未连/已拆时都 fail-closed)。
|
|
143
|
+
*
|
|
144
|
+
* 🔴 **S-401 复审 F2:这道门此前只查 `destroyed`,不查 `connected`**,于是 `readBinaryFile` /
|
|
145
|
+
* `readTextFile` / `readTextLines` / `appendFile` 能在**从没连过**的 env 上直接打一次真设备往返。
|
|
146
|
+
* 在本批之前那只是个不一致;本批**新开的 `shell_v2` fail-closed 闸就长在 `connect()` 里**,
|
|
147
|
+
* 所以「不查 connected」= 那道闸对整个读面**不生效** —— 而 `readTextFile` 把错误文本当文件内容返回
|
|
148
|
+
* 正是本 P1 点名的症状之一。闸的射程必须覆盖它要保护的每一个面,否则它只是一句话。
|
|
149
|
+
* ⇒ 规则收成一条:**这只 env 在 connect 成功之前、destroy 之后,文件面一个字都不答。**
|
|
150
|
+
* (`exec`/`execStream` 本就是这条规则,见 `:257`/`:276`;文件面从此同判,不再是两套。) */
|
|
128
151
|
private fsGate;
|
|
129
152
|
readBinaryFile(p: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
|
|
130
153
|
readTextFile(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
@@ -139,6 +162,10 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
|
|
|
139
162
|
fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
|
140
163
|
listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
|
141
164
|
readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
165
|
+
/** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
|
|
166
|
+
* 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
|
|
167
|
+
* 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
|
|
168
|
+
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
142
169
|
canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
143
170
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
144
171
|
createDir(p: string, options?: {
|
|
@@ -158,6 +185,42 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
|
|
|
158
185
|
}): Promise<Result<string, FileError>>;
|
|
159
186
|
/** Spawn the adb binary with the given args; collect stdout/stderr (+ raw bytes when binary), bound by timeout/abort. */
|
|
160
187
|
private adb;
|
|
188
|
+
/**
|
|
189
|
+
* 🔴 **S-401 —— 本适配器跑设备命令的唯一往返形**(`exec` / `execStream` / `readBinaryFile` 三个调用点
|
|
190
|
+
* 共用一只;此前 `exec-out` 写了三遍,于是「传输到底给不给退出码」这件事没有属主)。
|
|
191
|
+
*
|
|
192
|
+
* **`adb shell`(shell protocol v2)而不是 `adb exec-out`。** 协议出处 = adb 自己的 `adb help`:
|
|
193
|
+
* `-x: disable remote exit codes and stdout/stderr separation` —— 即**不带** `-x` 的 `adb shell` 就是
|
|
194
|
+
* 「远端退出码 + stdout/stderr 分流」那一形。`exec-out` 走的是另一条 `exec:` 服务:单条裸流,设备
|
|
195
|
+
* stderr **合流进 stdout**,宿主进程退出码**恒 0**。
|
|
196
|
+
*
|
|
197
|
+
* 真机实测(车GV 2026-09-17,SM-S721B / Android 15 / toybox 0.8.11 / adb 1.0.41 `35.0.2`,设备 features
|
|
198
|
+
* 含 `shell_v2`),逐条复现而不是从文档推断:
|
|
199
|
+
* - `exec-out 'false'` ⇒ 宿主 exit **0**;`shell -T -- 'false'` ⇒ **1**;`shell -T -- 'exit 7'` ⇒ **7**
|
|
200
|
+
* - `exec-out 'realpath -- <坏链>'` ⇒ exit 0 + 错误文本在 **stdout**;`shell -T --` ⇒ exit 1 + 在 **stderr**
|
|
201
|
+
* - 1 MiB 随机字节 `cat` 回读:两形 md5 **相同**(`shell -T` 无 CRLF 转换、无包裹字节),故 binary 面照用
|
|
202
|
+
* - 同一条 `realpath` 的墙钟:`shell -T` 68 ms/次 vs `exec-out` 79 ms/次(n=10)—— 不是性能代价
|
|
203
|
+
*
|
|
204
|
+
* 修前的后果链([ref] 安全轴静默 fail-open):共享基座 `posix-shell-fs.ts` 的**每一处**失败判据(7 处)
|
|
205
|
+
* 与链原语 `remote-shell.ts` 的 `runSymlinkChain`(第 8 处)认的都是 `exitCode !== 0`,而这条传输恒答 0
|
|
206
|
+
* ⇒ adb 腿上 `realpath` / `readlink` / `stat` / `ls` / `cat` **永不失败**,`canonicalPath` 把那行**错误
|
|
207
|
+
* 文本**当成 canonical key 交给敏感路径门。⚠️ **复审 F15 纠正:原文写「五处」是数错了**,而且漏掉的
|
|
208
|
+
* 第 8 处恰恰是最贵的一处 —— `canonicalChain`(S-362 / core 7.21.0 [ref],上一版刚接)在 exit 恒 0 下会把
|
|
209
|
+
* **合流进 stdout 的错误文本当成链上的跳**解析出来,等于给 core 的路径判官递一条**伪造的** symlink 链。
|
|
210
|
+
*
|
|
211
|
+
* 🔴 **`-T` 与 `--` 都是承重件,不是装饰**:
|
|
212
|
+
* - `-T` = 不分配 pty。带 pty 的交互 shell 会把 stderr 并进 stdout 并做 CRLF 转换(council §9.2 的那句话
|
|
213
|
+
* 本身没错,错的是它被用来给 `exec-out` 背书)。给了命令的 `adb shell` 默认本就不开 pty,`-T` 是**显式**
|
|
214
|
+
* 关闭,不依赖那个默认。
|
|
215
|
+
* - `--` = 终止 adb 自己的旗标解析。没有它,**首字符为 `-` 的设备命令会被 adb 当成自己的旗标吃掉**
|
|
216
|
+
* (真机复现:`adb shell -T '-x'` 不报错,直接变成一个挂住的交互 shell)—— 而 `-x` 恰好就是关掉
|
|
217
|
+
* 远端退出码与分流的那根旗标,即「一条以 `-x` 开头的命令能静默把本修关回去」。真机验证 `--` 之后的
|
|
218
|
+
* 参数原样落到设备 shell(`adb shell -T -- '-x'` ⇒ 设备答 `/system/bin/sh: -c: requires an argument`)。
|
|
219
|
+
*
|
|
220
|
+
* 命令永远是**最后一个**参数,整条包在一个 argv 元素里(设备侧 `sh -c` 收全串,与 `withCwdEnv` 的
|
|
221
|
+
* `cd … && …` 前缀语义一致)。
|
|
222
|
+
*/
|
|
223
|
+
private deviceShellArgs;
|
|
161
224
|
private withCwdEnv;
|
|
162
225
|
}
|
|
163
226
|
/**
|
|
@@ -5,7 +5,7 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
|
|
6
6
|
import { signalNumber } from "./host-platform.js";
|
|
7
7
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
8
|
-
import { fileErrorFromExec } from "./remote-env-file-error.js";
|
|
8
|
+
import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
|
|
9
9
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
10
10
|
const PROVIDER = "adb";
|
|
11
11
|
const ok = (value) => ({ ok: true, value });
|
|
@@ -76,6 +76,18 @@ export class RemoteAdbExecutionEnv {
|
|
|
76
76
|
const state = st.value.stdout.trim();
|
|
77
77
|
const err = `${st.value.stdout} ${st.value.stderr}`.toLowerCase();
|
|
78
78
|
if (state === "device") {
|
|
79
|
+
const feats = await this.adb(["-s", this.cfg.serial, "features"], { signal, timeoutMs: this.cfg.commandTimeoutMs });
|
|
80
|
+
if (!feats.ok)
|
|
81
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `adb features failed: ${feats.error.message}`, feats.error) };
|
|
82
|
+
if (feats.value.exitCode !== 0) {
|
|
83
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `adb features exited ${feats.value.exitCode} for ${this.cfg.serial}: ${feats.value.stderr.trim().slice(0, 200) || "(no stderr)"} — could not read the device's feature list this attempt`) };
|
|
84
|
+
}
|
|
85
|
+
if (!/(^|\s)shell_v2(\s|$)/.test(feats.value.stdout.trim())) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: new RemoteExecutionError("unsupported", `device ${this.cfg.serial} does not advertise the 'shell_v2' feature, so this adapter cannot establish that its command round-trip carries a real exit code with stderr kept separate — the two properties every filesystem judgment on this lane depends on to tell failure from success. Refusing to connect rather than run on an unverifiable transport: attach a device that supports adb shell protocol v2.`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
79
91
|
this.connected = true;
|
|
80
92
|
return ok(this.workspaceHandle());
|
|
81
93
|
}
|
|
@@ -112,7 +124,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
112
124
|
if (!this.connected)
|
|
113
125
|
return { ok: false, error: new ExecutionError("shell_unavailable", "ADB device not connected — call connect() first") };
|
|
114
126
|
const wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
|
|
115
|
-
const r = await this.adb(
|
|
127
|
+
const r = await this.adb(this.deviceShellArgs(wrapped), {
|
|
116
128
|
signal: options?.abortSignal,
|
|
117
129
|
timeoutMs: options?.timeout != null ? options.timeout * 1000 : this.cfg.commandTimeoutMs,
|
|
118
130
|
onStdout: options?.onStdout,
|
|
@@ -130,7 +142,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
130
142
|
if (!this.connected)
|
|
131
143
|
throw new RemoteExecutionError("connect_failed", "ADB device not connected — call connect() first");
|
|
132
144
|
const wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
|
|
133
|
-
const child = this.spawnFn(this.cfg.adbPath,
|
|
145
|
+
const child = this.spawnFn(this.cfg.adbPath, this.deviceShellArgs(wrapped), { stdio: ["ignore", "pipe", "pipe"] });
|
|
134
146
|
const queue = [];
|
|
135
147
|
let finished = false;
|
|
136
148
|
let failure;
|
|
@@ -176,6 +188,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
176
188
|
if (queue.length >= 512 && !paused) {
|
|
177
189
|
paused = true;
|
|
178
190
|
child.stdout.pause();
|
|
191
|
+
child.stderr.pause();
|
|
179
192
|
}
|
|
180
193
|
signalReady();
|
|
181
194
|
};
|
|
@@ -206,6 +219,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
206
219
|
if (paused) {
|
|
207
220
|
paused = false;
|
|
208
221
|
child.stdout.resume();
|
|
222
|
+
child.stderr.resume();
|
|
209
223
|
}
|
|
210
224
|
if (failure)
|
|
211
225
|
throw failure;
|
|
@@ -244,6 +258,8 @@ export class RemoteAdbExecutionEnv {
|
|
|
244
258
|
fsGate(p) {
|
|
245
259
|
if (this.destroyed)
|
|
246
260
|
return { ok: false, error: new FileError("unknown", "execution env already destroyed", this.resolve(p)) };
|
|
261
|
+
if (!this.connected)
|
|
262
|
+
return { ok: false, error: new FileError("unknown", "ADB device not connected — call connect() first", this.resolve(p)) };
|
|
247
263
|
return undefined;
|
|
248
264
|
}
|
|
249
265
|
async readBinaryFile(p, abortSignal) {
|
|
@@ -251,21 +267,11 @@ export class RemoteAdbExecutionEnv {
|
|
|
251
267
|
if (gated)
|
|
252
268
|
return gated;
|
|
253
269
|
const abs = this.resolve(p);
|
|
254
|
-
const r = await this.adb(
|
|
270
|
+
const r = await this.adb(this.deviceShellArgs(`cat ${shellQuote(abs)}`), { signal: abortSignal, timeoutMs: this.cfg.dataTimeoutMs, binary: true });
|
|
255
271
|
if (!r.ok)
|
|
256
272
|
return { ok: false, error: fileErrorFromExec(r.error, abs) };
|
|
257
|
-
const errOut = r.value.stderr.trim();
|
|
258
|
-
if (errOut) {
|
|
259
|
-
if (/no such file/i.test(errOut))
|
|
260
|
-
return { ok: false, error: new FileError("not_found", errOut, abs) };
|
|
261
|
-
if (/permission denied/i.test(errOut))
|
|
262
|
-
return { ok: false, error: new FileError("permission_denied", errOut, abs) };
|
|
263
|
-
if (/is a directory/i.test(errOut))
|
|
264
|
-
return { ok: false, error: new FileError("is_directory", errOut, abs) };
|
|
265
|
-
return { ok: false, error: new FileError("unknown", errOut, abs) };
|
|
266
|
-
}
|
|
267
273
|
if (r.value.exitCode !== 0)
|
|
268
|
-
return { ok: false, error:
|
|
274
|
+
return { ok: false, error: classifyFsStderr(r.value.stderr.trim() || `adb cat exited ${r.value.exitCode}`, abs) };
|
|
269
275
|
return ok(r.value.stdoutBytes ?? new Uint8Array(0));
|
|
270
276
|
}
|
|
271
277
|
async readTextFile(p, abortSignal) {
|
|
@@ -284,6 +290,9 @@ export class RemoteAdbExecutionEnv {
|
|
|
284
290
|
return ok(lines);
|
|
285
291
|
}
|
|
286
292
|
async writeFile(p, content, abortSignal) {
|
|
293
|
+
const gated = this.fsGate(p);
|
|
294
|
+
if (gated)
|
|
295
|
+
return gated;
|
|
287
296
|
const abs = this.resolve(p);
|
|
288
297
|
const dir = path.posix.dirname(abs);
|
|
289
298
|
if (dir && dir !== "." && dir !== "/") {
|
|
@@ -348,6 +357,9 @@ export class RemoteAdbExecutionEnv {
|
|
|
348
357
|
async readLink(p, abortSignal) {
|
|
349
358
|
return this.posixFs.readLink(p, abortSignal);
|
|
350
359
|
}
|
|
360
|
+
async canonicalChain(p, abortSignal) {
|
|
361
|
+
return this.posixFs.canonicalChain(p, abortSignal);
|
|
362
|
+
}
|
|
351
363
|
async canonicalPath(p, abortSignal) {
|
|
352
364
|
return this.posixFs.canonicalPath(p, abortSignal);
|
|
353
365
|
}
|
|
@@ -433,6 +445,9 @@ export class RemoteAdbExecutionEnv {
|
|
|
433
445
|
});
|
|
434
446
|
});
|
|
435
447
|
}
|
|
448
|
+
deviceShellArgs(command) {
|
|
449
|
+
return ["-s", this.cfg.serial, "shell", "-T", "--", command];
|
|
450
|
+
}
|
|
436
451
|
withCwdEnv(command, cwd, env) {
|
|
437
452
|
const parts = [];
|
|
438
453
|
const dir = cwd ?? this.cwd;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* iterator MAY throw `RemoteExecutionError` mid-stream before an `exit` chunk (seam contract, OutputChunk).
|
|
21
21
|
*/
|
|
22
22
|
import { Sandbox, type SandboxOpts, type SandboxConnectOpts } from "e2b";
|
|
23
|
-
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type ExecutionEnvFactory, type VmLifecycleOptions, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
|
|
23
|
+
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type ExecutionEnvFactory, type VmLifecycleOptions, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
|
|
24
24
|
/** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
|
|
25
25
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
26
26
|
/** Minimal optional observability hooks (kept structural so the adapter stays standalone-testable). */
|
|
@@ -250,6 +250,13 @@ export declare class RemoteContainerExecutionEnv implements RemoteExecutionEnv,
|
|
|
250
250
|
* 缺它 ⇒ 合法的悬空链接让整个工具调用失败。必须返回**原始链接目标**(`readlink` 不带 `-f`)。
|
|
251
251
|
* 本腿的文件面也走 `exec`(与 canonicalPath 同通道),所以实现方式与 shell 腿一致。 */
|
|
252
252
|
readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
253
|
+
/** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。本腿的文件面走原生 SDK 的
|
|
254
|
+
* `exec`([ref] B6 的既有裁决:host 与 e2b 是原生 API 腿,**不并**进 `posix-shell-fs` 基座),但
|
|
255
|
+
* 「链怎么走、怎么读」与四条 shell 腿**是同一份**:脚本与读法的单一属主在 `remote-shell.ts`,这里只多一行
|
|
256
|
+
* 传输。一次往返、上限 64、只锚不折、遇环即止;`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。
|
|
257
|
+
* 🔴 exit≠0 归 `unknown` 而不是 `not_found`(与本腿 `readLink` 那一格刻意分家,同 `posix-shell-fs` 的理由):
|
|
258
|
+
* 「叶不是软链」与「叶不存在」在这条命令上都是 exit 0 + 空 stdout,所以 exit≠0 一定不是「文件不在」。 */
|
|
259
|
+
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
253
260
|
canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
254
261
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
255
262
|
createDir(p: string, options?: {
|
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { Sandbox, CommandExitError, FileType } from "e2b";
|
|
3
3
|
import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
4
4
|
import { fileErrorFromExec } from "./remote-env-file-error.js";
|
|
5
|
-
import { numEnvOr, shellQuote } from "./remote-shell.js";
|
|
5
|
+
import { numEnvOr, runSymlinkChain, shellQuote } from "./remote-shell.js";
|
|
6
6
|
import { BackgroundShellManager } from "./background-shell-support.js";
|
|
7
7
|
const PROVIDER = "e2b";
|
|
8
8
|
const DEFAULT_MOUNT_PATH = "/home/user";
|
|
@@ -692,6 +692,9 @@ export class RemoteContainerExecutionEnv {
|
|
|
692
692
|
return { ok: false, error: new FileError("not_found", r.value.stderr.trim() || `readlink failed for ${abs}`, abs) };
|
|
693
693
|
return ok(r.value.stdout.trim());
|
|
694
694
|
}
|
|
695
|
+
async canonicalChain(p, abortSignal) {
|
|
696
|
+
return runSymlinkChain(this.resolve(p), (cmd) => this.exec(cmd, { abortSignal }));
|
|
697
|
+
}
|
|
695
698
|
async canonicalPath(p, abortSignal) {
|
|
696
699
|
const abs = this.resolve(p);
|
|
697
700
|
const r = await this.exec(`realpath -- ${shellQuote(abs)}`, { abortSignal });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FileError, ExecutionError, RemoteExecutionError, SchedulerError, type SchedulerCapability, type ScheduledIntent, type SchedulerContext, type ScheduledTaskId, type ScheduledTaskSummary, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, BackgroundShellError, type BackgroundShellCapability, type BackgroundShellId, type BackgroundPoll, type BackgroundSpawnOptions } from "@sema-agent/core";
|
|
1
|
+
import { FileError, ExecutionError, RemoteExecutionError, SchedulerError, type SchedulerCapability, type ScheduledIntent, type SchedulerContext, type ScheduledTaskId, type ScheduledTaskSummary, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, BackgroundShellError, type BackgroundShellCapability, type BackgroundShellId, type BackgroundPoll, type BackgroundSpawnOptions } from "@sema-agent/core";
|
|
2
2
|
/** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
|
|
3
3
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
4
4
|
/** Construction config for {@link RemoteHostExecutionEnv}. */
|
|
@@ -216,6 +216,20 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
216
216
|
listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
|
217
217
|
canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
218
218
|
readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
219
|
+
/**
|
|
220
|
+
* S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。host 腿是 node:fs 腿,所以按
|
|
221
|
+
* 提货单 [ref] @server 五适配器段的第一行,与 core 的 `NodeExecutionEnv.canonicalChain` **同形**:对整条
|
|
222
|
+
* 拼法 `readlink`,再对每一跳 `readlink`,**上限 64**、**只锚不折**、**遇环即止**;win32 宿主上跳到共享
|
|
223
|
+
* (UNC)时**记下该跳并停止探测**(不碰网络,CC `vr` 对网络拼法只给拼法)。
|
|
224
|
+
*
|
|
225
|
+
* 🔴 **不复用 core 的实现而是同形重写**:`NodeExecutionEnv` 是 core 的内部类,包根不导出它的这只方法;
|
|
226
|
+
* 而本腿的每一次读都必须先过 {@link fsReady}(2026-07-25 全族统一:拆除之后只读面不得与写面自相矛盾),
|
|
227
|
+
* 那道门在 core 的实现里不存在。同形的判据钉在 `test/remote-env-canonical-chain.test.ts`。
|
|
228
|
+
*
|
|
229
|
+
* 链尽的三种 errno(`EINVAL` 不是软链 / `ENOENT` 不存在 / `ENOTDIR`)⇒ `ok` + 到此为止的跳;其余 errno
|
|
230
|
+
* (`EACCES` …)⇒ `ok:false`(core 读作「这一次没有跳」,**永不是拒绝**)。非链接的叶答 `[]`。
|
|
231
|
+
*/
|
|
232
|
+
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
219
233
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
220
234
|
createDir(p: string, options?: {
|
|
221
235
|
recursive?: boolean;
|
|
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
|
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync, accessSync, constants as FS_CONST } from "node:fs";
|
|
7
7
|
import { StringDecoder } from "node:string_decoder";
|
|
8
|
-
import { armPipeDestroyGrace, numEnvOr } from "./remote-shell.js";
|
|
8
|
+
import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-shell.js";
|
|
9
9
|
import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
|
|
10
10
|
import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys } from "./host-platform.js";
|
|
11
11
|
import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
|
|
@@ -22,6 +22,7 @@ const HOST_BG_FILE_CAP = numEnvOr("HOST_BG_FILE_CAP", 64 * 1024 * 1024, HOST_BG_
|
|
|
22
22
|
const HOST_BG_KILL_GRACE_MS = numEnvOr("HOST_BG_KILL_GRACE_MS", 1000, 0);
|
|
23
23
|
const HOST_BG_MEM_CAP = numEnvOr("HOST_BG_MEM_CAP", 8 * 1024 * 1024, 64 * 1024);
|
|
24
24
|
const ok = (value) => ({ ok: true, value });
|
|
25
|
+
const UNC_SPELLING = /^[\\/]{2}[^\\/]+[\\/]+[^\\/]/;
|
|
25
26
|
const unsupported = (op) => ({
|
|
26
27
|
ok: false,
|
|
27
28
|
error: new RemoteExecutionError("unsupported", `${op} is not supported on a host target (the operator's own machine — no container boundary, not snapshotable; capabilities.suspendable=false)`),
|
|
@@ -1006,6 +1007,41 @@ export class RemoteHostExecutionEnv {
|
|
|
1006
1007
|
return { ok: false, error: this.fileError(e, abs, abortSignal) };
|
|
1007
1008
|
}
|
|
1008
1009
|
}
|
|
1010
|
+
async canonicalChain(p, abortSignal) {
|
|
1011
|
+
const ready = await this.fsReady(p);
|
|
1012
|
+
if (!ready.ok)
|
|
1013
|
+
return ready;
|
|
1014
|
+
const hops = [];
|
|
1015
|
+
let cur = this.resolve(p);
|
|
1016
|
+
const seen = new Set([cur]);
|
|
1017
|
+
for (let i = 0; i < SYMLINK_CHAIN_MAX_HOPS; i += 1) {
|
|
1018
|
+
let target;
|
|
1019
|
+
try {
|
|
1020
|
+
target = await fs.readlink(cur);
|
|
1021
|
+
}
|
|
1022
|
+
catch (e) {
|
|
1023
|
+
const fe = this.fileError(e, cur, abortSignal);
|
|
1024
|
+
if (fe.code === "invalid" || fe.code === "not_found" || fe.code === "not_directory")
|
|
1025
|
+
break;
|
|
1026
|
+
return { ok: false, error: fe };
|
|
1027
|
+
}
|
|
1028
|
+
let next;
|
|
1029
|
+
if (path.isAbsolute(target))
|
|
1030
|
+
next = target;
|
|
1031
|
+
else {
|
|
1032
|
+
const dir = path.dirname(cur);
|
|
1033
|
+
next = dir.endsWith(path.sep) ? `${dir}${target}` : `${dir}${path.sep}${target}`;
|
|
1034
|
+
}
|
|
1035
|
+
if (seen.has(next))
|
|
1036
|
+
break;
|
|
1037
|
+
seen.add(next);
|
|
1038
|
+
hops.push(next);
|
|
1039
|
+
if (process.platform === "win32" && UNC_SPELLING.test(next))
|
|
1040
|
+
break;
|
|
1041
|
+
cur = next;
|
|
1042
|
+
}
|
|
1043
|
+
return ok({ hops });
|
|
1044
|
+
}
|
|
1009
1045
|
async exists(p, abortSignal) {
|
|
1010
1046
|
const ready = await this.fsReady(p);
|
|
1011
1047
|
if (!ready.ok)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
|
|
1
|
+
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
|
|
2
2
|
import type { PodSpecPatch } from "./k8s-exec-protocol.js";
|
|
3
3
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
4
4
|
export { buildBgRunnerScript, buildBgLauncherScript, buildBgPollScript, buildBgKillScript, buildBgDisposeScript, buildDetachCapableExec, parseBgPollOutput, type ParsedBgPoll, } from "./k8s-bg-scripts.js";
|
|
@@ -310,6 +310,10 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
310
310
|
fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
|
311
311
|
listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
|
312
312
|
readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
313
|
+
/** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
|
|
314
|
+
* 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
|
|
315
|
+
* 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
|
|
316
|
+
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
313
317
|
canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
314
318
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
315
319
|
createDir(p: string, options?: {
|
|
@@ -919,6 +919,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
919
919
|
async readLink(p, abortSignal) {
|
|
920
920
|
return this.posixFs.readLink(p, abortSignal);
|
|
921
921
|
}
|
|
922
|
+
async canonicalChain(p, abortSignal) {
|
|
923
|
+
return this.posixFs.canonicalChain(p, abortSignal);
|
|
924
|
+
}
|
|
922
925
|
async canonicalPath(p, abortSignal) {
|
|
923
926
|
return this.posixFs.canonicalPath(p, abortSignal);
|
|
924
927
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
|
|
2
|
+
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
|
|
3
3
|
/** Options accepted by `ExecutionEnv.exec` (core does not re-export the type; derive it from the seam). */
|
|
4
4
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
5
5
|
/** Construction config for {@link RemoteLocalDockerExecutionEnv}. */
|
|
@@ -166,6 +166,10 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
|
|
|
166
166
|
fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
|
167
167
|
listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
|
168
168
|
readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
169
|
+
/** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
|
|
170
|
+
* 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
|
|
171
|
+
* 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
|
|
172
|
+
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
169
173
|
canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
170
174
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
171
175
|
createDir(p: string, options?: {
|
|
@@ -406,6 +406,9 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
406
406
|
async readLink(p, abortSignal) {
|
|
407
407
|
return this.posixFs.readLink(p, abortSignal);
|
|
408
408
|
}
|
|
409
|
+
async canonicalChain(p, abortSignal) {
|
|
410
|
+
return this.posixFs.canonicalChain(p, abortSignal);
|
|
411
|
+
}
|
|
409
412
|
async canonicalPath(p, abortSignal) {
|
|
410
413
|
return this.posixFs.canonicalPath(p, abortSignal);
|
|
411
414
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SshHostKeyPolicy } from "../ssh-host-key.js";
|
|
2
|
-
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
|
|
2
|
+
import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory } from "@sema-agent/core";
|
|
3
3
|
type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
|
|
4
4
|
export interface SshEnvConfig {
|
|
5
5
|
host: string;
|
|
@@ -171,6 +171,10 @@ export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv {
|
|
|
171
171
|
private posixFsInst?;
|
|
172
172
|
private get posixFs();
|
|
173
173
|
readLink(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
174
|
+
/** S-362 / core 7.21.0 [ref] —— **实现**(表态:本腿实现 `canonicalChain`)。一次往返里的一个 `readlink`
|
|
175
|
+
* 循环(上限 64、只锚不折、遇环即止),脚本与读法的单一属主在 `remote-shell.ts`,四条 shell 腿共用同一份。
|
|
176
|
+
* 缺席读法用不上(本腿在场);`ok:false` 由 core 读作「这一次没有跳」,永不是拒绝。 */
|
|
177
|
+
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
174
178
|
canonicalPath(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
175
179
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
176
180
|
createDir(p: string, options?: {
|
|
@@ -548,6 +548,9 @@ export class RemoteSshExecutionEnv {
|
|
|
548
548
|
async readLink(p, abortSignal) {
|
|
549
549
|
return this.posixFs.readLink(p, abortSignal);
|
|
550
550
|
}
|
|
551
|
+
async canonicalChain(p, abortSignal) {
|
|
552
|
+
return this.posixFs.canonicalChain(p, abortSignal);
|
|
553
|
+
}
|
|
551
554
|
async canonicalPath(p, abortSignal) {
|
|
552
555
|
return this.posixFs.canonicalPath(p, abortSignal);
|
|
553
556
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared shell/path helpers for the SSH + ADB `RemoteExecutionEnv` adapters. Kept in ONE place so the
|
|
3
3
|
* security-sensitive quoting can't drift between adapters (council design/#4, #5).
|
|
4
4
|
*/
|
|
5
|
-
import type { FileInfo } from "@sema-agent/core";
|
|
5
|
+
import type { ExecutionError, FileError, FileInfo, RemoteExecutionError, Result, SymlinkChain } from "@sema-agent/core";
|
|
6
6
|
import type { ChildProcess } from "node:child_process";
|
|
7
7
|
/**
|
|
8
8
|
* D5 pipe-destroy grace (parity with core's vendored `EXEC_FORCE_SETTLE_GRACE_MS`, harness/env/nodejs.js):
|
|
@@ -32,4 +32,77 @@ export declare function drainNumEnvWarnings(): Array<{
|
|
|
32
32
|
raw: string;
|
|
33
33
|
}>;
|
|
34
34
|
export declare function numEnvOr(name: string, def: number, min: number): number;
|
|
35
|
+
export declare const SYMLINK_CHAIN_MAX_HOPS = 64;
|
|
36
|
+
/**
|
|
37
|
+
* 一次往返走完叶 symlink 链的 POSIX sh 脚本(**纯数据**:返回一个命令串 ⇒ `build*`,CLAUDE.md 工厂律)。
|
|
38
|
+
*
|
|
39
|
+
* 🔴 `readlink` **缺失**必须与「不是软链」分开:两者在 shell 里都是 exit≠0,而 `|| break` 会把「工具不在」
|
|
40
|
+
* 静默读成「零跳」—— 那是安全轴上的静默兜底(链视图整条消失,判官少一个视图)。所以脚本**先探**一次
|
|
41
|
+
* `command -v`,缺失时打一句 `readlink: not found` 到 stderr 并 exit 127 —— 那句话正好命中
|
|
42
|
+
* {@link import("./remote-env-file-error.js").classifyFsStderr} 的**命令缺失护栏**(它把命令缺失归
|
|
43
|
+
* `unknown` 而不是 `not_found`),于是调用方拿到的是 `ok:false`(core 读作「这一次没有跳」,永不是拒绝)
|
|
44
|
+
* 而不是一个伪造的空链。
|
|
45
|
+
*
|
|
46
|
+
* 🔴 **整条命令包在一个 `{ …; }` 里**,理由是**调用方会给它加前缀**:四条腿的 `withCwdEnv` 都拼
|
|
47
|
+
* `cd <dir> && <命令>`(ssh `:760` / k8s `:1418` / adb `:578` / local-docker 同形)。这里连着犯过两次错,
|
|
48
|
+
* 两次都记下来,因为它们是**同一个坑的两面**:
|
|
49
|
+
* · 写成 `command -v … || { …; }`:`||` 与前缀的 `&&` 结成一条链(`cd X && command -v … || { … }`),
|
|
50
|
+
* **`cd` 失败时跑的是探测的失败臂** —— 报「readlink 不在」而真相是「那个目录没了」,运维去修一个没坏的东西;
|
|
51
|
+
* · 改成独立语句 `if ! …; fi; c=…; while …`:前缀的 `&&` 只短路**那一条 `if`**,后面的循环照跑 ——
|
|
52
|
+
* 于是「`cd` 失败 ∧ readlink 缺失」这一格里,`|| break` 把工具缺失吞成零跳,脚本 **exit 0 + 空 stdout**,
|
|
53
|
+
* 调用方拿到一条**伪造的空链**。这正是探测本来要挡的那个静默兜底([ref] 轴),换了个入口回来了。
|
|
54
|
+
* ⇒ 正解是**让整条命令只有一个退出状态**:包进 `{ …; }`,前缀的 `&&` 要么统治全部、要么全部不跑。
|
|
55
|
+
* `cd` 失败 ⇒ 退出码来自 `cd` 自己、stderr 是它自己的话 ⇒ `ok:false`(core 读作「这一次没有跳」),
|
|
56
|
+
* 诚实且指向真因;`cd` 成功 ⇒ 探测与循环照旧。`{ }` 不是子 shell,里面的 `exit 127` 照样结束整条命令。
|
|
57
|
+
*
|
|
58
|
+
* 词法细节(逐条对着 core 的 Node 实现写,两边同形):绝对目标直接接上;相对目标用参数展开
|
|
59
|
+
* `${c%/*}` 取**当前这条链接**所在目录(不调 `dirname` —— toybox 的 `dirname` 不认 `--`,且这里零外部命令
|
|
60
|
+
* 更省一次 fork),根目录那一格(`${c%/*}` 为空)补 `/`。**全程不折叠 `..`**。
|
|
61
|
+
*/
|
|
62
|
+
export declare function buildSymlinkChainCommand(absPath: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* {@link buildSymlinkChainCommand} 的 stdout → `SymlinkChain.hops`。
|
|
65
|
+
*
|
|
66
|
+
* 三件事**只在这里**做一次(四条 shell 腿 + e2b 共读):
|
|
67
|
+
* · **环即止** —— 出现重复跳时在**重复的那一跳之前**截断(CC `vr` 的 `d.has(u)` 同律),且 seen 表用
|
|
68
|
+
* **被寻址的拼法播种**(S-408,core 裁 2026-09-17:契约句「不重复被寻址的拼法」的正解是**起点本身不是一跳**——
|
|
69
|
+
* 链视图列的是「从被寻址拼法出发经过的中间拼法」,链绕回起点即结束、起点不再列;成环的事实由终点判决
|
|
70
|
+
* `canonicalPath`(realpath ELOOP ⇒ unresolvable ⇒ 拒)承担,不由链视图承担)。于是 `a -> b`、`b -> a`,问 `a`,
|
|
71
|
+
* 答 `[b]`,与 core `NodeExecutionEnv.canonicalChain`(`new Set([resolved])` 播种)逐字节同形。此前本族从空表
|
|
72
|
+
* 开始、答 `[b, a]`(7.82.0 及之前;多一个冗余视图 —— 判官对 `a` 已按拼法判过一次),裁定后对齐。
|
|
73
|
+
* 脚本侧不做:
|
|
74
|
+
* POSIX sh 里维护一张 seen 表要么靠内嵌换行的 `case` 模式(易碎),要么另起一个进程;放在这里则
|
|
75
|
+
* **一份实现**被四条腿与 e2b 共用,且与 host 腿的 Node 循环逐字节对得上。代价如实登记:链上真有环时,
|
|
76
|
+
* 远端在**那一次往返内**最多多跑到 64 次 `readlink`(有界,不是无界探测),而报出来的跳与 host 腿相同。
|
|
77
|
+
* · **上限 64** —— 脚本已经封顶,这里再切一次是对**传输层**的不信任(答多了不当真),不是第二份判据。
|
|
78
|
+
* · **逐行 trim** —— 与本族 `readLink` 的既有姿态**同一条**(基座头注:链接目标含换行是病态形,按 trim
|
|
79
|
+
* 处理,不假装支持)。残留如实写出来:目标拼法真带首尾空白时,trim 后的跳可能与真目标差几个字节 ——
|
|
80
|
+
* 方向上只会让一条跳**匹配不到**受保护行(少问,不是多放行),而终点判决(`canonicalPath`)完全不经本路。
|
|
81
|
+
*/
|
|
82
|
+
export declare function symlinkChainHopsFromStdout(stdout: string, addressed: string): string[];
|
|
83
|
+
/** {@link runSymlinkChain} 的传输面 —— 各腿的 `exec` 收敛成的那一个形(与 `PosixShellFs` 基座的 `run` 同形)。 */
|
|
84
|
+
export type ShellChainRun = (cmd: string) => Promise<Result<{
|
|
85
|
+
stdout: string;
|
|
86
|
+
stderr: string;
|
|
87
|
+
exitCode: number;
|
|
88
|
+
}, ExecutionError | RemoteExecutionError>>;
|
|
89
|
+
/**
|
|
90
|
+
* **shell 形 `canonicalChain` 的全部半场,一处**(对抗复审 F4 [low],验真后按类修)。
|
|
91
|
+
*
|
|
92
|
+
* 此前脚本与读法收在这里,而「怎么把它们接到传输上、exit≠0 怎么归码」在**两处**各写一遍:
|
|
93
|
+
* `posix-shell-fs.ts`(四条 shell 腿)与 `remote-env-e2b.ts`(原生 SDK 腿)。两份五行代码语义相同、
|
|
94
|
+
* 错误归类却各写各的,于是「e2b 与四条 shell 腿答得一样」只靠一句注释担保 —— 复审的换靶变异实测:
|
|
95
|
+
* 把 e2b 那五行改成 `return ok({ hops: [] })`,**全部钉照绿**。收成一只函数之后,那条担保是结构事实:
|
|
96
|
+
* 两边调的是同一个函数,分叉无处可生;基座上那一整套行为钉同时就是 e2b 的钉。
|
|
97
|
+
*
|
|
98
|
+
* 归码(两腿共用同一条,不再各写各的):
|
|
99
|
+
* · 传输自己失败 ⇒ {@link fileErrorFromExec}(`aborted` 穿透,其余诚实降级 `unknown`);
|
|
100
|
+
* · exit≠0 ⇒ {@link classifyFsStderr} 且 fallback 取 **`unknown` 而不是 `not_found`** —— 这条命令上
|
|
101
|
+
* 「叶不是软链」与「叶不存在」**都是 exit 0 + 空 stdout**(脚本里的 `|| break`),所以任何 exit≠0
|
|
102
|
+
* 都**不是**「这个文件不在」,而是解析器自己出了事(工具缺失 / cwd 没了 / 传输坏 / shell 拒)。
|
|
103
|
+
* 把它折成 `not_found` 会让运维去找一条根本没丢的路径。
|
|
104
|
+
* ⚠️ 例外由 `classifyFsStderr` 自己认:`cd` 失败那一格的 stderr 里真带「No such file」,它按**那句话**
|
|
105
|
+
* 归 `not_found` —— 那时真的有一条路径没了(工作目录),所以那个码是对的。
|
|
106
|
+
*/
|
|
107
|
+
export declare function runSymlinkChain(absPath: string, run: ShellChainRun): Promise<Result<SymlinkChain, FileError>>;
|
|
35
108
|
//# sourceMappingURL=remote-shell.d.ts.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
|
|
1
2
|
export const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
|
|
2
3
|
export function armPipeDestroyGrace(child) {
|
|
3
4
|
const t = setTimeout(() => {
|
|
@@ -36,4 +37,38 @@ export function numEnvOr(name, def, min) {
|
|
|
36
37
|
}
|
|
37
38
|
return Math.max(min, (Number.isFinite(n) ? n : 0) || def);
|
|
38
39
|
}
|
|
40
|
+
export const SYMLINK_CHAIN_MAX_HOPS = 64;
|
|
41
|
+
export function buildSymlinkChainCommand(absPath) {
|
|
42
|
+
return (`{ if ! command -v readlink >/dev/null 2>&1; then printf 'readlink: not found\\n' >&2; exit 127; fi; ` +
|
|
43
|
+
`c=${shellQuote(absPath)}; n=0; ` +
|
|
44
|
+
`while [ "$n" -lt ${SYMLINK_CHAIN_MAX_HOPS} ]; do ` +
|
|
45
|
+
`t=$(readlink -- "$c") || break; ` +
|
|
46
|
+
`case "$t" in "") break ;; /*) c="$t" ;; ` +
|
|
47
|
+
`*) d="${"${c%/*}"}"; case "$d" in "") c="/$t" ;; */) c="$d$t" ;; *) c="$d/$t" ;; esac ;; esac; ` +
|
|
48
|
+
`printf '%s\\n' "$c"; n=$((n+1)); done; }`);
|
|
49
|
+
}
|
|
50
|
+
export function symlinkChainHopsFromStdout(stdout, addressed) {
|
|
51
|
+
const hops = [];
|
|
52
|
+
const seen = new Set([addressed]);
|
|
53
|
+
for (const raw of stdout.split("\n")) {
|
|
54
|
+
const hop = raw.trim();
|
|
55
|
+
if (hop === "")
|
|
56
|
+
continue;
|
|
57
|
+
if (seen.has(hop))
|
|
58
|
+
break;
|
|
59
|
+
seen.add(hop);
|
|
60
|
+
hops.push(hop);
|
|
61
|
+
if (hops.length >= SYMLINK_CHAIN_MAX_HOPS)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
return hops;
|
|
65
|
+
}
|
|
66
|
+
export async function runSymlinkChain(absPath, run) {
|
|
67
|
+
const r = await run(buildSymlinkChainCommand(absPath));
|
|
68
|
+
if (!r.ok)
|
|
69
|
+
return { ok: false, error: fileErrorFromExec(r.error, absPath) };
|
|
70
|
+
if (r.value.exitCode !== 0)
|
|
71
|
+
return { ok: false, error: classifyFsStderr(r.value.stderr, absPath, "unknown") };
|
|
72
|
+
return { ok: true, value: { hops: symlinkChainHopsFromStdout(r.value.stdout, absPath) } };
|
|
73
|
+
}
|
|
39
74
|
//# sourceMappingURL=remote-shell.js.map
|