@sema-agent/server 7.88.1 → 7.89.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.
- package/dist/boot/stage-06-runners.js +7 -3
- package/dist/brain.js +2 -2
- package/dist/capabilities/center-plugins.d.ts +10 -0
- package/dist/capabilities/center-plugins.js +21 -5
- package/dist/config-catalog.js +4 -4
- package/dist/config-center/apply-effective.js +1 -1
- package/dist/config.js +9 -3
- package/dist/hooks/hook-llm.d.ts +25 -0
- package/dist/hooks/hook-llm.js +18 -4
- package/dist/hooks/hook-runner.js +2 -2
- package/dist/http/routes/session-sync.js +2 -1
- package/dist/model-route-url.d.ts +19 -3
- package/dist/model-route-url.js +13 -8
- package/dist/plugins/remote-env-adb.d.ts +13 -0
- package/dist/plugins/remote-env-adb.js +42 -9
- package/dist/plugins/remote-env-device.d.ts +39 -3
- package/dist/plugins/remote-env-device.js +107 -31
- package/dist/trace/redact.d.ts +20 -0
- package/dist/trace/redact.js +7 -3
- package/docs/DEVICE-EXECUTOR-PROTOCOL.md +23 -0
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@ import { redactSecrets } from "../trace/redact.js";
|
|
|
7
7
|
import { hasServiceAuth } from "../http/server.js";
|
|
8
8
|
import { workflowModelAllowlistFor } from "../task-workflow.js";
|
|
9
9
|
import { backendOwnsControlPlane } from "../memory-operator-faces.js";
|
|
10
|
-
import { createHookLlm } from "../hooks/hook-llm.js";
|
|
10
|
+
import { createHookLlm, buildHookFailureText } from "../hooks/hook-llm.js";
|
|
11
11
|
import { assertRetentionLaneWirable } from "./retention-lane.js";
|
|
12
12
|
import { assertConsolidationDriverWirable, buildConsolidationValveAudit, createMemoryConsolidationFaces, resolveConsolidationDriverSeat } from "./memory-consolidation.js";
|
|
13
13
|
import { createPerModelAuthSeat } from "./side-query-lane.js";
|
|
@@ -132,7 +132,7 @@ export function runStage06(ctx) {
|
|
|
132
132
|
const runnerTierFrozen = Object.keys(config.tiers).length > 0;
|
|
133
133
|
const { hookModelFor, hookLlm } = createHookLlm({ config, getKeyResolver: () => configCenter.getKeyResolver(), metrics });
|
|
134
134
|
const hookAgentRunner = new Runner({ ...runnerDeps, sessionStore: new TtlSessionStore({ defaultTtlDays: 1 / 24 }) });
|
|
135
|
-
const
|
|
135
|
+
const agentHookCall = async ({ prompt, model, timeoutMs, system }) => {
|
|
136
136
|
const pick = hookModelFor(model);
|
|
137
137
|
if (!pick.ok)
|
|
138
138
|
return pick;
|
|
@@ -150,7 +150,7 @@ export function runStage06(ctx) {
|
|
|
150
150
|
metrics.inc("hook_llm_calls_total", { type: "agent" });
|
|
151
151
|
if (result.terminal.kind !== "completed") {
|
|
152
152
|
const plane = terminalProjection(result.terminal);
|
|
153
|
-
return { ok: false, error: `hook agent ${plane.status}: ${
|
|
153
|
+
return { ok: false, error: `hook agent ${plane.status}: ${plane.errorMessage ?? ""}` };
|
|
154
154
|
}
|
|
155
155
|
return { ok: true, text: result.result ?? "" };
|
|
156
156
|
}
|
|
@@ -158,6 +158,10 @@ export function runStage06(ctx) {
|
|
|
158
158
|
return { ok: false, error: String(e) };
|
|
159
159
|
}
|
|
160
160
|
};
|
|
161
|
+
const hookAgent = async (opts) => {
|
|
162
|
+
const out = await agentHookCall(opts);
|
|
163
|
+
return out.ok ? out : { ...out, error: clipHookErr(buildHookFailureText(out.error)) };
|
|
164
|
+
};
|
|
161
165
|
const clipHookErr = (s) => (s ?? "").slice(0, 300);
|
|
162
166
|
const titleCapable = sessionStore;
|
|
163
167
|
const sessionTitler = config.sessionAutoTitle && titleCapable.setTitleIfNull && titleCapable.probeTitle
|
package/dist/brain.js
CHANGED
|
@@ -3,7 +3,7 @@ import { normalizeBaseUrl } from "@sema-agent/core";
|
|
|
3
3
|
import { buildDefaultResilience } from "./config-types.js";
|
|
4
4
|
import { resolveModelApiKey } from "./key-resolver.js";
|
|
5
5
|
import { catalogBrainLane } from "./model-provider.js";
|
|
6
|
-
import { buildDisplaySafeUrl } from "./trace/redact.js";
|
|
6
|
+
import { buildDisplaySafeUrl, redactSecrets } from "./trace/redact.js";
|
|
7
7
|
function isOffDeploymentRoot(baseUrl, config) {
|
|
8
8
|
if (!baseUrl)
|
|
9
9
|
return false;
|
|
@@ -158,7 +158,7 @@ export function brainSummary(config) {
|
|
|
158
158
|
const verdict = adjudicateModelRoute({ id: target.id, baseUrl: target.baseUrl }, resolveRouteCredential({ ...(config.gatewayApiKey ? { configApiKey: config.gatewayApiKey } : {}) }), { baseUrl: degradeDeclaredRoot(target.baseUrl, config) });
|
|
159
159
|
return verdict.ok
|
|
160
160
|
? { reactiveDegradeRoutePairing: `ok:${verdict.posture}`, reactiveDegradeRouteRefusal: null }
|
|
161
|
-
: { reactiveDegradeRoutePairing: `broken:${verdict.code}`, reactiveDegradeRouteRefusal: routeRefusalText(verdict) };
|
|
161
|
+
: { reactiveDegradeRoutePairing: `broken:${verdict.code}`, reactiveDegradeRouteRefusal: redactSecrets(routeRefusalText(verdict)) };
|
|
162
162
|
})()),
|
|
163
163
|
};
|
|
164
164
|
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Logger } from "../observability/logger.js";
|
|
2
2
|
import { type LoadedSkill } from "./skills.js";
|
|
3
|
+
/**
|
|
4
|
+
* 声明面的 `source.url` 长度上限(S-483)—— **界在声明关口,不在每个出口各切一刀**。
|
|
5
|
+
*
|
|
6
|
+
* 由来:出口上原有一刀 `.slice(0, 80)`,而它做了两件坏事(① 把 display-safe 铸点的 `«redacted…»` 记号截成
|
|
7
|
+
* 半只;② 与同一条 URL 在别处的处置成了两把尺)。删掉它之后,这个 **center 下发**值在日志行与
|
|
8
|
+
* `failures[]` 回执上就再没有界 —— 于是界回到它该在的地方:**声明被接收的那一刻**(与 hook 条目的 url
|
|
9
|
+
* 同形 —— `parseHooksConfig` 用 `MAX_HOOK_COMMAND_CHARS` 在解析期就拒)。2048 = URL 的事实上限
|
|
10
|
+
* (同一个数在 `http/routes/sessions.ts` 的对端 endpoint 读面上也在用);超界的 git clone URL 不存在真部署。
|
|
11
|
+
*/
|
|
12
|
+
export declare const MAX_PLUGIN_SOURCE_URL_CHARS = 2048;
|
|
3
13
|
/** 缺省 clone host 白名单(契约条款 1;国内镜像走本名单加 https 镜像域,不开 http 口)。 */
|
|
4
14
|
export declare const DEFAULT_PLUGIN_ALLOW_HOSTS: readonly string[];
|
|
5
15
|
/** wire 上的插件声明(registry-core PluginSpec 的宽读形——本模块只消费不裁决,未知键忽略)。 */
|
|
@@ -3,8 +3,17 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import { promises as fsp, realpathSync, existsSync, readFileSync, lstatSync, statSync } from "node:fs";
|
|
4
4
|
import { join, resolve, isAbsolute, sep } from "node:path";
|
|
5
5
|
import { assertConfined, loadSkills } from "./skills.js";
|
|
6
|
+
import { buildDisplaySafeUrl, redactSecrets } from "../trace/redact.js";
|
|
6
7
|
const execFileP = promisify(execFile);
|
|
8
|
+
export const MAX_PLUGIN_SOURCE_URL_CHARS = 2048;
|
|
7
9
|
export const DEFAULT_PLUGIN_ALLOW_HOSTS = ["github.com"];
|
|
10
|
+
function wireTypeWord(v) {
|
|
11
|
+
if (v === null)
|
|
12
|
+
return "null";
|
|
13
|
+
if (Array.isArray(v))
|
|
14
|
+
return "array";
|
|
15
|
+
return typeof v;
|
|
16
|
+
}
|
|
8
17
|
export function validatePluginDecl(decl, allowHosts) {
|
|
9
18
|
const name = typeof decl.name === "string" && decl.name !== "" ? decl.name : "(unnamed)";
|
|
10
19
|
if (decl.enabled === false)
|
|
@@ -16,8 +25,14 @@ export function validatePluginDecl(decl, allowHosts) {
|
|
|
16
25
|
if (kind !== "git")
|
|
17
26
|
return { ok: false, name, reason: `unsupported source.kind ${String(kind)}` };
|
|
18
27
|
const url = decl.source?.url;
|
|
19
|
-
if (typeof url !== "string"
|
|
20
|
-
return { ok: false, name, reason: `plugin source must be
|
|
28
|
+
if (typeof url !== "string") {
|
|
29
|
+
return { ok: false, name, reason: `plugin source.url must be a string (got ${wireTypeWord(url)})` };
|
|
30
|
+
}
|
|
31
|
+
if (url.length > MAX_PLUGIN_SOURCE_URL_CHARS) {
|
|
32
|
+
return { ok: false, name, reason: `plugin source url exceeds ${MAX_PLUGIN_SOURCE_URL_CHARS} chars (got ${url.length}) — a clone URL that long is a declaration error, and it would ride every log line and failure receipt verbatim` };
|
|
33
|
+
}
|
|
34
|
+
if (!/^https:\/\//i.test(url)) {
|
|
35
|
+
return { ok: false, name, reason: `plugin source must be an https:// URL (contract: https-only; got ${buildDisplaySafeUrl(url)})` };
|
|
21
36
|
}
|
|
22
37
|
let host;
|
|
23
38
|
try {
|
|
@@ -140,13 +155,14 @@ export async function applyCenterPlugins(baseline, eff, opts) {
|
|
|
140
155
|
}
|
|
141
156
|
catch (err) {
|
|
142
157
|
await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
158
|
+
const safeErr = redactSecrets(err instanceof Error ? err.message : String(err));
|
|
143
159
|
if (existsSync(liveDir)) {
|
|
144
|
-
opts.logger?.warn("center_plugin_fetch_failed_lkg", { plugin: v.name, error:
|
|
160
|
+
opts.logger?.warn("center_plugin_fetch_failed_lkg", { plugin: v.name, error: safeErr });
|
|
145
161
|
out.lkgUsed.push(v.name);
|
|
146
162
|
}
|
|
147
163
|
else {
|
|
148
|
-
opts.logger?.warn("center_plugin_fetch_failed", { plugin: v.name, error:
|
|
149
|
-
out.failures.push({ plugin: v.name, reason: `fetch failed: ${
|
|
164
|
+
opts.logger?.warn("center_plugin_fetch_failed", { plugin: v.name, error: safeErr });
|
|
165
|
+
out.failures.push({ plugin: v.name, reason: `fetch failed: ${safeErr}` });
|
|
150
166
|
continue;
|
|
151
167
|
}
|
|
152
168
|
}
|
package/dist/config-catalog.js
CHANGED
|
@@ -55,7 +55,7 @@ export const CONFIG_CATALOG = [
|
|
|
55
55
|
r("ALLOW_UNAUTHED_WRITES", "auth", "boolean", "无 service token 时仍接受任务提交(本地开发逃生口;缺省 fail-closed 拒提交)", { staticDefault: "false", danger: SEC, configKey: "allowUnauthedWrites" }),
|
|
56
56
|
r("ANTHROPIC_API_KEY", "modelPlane", "secret", "Anthropic 协议路线的 x-api-key 凭据(与 AUTH_TOKEN 二选一,AUTH_TOKEN 优先;与 BASE_URL 同在时可推断 provider)", { danger: [...SEC, ...BILL] }),
|
|
57
57
|
r("ANTHROPIC_AUTH_TOKEN", "modelPlane", "secret", "Anthropic 兼容生态的 Bearer 凭据(设了则不发 x-api-key)", { danger: [...SEC, ...BILL] }),
|
|
58
|
-
r("ANTHROPIC_BASE_URL", "modelPlane", "url", "Anthropic 协议路线 base URL(与凭据同在且 MODEL_PROVIDER 未设 ⇒ 推断 provider=anthropic 并 boot notice;S-475:带 userinfo 拒启,机读码 `config.gateway_url_userinfo`)"),
|
|
58
|
+
r("ANTHROPIC_BASE_URL", "modelPlane", "url", "Anthropic 协议路线 base URL(与凭据同在且 MODEL_PROVIDER 未设 ⇒ 推断 provider=anthropic 并 boot notice;S-475:带 userinfo 拒启,机读码 `config.gateway_url_userinfo`;S-483:解析不动/不透明形拒启,机读码 `config.model_route_url_invalid`)"),
|
|
59
59
|
r("ANTHROPIC_CACHE_BREAKPOINTS", "modelPlane", "boolean", "Anthropic 路线 prompt-cache breakpoints 开关", { staticDefault: "true" }),
|
|
60
60
|
r("ANTHROPIC_MAX_RETRIES", "modelPlane", "number", "Anthropic 路线 SDK 每次调用重试上限", { derivedDefaultNote: "缺席 ⇒ 不传引擎,core/SDK 默认当家" }),
|
|
61
61
|
r("ANTHROPIC_VERSION", "modelPlane", "string", "Anthropic API version 头(缺省由 SDK 定)"),
|
|
@@ -72,7 +72,7 @@ export const CONFIG_CATALOG = [
|
|
|
72
72
|
r("ATTACHMENT_ORPHAN_GRACE_MS", "limitsHttp", "number", "附件孤儿对象 GC 宽限(ms;0=关本腿,坏值回默认+warn)", { staticDefault: "3600000", danger: DATA, center: cLimits("attachmentOrphanGraceMs") }),
|
|
73
73
|
r("ATTACHMENT_UNBOUND_TTL_MS", "limitsHttp", "number", "未绑定附件的 TTL(ms)", { staticDefault: String(24 * 3600 * 1000), danger: DATA, configKey: "attachmentUnboundTtlMs", center: cLimits("attachmentUnboundTtlMs") }),
|
|
74
74
|
r("AUTH_BRIDGE_AUDIENCE", "auth", "string", "registry SSO-JWT 验签期望 aud", { danger: SEC }),
|
|
75
|
-
r("AUTH_BRIDGE_ISSUER", "auth", "
|
|
75
|
+
r("AUTH_BRIDGE_ISSUER", "auth", "url", "registry SSO-JWT issuer(auth bridge 的开关之一)", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 用 configCenter.baseUrl(配了 center 即默认开桥)" }),
|
|
76
76
|
r("AUTH_BRIDGE_JWKS_URL", "auth", "url", "registry SSO-JWT 的 JWKS 地址(缺省从 issuer 派生)", { danger: SEC }),
|
|
77
77
|
r("AUTONOMY", "auth", "enum", "运维 autonomy 模式,tighten-only 叠到每个 TaskSpec(read-only/plan ⇒ 只读手,ask ⇒ 恒 shellGate);center 腿热且 non-sticky", { danger: SEC, enumValues: ["read-only", "ask", "plan", "auto"], configKey: "autonomy", center: cGov("autonomy") }),
|
|
78
78
|
r("AUTO_MODE_CLASSIFIER_FALLBACK", "approval", "enum", "auto 分类器席答不出裁决时的梯子(main=主模型代判 / none=只留分类器那一级,整条拒)", { enumValues: ["main", "none"], danger: SEC, derivedDefaultNote: "未设/空串 ⇒ 不铸键,跟随引擎缺省 main(core 7.18.0 起;空串按未设,与其余词表旋钮同判);none = 7.77.0 及以前的行为逐字;词表外的值启动期拒启。权限轴放宽,升级须知见 DEPLOY-PREREQS", configKey: "autoModeClassifierFallback" }),
|
|
@@ -282,8 +282,8 @@ export const CONFIG_CATALOG = [
|
|
|
282
282
|
r("MODEL_EXTRA_BODY", "modelPlane", "opaque", "生成参数直通 JSON(top_k/logit_bias 等;必须静态 —— 前缀缓存键序)", { danger: SEC }),
|
|
283
283
|
r("MODEL_FIRST_TOKEN_TIMEOUT_MS", "modelPlane", "number", "首 token 超时(ms;0=关 —— 开流不吐字的唯一看门狗;自托管长 prefill 后端按此档留足)", { staticDefault: String(RESILIENCE_DEFAULTS.firstTokenTimeoutMs.value) }),
|
|
284
284
|
r("MODEL_FREQUENCY_PENALTY", "modelPlane", "number", "frequency_penalty 直通(压过 EXTRA_BODY 同键)"),
|
|
285
|
-
r("MODEL_GATEWAY_BASEURL", "modelPlane", "url", "openai 兼容网关 base URL(默认 localhost 占位 —— 公开分发不烤内网坐标;S-475:带 userinfo 拒启,机读码 `config.gateway_url_userinfo` —— WHATWG 禁止用带凭据的 URL 构造 fetch Request,凭据放 MODEL_API_KEY)", { staticDefault: "http://127.0.0.1:8000/v1", configKey: "gatewayBaseUrl", center: cModels("models[].baseUrl") }),
|
|
286
|
-
r("MODEL_GATEWAY_FALLBACK_URLS", "modelPlane", "url", "网关故障转移地址表(CSV;S-475:**逐项**判 userinfo,任一项带 ⇒ 拒启,机读码 `config.gateway_url_userinfo`)", { configKey: "gatewayFallbackUrls" }),
|
|
285
|
+
r("MODEL_GATEWAY_BASEURL", "modelPlane", "url", "openai 兼容网关 base URL(默认 localhost 占位 —— 公开分发不烤内网坐标;S-475:带 userinfo 拒启,机读码 `config.gateway_url_userinfo` —— WHATWG 禁止用带凭据的 URL 构造 fetch Request,凭据放 MODEL_API_KEY;S-483:解析不动/不透明形拒启,机读码 `config.model_route_url_invalid`)", { staticDefault: "http://127.0.0.1:8000/v1", configKey: "gatewayBaseUrl", center: cModels("models[].baseUrl") }),
|
|
286
|
+
r("MODEL_GATEWAY_FALLBACK_URLS", "modelPlane", "url", "网关故障转移地址表(CSV;S-475:**逐项**判 userinfo,任一项带 ⇒ 拒启,机读码 `config.gateway_url_userinfo`;S-483:逐项判可解析性,机读码 `config.model_route_url_invalid`)", { configKey: "gatewayFallbackUrls" }),
|
|
287
287
|
r("MODEL_ID", "modelPlane", "string", "主模型 id(3.0.0 起无出厂缺省,缺 ⇒ 拒启指路)", { configKey: "model", resolve: (c) => c.model.id, center: cModels("models[].id") }),
|
|
288
288
|
r("MODEL_IDLE_TIMEOUT_MS", "modelPlane", "number", "流中静默看门狗(ms;0=关)", { staticDefault: String(RESILIENCE_DEFAULTS.idleTimeoutMs.value) }),
|
|
289
289
|
r("MODEL_MAX_TOKENS", "modelPlane", "number", "主模型输出上限", { derivedDefaultNote: "缺席 ⇒ min(32000, contextWindow)", resolve: (c) => c.model.maxTokens ?? null, center: cModels("models[].maxTokens") }),
|
|
@@ -203,7 +203,7 @@ function stageEffective(config, eff, logger, opts) {
|
|
|
203
203
|
if (ok)
|
|
204
204
|
logger?.warn("sema_registry_model_base_url", { model: m.name, baseUrl: buildDisplaySafeUrl(rawBaseUrl), hint: "registry-directed gateway override — this model's key authenticates AGAINST THIS URL" });
|
|
205
205
|
else {
|
|
206
|
-
logger?.warn("sema_registry_model_base_url_rejected", { model: m.name, baseUrl: buildDisplaySafeUrl(String(rawBaseUrl))
|
|
206
|
+
logger?.warn("sema_registry_model_base_url_rejected", { model: m.name, baseUrl: buildDisplaySafeUrl(String(rawBaseUrl)), hint: "only http(s) URLs are honored — falling back to the boot-env gateway" });
|
|
207
207
|
safeUrl = undefined;
|
|
208
208
|
}
|
|
209
209
|
}
|
package/dist/config.js
CHANGED
|
@@ -1334,12 +1334,12 @@ function parseMemoryEmbedder(ctx) {
|
|
|
1334
1334
|
throw new Error(`MEMORY_EMBEDDER_* is half-configured — missing ${missing.join(", ")}. The memory vector plane needs all three of MEMORY_EMBEDDER_ENDPOINT, MEMORY_EMBEDDER_MODEL, MEMORY_EMBEDDER_DIM; refusing to start rather than silently running lexical-only retrieval while the deployment believes vectors are on (#228).`);
|
|
1335
1335
|
}
|
|
1336
1336
|
if (!URL.canParse(endpoint)) {
|
|
1337
|
-
throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${endpoint}" is not a valid URL (expected e.g. https://embeddings.example.com/v1 — the OpenAI-compatible base, or the full .../embeddings endpoint)`);
|
|
1337
|
+
throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${buildDisplaySafeUrl(endpoint)}" is not a valid URL (expected e.g. https://embeddings.example.com/v1 — the OpenAI-compatible base, or the full .../embeddings endpoint)`);
|
|
1338
1338
|
}
|
|
1339
1339
|
const parsedEndpoint = new URL(endpoint);
|
|
1340
1340
|
const proto = parsedEndpoint.protocol;
|
|
1341
1341
|
if (proto !== "http:" && proto !== "https:") {
|
|
1342
|
-
throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${endpoint}" must be http(s) (got "${proto}") — the embedder leg is an HTTP POST to an OpenAI-compatible /embeddings face`);
|
|
1342
|
+
throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${buildDisplaySafeUrl(endpoint)}" must be http(s) (got "${proto}") — the embedder leg is an HTTP POST to an OpenAI-compatible /embeddings face`);
|
|
1343
1343
|
}
|
|
1344
1344
|
if (parsedEndpoint.username !== "" || parsedEndpoint.password !== "") {
|
|
1345
1345
|
throw new Error("MEMORY_EMBEDDER_ENDPOINT must not carry credentials in the URL (user:password@host) — URL userinfo leaks into transport errors, logs and memory conflict reports. Use MEMORY_EMBEDDER_API_KEY (sent as a Bearer header) instead.");
|
|
@@ -1966,12 +1966,18 @@ function parseA2aServe() {
|
|
|
1966
1966
|
parsed = undefined;
|
|
1967
1967
|
}
|
|
1968
1968
|
if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
1969
|
-
throw new Error(`A2A_SERVE_URL must be a parseable http(s) URL (e.g. "https://agent.example.com/v1/a2a"); got ${JSON.stringify(rawUrl)}`);
|
|
1969
|
+
throw new Error(`A2A_SERVE_URL must be a parseable http(s) URL (e.g. "https://agent.example.com/v1/a2a"); got ${JSON.stringify(buildDisplaySafeUrl(rawUrl))}`);
|
|
1970
1970
|
}
|
|
1971
1971
|
if (parsed.username !== "" || parsed.password !== "") {
|
|
1972
1972
|
throw new Error(`A2A_SERVE_URL must not carry userinfo (credentials before "@") — it is published verbatim on the ANONYMOUSLY readable agent card, so those credentials would be handed to every discovery caller. ` +
|
|
1973
1973
|
`Inbound authentication on this endpoint is header-shaped (a service credential), not URL-embedded; strip the userinfo and keep the secret in the caller's headers.`);
|
|
1974
1974
|
}
|
|
1975
|
+
const a2aDisplaySafe = buildDisplaySafeUrl(rawUrl);
|
|
1976
|
+
if (a2aDisplaySafe !== parsed.href) {
|
|
1977
|
+
throw new Error(`A2A_SERVE_URL carries something the credential-redaction law recognizes (${a2aDisplaySafe}) — this address is published VERBATIM on the ANONYMOUSLY readable agent card (the card's url is a functional address: peers fetch it, so it is never rewritten for display), ` +
|
|
1978
|
+
`so it must not carry credentials of ANY shape: no userinfo, no credential-named query parameter (api_key/token/…), no token-shaped path segment or parameter value. ` +
|
|
1979
|
+
`Keep A2A_SERVE_URL to scheme://host[:port][/path] and move the secret into the caller's headers.`);
|
|
1980
|
+
}
|
|
1975
1981
|
return {
|
|
1976
1982
|
name: env("A2A_SERVE_NAME", "sema-server"),
|
|
1977
1983
|
...(process.env.A2A_SERVE_DESCRIPTION ? { description: process.env.A2A_SERVE_DESCRIPTION } : {}),
|
package/dist/hooks/hook-llm.d.ts
CHANGED
|
@@ -24,6 +24,31 @@ export type HookModelPick = {
|
|
|
24
24
|
ok: false;
|
|
25
25
|
error: string;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* 🔴 **hook 失败展示文本的唯一铸点**(S-494;`build*` = 返回纯数据的一只纯函数)。
|
|
29
|
+
*
|
|
30
|
+
* 病(codex 对 7.88.1/7.88.2 的补审抓出,存量泄漏,本车红先复现):这一族失败文本是**拼**出来的 ——
|
|
31
|
+
* 料有四种:core 的路由拒绝文案(`routeRefusalText` 的 `fixHint` 逐字带 `config.baseUrl` 的值)、
|
|
32
|
+
* 原生 `fetch` 的**构造期异常正文**(WHATWG:`…includes credentials: <整条 URL>`,口令逐字)、
|
|
33
|
+
* 它的 `cause`、per-model key 解析错。改前只有**两个 URL 位**走了 display-safe 铸点 —— 那是名单制:
|
|
34
|
+
* 组装料换一种(异常正文、cause)就漏,而它的出口是 `hook_runner` 的 `hook_llm_failed` warn 与
|
|
35
|
+
* `noteHookFailure` 的 stderr 位。⇒ 把尺放到**出口**:整段组装完了过**一次**律,规则从「N 个 URL 位」
|
|
36
|
+
* 减到「一只出口」(那两个 display-safe 调用随之删)。
|
|
37
|
+
*
|
|
38
|
+
* 用的是自由文本那一条律({@link redactSecrets})—— `buildDisplaySafeUrl` 是它加两道 URL 专属闸的
|
|
39
|
+
* **已知是一条 URL** 的那个特例,而这里的料是自由文本(一段异常正文里可能一个 URL 都没有,也可能有三个)。
|
|
40
|
+
* 同一张名表、同一批记号,不是第二把尺。
|
|
41
|
+
*
|
|
42
|
+
* 🔴 **脱敏器抛 ⇒ 响亮扣下**(不回原串、不 fail-open):文案形与 7.88.1 的
|
|
43
|
+
* `fail-open.ts` `renderDetail` 逐字同族(`[… withheld: redactor threw <Name>]`)—— 运维丢掉的是细节、
|
|
44
|
+
* 不是「这次失败了」这件事,而一个字节的原串都不会漏出去。
|
|
45
|
+
*
|
|
46
|
+
* 🚧 **已知射程边界(登记,另车修在源头)**:自由文本律对**非规范拼法**的 URL
|
|
47
|
+
* (`https:alice:pw@h/v1` 省了 `//`、authority 里夹 tab)认不出结构 ⇒ 那两形若出现在料里(目录下发的
|
|
48
|
+
* `models[].baseUrl` 今天原样存,`apply-effective.ts` 只校验 `protocol`)仍会原样。修在源头 =
|
|
49
|
+
* 目录 baseUrl 落库前归一成 `new URL(raw).href`(S-483 收车档未闭环项),不是在这里再加一把尺。
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildHookFailureText(raw: string): string;
|
|
27
52
|
/** The live object `createHookLlm` produces — model-pick resolution + the direct completion call, both capturing the hot-refreshed keyResolver/fetchImpl in closure. */
|
|
28
53
|
export interface HookLlm {
|
|
29
54
|
hookModelFor: (requested?: string) => HookModelPick;
|
package/dist/hooks/hook-llm.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { adjudicateModelRoute, normalizeBaseUrl, resolveRouteCredential, routeRefusalText } from "@sema-agent/core";
|
|
2
2
|
import { matchCatalogModel } from "../model-select.js";
|
|
3
|
-
import {
|
|
3
|
+
import { redactSecrets } from "../trace/redact.js";
|
|
4
|
+
export function buildHookFailureText(raw) {
|
|
5
|
+
if (raw === "")
|
|
6
|
+
return raw;
|
|
7
|
+
try {
|
|
8
|
+
return redactSecrets(raw);
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
return `[hook failure text withheld: redactor threw ${err instanceof Error ? err.name : typeof err}]`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
4
14
|
export function createHookLlm(deps) {
|
|
5
15
|
const { config, metrics } = deps;
|
|
6
16
|
const anthropicRouteBaseUrl = () => config.anthropic ? config.anthropic.baseUrl || "https://api.anthropic.com" : undefined;
|
|
@@ -48,7 +58,7 @@ export function createHookLlm(deps) {
|
|
|
48
58
|
if ((verdict.posture === "unpinned" || verdict.posture === "keyless") && normalizeBaseUrl(pick.baseUrl) !== normalizeBaseUrl(declaredRoot)) {
|
|
49
59
|
return {
|
|
50
60
|
ok: false,
|
|
51
|
-
error: `hook model "${pick.catalogRef}" points at ${
|
|
61
|
+
error: `hook model "${pick.catalogRef}" points at ${pick.baseUrl}, which this deployment did not declare as its own endpoint, ` +
|
|
52
62
|
`and no per-model credential resolved — refusing to send the prompt there (it embeds user content; an unauthenticated ` +
|
|
53
63
|
`request still hands that content to whoever operates that URL)`,
|
|
54
64
|
};
|
|
@@ -56,7 +66,7 @@ export function createHookLlm(deps) {
|
|
|
56
66
|
return { ok: true, credential };
|
|
57
67
|
};
|
|
58
68
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
59
|
-
const
|
|
69
|
+
const callLlm = async ({ prompt, model, timeoutMs, system, maxOutputTokens }) => {
|
|
60
70
|
const pick = hookModelFor(model);
|
|
61
71
|
if (!pick.ok)
|
|
62
72
|
return pick;
|
|
@@ -126,13 +136,17 @@ export function createHookLlm(deps) {
|
|
|
126
136
|
const cause = e?.cause;
|
|
127
137
|
return {
|
|
128
138
|
ok: false,
|
|
129
|
-
error: ac.signal.aborted ? "hook llm timeout" : `hook llm request to ${
|
|
139
|
+
error: ac.signal.aborted ? "hook llm timeout" : `hook llm request to ${pick.baseUrl} failed: ${String(e)}${cause ? ` (${String(cause)})` : ""}`,
|
|
130
140
|
};
|
|
131
141
|
}
|
|
132
142
|
finally {
|
|
133
143
|
clearTimeout(t);
|
|
134
144
|
}
|
|
135
145
|
};
|
|
146
|
+
const hookLlm = async (opts) => {
|
|
147
|
+
const out = await callLlm(opts);
|
|
148
|
+
return out.ok ? out : { ...out, error: buildHookFailureText(out.error) };
|
|
149
|
+
};
|
|
136
150
|
return { hookModelFor, hookLlm };
|
|
137
151
|
}
|
|
138
152
|
//# sourceMappingURL=hook-llm.js.map
|
|
@@ -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";
|
|
@@ -495,7 +495,7 @@ async function runHttpHook(entry, payload, ctx) {
|
|
|
495
495
|
if (trimmed === "")
|
|
496
496
|
return { code: 0, stdout: "{}", stderr: "", timedOut: false };
|
|
497
497
|
if (!parseHookStdout(body)) {
|
|
498
|
-
ctx.logger.warn("hook_http_non_json_response", { url:
|
|
498
|
+
ctx.logger.warn("hook_http_non_json_response", { url: buildDisplaySafeUrl(entry.url), preview: clip(trimmed, 200) });
|
|
499
499
|
noteHookFailure(ctx, {
|
|
500
500
|
event: eventOfPayload(payload),
|
|
501
501
|
entry,
|
|
@@ -6,6 +6,7 @@ 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;
|
|
@@ -194,7 +195,7 @@ export async function handleSessionSync(req, res, match, ctx) {
|
|
|
194
195
|
void deps.sessionMirrorRuling(auditPrincipal).then((mirror) => {
|
|
195
196
|
if (mirror?.required) {
|
|
196
197
|
deps.metrics?.inc("session_mirror_required_push_total");
|
|
197
|
-
deps.logger?.info("session_mirror_required_push_observed", { principal: auditPrincipal, engineUrl: mirror.engineUrl, sessionId });
|
|
198
|
+
deps.logger?.info("session_mirror_required_push_observed", { principal: auditPrincipal, engineUrl: buildDisplaySafeUrl(mirror.engineUrl), sessionId });
|
|
198
199
|
}
|
|
199
200
|
}).catch(() => { });
|
|
200
201
|
}
|
|
@@ -1,9 +1,25 @@
|
|
|
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
|
-
/**
|
|
4
|
-
|
|
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
|
+
* 模型路由 base URL 的拒因(`undefined` = 这一位合法)—— **这三根键的唯一判官**。
|
|
8
|
+
*
|
|
9
|
+
* 两臂,顺序即语义,**两臂都经 display-safe 铸点**:
|
|
10
|
+
* ① **不是一个层级式绝对 URL** ⇒ `config.model_route_url_invalid`;
|
|
11
|
+
* ② 是,但带 userinfo ⇒ `config.gateway_url_userinfo`。
|
|
12
|
+
*
|
|
13
|
+
* 🔴 ① 为什么必须在这里、而不是「由别的门管」(S-483 座⑨,与 ME/A2A 两座**同病镜像**):本函数此前先问
|
|
14
|
+
* 凭据位、并把「解析不动」读成**没有**凭据(`urlCarriesUserinfo` 的 `return false`),旁注写着「不是 URL 由
|
|
15
|
+
* 别的门管」—— 而 config 层对这三根**没有**那道门(`config.ts` 与 `config-invariants.ts` 只调本函数)。
|
|
16
|
+
* 于是半写 userinfo `http://user:pass@`(`URL.canParse` 假)与 `alice:pw@host:8000`(不透明 URL,
|
|
17
|
+
* `username`/`password` 恒空)两形被当「无凭据」放行,一路到 brain 装配期才由 core 拒;core 那一臂
|
|
18
|
+
* 今天不回显原值 —— 但**「恰好不漏」不是防线**,而且拒启点越晚,运维越读不出是哪根旋钮配错了。
|
|
19
|
+
* ⇒ 判据改成与铸点**同一只** {@link isHierarchicalUrl}:两处再也不可能各判各的。
|
|
20
|
+
*
|
|
21
|
+
* 🚧 **未闭环(不在本车射程)**:层级式但**非 http(s)** 的形(`ftp://h/v1`,不带 userinfo)今天仍放行 ——
|
|
22
|
+
* `fetch` 对它同样必然失败,但那是第三条臂、是一次新的行为面拒启,按三问另开车(S-483 收车档已登记)。
|
|
7
23
|
*
|
|
8
24
|
* 拒因四件齐:**码** + **哪个 env 键** + **洗过的 URL**(host/port/path 逐字,userinfo 与凭据参数成记号 ——
|
|
9
25
|
* 运维要靠 host 认出是哪一条路由,而密码一个字节都不许进启动错误)+ **出路一句**(点名真实旋钮)。
|
package/dist/model-route-url.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
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
|
|
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";
|
|
9
4
|
export function modelRouteUrlRefusal(envKey, raw, credentialKnob) {
|
|
10
|
-
if (
|
|
5
|
+
if (raw === "")
|
|
6
|
+
return undefined;
|
|
7
|
+
if (!isHierarchicalUrl(raw)) {
|
|
8
|
+
return (`env ${envKey} is not a parseable absolute URL (${buildDisplaySafeUrl(raw)}) [${MODEL_ROUTE_URL_INVALID_CODE}] — ` +
|
|
9
|
+
"a model-route base URL is consumed by fetch, which requires an absolute hierarchical URL " +
|
|
10
|
+
"(scheme://host[:port][/path]); half-written forms like \"http://user:pass@\" and opaque forms like " +
|
|
11
|
+
"\"host:8000\" are not one, and they also hide any credentials they carry from every credential-position check. " +
|
|
12
|
+
`Set ${envKey} to a full scheme://host URL (and put the credential in ${credentialKnob}, never in the URL).`);
|
|
13
|
+
}
|
|
14
|
+
const u = new URL(raw);
|
|
15
|
+
if (u.username === "" && u.password === "")
|
|
11
16
|
return undefined;
|
|
12
17
|
return (`env ${envKey} carries credentials in the URL userinfo (${buildDisplaySafeUrl(raw)}) [${GATEWAY_URL_USERINFO_CODE}] — ` +
|
|
13
18
|
"WHATWG forbids constructing a fetch Request from a URL with credentials, so EVERY model call on this route " +
|
|
@@ -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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* 终态 fail-closed + **懒连接**门(exec 面)。未连 ⇒ 先走一次准入,失败按 exec 货币报**准入自己的**
|
|
232
|
+
* 拒因(不再吐一句「call connect() first」—— 这条车道上没有任何人能照做)。
|
|
233
|
+
*/
|
|
207
234
|
private shellGate;
|
|
208
|
-
/**
|
|
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`。
|
|
@@ -99,6 +99,17 @@ const DEVICE_REJECT_TO_REMOTE_ERROR = {
|
|
|
99
99
|
function rejectText(reject) {
|
|
100
100
|
return `[${reject.code}] ${reject.message}`;
|
|
101
101
|
}
|
|
102
|
+
const ENV_TERMINAL = { kind: "env_terminal" };
|
|
103
|
+
const ENV_TERMINAL_MESSAGE = "execution env already destroyed";
|
|
104
|
+
function failureFor(code, message) {
|
|
105
|
+
return { remote: DEVICE_REJECT_TO_REMOTE_ERROR[code], exec: DEVICE_REJECT_TO_EXEC_ERROR[code], file: DEVICE_REJECT_TO_FILE_ERROR[code], message };
|
|
106
|
+
}
|
|
107
|
+
const ADMISSION_DESTROYED = {
|
|
108
|
+
remote: "connect_failed",
|
|
109
|
+
exec: "shell_unavailable",
|
|
110
|
+
file: "unknown",
|
|
111
|
+
message: "execution env already destroyed",
|
|
112
|
+
};
|
|
102
113
|
function assertPosixDevicePlatform(os, deviceId) {
|
|
103
114
|
switch (os) {
|
|
104
115
|
case "darwin":
|
|
@@ -118,6 +129,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
118
129
|
writeFileExclusive;
|
|
119
130
|
writeFileGuarded;
|
|
120
131
|
connected = false;
|
|
132
|
+
connectPromise;
|
|
121
133
|
destroyed = false;
|
|
122
134
|
constructor(cfg) {
|
|
123
135
|
this.cfg = cfg;
|
|
@@ -172,18 +184,61 @@ export class RemoteDeviceExecutionEnv {
|
|
|
172
184
|
};
|
|
173
185
|
}
|
|
174
186
|
async connect(config) {
|
|
175
|
-
if (this.destroyed)
|
|
176
|
-
return { ok: false, error: new RemoteExecutionError("connect_failed", "device env destroyed — create a fresh env instead of reviving") };
|
|
177
187
|
if (config?.abortSignal?.aborted === true)
|
|
178
188
|
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted") };
|
|
179
|
-
const admitted = await this.
|
|
189
|
+
const admitted = await this.admitShared();
|
|
190
|
+
if (admitted.ok)
|
|
191
|
+
return ok(admitted.value);
|
|
192
|
+
return { ok: false, error: new RemoteExecutionError(admitted.failure.remote, admitted.failure.message) };
|
|
193
|
+
}
|
|
194
|
+
async admitShared() {
|
|
195
|
+
const inflight = this.connectPromise;
|
|
196
|
+
if (inflight !== undefined)
|
|
197
|
+
return inflight;
|
|
198
|
+
const started = this.admit();
|
|
199
|
+
this.connectPromise = started;
|
|
200
|
+
try {
|
|
201
|
+
return await started;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
if (this.connectPromise === started)
|
|
205
|
+
this.connectPromise = undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async ensureConnected() {
|
|
209
|
+
if (this.destroyed)
|
|
210
|
+
return ADMISSION_DESTROYED;
|
|
211
|
+
if (this.connected)
|
|
212
|
+
return undefined;
|
|
213
|
+
const admitted = await this.admitShared();
|
|
214
|
+
return admitted.ok ? undefined : admitted.failure;
|
|
215
|
+
}
|
|
216
|
+
async admit() {
|
|
217
|
+
if (this.destroyed) {
|
|
218
|
+
return { ok: false, failure: { ...ADMISSION_DESTROYED, message: "device env destroyed — create a fresh env instead of reviving" } };
|
|
219
|
+
}
|
|
220
|
+
let admitted;
|
|
221
|
+
try {
|
|
222
|
+
admitted = await this.cfg.enrollment.admitSession({ rootSessionId: this.cfg.rootSessionId, requester: this.cfg.owner });
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
failure: {
|
|
228
|
+
remote: "connect_failed",
|
|
229
|
+
exec: "target_unavailable",
|
|
230
|
+
file: "transport_lost",
|
|
231
|
+
message: `[device.admission_unavailable] the device admission read failed, so this session's device could not be confirmed (fail-closed): ${err instanceof Error ? err.message : String(err)}`,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
180
235
|
if (!admitted.ok) {
|
|
181
|
-
return { ok: false,
|
|
236
|
+
return { ok: false, failure: failureFor(admitted.reject.code, rejectText(admitted.reject)) };
|
|
182
237
|
}
|
|
183
238
|
if (admitted.value.deviceId !== this.cfg.deviceId) {
|
|
184
239
|
return {
|
|
185
240
|
ok: false,
|
|
186
|
-
|
|
241
|
+
failure: failureFor("device.identity_mismatch", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device (${admitted.value.deviceId}) than the one this environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (device lane identity, v2 §4.5/§3)`),
|
|
187
242
|
};
|
|
188
243
|
}
|
|
189
244
|
{
|
|
@@ -191,12 +246,20 @@ export class RemoteDeviceExecutionEnv {
|
|
|
191
246
|
if (gained.length > 0) {
|
|
192
247
|
return {
|
|
193
248
|
ok: false,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
249
|
+
failure: {
|
|
250
|
+
remote: "connect_failed",
|
|
251
|
+
exec: "shell_unavailable",
|
|
252
|
+
file: "not_supported",
|
|
253
|
+
message: `[device.capability_drift] this environment was minted while the device's protective write primitives (${gained.join(", ")}) were not observable, ` +
|
|
254
|
+
"so they are absent from its filesystem face — core would read that absence as \"the backend has no such primitive\" and fall back to a NON-atomic write, " +
|
|
255
|
+
"silently re-opening the TOCTOU window guarded writes exist to close (#157). Refusing this connect; the per-task factory will mint a correctly-shaped env on retry.",
|
|
256
|
+
},
|
|
197
257
|
};
|
|
198
258
|
}
|
|
199
259
|
}
|
|
260
|
+
if (this.destroyed) {
|
|
261
|
+
return { ok: false, failure: { ...ADMISSION_DESTROYED, message: "device env destroyed while admitting — create a fresh env instead of reviving" } };
|
|
262
|
+
}
|
|
200
263
|
this.connected = true;
|
|
201
264
|
return ok(this.workspaceHandle());
|
|
202
265
|
}
|
|
@@ -226,24 +289,28 @@ export class RemoteDeviceExecutionEnv {
|
|
|
226
289
|
async cleanup() {
|
|
227
290
|
return this.destroy();
|
|
228
291
|
}
|
|
229
|
-
shellGate() {
|
|
292
|
+
async shellGate() {
|
|
230
293
|
if (this.destroyed)
|
|
231
294
|
return { ok: false, error: new ExecutionError("shell_unavailable", "execution env already destroyed") };
|
|
232
|
-
|
|
233
|
-
|
|
295
|
+
const failure = await this.ensureConnected();
|
|
296
|
+
if (failure)
|
|
297
|
+
return { ok: false, error: new ExecutionError(failure.exec, failure.message) };
|
|
234
298
|
return undefined;
|
|
235
299
|
}
|
|
236
|
-
fsGate(p) {
|
|
300
|
+
async fsGate(p) {
|
|
237
301
|
if (this.destroyed)
|
|
238
302
|
return { ok: false, error: new FileError("unknown", "execution env already destroyed", this.resolve(p)) };
|
|
239
|
-
|
|
240
|
-
|
|
303
|
+
const failure = await this.ensureConnected();
|
|
304
|
+
if (failure)
|
|
305
|
+
return { ok: false, error: new FileError(failure.file, failure.message, this.resolve(p)) };
|
|
241
306
|
return undefined;
|
|
242
307
|
}
|
|
243
308
|
timeoutMsFor(seconds) {
|
|
244
309
|
return seconds != null && Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds * 1000) : this.cfg.execTimeoutMs;
|
|
245
310
|
}
|
|
246
311
|
dispatch(kind, args, opts) {
|
|
312
|
+
if (this.destroyed)
|
|
313
|
+
return Promise.resolve(ENV_TERMINAL);
|
|
247
314
|
const input = {
|
|
248
315
|
rootSessionId: this.cfg.rootSessionId,
|
|
249
316
|
requester: this.cfg.owner,
|
|
@@ -262,6 +329,8 @@ export class RemoteDeviceExecutionEnv {
|
|
|
262
329
|
}
|
|
263
330
|
fileOutcome(res, absPath, take) {
|
|
264
331
|
switch (res.kind) {
|
|
332
|
+
case "env_terminal":
|
|
333
|
+
return { ok: false, error: new FileError("unknown", ENV_TERMINAL_MESSAGE, absPath) };
|
|
265
334
|
case "rejected":
|
|
266
335
|
return { ok: false, error: new FileError(DEVICE_REJECT_TO_FILE_ERROR[res.reject.code], rejectText(res.reject), absPath) };
|
|
267
336
|
case "never_started":
|
|
@@ -277,7 +346,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
277
346
|
return { ok: false, error: new FileError("unknown", `device returned a "${got}" outcome for an operation that requires "${want}" — protocol violation`, absPath) };
|
|
278
347
|
}
|
|
279
348
|
async exec(command, options) {
|
|
280
|
-
const gated = this.shellGate();
|
|
349
|
+
const gated = await this.shellGate();
|
|
281
350
|
if (gated)
|
|
282
351
|
return gated;
|
|
283
352
|
let stdout = "";
|
|
@@ -301,6 +370,8 @@ export class RemoteDeviceExecutionEnv {
|
|
|
301
370
|
},
|
|
302
371
|
});
|
|
303
372
|
switch (res.kind) {
|
|
373
|
+
case "env_terminal":
|
|
374
|
+
return { ok: false, error: new ExecutionError("shell_unavailable", ENV_TERMINAL_MESSAGE) };
|
|
304
375
|
case "rejected":
|
|
305
376
|
return { ok: false, error: new ExecutionError(DEVICE_REJECT_TO_EXEC_ERROR[res.reject.code], rejectText(res.reject)) };
|
|
306
377
|
case "never_started":
|
|
@@ -321,8 +392,11 @@ export class RemoteDeviceExecutionEnv {
|
|
|
321
392
|
async *execStream(command, options) {
|
|
322
393
|
if (this.destroyed)
|
|
323
394
|
throw new RemoteExecutionError("connect_failed", "execution env already destroyed");
|
|
324
|
-
|
|
325
|
-
|
|
395
|
+
{
|
|
396
|
+
const failure = await this.ensureConnected();
|
|
397
|
+
if (failure)
|
|
398
|
+
throw new RemoteExecutionError(failure.remote, failure.message);
|
|
399
|
+
}
|
|
326
400
|
const queue = [];
|
|
327
401
|
let queuedBytes = 0;
|
|
328
402
|
let done = false;
|
|
@@ -434,7 +508,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
434
508
|
}
|
|
435
509
|
}
|
|
436
510
|
async exists(p, abortSignal) {
|
|
437
|
-
const gated = this.fsGate(p);
|
|
511
|
+
const gated = await this.fsGate(p);
|
|
438
512
|
if (gated)
|
|
439
513
|
return gated;
|
|
440
514
|
const abs = this.resolve(p);
|
|
@@ -448,7 +522,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
448
522
|
});
|
|
449
523
|
}
|
|
450
524
|
async canonicalPath(p, abortSignal) {
|
|
451
|
-
const gated = this.fsGate(p);
|
|
525
|
+
const gated = await this.fsGate(p);
|
|
452
526
|
if (gated)
|
|
453
527
|
return gated;
|
|
454
528
|
const abs = this.resolve(p);
|
|
@@ -462,7 +536,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
462
536
|
});
|
|
463
537
|
}
|
|
464
538
|
async fileInfo(p, abortSignal) {
|
|
465
|
-
const gated = this.fsGate(p);
|
|
539
|
+
const gated = await this.fsGate(p);
|
|
466
540
|
if (gated)
|
|
467
541
|
return gated;
|
|
468
542
|
const abs = this.resolve(p);
|
|
@@ -476,7 +550,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
476
550
|
});
|
|
477
551
|
}
|
|
478
552
|
async doReadLink(p, abortSignal) {
|
|
479
|
-
const gated = this.fsGate(p);
|
|
553
|
+
const gated = await this.fsGate(p);
|
|
480
554
|
if (gated)
|
|
481
555
|
return gated;
|
|
482
556
|
const abs = this.resolve(p);
|
|
@@ -492,7 +566,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
492
566
|
});
|
|
493
567
|
}
|
|
494
568
|
async readBinaryFile(p, abortSignal) {
|
|
495
|
-
const gated = this.fsGate(p);
|
|
569
|
+
const gated = await this.fsGate(p);
|
|
496
570
|
if (gated)
|
|
497
571
|
return gated;
|
|
498
572
|
const abs = this.resolve(p);
|
|
@@ -508,7 +582,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
508
582
|
return this.fileOutcome(res, abs, (o) => (o.kind === "read" ? ok(new Uint8Array(Buffer.concat(parts))) : this.wrongOutcome("read", o.kind, abs)));
|
|
509
583
|
}
|
|
510
584
|
async readTextFile(p, abortSignal) {
|
|
511
|
-
const gated = this.fsGate(p);
|
|
585
|
+
const gated = await this.fsGate(p);
|
|
512
586
|
if (gated)
|
|
513
587
|
return gated;
|
|
514
588
|
const abs = this.resolve(p);
|
|
@@ -524,7 +598,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
524
598
|
return this.fileOutcome(res, abs, (o) => (o.kind === "read" ? ok(Buffer.concat(parts).toString("utf8")) : this.wrongOutcome("read", o.kind, abs)));
|
|
525
599
|
}
|
|
526
600
|
async readTextLines(p, options) {
|
|
527
|
-
const gated = this.fsGate(p);
|
|
601
|
+
const gated = await this.fsGate(p);
|
|
528
602
|
if (gated)
|
|
529
603
|
return gated;
|
|
530
604
|
const abs = this.resolve(p);
|
|
@@ -547,7 +621,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
547
621
|
});
|
|
548
622
|
}
|
|
549
623
|
async listDir(p, abortSignal) {
|
|
550
|
-
const gated = this.fsGate(p);
|
|
624
|
+
const gated = await this.fsGate(p);
|
|
551
625
|
if (gated)
|
|
552
626
|
return gated;
|
|
553
627
|
const abs = this.resolve(p);
|
|
@@ -567,7 +641,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
567
641
|
return this.doWrite("appendFile", p, content, undefined, abortSignal);
|
|
568
642
|
}
|
|
569
643
|
async doWrite(kind, p, content, _unused, abortSignal) {
|
|
570
|
-
const gated = this.fsGate(p);
|
|
644
|
+
const gated = await this.fsGate(p);
|
|
571
645
|
if (gated)
|
|
572
646
|
return gated;
|
|
573
647
|
const abs = this.resolve(p);
|
|
@@ -590,7 +664,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
590
664
|
});
|
|
591
665
|
}
|
|
592
666
|
async doGuardedWrite(p, content, expect, abortSignal) {
|
|
593
|
-
const gated = this.fsGate(p);
|
|
667
|
+
const gated = await this.fsGate(p);
|
|
594
668
|
if (gated)
|
|
595
669
|
return gated;
|
|
596
670
|
const abs = this.resolve(p);
|
|
@@ -615,7 +689,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
615
689
|
return false;
|
|
616
690
|
}
|
|
617
691
|
async createDir(p, options) {
|
|
618
|
-
const gated = this.fsGate(p);
|
|
692
|
+
const gated = await this.fsGate(p);
|
|
619
693
|
if (gated)
|
|
620
694
|
return gated;
|
|
621
695
|
const abs = this.resolve(p);
|
|
@@ -623,7 +697,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
623
697
|
return this.fileOutcome(res, abs, (o) => (o.kind === "void" ? ok(undefined) : this.wrongOutcome("void", o.kind, abs)));
|
|
624
698
|
}
|
|
625
699
|
async remove(p, options) {
|
|
626
|
-
const gated = this.fsGate(p);
|
|
700
|
+
const gated = await this.fsGate(p);
|
|
627
701
|
if (gated)
|
|
628
702
|
return gated;
|
|
629
703
|
const abs = this.resolve(p);
|
|
@@ -631,14 +705,14 @@ export class RemoteDeviceExecutionEnv {
|
|
|
631
705
|
return this.fileOutcome(res, abs, (o) => (o.kind === "void" ? ok(undefined) : this.wrongOutcome("void", o.kind, abs)));
|
|
632
706
|
}
|
|
633
707
|
async createTempDir(prefix = "tmp-", abortSignal) {
|
|
634
|
-
const gated = this.fsGate(this.cwd);
|
|
708
|
+
const gated = await this.fsGate(this.cwd);
|
|
635
709
|
if (gated)
|
|
636
710
|
return gated;
|
|
637
711
|
const res = await this.dispatch("createTempDir", { prefix }, { timeoutMs: this.cfg.execTimeoutMs, ...(abortSignal === undefined ? {} : { signal: abortSignal }) });
|
|
638
712
|
return this.fileOutcome(res, this.cwd, (o) => (o.kind === "write" ? ok(o.canonicalPath) : this.wrongOutcome("write", o.kind, this.cwd)));
|
|
639
713
|
}
|
|
640
714
|
async createTempFile(options) {
|
|
641
|
-
const gated = this.fsGate(this.cwd);
|
|
715
|
+
const gated = await this.fsGate(this.cwd);
|
|
642
716
|
if (gated)
|
|
643
717
|
return gated;
|
|
644
718
|
const res = await this.dispatch("createTempFile", { ...(options?.prefix === undefined ? {} : { prefix: options.prefix }), ...(options?.suffix === undefined ? {} : { suffix: options.suffix }) }, { timeoutMs: this.cfg.execTimeoutMs, ...(options?.abortSignal === undefined ? {} : { signal: options.abortSignal }) });
|
|
@@ -666,6 +740,8 @@ function toWireExpectation(e) {
|
|
|
666
740
|
}
|
|
667
741
|
function streamFailure(res) {
|
|
668
742
|
switch (res.kind) {
|
|
743
|
+
case "env_terminal":
|
|
744
|
+
return new RemoteExecutionError("connect_failed", ENV_TERMINAL_MESSAGE);
|
|
669
745
|
case "rejected":
|
|
670
746
|
return new RemoteExecutionError(DEVICE_REJECT_TO_REMOTE_ERROR[res.reject.code], rejectText(res.reject));
|
|
671
747
|
case "never_started":
|
package/dist/trace/redact.d.ts
CHANGED
|
@@ -136,6 +136,26 @@ export declare function redactDeep(value: unknown): unknown;
|
|
|
136
136
|
* owner-scoped, so this preview is only ever shown to the ask's own principal (or an operator inspecting it).
|
|
137
137
|
*/
|
|
138
138
|
export declare function redactedPreview(value: unknown, cap?: number): string | null;
|
|
139
|
+
/**
|
|
140
|
+
* **这一位是不是一个层级式绝对 URL**(有 authority 的那一族)—— 本铸点与**模型路由三键拒启**
|
|
141
|
+
* (`model-route-url.ts` 第一臂)共用的**同一只**判据:两处曾各判各的,而 `URL.canParse` 单独为真的
|
|
142
|
+
* **不透明** URL 恰好是「凭据位看不见」的那一形。
|
|
143
|
+
*
|
|
144
|
+
* ⚠️ **射程如实**(车JC 复审去幻觉轮:此处原文写的是「本仓唯一判据」,亲读证伪)—— 仓里另有两处仍用
|
|
145
|
+
* 裸 `URL.canParse` 回答同一个问题,且**刻意不在本批射程内**(改它们各是一次新的行为面拒启,按三问另开车):
|
|
146
|
+
* · `config.ts` 的 `GIT_API_BASEURL`(`parseGitApiKind`):无协议闸 ⇒ 不透明形今天过门(拒因经本铸点,
|
|
147
|
+
* 所以不回显凭据;可用性由 `fetch` 那侧决定);
|
|
148
|
+
* · `config-catalog.ts` 的 `stripUrlUserinfo`(url 型行的回显尺):不透明形 `href === 原串` ⇒ **原样回显**
|
|
149
|
+
* (登记为未闭环 —— 它等 core `displayUrlOf` 落地后与 catalog wire 行为面一起改,表态制点名 web-admin)。
|
|
150
|
+
*
|
|
151
|
+
* 🔴 谓词写成 `URL.canParse` 而不是 try/catch:这一臂**不是兜底**(兜底才归静默降级那张表管),它是判据本身 ——
|
|
152
|
+
* 「不是一个层级式 URL」是一个可判的状态,用异常当控制流只会让它看起来像一次意外。
|
|
153
|
+
* 🔴 「解析得动」**不够**:`new URL("alice:hunter2@gw.internal:8000")` 是合法的**不透明** URL(`alice:` 当 scheme,
|
|
154
|
+
* 与 `mailto:` 同族)—— 它没有 authority,URL 律的锚(`scheme://`)对它不成立,`username`/`password` 恒空,
|
|
155
|
+
* 直接回原串就是密码明文(红先实测)。闸取「**层级式** URL」:`href` 以 `<protocol>//` 起头 ⇒ 有 authority
|
|
156
|
+
* ⇒ 三个凭据位都在律的射程内。
|
|
157
|
+
*/
|
|
158
|
+
export declare function isHierarchicalUrl(raw: string): boolean;
|
|
139
159
|
/**
|
|
140
160
|
* 🔴 **配置型 URL 上日志 / 诊断面的唯一 display-safe 铸点**(S-475 件B;`build*` = 返回纯数据的一只纯函数)。
|
|
141
161
|
*
|
package/dist/trace/redact.js
CHANGED
|
@@ -1712,14 +1712,18 @@ export function redactedPreview(value, cap = 2000) {
|
|
|
1712
1712
|
s = redactSecrets(s, { json: true });
|
|
1713
1713
|
return s.length > cap ? `${s.slice(0, cap)}…[truncated]` : s;
|
|
1714
1714
|
}
|
|
1715
|
+
export function isHierarchicalUrl(raw) {
|
|
1716
|
+
if (!URL.canParse(raw))
|
|
1717
|
+
return false;
|
|
1718
|
+
const u = new URL(raw);
|
|
1719
|
+
return u.href.startsWith(`${u.protocol}//`);
|
|
1720
|
+
}
|
|
1715
1721
|
export function buildDisplaySafeUrl(raw) {
|
|
1716
1722
|
if (raw === "")
|
|
1717
1723
|
return "";
|
|
1718
|
-
if (!
|
|
1724
|
+
if (!isHierarchicalUrl(raw))
|
|
1719
1725
|
return DISPLAY_SAFE_URL_TOKEN;
|
|
1720
1726
|
const u = new URL(raw);
|
|
1721
|
-
if (!u.href.startsWith(`${u.protocol}//`))
|
|
1722
|
-
return DISPLAY_SAFE_URL_TOKEN;
|
|
1723
1727
|
return redactSecrets(u.href);
|
|
1724
1728
|
}
|
|
1725
1729
|
//# sourceMappingURL=redact.js.map
|
|
@@ -166,6 +166,29 @@ Sign with the device private key over exactly these bytes:
|
|
|
166
166
|
Any deviation — a different tag, a missing length prefix, a differently-encoded epoch — makes **every**
|
|
167
167
|
handshake fail with `auth_failed`, with no hint as to which half is wrong. Get this right first.
|
|
168
168
|
|
|
169
|
+
#### `epoch` is a byte-exact key: exactly which strings are accepted
|
|
170
|
+
|
|
171
|
+
`epoch` is a 26-character **upper-case** Crockford base32 ULID whose first character is `0`–`7`. Crockford's
|
|
172
|
+
alphabet is case-insensitive on paper; this protocol is **not**, on purpose: the same `epoch` string is also the
|
|
173
|
+
key the server compares when fencing connection generations and leases, so two spellings of one ULID would let
|
|
174
|
+
one connection be counted as two. That is worse than a failed handshake, so the decoder stays strict.
|
|
175
|
+
|
|
176
|
+
The two entry points reject differently — if you call the low-level decoder yourself, check for `null`:
|
|
177
|
+
|
|
178
|
+
| Input | `decodeUlid16(...)` | `buildHelloSignaturePayload({ epoch })` |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| valid, e.g. `01J8ZQ4T9X7M3K5N6P8R0S2V4W` | 16 bytes | payload |
|
|
181
|
+
| lower-case, e.g. `01j8zq4t9x…` | `null` | throws `Error: device-ws: epoch must be a 26-char Crockford ULID (got "…")` |
|
|
182
|
+
| first character `8`, `9`, `A` … `Z` (128-bit overflow) | `null` | throws |
|
|
183
|
+
| 25 characters (or 27) | `null` | throws |
|
|
184
|
+
| contains an excluded letter `I`, `L`, `O`, `U` | `null` | throws |
|
|
185
|
+
|
|
186
|
+
The discriminating case, if you want to prove your own decoder judges the right thing: a `Z` as the **last**
|
|
187
|
+
character is accepted (it is just the value 31), while a `Z` as the **first** character is rejected (overflow).
|
|
188
|
+
A decoder that rejects "any Z" is wrong. Remember that a bad signature is answered with `auth_failed` and nothing
|
|
189
|
+
else — the server does not say which half was wrong — so run the vector below and these rejection cases against
|
|
190
|
+
your implementation before the first live handshake.
|
|
191
|
+
|
|
169
192
|
#### Importable source of truth and a fixed test vector
|
|
170
193
|
|
|
171
194
|
You do not have to transcribe the byte layout. From `@sema-agent/server` 7.88.0 the executor-facing part of the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.89.0",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@sema-agent/core": "7.23.
|
|
62
|
+
"@sema-agent/core": "7.23.5",
|
|
63
63
|
"@sema-agent/settings-schema": "3.0.0",
|
|
64
64
|
"e2b": "^2.28.0",
|
|
65
65
|
"libsodium-wrappers": "^0.8.4",
|