@sema-agent/server 7.103.0 → 7.103.2

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 (39) hide show
  1. package/MIGRATION.md +55 -10
  2. package/USAGE.md +20 -6
  3. package/dist/boot/budget-tracing.d.ts +1 -0
  4. package/dist/boot/budget-tracing.js +3 -2
  5. package/dist/boot/config-center.d.ts +5 -0
  6. package/dist/boot/config-center.js +15 -17
  7. package/dist/boot/stage-04-stores.d.ts +1 -0
  8. package/dist/boot/stage-04-stores.js +2 -2
  9. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  10. package/dist/boot/stage-06-runners.d.ts +1 -0
  11. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  12. package/dist/boot/stage-08-reapers.d.ts +1 -0
  13. package/dist/boot/stage-08-reapers.js +2 -2
  14. package/dist/boot/stage-09-leader.d.ts +1 -0
  15. package/dist/boot/stage-10-http-server.d.ts +1 -0
  16. package/dist/brain.d.ts +32 -1
  17. package/dist/brain.js +24 -4
  18. package/dist/config-center/apply-effective.d.ts +24 -2
  19. package/dist/config-center/apply-effective.js +9 -1
  20. package/dist/config-center/facade.d.ts +1 -1
  21. package/dist/config-center/facade.js +1 -1
  22. package/dist/config-center/restart-signal.d.ts +40 -3
  23. package/dist/config-center/restart-signal.js +11 -12
  24. package/dist/host-lsp-manager.d.ts +1 -1
  25. package/dist/model-entry-refusal.d.ts +2 -1
  26. package/dist/observability/secret-env-scrub.d.ts +1 -1
  27. package/dist/observability/secret-env-scrub.js +3 -2
  28. package/dist/plugins/remote-env-adb.d.ts +10 -3
  29. package/dist/plugins/remote-env-adb.js +24 -20
  30. package/dist/plugins/remote-env-k8s.d.ts +8 -1
  31. package/dist/plugins/remote-env-k8s.js +31 -24
  32. package/dist/plugins/remote-env-ssh.d.ts +8 -1
  33. package/dist/plugins/remote-env-ssh.js +24 -18
  34. package/dist/plugins/remote-shell.d.ts +109 -9
  35. package/dist/plugins/remote-shell.js +83 -0
  36. package/dist/server-secret-env.d.ts +23 -31
  37. package/dist/server-secret-env.js +3 -19
  38. package/package.json +3 -3
  39. 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
- /** `config.degrade.to` when REACTIVE degrade is on (env `MODEL_DEGRADE_TO` + `MODEL_DEGRADE_REACTIVE`);
9
- * undefined = the lane is off ⇒ no degrading brain was composed ⇒ nothing is boot-frozen. */
10
- reactiveDegradeTo?: string;
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
- if (slice === "memory-consolidation") {
22
- const seat = ctx?.consolidationSeat;
23
- return seat === undefined ? [null, null] : [seat.baked, seat.current];
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
- return [restartSliceValue(boot, slice, ctx), restartSliceValue(current, slice, ctx)];
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) {
@@ -12,7 +12,7 @@
12
12
  * 语言服务器子进程的 env = `scrubServerSecrets`(process.env) ∪ 部署方点名放回的键(S-699,见下节;缺省空名单)——
13
13
  * 剥除与模型可驱动的 host shell(`inheritEnv:"scrub"`)
14
14
  * 和 project-memory 的 git 子进程**同一只函数**:目录 `type:"secret"` 键按名剥 ∘ core 形状规则按形剥。只交 core 的默认
15
- * 会漏掉形状规则认不出的本仓凭据键(今天是 `OTEL_EXPORTER_OTLP_HEADERS`),那正是 S-684 当初的病形。
15
+ * 会漏掉形状规则认不出的本仓凭据键(当时是 `OTEL_EXPORTER_OTLP_HEADERS`,core 7.33.7 #1180 起 core 按名剥它;今天只剩名单型旋钮三只),那正是 S-684 当初的病形。
16
16
  * 剥掉了什么经既有观测面留痕(site = `server.lsp.spawn-env`,每 site×key 一次披露 + 逐次计数)—— core 那侧
17
17
  * 刻意不报(无收集器,KL),本仓这一格补上。
18
18
  *
@@ -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 不复制)—— 秘密形 / 空串 / `__proto__` 这类出不了门的名不进键、不计数;引擎内部的判决不受影响(本机日志的写名行为与基线同)。
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, "secret env entries dropped before spawning a child process, by site/rule/confidence (core scrubSecretEnv shape rule + server config-catalog secret names)");
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, "secret env entries dropped before spawning a child process, by site/rule/confidence (core scrubSecretEnv shape rule + server config-catalog secret names)");
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 元素里(设备侧 `sh -c` 收全串,与 `withCwdEnv` 的
234
- * `cd … && …` 前缀语义一致)。
238
+ * 命令永远是**最后一个**参数,整条包在一个 argv 元素里 —— 设备侧 `sh -c` 收全串,即 {@link buildCwdEnvCommand} /
239
+ * {@link createCwdGuardedCall} 组出的那一串(带工作目录时含 cwd 守卫前缀:cd 失败 ⇒ 整条不跑)。
235
240
  */
236
241
  private deviceShellArgs;
237
- private withCwdEnv;
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 wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
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 = this.withCwdEnv(command, options?.cwd, options?.env);
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.exec(`mkdir -p ${shellQuote(dir)}`, { abortSignal });
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.exec(cmd, { abortSignal }),
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
- child.stderr.on("data", (d) => {
460
- errBuf.push(d);
461
- opts?.onStderr?.(d.toString());
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
- withCwdEnv(command, cwd, env) {
485
- const parts = [];
495
+ workingDirOf(cwd) {
486
496
  const dir = cwd ?? this.cwd;
487
- if (dir)
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
- private withCwdEnv;
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
- errBuf.push(payload);
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(composed, {
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, composed), {
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(this.withCwdEnv(command, options?.cwd, options?.env), {
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 = self.withCwdEnv(command, options?.cwd, options?.env);
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.exec(`mkdir -p ${shellQuote(dir)}`, { abortSignal });
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.exec(cmd, { abortSignal }),
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
- withCwdEnv(command, cwd, env) {
950
- const parts = [];
963
+ workingDirOf(cwd) {
951
964
  const dir = cwd ?? this.cwd;
952
- if (dir)
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
- private withCwdEnv;
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
- const full = this.withCwdEnv(command, options?.cwd, options?.env);
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
- errBuf.push(d);
301
- options?.onStderr?.(d.toString());
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 = this.withCwdEnv(command, options?.cwd, options?.env);
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.exec(`mkdir -p -- ${shellQuote(dir)}`, { abortSignal });
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.exec(cmd, { abortSignal }),
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
- withCwdEnv(command, cwd, env) {
578
- const parts = [];
590
+ workingDirOf(cwd) {
579
591
  const dir = cwd ?? (this.cwd !== "." ? this.cwd : undefined);
580
- if (dir)
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);