@sema-agent/server 7.10.0 → 7.12.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/README.md +1 -1
- package/USAGE.md +56 -0
- package/dist/adoption/plan.d.ts +152 -0
- package/dist/adoption/plan.js +513 -0
- package/dist/adoption/runner.d.ts +54 -0
- package/dist/adoption/runner.js +505 -0
- package/dist/adoption/sql.d.ts +76 -0
- package/dist/adoption/sql.js +106 -0
- package/dist/adoption/wire.d.ts +250 -0
- package/dist/adoption/wire.js +153 -0
- package/dist/approval-card.d.ts +24 -0
- package/dist/approval-card.js +32 -0
- package/dist/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/adoption.d.ts +30 -0
- package/dist/boot/adoption.js +57 -0
- package/dist/boot/coordinators.d.ts +4 -0
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/parked-revive-gate.d.ts +56 -7
- package/dist/boot/parked-revive-gate.js +177 -8
- package/dist/boot/permission-rules-audit.d.ts +49 -0
- package/dist/boot/permission-rules-audit.js +57 -0
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/boot/runner-deps.d.ts +10 -2
- package/dist/boot/runner-deps.js +12 -1
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +24 -1
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -75
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/adoption.d.ts +26 -0
- package/dist/http/routes/adoption.js +120 -0
- package/dist/http/routes/approvals-assistant.js +2 -1
- package/dist/http/routes/capabilities.js +49 -2
- package/dist/http/routes/rules.d.ts +35 -0
- package/dist/http/routes/rules.js +293 -0
- package/dist/http/routes/shared-memory.d.ts +31 -0
- package/dist/http/routes/shared-memory.js +181 -0
- package/dist/http/routes/trace-usage.js +139 -3
- package/dist/http/server.d.ts +23 -3
- package/dist/http/server.js +123 -3
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +70 -3
- package/dist/observability/fail-open.d.ts +12 -0
- package/dist/observability/fail-open.js +12 -0
- package/dist/observability/metrics.js +2 -1
- package/dist/observability/tool-trace.d.ts +5 -1
- package/dist/observability/tool-trace.js +33 -6
- package/dist/parked-decide.d.ts +13 -3
- package/dist/parked-decide.js +10 -1
- package/dist/plugins/adoption-log-sql.d.ts +191 -0
- package/dist/plugins/adoption-log-sql.js +273 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
- package/dist/plugins/checkpoint-store-sql.js +11 -0
- package/dist/plugins/local-checkpoint-store.d.ts +10 -0
- package/dist/plugins/local-checkpoint-store.js +8 -0
- package/dist/plugins/permission-rule-store-file.d.ts +83 -0
- package/dist/plugins/permission-rule-store-file.js +371 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +249 -0
- package/dist/plugins/permission-rule-store-sql.js +828 -0
- package/dist/plugins/pg-pool.js +37 -0
- package/dist/plugins/session-policy-store-sql.d.ts +6 -0
- package/dist/plugins/session-policy-store-sql.js +7 -1
- package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
- package/dist/plugins/shared-memory-store-sql.js +516 -0
- package/dist/plugins/store-backend.d.ts +38 -2
- package/dist/plugins/store-backend.js +69 -6
- package/dist/plugins/tidb-pool.js +47 -0
- package/dist/rules-consent.d.ts +194 -0
- package/dist/rules-consent.js +240 -0
- package/dist/run-local.js +120 -13
- package/dist/runtime-governance.js +9 -3
- package/dist/shared-memory-scope-authorizer.d.ts +29 -0
- package/dist/shared-memory-scope-authorizer.js +17 -0
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +60 -0
- package/dist/tool-approval.js +223 -25
- package/dist/trace/core-keyset-guard.d.ts +15 -4
- package/dist/trace/project.d.ts +20 -2
- package/dist/trace/project.js +25 -4
- package/package.json +3 -3
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/177 —— 共享记忆库(org 盘)的**只读 HTTP 面**。
|
|
3
|
+
*
|
|
4
|
+
* 路由:`GET /v1/shared-memory/stores`、`GET /v1/shared-memory/stores/:store/documents`、
|
|
5
|
+
* `GET /v1/shared-memory/stores/:store/document?path=`。本域零可变状态。
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* 挂载条件 = store ∧ org 折叠面,**双在场**才有这个域(v2/F4 硬化)
|
|
9
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
* 供给面在场但授权面缺席时,这个域整体**不挂载** —— 请求落到全局 404 `not_found.route`。刻意不是 501:
|
|
11
|
+
* · 501 的自述是「这个面存在,只是本部署没开」,它对一个连身份都无法被判定的调用方泄露了「这套部署
|
|
12
|
+
* 有共享记忆库」,并暗示补个配置就能读;
|
|
13
|
+
* · 「store 好了先挂载、授权后补」是这条纪律真正要堵的中间态 —— 一个没有成员性判据的读面,唯一能做的
|
|
14
|
+
* 要么是全放(跨租户读)要么是全拒(伪装成故障),两个都比「这个面不存在」差。
|
|
15
|
+
* 能力广告(`capabilities.sharedMemory`)读的是**同一个合取式**,所以「说 yes ⟺ 面真能用」结构上成立。
|
|
16
|
+
*
|
|
17
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
* 授权:一次 snapshot,零第二真源
|
|
19
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
* 每个请求取**一次** `provider.snapshot({ principal })` —— 与模型面(core 的 memory_list/memory_read)
|
|
21
|
+
* 走的是同一个供给口、同一次绑定。可见的 store 集合因此按定义等于模型看到的那一份,HTTP 面不可能比
|
|
22
|
+
* 模型面多看见一个库。越界一律 404 且**与「这个库根本不存在」字节同形**:状态码、errorCode、message
|
|
23
|
+
* 全同,差一个字就是一次成员性探针(某人能用 404 的差异枚举出别的 org 有哪些库)。
|
|
24
|
+
*
|
|
25
|
+
* 文档**不存在**则另说(`not_found.shared_memory_document`):调用方已经证明了自己是这个库的成员,
|
|
26
|
+
* 「库里没有这份文件」对他不是秘密,把它和「你不是成员」合并反而会让成员无法区分打错路径和权限问题。
|
|
27
|
+
*/
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
29
|
+
import type { RouteCtx } from "../route-ctx.js";
|
|
30
|
+
export declare function handleSharedMemory(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
31
|
+
//# sourceMappingURL=shared-memory.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { SHARED_MEMORY_READ_CAP_BYTES, SharedMemoryStoreError } from "@sema-agent/core";
|
|
2
|
+
import { sendJson, sendError } from "../send.js";
|
|
3
|
+
import { gatedPrincipal } from "../principal-gate.js";
|
|
4
|
+
const PREFIX = "/v1/shared-memory/";
|
|
5
|
+
/** `unavailable` 时给客户端的重试间隔。目录退避窗与库导入都在秒级,10s 是"别打穿"与"别显得死了"之间。 */
|
|
6
|
+
const RETRY_AFTER_SEC = 10;
|
|
7
|
+
/** 一次 documents 列举的窗口。core 的模型面用 50;HTTP 面给同一个数量级,靠 cursor 续窗。 */
|
|
8
|
+
const HTTP_LIST_LIMIT = 100;
|
|
9
|
+
/** core 的文档路径规则,HTTP 参数面复述:绝对、无控制字符/反斜杠、段非空且不以 `.` 开头、文件名带后缀。 */
|
|
10
|
+
const FORBIDDEN_PATH_CHARS = /[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Default_Ignorable_Code_Point}\u2028\u2029\\]/u;
|
|
11
|
+
const DOCUMENT_SUFFIXES = [".md", ".txt", ".json", ".jsonl"];
|
|
12
|
+
function isDocumentPath(path) {
|
|
13
|
+
if (!path.startsWith("/") || FORBIDDEN_PATH_CHARS.test(path))
|
|
14
|
+
return false;
|
|
15
|
+
const segments = path.slice(1).split("/");
|
|
16
|
+
const last = segments.at(-1);
|
|
17
|
+
if (last === undefined || !segments.every((s) => s !== "" && !s.startsWith(".")))
|
|
18
|
+
return false;
|
|
19
|
+
return DOCUMENT_SUFFIXES.some((s) => last.endsWith(s));
|
|
20
|
+
}
|
|
21
|
+
/** 零存在性 oracle 的那**一句**拒绝。三种成因(库不存在 / 不是成员 / 是别的 org 的库)共用它,因此
|
|
22
|
+
* 调用方无法用响应差异枚举别人的库。改这里的字面量时三条腿一起改——它们同形是**被测断言**。 */
|
|
23
|
+
function refuseStore(res) {
|
|
24
|
+
sendError(res, 404, "not_found.shared_memory_store", "not found");
|
|
25
|
+
}
|
|
26
|
+
/** 供给面自述不可用 ⇒ 503 + Retry-After。**绝不**塌成「200 空库」:后者对调用方是终局的
|
|
27
|
+
* 「你们团队没有共享库」,而真相是「暂时读不到,一会儿再来」——两句话是相反的指令。 */
|
|
28
|
+
function refuseUnavailable(res, message) {
|
|
29
|
+
res.setHeader("retry-after", String(RETRY_AFTER_SEC));
|
|
30
|
+
sendError(res, 503, "memory.shared_unavailable", "shared memory stores are unavailable", {
|
|
31
|
+
retryAfterSec: RETRY_AFTER_SEC,
|
|
32
|
+
...(message !== undefined ? { detail: message } : {}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** 取本次请求的绑定快照。返回 `undefined` = 已经应答(401/503),调用方直接 return。 */
|
|
36
|
+
async function bindSnapshot(req, res, ctx) {
|
|
37
|
+
const { deps } = ctx;
|
|
38
|
+
// 身份先于可用性(memory-policy 同款次序):后端姿态不该对一个本来什么都读不到的调用方 oracle。
|
|
39
|
+
const principal = gatedPrincipal(req, deps.config); // direct-door safe: verified identity, never the spoofable header
|
|
40
|
+
if (!principal && deps.config.requirePrincipal === true) {
|
|
41
|
+
sendError(res, 401, "auth.unauthorized", "unauthorized");
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const provider = deps.sharedMemoryStore;
|
|
45
|
+
if (!provider) {
|
|
46
|
+
// 到不了:域头的合取式已经保证它在场。写出来是为了让类型收窄不靠断言。
|
|
47
|
+
refuseStore(res);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const snapshot = await provider.snapshot({ sessionId: `http:${principal ?? "anonymous"}`, taskId: "http-read", ...(principal ? { principal } : {}) }, {});
|
|
51
|
+
if (snapshot.state === "unavailable") {
|
|
52
|
+
refuseUnavailable(res, snapshot.message);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return snapshot;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* reader 抛出的 **typed** 失败 → HTTP 的逐格映射。返回 `true` = 已应答。
|
|
59
|
+
*
|
|
60
|
+
* 🔴 codex 复审轮2 F2(真缺陷):不折叠的话这些全落进外层兜底的 500 `internal.error`。而它们**不是**
|
|
61
|
+
* 服务器缺陷:`store_not_found` 是治理面在绑定之后回收了库(正常竞态),`refused/document_too_large`
|
|
62
|
+
* 是一行旧数据(可恢复状况)。更要命的是——真 SQL 供给面对超帽行是**抛**而不是返回内容,所以本文件
|
|
63
|
+
* 那条 413 分支对它根本不可达,除非在这里折。
|
|
64
|
+
*
|
|
65
|
+
* `kind` 是 core 的闭集,用穷举 `switch` 消费:core 加员时这里当场编译红,而不是悄悄落进某个 default。
|
|
66
|
+
*/
|
|
67
|
+
function foldReaderError(res, err) {
|
|
68
|
+
if (!(err instanceof SharedMemoryStoreError))
|
|
69
|
+
return false;
|
|
70
|
+
switch (err.kind) {
|
|
71
|
+
case "store_not_found":
|
|
72
|
+
refuseStore(res); // 与"你不是成员"同形——绑定之后库没了,对调用方就是"它不存在"
|
|
73
|
+
return true;
|
|
74
|
+
case "refused":
|
|
75
|
+
if (err.reason === "document_too_large") {
|
|
76
|
+
// 与模型面同一条规则:拒绝,不截断(一份被截断的共享文档读起来和完整的一模一样)。
|
|
77
|
+
sendError(res, 413, "request.payload_too_large", `document exceeds the ${SHARED_MEMORY_READ_CAP_BYTES}-byte shared-store read cap`, { capBytes: SHARED_MEMORY_READ_CAP_BYTES });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
// host 自述的其它拒绝(配额等):按"现在读不到,带着机器原因重试"回,原因原样进 detail。
|
|
81
|
+
refuseUnavailable(res, err.reason);
|
|
82
|
+
return true;
|
|
83
|
+
case "transient":
|
|
84
|
+
refuseUnavailable(res, err.reason);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** 快照里解析一个 store,越界返回 `undefined` 并已应答 404(同形拒)。 */
|
|
89
|
+
function resolveStore(res, snapshot, storeId) {
|
|
90
|
+
if (snapshot.state !== "connected") {
|
|
91
|
+
refuseStore(res); // connecting 状态下没有绑定,任何具名库对这次请求都还不存在
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const found = snapshot.stores.find((s) => s.info.id === storeId);
|
|
95
|
+
if (!found) {
|
|
96
|
+
refuseStore(res);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return found.reader;
|
|
100
|
+
}
|
|
101
|
+
export async function handleSharedMemory(req, res, url, ctx) {
|
|
102
|
+
const miss = { fell: false };
|
|
103
|
+
await handleSharedMemoryBody(req, res, url, ctx, miss);
|
|
104
|
+
return !miss.fell;
|
|
105
|
+
}
|
|
106
|
+
async function handleSharedMemoryBody(req, res, url, ctx, miss) {
|
|
107
|
+
// GET /v1/shared-memory/stores —— 本会话可见的库清单(= 模型面 memory_list 无参形的同一份)。
|
|
108
|
+
if (url === "/v1/shared-memory/stores" && req.method === "GET") {
|
|
109
|
+
const snapshot = await bindSnapshot(req, res, ctx);
|
|
110
|
+
if (!snapshot)
|
|
111
|
+
return;
|
|
112
|
+
if (snapshot.state !== "connected") {
|
|
113
|
+
// 诚实的「还没好」:既不是错误(没坏)也不是空(不是终局)。与 core 模型面的 CONNECTING 同义。
|
|
114
|
+
// (`unavailable` 已在 bindSnapshot 里 503 掉,走不到这里;判 `!== "connected"` 是为了让类型
|
|
115
|
+
// 收窄不靠断言 —— 联合加员时这里当场编译红,而不是悄悄落进某个 else。)
|
|
116
|
+
sendJson(res, 200, { state: "connecting", stores: [] });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
sendJson(res, 200, { state: "connected", stores: snapshot.stores.map((s) => ({ id: s.info.id, description: s.info.description, writable: s.info.writable })) });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const rest = url.startsWith(PREFIX) ? url.slice(PREFIX.length) : undefined;
|
|
123
|
+
const segments = rest === undefined ? [] : rest.split("/");
|
|
124
|
+
// stores/:store/documents | stores/:store/document
|
|
125
|
+
if (segments.length === 3 && segments[0] === "stores" && req.method === "GET" && (segments[2] === "documents" || segments[2] === "document")) {
|
|
126
|
+
const storeId = decodeURIComponent(segments[1]);
|
|
127
|
+
const query = new URL(req.url ?? "", "http://x").searchParams; // route-level `url` is query-stripped
|
|
128
|
+
const snapshot = await bindSnapshot(req, res, ctx);
|
|
129
|
+
if (!snapshot)
|
|
130
|
+
return;
|
|
131
|
+
const reader = resolveStore(res, snapshot, storeId);
|
|
132
|
+
if (!reader)
|
|
133
|
+
return;
|
|
134
|
+
if (segments[2] === "documents") {
|
|
135
|
+
const prefix = query.get("path_prefix") ?? undefined;
|
|
136
|
+
const cursor = query.get("cursor") ?? undefined;
|
|
137
|
+
const page = await reader.list(prefix, { ...(cursor !== undefined ? { cursor } : {}), limit: HTTP_LIST_LIMIT }).catch((err) => {
|
|
138
|
+
if (foldReaderError(res, err))
|
|
139
|
+
return undefined;
|
|
140
|
+
throw err; // 非 typed 的失败仍归外层兜底(那才真是服务器缺陷)
|
|
141
|
+
});
|
|
142
|
+
if (page === undefined)
|
|
143
|
+
return;
|
|
144
|
+
const entries = Array.isArray(page) ? page : page.entries;
|
|
145
|
+
const nextCursor = Array.isArray(page) ? undefined : page.nextCursor;
|
|
146
|
+
sendJson(res, 200, { store: storeId, entries, ...(nextCursor !== undefined ? { nextCursor } : {}) });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const path = query.get("path");
|
|
150
|
+
if (path === null || path === "" || !isDocumentPath(path)) {
|
|
151
|
+
sendError(res, 400, "request.query_invalid", "missing or malformed ?path= (absolute, .md/.txt/.json/.jsonl)");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
let folded = false;
|
|
155
|
+
const doc = await reader.read(path, {}).catch((err) => {
|
|
156
|
+
if (foldReaderError(res, err)) {
|
|
157
|
+
folded = true;
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
throw err;
|
|
161
|
+
});
|
|
162
|
+
if (folded)
|
|
163
|
+
return;
|
|
164
|
+
if (doc === null) {
|
|
165
|
+
// 成员本人有权区分「打错了路径」与「无权」——见文件头注。
|
|
166
|
+
sendError(res, 404, "not_found.shared_memory_document", "not found");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const bytes = Buffer.byteLength(doc.content, "utf8");
|
|
170
|
+
if (bytes > SHARED_MEMORY_READ_CAP_BYTES) {
|
|
171
|
+
// core 的模型面对超帽文档是**拒绝并报出真实大小**,从不截断(一份被截断的共享文档读起来和完整的
|
|
172
|
+
// 一模一样,而那正是这个帽存在的理由)。HTTP 面用同一条规则,两个面因此对「什么读得到」永不分歧。
|
|
173
|
+
sendError(res, 413, "request.payload_too_large", `document is ${bytes} bytes, over the ${SHARED_MEMORY_READ_CAP_BYTES}-byte shared-store read cap`, { bytes, capBytes: SHARED_MEMORY_READ_CAP_BYTES });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
sendJson(res, 200, { store: storeId, path, content: doc.content, bytes, ...(doc.updatedAt !== undefined ? { updatedAt: doc.updatedAt } : {}) });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
miss.fell = true;
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=shared-memory.js.map
|
|
@@ -1,10 +1,26 @@
|
|
|
1
|
+
import { buildToolResultRef } from "@sema-agent/core";
|
|
1
2
|
import { projectEvents, taskSummary, mapTraceEvent } from "../../trace/project.js";
|
|
2
3
|
import { projectArtifacts } from "../../trace/artifacts.js";
|
|
3
4
|
import { usageSummary, usageSeries, usageBreakdown } from "../../usage-analytics.js";
|
|
4
5
|
import { sleep } from "../sse-log.js";
|
|
5
6
|
import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
|
|
6
7
|
import { gatedPrincipal } from "../principal-gate.js";
|
|
7
|
-
|
|
8
|
+
// [3321] tool-results 读面**共用这一段守卫**([3318] 的字面承诺):新 sub 进的是同一个 `sub` 捕获组,
|
|
9
|
+
// 于是下方 fleetWide / gateOwner / per-id owner-404 三道门原样罩住它,不存在第二套门可以漂。
|
|
10
|
+
// ⚠️ 「both-or-neither」结构不可破:`:id` 与 `sub` 同在一个可选组里,所以 TRACE_RE 从不匹配裸
|
|
11
|
+
// `/v1/tasks/<seg>` —— 那正是 `POST /v1/tasks/stream` 不被本读面遮蔽的原因(见下方 source-summary 注)。
|
|
12
|
+
// 新臂写成 `sub` 的一个 alternation 分支(而不是新开捕获组),组号与既有两个捕获保持逐字不变。
|
|
13
|
+
const TRACE_RE = /^\/v1\/tasks(?:\/([^/]+)(\/turns|\/stream|\/artifacts|\/tool-results\/[^/]+))?$/;
|
|
14
|
+
/** tool-results 读面的 sub 前缀(路由判别 + 段切分单源)。 */
|
|
15
|
+
const TOOL_RESULTS_SUB = "/tool-results/";
|
|
16
|
+
/** `limit` 的 server 权威上限(chars)。缺席 = 不传给 store,用 store 自己的默认;超上限 = 400(不静默夹取:
|
|
17
|
+
* 夹取会让「我要 1M」与「我要 256K」返回同一段内容,调用方无从知道自己被截了)。 */
|
|
18
|
+
const TOOL_RESULT_LIMIT_CAP = 262_144;
|
|
19
|
+
/** `offset` 的上限 = **最窄后端**能表达的值(codex 复审 R1 medium,已核真)。PG 那半边发的是
|
|
20
|
+
* `SUBSTRING(content FROM $1::int …)` 且绑的是 `offset + 1`(`plugins/tool-result-store-sql.ts`),
|
|
21
|
+
* 于是 `offset = 2147483647` 过得了「非负整数」这一关,却在库里变成 `2147483648::int` ⇒ 范围错 ⇒ 500。
|
|
22
|
+
* 「坏输入回 400」是本面的承诺,承诺不能挑后端兑现,所以上限按 int32 - 1 收(留出 +1 的余量)。 */
|
|
23
|
+
const TOOL_RESULT_OFFSET_CAP = 2_147_483_646;
|
|
8
24
|
export async function handleTraceUsage(req, res, url, ctx) {
|
|
9
25
|
const miss = { fell: false };
|
|
10
26
|
await handleTraceUsageBody(req, res, url, ctx, miss);
|
|
@@ -14,7 +30,7 @@ export async function handleTraceUsage(req, res, url, ctx) {
|
|
|
14
30
|
* = 「本域已应答」;走到函数尾才是「没匹配上」。跨域的东西一律在首行解构出来。 */
|
|
15
31
|
async function handleTraceUsageBody(req, res, url, ctx, miss) {
|
|
16
32
|
const { deps } = ctx;
|
|
17
|
-
const { isFleetWide } = ctx.helpers;
|
|
33
|
+
const { isFleetWide, safeDecode } = ctx.helpers;
|
|
18
34
|
// Task Trace API (S1, read-only): GET /v1/tasks[/:id/turns|/stream]. Authorized by EITHER the full authToken
|
|
19
35
|
// OR a read-only traceToken (sema-registry pulls traces fleet-wide with one read-only token). Handled BEFORE
|
|
20
36
|
// the global gate (like /metrics) so a traceToken-only caller isn't 401'd.
|
|
@@ -61,14 +77,19 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
|
|
|
61
77
|
}
|
|
62
78
|
const query = new URL(req.url ?? "", "http://x").searchParams;
|
|
63
79
|
const taskId = traceMatch[1];
|
|
64
|
-
const sub = traceMatch[2]; // undefined (list) | "/turns" | "/stream" | "/artifacts"
|
|
80
|
+
const sub = traceMatch[2]; // undefined (list) | "/turns" | "/stream" | "/artifacts" | "/tool-results/<ref>"
|
|
65
81
|
// per-id owner gate for the principal path (404 for a non-owner — no existence oracle, parity with runOwnerOk).
|
|
82
|
+
// 🔴 该行**只在 principal 路径**被取(fleet 路径按设计不做 per-id 归属判定)——tool-results 面还要用
|
|
83
|
+
// `run.sessionId` 算 ref 前缀,所以这里把取到的行留住(`ownedRun`),fleet 路径由该面自己补一次取行:
|
|
84
|
+
// 少了那次取行,fleet caller 就能拿任意 taskId 配任意 ref 读走**别的 session** 的产物。
|
|
85
|
+
let ownedRun;
|
|
66
86
|
if (taskId && gateOwner !== null) {
|
|
67
87
|
const run = await deps.runStore.getRun(taskId);
|
|
68
88
|
if (!run || (run.owner !== null && run.owner !== gateOwner)) {
|
|
69
89
|
sendError(res, 404, "not_found.run", "task not found");
|
|
70
90
|
return;
|
|
71
91
|
}
|
|
92
|
+
ownedRun = run;
|
|
72
93
|
}
|
|
73
94
|
if (!taskId)
|
|
74
95
|
await handleTaskList(res, deps.runStore, query, gateOwner); // gateOwner (when set) forces the owner filter
|
|
@@ -78,6 +99,9 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
|
|
|
78
99
|
await streamTaskTrace(req, res, deps.runStore, taskId, deps.config.runStaleSec * 1000);
|
|
79
100
|
else if (sub === "/artifacts")
|
|
80
101
|
await handleTaskArtifacts(res, deps.runStore, taskId);
|
|
102
|
+
else if (sub?.startsWith(TOOL_RESULTS_SUB)) {
|
|
103
|
+
await handleToolResult(res, deps.toolResultStore, deps.runStore, taskId, ownedRun, safeDecode(sub.slice(TOOL_RESULTS_SUB.length)), query);
|
|
104
|
+
}
|
|
81
105
|
else
|
|
82
106
|
sendError(res, 404, "not_found.route", "not found");
|
|
83
107
|
return;
|
|
@@ -202,6 +226,118 @@ async function handleTaskArtifacts(res, runStore, taskId) {
|
|
|
202
226
|
const events = await runStore.getEvents(taskId, 0);
|
|
203
227
|
sendJson(res, 200, { artifacts: projectArtifacts(events, { taskId, jobId: run.jobId }) });
|
|
204
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* GET /v1/tasks/:id/tool-results/:ref?offset&limit —— core `ToolResultStore.get` 的 wire 镜像([3321])。
|
|
231
|
+
*
|
|
232
|
+
* 三件事,顺序本身就是契约:
|
|
233
|
+
*
|
|
234
|
+
* ① **query 先判**。坏 offset/limit ⇒ 400,且这一判**先于**任何 ref/归属/store 在场性判定 —— 否则
|
|
235
|
+
* 「坏 query + 已知 ref = 400」而「坏 query + 未知 ref = 404」,状态码之差立刻成了存在性 oracle。
|
|
236
|
+
* query 的合法性只取决于 query 自身,与调用方看不看得见这个 ref 无关,所以先判是安全的。
|
|
237
|
+
*
|
|
238
|
+
* ② **归属绑定 = 对本 task 的耐久日志做精确成员判定**,不解析 ref、不复制段转义规则。
|
|
239
|
+
*
|
|
240
|
+
* 先说为什么**不是**前缀判定([3321]② 的字面形、[3322] 的指定形):core 的 ref 形是
|
|
241
|
+
* `tr_<seg(session)>_<seg(toolCallId)>`,而 `_` 在两段里都合法 ⇒ session `team` 的前缀 `tr_team_`
|
|
242
|
+
* 同样命中 session `team_blue` 的 ref(`tr_team_blue_toolu_x`)。本仓的 sessionId 是**调用方自选的
|
|
243
|
+
* 任意 ≤64 字符串**(`http/server.ts` 的 submit 面只查长度,刻意宽松),所以租户 A 可以把自己的
|
|
244
|
+
* sessionId 取成租户 B 的前缀,拿自己的 taskId 配 B 的 ref 读走 B 的产物(codex 复审 R1 high,已核真)。
|
|
245
|
+
* 这在字符串层面**无解**:把余段当 toolCallId 重铸一遍会逐字相等(拼接本身有歧义),而按「更长的
|
|
246
|
+
* session 是否存在」去探,既堵不住折成 base+sha 的 sessionId(候选串 ≠ 真 id),又反手造出一个新的
|
|
247
|
+
* 跨租户**拒绝**面(别人注册一个 `<你的 session>_toolu` 就能让你自己的 ref 永远 404)——R2 复审两条
|
|
248
|
+
* 中标,已撤回该启发式。
|
|
249
|
+
*
|
|
250
|
+
* 改判据:ref **必须由本 task 自己的耐久事件日志作证**。日志里每条 tool_start/tool_end 都带
|
|
251
|
+
* `toolCallId`(`trace/project.ts` 的 toolStartEventData / toolEndEventData 是唯一铸造点),用 core
|
|
252
|
+
* 的**同一个** `buildToolResultRef` 把它们铸成 ref 集合,再要求 `ref` 是其中一员 —— 精确相等,
|
|
253
|
+
* 零解析、零转义规则复制,歧义面结构上不存在(A 的 taskId 只作证 A 自己的 toolCallId)。
|
|
254
|
+
* 辖域(诚实写明,也正是本路由的语义):本面服务的是**这条 task 的 trace 里看得见的那些产物** ——
|
|
255
|
+
* trace 的 tool-result 块与本判据读的是同一份日志,所以「trace 上看得见 ⇒ 这里读得到」成立;
|
|
256
|
+
* 同 session 里**别的 task** 的 ref(或日志已过保留窗被逐出的)按 404 同形拒,请到那条 task 上读。
|
|
257
|
+
*
|
|
258
|
+
* ⚠️ 两处**已知残余**,都要 core 侧动刀才能消,写在明处而不是让下一个人自己撞(codex 复审 R3):
|
|
259
|
+
*
|
|
260
|
+
* (a) **覆盖面**:core 除了「按工具调用 id 卸载」这条主路,还从**合成 id** 铸 ref ——
|
|
261
|
+
* `task-registry-monitor` 的 `<handle>_<stream>_seg<n>`、`task-registry-agent` 的 `<handle>_c<n>`、
|
|
262
|
+
* `runner/compaction-call-options` 的 `<toolCallId>_s<sha>`、budget 的 `idx<k>`。这些 id **不进**
|
|
263
|
+
* 耐久事件日志(日志记的是外层调用 id),所以那几类 ref 在本面一律 404 同形拒。
|
|
264
|
+
* 方向明确:**不**在本仓照抄那几条命名规则去反推(复制上游规则 = 两处各自漂,宪法禁),
|
|
265
|
+
* 真解是 core 在写入时带上 (session/task) 出处、或给出 core 属主的精确成员判定。
|
|
266
|
+
*
|
|
267
|
+
* (b) **注入性**:`buildToolResultRef` 对 `(session, toolCallId)` **不是单射**(`_` 两段都合法):
|
|
268
|
+
* `("team","blue_toolu_1")` 与 `("team_blue","toolu_1")` 铸出同一枚 ref。所以「本 task 日志作证过
|
|
269
|
+
* 一个恰好长成 `<受害 session 尾巴>_<受害 toolCallId>` 的 toolCallId」时,判据仍会放行。
|
|
270
|
+
* 与被撤掉的前缀形的差别是**门槛**:前缀形只要知道 ref 就能读,本形还要求攻击者能让**模型网关**
|
|
271
|
+
* 铸出那一枚精确的 toolCallId(provider 铸,本服务不控也不产)。同样只有 core 换成单射编码才真消。
|
|
272
|
+
* 这里**不留特征化测试**去钉这条通路 —— 把缺陷钉成契约是另一种病。
|
|
273
|
+
*
|
|
274
|
+
* ③ **同形 404**(unknown ref / 未获本 task 日志作证 / 本部署没有 durable store)。任何可区分的响应都是
|
|
275
|
+
* 缺陷:前两者之差会告诉调用方「这枚 ref 存在,只是不是你的」;第三者若回 501 则会把部署形告诉一个
|
|
276
|
+
* 连自己的 ref 都读不到的 caller。文案恒定且**不回显 ref**(回显=把输入原样反射进错误面)。
|
|
277
|
+
* 残余面(诚实写明):响应字节同形,**时序**并不同形 —— 三条臂到达的后端调用数不同。要抹平时序得让
|
|
278
|
+
* 每条臂都跑满全部后端调用,那是另一档代价/收益裁定,不在本批;本批的判据是响应本身不给 oracle。
|
|
279
|
+
*/
|
|
280
|
+
async function handleToolResult(res, store, runStore, taskId, ownedRun,
|
|
281
|
+
/** 已过 `safeDecode` 的 ref;`null` = %-序列坏(客户端错,但同形 404 —— 它同样不该成为一条可区分的信道)。
|
|
282
|
+
* 解码用调用点传下来的 helper 而不是本地 try/catch:那条规则(坏序列 ⇒ null,绝不 500)在本仓只有
|
|
283
|
+
* `safeDecode` 一个属主,复制一份等于把它变成两个。 */
|
|
284
|
+
ref, query) {
|
|
285
|
+
// ① query 校验(先于一切存在性判定 —— 见头注)。非负十进制整数,`Number()` 的宽松形(空串/前后空白/
|
|
286
|
+
// "1e3"/"0x10"/"1.5")一律不认:宽松解析会让两个不同的请求串静默映射到同一段内容。
|
|
287
|
+
const intParam = (name, cap) => {
|
|
288
|
+
const raw = query.get(name);
|
|
289
|
+
if (raw === null)
|
|
290
|
+
return undefined; // 缺席 = 用 store 默认
|
|
291
|
+
if (!/^\d+$/.test(raw))
|
|
292
|
+
return "bad";
|
|
293
|
+
const n = Number(raw);
|
|
294
|
+
if (!Number.isSafeInteger(n))
|
|
295
|
+
return "bad";
|
|
296
|
+
if (cap !== undefined && n > cap)
|
|
297
|
+
return "bad";
|
|
298
|
+
return n;
|
|
299
|
+
};
|
|
300
|
+
const offset = intParam("offset", TOOL_RESULT_OFFSET_CAP);
|
|
301
|
+
if (offset === "bad") {
|
|
302
|
+
sendError(res, 400, "request.query_invalid", "offset must be a non-negative integer of at most 2147483646");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const limit = intParam("limit", TOOL_RESULT_LIMIT_CAP);
|
|
306
|
+
if (limit === "bad") {
|
|
307
|
+
sendError(res, 400, "request.query_invalid", "limit must be a non-negative integer of at most 262144");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// ③ 同形 404 的**唯一**下发点:每条臂都必须走这里,不许任何一条自己写一句(文案分家=oracle 复活)。
|
|
311
|
+
const notFound = () => sendError(res, 404, "not_found.tool_result", "tool result not found");
|
|
312
|
+
if (ref === null)
|
|
313
|
+
return notFound(); // 坏 %-序列(见参数注)
|
|
314
|
+
// fleet 路径没做 per-id 取行(见调用点注),这里补齐 —— 归属绑定要 run.sessionId,没有行就没有绑定面。
|
|
315
|
+
const run = ownedRun ?? (await runStore.getRun(taskId));
|
|
316
|
+
if (!run)
|
|
317
|
+
return notFound();
|
|
318
|
+
// ② 精确成员判定(见头注):本 task 耐久日志作证过的 toolCallId,经 core 同一个铸造点变成 ref 集合。
|
|
319
|
+
// 成本口径:整份日志一读一扫,与同域的 `/turns`、`/artifacts` 同款(不是本面引入的新量级);分页读会
|
|
320
|
+
// 逐页重扫 —— 真消要一张 (task, ref) 出处索引,与上面 (a) 是同一件 core/store 侧的活。
|
|
321
|
+
const events = await runStore.getEvents(taskId, 0);
|
|
322
|
+
let attested = false;
|
|
323
|
+
for (const ev of events) {
|
|
324
|
+
// 取键姿势与同域的 `trace/artifacts.ts` 逐字同款(同一份日志、同一个字段,读法不该有第二种)。
|
|
325
|
+
const { toolCallId: id } = (ev.data ?? {});
|
|
326
|
+
// 逐条比对而不是先建全集:命中即停,长日志上不必把整份集合物化。
|
|
327
|
+
if (typeof id === "string" && id.length > 0 && buildToolResultRef(run.sessionId, id) === ref) {
|
|
328
|
+
attested = true;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!attested)
|
|
333
|
+
return notFound();
|
|
334
|
+
if (!store)
|
|
335
|
+
return notFound(); // durable store 缺席:404 同形,不 501(不泄露部署形)
|
|
336
|
+
const slice = await store.get(ref, { ...(offset !== undefined ? { offset } : {}), ...(limit !== undefined ? { limit } : {}) });
|
|
337
|
+
if (!slice)
|
|
338
|
+
return notFound();
|
|
339
|
+
sendJson(res, 200, { content: slice.content, offset: slice.offset, totalChars: slice.totalChars });
|
|
340
|
+
}
|
|
205
341
|
async function handleTaskTurns(res, runStore, taskId, query) {
|
|
206
342
|
const run = await runStore.getRun(taskId);
|
|
207
343
|
if (!run) {
|
package/dist/http/server.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { TaskRequestBody } from "./wire-types.js";
|
|
|
5
5
|
import type { ServiceConfig } from "../config-types.js";
|
|
6
6
|
import { type RestartSignal, type SessionMirrorRuling } from "../config-center/facade.js";
|
|
7
7
|
import { type OwnerAwareSessionStore } from "../security.js";
|
|
8
|
-
import type { RunStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ServiceSessionPolicyStore, ServiceFileSnapshotStore, StoreBackend } from "../plugins/store-backend.js";
|
|
8
|
+
import type { RunStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ServiceSessionPolicyStore, ServiceFileSnapshotStore, ToolResultStoreFull, StoreBackend } from "../plugins/store-backend.js";
|
|
9
|
+
import type { RuleConsentLane } from "../rules-consent.js";
|
|
9
10
|
import { type MemorySyncRequest, type MemorySyncResponse } from "../memory-sync.js";
|
|
10
11
|
import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
|
|
11
12
|
import type { LeaderEndpoint } from "../leader/endpoint.js";
|
|
@@ -101,6 +102,13 @@ export interface ServiceStoreDeps {
|
|
|
101
102
|
/** E6 durable SessionPolicyStore (operator-tightened per-session tool rules). Enables PUT/GET /v1/sessions/:id/policy
|
|
102
103
|
* when present; core reads the rules at prepare-time (subtract-only) via RunnerDeps.sessionPolicyStore (wired separately). */
|
|
103
104
|
sessionPolicyStore?: ServiceSessionPolicyStore;
|
|
105
|
+
/** [3321] tool-results 读面([3315] 请求,core [3322] `buildToolResultRef` 到货):`GET /v1/tasks/:id/
|
|
106
|
+
* tool-results/:ref` 的数据源 —— 与 RunnerDeps 侧的 offload 店**同实例**(main.ts 的 `toolResultStore`,
|
|
107
|
+
* 即「durable 后端在场」那一只;无 backend 部署里 Runner 用的内存兜底 `runnerOffloadStore` **不接**这里)。
|
|
108
|
+
* 🔴 缺席 ⇒ 路由回 404 而不是 501:501 会向一个无权 caller 泄露「这台机器配没配 durable 店」的部署形,
|
|
109
|
+
* 与 unknown-ref / 归属不符两臂**同形** 404 才是防 oracle 的收口([3321] 的「另定」项按防 oracle 收紧)。
|
|
110
|
+
* 这是本组「absent ⇒ 501 诚实缺席」通例的**唯一具名例外**,例外理由即上一句。 */
|
|
111
|
+
toolResultStore?: ToolResultStoreFull;
|
|
104
112
|
/** E19 rewind-files snapshot store. Present ⇒ the `rewindFiles` capability is advertised; core uses it via
|
|
105
113
|
* RunnerDeps.fileSnapshotStore (snapshot per turn + restore on resumeAt, ANY env since gate-split 1.134.0). Also the 2c artifact store. */
|
|
106
114
|
fileSnapshotStore?: ServiceFileSnapshotStore;
|
|
@@ -156,6 +164,11 @@ export interface ServiceStoreDeps {
|
|
|
156
164
|
* `backend.session()`/`fileSnapshot()`/`sessionPolicy()`/`resumeAnchor()` must come from ONE backend). Present
|
|
157
165
|
* whenever a durable backend (tidb/pg/local) is; absent on the env-only/no-backend worker (the routes 501). */
|
|
158
166
|
backend?: StoreBackend;
|
|
167
|
+
/** #154 车二:**持久化权限规则的同意车道**(core design/179 三步协议的宿主半场)。
|
|
168
|
+
* 在场 ⇔ 规则店真装配(`backend.permissionRule()` 有值)⇒ `POST /v1/rules/cc-import/{prepare,redeem}`
|
|
169
|
+
* 可用、ask 帧才投 `ruleSuggestions`;缺席 ⇒ 两口 501、帧上零候选(发一格无处可兑的「不再询问」
|
|
170
|
+
* = wire 谎言,比缺席更坏)。 */
|
|
171
|
+
ruleConsent?: RuleConsentLane;
|
|
159
172
|
}
|
|
160
173
|
/** **进程内活体协调面**(本副本 only,重启即空):HITL 协调器、可 steer 句柄注册表、事件总线、
|
|
161
174
|
* 收件箱。跨副本一律不可见——每个消费点都按「不在本副本 ⇒ 409/降级」写,不假装全局。 */
|
|
@@ -281,6 +294,11 @@ export interface ServiceSeamDeps {
|
|
|
281
294
|
* 读面要该 scope 在授权表里、写面另要条目 `write === true`。缺席=本部署没有目录源 ⇒ `org:` 键
|
|
282
295
|
* 逐字保持 operator-only(收编前行为),绝不因目录不接线而放宽。 */
|
|
283
296
|
orgMemoryDirectory?: import("../org-memory-admission.js").OrgMemoryDirectory;
|
|
297
|
+
/** design/177 —— org 共享记忆库的供给面(与 core `RunnerDeps.sharedMemoryStores` **同一个实例**:
|
|
298
|
+
* HTTP 只读面看到的库集合按定义等于模型面看到的那一份,两面不可能分歧)。
|
|
299
|
+
* 🔴 缺席 ⇒ `/v1/shared-memory/*` 整域不挂载(诚实缺席);挂载还另要 {@link orgMemoryDirectory}
|
|
300
|
+
* 在场 —— 没有成员性判据的读面只能全放或全拒,两个都比「这个面不存在」差。 */
|
|
301
|
+
sharedMemoryStore?: import("@sema-agent/core").SharedMemoryStoreProvider;
|
|
284
302
|
/** WEB-SETUP-RECON 附B — org sessionMirror 治理态的 SERVICE 半场 = **观测面(audit),非执法面**。
|
|
285
303
|
* 裁定 (c):「该镜像而未镜像」发生在数据产生端(壳)——本 server 是镜像**目标**,只看得见到达的 push;
|
|
286
304
|
* 没到达的会话对 server 不存在(无客户端会话清单、无心跳契约),required 锁的真执法只能在壳(CC
|
|
@@ -316,8 +334,10 @@ export interface ServiceSeamDeps {
|
|
|
316
334
|
parkedKnownAgentTypes?: ReadonlySet<string>;
|
|
317
335
|
/** [1596]/[1597] 跨进程父约束重供:decide 腿按部署配置重建 host 任务的同语义父策略链(exempt 锚=
|
|
318
336
|
* row.rootSessionId),经 core 1.396 的 `parkedResume.inheritedGate` 席位透传——缺席时重启后带
|
|
319
|
-
* requiresParentConstraint 的 parked checkpoint 恒被 core pre-CAS 门诚实拒(不可赎回)。
|
|
320
|
-
|
|
337
|
+
* requiresParentConstraint 的 parked checkpoint 恒被 core pre-CAS 门诚实拒(不可赎回)。
|
|
338
|
+
* ⚠️ **async**(A-010.1):重建要按本行 principal 现解 entitlement——理由在 parked-decide.ts 的
|
|
339
|
+
* `rebuildInheritedGate` 头注。 */
|
|
340
|
+
parkedReviveInheritedGate?: (row: import("@sema-agent/core").BackgroundAgentRecord) => Promise<unknown>;
|
|
321
341
|
}
|
|
322
342
|
/** **可观测面**:日志/指标/累加器。全体 fail-open——观测失败绝不改变业务面行为。 */
|
|
323
343
|
export interface ServiceObservabilityDeps {
|