@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.32

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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +119 -136
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-hbcli-release.d.ts +3 -0
  9. package/bin/gotry-hbcli-release.js +4 -0
  10. package/bin/gotry-inner.js +2 -1
  11. package/client/client.js +228 -0
  12. package/dist/capabilities/doctor.js +14 -23
  13. package/dist/capabilities/effect.js +45 -12
  14. package/dist/capabilities/flyai-config.js +12 -10
  15. package/dist/capabilities/flyai.js +87 -56
  16. package/dist/capabilities/hbcli-config.js +156 -0
  17. package/dist/capabilities/hbcli.js +29 -2
  18. package/dist/capabilities/resilience.js +6 -6
  19. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  20. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  21. package/dist/capabilities/session/ctrip-city.js +186 -0
  22. package/dist/capabilities/session/extension-bridge.js +8 -1
  23. package/dist/capabilities/session/gui-flight.js +737 -0
  24. package/dist/capabilities/session/gui-hotel.js +413 -0
  25. package/dist/capabilities/session-search.js +98 -11
  26. package/dist/scripts/artifact-client-contract-tests.js +4 -3
  27. package/dist/scripts/artifact-delivery-tests.js +31 -0
  28. package/dist/scripts/bootstrap-tests.js +37 -10
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/doctor-tests.js +36 -7
  31. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  32. package/dist/scripts/effect-tests.js +602 -504
  33. package/dist/scripts/extension-tests.js +14 -8
  34. package/dist/scripts/flight-malformed-tests.js +1 -1
  35. package/dist/scripts/flyai-setup-tests.js +93 -5
  36. package/dist/scripts/flyai-tests.js +768 -653
  37. package/dist/scripts/gotry-web-api-tests.js +119 -0
  38. package/dist/scripts/gotry-web-client-tests.js +47 -0
  39. package/dist/scripts/gui-flight-live.js +27 -0
  40. package/dist/scripts/hbcli-tests.js +1 -1
  41. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  42. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  43. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  44. package/dist/scripts/hotel-result-tests.js +169 -0
  45. package/dist/scripts/issue-271-liveness-tests.js +3 -1
  46. package/dist/scripts/session-tests.js +177 -6
  47. package/dist/src/artifact-delivery.js +26 -0
  48. package/dist/src/gotry-web-api.js +219 -0
  49. package/dist/src/index.js +45 -17
  50. package/extension/manifest.json +2 -2
  51. package/package.json +8 -2
  52. package/ts/capabilities/flyai-config.ts +12 -10
  53. package/ts/capabilities/flyai.ts +91 -50
  54. package/ts/capabilities/hbcli-config.ts +128 -0
  55. package/ts/capabilities/hbcli.ts +30 -3
  56. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  57. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  58. package/ts/capabilities/session/ctrip-city.ts +158 -0
  59. package/ts/capabilities/session/extension-bridge.ts +9 -2
  60. package/ts/capabilities/session/gui-flight.ts +792 -0
  61. package/ts/capabilities/session/gui-hotel.ts +451 -0
  62. package/ts/capabilities/session-search.ts +115 -15
  63. package/ts/src/artifact-delivery.ts +25 -0
  64. package/ts/src/gotry-web-api.ts +189 -0
  65. package/ts/src/index.ts +46 -18
@@ -20,7 +20,9 @@ import { buildEntryUrl, parseBatchSearch, parseBatchSearchResult } from '../capa
20
20
  import { buildHotelEntryUrl, parseCtripHotelList, looksLikeHotelListBody } from '../capabilities/session/adapters/ctrip-hotel.js';
21
21
  import { buildTrainEntryUrl, hasRecognizedAvailableSeat, parseLeftTicketQuery, parseLeftTicketQueryResult, resolveTrainQueryTelecodes, STATION_TELECODES, validateTrainQueryResponseUrl } from '../capabilities/session/adapters/rail-12306.js';
22
22
  import { buildDidaEntryUrl, parseDidaRates, parseDidaSearchCache, looksLikeDidaRatesBody } from '../capabilities/session/adapters/dida-portal.js';
23
- import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
23
+ import { sessionFlightSearch, sessionFlightGuiSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, flightCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
24
+ import { planFlightGuiActions, assertGuiPlanSafe, runFlightGuiSearch, parseLowestPriceCalendar, __setGuiDriverForTest } from '../capabilities/session/gui-flight.js';
25
+ import { planHotelGuiActions, assertHotelPlanSafe, runHotelGuiSearch, __setHotelDriverForTest } from '../capabilities/session/gui-hotel.js';
24
26
  import { flyaiSearch } from '../capabilities/flyai.js';
25
27
  import { createConsentGate } from '../capabilities/session-consent.js';
26
28
  import { sessionLogin, pollTicketNames, LOGIN_TARGETS } from '../capabilities/session-login.js';
@@ -145,6 +147,16 @@ assert(e1.ok === true && e1.url === 'https://flights.ctrip.com/online/list/onewa
145
147
  const e2 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01');
146
148
  assert(e2.ok === false && e2.unresolved?.[0] === '乌兰巴托', '词表外城市 unresolved 逐字保留(不猜)');
147
149
  assert(buildEntryUrl('上海', '丽江', '十月一号').ok === false, '非法日期拒绝');
150
+ const internationalEntry = buildEntryUrl('迪拜', '香港', '2026-12-01', { fromCode: 'DXB', toCode: 'HKG' });
151
+ assert(internationalEntry.ok && internationalEntry.url?.includes('oneway-dxb-hkg') === true, '显式供应商城市码不受内置表限制');
152
+ assert(!buildEntryUrl('迪拜', '香港', '2026-12-01', { fromCode: '../dxb', toCode: 'HKG' }).ok, '显式城市码拒绝 URL 注入');
153
+ // 实测校准抽查(2026-10-14 list 页实证:合法组合 200+title 航线名,非法组合 302)
154
+ const e3 = buildEntryUrl('香港', '迪拜', '2026-12-06');
155
+ assert(e3.ok === true && e3.url === 'https://flights.ctrip.com/online/list/oneway-hkg-dxb?depdate=2026-12-06', '香港/迪拜=实测校准入缓存表(hkg/dxb)', e3);
156
+ const e4 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01', { fromCode: 'ubn' });
157
+ assert(e4.ok === true && e4.url === 'https://flights.ctrip.com/online/list/oneway-ubn-ljg?depdate=2026-10-01', '显式三字码逃生口(形状合格即成行)', e4);
158
+ assert(buildEntryUrl('乌兰巴托', '丽江', '2026-10-01', { fromCode: 'UBN!' }).ok === false, '显式码形状不合格不得进 URL');
159
+ assert(flightCityUnresolvedHint(['乌兰巴托']).includes('fromCityCode'), '机票 unresolved 指引带显式码逃生口');
148
160
  // D. DOM 提交件过滤
149
161
  console.log('D. isSubmitText(DOM 提交件黑名单)');
150
162
  assert(isSubmitText('立即下单') && isSubmitText('去支付') && isSubmitText('提交订单'), '下单/支付/提交 命中');
@@ -153,9 +165,9 @@ assert(!isSubmitText('搜索') && !isSubmitText('筛选'), '搜索/筛选 不误
153
165
  console.log('E. 节律闸(≥30s 间隔)');
154
166
  __resetRateLimiterForTest();
155
167
  const r1 = await sessionFlightSearch({ from: '乌兰巴托', to: '丽江', date: '2026-10-01' });
156
- const r2 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01' });
168
+ const r2 = await sessionFlightSearch({ from: '另一个未知城市', to: '丽江', date: '2026-10-01' });
157
169
  assert(r1.verdict === 'error' && /unresolved/.test(r1.error ?? ''), '首次调用:词表外 → error(unresolved)');
158
- assert(r2.verdict === 'cooldown', '30s 内二次调用 → cooldown,不发起导航');
170
+ assert(r2.verdict === 'error', '城市解析失败不占用库存检索节律额度,不误报 cooldown');
159
171
  __resetRateLimiterForTest();
160
172
  // F. transport 失败分类 + live FlyAI 官方通道
161
173
  console.log('F. transport verdict + flyaiSearch(live,飞猪官方,无 key;GOTRY_SESSION_LIVE=0 跳过)');
@@ -167,7 +179,10 @@ if (process.env.GOTRY_SESSION_LIVE === '0') {
167
179
  console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 外部实时端点)');
168
180
  }
169
181
  else {
170
- fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' });
182
+ // 滚动未来日期(硬编码日期跨日假红的教训:过去日期会被上游「出发日期非法」业务失败拒绝)
183
+ const roll = new Date(Date.now() + 30 * 24 * 3600 * 1000);
184
+ const rollingDepDate = `${roll.getFullYear()}-${String(roll.getMonth() + 1).padStart(2, '0')}-${String(roll.getDate()).padStart(2, '0')}`;
185
+ fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: rollingDepDate });
171
186
  const sentinelBlocked = fr.verdict === 'error' && /sentinel|block|trial limit/i.test(fr.error ?? '');
172
187
  if (sentinelBlocked) {
173
188
  console.log(' WARN - 飞猪上游限流(Sentinel 2026-08-28 / trial-limit 429 2026-08-31 实测,配额未文档化)——降级合同验证通过,跳过 hit 断言');
@@ -540,8 +555,8 @@ console.log('K. 酒店适配器(buildHotelEntryUrl/走形解析/形状嗅探/城
540
555
  __resetRateLimiterForTest();
541
556
  const e1 = await sessionHotelSearch({ to: '不在码表的城市' });
542
557
  assert(e1.ok === false && e1.verdict === 'error' && /city=/.test(e1.error ?? ''), '未收录城市 → error + cityId 指引', e1);
543
- const e2 = await sessionHotelSearch({ to: '上海' });
544
- assert(e2.ok === false && e2.verdict === 'cooldown', '节律闸:同站点 30s 内第二调 → cooldown(不发起导航)', e2);
558
+ const e2 = await sessionHotelSearch({ to: '另一个未知酒店城市' });
559
+ assert(e2.ok === false && e2.verdict === 'error', '酒店城市解析失败不占用库存节律额度', e2);
545
560
  }
546
561
  // L. 火车会话面(2026-09-03 实装,12306 公开查询面)纯函数:entry URL/管道行解析/电报码指引/闸面
547
562
  console.log('L. 火车适配器(buildTrainEntryUrl/parseLeftTicketQuery/电报码指引/节律闸)');
@@ -854,6 +869,162 @@ console.log('M. Dida 门户(entry 守域 + 信封走形 + 闸面)');
854
869
  assert(q2.ok === false && q2.verdict === 'cooldown', '节律闸:30s 内二调 → cooldown', q2);
855
870
  assert(typeof didaLoginHint() === 'string' && /自动登录/.test(didaLoginHint()), '登录指引指向 hotel-be portal 跳板(账密永不经 gotry)');
856
871
  }
872
+ // L. 携程机票 GUI 车道(2026-10-14):规划器/安全白名单/假 driver 全流程/readback 硬闸
873
+ {
874
+ console.log('L. GUI 车道(规划器 + 安全闸 + 假 driver)');
875
+ const planOw = planFlightGuiActions({ from: '上海', to: '丽江', date: '2027-05-02' });
876
+ assert(planOw.some(a => a.t === 'clickTripTab' && a.trip === 'oneway') && !planOw.some(a => a.t === 'setDate' && a.which === 'back'), '单程规划:显式点单程 tab(页面默认往返态)/无回程日期');
877
+ const planRt = planFlightGuiActions({ from: '香港', to: '迪拜', date: '2026-12-06', backDate: '2026-12-13' });
878
+ assert(planRt.some(a => a.t === 'clickTripTab' && a.trip === 'roundtrip') && planRt.some(a => a.t === 'setDate' && a.which === 'back'), '往返规划:往返 tab + 回程日期(不知 URL 形状也能搜)');
879
+ const rb = planRt.find(a => a.t === 'readback');
880
+ assert(!!rb && rb.t === 'readback' && rb.expect.back === '2026-12-13', 'readback 期望含回程日期');
881
+ // 安全白名单:闭集外/提交件文本/非固定入口/坏日期/坏按键全拒
882
+ assert(!assertGuiPlanSafe([{ t: 'goto', url: 'https://evil.example.com/' }]).ok, '安全闸:非固定入口 URL 拒绝');
883
+ assert(!assertGuiPlanSafe([{ t: 'typeCity', which: 'from', text: '立即下单' }]).ok, '安全闸:城市位提交件文本拒绝(写面红线)');
884
+ assert(!assertGuiPlanSafe([{ t: 'setDate', which: 'dep', ymd: '十二月六号' }]).ok, '安全闸:非 YMD 日期拒绝');
885
+ assert(!assertGuiPlanSafe([{ t: 'press', key: 'Meta' }]).ok, '安全闸:按键白名单外拒绝');
886
+ assert(assertGuiPlanSafe(planRt).ok, '合法规划全过');
887
+ // 假 driver:表单内存模型 + 可编程回包
888
+ const FIXTURE = JSON.stringify({ data: { flightItineraryList: [{
889
+ flightSegments: [{ airlineName: 'Spring Airlines', duration: 215, flightList: [{ flightNo: '9C6617', departureAirportName: '浦东', arrivalAirportName: '三义', departureDateTime: '2027-05-02 17:05:00', arrivalDateTime: '2027-05-02 20:40:00' }] }],
890
+ priceList: [{ adultPrice: 1790 }],
891
+ }] } });
892
+ // live 实测形状(2026-10-14 GUI 捕获,382 条之一):顶层 priceList,/Date(ms+0800)/ 日期,price>0 才可售
893
+ const CAL_FIXTURE = JSON.stringify({ responseStatus: { Ack: 'Success' }, priceList: [
894
+ { departDate: '/Date(1791993600000+0800)/', price: 0 }, // 无价日(过滤)
895
+ { departDate: '/Date(1792080000000+0800)/', price: 880 }, // 2027-05-02? 由断言锚定实际换算
896
+ { departDate: '/Date(1792166400000+0800)/', price: 1020 },
897
+ { departDate: '/Date(1792252800000+0800)/', price: 760 },
898
+ ] });
899
+ const makeFakeDriver = (opts = {}) => {
900
+ const state = { clickedSearch: false, form: { from: '', to: '', dep: '', back: '' } };
901
+ const page = {
902
+ async goto() { },
903
+ async clickTripTab() { },
904
+ async typeCity(which, text) { state.form[which] = text; },
905
+ async pickSuggest(which, text) { state.form[which] = `${text}(SHA)`; /* 站点补全归一化形态 */ },
906
+ async setDate(which, ymd) { state.form[which === 'dep' ? 'dep' : 'back'] = ymd; },
907
+ async press() { },
908
+ async clickSearch() { state.clickedSearch = true; },
909
+ async visibleFormText() { return Object.values({ ...state.form, ...opts.formOverride }).concat(['上海', '丽江']).join(' | '); },
910
+ async cityFieldValues() { const f = { ...state.form, ...opts.formOverride }; return { from: f.from ?? '', to: f.to ?? '' }; },
911
+ async pageText() { return opts.challenge ? '请输入验证码 拖动滑块' : '机票预订'; },
912
+ async currentUrl() { return opts.listUrl ?? 'https://flights.ctrip.com/online/channel/domestic'; },
913
+ async waitForApiCapture() {
914
+ const itinerary = opts.apiBody === undefined
915
+ ? { url: 'https://flights.ctrip.com/international/search/api/search/batchSearch', body: FIXTURE }
916
+ : opts.apiBody ? { url: 'x', body: opts.apiBody } : null;
917
+ const calendar = opts.calendarBody ? { url: 'https://m.ctrip.com/restapi/soa2/15380/bjjson/FlightIntlAndInlandLowestPriceSearch', body: opts.calendarBody } : null;
918
+ return (itinerary || calendar) ? { ...(itinerary ? { itinerary } : {}), ...(calendar ? { calendar } : {}) } : null;
919
+ },
920
+ };
921
+ return { driver: { open: async () => page, close: async () => { } }, state };
922
+ };
923
+ __setGuiDriverForTest(() => makeFakeDriver().driver);
924
+ const g1 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
925
+ assert(g1.ok === true && g1.verdict === 'hit' && g1.anonymous === true && (g1.options?.length ?? 0) === 1, '假 driver 全流程 → hit(anonymous 标注)', g1);
926
+ assert(/\[会话:ctrip-flight-gui@/.test(g1.evidence) && /transport=gui/.test(g1.evidence), 'GUI 证据链 [会话:ctrip-flight-gui@ts]');
927
+ assert(g1.options?.[0]?.flightNo === '9C6617' && g1.options?.[0]?.price === 1790, '既有 batchSearch 解析器复用(结构化选项)');
928
+ // #661/#662:匿名绑定面——无行程回包、仅有低价日历 → hit + 日历数据 + listUrl 透传
929
+ const calDriver = makeFakeDriver({ apiBody: null, calendarBody: CAL_FIXTURE, listUrl: 'https://flights.ctrip.com/online/list/roundtrip-sha-ljg?depdate=2027-05-02&retdate=2027-05-09' });
930
+ __setGuiDriverForTest(() => calDriver.driver);
931
+ const g5 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-09' });
932
+ assert(g5.ok === true && g5.verdict === 'hit' && (g5.options?.length ?? 0) === 0, '日历绑定:无行程回包时日历即 hit(#661)', g5);
933
+ assert((g5.calendar?.length ?? 0) === 3 && g5.calendar?.[0]?.date === '2026-10-16' && g5.calendar?.[0]?.price === 880 && g5.calendar?.[2]?.price === 760, '日历数据 /Date(ms)/→date 与 price>0 解析(#662,无价日过滤)', g5.calendar);
934
+ const calDup = parseLowestPriceCalendar(JSON.stringify({ priceList: [
935
+ { departDate: '/Date(1792080000000+0800)/', price: 900 }, { departDate: '/Date(1792080000000+0800)/', price: 700 },
936
+ ] }));
937
+ assert(calDup.length === 1 && calDup[0]?.price === 700, '往返双程同日双价 → 按日聚合取最低', calDup);
938
+ assert(g5.listUrl === 'https://flights.ctrip.com/online/list/roundtrip-sha-ljg?depdate=2027-05-02&retdate=2027-05-09', 'listUrl 回传(#663 URL 教师)', g5.listUrl);
939
+ assert(/低价日历/.test(g5.evidence), '日历证据链标注');
940
+ assert(parseLowestPriceCalendar('not json').length === 0 && parseLowestPriceCalendar('{"data":{"unknownShape":1}}').length === 0, '日历 parser 形状闸:未知形状空数组(不发明价格)');
941
+ const mixed = makeFakeDriver({ calendarBody: CAL_FIXTURE });
942
+ __setGuiDriverForTest(() => mixed.driver);
943
+ const g6 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
944
+ assert((g6.options?.length ?? 0) === 1 && (g6.calendar?.length ?? 0) === 3 && /calendar=3天/.test(g6.evidence), '行程+日历双槽并存(行程优先结算,日历随行)', g6);
945
+ const rbDriver = makeFakeDriver({ formOverride: { dep: '2027-05-03' } }); // 日期被「改回」→ readback 必须拦
946
+ __setGuiDriverForTest(() => rbDriver.driver);
947
+ const g2 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
948
+ assert(g2.ok === false && g2.verdict === 'error' && /readback 硬闸/.test(g2.error ?? ''), 'readback 硬闸:日期不符 → 终止,不发起搜索', g2);
949
+ assert(rbDriver.state.clickedSearch === false, 'readback 拦截后搜索按钮零点击(绝不搜错日期)');
950
+ const cityDriver = makeFakeDriver({ formOverride: { from: '北京(BJS)' } }); // 城市打进别的框(live 实测形态)
951
+ __setGuiDriverForTest(() => cityDriver.driver);
952
+ const g2b = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
953
+ assert(g2b.ok === false && /出发城市框未确认/.test(g2b.error ?? '') && cityDriver.state.clickedSearch === false, 'readback 逐字段:城市框错位(全局框假阳)→ 终止', g2b);
954
+ __setGuiDriverForTest(() => makeFakeDriver({ challenge: true }).driver);
955
+ const g3 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
956
+ assert(g3.ok === false && g3.verdict === 'challenged', '风控页 → challenged(不重试不绕过)', g3);
957
+ __setGuiDriverForTest(() => makeFakeDriver({ apiBody: null }).driver);
958
+ const g4 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
959
+ assert(g4.ok === false && g4.verdict === 'error' && /嗅探超时/.test(g4.error ?? ''), '回包超时 → 结构化 error', g4);
960
+ // 编排面:节律闸同站同桶 + 回程日期校验
961
+ __resetRateLimiterForTest();
962
+ __setGuiDriverForTest(() => makeFakeDriver().driver);
963
+ const s1 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
964
+ assert(s1.ok === true && s1.verdict === 'hit', 'sessionFlightGuiSearch 编排 → hit');
965
+ const s2 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
966
+ assert(s2.ok === false && s2.verdict === 'cooldown', 'GUI 与 bridge 同站同桶:30s 内二调 → cooldown');
967
+ __resetRateLimiterForTest();
968
+ const s3 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-01' });
969
+ assert(s3.ok === false && s3.verdict === 'error' && /回程日期/.test(s3.error ?? ''), '回程早于去程 → 参数闸拒绝');
970
+ // bridge 往返(#663 解锁):URL 形状由 GUI 教师实测定稿;回程≤去程仍参数闸拒绝
971
+ __resetRateLimiterForTest();
972
+ const s4 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-01' });
973
+ assert(s4.ok === false && s4.verdict === 'error' && /回程日期/.test(s4.error ?? ''), 'bridge 回程早于去程 → 参数闸拒绝', s4);
974
+ const rtUrl = buildEntryUrl('香港', '迪拜', '2026-11-13', {}, '2026-11-17');
975
+ assert(rtUrl.ok === true && rtUrl.url === 'https://flights.ctrip.com/online/list/round-hkg0-dxb0?depdate=2026-11-13_2026-11-17&cabin=y_s&adult=1&child=0&infant=0', '往返 URL=GUI 教师实证形状(round-{code}0)', rtUrl);
976
+ __setGuiDriverForTest(null);
977
+ }
978
+ // M. 携程酒店 GUI 车道(2026-10-14,#664 首片):规划/安全闸/readback/needs-login 边界
979
+ {
980
+ console.log('M. 酒店 GUI 车道(规划器 + 安全闸 + 假 driver + 登录墙判语)');
981
+ const hp = planHotelGuiActions({ to: '迪拜', checkIn: '2026-12-06', checkOut: '2026-12-08' });
982
+ assert(hp.some(a => a.t === 'setDate' && a.which === 'in') && hp.some(a => a.t === 'setDate' && a.which === 'out'), '酒店规划:入住/退房成对');
983
+ assert(!assertHotelPlanSafe([{ t: 'typeCity', text: '立即下单' }]).ok, '安全闸:目的地位提交件文本拒绝');
984
+ assert(!assertHotelPlanSafe([{ t: 'goto', url: 'https://evil.example.com/' }]).ok, '安全闸:非固定入口拒绝');
985
+ const HOTEL_FIXTURE = JSON.stringify({ ResponseStatus: { Ack: 'Success' }, hotelList: [
986
+ { hotelName: '测试酒店', minPrice: 500, star: 4 },
987
+ ] });
988
+ const mkHotelDriver = (opts = {}) => {
989
+ const state = { clickedSearch: false, form: { to: '', in: '', out: '' }, url: 'https://hotels.ctrip.com/' };
990
+ const page = {
991
+ async goto() { },
992
+ async typeCity(text) { state.form.to = text; },
993
+ async pickSuggest(text) { state.form.to = `${text}(测试)`; },
994
+ async setDate(which, ymd) { state.form[which] = ymd; },
995
+ async press() { },
996
+ async clickSearch() { state.clickedSearch = true; if (opts.passportAfterSearch)
997
+ state.url = 'https://passport.ctrip.com/user/login?backurl=x'; },
998
+ async visibleFormText() {
999
+ const f = { ...state.form, ...opts.formOverride };
1000
+ const cn = (ymd) => ymd ? `${Number(ymd.slice(5, 7))}月${Number(ymd.slice(8, 10))}日` : '';
1001
+ return Object.values(f).concat(['上海', cn(f.in ?? ''), cn(f.out ?? '')]).join(' | ');
1002
+ },
1003
+ async destFieldValue() { return (opts.formOverride?.to ?? state.form.to) + '(测试)'; },
1004
+ async pageText() { return '酒店预订'; },
1005
+ async currentUrl() { return state.url; },
1006
+ async waitForApiBody() {
1007
+ if (opts.passportAfterSearch)
1008
+ return null;
1009
+ return opts.body === undefined ? { url: 'https://hotels.ctrip.com/hotels/api/list', body: HOTEL_FIXTURE } : opts.body ? { url: 'x', body: opts.body } : null;
1010
+ },
1011
+ };
1012
+ return { driver: { open: async () => page, close: async () => { } }, state };
1013
+ };
1014
+ __setHotelDriverForTest(() => mkHotelDriver().driver);
1015
+ const h1 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
1016
+ assert(h1.ok === true && h1.verdict === 'hit' && h1.anonymous === true && (h1.hotels?.length ?? 0) >= 1, '酒店假 driver 全流程 → hit(匿名标注;本地化日期格式核对)', h1);
1017
+ const hLogin = mkHotelDriver({ passportAfterSearch: true });
1018
+ __setHotelDriverForTest(() => hLogin.driver);
1019
+ const h2 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
1020
+ assert(h2.ok === false && h2.verdict === 'needs-login' && /gotry_session_login/.test(h2.error ?? ''), '登录墙(passport 跳转)→ needs-login 判语(live 实证边界)', h2);
1021
+ const hBadDate = mkHotelDriver({ formOverride: { in: '2026-11-20' } });
1022
+ __setHotelDriverForTest(() => hBadDate.driver);
1023
+ const h3 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
1024
+ assert(h3.ok === false && /readback 硬闸/.test(h3.error ?? '') && hBadDate.state.clickedSearch === false, 'readback 硬闸:入住日期错位 → 零搜索点击', h3);
1025
+ assert((await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-15', checkOut: '2026-11-13' })).verdict === 'error', '退房早于入住 → 参数闸拒绝');
1026
+ __setHotelDriverForTest(null);
1027
+ }
857
1028
  if (process.env.GOTRY_SESSION_TEST_FORCE_FAILURE === '1') {
858
1029
  assert(false, 'forced session failure propagation proof');
859
1030
  }
@@ -0,0 +1,26 @@
1
+ import { realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, sep } from 'node:path';
3
+ export function registerArtifactDeliveries(ctx) {
4
+ // Tool-registration-only hosts have no conversation event surface.
5
+ if (typeof ctx.on !== 'function')
6
+ return;
7
+ ctx.on('tools/result', (exec, result) => {
8
+ if (result.isError || exec.signal.aborted || !['gotry_itinerary_render', 'gotry_itinerary_deck_render'].includes(exec.name))
9
+ return;
10
+ const value = result.value;
11
+ const session = exec.agent?.session, cwd = session?.header.cwd;
12
+ if (!session || !cwd || !isAbsolute(cwd) || value?.ok !== true || typeof value.path !== 'string' || !isAbsolute(value.path))
13
+ return;
14
+ const boundary = ctx.get('sessionProjections')?.stateOf(session, 'turnBoundary');
15
+ if (!boundary || boundary.openTurnStartSeq === null)
16
+ return;
17
+ try {
18
+ const path = realpathSync(value.path), root = realpathSync(cwd), inside = relative(root, path);
19
+ if (inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside) || !statSync(path).isFile())
20
+ return;
21
+ session.append('deliverables/presented', { turn: boundary.lastTurn, callId: exec.callId,
22
+ files: [{ path: value.path, description: 'GoTry itinerary' }] });
23
+ }
24
+ catch { /* A deleted or escaped file cannot become a delivery. */ }
25
+ });
26
+ }
@@ -0,0 +1,219 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute } from 'node:path';
4
+ import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js';
5
+ import { listArtifacts } from '../capabilities/artifacts.js';
6
+ import { atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath, isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification, removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey, sha256Hex, writeFlyaiVerification, } from '../capabilities/flyai-config.js';
7
+ import { hbcliCredentialPath, hbcliSetupStatus, readHbcliCredentials, removeHbcliVerification, verifyHbcliCandidate, withHbcliCredentials, withoutHbcliCredentials, writeHbcliCredentials, writeHbcliVerification, } from '../capabilities/hbcli-config.js';
8
+ const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
9
+ const rejection = (error, status = 400) => json({ ok: false, error }, status);
10
+ const BODY_LIMIT = 8192;
11
+ async function readInput(request) {
12
+ if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json')
13
+ return rejection('需要 JSON 请求。', 415);
14
+ const reader = request.body?.getReader();
15
+ if (!reader)
16
+ return rejection('请求为空。');
17
+ const chunks = [];
18
+ let bytes = 0;
19
+ try {
20
+ while (true) {
21
+ const chunk = await reader.read();
22
+ if (chunk.done)
23
+ break;
24
+ bytes += chunk.value.byteLength;
25
+ if (bytes > BODY_LIMIT) {
26
+ await reader.cancel();
27
+ return rejection('请求过大。', 413);
28
+ }
29
+ chunks.push(chunk.value);
30
+ }
31
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
32
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : rejection('请求无效。');
33
+ }
34
+ catch {
35
+ return rejection('请求无效。');
36
+ }
37
+ finally {
38
+ reader.releaseLock();
39
+ }
40
+ }
41
+ export function createGotryWebHandlers(options) {
42
+ const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env;
43
+ const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir);
44
+ const hbPaths = { homeDir, env, hbcliBin: options.hbcliBin };
45
+ let mutations = Promise.resolve();
46
+ const status = () => {
47
+ const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir);
48
+ const matches = current.key && receipt?.keySha256 === sha256Hex(current.key) && receipt.source === current.source
49
+ && receipt.endpointFingerprint === endpointFingerprint(endpoint.url) && receipt.endpointDebug === endpoint.debug;
50
+ return { ok: true, configured: Boolean(current.key), verified: Boolean(matches && receipt?.verdict === 'verified'),
51
+ writable: current.source !== 'env' && current.source !== 'env-debug' && process.platform !== 'win32', source: current.source,
52
+ endpoint: displayEndpoint(endpoint.url), endpointDebug: endpoint.debug,
53
+ ...(matches && receipt ? { checkedAt: receipt.at, verdict: receipt.verdict } : {}) };
54
+ };
55
+ const bytesAt = () => existsSync(configPath) ? readFileSync(configPath, 'utf8') : undefined;
56
+ async function mutate(input, signal) {
57
+ if (signal.aborted)
58
+ return rejection('操作已取消。', 409);
59
+ if (!status().writable)
60
+ return rejection('当前 Key 由环境变量提供或此平台不支持安全写入。', 409);
61
+ if (input.action === 'clear') {
62
+ const result = clearFlyaiKey(paths);
63
+ if (!result.ok)
64
+ return rejection('无法清除配置,请检查本机配置文件及权限。', 409);
65
+ removeFlyaiVerification(homeDir);
66
+ return json(status());
67
+ }
68
+ const key = typeof input.key === 'string' ? input.key.trim() : '';
69
+ if (!key || key.length > 2048 || /[\r\n\0]/.test(key) || isPlaceholderFlyaiKey(key))
70
+ return rejection('请粘贴控制台中的真实 API Key。');
71
+ const before = readFlyaiConfig(configPath);
72
+ if (!before.ok)
73
+ return rejection('原配置损坏,已保留原文件,请先修复。', 409);
74
+ const previousBytes = bytesAt(), endpoint = resolveFlyaiEndpoint(paths), fingerprint = endpointFingerprint(endpoint.url);
75
+ if (!fingerprint)
76
+ return rejection('当前服务地址无效。', 409);
77
+ const verify = await verifyFlyaiCandidate(key, { env: { ...env, HOME: homeDir }, signal });
78
+ if (signal.aborted || verify.verdict !== 'hit')
79
+ return json({ ok: false, verdict: signal.aborted ? 'cancelled' : verify.verdict,
80
+ error: signal.aborted ? '验证已取消,旧配置保留。' : verify.error ?? '验证失败,旧配置保留。' }, 422);
81
+ if (!status().writable || bytesAt() !== previousBytes || fingerprint !== endpointFingerprint(resolveFlyaiEndpoint(paths).url)) {
82
+ return rejection('配置在验证期间发生变化,请重新读取后再试。', 409);
83
+ }
84
+ const saved = saveFlyaiKey(key, paths);
85
+ if (!saved.ok)
86
+ return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409);
87
+ const receiptSaved = writeFlyaiVerification({ schema: 'gotry.flyai-verification.v1', source: 'config',
88
+ keySha256: sha256Hex(key), maskedKey: maskFlyaiKey(key), verdict: 'verified', endpointFingerprint: fingerprint,
89
+ endpointDebug: endpoint.debug, at: new Date().toISOString() }, homeDir);
90
+ if (!receiptSaved) {
91
+ const restored = previousBytes === undefined
92
+ ? (() => { try {
93
+ rmSync(configPath, { force: true });
94
+ return true;
95
+ }
96
+ catch {
97
+ return false;
98
+ } })()
99
+ : atomicWriteJson(configPath, before.data).ok;
100
+ return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,恢复旧配置失败,请检查本机文件。', 409);
101
+ }
102
+ return json(status());
103
+ }
104
+ async function mutateHbcli(input, signal) {
105
+ if (signal.aborted)
106
+ return rejection('操作已取消。', 409);
107
+ if (!hbcliSetupStatus(hbPaths).writable)
108
+ return rejection('当前凭证来自环境变量,或本机配置不可安全写入。', 409);
109
+ const before = readHbcliCredentials(hbPaths);
110
+ if (!before.ok)
111
+ return rejection('原配置损坏,已保留原文件,请先修复。', 409);
112
+ if (input.action === 'clear') {
113
+ if (!writeHbcliCredentials(withoutHbcliCredentials(before.data), hbPaths))
114
+ return rejection('无法清除配置,请检查本机文件及权限。', 409);
115
+ removeHbcliVerification(homeDir);
116
+ return json(hbcliSetupStatus(hbPaths));
117
+ }
118
+ const appKey = typeof input.appKey === 'string' ? input.appKey.trim() : '';
119
+ const appSecret = typeof input.appSecret === 'string' ? input.appSecret.trim() : '';
120
+ if ([appKey, appSecret].some(value => !value || value.length > 2048 || /[\r\n\0]/.test(value)))
121
+ return rejection('请填写 HotelByte 提供的 App Key 和 App Secret。');
122
+ const verified = await verifyHbcliCandidate(appKey, appSecret, hbPaths, signal);
123
+ if (signal.aborted || verified.verdict === 'error' || verified.verdict === 'cancelled') {
124
+ return json({ ok: false, error: verified.error ?? '验证已取消,旧配置保留。' }, 422);
125
+ }
126
+ const current = readHbcliCredentials(hbPaths);
127
+ if (!hbcliSetupStatus(hbPaths).writable || !current.ok || current.bytes !== before.bytes)
128
+ return rejection('配置在验证期间发生变化,请重新读取后再试。', 409);
129
+ const next = withHbcliCredentials(before.data, appKey, appSecret);
130
+ if (!writeHbcliCredentials(next, hbPaths))
131
+ return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409);
132
+ if (!writeHbcliVerification(next, verified.verdict, hbPaths)) {
133
+ const restored = before.bytes === undefined
134
+ ? (() => { try {
135
+ rmSync(hbcliCredentialPath(hbPaths));
136
+ return true;
137
+ }
138
+ catch {
139
+ return false;
140
+ } })()
141
+ : writeHbcliCredentials(before.data, hbPaths);
142
+ return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,请检查本机文件。', 409);
143
+ }
144
+ return json(hbcliSetupStatus(hbPaths));
145
+ }
146
+ return {
147
+ async hbcli(request) {
148
+ if (request.method === 'GET')
149
+ return json(hbcliSetupStatus(hbPaths));
150
+ if (request.method !== 'POST')
151
+ return rejection('不支持此操作。', 405);
152
+ const input = await readInput(request);
153
+ if (input instanceof Response)
154
+ return input;
155
+ if (!['save', 'clear'].includes(String(input.action))
156
+ || Object.keys(input).some(key => !['action', 'appKey', 'appSecret'].includes(key))
157
+ || (input.action === 'clear' && ('appKey' in input || 'appSecret' in input)))
158
+ return rejection('请求无效。');
159
+ const operation = mutations.then(() => mutateHbcli(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500));
160
+ mutations = operation.then(() => undefined);
161
+ return operation;
162
+ },
163
+ async flyai(request) {
164
+ if (request.method === 'GET')
165
+ return json(status());
166
+ if (request.method !== 'POST')
167
+ return rejection('不支持此操作。', 405);
168
+ const input = await readInput(request);
169
+ if (input instanceof Response)
170
+ return input;
171
+ if (!['save', 'clear'].includes(String(input.action)) || Object.keys(input).some(key => key !== 'action' && key !== 'key')
172
+ || (input.action === 'clear' && 'key' in input))
173
+ return rejection('请求无效。');
174
+ const operation = mutations.then(() => mutate(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500));
175
+ mutations = operation.then(() => undefined);
176
+ return operation;
177
+ },
178
+ async artifacts(request) {
179
+ const query = new URL(request.url).searchParams;
180
+ if ([...query.keys()].some(key => !['sessionId', 'search', 'offset', 'limit'].includes(key)))
181
+ return rejection('查询字段无效。');
182
+ const sessionId = query.get('sessionId') ?? '';
183
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(sessionId))
184
+ return rejection('需要有效会话。');
185
+ const cwd = await options.workspaceForSession(sessionId);
186
+ if (!cwd || !isAbsolute(cwd))
187
+ return rejection('会话工作目录不可用。', 404);
188
+ try {
189
+ return json({ ok: true, ...await listArtifacts({ stateRoot: options.stateRoot, cwd,
190
+ handoffRoot: env.GOTRY_TURN_HANDOFF_ROOT ?? (options.stateRoot !== '.' ? options.stateRoot : cwd),
191
+ search: query.get('search') ?? undefined, offset: query.has('offset') ? Number(query.get('offset')) : undefined,
192
+ limit: query.has('limit') ? Number(query.get('limit')) : 20 }) });
193
+ }
194
+ catch {
195
+ return rejection('查询参数无效或产物暂时不可读取。');
196
+ }
197
+ },
198
+ };
199
+ }
200
+ export function registerGotryWebApi(ctx, stateRoot, hbcliBin) {
201
+ if (typeof ctx.inject !== 'function')
202
+ return;
203
+ ctx.inject(['connection', 'sessions'], scope => {
204
+ const connection = scope.get('connection');
205
+ const handlers = createGotryWebHandlers({ stateRoot, hbcliBin, workspaceForSession: async (sessionId) => {
206
+ const id = sessionId;
207
+ const live = scope.sessions.get(id)?.header;
208
+ const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId);
209
+ return (live ?? stored?.header)?.cwd;
210
+ } });
211
+ // DSH's streaming adapter always attaches a request body; GET cannot use
212
+ // it. Exact routes have one body mode, so reads and bounded writes differ.
213
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }));
214
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }));
215
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli', methods: ['GET'], requestBody: 'buffered', fetch: handlers.hbcli }));
216
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.hbcli }));
217
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }));
218
+ });
219
+ }