@sema-agent/server 7.103.0 → 7.103.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/MIGRATION.md +40 -10
- package/USAGE.md +12 -4
- package/dist/boot/budget-tracing.d.ts +1 -0
- package/dist/boot/budget-tracing.js +3 -2
- package/dist/boot/config-center.d.ts +5 -0
- package/dist/boot/config-center.js +15 -17
- package/dist/boot/stage-04-stores.d.ts +1 -0
- package/dist/boot/stage-04-stores.js +2 -2
- package/dist/boot/stage-05-execution-env.d.ts +1 -0
- package/dist/boot/stage-06-runners.d.ts +1 -0
- package/dist/boot/stage-07-capability-layer.d.ts +1 -0
- package/dist/boot/stage-08-reapers.d.ts +1 -0
- package/dist/boot/stage-08-reapers.js +2 -2
- package/dist/boot/stage-09-leader.d.ts +1 -0
- package/dist/boot/stage-10-http-server.d.ts +1 -0
- package/dist/brain.d.ts +32 -1
- package/dist/brain.js +24 -4
- package/dist/config-center/apply-effective.d.ts +24 -2
- package/dist/config-center/apply-effective.js +9 -1
- package/dist/config-center/facade.d.ts +1 -1
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/restart-signal.d.ts +40 -3
- package/dist/config-center/restart-signal.js +11 -12
- package/dist/model-entry-refusal.d.ts +2 -1
- package/dist/observability/secret-env-scrub.d.ts +1 -1
- package/dist/observability/secret-env-scrub.js +3 -2
- package/dist/plugins/remote-env-adb.d.ts +10 -3
- package/dist/plugins/remote-env-adb.js +24 -20
- package/dist/plugins/remote-env-k8s.d.ts +8 -1
- package/dist/plugins/remote-env-k8s.js +31 -24
- package/dist/plugins/remote-env-ssh.d.ts +8 -1
- package/dist/plugins/remote-env-ssh.js +24 -18
- package/dist/plugins/remote-shell.d.ts +109 -9
- package/dist/plugins/remote-shell.js +83 -0
- package/dist/server-secret-env.d.ts +21 -29
- package/dist/server-secret-env.js +3 -19
- package/package.json +3 -3
- package/skills/update-config.md +23 -5
|
@@ -5,9 +5,14 @@ export type RestartSlice = "skills" | "mcp" | "a2a" | "scenarios" | "degrade-rou
|
|
|
5
5
|
* slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
|
|
6
6
|
* EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
|
|
7
7
|
export interface RestartSliceCtx {
|
|
8
|
-
/** `
|
|
9
|
-
*
|
|
10
|
-
|
|
8
|
+
/** S-794:反应式降级壳的两侧读数 —— `baked` = createBrain 组壳那一刻焊进去的目标路由指纹,`current` = 同一只
|
|
9
|
+
* 指纹函数(brain.ts `degradeRouteFingerprint`)对本拍活配置再读一次(读法见 brain.ts `createDegradeRouteWatch`)。
|
|
10
|
+
* 缺席 = 调用方没有递席(单元调用)⇒ `degrade-route` 片恒不签;车道没开 / 没焊壳在席内表达为两侧同为 null。
|
|
11
|
+
* (此前的 `reactiveDegradeTo` 位只剩「阀门开着」一义,已并进本席 —— 一位不留两义。) */
|
|
12
|
+
degradeRoute?: {
|
|
13
|
+
readonly baked: unknown;
|
|
14
|
+
readonly current: unknown;
|
|
15
|
+
};
|
|
11
16
|
/** #A4(codex 交叉复审 finding 2,验真后采纳):本机 env **显式占住**的 readFace 键
|
|
12
17
|
* (`applyCenterReadFace` 的返回值,由 boot 存下)。这些键的 center 值在本机**永不 boot-bake**,
|
|
13
18
|
* 所以它们的变化不该产生 restart 理由 —— 否则 center 改一个本机用不上的键,整片 fleet 会为一次
|
|
@@ -40,6 +45,38 @@ export interface RestartSliceCtx {
|
|
|
40
45
|
export type EngineRestartSignal = RestartSignal & {
|
|
41
46
|
reasons: RestartSlice[];
|
|
42
47
|
};
|
|
48
|
+
/** One catalog entry, reduced to the fields whose change is genuinely restart-to-apply. Consumers of a
|
|
49
|
+
* boot-frozen catalog entry read almost every field on the FULLY-DERIVED Model (`toModel`) — id → body.model,
|
|
50
|
+
* baseUrl → the endpoint, provider → which brain, maxTokens/compat/extraBody/input → the request body,
|
|
51
|
+
* apiKeyEnv/sealedApiKey → the credential — so we fingerprint the WHOLE entry minus the three provably HOT
|
|
52
|
+
* fields:
|
|
53
|
+
* - `cost`'s VALUES — refreshed by `mutateInPlace(pricing, buildPricing(config.models))`, and core prefers
|
|
54
|
+
* `deps.pricing[model.id]` over the (possibly frozen) `Model.cost`, so a pure re-pricing
|
|
55
|
+
* needs neither a swap nor a restart. 🔴 B-089:its PRESENCE is subtracted no longer —
|
|
56
|
+
* a roster row WITHOUT `cost` now mints no `cost` key (unpriced) and therefore no pricing
|
|
57
|
+
* row, and core's fallback is literally `deps.pricing?.[id] ?? modelCostToPricing(model.cost)`
|
|
58
|
+
* (core 7.13.0 run-settle-and-teardown.js and three siblings) — so a FROZEN generation
|
|
59
|
+
* would quietly keep billing at the WITHDRAWN rates. Presence rides the fingerprint in both
|
|
60
|
+
* directions (the rule does not branch on direction); the numbers stay subtracted.
|
|
61
|
+
* - `quotaWeight` — refreshed by `mutateInPlace(config.modelQuotaWeights, …)`, read live per burn.
|
|
62
|
+
* - `enabled` — `enabledOnly` already filtered on it, so among the entries that reach here it is
|
|
63
|
+
* `true`-or-absent: two encodings of ONE state. Keeping it would let a center serializer
|
|
64
|
+
* that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
|
|
65
|
+
* Every real enable/disable FLIP changes the filtered SET, so it still signals.
|
|
66
|
+
* A DENY-list (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically,
|
|
67
|
+
* so the failure mode of drift is a spurious restart, never a silently missed one.
|
|
68
|
+
*
|
|
69
|
+
* Shared by BOTH frozen-entry consumers — the `modelPlaneChanged` swap gate (over catalog entries) and brain.ts
|
|
70
|
+
* `degradeRouteFingerprint` (over the Model the reactive-degrade shell welded, S-794) — so "which fields are hot"
|
|
71
|
+
* is answered once. (复审 2026-07-29 留档:the since-retired `models-tiers` slice had
|
|
72
|
+
* kept all three, so on a tiers-active deployment a pure re-pricing / weight tweak / serializer writing
|
|
73
|
+
* `enabled:true` rolling-restarted the fleet.) For the swap gate a subtracted field means "no swap needed": the
|
|
74
|
+
* Runner reads pricing and quota weights live off the maps `mutateInPlace` refreshes. */
|
|
75
|
+
export declare function catalogEntryFingerprint(entry: {
|
|
76
|
+
readonly cost?: unknown;
|
|
77
|
+
readonly quotaWeight?: unknown;
|
|
78
|
+
readonly enabled?: unknown;
|
|
79
|
+
}): unknown;
|
|
43
80
|
/** Restart-to-apply slices that DIFFER between this process's BOOT config and a freshly-pulled one. Empty =
|
|
44
81
|
* nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
|
|
45
82
|
* can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
|
|
@@ -9,7 +9,7 @@ function enabledOnly(rows) {
|
|
|
9
9
|
return null;
|
|
10
10
|
return rows.filter((r) => r.enabled !== false);
|
|
11
11
|
}
|
|
12
|
-
function catalogEntryFingerprint(entry) {
|
|
12
|
+
export function catalogEntryFingerprint(entry) {
|
|
13
13
|
const { cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
|
|
14
14
|
return { ...route, costDeclared: cost !== undefined };
|
|
15
15
|
}
|
|
@@ -18,11 +18,17 @@ function planeEntriesFingerprint(rows) {
|
|
|
18
18
|
return enabled ? enabled.map((m) => catalogEntryFingerprint(m)) : null;
|
|
19
19
|
}
|
|
20
20
|
function restartSlicePair(boot, current, slice, ctx) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
switch (slice) {
|
|
22
|
+
case "memory-consolidation":
|
|
23
|
+
return seatPair(ctx?.consolidationSeat);
|
|
24
|
+
case "degrade-route":
|
|
25
|
+
return seatPair(ctx?.degradeRoute);
|
|
26
|
+
default:
|
|
27
|
+
return [restartSliceValue(boot, slice, ctx), restartSliceValue(current, slice, ctx)];
|
|
24
28
|
}
|
|
25
|
-
|
|
29
|
+
}
|
|
30
|
+
function seatPair(seat) {
|
|
31
|
+
return seat === undefined ? [null, null] : [seat.baked, seat.current];
|
|
26
32
|
}
|
|
27
33
|
function restartSliceValue(eff, slice, ctx) {
|
|
28
34
|
if (!eff)
|
|
@@ -48,13 +54,6 @@ function restartSliceValue(eff, slice, ctx) {
|
|
|
48
54
|
const view = Object.entries(d).filter(([k, v]) => !held.has(k) && !CENTER_READ_FACE_LIVE_KEYS.has(k) && v !== undefined);
|
|
49
55
|
return view.length > 0 ? Object.fromEntries(view) : null;
|
|
50
56
|
}
|
|
51
|
-
case "degrade-route": {
|
|
52
|
-
const to = ctx?.reactiveDegradeTo;
|
|
53
|
-
if (to === undefined)
|
|
54
|
-
return null;
|
|
55
|
-
const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
|
|
56
|
-
return target ? catalogEntryFingerprint(target) : null;
|
|
57
|
-
}
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
export function restartReasons(boot, current, ctx) {
|
|
@@ -83,7 +83,8 @@ export interface ModelRefusalVerdict {
|
|
|
83
83
|
* S-768 —— 判决 → `/health.modelRefusals` 与 fleet 心跳 `modelRefusals` 的 wire 形(settings-schema `ModelRefusalsSignal`,纯投影):
|
|
84
84
|
* 名 → 码,**值只码**(拒因事实里的上游 id / provider 词 / 坏字段键 / 出路句都不上 wire —— `entry_invalid` 行是没人验过的行,id / provider
|
|
85
85
|
* 可能就是坏行原样;句子是给本机运维的散文,不是契约)。**名筛在这里、只在这里**:键过 settings-schema `isReportableRefusalName`(唯一判据,
|
|
86
|
-
* import
|
|
86
|
+
* import 不复制;6.3.0 起 = 标识符白名单 ∧ 无密钥形 ∧ 无 URL userinfo)—— 秘密形 / 空串 / `__proto__` / 不是标识符的串这类出不了门的名不进键、
|
|
87
|
+
* 不计数;引擎内部的判决不受影响(本机日志的写名行为与基线同)。
|
|
87
88
|
* 逐条空且无组拒 ⇒ `undefined`(键缺席:「没有」只有一种拼法)。
|
|
88
89
|
*/
|
|
89
90
|
export declare function buildModelRefusalsSignal(v: ModelRefusalVerdict | undefined): ModelRefusalsSignal | undefined;
|
|
@@ -49,7 +49,7 @@ interface Sink {
|
|
|
49
49
|
/**
|
|
50
50
|
* 记一次剥除。**总不抛** —— 观测本身绝不能变成故障(同 `recordFailOpen` / reapers 的判据)。
|
|
51
51
|
* `findings` 为空(这一次没丢任何 key)时什么也不做:那不是事件,是常态。
|
|
52
|
-
* `findings`
|
|
52
|
+
* `findings` 只许装**没有按原样交给子进程**的键(剥掉 / 换成 core 占位半条)—— 点名放回的键取回源值,不是剥除(不计数、不披露)。
|
|
53
53
|
* `allowed` = 这一次生效的点名放行名单(S-699),只在有出路的现场(`remedy` 在场)写进披露行;没有放行面的腿传 `[]`。
|
|
54
54
|
*/
|
|
55
55
|
export declare function recordSecretEnvScrub(site: SecretEnvScrubSite, findings: readonly ServerSecretEnvFinding[], allowed: readonly string[]): void;
|
|
@@ -27,6 +27,7 @@ export const SECRET_ENV_SCRUB_SITES = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
const SCRUB_COUNTER = "secret_env_scrubbed_total";
|
|
30
|
+
const SCRUB_COUNTER_HELP = "secret env entries not handed on as written (dropped or replaced in place) to a spawned child process, by site/kind/confidence (core scrubSecretEnv rules + server config-catalog secret names + name-list knobs)";
|
|
30
31
|
const DISCLOSED_CAP = 512;
|
|
31
32
|
const SEP = "=";
|
|
32
33
|
let sink = {};
|
|
@@ -90,7 +91,7 @@ export function recordSecretEnvScrub(site, findings, allowed) {
|
|
|
90
91
|
}
|
|
91
92
|
export function installSecretEnvScrubRecorder(deps) {
|
|
92
93
|
sink = { ...deps };
|
|
93
|
-
deps.metrics?.counter(SCRUB_COUNTER,
|
|
94
|
+
deps.metrics?.counter(SCRUB_COUNTER, SCRUB_COUNTER_HELP);
|
|
94
95
|
drainToMetrics();
|
|
95
96
|
}
|
|
96
97
|
export function secretEnvScrubDisclosedSize() {
|
|
@@ -104,6 +105,6 @@ export function resetSecretEnvScrubRecorderForTest(deps = {}) {
|
|
|
104
105
|
pushed = new Map();
|
|
105
106
|
disclosed = new Set();
|
|
106
107
|
sink = { ...deps };
|
|
107
|
-
deps.metrics?.counter(SCRUB_COUNTER,
|
|
108
|
+
deps.metrics?.counter(SCRUB_COUNTER, SCRUB_COUNTER_HELP);
|
|
108
109
|
}
|
|
109
110
|
//# sourceMappingURL=secret-env-scrub.js.map
|
|
@@ -149,6 +149,11 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
|
|
|
149
149
|
stderr: string;
|
|
150
150
|
exitCode: number;
|
|
151
151
|
}, ExecutionError>>;
|
|
152
|
+
/** 文件面探针的 exec(车PL 验修 ③):只用绝对路径,**不进工作目录**、没有 cwd 守卫 —— 工作目录进不去时探针照样答真话(`permission_denied` /
|
|
153
|
+
* `not_found` 由命令自己的 stderr 归),core 的 `isDeadCwd` 不会把「进不去」读成「没了」。 */
|
|
154
|
+
private fsExec;
|
|
155
|
+
/** buffered exec 的唯一体:`call` 定了整串、stderr 的 started 行剥法与收口(`remote-shell.ts` `createCwdGuardedCall`)。 */
|
|
156
|
+
private execCall;
|
|
152
157
|
execStream(command: string, options?: ExecStreamOptions): AsyncIterable<OutputChunk>;
|
|
153
158
|
absolutePath(p: string): Promise<Result<string, FileError>>;
|
|
154
159
|
joinPath(parts: string[]): Promise<Result<string, FileError>>;
|
|
@@ -230,11 +235,13 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
|
|
|
230
235
|
* 远端退出码与分流的那根旗标,即「一条以 `-x` 开头的命令能静默把本修关回去」。真机验证 `--` 之后的
|
|
231
236
|
* 参数原样落到设备 shell(`adb shell -T -- '-x'` ⇒ 设备答 `/system/bin/sh: -c: requires an argument`)。
|
|
232
237
|
*
|
|
233
|
-
* 命令永远是**最后一个**参数,整条包在一个 argv 元素里
|
|
234
|
-
*
|
|
238
|
+
* 命令永远是**最后一个**参数,整条包在一个 argv 元素里 —— 设备侧 `sh -c` 收全串,即 {@link buildCwdEnvCommand} /
|
|
239
|
+
* {@link createCwdGuardedCall} 组出的那一串(带工作目录时含 cwd 守卫前缀:cd 失败 ⇒ 整条不跑)。
|
|
235
240
|
*/
|
|
236
241
|
private deviceShellArgs;
|
|
237
|
-
|
|
242
|
+
/** 本腿只定工作目录(缺省 cwd 规则 + 绝对化);串的形状归 {@link buildCwdEnvCommand} / {@link createCwdGuardedCall}(S-410:cwd 不可用 ⇒ 命令不跑;
|
|
243
|
+
* 车PL:buffered `exec` 上守卫触发 ⇒ `spawn_error`)。 */
|
|
244
|
+
private workingDirOf;
|
|
238
245
|
}
|
|
239
246
|
/**
|
|
240
247
|
* `ExecutionEnvFactory` for the ADB backend — one fresh (unconnected) env per task against the same device
|
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
|
|
5
|
+
import { buildCwdEnvCommand, createCwdGuardedCall, 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
8
|
import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
|
|
@@ -147,15 +147,21 @@ export class RemoteAdbExecutionEnv {
|
|
|
147
147
|
return this.destroy();
|
|
148
148
|
}
|
|
149
149
|
async exec(command, options) {
|
|
150
|
+
return this.execCall(createCwdGuardedCall("adb", command, this.workingDirOf(options?.cwd), options?.env), options);
|
|
151
|
+
}
|
|
152
|
+
fsExec(command, abortSignal) {
|
|
153
|
+
return this.execCall(createCwdGuardedCall("adb", command, undefined, undefined), { abortSignal });
|
|
154
|
+
}
|
|
155
|
+
async execCall(call, options) {
|
|
150
156
|
const ready = await this.ensureConnected();
|
|
151
157
|
if (!ready.ok)
|
|
152
158
|
return { ok: false, error: lazyConnectExecError(ready.error) };
|
|
153
|
-
const
|
|
154
|
-
const r = await this.adb(this.deviceShellArgs(wrapped), {
|
|
159
|
+
const r = await this.adb(this.deviceShellArgs(call.command), {
|
|
155
160
|
signal: options?.abortSignal,
|
|
156
161
|
timeoutMs: options?.timeout != null ? options.timeout * 1000 : this.cfg.commandTimeoutMs,
|
|
157
162
|
onStdout: options?.onStdout,
|
|
158
163
|
onStderr: options?.onStderr,
|
|
164
|
+
stderrFilter: call,
|
|
159
165
|
});
|
|
160
166
|
if (!r.ok)
|
|
161
167
|
return { ok: false, error: r.error };
|
|
@@ -163,7 +169,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
163
169
|
this.connected = false;
|
|
164
170
|
return { ok: false, error: new ExecutionError("transport_lost", `adb transport lost mid-command: ${r.value.stderr.trim().slice(0, 200)} — reconnect + retry per idempotency`) };
|
|
165
171
|
}
|
|
166
|
-
return ok(r.value);
|
|
172
|
+
return call.settle(ok(r.value));
|
|
167
173
|
}
|
|
168
174
|
async *execStream(command, options) {
|
|
169
175
|
const ready = await this.ensureConnected();
|
|
@@ -171,7 +177,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
171
177
|
throw ready.error;
|
|
172
178
|
if (this.destroyed)
|
|
173
179
|
throw new RemoteExecutionError("connect_failed", "execution env already destroyed");
|
|
174
|
-
const wrapped =
|
|
180
|
+
const wrapped = buildCwdEnvCommand(command, this.workingDirOf(options?.cwd), options?.env, "raw").command;
|
|
175
181
|
const child = this.spawnFn(this.cfg.adbPath, this.deviceShellArgs(wrapped), { stdio: ["ignore", "pipe", "pipe"] });
|
|
176
182
|
const queue = [];
|
|
177
183
|
let finished = false;
|
|
@@ -327,7 +333,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
327
333
|
const abs = this.resolve(p);
|
|
328
334
|
const dir = path.posix.dirname(abs);
|
|
329
335
|
if (dir && dir !== "." && dir !== "/") {
|
|
330
|
-
const mk = await this.
|
|
336
|
+
const mk = await this.fsExec(`mkdir -p ${shellQuote(dir)}`, abortSignal);
|
|
331
337
|
if (!mk.ok)
|
|
332
338
|
return { ok: false, error: fileErrorFromExec(mk.error, dir) };
|
|
333
339
|
if (mk.value.exitCode !== 0)
|
|
@@ -374,7 +380,7 @@ export class RemoteAdbExecutionEnv {
|
|
|
374
380
|
posixFsInst;
|
|
375
381
|
get posixFs() {
|
|
376
382
|
return (this.posixFsInst ??= createPosixShellFs({
|
|
377
|
-
run: (cmd, abortSignal) => this.
|
|
383
|
+
run: (cmd, abortSignal) => this.fsExec(cmd, abortSignal),
|
|
378
384
|
resolve: (p) => this.resolve(p),
|
|
379
385
|
tempDir: () => this.cwd,
|
|
380
386
|
}));
|
|
@@ -456,14 +462,19 @@ export class RemoteAdbExecutionEnv {
|
|
|
456
462
|
outTail.push(d);
|
|
457
463
|
opts?.onStdout?.(d.toString());
|
|
458
464
|
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
465
|
+
const takeStderr = (bytes) => {
|
|
466
|
+
if (bytes.length === 0)
|
|
467
|
+
return;
|
|
468
|
+
errBuf.push(bytes);
|
|
469
|
+
opts?.onStderr?.(bytes.toString());
|
|
470
|
+
};
|
|
471
|
+
child.stderr.on("data", (d) => takeStderr(opts?.stderrFilter ? opts.stderrFilter.filterStderr(d) : d));
|
|
463
472
|
child.on("error", (e) => finish({ ok: false, error: new ExecutionError("spawn_error", `adb spawn failed (is '${this.cfg.adbPath}' installed?): ${e.message}`, e) }));
|
|
464
473
|
child.on("close", (code, signal) => {
|
|
465
474
|
if (forceSettleTimer)
|
|
466
475
|
clearTimeout(forceSettleTimer);
|
|
476
|
+
if (opts?.stderrFilter)
|
|
477
|
+
takeStderr(opts.stderrFilter.endStderr());
|
|
467
478
|
const err = errBuf.result();
|
|
468
479
|
const stderr = markTruncated(err.text, err.droppedBytes);
|
|
469
480
|
const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0);
|
|
@@ -481,16 +492,9 @@ export class RemoteAdbExecutionEnv {
|
|
|
481
492
|
deviceShellArgs(command) {
|
|
482
493
|
return ["-s", this.cfg.serial, "shell", "-T", "--", command];
|
|
483
494
|
}
|
|
484
|
-
|
|
485
|
-
const parts = [];
|
|
495
|
+
workingDirOf(cwd) {
|
|
486
496
|
const dir = cwd ?? this.cwd;
|
|
487
|
-
|
|
488
|
-
parts.push(`cd ${shellQuote(this.resolve(dir))} &&`);
|
|
489
|
-
if (env)
|
|
490
|
-
for (const [k, v] of Object.entries(env))
|
|
491
|
-
parts.push(`export ${k}=${shellQuote(v)};`);
|
|
492
|
-
parts.push(command);
|
|
493
|
-
return parts.join(" ");
|
|
497
|
+
return dir ? this.resolve(dir) : undefined;
|
|
494
498
|
}
|
|
495
499
|
}
|
|
496
500
|
export function adbExecutionEnvFactory(config) {
|
|
@@ -245,6 +245,11 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
245
245
|
shellId: string;
|
|
246
246
|
};
|
|
247
247
|
}, ExecutionError>>;
|
|
248
|
+
/** 文件面探针的 exec(车PL 验修 ③):只用绝对路径,**不进工作目录**、没有 cwd 守卫 —— 工作目录进不去时探针照样答真话(`permission_denied` /
|
|
249
|
+
* `not_found` 由命令自己的 stderr 归),core 的 `isDeadCwd` 不会把「进不去」读成「没了」。 */
|
|
250
|
+
private fsExec;
|
|
251
|
+
/** buffered exec 的唯一体:`call` 定了整串、stderr 的 started 行剥法与收口(`remote-shell.ts` `createCwdGuardedCall`)。 */
|
|
252
|
+
private execCall;
|
|
248
253
|
/**
|
|
249
254
|
* LSP surface (lsp/manager.ts `isLspCapable` duck-type, [ref] §13.1): cluster-internal address for a
|
|
250
255
|
* port on the sandbox pod. The E2B counterpart returns a public proxy host; a pod has none — the WORKER
|
|
@@ -333,7 +338,9 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
333
338
|
}): Promise<Result<string, FileError>>;
|
|
334
339
|
/** exec with the data-transfer bound (large reads/writes legitimately run longer than a control op). */
|
|
335
340
|
private execData;
|
|
336
|
-
|
|
341
|
+
/** 本腿只定工作目录(缺省 cwd 规则 + 绝对化);串的形状归 {@link buildCwdEnvCommand} / {@link createCwdGuardedCall}(S-410:cwd 不可用 ⇒ 命令不跑;
|
|
342
|
+
* 车PL:buffered `exec` 上守卫触发 ⇒ `spawn_error`)。 */
|
|
343
|
+
private workingDirOf;
|
|
337
344
|
}
|
|
338
345
|
/**
|
|
339
346
|
* `ExecutionEnvFactory` for the k8s pod-sandbox backend — one fresh (unconnected) env per task; the pod is
|
|
@@ -4,7 +4,7 @@ import https from "node:https";
|
|
|
4
4
|
import http from "node:http";
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
6
6
|
import WebSocket from "ws";
|
|
7
|
-
import { shellQuote, kindFromMode, numEnvOr } from "./remote-shell.js";
|
|
7
|
+
import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode, numEnvOr } from "./remote-shell.js";
|
|
8
8
|
import { presignS3Url } from "./s3-presign.js";
|
|
9
9
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
10
10
|
import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
|
|
@@ -479,6 +479,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
479
479
|
return onAbort();
|
|
480
480
|
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
481
481
|
}
|
|
482
|
+
const takeStderr = (bytes) => {
|
|
483
|
+
if (bytes.length === 0)
|
|
484
|
+
return;
|
|
485
|
+
errBuf.push(bytes);
|
|
486
|
+
if (detachTee)
|
|
487
|
+
feedMemStream(detachTee.err, bytes, K8S_BG_MEM_CAP);
|
|
488
|
+
if (errDecoder)
|
|
489
|
+
opts.onStderr(errDecoder.write(bytes));
|
|
490
|
+
};
|
|
482
491
|
sock.on("message", (...args) => {
|
|
483
492
|
const data = args[0];
|
|
484
493
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
@@ -505,11 +514,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
505
514
|
opts.onStdout(outDecoder.write(payload));
|
|
506
515
|
}
|
|
507
516
|
else if (channel === 2) {
|
|
508
|
-
|
|
509
|
-
if (detachTee)
|
|
510
|
-
feedMemStream(detachTee.err, payload, K8S_BG_MEM_CAP);
|
|
511
|
-
if (errDecoder)
|
|
512
|
-
opts.onStderr(errDecoder.write(payload));
|
|
517
|
+
takeStderr(opts?.stderrFilter ? opts.stderrFilter.filterStderr(payload) : payload);
|
|
513
518
|
}
|
|
514
519
|
else if (channel === 3) {
|
|
515
520
|
statusFrame = payload.toString("utf8");
|
|
@@ -545,6 +550,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
545
550
|
finish({ ok: false, error: new ExecutionError("spawn_error", parsed.error) });
|
|
546
551
|
return;
|
|
547
552
|
}
|
|
553
|
+
if (opts?.stderrFilter)
|
|
554
|
+
takeStderr(opts.stderrFilter.endStderr());
|
|
548
555
|
const err = errBuf.result();
|
|
549
556
|
const stderr = markTruncated(err.text, err.droppedBytes);
|
|
550
557
|
if (boundOutput) {
|
|
@@ -584,31 +591,38 @@ export class RemoteK8sExecutionEnv {
|
|
|
584
591
|
});
|
|
585
592
|
}
|
|
586
593
|
async exec(command, options) {
|
|
594
|
+
return this.execCall(createCwdGuardedCall("k8s", command, this.workingDirOf(options?.cwd), options?.env), options);
|
|
595
|
+
}
|
|
596
|
+
fsExec(command, abortSignal) {
|
|
597
|
+
return this.execCall(createCwdGuardedCall("k8s", command, undefined, undefined), { abortSignal });
|
|
598
|
+
}
|
|
599
|
+
async execCall(call, options) {
|
|
587
600
|
const c = await this.ensureConnected();
|
|
588
601
|
if (!c.ok)
|
|
589
602
|
return c;
|
|
590
603
|
const detachSignal = options?.detachSignal;
|
|
591
604
|
this.inFlightExecs++;
|
|
592
605
|
try {
|
|
593
|
-
const composed = this.withCwdEnv(command, options?.cwd, options?.env);
|
|
594
606
|
if (!detachSignal) {
|
|
595
|
-
return await this.runExec(
|
|
607
|
+
return call.settle(await this.runExec(call.command, {
|
|
596
608
|
signal: options?.abortSignal,
|
|
597
609
|
timeoutMs: options?.timeout != null ? options.timeout * 1000 : undefined,
|
|
598
610
|
boundOutput: true,
|
|
599
611
|
onStdout: options?.onStdout,
|
|
600
612
|
onStderr: options?.onStderr,
|
|
601
|
-
|
|
613
|
+
stderrFilter: call,
|
|
614
|
+
}));
|
|
602
615
|
}
|
|
603
616
|
const adoptId = randomUUID();
|
|
604
617
|
const dir = `${K8S_BG_DIR_ROOT}/exec-${adoptId}`;
|
|
605
618
|
const tee = { out: seedMemStream(""), err: seedMemStream("") };
|
|
606
|
-
return await this.runExec(buildDetachCapableExec(dir,
|
|
619
|
+
return call.settle(await this.runExec(buildDetachCapableExec(dir, call.command), {
|
|
607
620
|
signal: options?.abortSignal,
|
|
608
621
|
timeoutMs: options?.timeout != null ? options.timeout * 1000 : undefined,
|
|
609
622
|
boundOutput: true,
|
|
610
623
|
onStdout: options?.onStdout,
|
|
611
624
|
onStderr: options?.onStderr,
|
|
625
|
+
stderrFilter: call,
|
|
612
626
|
detach: {
|
|
613
627
|
signal: detachSignal,
|
|
614
628
|
tee,
|
|
@@ -624,7 +638,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
624
638
|
return adopted?.shellId;
|
|
625
639
|
},
|
|
626
640
|
},
|
|
627
|
-
});
|
|
641
|
+
}));
|
|
628
642
|
}
|
|
629
643
|
finally {
|
|
630
644
|
this.inFlightExecs--;
|
|
@@ -665,7 +679,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
665
679
|
let bytes = 0;
|
|
666
680
|
const ac = new AbortController();
|
|
667
681
|
const signal = options?.signal ? AbortSignal.any([options.signal, ac.signal]) : ac.signal;
|
|
668
|
-
const done = this.runExec(
|
|
682
|
+
const done = this.runExec(buildCwdEnvCommand(command, this.workingDirOf(options?.cwd), options?.env, "raw").command, {
|
|
669
683
|
signal,
|
|
670
684
|
boundOutput: true,
|
|
671
685
|
onStdout: (d) => {
|
|
@@ -752,7 +766,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
752
766
|
throw new Error(`pod connect failed: ${conn.error.message}`);
|
|
753
767
|
const jobId = randomUUID();
|
|
754
768
|
const dir = `${K8S_BG_DIR_ROOT}/${jobId}`;
|
|
755
|
-
const inner =
|
|
769
|
+
const inner = buildCwdEnvCommand(command, self.workingDirOf(options?.cwd), options?.env, "raw").command;
|
|
756
770
|
const cmdB64 = Buffer.from(inner, "utf8").toString("base64");
|
|
757
771
|
const runnerB64 = Buffer.from(buildBgRunnerScript(dir), "utf8").toString("base64");
|
|
758
772
|
const r = await bgExec(buildBgLauncherScript(dir, cmdB64, runnerB64), true);
|
|
@@ -868,7 +882,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
868
882
|
const dir = path.posix.dirname(abs);
|
|
869
883
|
if (!dir || dir === "." || dir === "/")
|
|
870
884
|
return undefined;
|
|
871
|
-
const mk = await this.
|
|
885
|
+
const mk = await this.fsExec(`mkdir -p ${shellQuote(dir)}`, abortSignal);
|
|
872
886
|
if (!mk.ok)
|
|
873
887
|
return { ok: false, error: fileErrorFromExec(mk.error, dir) };
|
|
874
888
|
if (mk.value.exitCode !== 0)
|
|
@@ -905,7 +919,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
905
919
|
posixFsInst;
|
|
906
920
|
get posixFs() {
|
|
907
921
|
return (this.posixFsInst ??= createPosixShellFs({
|
|
908
|
-
run: (cmd, abortSignal) => this.
|
|
922
|
+
run: (cmd, abortSignal) => this.fsExec(cmd, abortSignal),
|
|
909
923
|
resolve: (p) => this.resolve(p),
|
|
910
924
|
tempDir: () => this.cwd,
|
|
911
925
|
}));
|
|
@@ -946,16 +960,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
946
960
|
return c;
|
|
947
961
|
return this.runExec(command, { signal: abortSignal, timeoutMs: this.cfg.dataTimeoutMs });
|
|
948
962
|
}
|
|
949
|
-
|
|
950
|
-
const parts = [];
|
|
963
|
+
workingDirOf(cwd) {
|
|
951
964
|
const dir = cwd ?? this.cwd;
|
|
952
|
-
|
|
953
|
-
parts.push(`cd ${shellQuote(this.resolve(dir))} &&`);
|
|
954
|
-
if (env)
|
|
955
|
-
for (const [k, v] of Object.entries(env))
|
|
956
|
-
parts.push(`export ${k}=${shellQuote(v)};`);
|
|
957
|
-
parts.push(command);
|
|
958
|
-
return parts.join(" ");
|
|
965
|
+
return dir ? this.resolve(dir) : undefined;
|
|
959
966
|
}
|
|
960
967
|
}
|
|
961
968
|
export function k8sExecutionEnvFactory(config) {
|
|
@@ -154,6 +154,11 @@ export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv {
|
|
|
154
154
|
stderr: string;
|
|
155
155
|
exitCode: number;
|
|
156
156
|
}, ExecutionError>>;
|
|
157
|
+
/** 文件面探针的 exec(车PL 验修 ③):只用绝对路径,**不进工作目录**、没有 cwd 守卫 —— 工作目录进不去时探针照样答真话(`permission_denied` /
|
|
158
|
+
* `not_found` 由命令自己的 stderr 归),core 的 `isDeadCwd` 不会把「进不去」读成「没了」。 */
|
|
159
|
+
private fsExec;
|
|
160
|
+
/** buffered exec 的唯一体:`call` 定了整串、stderr 的 started 行剥法与收口(`remote-shell.ts` `createCwdGuardedCall`)。 */
|
|
161
|
+
private execCall;
|
|
157
162
|
execStream(command: string, options?: ExecStreamOptions): AsyncIterable<OutputChunk>;
|
|
158
163
|
private sftp;
|
|
159
164
|
absolutePath(p: string): Promise<Result<string, FileError>>;
|
|
@@ -192,7 +197,9 @@ export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv {
|
|
|
192
197
|
suffix?: string;
|
|
193
198
|
abortSignal?: AbortSignal;
|
|
194
199
|
}): Promise<Result<string, FileError>>;
|
|
195
|
-
|
|
200
|
+
/** 本腿只定工作目录(缺省 cwd 规则 + 绝对化);串的形状归 {@link buildCwdEnvCommand} / {@link createCwdGuardedCall}(S-410:cwd 不可用 ⇒ 命令不跑;
|
|
201
|
+
* 车PL:buffered `exec` 上守卫触发 ⇒ `spawn_error`)。 */
|
|
202
|
+
private workingDirOf;
|
|
196
203
|
private connErr;
|
|
197
204
|
private fileError;
|
|
198
205
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Client } from "ssh2";
|
|
3
3
|
import { createSshHostKeyVerifier } from "../ssh-host-key.js";
|
|
4
|
-
import { shellQuote, kindFromMode } from "./remote-shell.js";
|
|
4
|
+
import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode } from "./remote-shell.js";
|
|
5
5
|
import { signalNumber } from "./host-platform.js";
|
|
6
6
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
7
7
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
@@ -234,6 +234,12 @@ export class RemoteSshExecutionEnv {
|
|
|
234
234
|
return this.destroy();
|
|
235
235
|
}
|
|
236
236
|
async exec(command, options) {
|
|
237
|
+
return this.execCall(createCwdGuardedCall("ssh", command, this.workingDirOf(options?.cwd), options?.env), options);
|
|
238
|
+
}
|
|
239
|
+
fsExec(command, abortSignal) {
|
|
240
|
+
return this.execCall(createCwdGuardedCall("ssh", command, undefined, undefined), { abortSignal });
|
|
241
|
+
}
|
|
242
|
+
async execCall(call, options) {
|
|
237
243
|
const ready = await this.ensureConnected(options?.abortSignal);
|
|
238
244
|
if (!ready.ok)
|
|
239
245
|
return ready;
|
|
@@ -269,8 +275,7 @@ export class RemoteSshExecutionEnv {
|
|
|
269
275
|
return onAbort();
|
|
270
276
|
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
271
277
|
}
|
|
272
|
-
|
|
273
|
-
conn.exec(full, (err, stream) => {
|
|
278
|
+
conn.exec(call.command, (err, stream) => {
|
|
274
279
|
if (err)
|
|
275
280
|
return finish({ ok: false, error: new ExecutionError("spawn_error", `ssh exec failed: ${err.message}`, err) });
|
|
276
281
|
streamRef = stream;
|
|
@@ -291,14 +296,22 @@ export class RemoteSshExecutionEnv {
|
|
|
291
296
|
if (code == null && !signal) {
|
|
292
297
|
return finish({ ok: false, error: new ExecutionError("transport_lost", "ssh channel closed without exit status (transport lost / connection dropped)") });
|
|
293
298
|
}
|
|
299
|
+
const tail = call.endStderr();
|
|
300
|
+
if (tail.length > 0) {
|
|
301
|
+
errBuf.push(tail);
|
|
302
|
+
options?.onStderr?.(tail.toString());
|
|
303
|
+
}
|
|
294
304
|
const out = outBuf.result();
|
|
295
305
|
const err = errBuf.result();
|
|
296
306
|
const exitCode = code ?? (signal ? exitCodeForSignalKill(signal) : 1);
|
|
297
|
-
finish(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr: markTruncated(err.text, err.droppedBytes), exitCode }));
|
|
307
|
+
finish(call.settle(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr: markTruncated(err.text, err.droppedBytes), exitCode })));
|
|
298
308
|
})
|
|
299
309
|
.stderr.on("data", (d) => {
|
|
300
|
-
|
|
301
|
-
|
|
310
|
+
const c = call.filterStderr(d);
|
|
311
|
+
if (c.length === 0)
|
|
312
|
+
return;
|
|
313
|
+
errBuf.push(c);
|
|
314
|
+
options?.onStderr?.(c.toString());
|
|
302
315
|
});
|
|
303
316
|
});
|
|
304
317
|
});
|
|
@@ -347,7 +360,7 @@ export class RemoteSshExecutionEnv {
|
|
|
347
360
|
catch {
|
|
348
361
|
}
|
|
349
362
|
};
|
|
350
|
-
const full =
|
|
363
|
+
const full = buildCwdEnvCommand(command, this.workingDirOf(options?.cwd), options?.env, "raw").command;
|
|
351
364
|
conn.exec(full, (err, stream) => {
|
|
352
365
|
if (err) {
|
|
353
366
|
failure = new RemoteExecutionError("unknown", `ssh exec failed: ${err.message}`, err);
|
|
@@ -480,7 +493,7 @@ export class RemoteSshExecutionEnv {
|
|
|
480
493
|
const abs = this.resolve(p);
|
|
481
494
|
const dir = path.posix.dirname(abs);
|
|
482
495
|
if (dir && dir !== "." && dir !== "/") {
|
|
483
|
-
const mk = await this.
|
|
496
|
+
const mk = await this.fsExec(`mkdir -p -- ${shellQuote(dir)}`, abortSignal);
|
|
484
497
|
if (!mk.ok)
|
|
485
498
|
return { ok: false, error: fileErrorFromExec(mk.error, dir) };
|
|
486
499
|
if (mk.value.exitCode !== 0)
|
|
@@ -540,7 +553,7 @@ export class RemoteSshExecutionEnv {
|
|
|
540
553
|
posixFsInst;
|
|
541
554
|
get posixFs() {
|
|
542
555
|
return (this.posixFsInst ??= createPosixShellFs({
|
|
543
|
-
run: (cmd, abortSignal) => this.
|
|
556
|
+
run: (cmd, abortSignal) => this.fsExec(cmd, abortSignal),
|
|
544
557
|
resolve: (p) => this.resolve(p),
|
|
545
558
|
tempDir: () => null,
|
|
546
559
|
}));
|
|
@@ -574,16 +587,9 @@ export class RemoteSshExecutionEnv {
|
|
|
574
587
|
async createTempFile(options) {
|
|
575
588
|
return this.posixFs.createTempFile(options);
|
|
576
589
|
}
|
|
577
|
-
|
|
578
|
-
const parts = [];
|
|
590
|
+
workingDirOf(cwd) {
|
|
579
591
|
const dir = cwd ?? (this.cwd !== "." ? this.cwd : undefined);
|
|
580
|
-
|
|
581
|
-
parts.push(`cd ${shellQuote(this.resolve(dir))} &&`);
|
|
582
|
-
if (env)
|
|
583
|
-
for (const [k, v] of Object.entries(env))
|
|
584
|
-
parts.push(`export ${k}=${shellQuote(v)};`);
|
|
585
|
-
parts.push(command);
|
|
586
|
-
return parts.join(" ");
|
|
592
|
+
return dir ? this.resolve(dir) : undefined;
|
|
587
593
|
}
|
|
588
594
|
connErr(e) {
|
|
589
595
|
const msg = e.message || String(e);
|