@sema-agent/server 7.87.2 → 7.88.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/auth-bridge.js +5 -3
- package/dist/boot/device-lane.d.ts +12 -19
- package/dist/boot/device-lane.js +3 -13
- package/dist/boot/stage-05-execution-env.js +1 -2
- package/dist/boot/stage-07-capability-layer.js +2 -1
- package/dist/boot/stage-09-leader.js +2 -1
- package/dist/boot/stage-10-http-server.js +4 -2
- package/dist/boot/stores.js +2 -1
- package/dist/brain.js +3 -2
- package/dist/config-catalog.js +4 -3
- package/dist/config-center/apply-effective.js +3 -2
- package/dist/config-center/http-client.d.ts +11 -2
- package/dist/config-center/http-client.js +1 -1
- package/dist/config-center/read-warnings.d.ts +1 -1
- package/dist/config-center/read-warnings.js +5 -3
- package/dist/config-center/types.d.ts +3 -1
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +12 -0
- package/dist/config-provider.d.ts +9 -1
- package/dist/config-provider.js +3 -1
- package/dist/config-types.d.ts +7 -0
- package/dist/config.js +28 -3
- package/dist/device-enrollment.d.ts +25 -4
- package/dist/device-enrollment.js +20 -2
- package/dist/device-protocol.d.ts +15 -0
- package/dist/device-protocol.js +2 -0
- package/dist/device-ws-hub.d.ts +11 -0
- package/dist/device-ws-hub.js +6 -0
- package/dist/fleet-client.js +2 -1
- package/dist/http/admission.js +10 -1
- package/dist/http/device-placement.d.ts +177 -0
- package/dist/http/device-placement.js +148 -0
- package/dist/http/durable-run-leg.js +4 -0
- package/dist/http/resume-legs.js +4 -0
- package/dist/http/route-ctx.d.ts +8 -0
- package/dist/http/routes/capabilities.js +1 -1
- package/dist/http/routes/devices.d.ts +18 -8
- package/dist/http/routes/devices.js +175 -12
- package/dist/http/routes/tasks.js +10 -0
- package/dist/http/server.d.ts +13 -0
- package/dist/http/wire-types.d.ts +15 -0
- package/dist/model-route-url.d.ts +15 -0
- package/dist/model-route-url.js +19 -0
- package/dist/request-key-closure.js +1 -1
- package/dist/run-local.js +2 -1
- package/dist/sealed-key.js +2 -1
- package/dist/security.d.ts +6 -0
- package/dist/security.js +3 -0
- package/dist/trace/redact.d.ts +20 -0
- package/dist/trace/redact.js +117 -26
- package/package.json +7 -3
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* 判定);②吊销同事务把连接租约作废并把 `conn_generation` 推进一格 ⇒ 在途连接的每一次带世代谓词的写
|
|
24
24
|
* 都会影响 0 行,设备侧因此立刻失权(§4.6 步 2);③重新 enrollment = 新 deviceId,不复活旧行。
|
|
25
25
|
*/
|
|
26
|
-
import { type DeviceOwner, type DeviceRejectCode, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
|
|
26
|
+
import { type DeviceErrorCode, type DeviceOwner, type DeviceRejectCode, type DeviceEnrollTokenRow, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
|
|
27
27
|
/** typed 拒绝 —— 端点层只负责把它翻成 HTTP(status 已在体内,不许调用方另算)。 */
|
|
28
28
|
export interface DeviceRejection {
|
|
29
29
|
/** [ref]:含两个借用位(`limit.rate_exceeded` 429 / `draining` 503);enrollment 本文件只铸前者,
|
|
@@ -89,7 +89,26 @@ export interface AdmitSessionValue {
|
|
|
89
89
|
*/
|
|
90
90
|
connGeneration: number;
|
|
91
91
|
}
|
|
92
|
+
export interface IssueEnrollTokenRequest {
|
|
93
|
+
/** 签发对象 = 请求方**验证过**的复合身份。v1 只能为自己签(operator 代他人签发是另一件事,
|
|
94
|
+
* 它要一条「谁可以代谁」的授权面,不在本动词里偷偷发生)。 */
|
|
95
|
+
target: DeviceOwner;
|
|
96
|
+
/** 审计列 `created_by`(= 发起这次签发的已验证 principal 串)。 */
|
|
97
|
+
createdBy: string;
|
|
98
|
+
/** 有效期**时长**(整秒,自 **DB now** 起算)。属主 = 部署旋钮 `DEVICE_ENROLL_TOKEN_TTL_SEC`;
|
|
99
|
+
* 缺席 ⇒ {@link DEFAULT_ENROLL_TOKEN_TTL_SEC}。 */
|
|
100
|
+
ttlSeconds?: number;
|
|
101
|
+
}
|
|
102
|
+
export interface IssuedEnrollToken {
|
|
103
|
+
/** 🔴 明文 token —— **只在这一次**存在于内存与响应体里;库里只有 sha256 摘要。 */
|
|
104
|
+
token: string;
|
|
105
|
+
row: DeviceEnrollTokenRow;
|
|
106
|
+
}
|
|
107
|
+
/** §7-T5 ① 的 15 分钟。**唯一默认点**(config 层刻意不抄第二份 —— 缺席即此值,见 config-types 的键注)。 */
|
|
108
|
+
export declare const DEFAULT_ENROLL_TOKEN_TTL_SEC = 900;
|
|
92
109
|
export interface DeviceEnrollment {
|
|
110
|
+
/** §7-T5 五件的第 ①②③ 件的**写入侧**:绑定复合身份 + TTL + 一次性摘要落库(明文一现)。 */
|
|
111
|
+
issueEnrollToken(input: IssueEnrollTokenRequest): Promise<DeviceOutcome<IssuedEnrollToken>>;
|
|
93
112
|
enroll(input: EnrollDeviceInput): Promise<DeviceOutcome<EnrollDeviceValue>>;
|
|
94
113
|
bindPlacement(input: {
|
|
95
114
|
rootSessionId: string;
|
|
@@ -111,13 +130,15 @@ export interface DeviceEnrollmentDeps {
|
|
|
111
130
|
/** 测试可注入确定性铸造;默认 `mintDeviceId()`。 */
|
|
112
131
|
mintId?: () => string;
|
|
113
132
|
}
|
|
133
|
+
/** 一条 typed 拒绝的**构造点**:状态档恒取 {@link DEVICE_ERROR_STATUS} 单表,调用方只给码与文案 —— 不许各处手抄「码 ↔ 状态」配对。 */
|
|
134
|
+
export declare function buildDeviceRejection(errorCode: DeviceErrorCode, message: string): DeviceRejection;
|
|
114
135
|
export declare function createDeviceEnrollment(deps: DeviceEnrollmentDeps): DeviceEnrollment;
|
|
115
136
|
/**
|
|
116
137
|
* 进程内固定窗限速(§7-T5 ⑤ 的**默认**实现)。
|
|
117
138
|
*
|
|
118
|
-
* 🔴
|
|
119
|
-
* (
|
|
120
|
-
*
|
|
139
|
+
* 🔴 诚实边界:这是**每副本**的窗,不在多副本部署下声称有全局上界。device 车道的生产接线恒用它
|
|
140
|
+
* (`boot/device-lane.ts` 的 `DEVICE_ENROLL_RATE_POLICY`)—— 曾经的「适配全局 RateGate」一支已删:那只限速器的
|
|
141
|
+
* 限额值跟全局 `RATE_LIMIT_RPM` 走,缺省 0 = 关断,等于让出厂形的注册口不限速。
|
|
121
142
|
*/
|
|
122
143
|
export declare function createFixedWindowRateLimiter(opts: {
|
|
123
144
|
limit: number;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, DEVICE_RATE_LIMIT_CODE, assertDeviceIdShape, assertDeviceOwnerShape, assertDevicePubkeyShape, assertEnrollTokenHashShape, assertRootSessionIdShape, hashEnrollToken, mintDeviceId, } from "./device-store.js";
|
|
1
|
+
import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, DEVICE_RATE_LIMIT_CODE, assertDeviceIdShape, assertDeviceOwnerShape, assertDevicePubkeyShape, assertEnrollTokenHashShape, assertRootSessionIdShape, hashEnrollToken, mintDeviceId, mintEnrollToken, } from "./device-store.js";
|
|
2
|
+
export const DEFAULT_ENROLL_TOKEN_TTL_SEC = 900;
|
|
3
|
+
export function buildDeviceRejection(errorCode, message) {
|
|
4
|
+
return { code: errorCode, status: DEVICE_ERROR_STATUS[errorCode], message };
|
|
5
|
+
}
|
|
2
6
|
function reject(code, message, extra) {
|
|
3
7
|
return { ok: false, reject: { code, status: DEVICE_ERROR_STATUS[code], message, ...(extra?.retryAfterSec === undefined ? {} : { retryAfterSec: extra.retryAfterSec }) } };
|
|
4
8
|
}
|
|
@@ -24,6 +28,20 @@ export function createDeviceEnrollment(deps) {
|
|
|
24
28
|
return reject(DEVICE_RATE_LIMIT_CODE, `device enrollment rate check unavailable (fail-closed): ${err instanceof Error ? err.message : String(err)}`, { retryAfterSec: 1 });
|
|
25
29
|
}
|
|
26
30
|
}
|
|
31
|
+
async function issueEnrollToken(input) {
|
|
32
|
+
assertDeviceOwnerShape(input.target);
|
|
33
|
+
const limited = await rateGate(`issue:${input.target.tenant}\u0000${input.target.subject}`);
|
|
34
|
+
if (limited)
|
|
35
|
+
return limited;
|
|
36
|
+
const token = mintEnrollToken();
|
|
37
|
+
const row = await store.issueEnrollToken({
|
|
38
|
+
tokenHash: hashEnrollToken(token),
|
|
39
|
+
target: input.target,
|
|
40
|
+
createdBy: input.createdBy,
|
|
41
|
+
ttlSeconds: input.ttlSeconds ?? DEFAULT_ENROLL_TOKEN_TTL_SEC,
|
|
42
|
+
});
|
|
43
|
+
return { ok: true, value: { token, row } };
|
|
44
|
+
}
|
|
27
45
|
async function enroll(input) {
|
|
28
46
|
assertDeviceOwnerShape(input.requester);
|
|
29
47
|
assertDevicePubkeyShape(input.device.pubkey);
|
|
@@ -120,7 +138,7 @@ export function createDeviceEnrollment(deps) {
|
|
|
120
138
|
return rejectNotFound();
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
|
-
return { enroll, bindPlacement, admitSession, revoke };
|
|
141
|
+
return { issueEnrollToken, enroll, bindPlacement, admitSession, revoke };
|
|
124
142
|
}
|
|
125
143
|
export function createFixedWindowRateLimiter(opts) {
|
|
126
144
|
const now = opts.now ?? (() => Date.now());
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sema-agent/server/device-protocol` —— 设备端 executor 需要的那一组协议面,**精选入口**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么有这只文件:executor 住在别的仓,而握手签名的逐字节编码是**运行时代码**,不是一份型 —— 手抄错一个字节,
|
|
5
|
+
* 所有合法 executor 恒 `auth_failed`。只给文档让人照抄 = 把「抄对没有」交给运气;给一条可 import 的子路径 =
|
|
6
|
+
* 两边用的是同一只函数。
|
|
7
|
+
*
|
|
8
|
+
* 为什么是精选而不是把 `device-ws-protocol.ts` 整只暴露:那个模块里还有**服务端**自己的半场(帧解析与准入、
|
|
9
|
+
* 验签、各种铸 id)。把它们变成公开面,等于替实现细节背上 semver。这里只再导出 executor **会用到**的:
|
|
10
|
+
* 闭集常量、两向帧型、以及构造握手签名字节所需的两只纯函数。要加名字 = 改这只文件 + 同批改
|
|
11
|
+
* `test/device-protocol-subpath.test.ts` 的闭集(新增公开面必须是一次有意的动作)。
|
|
12
|
+
*/
|
|
13
|
+
export { DEVICE_WS_PATH, DEVICE_PROTOCOL_VERSION, DEVICE_MIN_PROTOCOL_VERSION, DEVICE_HELLO_DOMAIN_TAG, DEVICE_WS_LIMITS, DEVICE_FRAME_TYPES, DEVICE_SERVER_FRAME_TYPES, DEVICE_HELLO_REJECT_CODES, DEVICE_BYE_REASONS, DEVICE_GOODBYE_REASONS, DEVICE_CHUNK_KINDS, DEVICE_CANCEL_STATES, DEVICE_INSTRUCTION_KINDS, DEVICE_OUTCOME_ERROR_CODES, buildHelloSignaturePayload, decodeUlid16, } from "./device-ws-protocol.js";
|
|
14
|
+
export type { DeviceFrameType, DeviceServerFrameType, DeviceHelloRejectCode, DeviceByeReason, DeviceGoodbyeReason, DeviceChunkKind, DeviceCancelState, DeviceInstructionKind, DeviceOutcomeErrorCode, WireFileInfo, WireWriteExpectation, DeviceInstructionOutcome, DeviceInstructionArgsByKind, DeviceChallengeFrame, DeviceHelloAckFrame, DeviceHelloRejectFrame, DeviceInstructionFrame, DevicePayloadFrame, DeviceServerFrame, DeviceHelloFrame, DeviceFrame, DeviceResultFrame, DeviceChunkFrame, DeviceGoodbyeFrame, DeviceHeartbeatFrame, } from "./device-ws-protocol.js";
|
|
15
|
+
//# sourceMappingURL=device-protocol.d.ts.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { DEVICE_WS_PATH, DEVICE_PROTOCOL_VERSION, DEVICE_MIN_PROTOCOL_VERSION, DEVICE_HELLO_DOMAIN_TAG, DEVICE_WS_LIMITS, DEVICE_FRAME_TYPES, DEVICE_SERVER_FRAME_TYPES, DEVICE_HELLO_REJECT_CODES, DEVICE_BYE_REASONS, DEVICE_GOODBYE_REASONS, DEVICE_CHUNK_KINDS, DEVICE_CANCEL_STATES, DEVICE_INSTRUCTION_KINDS, DEVICE_OUTCOME_ERROR_CODES, buildHelloSignaturePayload, decodeUlid16, } from "./device-ws-protocol.js";
|
|
2
|
+
//# sourceMappingURL=device-protocol.js.map
|
package/dist/device-ws-hub.d.ts
CHANGED
|
@@ -179,6 +179,17 @@ export interface DeviceWsHub {
|
|
|
179
179
|
}): void;
|
|
180
180
|
isDraining(): boolean;
|
|
181
181
|
connectedDeviceIds(): string[];
|
|
182
|
+
/**
|
|
183
|
+
* S-470(车C C-G2「吊销后活连接 5s 内断」)—— **立刻**对这台设备的活连接重跑一次世代围栏。
|
|
184
|
+
*
|
|
185
|
+
* 🔴 为什么是「重跑围栏」而不是一条新的断连路径:吊销已经在**店内同事务**作废租约并把
|
|
186
|
+
* `conn_generation` 推进一格(§4.6 步 2),所以本连接的下一次带世代谓词的写必然影响 0 行 ——
|
|
187
|
+
* {@link fenceConnection} 于是发 `bye:revoked`、断连、把在途按 outcome-unknown 收尾。管理面要的
|
|
188
|
+
* 「立刻」只是**把那一次围栏提前到现在**,不是第二种断法(两种断法 = 两份 bye 语义会漂)。
|
|
189
|
+
* 定时器驱动的续租(`leaseRenewIntervalMs`)仍是**跨副本**的兜底:连接不在本副本时本调用是 no-op,
|
|
190
|
+
* 那台设备最迟在一个续租拍距内失权。
|
|
191
|
+
*/
|
|
192
|
+
fenceDevice(deviceId: string): Promise<void>;
|
|
182
193
|
/**
|
|
183
194
|
* 车A-4:该设备**当前连接**在 hello 里声明的指令面(与本 server 的闭集取过交集之后的那一份)。
|
|
184
195
|
* 没有活连接 ⇒ `undefined`(**不是**空集:「不知道」与「明确什么都不支持」是两件事)。
|
package/dist/device-ws-hub.js
CHANGED
|
@@ -1034,6 +1034,12 @@ export function createDeviceWsHub(options) {
|
|
|
1034
1034
|
beginDrain,
|
|
1035
1035
|
isDraining: () => draining,
|
|
1036
1036
|
connectedDeviceIds: () => [...conns.keys()],
|
|
1037
|
+
fenceDevice: async (deviceId) => {
|
|
1038
|
+
const conn = conns.get(deviceId);
|
|
1039
|
+
if (!conn)
|
|
1040
|
+
return;
|
|
1041
|
+
await fenceConnection(conn);
|
|
1042
|
+
},
|
|
1037
1043
|
declaredSupports: (deviceId) => {
|
|
1038
1044
|
const conn = conns.get(deviceId);
|
|
1039
1045
|
return conn && (conn.phase === "active" || conn.phase === "acquiring") ? conn.supports : undefined;
|
package/dist/fleet-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InstanceAnnouncement, UsageReport, DEFAULT_HEARTBEAT_MS } from "@sema-agent/settings-schema/fleet";
|
|
2
|
+
import { buildDisplaySafeUrl } from "./trace/redact.js";
|
|
2
3
|
export const FLEET_USAGE_KEY_CAP = 1024;
|
|
3
4
|
export const FLEET_CLIENT_FETCH_TIMEOUT_MS = 5000;
|
|
4
5
|
export class FleetUsageAccumulator {
|
|
@@ -159,7 +160,7 @@ export function startFleetClientFromEnv(cfg, opts) {
|
|
|
159
160
|
parsed = undefined;
|
|
160
161
|
}
|
|
161
162
|
if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
162
|
-
throw new Error(`FLEET_ADVERTISE_ADDRESS must be a parseable http(s) base URL (e.g. "http://10.0.0.5:8090"); got "${address}" — the fleet center rejects any other shape on every announce`);
|
|
163
|
+
throw new Error(`FLEET_ADVERTISE_ADDRESS must be a parseable http(s) base URL (e.g. "http://10.0.0.5:8090"); got "${buildDisplaySafeUrl(address)}" — the fleet center rejects any other shape on every announce`);
|
|
163
164
|
}
|
|
164
165
|
if (!cc.worker) {
|
|
165
166
|
opts.logger?.warn("fleet_client_no_worker_scope", { note: "FLEET_ADVERTISE_ADDRESS set but SEMA_REGISTRY_WORKER missing — announce needs the worker (dispatch key); fleet client not started" });
|
package/dist/http/admission.js
CHANGED
|
@@ -10,6 +10,7 @@ import { parseHooksConfig } from "../hooks/hook-runner.js";
|
|
|
10
10
|
import { normalizeApproachNotice, unknownTaskAgentModelRef, validateTaskAgents } from "../spec-fields.js";
|
|
11
11
|
import { isValidCwd, MAX_ADDITIONAL_DIRS, MAX_CWD_CHARS } from "../task-cwd.js";
|
|
12
12
|
import { verifyRoundsFromBody } from "./verify-rounds.js";
|
|
13
|
+
import { resolveDevicePlacementIntent } from "./device-placement.js";
|
|
13
14
|
import { sendError, httpErrorCode } from "./send.js";
|
|
14
15
|
export const MAX_SKILL_CONTENT_CHARS = 1_048_576;
|
|
15
16
|
export const MAX_SYSTEM_PROMPT_CHARS = 16_384;
|
|
@@ -547,7 +548,15 @@ export function createAdmission(ctx) {
|
|
|
547
548
|
const auth = deps.authorize ? await deps.authorize({ req, body }) : undefined;
|
|
548
549
|
const engineNotices = [];
|
|
549
550
|
const spec = await deps.resolveSpec(body, req, auth, { leg: "fresh", ...(legFacts?.liveStream === true ? { liveStream: true } : {}), onNotice: (n) => engineNotices.push(n) });
|
|
550
|
-
|
|
551
|
+
const bindingIntent = await resolveDevicePlacementIntent({
|
|
552
|
+
config: deps.config,
|
|
553
|
+
req,
|
|
554
|
+
rootSessionId: spec.sessionId,
|
|
555
|
+
deviceId: body.deviceId,
|
|
556
|
+
store: deps.deviceStore,
|
|
557
|
+
enrollment: deps.deviceEnrollment,
|
|
558
|
+
});
|
|
559
|
+
return { spec, runner: runnerFor(spec), auth, verify, cascade, jobId: body.jobId, body, ...(bindingIntent === undefined ? {} : { bindingIntent }), ...(engineNotices.length > 0 ? { engineNotices } : {}) };
|
|
551
560
|
}
|
|
552
561
|
catch (err) {
|
|
553
562
|
if (err instanceof HttpError) {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device lane **首绑写协议**(design `device-executor-lane-v2` §4.3.2)—— 两阶段 placement 的
|
|
3
|
+
* server 半场:`resolveDevicePlacementIntent`(**只校验、不写库**)+ {@link commitPlacement}
|
|
4
|
+
* (**唯一**写入属主)。
|
|
5
|
+
*
|
|
6
|
+
* ── 两阶段的**分界在哪**(codex R1 两条 finding 判真后的正形)────────────────────────────────────
|
|
7
|
+
* 设计同段给了两条等价路(「同一 DB 事务原子提交,**或** commit 失败时以 taskId/rev CAS 终结并释放已建的
|
|
8
|
+
* run」)。本仓取**后者**,理由是结构性的:run 行住在 `RunStore`(tidb / pg / **file**),绑定行住在
|
|
9
|
+
* `DeviceStore`(tidb / pg)—— 要它们共享一个事务席,就等于要求「凡 device 车道,两只店必须是同一个
|
|
10
|
+
* SQL 连接池」。那会把 file / memory 形态的 run 店整个排除在外,而「本地与云无缝、TOC 面 backend 可插」
|
|
11
|
+
* 是本仓的铁律。
|
|
12
|
+
*
|
|
13
|
+
* 🔴 **分界的判据只有一句话:凡需要「这条会话已经归我」才成立的事,排在 claim 之后;其余全排在建任何
|
|
14
|
+
* 东西之前。** claim 之后只剩两件,而且是同一条不变量的两条臂 —— **离开 commit 的那一刻,绑定就是准入
|
|
15
|
+
* 批准的那一台**:首绑那一次 CAS(写),或对已有绑定的**再断言**(零写;codex 轮 2 [high] 的类修)。
|
|
16
|
+
* ⚠️ 「那一刻」是字面义,**不是「此后不变」** —— 论证与残余见 {@link commitPlacement} 顶注。
|
|
17
|
+
首绑那一次 CAS 必须在 claim 之后 —— 否则两条并发提交里输掉 claim 的那个会先把绑定写成**它**要的设备,
|
|
18
|
+
* 赢家的 run 于是落到别人挑的机器上。而准入链的三步(绑定解出 / devices active / presence)**全是读**,
|
|
19
|
+
* 它们一个字节都不写,所以整段挪到 `resolveDevicePlacementIntent` 里、在 `createRun` **之前**判
|
|
20
|
+
*(意图把批准的那台设备**带上**,commit 才有东西可断言 —— 见 `DevicePlacementIntent.admittedDeviceId`)。
|
|
21
|
+
* 这条收法同时关掉 codex 轮 1 的两条(都判真):
|
|
22
|
+
* · **[medium] 幂等键被常规拒绝烧掉**:`POST /v1/runs` 的自带 `body.taskId` 重放腿读的是 durable run 行
|
|
23
|
+
* (`routes/runs.ts` 的 `getRun(clientTaskId)` 臂),行在**哪怕是 failed** 也直接回 `202 {status:"failed"}`
|
|
24
|
+
* ⇒ 「设备离线」这种**常规**拒绝若先建了行,网关按同一个 taskId 的正常重试从此永远拿不到一次真准入。
|
|
25
|
+
* 读排在前面 ⇒ 常规拒绝的读面代价为零(零 run 行、零 claim、零补偿)。
|
|
26
|
+
* · **[high] claim 与 placement 写之间的无界窗**:claim 之后的**每一次**店往返都带
|
|
27
|
+
* {@link placementWriteDeadlineMs} 派生的**硬时限**(远小于 `runStaleSec`)⇒ 「挂得比 stale 回收还久、
|
|
28
|
+
* 回来以后仍然放行」这条竞态被时限从结构上关掉;超时 = 拒(fail-closed)+ 释放,绝不放行。
|
|
29
|
+
* 残余(如实):`createRun` 之后到心跳起来之前**本来**就有若干 await(`putCtx`、SSE 前导),那个窗是
|
|
30
|
+
* 存量形,本文件只负责**不加宽它**(本层的写有界),不假装把它关掉了。
|
|
31
|
+
*
|
|
32
|
+
* ── 为什么 commit 只许有一个属主函数 ───────────────────────────────────────────────────────────────
|
|
33
|
+
* 真码有**四个** `runStore.createRun(` 成功点(流式提交 / 同步提交 / durable 提交腿 / wake 续跑腿),
|
|
34
|
+
* 而「建了 run 行之后要不要绑设备」这件事对它们是同一句话。四份手抄 = 第五个入口出现时漏接一份,
|
|
35
|
+
* 而漏接的症状是「run 跑起来了但没有绑定」⇒ 执行期才 `device.not_bound`,账面上是一条已收费的失败 run。
|
|
36
|
+
* 类级机器门钉住这条:`test/device-first-bind.test.ts` 按 AST 找 `src/http/**` 里每一处 `createRun(`,
|
|
37
|
+
* 要求同一函数体内可见地走到 {@link commitPlacement}(漏接 = 当场红)。
|
|
38
|
+
*
|
|
39
|
+
* ── 准入链(§4.3.2 提交期三步,全 fail-closed)─────────────────────────────────────────────────────
|
|
40
|
+
* 1. 绑定解出 deviceId —— 解不出 = `device.not_bound`;
|
|
41
|
+
* 2. devices 行 `active` —— 行不在 / 不是你的 = `not_found.device`(反枚举同形),吊销 = `device.revoked`;
|
|
42
|
+
* 3. presence 在线 —— 否 = `device.offline`(**只在提交期**校验;执行期离线走 §5.4/§8)。
|
|
43
|
+
* 三步的属主是 `device-enrollment.ts` 的 `admitSession`,本文件一个判据都不自建,而且**整段在建 run 之前**跑
|
|
44
|
+
* (见上一段的分界判据)。任何查表故障 = 拒([ref]:安全轴上没有静默 fail-open 臂)。
|
|
45
|
+
*
|
|
46
|
+
* ── 便利度不变(clay [ref] 硬约束)────────────────────────────────────────────────────────────────
|
|
47
|
+
* 本文件**不产生任何审批**:它只做「这条 session 该落在哪台设备上」的查表与一次 CAS。device 车道上
|
|
48
|
+
* `permissionMode: "bypassPermissions"` 的 run 与 host 车道同构造同读数(判据钉在 device-first-bind
|
|
49
|
+
* 测试的「零额外审批」格)——「因为是 device 车道所以多问一次」在本层结构上不存在。
|
|
50
|
+
*/
|
|
51
|
+
import type { IncomingMessage } from "node:http";
|
|
52
|
+
import { type DeviceOwner } from "../device-store.js";
|
|
53
|
+
import { type DeviceEnrollment, type DeviceRejection } from "../device-enrollment.js";
|
|
54
|
+
import type { ServiceConfig } from "../config.js";
|
|
55
|
+
import type { RunStore } from "../plugins/store-backend.js";
|
|
56
|
+
/**
|
|
57
|
+
* 调用方 device 域的**唯一铸造点**。`undefined` = 判不出域(无 principal,或凭据不带 tenant claim)——
|
|
58
|
+
* 消费点必须响亮拒,禁静默空视图。
|
|
59
|
+
*
|
|
60
|
+
* 🔴 位置即契约:`{owner_tenant, owner_subject}` 是 O11 的 device-lane-local 复合身份(全局 principal
|
|
61
|
+
* 一个字节不动)。subject = `gatedPrincipal`(verified 链),tenant = `ssoVerifiedScope`(registry
|
|
62
|
+
* auth-bridge JWT 的 `scope` claim)—— **两者都是签名真源,不是自报头**。管理面三读三写与首绑写协议
|
|
63
|
+
* 全部从这一只函数取域;派生第二份就是把 O11 的判据本体复制成一份会漂的孪生。
|
|
64
|
+
*/
|
|
65
|
+
export declare function deviceOwnerDomainOf(req: IncomingMessage, config: ServiceConfig): DeviceOwner | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* **已校验、未写库**的 placement 意图(§4.3.2 ①)。
|
|
68
|
+
*
|
|
69
|
+
* 它的存在本身就是那条设计裁定:`resolveSpec` / `prepareSpec` 那一刻**还不知道**哪个并发请求会赢下
|
|
70
|
+
* session 的 active-run claim,所以它不能写绑定行 —— 只能把「如果我赢了,该写什么」算好交出去。
|
|
71
|
+
* 而**除了那一次写**,别的都已经在这一刻判完了(见文件头注的分界判据)。
|
|
72
|
+
*/
|
|
73
|
+
export interface DevicePlacementIntent {
|
|
74
|
+
/** 绑定键 = **根**会话(后代无条件继承;core 为 subagent/cascade/verify 另铸 sessionId,不另绑)。 */
|
|
75
|
+
readonly rootSessionId: string;
|
|
76
|
+
/** 验证过的复合身份(§4.3.2:恒取 verified 链,不取自可伪造头)。 */
|
|
77
|
+
readonly owner: DeviceOwner;
|
|
78
|
+
/**
|
|
79
|
+
* **准入链批准的那台设备** —— 意图阶段判完的那一台,也是 {@link commitPlacement} 的**断言对象**。
|
|
80
|
+
*
|
|
81
|
+
* 🔴 这一位是 codex 轮 2 [high] 的类修(病:已绑路径的意图**丢掉了设备身份**,于是一条明确写了
|
|
82
|
+
* `deviceId: A` 的提交在「准入过后、claim 之前」被换绑到 B 时会**静默地在 B 上跑完** —— 执行环境
|
|
83
|
+
* 工厂读的是**当下**的绑定,它根本不知道调用方点过名,`plugins/remote-env-device.ts` 的工厂段亲读坐实)。
|
|
84
|
+
* 身份留在意图里,commit 才有东西可断言。
|
|
85
|
+
*/
|
|
86
|
+
readonly admittedDeviceId: string;
|
|
87
|
+
/**
|
|
88
|
+
* `true` = 这条根会话在意图阶段**还没有绑定** ⇒ commit 要做那一次首绑 CAS(写);
|
|
89
|
+
* `false` = 已有绑定 ⇒ commit **不写**,只把 {@link admittedDeviceId} 对着活绑定**再断言一次**。
|
|
90
|
+
* 两条臂的共同不变量只有一句:**离开 commitPlacement 时,这条会话的绑定就是准入批准的那一台。**
|
|
91
|
+
*/
|
|
92
|
+
readonly firstBind: boolean;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* §4.3.2 ① —— 产出**已校验、不写库**的意图,或响亮拒。**准入链整段在这里跑**(它全是读),所以
|
|
96
|
+
* 常规拒绝(未绑定 / 离线 / 已吊销 / 不是你的设备)的读面代价是零:没有 run 行、没有会话占用、
|
|
97
|
+
* 没有补偿要做,调用方自带的幂等 taskId 一个都不会被烧掉(codex R1-[medium] 的类修)。
|
|
98
|
+
*
|
|
99
|
+
* 判据(逐条,fail-closed):
|
|
100
|
+
* · 车道不是 device 而体携 `deviceId` ⇒ **400**(不静默忽略 —— 安全轴禁 quiet fallback:调用方以为
|
|
101
|
+
* 自己指定了执行设备,而那条声明从未存在);
|
|
102
|
+
* · 车道是 device 而装配缺件 ⇒ **501**(boot 的 `device-lane-unwired` 不变量本应先拒启,这里是消费点自卫);
|
|
103
|
+
* · 判不出调用方域 ⇒ **403 响亮**(O11:准入等值比对没有真源时放行 = 把绑定写成任何人的);
|
|
104
|
+
* · 没有根会话 ⇒ **400**(绑定键不存在,绑无可绑);
|
|
105
|
+
* · 其余全部交给准入链属主 `admitSession`,**唯一的分叉**是它答 `device.not_bound` 那一支 ——
|
|
106
|
+
* 那正是「还没有绑定」,于是本函数看提交体有没有携首绑目标:没携 ⇒ 把 `device.not_bound` 原样交回;
|
|
107
|
+
* 携了 ⇒ 形门 + devices 行(active ∧ 是本域的 ∧ **在线**)三条读判完,产出首绑意图。
|
|
108
|
+
*
|
|
109
|
+
* 返回 `undefined` = 本部署不是 device 车道 ⇒ 整条 placement 面不存在(既有行为逐字节不变)。
|
|
110
|
+
*/
|
|
111
|
+
export declare function resolveDevicePlacementIntent(args: {
|
|
112
|
+
config: ServiceConfig;
|
|
113
|
+
req: IncomingMessage | undefined;
|
|
114
|
+
rootSessionId: string | undefined;
|
|
115
|
+
deviceId: unknown;
|
|
116
|
+
store: import("../device-store.js").DeviceStore | undefined;
|
|
117
|
+
enrollment: DeviceEnrollment | undefined;
|
|
118
|
+
}): Promise<DevicePlacementIntent | undefined>;
|
|
119
|
+
/** {@link commitPlacement} 的依赖 —— 逐字取自 `ServiceDeps` 的同名键(同一只 deps 对象喂进来,所以
|
|
120
|
+
* 「意图在 ⟹ 准入链在」是**同对象不变量**,不是一句祈愿)。 */
|
|
121
|
+
export interface PlacementCommitCtx {
|
|
122
|
+
deviceEnrollment?: DeviceEnrollment | undefined;
|
|
123
|
+
config?: {
|
|
124
|
+
runStaleSec?: number;
|
|
125
|
+
} | undefined;
|
|
126
|
+
logger?: {
|
|
127
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
128
|
+
} | undefined;
|
|
129
|
+
metrics?: {
|
|
130
|
+
inc(name: string, labels?: Record<string, string>): void;
|
|
131
|
+
} | undefined;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* claim 之后那一次写的**硬时限**(codex R1-[high] 的类修)。
|
|
135
|
+
*
|
|
136
|
+
* 界从 `runStaleSec` **派生**而不是新铸一个旋钮(operator-knob 纪律:没人要求过的旋钮不铸):它要回答的
|
|
137
|
+
* 问题只有一个 —— 「这次写会不会挂到 stale 回收把我的会话占用收走」。取四分之一并夹在 `[1s, 15s]`:
|
|
138
|
+
* 上夹是因为一次 CAS 写挂过 15 秒已经不是慢而是坏,下夹是因为运维把 `runStaleSec` 配得极小时,时限
|
|
139
|
+
* 不该短到把正常的库往返判成故障。
|
|
140
|
+
*/
|
|
141
|
+
export declare function placementWriteDeadlineMs(runStaleSec: number | undefined): number;
|
|
142
|
+
/**
|
|
143
|
+
* §4.3.2 ②③ —— placement 的**唯一**提交点(写 + 断言都只在这里发生)。四个 `createRun` 成功点全部经它。
|
|
144
|
+
*
|
|
145
|
+
* 到这一刻为止,该判的读已经判完(见文件头注的分界判据),而本函数拿到了一样意图阶段拿不到的东西:
|
|
146
|
+
* **这条会话的 active-run claim 已经归我们了**。两件事因此才成立:
|
|
147
|
+
* · **首绑臂**(`firstBind`)—— 做那一次 INSERT-if-absent CAS(赢 = 成立;输且同设备 = 幂等成功;
|
|
148
|
+
* 输且异设备 = `device.claim_conflict`);
|
|
149
|
+
* · **已绑臂** —— **不写**,但拿同一只 `admitSession` 对着活绑定把 {@link DevicePlacementIntent.admittedDeviceId}
|
|
150
|
+
* **再断言一次**(codex 轮 2 [high] 的类修)。判据属主仍是准入链那一只函数,本文件一个新判据都没造。
|
|
151
|
+
*
|
|
152
|
+
* 不变量(一句话):**离开本函数的那一刻,这条会话的绑定就是准入批准的那一台;否则本请求刚建的 run
|
|
153
|
+
* 已被终结并释放,调用方拿到的是一个 typed 拒绝。**
|
|
154
|
+
*
|
|
155
|
+
* 🔴 **这条不变量到此为止,它不说「此后不变」——一次如实改口(codex 轮 3 驳回了我方轮 2 的论证)。**
|
|
156
|
+
* 我方轮 2 写过「claim 一归我们,换绑动词的『无活跃 run』门就把并发换绑拒掉 ⇒ 断言之后绑定不可能再动」。
|
|
157
|
+
* **那句话是错的**,codex 轮 3 给的交错成立、亲读坐标复核无误:换绑腿的那道门是 check→act
|
|
158
|
+
* (`http/routes/devices.ts` 的「🔴 成文残留(设计裁定,非疏漏)」那一段逐字写着这个窗),它可以在本次
|
|
159
|
+
* `createRun` **之前**读到「无活跃 run」而在本函数**之后**才提交 CAS —— run claim 不推进绑定 `rev`,
|
|
160
|
+
* 那条 CAS 照样赢。于是:
|
|
161
|
+
* · 本断言**关掉**的是「换绑在本函数读之前已可见」那一半(较大的一半);
|
|
162
|
+
* · **剩下**的一半是那条**存量**的 check→act 窗,它的既有处置逐字在那段注里:①赢窗的 run 在换绑提交
|
|
163
|
+
* **后**铸 env ⇒ 读到新绑定;②在提交前铸(或已 connect)⇒ 投递门 `expectedDeviceId` 围栏按铸造时
|
|
164
|
+
* 设备恒拒,零指令落到任何一台设备。**对本批新增的「体里点了名」这一形,臂 ① 的『合法』口径不再成立**
|
|
165
|
+
* (调用方点了 A,却在 B 上跑),这条**登记为未闭环**:要真正关掉它,得把 `admittedDeviceId` 一路钉进
|
|
166
|
+
* 执行环境工厂与投递围栏(那是一条跨 core seam 的**新能力**,按本仓复审纪律不在复审环里加),或按
|
|
167
|
+
* 稿 §6 的候门段做跨店互斥 claim。两条都是独立的车与设计,不是本层的一个补丁。
|
|
168
|
+
*
|
|
169
|
+
* 返回 `null` = 放行(含「本部署不是 device 车道」)。
|
|
170
|
+
*/
|
|
171
|
+
export declare function commitPlacement(ctx: PlacementCommitCtx, runStore: RunStore, taskId: string, intent: DevicePlacementIntent | undefined): Promise<DeviceRejection | null>;
|
|
172
|
+
/** 拒绝的**唯一** wire 体铸点(四个消费点各自用自己协议的发送姿势,但体的键集只有这一份)。 */
|
|
173
|
+
export declare function devicePlacementErrorBody(rej: DeviceRejection): {
|
|
174
|
+
error: string;
|
|
175
|
+
errorCode: string;
|
|
176
|
+
};
|
|
177
|
+
//# sourceMappingURL=device-placement.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createCodedHttpError, ssoVerifiedScope } from "../security.js";
|
|
2
|
+
import { gatedPrincipal } from "./principal-gate.js";
|
|
3
|
+
import { DEVICE_ERROR_CODES, assertDeviceIdShape, } from "../device-store.js";
|
|
4
|
+
import { buildDeviceRejection } from "../device-enrollment.js";
|
|
5
|
+
export function deviceOwnerDomainOf(req, config) {
|
|
6
|
+
const subject = gatedPrincipal(req, config);
|
|
7
|
+
if (subject === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
const tenant = ssoVerifiedScope(req);
|
|
10
|
+
if (tenant === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
return { tenant, subject };
|
|
13
|
+
}
|
|
14
|
+
function asHttpError(rej) {
|
|
15
|
+
return createCodedHttpError(rej.status, rej.code, rej.message);
|
|
16
|
+
}
|
|
17
|
+
function registryUnavailable() {
|
|
18
|
+
return buildDeviceRejection(DEVICE_ERROR_CODES.NOT_BOUND, "the device binding for this session could not be resolved (the device registry is unavailable) — refusing rather than running on an unverified device");
|
|
19
|
+
}
|
|
20
|
+
export async function resolveDevicePlacementIntent(args) {
|
|
21
|
+
const laneIsDevice = args.config.remoteExec?.provider === "device";
|
|
22
|
+
const requested = args.deviceId;
|
|
23
|
+
if (!laneIsDevice) {
|
|
24
|
+
if (requested !== undefined) {
|
|
25
|
+
throw createCodedHttpError(400, "request.precondition_unmet", "deviceId is only accepted on a deployment that executes on the user's own device (REMOTE_EXEC=device) — this deployment does not, so binding this session to a device id would be a declaration that never took effect");
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (!args.store || !args.enrollment) {
|
|
30
|
+
throw createCodedHttpError(501, "capability.device_lane_required", "device execution is selected for this deployment but its stores are not wired on this replica — refusing to admit a submit that would execute nowhere");
|
|
31
|
+
}
|
|
32
|
+
if (args.req === undefined) {
|
|
33
|
+
throw createCodedHttpError(403, "auth.forbidden", "device placement needs a verified caller domain and this leg carries no request context");
|
|
34
|
+
}
|
|
35
|
+
const owner = deviceOwnerDomainOf(args.req, args.config);
|
|
36
|
+
if (owner === undefined) {
|
|
37
|
+
throw createCodedHttpError(403, "auth.forbidden", "device placement needs a caller domain: this credential carries no verified tenant scope claim (registry auth-bridge JWT `scope`) — connect through the registry auth bridge");
|
|
38
|
+
}
|
|
39
|
+
if (args.rootSessionId === undefined || args.rootSessionId === "") {
|
|
40
|
+
throw createCodedHttpError(400, "request.precondition_unmet", "device execution binds a device to a ROOT SESSION, so this submit needs a session — none was resolved for it");
|
|
41
|
+
}
|
|
42
|
+
if (requested !== undefined) {
|
|
43
|
+
if (typeof requested !== "string") {
|
|
44
|
+
throw createCodedHttpError(400, "request.field_invalid", "deviceId must be a string (the device id returned by POST /v1/devices/enroll)");
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
assertDeviceIdShape(requested);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw createCodedHttpError(400, "request.field_invalid", "deviceId is not a valid device id (byte-exact key: non-empty, no control characters or surrounding whitespace, at most 64 bytes)");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const requestedDeviceId = requested;
|
|
54
|
+
let admitted;
|
|
55
|
+
try {
|
|
56
|
+
admitted = await args.enrollment.admitSession({
|
|
57
|
+
rootSessionId: args.rootSessionId,
|
|
58
|
+
requester: owner,
|
|
59
|
+
...(requestedDeviceId === undefined ? {} : { requestedDeviceId }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw asHttpError(registryUnavailable());
|
|
64
|
+
}
|
|
65
|
+
if (admitted.ok)
|
|
66
|
+
return { rootSessionId: args.rootSessionId, owner, admittedDeviceId: admitted.value.deviceId, firstBind: false };
|
|
67
|
+
if (admitted.reject.code !== DEVICE_ERROR_CODES.NOT_BOUND || requestedDeviceId === undefined)
|
|
68
|
+
throw asHttpError(admitted.reject);
|
|
69
|
+
let view;
|
|
70
|
+
try {
|
|
71
|
+
view = await args.store.readDeviceForAdmission(requestedDeviceId, owner);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw asHttpError(registryUnavailable());
|
|
75
|
+
}
|
|
76
|
+
if (!view)
|
|
77
|
+
throw asHttpError(buildDeviceRejection(DEVICE_ERROR_CODES.NOT_FOUND, "device not found"));
|
|
78
|
+
if (view.row.status === "revoked")
|
|
79
|
+
throw asHttpError(buildDeviceRejection(DEVICE_ERROR_CODES.REVOKED, "device has been revoked"));
|
|
80
|
+
if (!view.online)
|
|
81
|
+
throw asHttpError(buildDeviceRejection(DEVICE_ERROR_CODES.OFFLINE, "device is not connected"));
|
|
82
|
+
return { rootSessionId: args.rootSessionId, owner, admittedDeviceId: requestedDeviceId, firstBind: true };
|
|
83
|
+
}
|
|
84
|
+
export function placementWriteDeadlineMs(runStaleSec) {
|
|
85
|
+
const stale = typeof runStaleSec === "number" && Number.isFinite(runStaleSec) && runStaleSec > 0 ? runStaleSec : 120;
|
|
86
|
+
return Math.min(15_000, Math.max(1_000, Math.floor((stale * 1000) / 4)));
|
|
87
|
+
}
|
|
88
|
+
export async function commitPlacement(ctx, runStore, taskId, intent) {
|
|
89
|
+
if (intent === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
const enrollment = ctx.deviceEnrollment;
|
|
92
|
+
const releaseRun = async (why) => {
|
|
93
|
+
try {
|
|
94
|
+
await runStore.setTerminal(taskId, "failed", null, why);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
ctx.logger?.warn("device_placement_run_release_failed", { taskId, err: err instanceof Error ? err.message : String(err) });
|
|
98
|
+
ctx.metrics?.inc("device_placement_run_release_failed_total");
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
if (!enrollment) {
|
|
102
|
+
await releaseRun("device placement could not be committed: the device lane's admission chain is not wired");
|
|
103
|
+
throw new Error("commitPlacement: a device placement intent was minted without a DeviceEnrollment — assembly bug (both are read from the same ServiceDeps)");
|
|
104
|
+
}
|
|
105
|
+
const deadlineMs = placementWriteDeadlineMs(ctx.config?.runStaleSec);
|
|
106
|
+
const withDeadline = async (work) => {
|
|
107
|
+
let timer;
|
|
108
|
+
try {
|
|
109
|
+
return await Promise.race([
|
|
110
|
+
work,
|
|
111
|
+
new Promise((_resolve, reject) => {
|
|
112
|
+
timer = setTimeout(() => reject(new Error(`device placement store call exceeded ${deadlineMs}ms`)), deadlineMs);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
let rejection = null;
|
|
123
|
+
try {
|
|
124
|
+
if (intent.firstBind) {
|
|
125
|
+
const bound = await withDeadline(enrollment.bindPlacement({ rootSessionId: intent.rootSessionId, deviceId: intent.admittedDeviceId, requester: intent.owner }));
|
|
126
|
+
if (!bound.ok)
|
|
127
|
+
rejection = bound.reject;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const readmitted = await withDeadline(enrollment.admitSession({ rootSessionId: intent.rootSessionId, requester: intent.owner, requestedDeviceId: intent.admittedDeviceId }));
|
|
131
|
+
if (!readmitted.ok)
|
|
132
|
+
rejection = readmitted.reject;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
ctx.logger?.warn("device_placement_commit_failed", { rootSessionId: intent.rootSessionId, firstBind: intent.firstBind, deadlineMs, err: err instanceof Error ? err.message : String(err) });
|
|
137
|
+
rejection = registryUnavailable();
|
|
138
|
+
}
|
|
139
|
+
if (rejection === null)
|
|
140
|
+
return null;
|
|
141
|
+
ctx.metrics?.inc("device_typed_rejects_total", { errorCode: rejection.code });
|
|
142
|
+
await releaseRun(`device placement rejected: [${rejection.code}] ${rejection.message}`);
|
|
143
|
+
return rejection;
|
|
144
|
+
}
|
|
145
|
+
export function devicePlacementErrorBody(rej) {
|
|
146
|
+
return { error: rej.message, errorCode: rej.code };
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=device-placement.js.map
|
|
@@ -4,6 +4,7 @@ import { fleetRunPublisher, fleetRunLabels } from "../fleet/fleet-bus.js";
|
|
|
4
4
|
import { cascadeConfig, runMeta } from "./run-meta.js";
|
|
5
5
|
import { resolveStreamApprovalGate } from "../tool-approval.js";
|
|
6
6
|
import { buildActiveRunConflict } from "./active-run-conflict.js";
|
|
7
|
+
import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
|
|
7
8
|
import { clearTurnActivity, readTurnActivityMs } from "../turn-activity.js";
|
|
8
9
|
import { recordResourceWindow } from "../resource-window.js";
|
|
9
10
|
export async function mintAndDriveDurableRun(ctx, runStore, args) {
|
|
@@ -24,6 +25,9 @@ export async function mintAndDriveDurableRun(ctx, runStore, args) {
|
|
|
24
25
|
}
|
|
25
26
|
return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
|
|
26
27
|
}
|
|
28
|
+
const placementReject = await commitPlacement(deps, runStore, taskId, args.prepared.bindingIntent);
|
|
29
|
+
if (placementReject)
|
|
30
|
+
return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
|
|
27
31
|
if (deps.checkpointStore) {
|
|
28
32
|
await deps.checkpointStore.putCtx(sessionId, { body: args.prepared.body, memoryScope: args.prepared.auth?.memoryScope });
|
|
29
33
|
}
|
package/dist/http/resume-legs.js
CHANGED
|
@@ -32,6 +32,7 @@ import { createSessionSyncLocal } from "./routes/session-sync.js";
|
|
|
32
32
|
import { createImagesLocal } from "./routes/images.js";
|
|
33
33
|
import { gatedPrincipal, explicitOperatorOk } from "./principal-gate.js";
|
|
34
34
|
import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
|
|
35
|
+
import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
|
|
35
36
|
import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
|
|
36
37
|
function isCheckpointReopenedFailure(r) {
|
|
37
38
|
const code = failureCodeOf(r.terminal);
|
|
@@ -394,6 +395,9 @@ export function createResumeLegs(ctx) {
|
|
|
394
395
|
if (!created.ok) {
|
|
395
396
|
return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
|
|
396
397
|
}
|
|
398
|
+
const placementReject = await commitPlacement(deps, deps.runStore, freshId, undefined);
|
|
399
|
+
if (placementReject)
|
|
400
|
+
return { status: placementReject.status, body: devicePlacementErrorBody(placementReject) };
|
|
397
401
|
taskId = freshId;
|
|
398
402
|
freshRunClaimed = true;
|
|
399
403
|
}
|
package/dist/http/route-ctx.d.ts
CHANGED
|
@@ -127,6 +127,14 @@ export interface PreparedTaskSubmission {
|
|
|
127
127
|
* 装配跑在这条腿的通告口开张之前,所以铸点交不出去 —— 由腿在 `registerEngineNoticeLeg` 那一拍
|
|
128
128
|
* 经 `pending` 一起投(投递单点仍是那一只注册助手,不新开第二条机制)。缺席 = 本次装配没铸下任何通告。 */
|
|
129
129
|
engineNotices?: readonly EngineNotice[];
|
|
130
|
+
/**
|
|
131
|
+
* S-470(device lane 首绑写协议,design §4.3.2 两阶段 placement)—— **已校验、未写库**的 placement 意图。
|
|
132
|
+
*
|
|
133
|
+
* 缺席的两种含义都等价于「本腿没有 placement 要提交」:①本部署不是 device 车道(绝大多数);
|
|
134
|
+
* ②本腿不是 fresh 根提交(resume 家族根本不经 `prepareSpec`)。在场 ⇒ 每个 `runStore.createRun(`
|
|
135
|
+
* 成功点**必须**把它交给 `commitPlacement`(唯一写入属主;类级机器门钉住每个调用点都接了)。
|
|
136
|
+
*/
|
|
137
|
+
bindingIntent?: import("./device-placement.js").DevicePlacementIntent;
|
|
130
138
|
}
|
|
131
139
|
/**
|
|
132
140
|
* 一条会话的**续跑重建输入**(`checkpoint_ctx` 的行体)—— 提交腿写一次,续跑腿读它重建 spec。
|
|
@@ -66,7 +66,7 @@ export async function handleCapabilities(req, res, match, ctx) {
|
|
|
66
66
|
approvals: Boolean(deps.checkpointStore),
|
|
67
67
|
sessions: Boolean(deps.sessionAudit),
|
|
68
68
|
deviceExecutor: deps.deviceHub
|
|
69
|
-
? { enabled: true, protocolVersion: DEVICE_PROTOCOL_VERSION, maxInflightPerDevice: deps.config.remoteExec?.provider === "device" ? (deps.config.remoteExec.maxInflightPerDevice ?? 4) : 4, wsPath: DEVICE_WS_PATH }
|
|
69
|
+
? { enabled: true, protocolVersion: DEVICE_PROTOCOL_VERSION, maxInflightPerDevice: deps.config.remoteExec?.provider === "device" ? (deps.config.remoteExec.maxInflightPerDevice ?? 4) : 4, wsPath: DEVICE_WS_PATH, management: Boolean(deps.deviceStore && deps.deviceEnrollment) }
|
|
70
70
|
: false,
|
|
71
71
|
sessionEvents: Boolean(deps.sessionWatch && deps.sessionStorage?.getLeafId && deps.sessionStorage?.ownerOf),
|
|
72
72
|
fleet: deps.fleetBus
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
* `ssoVerifiedScope`(registry-JWT 的 `scope` claim,security.ts:同为签名真源、非自报头)。两者齐备
|
|
34
34
|
* 才构成一个 device 域;缺 tenant claim 的凭据(纯 principal 头 / 静态 token 直连)**判不出域** ⇒
|
|
35
35
|
* 非 operator 一律响亮 403(fail-closed + loud,[ref]:静默空列表是假健康)——基线稿对 enrollment 的
|
|
36
|
-
* 同向裁定是「缺 tenant/global scope
|
|
37
|
-
*
|
|
36
|
+
* 同向裁定是「缺 tenant/global scope 明确拒注册」。enrollment/管理端点与**首绑写协议**都从
|
|
37
|
+
* 这同一个函数取域(S-470 起它住在 `http/device-placement.ts` —— 提交腿也要用,放在路由域里是层序倒挂),
|
|
38
|
+
* 不得手抄第二份派生。
|
|
38
39
|
*
|
|
39
40
|
* ── 反枚举 404 的单一铸造点 ──────────────────────────────────────────────────────────────────────
|
|
40
41
|
* 「绑定不在 / 不是你的 / 目标设备不在 / 不是该域的 / 已吊销」五臂共用 {@link sendDeviceNotFound} ——
|
|
@@ -54,18 +55,27 @@
|
|
|
54
55
|
*/
|
|
55
56
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
56
57
|
import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
|
|
57
|
-
import { type DeviceOwner } from "../../device-store.js";
|
|
58
|
-
/**
|
|
59
|
-
* 调用方 device 域的**唯一铸造点**(文件头注)。undefined = 判不出域(无 principal,或无 tenant
|
|
60
|
-
* claim)——消费点必须走 explicit operator 或响亮 403,禁静默空视图。
|
|
61
|
-
*/
|
|
62
|
-
export declare function deviceOwnerDomainOf(req: IncomingMessage, config: RouteCtx["deps"]["config"]): DeviceOwner | undefined;
|
|
63
58
|
export declare function handleDevices(req: IncomingMessage, res: ServerResponse, match: RouteMatch<DevicesRouteId>, ctx: RouteCtx): Promise<void>;
|
|
64
59
|
export declare const DEVICES_ROUTES: readonly [{
|
|
65
60
|
readonly id: "devices-list";
|
|
66
61
|
readonly path: "/v1/devices";
|
|
67
62
|
readonly credentialGated: readonly ["GET"];
|
|
68
63
|
readonly methods: readonly ["GET"];
|
|
64
|
+
}, {
|
|
65
|
+
readonly id: "device-enroll-tokens";
|
|
66
|
+
readonly path: "/v1/devices/enroll-tokens";
|
|
67
|
+
readonly credentialGated: readonly ["POST"];
|
|
68
|
+
readonly methods: readonly ["POST"];
|
|
69
|
+
}, {
|
|
70
|
+
readonly id: "device-enroll";
|
|
71
|
+
readonly path: "/v1/devices/enroll";
|
|
72
|
+
readonly credentialGated: readonly ["POST"];
|
|
73
|
+
readonly methods: readonly ["POST"];
|
|
74
|
+
}, {
|
|
75
|
+
readonly id: "device-revoke";
|
|
76
|
+
readonly pattern: RegExp;
|
|
77
|
+
readonly label: "/v1/devices/:deviceId/revoke";
|
|
78
|
+
readonly methods: readonly ["POST"];
|
|
69
79
|
}, {
|
|
70
80
|
readonly id: "device-session-rebind";
|
|
71
81
|
readonly pattern: RegExp;
|