@sema-agent/server 7.88.2 → 7.90.0

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 (44) hide show
  1. package/dist/boot/fatal-exit.d.ts +3 -0
  2. package/dist/boot/fatal-exit.js +5 -0
  3. package/dist/boot/resolve-spec.js +2 -0
  4. package/dist/boot/stage-06-runners.js +7 -3
  5. package/dist/brain.js +32 -19
  6. package/dist/capabilities/center-plugins.d.ts +10 -0
  7. package/dist/capabilities/center-plugins.js +21 -5
  8. package/dist/capabilities/memory-notice.d.ts +7 -1
  9. package/dist/capabilities/memory-notice.js +6 -6
  10. package/dist/capabilities/team.d.ts +37 -0
  11. package/dist/config-catalog.js +7 -19
  12. package/dist/config-center/apply-effective.js +1 -1
  13. package/dist/config.d.ts +1 -1
  14. package/dist/config.js +11 -5
  15. package/dist/hooks/hook-llm.d.ts +25 -0
  16. package/dist/hooks/hook-llm.js +27 -9
  17. package/dist/hooks/hook-runner.js +21 -4
  18. package/dist/http/admission.js +9 -5
  19. package/dist/http/routes/capabilities.js +2 -2
  20. package/dist/http/routes/session-sync.js +10 -3
  21. package/dist/http/routes/sessions.js +16 -14
  22. package/dist/http/wire-types.d.ts +12 -0
  23. package/dist/main.js +2 -1
  24. package/dist/model-route-url.d.ts +27 -3
  25. package/dist/model-route-url.js +28 -14
  26. package/dist/plugins/memory-embedder.d.ts +0 -6
  27. package/dist/plugins/memory-embedder.js +10 -16
  28. package/dist/plugins/remote-env-adb.d.ts +13 -0
  29. package/dist/plugins/remote-env-adb.js +42 -9
  30. package/dist/plugins/remote-env-device.d.ts +39 -3
  31. package/dist/plugins/remote-env-device.js +107 -31
  32. package/dist/request-key-closure.js +2 -1
  33. package/dist/runtime-governance.d.ts +16 -7
  34. package/dist/runtime-governance.js +27 -7
  35. package/dist/session-policy-wire.d.ts +103 -0
  36. package/dist/session-policy-wire.js +66 -0
  37. package/dist/task-settings.d.ts +12 -2
  38. package/dist/task-settings.js +7 -5
  39. package/dist/trace/core-keyset-guard.d.ts +2 -2
  40. package/dist/trace/project.d.ts +7 -0
  41. package/dist/trace/project.js +1 -0
  42. package/dist/trace/redact.d.ts +110 -5
  43. package/dist/trace/redact.js +71 -12
  44. package/package.json +2 -2
@@ -3,7 +3,7 @@ import { hostShell, resolveHostShell } from "../plugins/host-platform.js";
3
3
  import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/settings-schema/hooks";
4
4
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
5
5
  import { recordFailOpen } from "../observability/fail-open.js";
6
- import { redactSecrets } from "../trace/redact.js";
6
+ import { redactSecrets, buildDisplaySafeUrl } from "../trace/redact.js";
7
7
  import { ccPromptSystemFor, wrapCondition, parseCcVerdict, CC_EVALUATOR_MAX_OUTPUT_TOKENS } from "./cc-stop-prompt.js";
8
8
  import { ccAgentHookSystemFor } from "./cc-agent-hook-prompt.js";
9
9
  import { renderBranchTranscript } from "./branch-transcript.js";
@@ -52,7 +52,9 @@ const BRAIN_TO_TURN_ERROR = {
52
52
  max_output_tokens: "max_output_tokens",
53
53
  };
54
54
  function toAssistantTurnError(errorKind) {
55
- return (errorKind !== undefined ? BRAIN_TO_TURN_ERROR[errorKind] : undefined) ?? "unknown";
55
+ if (errorKind === undefined)
56
+ return "unknown";
57
+ return Object.prototype.hasOwnProperty.call(BRAIN_TO_TURN_ERROR, errorKind) ? (BRAIN_TO_TURN_ERROR[errorKind] ?? "unknown") : "unknown";
56
58
  }
57
59
  export function parseHooksConfig(raw) {
58
60
  const parsed = HooksConfig.safeParse(raw);
@@ -495,7 +497,7 @@ async function runHttpHook(entry, payload, ctx) {
495
497
  if (trimmed === "")
496
498
  return { code: 0, stdout: "{}", stderr: "", timedOut: false };
497
499
  if (!parseHookStdout(body)) {
498
- ctx.logger.warn("hook_http_non_json_response", { url: clip(entry.url, 300), preview: clip(trimmed, 200) });
500
+ ctx.logger.warn("hook_http_non_json_response", { url: buildDisplaySafeUrl(entry.url), preview: clip(trimmed, 200) });
499
501
  noteHookFailure(ctx, {
500
502
  event: eventOfPayload(payload),
501
503
  entry,
@@ -1148,6 +1150,21 @@ export function createTaskHooks(config, ctx) {
1148
1150
  }
1149
1151
  return hooks;
1150
1152
  }
1153
+ const HOOK_SLOT_FOLD = {
1154
+ preToolUse: "custom",
1155
+ preToolUseObservational: "custom",
1156
+ postToolUse: "custom",
1157
+ postToolUseFailure: "custom",
1158
+ postToolBatch: "custom",
1159
+ userPromptSubmit: "custom",
1160
+ stop: "custom",
1161
+ preCompact: "custom",
1162
+ timeoutMs: "scalar",
1163
+ postCompact: "observe-void",
1164
+ stopFailure: "observe-void",
1165
+ permissionDenied: "observe-void",
1166
+ };
1167
+ const OBSERVE_VOID_HOOK_SLOTS = Object.keys(HOOK_SLOT_FOLD).filter((k) => HOOK_SLOT_FOLD[k] === "observe-void");
1151
1168
  export function composeHooks(deployment, task) {
1152
1169
  if (!deployment)
1153
1170
  return task;
@@ -1270,7 +1287,7 @@ export function composeHooks(deployment, task) {
1270
1287
  };
1271
1288
  };
1272
1289
  }
1273
- for (const slot of ["stopFailure", "postCompact", "permissionDenied"]) {
1290
+ for (const slot of OBSERVE_VOID_HOOK_SLOTS) {
1274
1291
  const ds = d[slot];
1275
1292
  const ts = t[slot];
1276
1293
  if (ds && ts) {
@@ -1,7 +1,7 @@
1
1
  import { isThinkingLevel, expandTiers } from "@sema-agent/core";
2
2
  import { isModelAllowlisted, matchCatalogModel } from "../model-select.js";
3
3
  import { HttpError } from "../security.js";
4
- import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, parseTaskSettings, skillListingBudgetFractionOk, skillListingMaxDescOk, taskSettingsKeyIssue } from "../task-settings.js";
4
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, SETTINGS_PERMISSION_MODE_WORDS, isSettingsPermissionMode, parseTaskSettings, skillListingBudgetFractionOk, skillListingMaxDescOk, taskSettingsKeyIssue } from "../task-settings.js";
5
5
  import { taskBodyKeyIssue } from "../request-key-closure.js";
6
6
  import { singleMemberOffFieldIssue } from "../request-value-closure.js";
7
7
  import { selfOrchestrationDenial } from "../task-workflow.js";
@@ -430,6 +430,10 @@ export function createAdmission(ctx) {
430
430
  sendError(res, 400, "request.field_invalid", "retainBackgroundProcesses must be a boolean");
431
431
  return null;
432
432
  }
433
+ if (body.excludeAllTools !== undefined && typeof body.excludeAllTools !== "boolean") {
434
+ sendError(res, 400, "request.field_invalid", "excludeAllTools must be a boolean");
435
+ return null;
436
+ }
433
437
  for (const key of ["excludeTools", "deferTools"]) {
434
438
  const v = body[key];
435
439
  if (v !== undefined && (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0))) {
@@ -453,8 +457,8 @@ export function createAdmission(ctx) {
453
457
  }
454
458
  {
455
459
  const v = body.permissionMode;
456
- if (v !== undefined && v !== "default" && v !== "acceptEdits" && v !== "plan" && v !== "bypassPermissions" && v !== "auto") {
457
- sendError(res, 400, "request.field_invalid", 'permissionMode must be one of "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto"');
460
+ if (v !== undefined && !isSettingsPermissionMode(v)) {
461
+ sendError(res, 400, "request.field_invalid", `permissionMode must be one of ${SETTINGS_PERMISSION_MODE_WORDS}`);
458
462
  return null;
459
463
  }
460
464
  }
@@ -464,8 +468,8 @@ export function createAdmission(ctx) {
464
468
  const p = s.permissions;
465
469
  if (p !== null && typeof p === "object" && !Array.isArray(p)) {
466
470
  const m = p.defaultMode;
467
- if (m !== undefined && m !== "default" && m !== "acceptEdits" && m !== "plan" && m !== "bypassPermissions" && m !== "auto") {
468
- sendError(res, 400, "request.field_invalid", 'settings.permissions.defaultMode must be one of "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto"');
471
+ if (m !== undefined && !isSettingsPermissionMode(m)) {
472
+ sendError(res, 400, "request.field_invalid", `settings.permissions.defaultMode must be one of ${SETTINGS_PERMISSION_MODE_WORDS}`);
469
473
  return null;
470
474
  }
471
475
  const d = p.disableAutoMode;
@@ -18,7 +18,7 @@ import { projectApprovalsStreamLiveCapability } from "../../capabilities/approva
18
18
  import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-workflow.js";
19
19
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
20
20
  import { judgeAutoModeArming } from "../../auto-mode-face.js";
21
- import { coercePermissionModeStrict } from "../../task-settings.js";
21
+ import { coercePermissionModeStrict, SETTINGS_PERMISSION_MODE_WORDS } from "../../task-settings.js";
22
22
  import { gatedPrincipal } from "../principal-gate.js";
23
23
  function routePairingOf(deps, name, m) {
24
24
  if (isSealedKeyPoison(deps.config.modelApiKeys?.[name]))
@@ -52,7 +52,7 @@ export async function handleCapabilities(req, res, match, ctx) {
52
52
  const requestedModeRaw = new URLSearchParams(match.search).get("permissionMode");
53
53
  const requestedMode = requestedModeRaw === null ? undefined : coercePermissionModeStrict(requestedModeRaw);
54
54
  if (requestedModeRaw !== null && requestedMode === undefined) {
55
- sendError(res, 400, "request.field_invalid", 'permissionMode must be one of "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto"');
55
+ sendError(res, 400, "request.field_invalid", `permissionMode must be one of ${SETTINGS_PERMISSION_MODE_WORDS}`);
56
56
  return;
57
57
  }
58
58
  const entitlementSource = centerEntitlementSourceWired(deps.config);
@@ -6,12 +6,14 @@ import { exportSessionManifest, fileHistoryImportFace, replayFileHistory, replay
6
6
  import { classifySyncRelationshipByIds, SyncConflictError } from "../../session-sync-kernel.js";
7
7
  import { isUuidShape } from "../../security.js";
8
8
  import { sendJson, sendError, msg } from "../send.js";
9
+ import { buildDisplaySafeUrl } from "../../trace/redact.js";
9
10
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
10
11
  const SYNC_BLOB_MAX_BYTES = 64 * 1024 * 1024;
11
12
  const SYNC_NDJSON_LINE_MAX_BYTES = 8 * 1024 * 1024;
12
13
  const SYNC_IMPORT_BATCH = 500;
13
14
  import { SHA256_HEX_RE } from "../../digest-form.js";
14
15
  import { assertNever } from "../route-ctx.js";
16
+ import { readSessionRulesRecords, describeSessionRulesRecordProblem } from "../../session-policy-wire.js";
15
17
  export function createSessionSyncLocal() {
16
18
  const pendingStagings = new Map();
17
19
  const importLeases = new Map();
@@ -194,7 +196,7 @@ export async function handleSessionSync(req, res, match, ctx) {
194
196
  void deps.sessionMirrorRuling(auditPrincipal).then((mirror) => {
195
197
  if (mirror?.required) {
196
198
  deps.metrics?.inc("session_mirror_required_push_total");
197
- deps.logger?.info("session_mirror_required_push_observed", { principal: auditPrincipal, engineUrl: mirror.engineUrl, sessionId });
199
+ deps.logger?.info("session_mirror_required_push_observed", { principal: auditPrincipal, engineUrl: buildDisplaySafeUrl(mirror.engineUrl), sessionId });
198
200
  }
199
201
  }).catch(() => { });
200
202
  }
@@ -214,10 +216,15 @@ export async function handleSessionSync(req, res, match, ctx) {
214
216
  sendError(res, 400, "request.field_invalid", "entryIds must be an array of strings");
215
217
  return;
216
218
  }
217
- if (!Array.isArray(body.policy) || !Array.isArray(body.anchors)) {
219
+ if (!Array.isArray(body.anchors)) {
218
220
  sendError(res, 400, "request.body_shape", "malformed import metadata");
219
221
  return;
220
222
  }
223
+ const policyRead = readSessionRulesRecords(body.policy);
224
+ if (policyRead.problem !== undefined) {
225
+ sendError(res, 400, "request.field_invalid", `policy: ${describeSessionRulesRecordProblem(policyRead.problem)}`);
226
+ return;
227
+ }
221
228
  let fileHistory = null;
222
229
  if (body.fileHistory !== undefined && body.fileHistory !== null) {
223
230
  const door = validateFileHistoryExport(body.fileHistory);
@@ -234,7 +241,7 @@ export async function handleSessionSync(req, res, match, ctx) {
234
241
  const resolution = body.resolution;
235
242
  const srcLogDigest = typeof body.logDigest === "string" && body.logDigest.length > 0 ? body.logDigest : undefined;
236
243
  const entryIds = body.entryIds;
237
- const policy = body.policy;
244
+ const policy = policyRead.records;
238
245
  const anchors = body.anchors;
239
246
  const dstIds = (await deps.sessionStorage.listEntryIds?.(sessionId)) ?? null;
240
247
  const relA = classifySyncRelationshipByIds(entryIds, dstIds);
@@ -5,6 +5,7 @@ import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
6
6
  import { recordFailOpen } from "../../observability/fail-open.js";
7
7
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
8
+ import { readSessionPermissionRules, describeSessionPermissionRulesProblem } from "../../session-policy-wire.js";
8
9
  import { sendJson, sendError, sseHeaders } from "../send.js";
9
10
  import { gatedPrincipal, explicitOperatorOk, runOwnerReadable, sendPrincipalRefusal } from "../principal-gate.js";
10
11
  import { assertNever } from "../route-ctx.js";
@@ -741,25 +742,26 @@ export async function handleSessions(req, res, match, ctx) {
741
742
  sendError(res, 400, "request.invalid_json", "invalid JSON body");
742
743
  return;
743
744
  }
744
- if (typeof body.rules !== "object" || body.rules === null || Array.isArray(body.rules)) {
745
- sendError(res, 400, "request.body_shape", "rules must be a SessionPermissionRules object");
746
- return;
745
+ const read = readSessionPermissionRules(body.rules);
746
+ if (read.problem !== undefined) {
747
+ switch (read.problem.kind) {
748
+ case "not-an-object":
749
+ sendError(res, 400, "request.body_shape", describeSessionPermissionRulesProblem(read.problem));
750
+ return;
751
+ case "field-not-string-array":
752
+ sendError(res, 400, "request.field_invalid", describeSessionPermissionRulesProblem(read.problem));
753
+ return;
754
+ default: {
755
+ const unhandled = read.problem;
756
+ throw new Error(`unhandled SessionPermissionRules problem: ${JSON.stringify(unhandled)}`);
757
+ }
758
+ }
747
759
  }
748
760
  if (body.expectedRev !== undefined && (typeof body.expectedRev !== "number" || !Number.isInteger(body.expectedRev) || body.expectedRev < 0)) {
749
761
  sendError(res, 400, "request.field_invalid", "expectedRev must be a non-negative integer");
750
762
  return;
751
763
  }
752
- const raw = body.rules;
753
- const rules = {};
754
- for (const f of ["toolAllow", "toolDeny", "allowDirs", "commandAllow", "commandDeny"]) {
755
- if (raw[f] === undefined)
756
- continue;
757
- if (!Array.isArray(raw[f]) || !raw[f].every((x) => typeof x === "string")) {
758
- sendError(res, 400, "request.field_invalid", `${f} must be an array of strings`);
759
- return;
760
- }
761
- rules[f] = raw[f];
762
- }
764
+ const rules = read.rules;
763
765
  try {
764
766
  const stored = await store.putRules(sessionId, target, rules, { ...(typeof body.expectedRev === "number" ? { expectedRev: body.expectedRev } : {}), operator });
765
767
  sendJson(res, 200, { rules: stored });
@@ -264,6 +264,18 @@ export interface TaskRequestBody {
264
264
  * it takes the same single-user gate as backgroundShell/hooks (multi-tenant warns + ignores; advertised via
265
265
  * capabilities.retainBackgroundProcesses). Boolean fail-loud at submit; only `true` rides the spec. */
266
266
  retainBackgroundProcesses?: boolean;
267
+ /** S-493(core 7.8.0 [ref] `TaskSpec.excludeAllTools`,cli L-207 / 板 [ref]): **整只工具面卸载**
268
+ * —— 调用方工具、脚手架、手、MCP、skills 一个都不挂,模型这一 run 里没有任何工具可调、终局是纯文本。
269
+ *
270
+ * 🔴 **与 {@link excludeTools} 是两种语义,刻意不折叠**:`"*"` 是一个合法的调用方工具名,所以「卸全部」
271
+ * 不能写成一条通配规则(那会把「卸全部」与「卸一只叫 `*` 的工具」塌成一件事)。两键同带 ⇒ 各自生效
272
+ * (全卸之后名单里那几只本来也不在,不矛盾、不拒)。
273
+ * 只 literal `true` 上 spec(`false` / 缺席 = 名册照旧,不挂键保持 byte-compat —— 挂一个 `false` 上去
274
+ * 等于替引擎重申了一次它自己的缺省);非 boolean 由 HTTP 门 400 `request.field_invalid`,resume 重放
275
+ * defensive DROP。per-run 工具面**收窄**无跨租户面 ⇒ 无租户门(`excludeTools` 同姿势)。
276
+ * 探测:本键在不在受理面由**键闭集**自己回答 —— 没接线的 server 版本对它 400 `request.body_shape`
277
+ * 并逐字列名,所以**不加能力位**(一个键一条探测路,不长第二条)。 */
278
+ excludeAllTools?: boolean;
267
279
  /** [ref]② (core 1.314 工具面控制批): roster 真卸载清单(schema 不上 wire,manifest 收窄)。wire 工具名;
268
280
  * tighten-only(core 继承不变量 union 进子 spawn)。数组-of-string fail-loud at submit。 */
269
281
  excludeTools?: string[];
package/dist/main.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createLogger } from "./observability/logger.js";
2
+ import { buildFatalErrorText } from "./boot/fatal-exit.js";
2
3
  import { runStage01 } from "./boot/stage-01-config.js";
3
4
  import { runStage02 } from "./boot/stage-02-registry-pull.js";
4
5
  import { runStage03 } from "./boot/stage-03-store-backend.js";
@@ -22,7 +23,7 @@ async function main() {
22
23
  await runStage10(s09);
23
24
  }
24
25
  void main().catch((err) => {
25
- createLogger().error("fatal", { err: String(err) });
26
+ createLogger().error("fatal", { err: buildFatalErrorText(err) });
26
27
  process.exit(1);
27
28
  });
28
29
  //# sourceMappingURL=main.js.map
@@ -1,9 +1,33 @@
1
1
  /** 机读码(拼法随 `config.<键名蛇形>_*` 既有族:`config.model_provider_invalid` / `config.retention_policy_invalid`)。 */
2
2
  export declare const GATEWAY_URL_USERINFO_CODE = "config.gateway_url_userinfo";
3
- /** 这个 URL 带 userinfo(用户名或密码任一非空)吗?解析不动 ⇒ `false`(「不是 URL」由别的门管,本条只判凭据位)。 */
4
- export declare function urlCarriesUserinfo(raw: string): boolean;
3
+ /** 同族第二码(S-483 座⑨):这一位根本不是一个层级式绝对 URL。三根键共用一码,与上面那只同纪律
4
+ * (码按**规则**命名,不按键名 —— 键名已经逐字在拒因文案里点过了)。 */
5
+ export declare const MODEL_ROUTE_URL_INVALID_CODE = "config.model_route_url_invalid";
5
6
  /**
6
- * 模型路由 base URL 的拒因(`undefined` = 这一位合法)。
7
+ * 🔴 **第三条臂的机读码(S-514;core 7.24.2 / [ref] 提货)——请求根带 `#fragment`**。
8
+ *
9
+ * 为什么与前两条各自一码:运维要按码行动,而三条臂的**出路不同**(补全 URL / 把凭据搬去旋钮 / 删掉 `#`
10
+ * 之后的一切)。`fetch` **从不把 fragment 发上线**,所以这一形此前是**静默**打根自己的路径、端点后缀整段
11
+ * 落进被丢弃的那一半 —— 请求发得出去、也可能 200,只是打错了地方而一个字不说。
12
+ */
13
+ export declare const MODEL_ROUTE_URL_FRAGMENT_CODE = "config.model_route_url_fragment";
14
+ /**
15
+ * 模型路由 base URL 的拒因(`undefined` = 这一位合法)—— **这三根键的唯一判官**。
16
+ *
17
+ * 两臂,顺序即语义,**两臂都经 display-safe 铸点**:
18
+ * ① **不是一个层级式绝对 URL** ⇒ `config.model_route_url_invalid`;
19
+ * ② 是,但带 userinfo ⇒ `config.gateway_url_userinfo`。
20
+ *
21
+ * 🔴 ① 为什么必须在这里、而不是「由别的门管」(S-483 座⑨,与 ME/A2A 两座**同病镜像**):本函数此前先问
22
+ * 凭据位、并把「解析不动」读成**没有**凭据(`urlCarriesUserinfo` 的 `return false`),旁注写着「不是 URL 由
23
+ * 别的门管」—— 而 config 层对这三根**没有**那道门(`config.ts` 与 `config-invariants.ts` 只调本函数)。
24
+ * 于是半写 userinfo `http://user:pass@`(`URL.canParse` 假)与 `alice:pw@host:8000`(不透明 URL,
25
+ * `username`/`password` 恒空)两形被当「无凭据」放行,一路到 brain 装配期才由 core 拒;core 那一臂
26
+ * 今天不回显原值 —— 但**「恰好不漏」不是防线**,而且拒启点越晚,运维越读不出是哪根旋钮配错了。
27
+ * ⇒ 判据改成与铸点**同一只** {@link isHierarchicalUrl}:两处再也不可能各判各的。
28
+ *
29
+ * 🚧 **未闭环(不在本车射程)**:层级式但**非 http(s)** 的形(`ftp://h/v1`,不带 userinfo)今天仍放行 ——
30
+ * `fetch` 对它同样必然失败,但那是第三条臂、是一次新的行为面拒启,按三问另开车(S-483 收车档已登记)。
7
31
  *
8
32
  * 拒因四件齐:**码** + **哪个 env 键** + **洗过的 URL**(host/port/path 逐字,userinfo 与凭据参数成记号 ——
9
33
  * 运维要靠 host 认出是哪一条路由,而密码一个字节都不许进启动错误)+ **出路一句**(点名真实旋钮)。
@@ -1,19 +1,33 @@
1
- import { buildDisplaySafeUrl } from "./trace/redact.js";
1
+ import { buildDisplaySafeUrl, isHierarchicalUrl } from "./trace/redact.js";
2
2
  export const GATEWAY_URL_USERINFO_CODE = "config.gateway_url_userinfo";
3
- export function urlCarriesUserinfo(raw) {
4
- if (raw === "" || !URL.canParse(raw))
5
- return false;
6
- const u = new URL(raw);
7
- return u.username !== "" || u.password !== "";
8
- }
3
+ export const MODEL_ROUTE_URL_INVALID_CODE = "config.model_route_url_invalid";
4
+ export const MODEL_ROUTE_URL_FRAGMENT_CODE = "config.model_route_url_fragment";
9
5
  export function modelRouteUrlRefusal(envKey, raw, credentialKnob) {
10
- if (!urlCarriesUserinfo(raw))
6
+ if (raw === "")
11
7
  return undefined;
12
- return (`env ${envKey} carries credentials in the URL userinfo (${buildDisplaySafeUrl(raw)}) [${GATEWAY_URL_USERINFO_CODE}] — ` +
13
- "WHATWG forbids constructing a fetch Request from a URL with credentials, so EVERY model call on this route " +
14
- "fails deterministically at request-construction time (it used to look like a slow network flake: the error was " +
15
- "retried for ~3 minutes before surfacing). " +
16
- `Put the credential in ${credentialKnob} (or the matching header knob) instead of the URL, and keep the URL to ` +
17
- "scheme/host/port/path.");
8
+ if (!isHierarchicalUrl(raw)) {
9
+ return (`env ${envKey} is not a parseable absolute URL (${buildDisplaySafeUrl(raw)}) [${MODEL_ROUTE_URL_INVALID_CODE}] — ` +
10
+ "a model-route base URL is consumed by fetch, which requires an absolute hierarchical URL " +
11
+ "(scheme://host[:port][/path]); half-written forms like \"http://user:pass@\" and opaque forms like " +
12
+ "\"host:8000\" are not one, and they also hide any credentials they carry from every credential-position check. " +
13
+ `Set ${envKey} to a full scheme://host URL (and put the credential in ${credentialKnob}, never in the URL).`);
14
+ }
15
+ const u = new URL(raw);
16
+ if (u.username !== "" || u.password !== "")
17
+ return (`env ${envKey} carries credentials in the URL userinfo (${buildDisplaySafeUrl(raw)}) [${GATEWAY_URL_USERINFO_CODE}] — ` +
18
+ "WHATWG forbids constructing a fetch Request from a URL with credentials, so EVERY model call on this route " +
19
+ "fails deterministically at request-construction time (it used to look like a slow network flake: the error was " +
20
+ "retried for ~3 minutes before surfacing). " +
21
+ `Put the credential in ${credentialKnob} (or the matching header knob) instead of the URL, and keep the URL to ` +
22
+ "scheme/host/port/path.");
23
+ if (u.hash !== "") {
24
+ return (`env ${envKey} carries a URL fragment (${buildDisplaySafeUrl(raw)}#…) [${MODEL_ROUTE_URL_FRAGMENT_CODE}] — ` +
25
+ "a fragment is never sent on the wire, so this route would silently target the root's own path and every " +
26
+ "endpoint suffix (/chat/completions, /v1/messages) would land inside the discarded part: the request goes out, " +
27
+ "may even return 200, and points somewhere else without saying so. " +
28
+ `Drop everything from the "#" on in ${envKey}. A per-deployment QUERY is fully supported and rides every ` +
29
+ "request (a gateway's `?api-version=…`); a fragment cannot be.");
30
+ }
31
+ return undefined;
18
32
  }
19
33
  //# sourceMappingURL=model-route-url.js.map
@@ -26,12 +26,6 @@ export interface OpenAiCompatEmbedderOptions extends MemoryEmbedderConfig {
26
26
  * URL 合法性门,只在运行期炸(而运行期的炸法就是上面那条「写入失败/检索退档」的静默病)。
27
27
  */
28
28
  export declare function embeddingsUrl(endpoint: string): string;
29
- /**
30
- * 诊断面用的**脱敏** URL(codex 复审 F4,已核真):端点里可能带 userinfo(`https://user:pass@h/v1`)
31
- * 或把凭据放 query(Azure 的 `api-key=`),而错误文本会进日志、进 PatchReport 的 conflict reason。
32
- * 主机与路径保留(诊断价值全在这里),userinfo 抹掉、query 值一律换成 `***`(键名保留,便于认形)。
33
- */
34
- export declare function redactEndpointForLog(raw: string): string;
35
29
  /** 造一个 OpenAI 兼容的 `PgEmbedder`(工厂命名律:返回带行为的对象 ⇒ `create*`)。 */
36
30
  export declare function createOpenAiCompatEmbedder(opts: OpenAiCompatEmbedderOptions): PgEmbedder;
37
31
  /**
@@ -1,13 +1,17 @@
1
+ import { joinRequestUrl } from "@sema-agent/core";
1
2
  import { z } from "zod";
3
+ import { buildDisplaySafeRefusal, buildDisplaySafeUrl } from "../trace/redact.js";
2
4
  const EmbeddingsResponse = z.object({
3
5
  data: z.array(z.object({ embedding: z.array(z.number()) })).min(1),
4
6
  });
5
7
  export function embeddingsUrl(endpoint) {
6
- const u = new URL(endpoint);
7
- u.hash = "";
8
- const path = u.pathname.replace(/\/+$/, "");
9
- u.pathname = decodeUnreservedEscapes(path).endsWith("/embeddings") ? path : `${path}/embeddings`;
10
- return u.toString();
8
+ const path = URL.canParse(endpoint) ? new URL(endpoint).pathname.replace(/\/+$/, "") : "";
9
+ try {
10
+ return joinRequestUrl(endpoint, decodeUnreservedEscapes(path).endsWith("/embeddings") ? "" : "/embeddings");
11
+ }
12
+ catch (e) {
13
+ throw new Error(buildDisplaySafeRefusal(e instanceof Error ? e.message : String(e), [endpoint]), { cause: undefined });
14
+ }
11
15
  }
12
16
  function decodeUnreservedEscapes(s) {
13
17
  return s.replace(/%([0-9A-Fa-f]{2})/g, (whole, hex) => {
@@ -15,16 +19,6 @@ function decodeUnreservedEscapes(s) {
15
19
  return /[A-Za-z0-9\-._~]/.test(c) ? c : whole;
16
20
  });
17
21
  }
18
- export function redactEndpointForLog(raw) {
19
- if (!URL.canParse(raw))
20
- return "<invalid-url>";
21
- const u = new URL(raw);
22
- u.username = "";
23
- u.password = "";
24
- for (const k of [...u.searchParams.keys()])
25
- u.searchParams.set(k, "***");
26
- return u.toString();
27
- }
28
22
  function credentialsInQuery(endpoint) {
29
23
  if (!URL.canParse(endpoint))
30
24
  return [];
@@ -32,7 +26,7 @@ function credentialsInQuery(endpoint) {
32
26
  }
33
27
  export function createOpenAiCompatEmbedder(opts) {
34
28
  const url = embeddingsUrl(opts.endpoint);
35
- const shownUrl = redactEndpointForLog(url);
29
+ const shownUrl = buildDisplaySafeUrl(url);
36
30
  const fetchImpl = opts.fetchImpl ?? fetch;
37
31
  const { model, dimensions, timeoutMs, apiKey, onFailure } = opts;
38
32
  const secrets = [...(apiKey !== undefined ? [apiKey] : []), ...credentialsInQuery(opts.endpoint)];
@@ -84,6 +84,8 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
84
84
  * 同姿态的 ssh 腿早有先例(`remote-env-ssh.ts`「destroy 后一律不复活,显式 connect/reconnect 同禁」),
85
85
  * 五条兄弟腿全部 fail-closed,只有 adb 例外 —— 补齐它。 */
86
86
  private destroyed;
87
+ /** 在飞的那一次懒连接(单飞记忆)——见 {@link ensureConnected}。settle 即清,**不缓存败诺**。 */
88
+ private connectPromise?;
87
89
  /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */
88
90
  readonly homeDir?: string | undefined;
89
91
  private readonly cfg;
@@ -99,6 +101,17 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
99
101
  ok: false;
100
102
  error: RemoteExecutionError;
101
103
  }>;
104
+ /**
105
+ * 懒连接:**首次调用任一面**(exec / execStream / 文件面)时自动 `connect()`(含重试策略),在飞的
106
+ * 那一次由全部调用者共用,失败**不缓存**(下一次调用重新试),`destroyed` 是终态不复活。
107
+ *
108
+ * 🔴 为什么这条必须在 adapter 里:core 的 task/run 车道**没有任何人**替 adapter 调 `connect()` ——
109
+ * 工厂铸出 env 就直接用它的 exec/文件面(本仓亲核:`src/` 里对执行环境的 `.connect()` 只有 bench 与
110
+ * 生命周期面自己)。所以一条「设了未连闸、又不自连」的腿,它的每一次工具调用都被自家的闸挡回;
111
+ * 那不是 adb 特有语义,而是与 ssh([ref] 同一实红点)/ k8s / e2b / local-docker / device 不均匀。
112
+ * 未连时的错误文案因此从「call connect() first」(这条车道上没人能照做)换成**真试过之后**的拒因。
113
+ */
114
+ private ensureConnected;
102
115
  private connectOnce;
103
116
  suspendVM(_options?: VmLifecycleOptions): Promise<{
104
117
  ok: true;
@@ -13,6 +13,12 @@ const unsupported = (op) => ({
13
13
  ok: false,
14
14
  error: new RemoteExecutionError("unsupported", `${op} is not supported on an ADB device (real hardware — not snapshotable; capabilities.suspendable=false)`),
15
15
  });
16
+ function lazyConnectExecError(e) {
17
+ return e.code === "aborted" ? new ExecutionError("aborted", e.message, e) : new ExecutionError("shell_unavailable", `ADB lazy-connect failed: ${e.message}`, e);
18
+ }
19
+ function lazyConnectFileError(e, absPath) {
20
+ return e.code === "aborted" ? new FileError("aborted", e.message, absPath, e) : new FileError("unknown", `ADB lazy-connect failed: ${e.message}`, absPath, e);
21
+ }
16
22
  const isTcp = (serial) => /:\d+$/.test(serial);
17
23
  export function isAdbTransportLost(stderr) {
18
24
  return /device offline|device '.*' not found|no devices\/emulators found|connection reset|protocol fault|device disconnected/i.test(stderr);
@@ -22,6 +28,7 @@ export class RemoteAdbExecutionEnv {
22
28
  cwd;
23
29
  connected = false;
24
30
  destroyed = false;
31
+ connectPromise;
25
32
  homeDir;
26
33
  cfg;
27
34
  spawnFn;
@@ -57,6 +64,25 @@ export class RemoteAdbExecutionEnv {
57
64
  const once = this.injectedConnectOnce ?? ((s) => this.connectOnce(s));
58
65
  return withRetry((_attempt) => once(config?.abortSignal), policy, { signal: config?.abortSignal });
59
66
  }
67
+ async ensureConnected() {
68
+ if (this.destroyed)
69
+ return { ok: false, error: new RemoteExecutionError("connect_failed", "ADB env destroyed — no lazy reconnect after teardown") };
70
+ if (this.connected)
71
+ return { ok: true };
72
+ const started = this.connectPromise ?? this.connect();
73
+ this.connectPromise = started;
74
+ try {
75
+ const r = await started;
76
+ return r.ok ? { ok: true } : { ok: false, error: r.error };
77
+ }
78
+ catch (err) {
79
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `adb lazy-connect threw: ${err instanceof Error ? err.message : String(err)}`) };
80
+ }
81
+ finally {
82
+ if (this.connectPromise === started)
83
+ this.connectPromise = undefined;
84
+ }
85
+ }
60
86
  async connectOnce(signal) {
61
87
  if (signal?.aborted)
62
88
  return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted") };
@@ -121,8 +147,9 @@ export class RemoteAdbExecutionEnv {
121
147
  return this.destroy();
122
148
  }
123
149
  async exec(command, options) {
124
- if (!this.connected)
125
- return { ok: false, error: new ExecutionError("shell_unavailable", "ADB device not connected — call connect() first") };
150
+ const ready = await this.ensureConnected();
151
+ if (!ready.ok)
152
+ return { ok: false, error: lazyConnectExecError(ready.error) };
126
153
  const wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
127
154
  const r = await this.adb(this.deviceShellArgs(wrapped), {
128
155
  signal: options?.abortSignal,
@@ -139,8 +166,11 @@ export class RemoteAdbExecutionEnv {
139
166
  return ok(r.value);
140
167
  }
141
168
  async *execStream(command, options) {
142
- if (!this.connected)
143
- throw new RemoteExecutionError("connect_failed", "ADB device not connected — call connect() first");
169
+ const ready = await this.ensureConnected();
170
+ if (!ready.ok)
171
+ throw ready.error;
172
+ if (this.destroyed)
173
+ throw new RemoteExecutionError("connect_failed", "execution env already destroyed");
144
174
  const wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
145
175
  const child = this.spawnFn(this.cfg.adbPath, this.deviceShellArgs(wrapped), { stdio: ["ignore", "pipe", "pipe"] });
146
176
  const queue = [];
@@ -255,15 +285,16 @@ export class RemoteAdbExecutionEnv {
255
285
  async joinPath(parts) {
256
286
  return ok(path.posix.join(...parts));
257
287
  }
258
- fsGate(p) {
288
+ async fsGate(p) {
259
289
  if (this.destroyed)
260
290
  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)) };
291
+ const ready = await this.ensureConnected();
292
+ if (!ready.ok)
293
+ return { ok: false, error: lazyConnectFileError(ready.error, this.resolve(p)) };
263
294
  return undefined;
264
295
  }
265
296
  async readBinaryFile(p, abortSignal) {
266
- const gated = this.fsGate(p);
297
+ const gated = await this.fsGate(p);
267
298
  if (gated)
268
299
  return gated;
269
300
  const abs = this.resolve(p);
@@ -290,7 +321,7 @@ export class RemoteAdbExecutionEnv {
290
321
  return ok(lines);
291
322
  }
292
323
  async writeFile(p, content, abortSignal) {
293
- const gated = this.fsGate(p);
324
+ const gated = await this.fsGate(p);
294
325
  if (gated)
295
326
  return gated;
296
327
  const abs = this.resolve(p);
@@ -379,6 +410,8 @@ export class RemoteAdbExecutionEnv {
379
410
  return this.posixFs.createTempFile(options);
380
411
  }
381
412
  adb(args, opts) {
413
+ if (this.destroyed)
414
+ return Promise.resolve({ ok: false, error: new ExecutionError("shell_unavailable", "execution env already destroyed") });
382
415
  return new Promise((resolve) => {
383
416
  let settled = false;
384
417
  let forceSettleTimer;
@@ -118,6 +118,8 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
118
118
  writeFileExclusive?: (path: string, content: string | Uint8Array, abortSignal?: AbortSignal) => Promise<Result<void, FileError>>;
119
119
  writeFileGuarded?: (path: string, content: string | Uint8Array, expect: WriteExpectation, abortSignal?: AbortSignal) => Promise<Result<WriteReceipt, FileError>>;
120
120
  private connected;
121
+ /** 在飞的那一次准入(单飞记忆)——见 {@link admitShared}。settle 即清,**不缓存败诺**。 */
122
+ private connectPromise?;
121
123
  /** 拆除是**终态**(六条兄弟腿同纪律:destroy 之后 connect/exec/读面一律 fail-closed,不复活)。 */
122
124
  private destroyed;
123
125
  constructor(cfg: DeviceEnvConfig);
@@ -154,7 +156,8 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
154
156
  joinPath(parts: string[]): Promise<Result<string, FileError>>;
155
157
  workspaceHandle(): WorkspaceHandle;
156
158
  /**
157
- * 不建连接:做**准入 + 取活连接**(文件头「唯一范式差」)。
159
+ * 不建连接:做**准入 + 取活连接**(文件头「唯一范式差」)。**显式**入口 —— 首次调用任一面时
160
+ * {@link ensureConnected} 会自动走同一条路(单飞共享),所以这里重复调用是幂等的。
158
161
  *
159
162
  * 🔴 **绝不换设备**:准入解出的 deviceId 与本 env 铸造时钉住的那一台不符 ⇒ typed
160
163
  * `device.identity_mismatch`(lane 是身份不是偏好,§4.5 步 2)。这一格正是 park 赎回期换绑/吊销的
@@ -167,6 +170,27 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
167
170
  ok: false;
168
171
  error: RemoteExecutionError;
169
172
  }>;
173
+ /**
174
+ * 准入的**单飞**入口:在飞的那一次由全部等待者共用(显式 `connect()` 与任意面的懒连接共享同一次),
175
+ * settle 后记忆即清 —— 成功由 `connected` 位挡住重入,失败让下一次调用重新试(**不缓存败诺**,
176
+ * 与 k8s/ssh/e2b 三腿逐字同纪律)。
177
+ */
178
+ private admitShared;
179
+ /**
180
+ * 懒连接:**首次调用任一面**(exec / execStream / 文件面)时自动走一次准入。
181
+ *
182
+ * 🔴 为什么这条必须在 adapter 里(而不是「谁用谁先 connect」):task/run 车道**没有任何人**会替
183
+ * adapter 调 `connect()` —— 工厂铸出 env 就直接用它的 exec/文件面(本仓亲核:`src/` 里对执行环境的
184
+ * `.connect()` 只有 bench 与生命周期面自己)。所以一条「设了未连闸、又不自连」的腿,它的每一次工具
185
+ * 调用都被自家的闸挡回,而设备侧一条指令帧都收不到。e2b(首用建 VM)/ k8s(`connectPromise ??=`)/
186
+ * ssh(single-flight join)/ local-docker 四腿早就是懒连接;本腿从此同形,`connect()` 的语义
187
+ * (准入 + 取活连接 + 保护面对拍)一个字不动,只是触发时机与兄弟一致。
188
+ *
189
+ * 返回 `undefined` = 可以往下走;否则是**三币种**的失败行,由调用面翻成自己那种货币。
190
+ */
191
+ private ensureConnected;
192
+ /** `connect()` 的**本体**(准入 + 身份等值 + 保护面对拍)。失败以三币种形返回,见 {@link DeviceAdmissionFailure}。 */
193
+ private admit;
170
194
  /** 控制面重连(core 在 transport 重挂时调)——语义与 connect 逐字同一条:重新准入 + 身份等值。 */
171
195
  reconnect(_sessionToken: SessionToken): Promise<{
172
196
  ok: true;
@@ -203,11 +227,23 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
203
227
  */
204
228
  destroy(): Promise<void>;
205
229
  cleanup(): Promise<void>;
206
- /** 终态/未连接的 fail-closed 门(exec 面)。 */
230
+ /**
231
+ * 终态 fail-closed + **懒连接**门(exec 面)。未连 ⇒ 先走一次准入,失败按 exec 货币报**准入自己的**
232
+ * 拒因(不再吐一句「call connect() first」—— 这条车道上没有任何人能照做)。
233
+ */
207
234
  private shellGate;
208
- /** 终态/未连接的 fail-closed 门(文件面)。 */
235
+ /** 终态 fail-closed + **懒连接**门(文件面)。语义与 {@link shellGate} 逐字同一条,只换货币。 */
209
236
  private fsGate;
210
237
  private timeoutMsFor;
238
+ /**
239
+ * 投递漏斗。**终态复核就在这里**,不在门上(codex 对抗复审 r1 [high],判真后收)。
240
+ *
241
+ * 🔴 为什么门上那一次不够:门是 `async`,所以调用方从门返回到这里之间**让出过执行权** —— 一次
242
+ * `destroy()` 可以整个跑完再让调用方恢复,于是门读到的「未拆除」在投递那一刻已经过期(懒连接把门
243
+ * 变成异步的那一刻就开了这个窗;`connected` 快路径也一样让出)。本函数的函数体到 `hub.dispatch(input)`
244
+ * 之间**没有任何 await**,所以它是投递前最后一个同步点 —— 闸要放在真正不可回退的那一格上。
245
+ * 三个面各自把这一态翻成自己的货币(穷举 switch,加臂 = 编译红)。
246
+ */
211
247
  private dispatch;
212
248
  /**
213
249
  * 三态结果 → 文件面的 `Result`。