@sema-agent/server 7.79.0 → 7.79.1
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/file-history-disclosure.d.ts +53 -0
- package/dist/file-history-disclosure.js +89 -0
- package/dist/http/admission.d.ts +56 -0
- package/dist/http/admission.js +560 -0
- package/dist/http/approval-sweeps.d.ts +21 -0
- package/dist/http/approval-sweeps.js +138 -0
- package/dist/http/dispatch.d.ts +96 -0
- package/dist/http/dispatch.js +499 -0
- package/dist/http/request-guards.d.ts +51 -0
- package/dist/http/request-guards.js +205 -0
- package/dist/http/resume-legs.d.ts +80 -0
- package/dist/http/resume-legs.js +1230 -0
- package/dist/http/server.d.ts +19 -104
- package/dist/http/server.js +23 -2589
- package/dist/main.js +2 -0
- package/dist/observability/metrics.js +1 -1
- package/dist/plugins/file-history-store-sql.js +12 -11
- package/package.json +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { redactHead } from "../observability/run-terminal-log.js";
|
|
2
|
+
import { findParkOrigin } from "../parked-decide.js";
|
|
3
|
+
import { decodeCheckpointScope } from "../security.js";
|
|
4
|
+
import { personDecision } from "../host-decision.js";
|
|
5
|
+
import { APPROVAL_GATE_KINDS } from "../tool-approval.js";
|
|
6
|
+
import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
|
|
7
|
+
import { hasServiceAuth } from "./dispatch.js";
|
|
8
|
+
import { gatedPrincipal } from "./principal-gate.js";
|
|
9
|
+
export function createApprovalSweeps(ctx) {
|
|
10
|
+
const { deps, resumeCheckpoint, mayDiscloseRowError } = ctx;
|
|
11
|
+
const denySweepClockGuard = createWallClockJumpGuard({
|
|
12
|
+
slaMs: deps.config.approvalTimeoutSec > 0 ? deps.config.approvalTimeoutSec * 1000 : undefined,
|
|
13
|
+
onJump: ({ jumpMs, graceMs }) => deps.logger?.warn?.("deny_sweep_wall_clock_jump", {
|
|
14
|
+
jumpMs,
|
|
15
|
+
graceMs,
|
|
16
|
+
note: jumpMs > 0
|
|
17
|
+
? "the wall clock stepped FORWARD relative to the monotonic clock; the approval SLA deny-sweep stands down for graceMs of MONOTONIC time so no pending gate is denied earlier than one full SLA of REAL time ([4232]①/[4235]). The checkpoint terminal_at_ms backstop is unaffected — nothing can hang forever."
|
|
18
|
+
: "the wall clock stepped BACKWARD relative to the monotonic clock; the deny-sweep needs no grace for this direction (a receding deadline only DELAYS a deny) but the step itself is an operational fact worth recording.",
|
|
19
|
+
}),
|
|
20
|
+
onSuppressed: ({ remainingMs }) => deps.logger?.info?.("deny_sweep_clock_grace", { remainingMs }),
|
|
21
|
+
onGraceCapped: ({ totalGraceMs }) => deps.logger?.warn?.("deny_sweep_clock_grace_capped", {
|
|
22
|
+
totalGraceMs,
|
|
23
|
+
note: "the wall clock kept stepping forward; the approval SLA deny-sweep has stood down for the maximum cumulative grace and now RESUMES on a demonstrably unstable clock — a fail-closed leg that never runs is worse than one running on a bad clock. Fix NTP on this host.",
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
async function denyExpiredApprovals(now) {
|
|
27
|
+
const cs = deps.checkpointStore;
|
|
28
|
+
if (!cs)
|
|
29
|
+
return;
|
|
30
|
+
if (!denySweepClockGuard.admit(now))
|
|
31
|
+
return;
|
|
32
|
+
const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
|
|
33
|
+
const isParkedOwned = async (sessionId) => {
|
|
34
|
+
if (deps.backgroundAgentStore === undefined && deps.workflowAgentSessionIndex === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
const token = await cs.findPendingTokenBySession(sessionId, undefined, { gateKinds: APPROVAL_GATE_KINDS });
|
|
38
|
+
if (!token)
|
|
39
|
+
return false;
|
|
40
|
+
const cp = await cs.get(token);
|
|
41
|
+
if (!cp)
|
|
42
|
+
return false;
|
|
43
|
+
return (await findParkOrigin({
|
|
44
|
+
...(deps.backgroundAgentStore !== undefined ? { agentStore: deps.backgroundAgentStore } : {}),
|
|
45
|
+
...(deps.workflowAgentSessionIndex !== undefined && deps.workflowRunStore !== undefined
|
|
46
|
+
? { workflowIndex: deps.workflowAgentSessionIndex, workflowRunStore: deps.workflowRunStore }
|
|
47
|
+
: {}),
|
|
48
|
+
}, { token, scope: cp.scope, sessionId: cp.sessionId })) !== undefined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
let parkedSkipped = 0;
|
|
55
|
+
for (const { sessionId } of expired) {
|
|
56
|
+
if (await isParkedOwned(sessionId)) {
|
|
57
|
+
parkedSkipped += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
await resumeCheckpoint(sessionId, "deny", "approval SLA expired — auto-denied", { decidedBy: "sla_timeout" }).then((r) => { if (r.status >= 400)
|
|
61
|
+
deps.logger?.warn?.("deny_sweep_resume_blocked", { sessionId, status: r.status, errorCode: r.body?.errorCode }); }, (e) => deps.logger?.warn?.("deny_sweep_resume_failed", { sessionId, err: redactHead(e instanceof Error ? e.message : String(e), 2000) }));
|
|
62
|
+
}
|
|
63
|
+
if (parkedSkipped > 0)
|
|
64
|
+
deps.logger?.info?.("deny_sweep_parked_skipped", { count: parkedSkipped });
|
|
65
|
+
}
|
|
66
|
+
async function redeemParkedAsk(req) {
|
|
67
|
+
const cs = deps.checkpointStore;
|
|
68
|
+
if (!cs) {
|
|
69
|
+
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
70
|
+
}
|
|
71
|
+
if (!hasServiceAuth(deps.config) && !deps.config.allowUnauthedWrites) {
|
|
72
|
+
return { status: 503, body: { error: "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions", errorCode: "auth.service_token_required" } };
|
|
73
|
+
}
|
|
74
|
+
if (deps.drainState?.draining) {
|
|
75
|
+
return {
|
|
76
|
+
status: 503,
|
|
77
|
+
body: {
|
|
78
|
+
error: "draining",
|
|
79
|
+
errorCode: "draining",
|
|
80
|
+
message: "this instance is draining for shutdown/upgrade — retry against the replacement instance",
|
|
81
|
+
...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (deps.modelReady && !deps.modelReady()) {
|
|
86
|
+
return {
|
|
87
|
+
status: 503,
|
|
88
|
+
body: {
|
|
89
|
+
error: "model_roster_pending",
|
|
90
|
+
errorCode: "state.model_roster_pending",
|
|
91
|
+
message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (deps.costQuota && req.httpReq) {
|
|
96
|
+
const quotaPrincipal = gatedPrincipal(req.httpReq, deps.config);
|
|
97
|
+
if (quotaPrincipal !== undefined) {
|
|
98
|
+
const d = deps.costQuota.check(quotaPrincipal);
|
|
99
|
+
if (!d.allowed) {
|
|
100
|
+
deps.metrics?.inc("cost_quota_rejected_total");
|
|
101
|
+
return {
|
|
102
|
+
status: 429,
|
|
103
|
+
body: { error: "cost quota exceeded", errorCode: "limit.cost_quota_exceeded", usedMicroUsd: d.usedMicroUsd, limitMicroUsd: d.limitMicroUsd, retryAfterSec: d.retryAfterSec },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let cp;
|
|
109
|
+
try {
|
|
110
|
+
cp = await cs.get(req.gateToken);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (!(await mayDiscloseRowError(cs, req.gateToken, { principal: req.principal, explicitOperator: false }))) {
|
|
114
|
+
deps.logger?.warn?.("parked_late_decision_unreadable_row_withheld", { askId: req.askId });
|
|
115
|
+
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
116
|
+
}
|
|
117
|
+
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
118
|
+
}
|
|
119
|
+
if (!cp) {
|
|
120
|
+
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
121
|
+
}
|
|
122
|
+
const cpPrincipal = decodeCheckpointScope(cp.scope);
|
|
123
|
+
if (cpPrincipal !== undefined && cpPrincipal !== req.principal) {
|
|
124
|
+
deps.logger?.warn?.("parked_late_decision_scope_mismatch", { askId: req.askId });
|
|
125
|
+
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
126
|
+
}
|
|
127
|
+
const binding = {
|
|
128
|
+
checkpointToken: req.gateToken,
|
|
129
|
+
...(req.gateBoundCallId !== null ? { boundCallId: req.gateBoundCallId } : {}),
|
|
130
|
+
...(req.gateBoundInputHash !== null ? { boundInputHash: req.gateBoundInputHash } : {}),
|
|
131
|
+
...(req.updatedInput !== undefined ? { updatedInput: req.updatedInput } : {}),
|
|
132
|
+
};
|
|
133
|
+
const out = await resumeCheckpoint(cp.sessionId, req.decision, req.note, personDecision(req.principal), req.httpReq, undefined, binding, undefined, true, { principal: req.principal, explicitOperator: false });
|
|
134
|
+
return { status: out.status, body: out.body };
|
|
135
|
+
}
|
|
136
|
+
return { denyExpiredApprovals, redeemParkedAsk };
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=approval-sweeps.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求流水线(S-147 切片:`http/server.ts` 的 `handle()` 1130–1568 与它的模块级分类词表逐字搬家,正文一字未改)。
|
|
3
|
+
*
|
|
4
|
+
* 住在这里的三样东西是一件事的三个面:①`handle()` —— admission 计数 / 附着登记 / CORS / /health / /metrics /
|
|
5
|
+
* 鉴权桥 / 全局服务凭据门 / drain 与 roster-pending 两道 503 / 35 只域调用链;②`ROUTE_DOMAINS` —— 那条链的
|
|
6
|
+
* 域名册(`test/route-shape-roster.test.ts` 层D 从 handle() 真源码抽序与它对拍);③分类谓词族
|
|
7
|
+
* (`routeLabel` / `isBillableSubmitPath` / `isCredentialGatedRewrite` / `isMethodDispatchedSubmitPath` /
|
|
8
|
+
* `hasServiceAuth` / `cors` / `PROCESS_STARTED_AT_MS`)—— 流水线判「这条 url 吃哪几道门、记哪个标签」的全部词表。
|
|
9
|
+
* 留在 server.ts 则本文件必须值 import 组合根 ⇒ 闭合运行时装载环;设计稿 §5(S-146 第二刀)也正是要把
|
|
10
|
+
* ②③ 合成一张 `ROUTES` 声明表,那张表的家就是这里。公面五名由 server.ts **re-export**,消费方 import 零改动。
|
|
11
|
+
*
|
|
12
|
+
* ctx 字段 4 只(`deps` / `counters` / `noteActivity` / `routeCtxBase`),机器算(closure-free-vars)与手抄一致。
|
|
13
|
+
* `openResponses` / `lastAttachEndedAt` 是 handle 自己写的外层 `let`/`Set`,随它一起进工厂 —— 这样那两行赋值
|
|
14
|
+
* 正文一个字都不用改(装盒改写会把 `lastAttachEndedAt = …` 改成 `box.x = …`,那就不是 move 了)。
|
|
15
|
+
*/
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
17
|
+
import type { ServiceConfig } from "../config-types.js";
|
|
18
|
+
import type { RouteCtxBase, RunRegistry } from "./route-ctx.js";
|
|
19
|
+
import type { FlatServiceDeps } from "./server.js";
|
|
20
|
+
/** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
|
|
21
|
+
* guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
|
|
22
|
+
* via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
|
|
23
|
+
/**
|
|
24
|
+
* 「这条 url **是**计费提交面,但它到底提不提交由**请求体里的方法**决定」—— 目前只有 A2A 的
|
|
25
|
+
* JSON-RPC 端点(单 URL 承载 `message/send` 写 + `tasks/get` 读,因为 agent card 只能公告一个 `url`)。
|
|
26
|
+
*
|
|
27
|
+
* 用途:两道**可用性** 503(drain / model-roster-pending)按 url 一刀切会把 `tasks/get` 这条纯读一起
|
|
28
|
+
* 关掉,而单副本部署恰好在重启/依赖降级窗口里最需要取回已完成的结果(codex 轮2 R2-3,验真后修)。
|
|
29
|
+
* 本谓词让 `handle()` 对这条 url **跳过**那两道,改由域模块在 `message/send` 分支里施加同样的两道
|
|
30
|
+
* (`routes/a2a-serve.ts` 的 `submitUnavailable`,文案逐字同源)。
|
|
31
|
+
*
|
|
32
|
+
* 🔴 **只豁免那两道,不豁免安全轴**:`isBillableSubmitPath` 本身**不变**(它仍答 true),所以
|
|
33
|
+
* ① 无 service token 的部署上那道 fail-closed 503 照旧罩着这条 url —— 它判的是「这台机能不能收写」,
|
|
34
|
+
* 与方法无关,而且门必须在解析请求体**之前**;
|
|
35
|
+
* ② billable 申明门钉的仍是那只纯谓词(表里 `/v1/a2a` 依旧 billable=true)。
|
|
36
|
+
* 新成员进本表 = 承诺「该域在自己的方法分支里补齐那两道」,漏补就是一条静默旁路;所以本表只该有
|
|
37
|
+
* 「单 URL 多方法」这一种成员,加一条要连同它的域内补门一起过评审。
|
|
38
|
+
*/
|
|
39
|
+
export declare function isMethodDispatchedSubmitPath(url: string): boolean;
|
|
40
|
+
/** 「这台部署配了任何一把 service 凭证吗」—— fail-closed 写门族的真值源。**单一属主**:`handle()` 的
|
|
41
|
+
* 凭证门、[ref] 的 parked 赎回席、以及 [ref] 的 consolidation 阀门启动期告警读同一句(手抄迟早
|
|
42
|
+
* 只改一处,而这条判据决定的是「无凭证部署上写口开不开」)。导出仅为最后一个消费点(main.ts)。 */
|
|
43
|
+
export declare function hasServiceAuth(config: ServiceConfig): boolean;
|
|
44
|
+
export declare function isBillableSubmitPath(url: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* [ref]([ref] 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
|
|
47
|
+
* billable / bake 两族一样 fail-closed 的写门。谓词与 `isBillableSubmitPath` **刻意分开**:这几扇门
|
|
48
|
+
* `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 model-roster-pending 两道 503 ——
|
|
49
|
+
* 而收编与规则读写在排空期/roster 未落时做完全无害。
|
|
50
|
+
*
|
|
51
|
+
* 判据(两个合取项,缺一不进本表)= 「授权的唯一输入是 principal 头」∧「动作是**持久改写**」:
|
|
52
|
+
* · `POST /v1/adoption` —— operator-only 的一次性部署级动作,把一个 principal 名下**每一行**重写到另一个
|
|
53
|
+
* 身份;授权判据 `explicitOperatorOk(gatedPrincipal(...))`。
|
|
54
|
+
* · `POST /v1/rules/cc-import/*` —— principal lane 的**持久 allow 规则**铸造口;一条持久 allow 规则对后续
|
|
55
|
+
* 同命令的 classify 档 ask 常驻消音(core [ref] 之后仍然如此,只是不再消 mandated 的那些)。
|
|
56
|
+
* · `DELETE /v1/rules` —— 同一个规则店的**撤销**口。方向相反(收紧)但同样是持久改写,而且它多一条
|
|
57
|
+
* operator 超范围域(`?principal=` / body 的 `principal` 让 operator 收回**任一**租户的规则)⇒ 自造一个
|
|
58
|
+
* 列在 `OPERATOR_PRINCIPALS` 里的头,就能把每一位租户的规则一次清空。
|
|
59
|
+
* 无 service token 的部署上 `verifiedPrincipal` 走的是「BFF/gated:头已由上游验过」那一支,而这一形恰恰
|
|
60
|
+
* 是**没有**那个上游 —— 于是自造一个头就能收编别人、替任意租户种下常驻放行、或替他们全删。bake 门当年
|
|
61
|
+
* 补的就是同一句话,`isDestructiveSessionWrite` 从逐条路由白名单改成族判定也是同一句话(那次的措辞逐字是
|
|
62
|
+
* 「a forged principal header alone could **tighten/DoS** a session's tools」——撤销口落的正是 DoS 那一半)。
|
|
63
|
+
*
|
|
64
|
+
* 🔴 **方法感知**(codex 交叉复审 R2-[high],验真后修):撤销走的是 `DELETE`,而本门最初只挂在 `POST` 的
|
|
65
|
+
* 合取里 —— 于是一个自称「持久改写」的谓词把爆炸半径最大的那条动词漏在门外,正是 `isDestructiveSessionWrite`
|
|
66
|
+
* 当年被抓到的同一形。签名因此收方法,与那只谓词逐字同形。
|
|
67
|
+
* 🔴 读面**原则上**不进本表:`GET /v1/adoption/:id` 与 `GET /v1/rules` 各有自己的**属主门**兜底,本门只拦写。
|
|
68
|
+
*
|
|
69
|
+
* 🔴 **第二个合取项的加宽**(codex 交叉复审 [high],[ref] 提货批,验真后修)——判据现在是
|
|
70
|
+
* 「持久改写 **或** 授权唯一输入是 principal 头的**跨租户治理读**」。加宽是为了收进 [ref] v2-c 的两口:
|
|
71
|
+
* · `POST /v1/memory/import` —— 直接落在**原**判据上,而且是本仓最深的那一种持久改写:它把别处铸的
|
|
72
|
+
* 条目 / 挑战 / 污染标记 / 托管链落进本部署的记忆平面。自造一个在册 operator 头 ⇒ 一份**不当调用方自制的
|
|
73
|
+
* 治理史**成为本店的账;而记忆是模型**每次挂载都读**的面,比种一条常驻 allow 规则更深(那条只影响
|
|
74
|
+
* ask 会不会问,这条影响模型相信什么)。
|
|
75
|
+
* · `POST /v1/memory/export` —— 是**读**,按原措辞本不该进。但被点名豁免的那两条读各有属主门兜底,
|
|
76
|
+
* 而本条**没有**:它的授权判据就是 `explicitOperatorOk`,过了就是**全库**——一次调用取走每个 scope 的
|
|
77
|
+
* 条目正文 + 托管链 + 跨会话污染标记 + lineage。把一次全租户记忆外泄留在门外、只为守住「本门只拦写」
|
|
78
|
+
* 这句措辞,是把措辞看得比它要保护的东西更重。加宽后的判据仍然是**闭**的(不是「写门以外都进」):
|
|
79
|
+
* 它要求「授权的唯一输入是那个头」∧「一次调用的爆炸半径跨租户」,`GET /v1/rules` 那种属主门读不满足。
|
|
80
|
+
* 行为面钉:`test/rewrite-door-service-token-gate.test.ts`(六扇门各一正一反 + 逃生口格 + 读面负控)。
|
|
81
|
+
*/
|
|
82
|
+
export declare function isCredentialGatedRewrite(method: string, url: string): boolean;
|
|
83
|
+
/** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
|
|
84
|
+
* [[ref]] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
|
|
85
|
+
* 落 ":id"、`/v1/images/{bakes,select,register}` 落 ":profile"、`/v1/images/bakes/claim` 落
|
|
86
|
+
* "bakes/:id")——长连接 stream 的 duration 混进 :id 桶正是 fleet/stream 案的同族错桶病。
|
|
87
|
+
* 精确匹配恒比形状匹配更对,序修零反例(billable-route-declaration 门逐样本钉)。 */
|
|
88
|
+
export declare function routeLabel(_method: string, url: string): string;
|
|
89
|
+
export declare function createDispatch(ctx: {
|
|
90
|
+
deps: FlatServiceDeps;
|
|
91
|
+
noteActivity: (url: string) => void;
|
|
92
|
+
routeCtxBase: RouteCtxBase;
|
|
93
|
+
} & Pick<RunRegistry, "counters">): {
|
|
94
|
+
handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=dispatch.d.ts.map
|