@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -19,12 +19,22 @@
|
|
|
19
19
|
* POST /results/:jobId → 善果/失败回传(≤8MB;batchSearch ~550KB)
|
|
20
20
|
* 语义:桥不存在即整个扩展车道不存在——fail-closed 不变量在调用方(session-search 的 needs-extension)。
|
|
21
21
|
* 审计:job 提交/回传写 GuardAuditEntry 同款 JSONL(kind:'extension-session-job',channel 层负责)。
|
|
22
|
+
*
|
|
23
|
+
* AaaS A2(#674,威胁 S-5)组织维度:作业、取活者(parked/长轮询)、在飞作业、心跳时刻、节律、
|
|
24
|
+
* 事件都属于恰好一个组织。作业匹配要求组织严格相等(先于 origin/clientId/站点 capability 判定);
|
|
25
|
+
* 另一个组织的回包与「未知作业」一模一样地 404 且**不落任何行**,在飞作业纹丝不动。
|
|
26
|
+
* 取活者的组织只由挂载宿主在鉴权之后经 `handleMountedRequest(req, res, { organization })`
|
|
27
|
+
* 传入,**从不读请求体或请求头**;不传 = 缺省 `local`(桌面 loopback 桥与既有调用方行为逐字不变)。
|
|
28
|
+
* 发给扩展的 ExtensionJob 线上形状不变:组织只活在队列内部条目与账本列里。
|
|
22
29
|
*/
|
|
23
30
|
import { randomUUID } from 'node:crypto';
|
|
24
31
|
import { existsSync } from 'node:fs';
|
|
25
32
|
import { homedir } from 'node:os';
|
|
26
33
|
import { join } from 'node:path';
|
|
27
34
|
import { createServer } from 'node:http';
|
|
35
|
+
import { assertOrganizationId } from '../../src/tenant-state/ids.js';
|
|
36
|
+
/** 缺省组织 = 今天的单车道:不传组织的调用方(桌面 loopback 桥、既有测试)都落在这里 */
|
|
37
|
+
export const BRIDGE_DEFAULT_ORGANIZATION = 'local';
|
|
28
38
|
/** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
|
|
29
39
|
export const BRIDGE_PORTS = [8791, 8792, 8793, 8794, 8795];
|
|
30
40
|
export const BRIDGE_PROTOCOL = 'session-bridge.v1';
|
|
@@ -82,7 +92,10 @@ export const DEFAULT_EXTENSION_WAIT_MS = 6_000;
|
|
|
82
92
|
export const MAX_RESULT_BODY_BYTES = 8 * 1024 * 1024;
|
|
83
93
|
/** 扩展客户端标识形状(每 SW 生命周期一个 crypto.randomUUID);导出供事件上行端点(批次 B)同链校验 */
|
|
84
94
|
export const CLIENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
85
|
-
function jobMatches(entry, capabilities, origin, clientId) {
|
|
95
|
+
function jobMatches(entry, capabilities, origin, clientId, organization) {
|
|
96
|
+
// 组织严格相等先于一切:站点/origin/clientId 全部吻合的另一个组织的取活者,也永远领不到这个作业
|
|
97
|
+
if (entry.organization !== organization)
|
|
98
|
+
return false;
|
|
86
99
|
if (entry.preferredOrigin && entry.preferredOrigin !== origin)
|
|
87
100
|
return false;
|
|
88
101
|
if (entry.preferredClientId && entry.preferredClientId !== clientId)
|
|
@@ -121,6 +134,16 @@ const NAVIGATION_JOB_KINDS = new Set(['search', 'open-login']);
|
|
|
121
134
|
export function isNavigationJobKind(kind) {
|
|
122
135
|
return NAVIGATION_JOB_KINDS.has(kind);
|
|
123
136
|
}
|
|
137
|
+
/** 组织 id 是否合法(assertOrganizationId 的布尔形式;非法组织一律在碰任何状态之前被拒) */
|
|
138
|
+
export function isBridgeOrganization(value) {
|
|
139
|
+
try {
|
|
140
|
+
assertOrganizationId(value);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
124
147
|
export function createBridgeJobQueue(opts = {}) {
|
|
125
148
|
const extensionOrigins = new Set(opts.extensionOrigins ?? EXTENSION_ORIGINS);
|
|
126
149
|
const now = opts.now ?? Date.now;
|
|
@@ -128,9 +151,13 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
128
151
|
const queue = [];
|
|
129
152
|
const inFlight = new Map();
|
|
130
153
|
const parked = [];
|
|
131
|
-
|
|
154
|
+
// 心跳时刻按组织分开记:一个组织的扩展在线,不代表另一个组织的扩展在线
|
|
155
|
+
const lastSeenAt = new Map();
|
|
132
156
|
let closed = false;
|
|
133
|
-
const extensionConnected = (
|
|
157
|
+
const extensionConnected = (organization = BRIDGE_DEFAULT_ORGANIZATION) => {
|
|
158
|
+
const seen = lastSeenAt.get(assertOrganizationId(organization)) ?? 0;
|
|
159
|
+
return seen > 0 && now() - seen < EXTENSION_CONNECTED_WINDOW_MS;
|
|
160
|
+
};
|
|
134
161
|
/**
|
|
135
162
|
* 账本写兜底:持久面故障不得打断桥协议主路径(write-before-submit 的 INSERT 除外,
|
|
136
163
|
* 它在 submit 里直接决定提交成败)。stderr 留痕,作业照常流转。
|
|
@@ -146,48 +173,69 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
146
173
|
}
|
|
147
174
|
};
|
|
148
175
|
/** 领走首个 capability 匹配的 job(移入 inFlight——回包按 jobId 路由,两处都可找到) */
|
|
149
|
-
function takeMatching(capabilities, origin, clientId) {
|
|
150
|
-
const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId));
|
|
176
|
+
function takeMatching(capabilities, origin, clientId, organization) {
|
|
177
|
+
const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId, organization));
|
|
151
178
|
if (idx < 0)
|
|
152
179
|
return null;
|
|
153
180
|
const [next] = queue.splice(idx, 1);
|
|
154
181
|
next.claimedOrigin = origin;
|
|
155
182
|
next.claimedClientId = clientId;
|
|
156
183
|
inFlight.set(next.job.jobId, next);
|
|
157
|
-
record('markClaimed', () => store.markClaimed(next.job.jobId, { origin, clientId }));
|
|
184
|
+
record('markClaimed', () => store.markClaimed(next.organization, next.job.jobId, { origin, clientId }));
|
|
158
185
|
if (isNavigationJobKind(next.job.kind))
|
|
159
|
-
record('noteSiteHit', () => store.noteSiteHit(next.job.site));
|
|
186
|
+
record('noteSiteHit', () => store.noteSiteHit(next.organization, next.job.site));
|
|
160
187
|
return next;
|
|
161
188
|
}
|
|
162
189
|
/** 扩展回包:queued 与 inFlight 两处都可寻址(job 执行失败 = result.ok:false,桥自身不吞) */
|
|
163
|
-
function resolveJob(jobId, parsed) {
|
|
190
|
+
function resolveJob(organization, jobId, parsed) {
|
|
164
191
|
const inFlightHit = inFlight.get(jobId);
|
|
165
192
|
if (inFlightHit) {
|
|
193
|
+
// 第二道防线:回包处理已把别的组织的 jobId 按「未知作业」挡在外面,这里再不认一次
|
|
194
|
+
if (inFlightHit.organization !== organization)
|
|
195
|
+
return;
|
|
166
196
|
inFlight.delete(jobId);
|
|
167
197
|
if (inFlightHit.timer)
|
|
168
198
|
clearTimeout(inFlightHit.timer);
|
|
169
|
-
record('markResolved', () => store.markResolved(jobId, parsed));
|
|
199
|
+
record('markResolved', () => store.markResolved(inFlightHit.organization, jobId, parsed));
|
|
170
200
|
record('client-login-site', () => {
|
|
171
201
|
const names = Array.isArray(parsed.names) ? parsed.names : [];
|
|
172
202
|
if (parsed.ok === true && inFlightHit.job.kind === 'cookie-names' && names.length > 0 && inFlightHit.claimedClientId) {
|
|
173
|
-
store.recordClientLoginSite(inFlightHit.claimedClientId, inFlightHit.job.site);
|
|
203
|
+
store.recordClientLoginSite(inFlightHit.organization, inFlightHit.claimedClientId, inFlightHit.job.site);
|
|
174
204
|
}
|
|
175
205
|
});
|
|
176
206
|
inFlightHit.resolve({ ok: true, result: parsed, origin: inFlightHit.claimedOrigin, clientId: inFlightHit.claimedClientId });
|
|
177
207
|
return;
|
|
178
208
|
}
|
|
179
|
-
const idx = queue.findIndex((q) => q.job.jobId === jobId);
|
|
209
|
+
const idx = queue.findIndex((q) => q.job.jobId === jobId && q.organization === organization);
|
|
180
210
|
if (idx < 0)
|
|
181
211
|
return;
|
|
182
212
|
const [entry] = queue.splice(idx, 1);
|
|
183
213
|
if (entry.timer)
|
|
184
214
|
clearTimeout(entry.timer);
|
|
185
|
-
record('markResolved', () => store.markResolved(jobId, parsed));
|
|
215
|
+
record('markResolved', () => store.markResolved(entry.organization, jobId, parsed));
|
|
186
216
|
entry.resolve({ ok: true, result: parsed });
|
|
187
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* 这个 jobId 属于另一个组织吗(内存里的排队/在飞条目,或账本里的行)?回包处理用它在**一切写入之前**
|
|
220
|
+
* 把别的组织的回包挡掉。读不了账本就证明不了「它不属于别的组织」,宁可当作属于(不落孤儿行)。
|
|
221
|
+
*/
|
|
222
|
+
function belongsToAnotherOrganization(jobId, organization) {
|
|
223
|
+
const held = inFlight.get(jobId) ?? queue.find((q) => q.job.jobId === jobId);
|
|
224
|
+
if (held)
|
|
225
|
+
return held.organization !== organization;
|
|
226
|
+
if (!store)
|
|
227
|
+
return false;
|
|
228
|
+
try {
|
|
229
|
+
const owner = store.organizationOfJob(jobId);
|
|
230
|
+
return owner !== null && owner !== organization;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
188
236
|
/** 新 job 入队时即时派发给 capability 匹配的 parked 取活者(否则要白等一个 20s 长轮询周期) */
|
|
189
237
|
function dispatchToParked(entry) {
|
|
190
|
-
const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId));
|
|
238
|
+
const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId, p.organization));
|
|
191
239
|
if (i < 0)
|
|
192
240
|
return false;
|
|
193
241
|
const [p] = parked.splice(i, 1);
|
|
@@ -195,20 +243,24 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
195
243
|
entry.claimedOrigin = p.origin;
|
|
196
244
|
entry.claimedClientId = p.clientId;
|
|
197
245
|
inFlight.set(entry.job.jobId, entry);
|
|
198
|
-
record('markClaimed', () => store.markClaimed(entry.job.jobId, { origin: p.origin, clientId: p.clientId }));
|
|
246
|
+
record('markClaimed', () => store.markClaimed(entry.organization, entry.job.jobId, { origin: p.origin, clientId: p.clientId }));
|
|
199
247
|
if (isNavigationJobKind(entry.job.kind))
|
|
200
|
-
record('noteSiteHit', () => store.noteSiteHit(entry.job.site));
|
|
248
|
+
record('noteSiteHit', () => store.noteSiteHit(entry.organization, entry.job.site));
|
|
201
249
|
p.res.statusCode = 200;
|
|
202
250
|
p.res.setHeader('content-type', 'application/json');
|
|
203
251
|
p.res.end(JSON.stringify({ job: entry.job }));
|
|
204
252
|
return true;
|
|
205
253
|
}
|
|
206
|
-
const stats = () =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
254
|
+
const stats = (organization = BRIDGE_DEFAULT_ORGANIZATION) => {
|
|
255
|
+
const org = assertOrganizationId(organization);
|
|
256
|
+
const seen = lastSeenAt.get(org) ?? 0;
|
|
257
|
+
return {
|
|
258
|
+
queued: queue.filter((q) => q.organization === org).length,
|
|
259
|
+
inFlight: [...inFlight.values()].filter((q) => q.organization === org).length,
|
|
260
|
+
parked: parked.filter((p) => p.organization === org).length,
|
|
261
|
+
lastSeenMsAgo: seen > 0 ? now() - seen : null,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
212
264
|
/**
|
|
213
265
|
* 结算器(submit 与 recoverOnBoot 复活条目共用):内存三态清理 + 账本终态落笔。
|
|
214
266
|
* 幂等护栏(settled 标记)防 waitTimer 兜底在 resolved 之后二次结算把账本写花。
|
|
@@ -233,19 +285,19 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
233
285
|
if (store && !outcome.ok) {
|
|
234
286
|
if (outcome.reason === 'timeout') {
|
|
235
287
|
if (wasClaimed) {
|
|
236
|
-
record('markUnresolved', () => store.markUnresolved(entry.job.jobId, { reason: 'timeout' }));
|
|
288
|
+
record('markUnresolved', () => store.markUnresolved(entry.organization, entry.job.jobId, { reason: 'timeout' }));
|
|
237
289
|
if (isNavigationJobKind(entry.job.kind))
|
|
238
|
-
record('applyCooldown', () => store.applyCooldown(entry.job.site));
|
|
290
|
+
record('applyCooldown', () => store.applyCooldown(entry.organization, entry.job.site));
|
|
239
291
|
}
|
|
240
292
|
else {
|
|
241
|
-
record('markVoid', () => store.markVoid(entry.job.jobId));
|
|
293
|
+
record('markVoid', () => store.markVoid(entry.organization, entry.job.jobId));
|
|
242
294
|
}
|
|
243
295
|
}
|
|
244
296
|
else if (outcome.reason === 'extension-not-connected') {
|
|
245
|
-
record('markVoid', () => store.markVoid(entry.job.jobId));
|
|
297
|
+
record('markVoid', () => store.markVoid(entry.organization, entry.job.jobId));
|
|
246
298
|
}
|
|
247
299
|
else if (!wasClaimed) {
|
|
248
|
-
record('markVoid', () => store.markVoid(entry.job.jobId));
|
|
300
|
+
record('markVoid', () => store.markVoid(entry.organization, entry.job.jobId));
|
|
249
301
|
}
|
|
250
302
|
}
|
|
251
303
|
deliver?.(outcome);
|
|
@@ -272,6 +324,11 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
272
324
|
return summary;
|
|
273
325
|
}
|
|
274
326
|
for (const row of pending) {
|
|
327
|
+
// 行里的组织必须是合法 id:被改坏的行还原不出所属组织,留在原地不派发(fail-closed),绝不猜成 local
|
|
328
|
+
if (!isBridgeOrganization(row.organization)) {
|
|
329
|
+
process.stderr.write(`[bridge] 账本恢复跳过组织非法的作业 ${row.id}\n`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
275
332
|
const expired = row.timeout_at !== null && row.timeout_at <= now();
|
|
276
333
|
let job = null;
|
|
277
334
|
try {
|
|
@@ -284,6 +341,7 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
284
341
|
if (!expired && job) {
|
|
285
342
|
const entry = {
|
|
286
343
|
job,
|
|
344
|
+
organization: row.organization,
|
|
287
345
|
resolve: null,
|
|
288
346
|
timer: null,
|
|
289
347
|
claimedOrigin: row.claimed_origin ?? undefined,
|
|
@@ -297,9 +355,9 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
297
355
|
summary.rehydratedInFlight++;
|
|
298
356
|
}
|
|
299
357
|
else {
|
|
300
|
-
record('markUnresolved', () => store.markUnresolved(row.id, { reason: 'timeout' }));
|
|
358
|
+
record('markUnresolved', () => store.markUnresolved(row.organization, row.id, { reason: 'timeout' }));
|
|
301
359
|
if (isNavigationJobKind(row.kind))
|
|
302
|
-
record('applyCooldown', () => store.applyCooldown(row.site));
|
|
360
|
+
record('applyCooldown', () => store.applyCooldown(row.organization, row.site));
|
|
303
361
|
summary.settledUnresolved++;
|
|
304
362
|
}
|
|
305
363
|
}
|
|
@@ -307,6 +365,7 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
307
365
|
if (!expired && job) {
|
|
308
366
|
const entry = {
|
|
309
367
|
job,
|
|
368
|
+
organization: row.organization,
|
|
310
369
|
resolve: null,
|
|
311
370
|
timer: null,
|
|
312
371
|
};
|
|
@@ -319,7 +378,7 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
319
378
|
summary.requeued++;
|
|
320
379
|
}
|
|
321
380
|
else {
|
|
322
|
-
record('markVoid', () => store.markVoid(row.id));
|
|
381
|
+
record('markVoid', () => store.markVoid(row.organization, row.id));
|
|
323
382
|
summary.voidedStale++;
|
|
324
383
|
}
|
|
325
384
|
}
|
|
@@ -331,8 +390,19 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
331
390
|
* 为挂载形态(查询式 results——gotry-backend 内核只支持精确路由,jobId 走 query)。
|
|
332
391
|
* Origin 白名单校验内置于两种形态(远程形态同样强制:网页跨域请求必带邪恶 Origin)。
|
|
333
392
|
*/
|
|
334
|
-
function makeHandler(prefix, resultsViaQuery, statusExtra) {
|
|
335
|
-
return (req, res) => {
|
|
393
|
+
function makeHandler(prefix, resultsViaQuery, mounted, statusExtra) {
|
|
394
|
+
return (req, res, caller) => {
|
|
395
|
+
// 组织只来自挂载宿主在鉴权之后传入的 caller(loopback 桥恒为 local),**从不读请求体或请求头**;
|
|
396
|
+
// 非法组织先于一切 403,不碰任何状态
|
|
397
|
+
// 传了 caller 就必须带合法组织(缺字段/null 也算非法,绝不悄悄回落成 local);没传 caller = 单车道
|
|
398
|
+
const requested = mounted ? (caller === undefined ? BRIDGE_DEFAULT_ORGANIZATION : caller.organization) : BRIDGE_DEFAULT_ORGANIZATION;
|
|
399
|
+
if (!isBridgeOrganization(requested)) {
|
|
400
|
+
res.statusCode = 403;
|
|
401
|
+
res.setHeader('content-type', 'application/json');
|
|
402
|
+
res.end(JSON.stringify({ ok: false, error: 'organization-invalid' }));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const organization = requested;
|
|
336
406
|
if (closed) {
|
|
337
407
|
res.statusCode = 503;
|
|
338
408
|
res.end();
|
|
@@ -354,7 +424,7 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
354
424
|
// 一切携带白名单扩展 Origin 的请求都刷新 lastSeen,否则 45s 后误判扩展掉线;
|
|
355
425
|
// 无 Origin 的诊断 GET(/health /status)不记心跳,curl 探活不能伪造「扩展在线」
|
|
356
426
|
if (originTrusted) {
|
|
357
|
-
lastSeenAt
|
|
427
|
+
lastSeenAt.set(organization, now());
|
|
358
428
|
}
|
|
359
429
|
const finish = (code, body) => {
|
|
360
430
|
res.statusCode = code;
|
|
@@ -367,8 +437,8 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
367
437
|
}
|
|
368
438
|
if (req.method === 'GET' && path === '/status') {
|
|
369
439
|
finish(200, {
|
|
370
|
-
ok: true, protocol: BRIDGE_PROTOCOL, ...stats(),
|
|
371
|
-
extensionConnected: extensionConnected(),
|
|
440
|
+
ok: true, protocol: BRIDGE_PROTOCOL, ...stats(organization),
|
|
441
|
+
extensionConnected: extensionConnected(organization),
|
|
372
442
|
...(statusExtra ? statusExtra() : {}),
|
|
373
443
|
});
|
|
374
444
|
return;
|
|
@@ -393,16 +463,16 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
393
463
|
})();
|
|
394
464
|
// 客户端注册落账(store 形态):轮询即刷新 origin/capabilities/last_seen(bridge_clients upsert)
|
|
395
465
|
if (pollerInfo.clientId) {
|
|
396
|
-
record('upsertClient', () => store.upsertClient(pollerInfo.clientId, origin, [...(pollerInfo.capabilities ?? [])]));
|
|
466
|
+
record('upsertClient', () => store.upsertClient(organization, pollerInfo.clientId, origin, [...(pollerInfo.capabilities ?? [])]));
|
|
397
467
|
}
|
|
398
|
-
const next = takeMatching(pollerInfo.capabilities, origin, pollerInfo.clientId);
|
|
468
|
+
const next = takeMatching(pollerInfo.capabilities, origin, pollerInfo.clientId, organization);
|
|
399
469
|
if (next) {
|
|
400
470
|
finish(200, { job: next.job });
|
|
401
471
|
return;
|
|
402
472
|
}
|
|
403
473
|
// 长轮询:hold ≤ JOBS_LONG_POLL_MS(必须 < MV3 SW 30s 存活窗口,每次响应都续命);
|
|
404
474
|
// 新 job 提交时即时唤醒 parked 取活者(见 dispatchToParked)
|
|
405
|
-
const poller = { res, timer: null, capabilities: pollerInfo.capabilities, origin: origin, clientId: pollerInfo.clientId };
|
|
475
|
+
const poller = { res, timer: null, capabilities: pollerInfo.capabilities, origin: origin, clientId: pollerInfo.clientId, organization };
|
|
406
476
|
const parkTimer = setTimeout(() => {
|
|
407
477
|
const i = parked.findIndex((p) => p.res === res);
|
|
408
478
|
if (i >= 0)
|
|
@@ -446,6 +516,13 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
446
516
|
finish(400, { ok: false, error: '回包不是 JSON' });
|
|
447
517
|
return;
|
|
448
518
|
}
|
|
519
|
+
// 组织边界先于一切(含下面的 Origin/客户端校验):jobId 在内存或账本里属于另一个组织,
|
|
520
|
+
// 就答得与未知作业一模一样(404 unknown-job),但**什么都不写**——不落孤儿行、不动在飞作业
|
|
521
|
+
// (它自己的合法回包照常闭环),也不泄露「这个 id 存在」。
|
|
522
|
+
if (belongsToAnotherOrganization(jobId, organization)) {
|
|
523
|
+
finish(404, { ok: false, error: 'unknown-job' });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
449
526
|
const assigned = inFlight.get(jobId);
|
|
450
527
|
if (assigned?.claimedOrigin && assigned.claimedOrigin !== origin) {
|
|
451
528
|
finish(403, { ok: false, error: '作业由另一扩展 Origin 领取' });
|
|
@@ -458,14 +535,14 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
458
535
|
// 未知 jobId(批次 A):不再 200 静默丢弃——404 明示,孤儿回包内容落账
|
|
459
536
|
// (status='unresolved',result_json 保留)供对账。扩展 postResult 本就有
|
|
460
537
|
// catch 且不检查状态码(extension/background.js),协议兼容零改动。
|
|
461
|
-
const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId);
|
|
538
|
+
const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId && q.organization === organization);
|
|
462
539
|
if (!known) {
|
|
463
|
-
record('orphan-result', () => store.recordOrphanResult(jobId, parsed));
|
|
540
|
+
record('orphan-result', () => store.recordOrphanResult(organization, jobId, parsed));
|
|
464
541
|
finish(404, { ok: false, error: 'unknown-job' });
|
|
465
542
|
return;
|
|
466
543
|
}
|
|
467
544
|
finish(200, { ok: true });
|
|
468
|
-
resolveJob(jobId, parsed);
|
|
545
|
+
resolveJob(organization, jobId, parsed);
|
|
469
546
|
});
|
|
470
547
|
return;
|
|
471
548
|
}
|
|
@@ -476,14 +553,31 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
476
553
|
port: 0,
|
|
477
554
|
extensionConnected,
|
|
478
555
|
stats,
|
|
479
|
-
|
|
480
|
-
|
|
556
|
+
forOrganization(organization) {
|
|
557
|
+
const bound = assertOrganizationId(organization);
|
|
558
|
+
return {
|
|
559
|
+
get port() { return queueHandle.port; },
|
|
560
|
+
extensionConnected: () => extensionConnected(bound),
|
|
561
|
+
// 先展开调用方的选项、再盖上绑定的组织:下游传什么 organization 都改不了提交去向
|
|
562
|
+
submit: (job, submitOpts) => queueHandle.submit(job, { ...submitOpts, organization: bound }),
|
|
563
|
+
// 句柄只是视图,共享队列的生命周期归创建它的宿主
|
|
564
|
+
close: () => Promise.resolve(),
|
|
565
|
+
};
|
|
566
|
+
},
|
|
567
|
+
handleRequest: makeHandler('', false, false),
|
|
568
|
+
handleMountedRequest: makeHandler('/v1/session/bridge', true, true),
|
|
481
569
|
...(store ? { recoverOnBoot } : {}),
|
|
482
570
|
submit(job, submitOpts = {}) {
|
|
483
571
|
const extensionWaitMs = submitOpts.extensionWaitMs ?? DEFAULT_EXTENSION_WAIT_MS;
|
|
484
572
|
const timeoutMs = submitOpts.timeoutMs ?? job.timeoutMs ?? 30_000;
|
|
485
573
|
const full = { jobId: randomUUID(), ...job };
|
|
486
574
|
return new Promise((resolve) => {
|
|
575
|
+
// 组织只能是合法 id:非法的在碰账本/队列之前就失败(fail-closed)
|
|
576
|
+
const organization = submitOpts.organization === undefined ? BRIDGE_DEFAULT_ORGANIZATION : submitOpts.organization;
|
|
577
|
+
if (!isBridgeOrganization(organization)) {
|
|
578
|
+
resolve({ ok: false, reason: 'bridge-unavailable', summary: '指定的组织标识无效' });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
487
581
|
if (closed) {
|
|
488
582
|
resolve({ ok: false, reason: 'bridge-unavailable', summary: '扩展桥已关闭' });
|
|
489
583
|
return;
|
|
@@ -500,16 +594,16 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
500
594
|
// 绝不派发无账作业。桌面纯内存形态此步整体跳过,行为零变化。
|
|
501
595
|
if (store) {
|
|
502
596
|
try {
|
|
503
|
-
store.insertQueuedJob(full, now() + timeoutMs);
|
|
597
|
+
store.insertQueuedJob(organization, full, now() + timeoutMs);
|
|
504
598
|
}
|
|
505
599
|
catch (e) {
|
|
506
600
|
resolve({ ok: false, reason: 'bridge-unavailable', summary: `桥账本写入失败,作业不入队:${e instanceof Error ? e.message : String(e)}` });
|
|
507
601
|
return;
|
|
508
602
|
}
|
|
509
603
|
if (isNavigationJobKind(full.kind))
|
|
510
|
-
record('noteSiteHit', () => store.noteSiteHit(full.site));
|
|
604
|
+
record('noteSiteHit', () => store.noteSiteHit(organization, full.site));
|
|
511
605
|
}
|
|
512
|
-
const entry = { job: full, resolve: null, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId };
|
|
606
|
+
const entry = { job: full, organization, resolve: null, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId };
|
|
513
607
|
const settle = armSettle(entry, resolve);
|
|
514
608
|
entry.resolve = settle;
|
|
515
609
|
entry.timer = setTimeout(() => {
|
|
@@ -528,7 +622,7 @@ export function createBridgeJobQueue(opts = {}) {
|
|
|
528
622
|
let waited = 0;
|
|
529
623
|
const waitTimer = setInterval(() => {
|
|
530
624
|
waited += 250;
|
|
531
|
-
if (
|
|
625
|
+
if (extensionConnected(organization)) {
|
|
532
626
|
clearInterval(waitTimer);
|
|
533
627
|
return;
|
|
534
628
|
}
|