@danceiny/gotry 0.2.0-rc.31 → 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 (54) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/ctrip-city.js +186 -0
  17. package/dist/capabilities/session/extension-bridge.js +8 -1
  18. package/dist/capabilities/session/gui-flight.js +737 -0
  19. package/dist/capabilities/session/gui-hotel.js +413 -0
  20. package/dist/capabilities/session-search.js +98 -11
  21. package/dist/scripts/bootstrap-tests.js +23 -6
  22. package/dist/scripts/ctrip-city-tests.js +80 -0
  23. package/dist/scripts/effect-tests.js +602 -504
  24. package/dist/scripts/extension-tests.js +14 -8
  25. package/dist/scripts/flight-malformed-tests.js +1 -1
  26. package/dist/scripts/flyai-setup-tests.js +19 -4
  27. package/dist/scripts/flyai-tests.js +768 -653
  28. package/dist/scripts/gotry-web-api-tests.js +5 -0
  29. package/dist/scripts/gotry-web-client-tests.js +20 -1
  30. package/dist/scripts/gui-flight-live.js +27 -0
  31. package/dist/scripts/hbcli-tests.js +1 -1
  32. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  33. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  34. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  35. package/dist/scripts/hotel-result-tests.js +169 -0
  36. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  37. package/dist/scripts/session-tests.js +177 -6
  38. package/dist/src/gotry-web-api.js +64 -2
  39. package/dist/src/index.js +40 -18
  40. package/extension/manifest.json +2 -2
  41. package/package.json +2 -1
  42. package/ts/capabilities/flyai-config.ts +12 -10
  43. package/ts/capabilities/flyai.ts +91 -50
  44. package/ts/capabilities/hbcli-config.ts +128 -0
  45. package/ts/capabilities/hbcli.ts +30 -3
  46. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  47. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  48. package/ts/capabilities/session/ctrip-city.ts +158 -0
  49. package/ts/capabilities/session/extension-bridge.ts +9 -2
  50. package/ts/capabilities/session/gui-flight.ts +792 -0
  51. package/ts/capabilities/session/gui-hotel.ts +451 -0
  52. package/ts/capabilities/session-search.ts +115 -15
  53. package/ts/src/gotry-web-api.ts +51 -2
  54. package/ts/src/index.ts +41 -19
@@ -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
  }
@@ -4,6 +4,7 @@ import { isAbsolute } from 'node:path';
4
4
  import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js';
5
5
  import { listArtifacts } from '../capabilities/artifacts.js';
6
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';
7
8
  const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
8
9
  const rejection = (error, status = 400) => json({ ok: false, error }, status);
9
10
  const BODY_LIMIT = 8192;
@@ -40,6 +41,7 @@ async function readInput(request) {
40
41
  export function createGotryWebHandlers(options) {
41
42
  const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env;
42
43
  const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir);
44
+ const hbPaths = { homeDir, env, hbcliBin: options.hbcliBin };
43
45
  let mutations = Promise.resolve();
44
46
  const status = () => {
45
47
  const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir);
@@ -99,7 +101,65 @@ export function createGotryWebHandlers(options) {
99
101
  }
100
102
  return json(status());
101
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
+ }
102
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
+ },
103
163
  async flyai(request) {
104
164
  if (request.method === 'GET')
105
165
  return json(status());
@@ -137,12 +197,12 @@ export function createGotryWebHandlers(options) {
137
197
  },
138
198
  };
139
199
  }
140
- export function registerGotryWebApi(ctx, stateRoot) {
200
+ export function registerGotryWebApi(ctx, stateRoot, hbcliBin) {
141
201
  if (typeof ctx.inject !== 'function')
142
202
  return;
143
203
  ctx.inject(['connection', 'sessions'], scope => {
144
204
  const connection = scope.get('connection');
145
- const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async (sessionId) => {
205
+ const handlers = createGotryWebHandlers({ stateRoot, hbcliBin, workspaceForSession: async (sessionId) => {
146
206
  const id = sessionId;
147
207
  const live = scope.sessions.get(id)?.header;
148
208
  const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId);
@@ -152,6 +212,8 @@ export function registerGotryWebApi(ctx, stateRoot) {
152
212
  // it. Exact routes have one body mode, so reads and bounded writes differ.
153
213
  scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }));
154
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 }));
155
217
  scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }));
156
218
  });
157
219
  }
package/dist/src/index.js CHANGED
@@ -432,7 +432,7 @@ export function apply(ctx, config, seams = {}) {
432
432
  // 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
433
433
  // 必须产出最终交付物,不得再次 handoff(否则无限递归)。
434
434
  if (!rawBenchmarkEnvironmentConfigPath) {
435
- registerGotryWebApi(ctx, config.stateRoot ?? '.');
435
+ registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin);
436
436
  registerArtifactDeliveries(ctx);
437
437
  }
438
438
  const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
@@ -1305,10 +1305,15 @@ export function apply(ctx, config, seams = {}) {
1305
1305
  const resp = itp.result;
1306
1306
  const dir = await ensureStateDir(config.stateRoot);
1307
1307
  const isLive = resp.via === 'hbcli-realtime';
1308
- const evidence = isLive ? resp.evidence : '[静态包:估算]';
1308
+ const staticStays = resp.hotels?.stays;
1309
+ const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0;
1310
+ const liveList = resp.hotels?.list;
1311
+ const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error';
1312
+ const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence;
1309
1313
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => { });
1310
1314
  const payload = {
1311
- ok: true,
1315
+ ok: isLive || hasEstimate,
1316
+ verdict,
1312
1317
  hotels: resp.hotels ?? null,
1313
1318
  evidence,
1314
1319
  destination: q.destination,
@@ -1316,6 +1321,7 @@ export function apply(ctx, config, seams = {}) {
1316
1321
  latency_ms: Date.now() - started,
1317
1322
  summary: resp.summary,
1318
1323
  error: resp.error,
1324
+ ...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
1319
1325
  ...(dateNotes.length ? { date_notes: dateNotes } : {}),
1320
1326
  };
1321
1327
  return JSON.parse(JSON.stringify(payload));
@@ -1342,13 +1348,14 @@ export function apply(ctx, config, seams = {}) {
1342
1348
  };
1343
1349
  }
1344
1350
  const h = r.hotels;
1345
- const liveCount = Array.isArray(h) ? h.length : 0;
1351
+ const liveList = h?.list;
1352
+ const liveCount = Array.isArray(liveList) ? liveList.length : 0;
1346
1353
  // 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
1347
1354
  const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
1348
1355
  const staticCount = Array.isArray(stays) ? stays.length : 0;
1349
1356
  const tag = r.via === 'hbcli-realtime'
1350
- ? (liveCount ? `实时 ${liveCount} 家` : '实时')
1351
- : staticCount ? `静态包 ${staticCount} 块` : (liveCount ? `${liveCount} 家` : '无结果');
1357
+ ? `实时 ${liveCount} 家`
1358
+ : staticCount ? `静态包 ${staticCount} 块` : '查询未完成';
1352
1359
  return {
1353
1360
  card: 'generic',
1354
1361
  title: `酒店:${r.destination ?? ''} ${tag}`,
@@ -1731,8 +1738,9 @@ export function apply(ctx, config, seams = {}) {
1731
1738
  description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
1732
1739
  + 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
1733
1740
  + 'Transport: Stai Travel Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
1734
- + 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
1735
- + 'kind="hotel": to=目的地中文, cityId? = the numeric city= in a hotels.ctrip.com list URL (web-search it when the destination is outside the built-in city table), checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
1741
+ + 'kind="flight": from/to city or airport names (Chinese/English) + date YYYY-MM-DD; unknown names resolve from supplier metadata, ambiguous names require confirmation; optional fromCityCode/toCityCode are explicit Ctrip three-letter codes — sniffs the site search API for structured options. '
1742
+ + 'ui?="bridge" (default; the user\'s own logged-in session, real member prices) | "gui" (gotry opens its OWN isolated anonymous Chrome window and drives the Ctrip PC search form — roundtrip via backDate? and ANY city by construction: the site\'s own autocomplete resolves cities; anonymous public prices, NOT logged-in prices; verdicts carry anonymous=true; result-binding iteration tracked in #661). Evidence [会话:ctrip-flight@ts] / [会话:ctrip-flight-gui@ts]. '
1743
+ + 'kind="hotel": to=destination name, country? for same-name disambiguation; cityId? = explicit numeric city= from a Ctrip hotel list URL; otherwise resolves from the hotel destination service, checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
1736
1744
  + 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
1737
1745
  + 'kind="train": from/to/date(YYYY-MM-DD) + fromStationTelecode?/toStationTelecode? (three-letter codes in the kyfw query URL, for cities outside the built-in table) — 12306 left-ticket query (public face): train codes, times, durations, seat availability; the list API carries NO prices (prices live on the 12306 page). Evidence [会话:train-12306@ts]. '
1738
1746
  + 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
@@ -1745,15 +1753,20 @@ export function apply(ctx, config, seams = {}) {
1745
1753
  // execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
1746
1754
  parameters: {
1747
1755
  kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
1748
- from: { type: 'string', description: '出发城市中文(词表内),如 上海——kind=flight|train 必填' },
1756
+ from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
1749
1757
  to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
1750
1758
  date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
1751
- cityId: { type: 'integer', description: '携程酒店 city= 数字,仅 kind=hotel;目的地码表外时 web 搜 hotels.ctrip.com list URL 取 city=' },
1759
+ fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
1760
+ toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
1761
+ cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
1762
+ country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
1752
1763
  checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
1753
1764
  checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
1754
1765
  adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
1755
1766
  fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
1756
1767
  toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
1768
+ backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
1769
+ ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/任意城市原生+低价日历;hotel 任意目的地原生(cityId 码表不在主链路);匿名公开价)' },
1757
1770
  },
1758
1771
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v.summary ?? JSON.stringify(v).slice(0, 600)) }] },
1759
1772
  async execute(args, _exec) {
@@ -1830,17 +1843,17 @@ export function apply(ctx, config, seams = {}) {
1830
1843
  }
1831
1844
  if (q.kind === 'hotel') {
1832
1845
  if (!q.to) {
1833
- return { ok: false, summary: 'kind=hotel 需要 to(目的地中文;城市码表外带 cityId=携程酒店 list 页 URL 里的 city= 数字)' };
1846
+ return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' };
1834
1847
  }
1835
1848
  const itp = await runSessionEffect({
1836
- effect: 'SESSION_HOTEL_SEARCH',
1849
+ effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
1837
1850
  params: {
1838
- to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1851
+ to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1839
1852
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1840
1853
  },
1841
1854
  });
1842
1855
  if (!itp.result)
1843
- return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace);
1856
+ return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace);
1844
1857
  const r = itp.result;
1845
1858
  await noteChannel('session:ctrip-hotel', r.verdict);
1846
1859
  // D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
@@ -1862,14 +1875,19 @@ export function apply(ctx, config, seams = {}) {
1862
1875
  }));
1863
1876
  }
1864
1877
  if (!q.from || !q.to || !q.date) {
1865
- return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
1878
+ return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
1866
1879
  }
1867
1880
  // 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
1868
1881
  // 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
1882
+ // GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
1883
+ if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
1884
+ return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' };
1885
+ }
1869
1886
  const itp = await runSessionEffect({
1870
- effect: 'SESSION_FLIGHT_SEARCH',
1887
+ effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
1871
1888
  params: {
1872
- from: q.from, to: q.to, date: q.date,
1889
+ from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
1890
+ ...(q.backDate ? { backDate: q.backDate } : {}),
1873
1891
  // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
1874
1892
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1875
1893
  },
@@ -1882,8 +1900,12 @@ export function apply(ctx, config, seams = {}) {
1882
1900
  const avMapS = await loadAirlineAirportMap();
1883
1901
  await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias));
1884
1902
  const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
1903
+ const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)';
1904
+ const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`);
1905
+ const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : '';
1906
+ const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : '';
1885
1907
  const summary = r.verdict === 'hit'
1886
- ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
1908
+ ? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
1887
1909
  : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
1888
1910
  return JSON.parse(JSON.stringify({
1889
1911
  ...r, summary,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stai Travel Bridge",
4
- "version": "0.2.0.31",
4
+ "version": "0.2.0.32",
5
5
  "description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -56,5 +56,5 @@
56
56
  "run_at": "document_start"
57
57
  }
58
58
  ],
59
- "version_name": "0.2.0-rc.31"
59
+ "version_name": "0.2.0-rc.32"
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.31",
3
+ "version": "0.2.0-rc.32",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -115,6 +115,7 @@
115
115
  "ts/capabilities/opensky.ts",
116
116
  "ts/capabilities/flyai.ts",
117
117
  "ts/capabilities/flyai-config.ts",
118
+ "ts/capabilities/hbcli-config.ts",
118
119
  "ts/capabilities/session-search.ts",
119
120
  "ts/capabilities/session/",
120
121
  "extension/",
@@ -185,22 +185,22 @@ export function verifySecureMode(dir: string, file: string): { ok: boolean; erro
185
185
  /**
186
186
  * 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
187
187
  * 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
188
- * 0700;验证回执所在 ~/.gotry 仍严格拒绝既有 0755 目录。
188
+ * 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
189
189
  */
190
- function prepareFlyaiConfigDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
190
+ function preparePrivateDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
191
191
  if (process.platform === 'win32') return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' }
192
192
  try {
193
193
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
194
194
  const entry = lstatSync(dir)
195
- if (entry.isSymbolicLink()) return { ok: false, error: '.flyai 目录是符号链接,拒绝写入' }
196
- if (!entry.isDirectory()) return { ok: false, error: '.flyai 路径不是目录,拒绝写入' }
195
+ if (entry.isSymbolicLink()) return { ok: false, error: '配置目录是符号链接,拒绝写入' }
196
+ if (!entry.isDirectory()) return { ok: false, error: '配置路径不是目录,拒绝写入' }
197
197
  if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
198
- return { ok: false, error: '.flyai 目录不属于当前用户,拒绝写入' }
198
+ return { ok: false, error: '配置目录不属于当前用户,拒绝写入' }
199
199
  }
200
200
  if ((entry.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
201
201
  const after = lstatSync(dir)
202
- if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '.flyai 目录状态发生变化,拒绝写入' }
203
- if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '.flyai 目录所有者发生变化,拒绝写入' }
202
+ if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '配置目录状态发生变化,拒绝写入' }
203
+ if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '配置目录所有者发生变化,拒绝写入' }
204
204
  return verifySecureMode(dir, file)
205
205
  } catch (e) {
206
206
  return { ok: false, error: (e as Error).message }
@@ -259,7 +259,7 @@ export function saveFlyaiKey(key: string, opts: FlyaiConfigPaths = {}): SaveFlya
259
259
  const read = readFlyaiConfig(configPath)
260
260
  if (!read.ok) return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` }
261
261
 
262
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath)
262
+ const configDir = preparePrivateDir(dirname(configPath), configPath)
263
263
  if (!configDir.ok) return { ok: false, error: configDir.error }
264
264
  const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text })
265
265
  if (!write.ok) return { ok: false, error: write.error }
@@ -297,7 +297,7 @@ export function clearFlyaiKey(opts: FlyaiConfigPaths = {}): ClearFlyaiKeyResult
297
297
  return { ok: false, source: 'none', error: `移除文件失败:${(e as Error).message}` }
298
298
  }
299
299
  } else {
300
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath)
300
+ const configDir = preparePrivateDir(dirname(configPath), configPath)
301
301
  if (!configDir.ok) return { ok: false, source: 'none', error: configDir.error }
302
302
  const write = atomicWriteJson(configPath, next)
303
303
  if (!write.ok) return { ok: false, source: 'none', error: write.error }
@@ -355,7 +355,9 @@ export function readFlyaiVerification(homeDir: string): FlyaiVerification | unde
355
355
 
356
356
  /** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
357
357
  export function writeFlyaiVerification(entry: FlyaiVerification, homeDir: string): boolean {
358
- return atomicWriteJson(flyaiVerificationPath(homeDir), entry).ok
358
+ const path = flyaiVerificationPath(homeDir)
359
+ const prepared = preparePrivateDir(dirname(path), path)
360
+ return prepared.ok && atomicWriteJson(path, entry).ok
359
361
  }
360
362
 
361
363
  export function removeFlyaiVerification(homeDir: string): void {