@danceiny/gotry 0.0.1-rc.15 → 0.0.1-rc.16
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 +10 -6
- package/README.zh-CN.md +10 -6
- package/bin/gotry-bootstrap.js +294 -5
- package/cordis.gotry-patch.yml +10 -0
- package/data/airline-airports.json +39 -0
- package/dist/capabilities/agent-reach-deep.js +1 -1
- package/dist/capabilities/agent-reach.js +1 -1
- package/dist/capabilities/anything.js +1 -1
- package/dist/capabilities/artifacts.js +1 -1
- package/dist/capabilities/effect.js +293 -0
- package/dist/capabilities/fact-log.js +41 -0
- package/dist/capabilities/flyai.js +1 -1
- package/dist/capabilities/hbcli.js +43 -1
- package/dist/capabilities/incident-log.js +1 -1
- package/dist/capabilities/opensky.js +1 -1
- package/dist/capabilities/resilience.js +90 -0
- package/dist/capabilities/session/action-cache.js +1 -1
- package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
- package/dist/capabilities/session/adapters/meituan-local.js +1 -1
- package/dist/capabilities/session/benchmark.js +2 -1
- package/dist/capabilities/session/extension-bridge.js +345 -0
- package/dist/capabilities/session/extension-channel.js +93 -0
- package/dist/capabilities/session/extract.js +1 -1
- package/dist/capabilities/session/health-watch.js +216 -0
- package/dist/capabilities/session/read-guard.js +1 -1
- package/dist/capabilities/session/transport.js +1 -1
- package/dist/capabilities/session/wizard.js +377 -0
- package/dist/capabilities/session-consent.js +1 -1
- package/dist/capabilities/session-login.js +77 -1
- package/dist/capabilities/session-search.js +69 -2
- package/dist/capabilities/weather.js +1 -1
- package/dist/scripts/action-cache-tests.js +1 -1
- package/dist/scripts/agent-reach-deep-tests.js +1 -1
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/anything-tests.js +1 -1
- package/dist/scripts/async-collect.js +1 -1
- package/dist/scripts/booking-saga-tests.js +1 -1
- package/dist/scripts/bootstrap-tests.js +39 -3
- package/dist/scripts/build-changelog.js +279 -0
- package/dist/scripts/changelog-tests.js +165 -0
- package/dist/scripts/companion-tests.js +1 -1
- package/dist/scripts/diff-test.js +1 -1
- package/dist/scripts/effect-tests.js +474 -0
- package/dist/scripts/engine-run.js +1 -1
- package/dist/scripts/engine-tests.js +1 -1
- package/dist/scripts/extension-tests.js +468 -0
- package/dist/scripts/fact-gate-tests.js +318 -0
- package/dist/scripts/flyai-tests.js +1 -1
- package/dist/scripts/hbcli-e2e-tests.js +180 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/health-watch-cli.js +51 -0
- package/dist/scripts/i18n-tests.js +1 -1
- package/dist/scripts/incident-tests.js +1 -1
- package/dist/scripts/journey-tests.js +1 -1
- package/dist/scripts/ledger-tests.js +1 -1
- package/dist/scripts/ledger-workflow-crash.js +1 -1
- package/dist/scripts/memory-capture-tests.js +1 -1
- package/dist/scripts/memory-decay-tests.js +1 -1
- package/dist/scripts/memory-metrics.js +1 -1
- package/dist/scripts/memory-value-report.js +1 -1
- package/dist/scripts/nightly-evidence-tests.js +67 -4
- package/dist/scripts/nightly-evidence.js +59 -3
- package/dist/scripts/nudge-digest.js +1 -1
- package/dist/scripts/onboarding-tests.js +218 -0
- package/dist/scripts/opensky-check.js +1 -1
- package/dist/scripts/opensky-tests.js +1 -1
- package/dist/scripts/price-drift-tests.js +441 -0
- package/dist/scripts/price-drift-watch.js +491 -0
- package/dist/scripts/probe-poi-tests.js +1 -1
- package/dist/scripts/product-metrics.js +1 -1
- package/dist/scripts/publish-preverify.js +1 -1
- package/dist/scripts/realtime-pricing-tests.js +1 -1
- package/dist/scripts/replay-async.js +1 -1
- package/dist/scripts/replay-real.js +1 -1
- package/dist/scripts/replay.js +1 -1
- package/dist/scripts/session-attach-diagnose.js +1 -1
- package/dist/scripts/session-attach-poc.js +1 -1
- package/dist/scripts/session-benchmark.js +1 -1
- package/dist/scripts/session-extract-tests.js +1 -1
- package/dist/scripts/session-login.js +1 -1
- package/dist/scripts/session-tests.js +1 -1
- package/dist/scripts/skeleton-check.js +1 -1
- package/dist/scripts/skeleton-integration-test.js +1 -1
- package/dist/scripts/skills-contract-tests.js +1 -1
- package/dist/scripts/smoke.js +76 -1
- package/dist/scripts/state-cli-tests.js +1 -1
- package/dist/scripts/state-cli.js +1 -1
- package/dist/scripts/time-eval-tests.js +1 -1
- package/dist/scripts/travel-timeline-tests.js +1 -1
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/scripts/weather-tests.js +1 -1
- package/dist/scripts/wizard-bootstrap.js +32 -0
- package/dist/scripts/z3-race-tests.js +1 -1
- package/dist/src/artifact-gate.js +310 -0
- package/dist/src/bookable-facts.js +349 -0
- package/dist/src/booking-saga.js +1 -1
- package/dist/src/bridge.js +1 -1
- package/dist/src/companions.js +1 -1
- package/dist/src/contracts.js +1 -1
- package/dist/src/dsh-llm.js +1 -1
- package/dist/src/engine.js +1 -1
- package/dist/src/i18n.js +1 -1
- package/dist/src/index.js +189 -46
- package/dist/src/journey.js +1 -1
- package/dist/src/loop.js +1 -1
- package/dist/src/memory-capture.js +1 -1
- package/dist/src/memory-decay.js +1 -1
- package/dist/src/memory-utility.js +1 -1
- package/dist/src/mock-llm.js +1 -1
- package/dist/src/model.js +1 -1
- package/dist/src/realtime-pricing.js +14 -7
- package/dist/src/slot-spec.js +1 -1
- package/dist/src/state-ledger.js +1 -1
- package/dist/src/time-anchor.js +1 -1
- package/dist/src/tool-packet.js +1 -1
- package/dist/src/travel-slots.js +1 -1
- package/dist/src/travel-timeline.js +1 -1
- package/dist/src/unified.js +1 -1
- package/dist/src/wish-pool.js +1 -1
- package/dist/src/z3-shared.js +1 -1
- package/extension/README.md +26 -0
- package/extension/background.js +160 -0
- package/extension/content-bridge.js +41 -0
- package/extension/content-main.js +62 -0
- package/extension/manifest.json +32 -0
- package/package.json +6 -2
- package/ts/capabilities/fact-log.ts +54 -0
- package/ts/capabilities/hbcli.ts +46 -0
- package/ts/capabilities/session/benchmark.ts +4 -1
- package/ts/capabilities/session/extension-bridge.ts +354 -0
- package/ts/capabilities/session/extension-channel.ts +92 -0
- package/ts/capabilities/session/health-watch.ts +257 -0
- package/ts/capabilities/session/wizard.ts +282 -0
- package/ts/capabilities/session-login.ts +53 -2
- package/ts/capabilities/session-search.ts +68 -3
- package/ts/src/artifact-gate.ts +365 -0
- package/ts/src/bookable-facts.ts +557 -0
- package/ts/src/index.ts +102 -8
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话扩展桥(extension-bridge,RFC user-session-data-rfc.md §2.2 通道 C——2026-08-29 founder 定案为 PRIMARY 传输)。
|
|
3
|
+
*
|
|
4
|
+
* 形态:Node http 服务,懒启动单例,只绑 127.0.0.1,端口池 8791-8795(与 extension/manifest.json
|
|
5
|
+
* host_permissions 一一对应,防漂移测试守住);**零新依赖**(node:http 手写,对齐已退役 shell 薄壳
|
|
6
|
+
* 的回环纪律;不引 ws——MV3 Service Worker 靠 ≤20s 的长轮询节奏维持 30s 存活窗口,HTTP 够用)。
|
|
7
|
+
*
|
|
8
|
+
* 授权模型:扩展经「一次性安装 + manifest 固定 key(扩展 ID 跨机器稳定)」获得信任,
|
|
9
|
+
* 每请求校验 Origin === chrome-extension://<固定ID>(网页发起的跨域请求必带邪恶 Origin,直接 403;
|
|
10
|
+
* POST 端点强制;GET /health /status 是本机诊断面放行)。不做 CORS——有 host_permissions 的扩展
|
|
11
|
+
* fetch 天然免预检,网页过不来。
|
|
12
|
+
*
|
|
13
|
+
* 协议(长轮询):
|
|
14
|
+
* GET /health → lastSeen 刷新(扩展每 ≤30s 一次 + 每次断线重连)
|
|
15
|
+
* GET /status → 诊断面 {port, extensionConnected, lastSeenMsAgo}
|
|
16
|
+
* POST /jobs → 长轮询取活(hold ≤20s 返 {job:null});扩展单客户端
|
|
17
|
+
* POST /results/:jobId → 善果/失败回传(≤8MB;batchSearch ~550KB)
|
|
18
|
+
* 语义:桥不存在即整个扩展车道不存在——fail-closed 不变量在调用方(session-search 的 needs-extension)。
|
|
19
|
+
* 审计:job 提交/回传写 GuardAuditEntry 同款 JSONL(kind:'extension-session-job',channel 层负责)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
24
|
+
|
|
25
|
+
/** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
|
|
26
|
+
export const BRIDGE_PORTS = [8791, 8792, 8793, 8794, 8795] as const
|
|
27
|
+
export const BRIDGE_PROTOCOL = 'session-bridge.v1'
|
|
28
|
+
/** 扩展固定 ID(manifest key 派生;变更即失配,Node 侧拒绝) */
|
|
29
|
+
export const EXTENSION_ID = 'olpgkofjhhiiiahdkkbcninhjmegghfe'
|
|
30
|
+
export const EXTENSION_ORIGIN = `chrome-extension://${EXTENSION_ID}`
|
|
31
|
+
/** 扩展在线判定:/health 心跳 ≤30s 一次 + 轮询重连,1.5 倍容差 */
|
|
32
|
+
export const EXTENSION_CONNECTED_WINDOW_MS = 45_000
|
|
33
|
+
/** /jobs 长轮询 hold 上限(必须 < MV3 SW 30s 存活窗口) */
|
|
34
|
+
export const JOBS_LONG_POLL_MS = 20_000
|
|
35
|
+
/** 提交 job 时扩展尚未上线,等它连上桥的宽限(首次调用后桥才拉起,扩展 5s 内即回连) */
|
|
36
|
+
export const DEFAULT_EXTENSION_WAIT_MS = 6_000
|
|
37
|
+
/** /results 回包上限(batchSearch ~550KB,余量给未来酒店/多航段) */
|
|
38
|
+
export const MAX_RESULT_BODY_BYTES = 8 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
export type ExtensionJobKind = 'search' | 'open-login' | 'cookie-names'
|
|
41
|
+
|
|
42
|
+
export interface ExtensionJob {
|
|
43
|
+
jobId: string
|
|
44
|
+
kind: ExtensionJobKind
|
|
45
|
+
site: string
|
|
46
|
+
/** search / open-login:置顶导航地址(白名单域在扩展侧再校验一道) */
|
|
47
|
+
url?: string
|
|
48
|
+
/** search:等嗅探回包上限 */
|
|
49
|
+
timeoutMs?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ExtensionJobResult {
|
|
53
|
+
ok: boolean
|
|
54
|
+
kind?: ExtensionJobKind
|
|
55
|
+
/** cookie-names:命中票据名(名字级——协议面不存在 cookie 值字段) */
|
|
56
|
+
names?: string[]
|
|
57
|
+
/** search:NETWORK_HINTS 命中的响应原文 */
|
|
58
|
+
body?: string
|
|
59
|
+
title?: string
|
|
60
|
+
opened?: boolean
|
|
61
|
+
timeout?: boolean
|
|
62
|
+
error?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SubmitOutcome =
|
|
66
|
+
| { ok: true; result: ExtensionJobResult }
|
|
67
|
+
| { ok: false; reason: 'bridge-unavailable' | 'extension-not-connected' | 'timeout'; summary: string }
|
|
68
|
+
|
|
69
|
+
export interface SessionJobHandle {
|
|
70
|
+
submit(job: Omit<ExtensionJob, 'jobId'>, opts?: { timeoutMs?: number; extensionWaitMs?: number }): Promise<SubmitOutcome>
|
|
71
|
+
extensionConnected(): boolean
|
|
72
|
+
port: number
|
|
73
|
+
close(): Promise<void>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SessionBridgeOptions {
|
|
77
|
+
/** 测试用 [0] 让 OS 分配;缺省 8791..8795 端口池 */
|
|
78
|
+
ports?: number[]
|
|
79
|
+
extensionOrigin?: string
|
|
80
|
+
now?: () => number
|
|
81
|
+
/**
|
|
82
|
+
* **保持桥进程钉住事件循环**(不 unref)。wizard 期间需要:扩展 SW 周期心跳
|
|
83
|
+
* ≤30s,unref 后 wizard main 一退桥就退,扩展再发心跳连不上 → 误判扩展未就绪。
|
|
84
|
+
* 真实 session 检索走单 job 流程,默认 unref=true(任何跑完主流程的宿主进程能退出)。
|
|
85
|
+
* run-all §40 onboarding-tests 用 keepBridge=true 验证 wizard 探活;
|
|
86
|
+
* §38 session 套件走默认 unref,语义不变。
|
|
87
|
+
*/
|
|
88
|
+
keepBridge?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface QueuedJob {
|
|
92
|
+
job: ExtensionJob
|
|
93
|
+
resolve: (outcome: SubmitOutcome) => void
|
|
94
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readBody(req: IncomingMessage, cap: number): Promise<string | { err: string }> {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const chunks: Buffer[] = []
|
|
100
|
+
let size = 0
|
|
101
|
+
let done = false
|
|
102
|
+
req.on('data', (c: Buffer) => {
|
|
103
|
+
if (done) return
|
|
104
|
+
size += c.length
|
|
105
|
+
if (size > cap) {
|
|
106
|
+
done = true
|
|
107
|
+
req.removeAllListeners('data')
|
|
108
|
+
resolve({ err: `body 超 ${cap} 字节上限(防失控回包)` })
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
chunks.push(c)
|
|
112
|
+
})
|
|
113
|
+
req.on('end', () => { if (!done) { done = true; resolve(Buffer.concat(chunks).toString('utf8')) } })
|
|
114
|
+
req.on('error', () => { if (!done) { done = true; resolve({ err: 'body 读取失败' }) } })
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 创 Bridge 服务(测试可直接调;生产走 getOrCreateSessionBridge 懒单例) */
|
|
119
|
+
export async function createSessionBridge(opts: SessionBridgeOptions = {}): Promise<{ ok: true; bridge: SessionJobHandle } | { ok: false; summary: string }> {
|
|
120
|
+
const ports = opts.ports ?? [...BRIDGE_PORTS]
|
|
121
|
+
const extensionOrigin = opts.extensionOrigin ?? EXTENSION_ORIGIN
|
|
122
|
+
const now = opts.now ?? Date.now
|
|
123
|
+
const queue: QueuedJob[] = []
|
|
124
|
+
const inFlight = new Map<string, QueuedJob>()
|
|
125
|
+
const parked: Array<{ res: ServerResponse; timer: ReturnType<typeof setTimeout> }> = []
|
|
126
|
+
let lastSeenAt = 0
|
|
127
|
+
let closed = false
|
|
128
|
+
let port = 0
|
|
129
|
+
const extensionConnected = (): boolean => lastSeenAt > 0 && now() - lastSeenAt < EXTENSION_CONNECTED_WINDOW_MS
|
|
130
|
+
|
|
131
|
+
/** 领走队首 job(移入 inFlight——回包按 jobId 路由,queued 与 inFlight 两处都可找到) */
|
|
132
|
+
function takeQueued(): QueuedJob | null {
|
|
133
|
+
const next = queue.shift()
|
|
134
|
+
if (next) inFlight.set(next.job.jobId, next)
|
|
135
|
+
return next ?? null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const server = createServer((req, res) => {
|
|
139
|
+
if (closed) { res.statusCode = 503; res.end(); return }
|
|
140
|
+
const urlPath = (req.url ?? '').split('?')[0]
|
|
141
|
+
const origin = req.headers.origin
|
|
142
|
+
const isPost = req.method === 'POST'
|
|
143
|
+
// 网页侧请求必带 Origin;桥端点只信任本扩展的 chrome-extension:// 源(诊断 GET 面放行)
|
|
144
|
+
if (isPost && origin !== extensionOrigin) {
|
|
145
|
+
res.statusCode = 403
|
|
146
|
+
res.setHeader('content-type', 'application/json')
|
|
147
|
+
res.end(JSON.stringify({ ok: false, error: 'origin 不在桥白名单' }))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
// 心跳:扩展平时只走 /jobs 长轮询(健康只在启动探测时 ping 一次)——
|
|
151
|
+
// 一切携带本扩展 Origin 的请求都刷新 lastSeen,否则 45s 后误判扩展掉线;
|
|
152
|
+
// 无 Origin 的诊断 GET(/health /status)不记心跳,curl 探活不能伪造「扩展在线」
|
|
153
|
+
if (origin === extensionOrigin) {
|
|
154
|
+
lastSeenAt = now()
|
|
155
|
+
}
|
|
156
|
+
const finish = (code: number, body: unknown): void => {
|
|
157
|
+
res.statusCode = code
|
|
158
|
+
res.setHeader('content-type', 'application/json')
|
|
159
|
+
res.end(JSON.stringify(body))
|
|
160
|
+
}
|
|
161
|
+
if (req.method === 'GET' && urlPath === '/health') {
|
|
162
|
+
finish(200, { ok: true, service: 'gotry-session-bridge', protocol: BRIDGE_PROTOCOL })
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (req.method === 'GET' && urlPath === '/status') {
|
|
166
|
+
finish(200, {
|
|
167
|
+
ok: true, port, protocol: BRIDGE_PROTOCOL,
|
|
168
|
+
extensionConnected: extensionConnected(),
|
|
169
|
+
lastSeenMsAgo: lastSeenAt > 0 ? now() - lastSeenAt : null,
|
|
170
|
+
queued: queue.length,
|
|
171
|
+
inFlight: inFlight.size,
|
|
172
|
+
})
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (isPost && urlPath === '/jobs') {
|
|
176
|
+
const next = takeQueued()
|
|
177
|
+
if (next) { finish(200, { job: next.job }); return }
|
|
178
|
+
// 长轮询:hold ≤ JOBS_LONG_POLL_MS(必须 < MV3 SW 30s 存活窗口,每次响应都续命);
|
|
179
|
+
// 新 job 提交时即时唤醒 parked 取活者(见 submit→dispatchToParked)
|
|
180
|
+
const parkTimer = setTimeout(() => {
|
|
181
|
+
const i = parked.findIndex((p) => p.res === res)
|
|
182
|
+
if (i >= 0) parked.splice(i, 1)
|
|
183
|
+
finish(200, { job: null })
|
|
184
|
+
}, JOBS_LONG_POLL_MS)
|
|
185
|
+
parked.push({ res, timer: parkTimer })
|
|
186
|
+
res.on('close', () => {
|
|
187
|
+
const i = parked.findIndex((p) => p.res === res)
|
|
188
|
+
if (i >= 0) parked.splice(i, 1)
|
|
189
|
+
clearTimeout(parkTimer)
|
|
190
|
+
})
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (isPost && urlPath.startsWith('/results/')) {
|
|
194
|
+
const jobId = decodeURIComponent(urlPath.slice('/results/'.length))
|
|
195
|
+
void readBody(req, MAX_RESULT_BODY_BYTES).then((body) => {
|
|
196
|
+
if (typeof body !== 'string') { finish(400, { ok: false, error: body.err }); return }
|
|
197
|
+
let parsed: ExtensionJobResult
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(body) as ExtensionJobResult
|
|
200
|
+
} catch {
|
|
201
|
+
finish(400, { ok: false, error: '回包不是 JSON' })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
finish(200, { ok: true })
|
|
205
|
+
resolveJob(jobId, parsed)
|
|
206
|
+
})
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
finish(404, { ok: false, error: 'unknown endpoint' })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
/** 扩展回包:queued 与 inFlight 两处都可寻址(job 执行失败 = result.ok:false,桥自身不吞) */
|
|
213
|
+
function resolveJob(jobId: string, parsed: ExtensionJobResult): void {
|
|
214
|
+
const inFlightHit = inFlight.get(jobId)
|
|
215
|
+
if (inFlightHit) {
|
|
216
|
+
inFlight.delete(jobId)
|
|
217
|
+
if (inFlightHit.timer) clearTimeout(inFlightHit.timer)
|
|
218
|
+
inFlightHit.resolve({ ok: true, result: parsed })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
const idx = queue.findIndex((q) => q.job.jobId === jobId)
|
|
222
|
+
if (idx < 0) return
|
|
223
|
+
const [entry] = queue.splice(idx, 1)
|
|
224
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
225
|
+
entry.resolve({ ok: true, result: parsed })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 新 job 入队时即时派发给 parked 取活者(否则要白等一个 20s 长轮询周期) */
|
|
229
|
+
function dispatchToParked(entry: QueuedJob): boolean {
|
|
230
|
+
const p = parked.shift()
|
|
231
|
+
if (!p) return false
|
|
232
|
+
clearTimeout(p.timer)
|
|
233
|
+
inFlight.set(entry.job.jobId, entry)
|
|
234
|
+
p.res.statusCode = 200
|
|
235
|
+
p.res.setHeader('content-type', 'application/json')
|
|
236
|
+
p.res.end(JSON.stringify({ job: entry.job }))
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const bridge: SessionJobHandle = {
|
|
241
|
+
port: 0,
|
|
242
|
+
extensionConnected,
|
|
243
|
+
submit(job, submitOpts = {}) {
|
|
244
|
+
const extensionWaitMs = submitOpts.extensionWaitMs ?? DEFAULT_EXTENSION_WAIT_MS
|
|
245
|
+
const timeoutMs = submitOpts.timeoutMs ?? job.timeoutMs ?? 30_000
|
|
246
|
+
const full: ExtensionJob = { jobId: randomUUID(), ...job }
|
|
247
|
+
return new Promise<SubmitOutcome>((resolve) => {
|
|
248
|
+
if (closed) { resolve({ ok: false, reason: 'bridge-unavailable', summary: '扩展桥已关闭' }); return }
|
|
249
|
+
const entry = { job: full, resolve: null as unknown as (o: SubmitOutcome) => void, timer: null as ReturnType<typeof setTimeout> | null }
|
|
250
|
+
const settle = (outcome: SubmitOutcome): void => {
|
|
251
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
252
|
+
const i = queue.findIndex((q) => q.job.jobId === full.jobId)
|
|
253
|
+
if (i >= 0) queue.splice(i, 1)
|
|
254
|
+
inFlight.delete(full.jobId)
|
|
255
|
+
resolve(outcome)
|
|
256
|
+
}
|
|
257
|
+
entry.resolve = settle
|
|
258
|
+
entry.timer = setTimeout(() => {
|
|
259
|
+
settle({ ok: false, reason: 'timeout', summary: `扩展未在 ${timeoutMs}ms 内回包(标签页无嗅探命中或扩展已停用)` })
|
|
260
|
+
}, timeoutMs)
|
|
261
|
+
if (!dispatchToParked(entry)) queue.push(entry)
|
|
262
|
+
// 扩展未上线:有界等待(桥刚拉起时扩展 ≤5s 即连上);宽限过仍无 → 立即 no-spend 失败,不空耗
|
|
263
|
+
let waited = 0
|
|
264
|
+
const waitTimer = setInterval(() => {
|
|
265
|
+
waited += 250
|
|
266
|
+
if (bridge.extensionConnected()) { clearInterval(waitTimer); return }
|
|
267
|
+
if (waited >= extensionWaitMs) {
|
|
268
|
+
clearInterval(waitTimer)
|
|
269
|
+
settle({
|
|
270
|
+
ok: false,
|
|
271
|
+
reason: 'extension-not-connected',
|
|
272
|
+
summary: 'GoTry Session Bridge 扩展未连接(未安装或已停用)。一次性安装:npx gotry setup 查看指引 → chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension',
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}, 250)
|
|
276
|
+
})
|
|
277
|
+
},
|
|
278
|
+
close: () =>
|
|
279
|
+
new Promise<void>((resolve) => {
|
|
280
|
+
closed = true
|
|
281
|
+
const voided: SubmitOutcome = { ok: false, reason: 'bridge-unavailable', summary: '扩展桥关闭,任务作废(零花费)' }
|
|
282
|
+
for (const q of queue.splice(0)) {
|
|
283
|
+
if (q.timer) clearTimeout(q.timer)
|
|
284
|
+
q.resolve(voided)
|
|
285
|
+
}
|
|
286
|
+
for (const [, q] of inFlight) {
|
|
287
|
+
if (q.timer) clearTimeout(q.timer)
|
|
288
|
+
q.resolve(voided)
|
|
289
|
+
}
|
|
290
|
+
inFlight.clear()
|
|
291
|
+
for (const p of parked.splice(0)) {
|
|
292
|
+
clearTimeout(p.timer)
|
|
293
|
+
p.res.destroy()
|
|
294
|
+
}
|
|
295
|
+
server.close(() => resolve())
|
|
296
|
+
server.closeAllConnections?.()
|
|
297
|
+
}),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const listenAt = (p: number): Promise<void> =>
|
|
301
|
+
new Promise((resolve, reject) => {
|
|
302
|
+
const onError = (e: Error): void => { server.off('listening', onListening); reject(e) }
|
|
303
|
+
const onListening = (): void => { server.off('error', onError); resolve() }
|
|
304
|
+
server.once('error', onError)
|
|
305
|
+
server.once('listening', onListening)
|
|
306
|
+
server.listen(p, '127.0.0.1')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
for (const p of ports) {
|
|
310
|
+
try {
|
|
311
|
+
await listenAt(p)
|
|
312
|
+
if (!opts.keepBridge) {
|
|
313
|
+
// 桥是后台服务:不得钉住事件循环——否则任何跑完主流程的宿主进程(如 smoke/CI)无法退出
|
|
314
|
+
server.unref()
|
|
315
|
+
}
|
|
316
|
+
// keepBridge 模式下:wizard 期间要持续监听扩展心跳(30s 周期),
|
|
317
|
+
// 不 unref 让 server 钉事件循环,wizard main 退时再显式 close 释放。
|
|
318
|
+
port = (server.address() as { port: number }).port
|
|
319
|
+
bridge.port = port
|
|
320
|
+
return { ok: true, bridge }
|
|
321
|
+
} catch {
|
|
322
|
+
// 端口被占(如另一个 gotry 实例)——试端口池下一个
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
summary: `扩展桥端口池 ${ports.join('/')} 全部被占(多为并行 gotry 实例);不影响官方通道 gotry_flyai_search。关闭多余实例后重试。`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ---------- 生产懒单例(进程内随 dsh 存活;失败可重试) ---------- */
|
|
332
|
+
|
|
333
|
+
let singleton: { ok: true; bridge: SessionJobHandle } | { ok: false; summary: string } | null = null
|
|
334
|
+
|
|
335
|
+
export async function getOrCreateSessionBridge(opts: SessionBridgeOptions = {}): Promise<{ ok: true; bridge: SessionJobHandle } | { ok: false; summary: string }> {
|
|
336
|
+
if (singleton) return singleton
|
|
337
|
+
const created = await createSessionBridge(opts)
|
|
338
|
+
const result = created.ok
|
|
339
|
+
? ({ ok: true, bridge: created.bridge } as { ok: true; bridge: SessionJobHandle })
|
|
340
|
+
: ({ ok: false, summary: created.summary } as { ok: false; summary: string })
|
|
341
|
+
singleton = result
|
|
342
|
+
return result
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 测试隔离:丢弃单例(端口是进程内资源,close 后端口池立即可复用) */
|
|
346
|
+
export async function __resetSessionBridgeForTest(): Promise<void> {
|
|
347
|
+
if (singleton?.ok) await singleton.bridge.close().catch(() => { /* ignore */ })
|
|
348
|
+
singleton = null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 测试注入:以临时端口的桥替换单例(channel 层测试不碰真实端口池) */
|
|
352
|
+
export function __setSessionBridgeForTest(bridge: SessionJobHandle | null): void {
|
|
353
|
+
singleton = bridge ? { ok: true, bridge } : null
|
|
354
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展车道客户端(RFC §2.2 通道 C,2026-08-29 PRIMARY;issue #21 传输层方案 B)。
|
|
3
|
+
*
|
|
4
|
+
* 三种 job 封装:search(被动嗅探 batchSearch)/ open-login(登录入口置前台)/
|
|
5
|
+
* cookie-names(票据 cookie **名字**,协议面不存在值字段)。verdict 映射纯函数
|
|
6
|
+
* `classifyBridgeFailure` 供回归覆盖:
|
|
7
|
+
* - extension-not-connected → 'needs-extension'(waiting no-spend,一次性安装指引;
|
|
8
|
+
* **不静默回退 CDP**——回退即重新引入逐连接权限框,正是本通道要消灭的摩擦);
|
|
9
|
+
* - bridge-unavailable(端口池全占等环境故障)→ 'error'(非用户门)。
|
|
10
|
+
* 检索页无回包不是车道失败:与 CDP 车道同语义(到点无 hints 命中 ⇒ miss,页标题命挑战 ⇒ challenged)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getOrCreateSessionBridge, type SessionJobHandle } from './extension-bridge.ts'
|
|
14
|
+
|
|
15
|
+
/** 一次性用户门文案(needs-extension 统一指引,与 bootstrap setupExtension 输出同口径) */
|
|
16
|
+
export const NEEDS_EXTENSION_HINT =
|
|
17
|
+
'需要一次性安装 GoTry Session Bridge 浏览器扩展(约 30 秒,装完零弹窗):跑 npx gotry setup 拿指引,'
|
|
18
|
+
+ '或在 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension'
|
|
19
|
+
|
|
20
|
+
export type BridgeFailureKind = 'bridge-unavailable' | 'extension-not-connected' | 'timeout' | 'job-error'
|
|
21
|
+
|
|
22
|
+
export interface BridgeFailure {
|
|
23
|
+
ok: false
|
|
24
|
+
kind: BridgeFailureKind
|
|
25
|
+
summary: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 桥故障 → 用户门 verdict 纯函数(测试锚点):只有「扩展未连接」是用户门(waiting),其余降级 error */
|
|
29
|
+
export function classifyBridgeFailure(kind: BridgeFailureKind): 'needs-extension' | 'error' {
|
|
30
|
+
return kind === 'extension-not-connected' ? 'needs-extension' : 'error'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionJobHandleRef {
|
|
34
|
+
ok: true
|
|
35
|
+
bridge: SessionJobHandle
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function bridgeReady(b: { ok: true; bridge: SessionJobHandle } | { ok: false; summary: string }): b is SessionJobHandleRef {
|
|
39
|
+
return b.ok === true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** cookie-names job:票据 cookie 名存在性(名字级;协议不含值) */
|
|
43
|
+
export async function extensionCookieNames(q: { site: string; domain: string; ticketNames: string[]; timeoutMs?: number }): Promise<{ ok: true; tickets: string[] } | BridgeFailure> {
|
|
44
|
+
const bridge = await getOrCreateSessionBridge()
|
|
45
|
+
if (!bridgeReady(bridge)) return { ok: false, kind: 'bridge-unavailable', summary: bridge.summary }
|
|
46
|
+
const outcome = await bridge.bridge.submit(
|
|
47
|
+
{ kind: 'cookie-names', site: q.site, timeoutMs: q.timeoutMs ?? 8_000 },
|
|
48
|
+
{ timeoutMs: q.timeoutMs ?? 8_000 },
|
|
49
|
+
)
|
|
50
|
+
if (!outcome.ok) return { ok: false, kind: outcome.reason, summary: outcome.summary }
|
|
51
|
+
const names = Array.isArray(outcome.result.names) ? outcome.result.names.filter((n): n is string => typeof n === 'string') : []
|
|
52
|
+
// 红线自证:隧道面只有名字(extension-tests §红线断言协议面无值字段)
|
|
53
|
+
return { ok: true, tickets: names }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 登录入口置前台打开(#34 纪律:标签留给用户,扩展侧不 close) */
|
|
57
|
+
export async function extensionOpenLogin(q: { site: string; url: string; timeoutMs?: number }): Promise<{ ok: true; opened: boolean } | BridgeFailure> {
|
|
58
|
+
const bridge = await getOrCreateSessionBridge()
|
|
59
|
+
if (!bridgeReady(bridge)) return { ok: false, kind: 'bridge-unavailable', summary: bridge.summary }
|
|
60
|
+
const outcome = await bridge.bridge.submit({ kind: 'open-login', site: q.site, url: q.url }, { timeoutMs: q.timeoutMs ?? 15_000 })
|
|
61
|
+
if (!outcome.ok) return { ok: false, kind: outcome.reason, summary: outcome.summary }
|
|
62
|
+
if (!outcome.result.opened) return { ok: false, kind: 'job-error', summary: outcome.result.error ?? '登录入口打开失败' }
|
|
63
|
+
return { ok: true, opened: true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ExtensionSearchOutcome {
|
|
67
|
+
ok: true
|
|
68
|
+
/** NETWORK_HINTS 命中的响应原文;未命中/超时为空 */
|
|
69
|
+
body: string
|
|
70
|
+
/** 页标题(challenged 判定素材) */
|
|
71
|
+
title: string
|
|
72
|
+
/** 到点未见 hints 命中(站点无回包/风控拦截页) */
|
|
73
|
+
timedOut: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 检索 job:后台标签打开 entry,等 content hook 的嗅探回包;页无响应=timedOut(非车道失败) */
|
|
77
|
+
export async function extensionSearchJob(q: { site: string; url: string; timeoutMs?: number }): Promise<ExtensionSearchOutcome | BridgeFailure> {
|
|
78
|
+
const bridge = await getOrCreateSessionBridge()
|
|
79
|
+
if (!bridgeReady(bridge)) return { ok: false, kind: 'bridge-unavailable', summary: bridge.summary }
|
|
80
|
+
const timeoutMs = q.timeoutMs ?? 30_000
|
|
81
|
+
const outcome = await bridge.bridge.submit(
|
|
82
|
+
{ kind: 'search', site: q.site, url: q.url, timeoutMs },
|
|
83
|
+
{ timeoutMs: timeoutMs + 10_000 },
|
|
84
|
+
)
|
|
85
|
+
if (!outcome.ok) return { ok: false, kind: outcome.reason, summary: outcome.summary }
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
body: typeof outcome.result.body === 'string' ? outcome.result.body : '',
|
|
89
|
+
title: typeof outcome.result.title === 'string' ? outcome.result.title : '',
|
|
90
|
+
timedOut: outcome.result.timeout === true,
|
|
91
|
+
}
|
|
92
|
+
}
|