@sema-agent/server 7.26.0 → 7.27.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.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * [DESIGN-269 车1 §2.2/§2.3 — A2A client seam] — project a caller's per-request A2A peers
3
+ * (`TaskRequest.a2aPeers`) into the engine, so a `sema` run can talk to the agents the USER declares,
4
+ * not only the ones the deployment's config center declares. Point-for-point mirror of `task-mcp.ts`
5
+ * (the MCP seam), because the trust question is the same question — and deliberately NOT a copy where
6
+ * the two protocols differ (see 「A2A 比 MCP 窄」 below).
7
+ *
8
+ * 🔒 SECURITY — an A2A peer is a remote **AGENT**, not a data source: it acts on its own side. core ruled
9
+ * (2026-08-03, `A2aServerSpec` doc) that every skill such a peer advertises mounts with `egress: true` +
10
+ * `effect: "write"`, i.e.接入即引入对外写通道 — it registers the approval gate, is never auto-allowed on a
11
+ * no-policy deployment, and its replies reach the model inside an untrusted-data fence. The trust boundary
12
+ * is therefore the SAME one the MCP seam draws, for the same reason:
13
+ * - SINGLE-USER deployment (`requirePrincipal !== false`… i.e. `!== true`): the requester IS the
14
+ * super-admin of their own worker, so the peers they name are their own choice (CC-parity), on EVERY
15
+ * execution lane — an A2A call is a plain outbound HTTPS request made by the worker, so the lane where
16
+ * the agent's TOOL CALLS run is irrelevant (exactly the fact that decoupled the MCP gate from the lane).
17
+ * - MULTI-TENANT deployment (`requirePrincipal === true`): a tenant must NOT make the SHARED worker POST
18
+ * to a body-chosen URL (SSRF) carrying body-chosen headers, nor mount write-capable tools from an agent
19
+ * the operator never vetted. Gate CLOSED → body peers ignored; the fleet gets peers only through center
20
+ * config (`config.a2aPeers`).
21
+ * When the gate is closed a sent `a2aPeers` is IGNORED — and SAID (`a2a_injection_ignored` + the
22
+ * `capabilities.a2aInjection` advertisement), never silently swallowed.
23
+ *
24
+ * ⚠️ **A2A 的轴比 MCP 窄,这不是简化而是协议事实**:A2A has no per-skill annotation vocabulary, so there is
25
+ * no server-hint leg to fold — `toolAxes` (the CALLER's own judgement) is the ONLY thing that can move an axis
26
+ * off the fail-closed default. core says as much on `A2aToolAxis`: `irreversibility:"never"` / `egress:false`
27
+ * 「appears ONLY via an explicit caller override」, enforcement ignores the loosening (tighten-only) and the
28
+ * ask's risk-axes REPORT face consumes it. So this module carries the caller's overrides through verbatim —
29
+ * they are a judgement, not silence — while knowing that core will not let them widen enforcement.
30
+ *
31
+ * ⚠️ **两票判据的词表在 core 5.36 尚未到货**(分单件 C-1/C-2,core 已认领排 5.37 后首个 A2A 窗):
32
+ * `LockedKey` 是 `"mcp"|"toolPolicy"|"compliancePosture"|"retentionPolicy"`,`ComplianceCapability` 是
33
+ * `"mcp_servers"|"workflows"|"web_fetch"|"org_memory_mount"` —— 两张表里都还没有 A2A 的位。本模块因此按
34
+ * **字符串**判(server config 的 `lockedConfigKeys` 本就是 server 自己的声明面,不经 core 闭集型),
35
+ * 判据一到货就换 core 真源、钉子逐字不用改。
36
+ * 🔴 **今天这两票在 env 通道上还打不响**,而这是刻意如实记下的、不是被忽略的缺口:`LOCKED_CONFIG_KEYS`
37
+ * 经 core 的 `resolveLockedKeys` 校验(未知键拒启)、`COMPLIANCE_ADDITIONAL_DENIES` 经 `COMPLIANCE_CAPABILITIES`
38
+ * 校验(同拒),所以运维今天**写不进** `a2a` / `a2a_peers`。而且合规那一票还有第二层:即便有人绕过 boot 期
39
+ * 校验把 `a2a_peers` 塞进档位,`resolveComplianceDenies` 对闭集外的词是**抛**(亲测 core 5.36),于是这条
40
+ * 请求腿的失败方向是**响亮**(500)而不是安静放行 —— 方向正确,故本模块不加任何兜底。两票的代码先在场是
41
+ * 纵深:core 词表到货那一拍这条腿自己就活了,不需要有人记得回来补;反过来(等词表到了再写判据)才是
42
+ * 「广告了却拦不住」的那一类缺口。钉在 `test/task-a2a.test.ts`(含到货后该怎么改那一格的原地说明)。
43
+ */
44
+ import { resolveComplianceDenies } from "@sema-agent/core";
45
+ import { HttpError } from "./security.js";
46
+ /** Bound on caller-supplied peers (anti-DoS; a real declaration names a handful of agents). */
47
+ export const MAX_REQUEST_A2A_PEERS = 32;
48
+ /**
49
+ * Does THIS deployment honor caller-supplied per-request A2A peers? Three vetoes, ALL of them here (this
50
+ * predicate is the single owner — the capability bit and the request leg both read it, so there is exactly
51
+ * one place where the answer can be wrong):
52
+ * ① the deployment locks the `a2a` key — a locked deployment that advertised `a2aInjection:true` would
53
+ * hand every consumer an affordance whose every use 400s (`config.locked_key`); "says yes ⟺ route
54
+ * works" breaks on the spot. Same conjunction, same reason, as `mcpInjectionHonored`'s lock veto;
55
+ * ② the compliance posture denies `a2a_peers` — advertised-but-refused again;
56
+ * ③ multi-tenant (`requirePrincipal === true`) — a tenant cannot point the shared worker at an agent.
57
+ *
58
+ * ⚠️ **`resolveComplianceDenies` is deliberately NOT wrapped in try/catch** (same ruling as the MCP twin):
59
+ * it is fail-loud on a posture outside the closed set, and the posture has already been validated twice
60
+ * before reaching here (config parse + boot assembly). If it ever throws here, someone bypassed both doors
61
+ * and the LOUD direction (route 500) is the correct one — swallowing it into a boolean would let a
62
+ * deployment with a broken posture keep advertising the capability (仓规:安全轴禁静默兜底).
63
+ */
64
+ export function a2aInjectionHonored(config) {
65
+ if (config.lockedConfigKeys?.includes("a2a") === true)
66
+ return false;
67
+ if (config.compliancePosture !== undefined) {
68
+ // core 词表到货前的读法:闭集 Set 按 string 读(`ReadonlySet<ComplianceCapability>` 的 `has` 是方法,
69
+ // 结构上可当 `ReadonlySet<string>` 读)。到货后这一行换成 `.has("a2a_peers")` 的闭集形。
70
+ const denies = resolveComplianceDenies(config.compliancePosture);
71
+ if (denies.has("a2a_peers"))
72
+ return false;
73
+ }
74
+ return config.requirePrincipal !== true;
75
+ }
76
+ function isStringRecord(v) {
77
+ return !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
78
+ }
79
+ /** http(s) only — never `file://`, never a bare host. A syntactic guard on BOTH URL-shaped fields: the
80
+ * peer URL doubles as the well-known card origin, and `cardUrl` is dialed on its own when present. */
81
+ const HTTP_URL_RE = /^https?:\/\//i;
82
+ /**
83
+ * Validate + normalize ONE caller entry to a core `A2aServerSpec`, or null if malformed (that peer is dropped
84
+ * and NAMED in `dropped` — never fatal to the request).
85
+ *
86
+ * 🔴 **判据一句话:一个键在场就必须合法,否则这条 peer 整条不进** —— 不做「丢那个键、留这条 peer」。
87
+ * (codex 对抗复审 finding 1,验真后采纳。)理由是方向性的,不是洁癖:
88
+ * · `allowSkills` 是**收窄**字段,而 core 把它的**缺席**解释成「不限制」。于是「丢坏键」在这条字段上
89
+ * 等于「调用方要求只挂一条,实际挂上远端卡声明的全部技能」—— 而每条 A2A 技能默认 `egress:true` +
90
+ * `effect:"write"`。收窄意图坏形时退化成放行 = 仓规点名的静默 fail-open。
91
+ * · `toolAxes` 的**半应用**同样有害:留下 `effect:"read"` 而丢掉坏形的 `egress` 会拼出 core 明文判为
92
+ * 矛盾的组合(「降 effect 却没清 egress」),把一个形状问题变成一条更难读的 prepare 失败。
93
+ * · 逐字段例外表是下一个漏洞的藏身处;一句话判据全域一致,而且与 `resolveA2aPeers`(缺任一 env 引用
94
+ * 即整条跳过)是**同一条**纪律:绝不半配置地拨号一个远端 agent。
95
+ * 代价明码:一个 header 打错字的调用方会整条丢掉这个 peer(带名字进 warn 日志),而不是匿名连上去。
96
+ * 那正是更好的失败方向。
97
+ */
98
+ function normalizeA2aPeer(e) {
99
+ if (!e || typeof e !== "object")
100
+ return null;
101
+ const o = e;
102
+ if (typeof o.name !== "string" || o.name.length === 0 || o.name.length > 128)
103
+ return null;
104
+ if (typeof o.url !== "string" || !HTTP_URL_RE.test(o.url))
105
+ return null;
106
+ // `cardUrl` in place must be WELL-FORMED — a garbage card URL is not "fall back to well-known probing"
107
+ // (core treats a set cardUrl as the ONLY location, deliberately: no probing behind the operator's back),
108
+ // so silently dropping it would change WHICH endpoint gets dialed.
109
+ if (o.cardUrl !== undefined && (typeof o.cardUrl !== "string" || !HTTP_URL_RE.test(o.cardUrl)))
110
+ return null;
111
+ if (o.headers !== undefined && !isStringRecord(o.headers))
112
+ return null;
113
+ if (o.principalHeader !== undefined && typeof o.principalHeader !== "string")
114
+ return null;
115
+ if (o.allowSkills !== undefined && !(Array.isArray(o.allowSkills) && o.allowSkills.every((s) => typeof s === "string")))
116
+ return null;
117
+ const spec = { name: o.name, url: o.url };
118
+ if (typeof o.cardUrl === "string")
119
+ spec.cardUrl = o.cardUrl;
120
+ if (isStringRecord(o.headers))
121
+ spec.headers = o.headers;
122
+ if (typeof o.principalHeader === "string")
123
+ spec.principalHeader = o.principalHeader;
124
+ if (Array.isArray(o.allowSkills))
125
+ spec.allowSkills = o.allowSkills;
126
+ if (o.toolAxes !== undefined) {
127
+ // 逐键形状校验(不是裸 cast):值语义仍由 caller 定(caller = 信任根),只挡畸形值直达 core。
128
+ // A2A 词表 = core `A2aToolAxis` 的三轴,与 MCP 同域但**没有 server-hint 腿**可折——这里收下的
129
+ // 就是最终值。`"never"` / `egress:false` 只可能来自这条显式覆写腿(core 原话),enforcement 忽略
130
+ // 这个方向的放宽(tighten-only),风险轴报告面消费它。坏形按上面那句话整条拒。
131
+ if (!o.toolAxes || typeof o.toolAxes !== "object" || Array.isArray(o.toolAxes))
132
+ return null;
133
+ const axes = {};
134
+ for (const [skill, ax] of Object.entries(o.toolAxes)) {
135
+ if (!ax || typeof ax !== "object" || Array.isArray(ax))
136
+ return null;
137
+ const a = ax;
138
+ if (a.effect !== undefined && a.effect !== "read" && a.effect !== "write" && a.effect !== "idempotent")
139
+ return null;
140
+ if (a.egress !== undefined && typeof a.egress !== "boolean")
141
+ return null;
142
+ if (a.irreversibility !== undefined && a.irreversibility !== "always" && a.irreversibility !== "never")
143
+ return null;
144
+ const entry = {
145
+ ...(a.effect !== undefined ? { effect: a.effect } : {}),
146
+ ...(a.egress !== undefined ? { egress: a.egress } : {}),
147
+ ...(a.irreversibility !== undefined ? { irreversibility: a.irreversibility } : {}),
148
+ };
149
+ if (Object.keys(entry).length > 0)
150
+ axes[skill] = entry;
151
+ }
152
+ if (Object.keys(axes).length > 0)
153
+ spec.toolAxes = axes;
154
+ }
155
+ return spec;
156
+ }
157
+ /**
158
+ * Shape-check the raw `body.a2aPeers`. `null` → a non-array (caller error; the route may 400). Otherwise the
159
+ * valid subset + the names of dropped (malformed/over-cap) entries.
160
+ */
161
+ export function validateRequestA2a(raw) {
162
+ if (raw === undefined || raw === null)
163
+ return { ok: [], dropped: [] };
164
+ if (!Array.isArray(raw))
165
+ return null;
166
+ const ok = [];
167
+ const dropped = [];
168
+ for (const e of raw.slice(0, MAX_REQUEST_A2A_PEERS)) {
169
+ const v = normalizeA2aPeer(e);
170
+ if (v)
171
+ ok.push(v);
172
+ // 名字是**调用方文本**,而「名字超长」本身就是被丢的理由之一 —— 原样递给 logger 等于让一条被拒的
173
+ // 请求决定日志行有多大(自审第四镜)。限长到 name 上限的量级,截断即止。
174
+ else
175
+ dropped.push(e && typeof e === "object" && typeof e.name === "string" ? String(e.name).slice(0, 128) : "<malformed>");
176
+ }
177
+ if (raw.length > MAX_REQUEST_A2A_PEERS)
178
+ dropped.push(`(+${raw.length - MAX_REQUEST_A2A_PEERS} over cap of ${MAX_REQUEST_A2A_PEERS})`);
179
+ return { ok, dropped };
180
+ }
181
+ /**
182
+ * DESIGN-269 §2.4 —— **同步**拒面:部署锁了 `a2a` 而请求仍带 `a2aPeers` ⇒ 400 `config.locked_key`
183
+ * (逐字沿用 `assertRequestMcpUnlocked` 的裁定,连错误码都同一个 —— 消费端判的是「某个键被行政锁住了」,
184
+ * 不是「哪个键」;码分裂只会让 SDK 多写一条等价分支)。
185
+ *
186
+ * 判据是「请求**占位**」而不是「请求的值合不合法」:锁是两态的,占了就整拒,不静默丢(静默丢正是把
187
+ * 判决变成探针的那一形)。键缺席 / 显式 `null` 都不是占位;**空数组是占位**(调用方确实写了这个键)。
188
+ *
189
+ * ⚠️ **不看车道**:多租户腿本来就会忽略 body peers(`a2aInjectionHonored` 关),但锁在场时「忽略」是
190
+ * 错的失败方向 —— operator 声明了「本部署不收任务自带 A2A peer」,那就该说出来。
191
+ *
192
+ * ⚠️ 参数类型是 `ReadonlySet<string>`:core 的 `LockedKey` 尚无 `a2a` 成员(见模块头),而
193
+ * `ReadonlySet<LockedKey>` 结构上可当 `ReadonlySet<string>` 传入,所以调用点无需任何转换。
194
+ */
195
+ export function assertRequestA2aUnlocked(bodyA2a, lockedKeys) {
196
+ if (!lockedKeys.has("a2a"))
197
+ return;
198
+ if (bodyA2a === undefined || bodyA2a === null)
199
+ return;
200
+ throw new HttpError(400, "`a2aPeers` is administratively locked by this deployment (locked key \"a2a\") — task-supplied A2A peers are refused; locks are config-time and there is no request-time unlock", {
201
+ code: "config.locked_key",
202
+ });
203
+ }
204
+ /**
205
+ * Merge GATED per-request peers OVER the deployment baseline (center/config). The baseline WINS on a name
206
+ * clash — a caller can ADD a peer but can never SHADOW a configured one. This is load-bearing on THIS lane
207
+ * specifically: the peer name is the tool-namespace segment (`a2a__<peer>__<skill>`), so shadowing would let
208
+ * a caller keep the tool NAMES the model was told about while swapping the agent behind them.
209
+ * Returns the baseline unchanged when there is nothing to add.
210
+ */
211
+ export function mergeRequestA2a(baseline, gated) {
212
+ if (gated.length === 0)
213
+ return baseline;
214
+ const taken = new Set((baseline ?? []).map((p) => p.name));
215
+ const add = gated.filter((p) => !taken.has(p.name));
216
+ return add.length === 0 ? baseline : [...(baseline ?? []), ...add];
217
+ }
218
+ /**
219
+ * The single entry point the spec-builder calls: compute the effective `TaskSpec.a2a` = deployment baseline
220
+ * (`a2aForScenario(config.a2aPeers, scenario)`) + the caller's gated per-request peers. Off the honored lane
221
+ * (or with no caller peers) returns the baseline unchanged. Logs `a2a_injection_ignored` when a caller SENT
222
+ * peers a closed gate dropped, so a shell never silently believes its declaration took effect (it also reads
223
+ * `capabilities.a2aInjection`).
224
+ */
225
+ export function resolveRequestA2a(baseline, bodyA2a,
226
+ // `lockedConfigKeys` rides in with the config: `assertRequestA2aUnlocked` already refuses at the leg head,
227
+ // so the lock veto below is unreachable in the locked shape — it stays as depth (ONE predicate owns the
228
+ // question; two half-answers in two places is how the two drift apart).
229
+ config, logger) {
230
+ if (bodyA2a === undefined || bodyA2a === null)
231
+ return baseline;
232
+ if (!a2aInjectionHonored(config)) {
233
+ logger?.warn("a2a_injection_ignored", { reason: "gate_closed (per-request A2A peers honored only on a single-user, unlocked, compliance-permitted deployment)" });
234
+ return baseline;
235
+ }
236
+ const v = validateRequestA2a(bodyA2a);
237
+ if (v === null) {
238
+ logger?.warn("a2a_injection_ignored", { reason: "a2aPeers not an array" });
239
+ return baseline;
240
+ }
241
+ if (v.dropped.length > 0)
242
+ logger?.warn("a2a_injection_dropped", { dropped: v.dropped });
243
+ return mergeRequestA2a(baseline, v.ok);
244
+ }
245
+ //# sourceMappingURL=task-a2a.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.26.0",
3
+ "version": "7.27.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",