@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
@@ -134,9 +134,9 @@ export function verifySecureMode(dir, file) {
134
134
  /**
135
135
  * 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
136
136
  * 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
137
- * 0700;验证回执所在 ~/.gotry 仍严格拒绝既有 0755 目录。
137
+ * 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
138
138
  */
139
- function prepareFlyaiConfigDir(dir, file) {
139
+ function preparePrivateDir(dir, file) {
140
140
  if (process.platform === 'win32')
141
141
  return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' };
142
142
  try {
@@ -144,19 +144,19 @@ function prepareFlyaiConfigDir(dir, file) {
144
144
  mkdirSync(dir, { recursive: true, mode: 0o700 });
145
145
  const entry = lstatSync(dir);
146
146
  if (entry.isSymbolicLink())
147
- return { ok: false, error: '.flyai 目录是符号链接,拒绝写入' };
147
+ return { ok: false, error: '配置目录是符号链接,拒绝写入' };
148
148
  if (!entry.isDirectory())
149
- return { ok: false, error: '.flyai 路径不是目录,拒绝写入' };
149
+ return { ok: false, error: '配置路径不是目录,拒绝写入' };
150
150
  if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
151
- return { ok: false, error: '.flyai 目录不属于当前用户,拒绝写入' };
151
+ return { ok: false, error: '配置目录不属于当前用户,拒绝写入' };
152
152
  }
153
153
  if ((entry.mode & 0o777) !== 0o700)
154
154
  chmodSync(dir, 0o700);
155
155
  const after = lstatSync(dir);
156
156
  if (after.isSymbolicLink() || !after.isDirectory())
157
- return { ok: false, error: '.flyai 目录状态发生变化,拒绝写入' };
157
+ return { ok: false, error: '配置目录状态发生变化,拒绝写入' };
158
158
  if (typeof process.getuid === 'function' && after.uid !== process.getuid())
159
- return { ok: false, error: '.flyai 目录所有者发生变化,拒绝写入' };
159
+ return { ok: false, error: '配置目录所有者发生变化,拒绝写入' };
160
160
  return verifySecureMode(dir, file);
161
161
  }
162
162
  catch (e) {
@@ -225,7 +225,7 @@ export function saveFlyaiKey(key, opts = {}) {
225
225
  const read = readFlyaiConfig(configPath);
226
226
  if (!read.ok)
227
227
  return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` };
228
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath);
228
+ const configDir = preparePrivateDir(dirname(configPath), configPath);
229
229
  if (!configDir.ok)
230
230
  return { ok: false, error: configDir.error };
231
231
  const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text });
@@ -267,7 +267,7 @@ export function clearFlyaiKey(opts = {}) {
267
267
  }
268
268
  }
269
269
  else {
270
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath);
270
+ const configDir = preparePrivateDir(dirname(configPath), configPath);
271
271
  if (!configDir.ok)
272
272
  return { ok: false, source: 'none', error: configDir.error };
273
273
  const write = atomicWriteJson(configPath, next);
@@ -303,7 +303,9 @@ export function readFlyaiVerification(homeDir) {
303
303
  }
304
304
  /** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
305
305
  export function writeFlyaiVerification(entry, homeDir) {
306
- return atomicWriteJson(flyaiVerificationPath(homeDir), entry).ok;
306
+ const path = flyaiVerificationPath(homeDir);
307
+ const prepared = preparePrivateDir(dirname(path), path);
308
+ return prepared.ok && atomicWriteJson(path, entry).ok;
307
309
  }
308
310
  export function removeFlyaiVerification(homeDir) {
309
311
  try {
@@ -31,7 +31,7 @@
31
31
  import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs';
32
32
  import { tmpdir } from 'node:os';
33
33
  import { join } from 'node:path';
34
- import { resolveFlyaiEndpoint, resolveFlyaiKey } from './flyai-config.js';
34
+ import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex } from './flyai-config.js';
35
35
  import { spawnBounded } from './spawn-bounded.js';
36
36
  function nonEmptyText(value) {
37
37
  if (typeof value !== 'string')
@@ -132,7 +132,7 @@ export function parseFlyaiItemList(stdout) {
132
132
  : 'no complete FlyAI itemList JSON object');
133
133
  }
134
134
  /** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
135
- export function parseFlyaiJsonEnvelope(stdout) {
135
+ export function parseFlyaiJsonEnvelope(stdout, accepts = () => true) {
136
136
  let start = -1;
137
137
  let depth = 0;
138
138
  let inString = false;
@@ -166,12 +166,13 @@ export function parseFlyaiJsonEnvelope(stdout) {
166
166
  if (depth === 0) {
167
167
  const candidate = stdout.slice(start, index + 1);
168
168
  try {
169
- return JSON.parse(candidate);
170
- }
171
- catch {
172
- start = -1;
173
- depth = 0;
169
+ const parsed = JSON.parse(candidate);
170
+ if (accepts(parsed))
171
+ return parsed;
174
172
  }
173
+ catch { /* prefix logs may contain balanced non-JSON braces */ }
174
+ start = -1;
175
+ depth = 0;
175
176
  }
176
177
  }
177
178
  }
@@ -463,6 +464,14 @@ const KIND_LABEL = {
463
464
  flight: 'flight', train: 'train', hotel: 'hotel', poi: 'poi',
464
465
  keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
465
466
  };
467
+ /** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
468
+ export function flyaiBreakerScope(kind) {
469
+ const key = resolveFlyaiKey();
470
+ const endpoint = resolveFlyaiEndpoint().url;
471
+ const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]));
472
+ const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind';
473
+ return `${safeKind}:${identity}`;
474
+ }
466
475
  /**
467
476
  * FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
468
477
  */
@@ -472,10 +481,10 @@ export async function flyaiSearch(q) {
472
481
  const envContext = await runtimeEnvNote();
473
482
  const envNote = envContext.note;
474
483
  const sensitive = envContext.sensitive;
475
- const safe = (result) => safeFlyaiResult(result, sensitive);
484
+ const safe = (result) => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive);
476
485
  const built = buildCliArgs(q);
477
486
  if (built.error)
478
- return { ...badArg(q.kind, built.error, ts), ...envNote };
487
+ return safe({ ...badArg(q.kind, built.error, ts), ...envNote });
479
488
  const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16'];
480
489
  const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args], {
481
490
  timeoutMs: q.timeoutMs ?? 30_000,
@@ -483,7 +492,6 @@ export async function flyaiSearch(q) {
483
492
  signal: q.signal,
484
493
  });
485
494
  const latencyMs = Date.now() - started;
486
- const combined = `${r.stderr}\n${r.stdout}`;
487
495
  if (r.cancelled) {
488
496
  return safe({
489
497
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'cancelled',
@@ -507,60 +515,65 @@ export async function flyaiSearch(q) {
507
515
  if (r.localTermination) {
508
516
  return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote));
509
517
  }
510
- if (r.error || r.code !== 0 || /HTTP\s*(4\d\d|5\d\d)/.test(combined)) {
518
+ if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
511
519
  return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote));
512
520
  }
521
+ // Validate the response envelope before accepting inventory for any kind.
522
+ // A business status is not an HTTP status; successful data is never scanned
523
+ // for fault words (for example an AI answer quoting an HTTP error).
524
+ let envelope;
525
+ try {
526
+ envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
527
+ || explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''));
528
+ }
529
+ catch (e) {
530
+ if (explicitUpstreamFailure(r.stdout))
531
+ return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote));
532
+ const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
533
+ return safe({
534
+ kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
535
+ evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
536
+ error: `failed to parse flyai output (${e.message}): ${raw}`,
537
+ });
538
+ }
539
+ const systemMessage = nonEmptyText(envelope.systemMessage);
540
+ const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`;
541
+ if (explicitUpstreamFailure(envelopeFailure)) {
542
+ return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote));
543
+ }
544
+ if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
545
+ return safe({
546
+ kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
547
+ evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
548
+ error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
549
+ ...(systemMessage ? { systemMessage } : {}),
550
+ });
551
+ }
513
552
  // ai-search:data 是自由形状,不走 itemList。
514
553
  if (q.kind === 'ai') {
515
- let envelope;
516
- try {
517
- envelope = parseFlyaiJsonEnvelope(r.stdout);
518
- }
519
- catch (e) {
520
- const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
521
- return safe({
522
- kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
523
- evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
524
- error: `failed to parse flyai ai output (${e.message}): ${raw}`,
525
- });
526
- }
527
- const systemMessage = nonEmptyText(envelope.systemMessage);
528
- if (envelope.status !== undefined && envelope.status !== 0) {
554
+ if (!Object.hasOwn(envelope, 'data')) {
529
555
  return safe({
530
556
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
531
- evidence: `[实时API:flyai@error@${ts}] ai status=${String(envelope.status)}`,
532
- error: nonEmptyText(envelope.message) ?? 'ai-search upstream failure',
557
+ evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
558
+ error: 'FlyAI ai response malformed: missing data member',
533
559
  ...(systemMessage ? { systemMessage } : {}),
534
560
  });
535
561
  }
536
562
  const aiData = envelope.data;
537
- const empty = aiData === undefined || aiData === null || aiData === ''
563
+ const empty = aiData === null || aiData === ''
538
564
  || (Array.isArray(aiData) && aiData.length === 0);
539
565
  return safe(JSON.parse(JSON.stringify({
540
- kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
566
+ kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
541
567
  verdict: empty ? 'miss' : 'hit',
542
568
  ...(empty ? {} : { aiData }),
543
569
  ...(systemMessage ? { systemMessage } : {}),
544
570
  evidence: `[实时API:flyai@${ts}] ai-search ${empty ? 'empty' : 'data returned'}`,
545
571
  })));
546
572
  }
547
- let items;
548
- try {
549
- items = parseFlyaiItemList(r.stdout);
550
- }
551
- catch (e) {
573
+ const data = envelope.data;
574
+ if (!Array.isArray(data?.itemList)) {
552
575
  const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
553
- // Sentinel 限流:exit 0,stdout 为 {"message":"SentinelBlockException…"}
554
- if (/Sentinel/i.test(raw)) {
555
- return safe({
556
- kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
557
- retryable: false,
558
- evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
559
- error: `FlyAI Sentinel 限流:${raw}`,
560
- setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
561
- });
562
- }
563
- const reason = e.message;
576
+ const reason = 'no complete FlyAI itemList JSON object';
564
577
  return safe({
565
578
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
566
579
  retryable: false,
@@ -568,7 +581,15 @@ export async function flyaiSearch(q) {
568
581
  error: `failed to parse flyai output (${reason}): ${raw}`,
569
582
  });
570
583
  }
571
- return safe(parseItemListResult(q.kind, items, latencyMs, ts, envNote, r.stdout));
584
+ return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout));
585
+ }
586
+ // Explicit protocol/transport evidence only. Bare business codes and generic
587
+ // words such as "network" or "timeout" do not diagnose an upstream outage.
588
+ const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i;
589
+ const TRANSPORT_FAILURE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b|socket hang up|fetch failed|network (?:error|failure|unreachable)|(?:request|connection) (?:timed out|timeout|reset|refused)/i;
590
+ function explicitUpstreamFailure(text) {
591
+ return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
592
+ || /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text);
572
593
  }
573
594
  /** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
574
595
  async function runtimeEnvNote() {
@@ -587,38 +608,46 @@ async function runtimeEnvNote() {
587
608
  /** 非 0 退出 / HTTP 4xx-5xx 文本 → 故障闭集分类 */
588
609
  function classifyUpstreamFailure(kind, r, latencyMs, ts, envNote) {
589
610
  const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim();
590
- const tag = (verdict, evidence, extra) => ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra });
591
- if (/Invalid API key|HTTP\s*401|\b401\b/.test(upstream)) {
611
+ const tag = (verdict, evidence, extra) => ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra });
612
+ const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1]);
613
+ if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
592
614
  return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
593
615
  retryable: false,
594
616
  error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
595
617
  setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
596
618
  });
597
619
  }
598
- if (/HTTP\s*403|\b403\b/.test(upstream)) {
620
+ if (httpStatus === 403) {
599
621
  return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
600
622
  retryable: false,
601
623
  error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
602
624
  setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
603
625
  });
604
626
  }
605
- if (/Trial limit reached/.test(upstream)) {
627
+ if (/Trial limit reached/i.test(upstream)) {
606
628
  return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
607
629
  retryable: false,
608
630
  error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
609
631
  setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
610
632
  });
611
633
  }
612
- if (/HTTP\s*429|\b429\b/.test(upstream)) {
634
+ if (/SentinelBlockException/i.test(upstream)) {
635
+ return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
636
+ retryable: false, upstreamHealth: 'unhealthy',
637
+ error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
638
+ setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
639
+ });
640
+ }
641
+ if (httpStatus === 429) {
613
642
  return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
614
- retryable: true,
643
+ retryable: true, upstreamHealth: 'unhealthy',
615
644
  error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
616
645
  setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
617
646
  });
618
647
  }
619
- if (/timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|socket|network|fetch failed|HTTP\s*5\d\d|\b5\d\d\b/i.test(upstream)) {
648
+ if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
620
649
  return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
621
- retryable: true,
650
+ retryable: true, upstreamHealth: 'unhealthy',
622
651
  error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
623
652
  setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
624
653
  });
@@ -676,7 +705,7 @@ function parseItemListResult(kind, items, latencyMs, ts, envNote, rawStdout) {
676
705
  const count = parsed.options.length;
677
706
  const verdict = count > 0 ? 'hit' : 'miss';
678
707
  const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`;
679
- const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai', verdict, evidence, ...(systemMessage ? { systemMessage } : {}) };
708
+ const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy', verdict, evidence, ...(systemMessage ? { systemMessage } : {}) };
680
709
  switch (kind) {
681
710
  case 'flight':
682
711
  case 'train':
@@ -781,7 +810,9 @@ function parseHotelItems(items) {
781
810
  }
782
811
  const it = raw;
783
812
  const name = nonEmptyText(it.name);
784
- const star = stringField(it, 'star');
813
+ // Unrated homestays may return whitespace (live Harbin response).
814
+ // Only this optional descriptive field permits blank text; types stay strict.
815
+ const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star');
785
816
  const rawPrice = stringField(it, 'price');
786
817
  const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad';
787
818
  const address = stringField(it, 'address');
@@ -0,0 +1,156 @@
1
+ /** Official hbcli credential-file contract; no credentials enter tool results. */
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { atomicWriteJson, sha256Hex } from './flyai-config.js';
6
+ import { hbcliBinCandidates } from './hbcli.js';
7
+ import { spawnBounded } from './spawn-bounded.js';
8
+ export const HBCLI_REGISTRATION_URL = 'https://hotelbyte.com/zh/guides/sandbox-verification';
9
+ const ENVIRONMENT = 'uat';
10
+ const ENTRY = `openapi:${ENVIRONMENT}`;
11
+ export function hbcliCredentialRoot(options) {
12
+ return options.env?.STAICLI_HOME?.trim() || join(options.homeDir ?? homedir(), '.staicli');
13
+ }
14
+ export function hbcliCredentialPath(options) { return join(hbcliCredentialRoot(options), 'credentials.json'); }
15
+ export function hbcliVerificationPath(homeDir) { return join(homeDir, '.gotry', 'hbcli-verification.json'); }
16
+ function safeEntry(path, directory) {
17
+ try {
18
+ const entry = lstatSync(path);
19
+ return !entry.isSymbolicLink() && (directory ? entry.isDirectory() : entry.isFile())
20
+ && (typeof process.getuid !== 'function' || entry.uid === process.getuid());
21
+ }
22
+ catch (e) {
23
+ return e.code === 'ENOENT';
24
+ }
25
+ }
26
+ export function readHbcliCredentials(options) {
27
+ const root = hbcliCredentialRoot(options), file = hbcliCredentialPath(options);
28
+ try {
29
+ if (!isAbsolute(root) || !safeEntry(root, true) || !safeEntry(file, false))
30
+ return { ok: false };
31
+ if (!existsSync(file))
32
+ return { ok: true, data: {} };
33
+ const bytes = readFileSync(file, 'utf8'), data = JSON.parse(bytes);
34
+ return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data, bytes } : { ok: false };
35
+ }
36
+ catch {
37
+ return { ok: false };
38
+ }
39
+ }
40
+ function fingerprint(data) {
41
+ const entry = data[ENTRY];
42
+ return typeof entry?.appKey === 'string' && entry.appKey.trim() && typeof entry.appSecret === 'string' && entry.appSecret.trim()
43
+ ? sha256Hex(JSON.stringify([ENVIRONMENT, entry.appKey, entry.appSecret])) : undefined;
44
+ }
45
+ function readReceipt(home) {
46
+ try {
47
+ const path = hbcliVerificationPath(home);
48
+ if (!safeEntry(join(home, '.gotry'), true) || !safeEntry(path, false))
49
+ return undefined;
50
+ const v = JSON.parse(readFileSync(path, 'utf8'));
51
+ return v.schema === 'gotry.hbcli-verification.v1' && typeof v.fingerprint === 'string'
52
+ && ['hit', 'miss', 'no-suppliers'].includes(v.verdict) && Number.isFinite(Date.parse(v.at)) ? v : undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ export function hbcliSetupStatus(options) {
59
+ const read = readHbcliCredentials(options), hash = read.ok ? fingerprint(read.data) : undefined;
60
+ const receipt = readReceipt(options.homeDir ?? homedir());
61
+ const envToken = Boolean(options.env?.HOTELBYTE_TOKEN?.trim());
62
+ const matches = !envToken && hash && receipt?.fingerprint === hash;
63
+ return { ok: true, configured: envToken || Boolean(hash), writable: read.ok && !envToken && process.platform !== 'win32',
64
+ source: envToken ? 'env' : hash ? 'config' : 'none', environment: ENVIRONMENT,
65
+ verified: Boolean(matches && receipt?.verdict !== 'no-suppliers'),
66
+ ...(matches && receipt ? { verdict: receipt.verdict, checkedAt: receipt.at } : {}), registrationUrl: HBCLI_REGISTRATION_URL };
67
+ }
68
+ function prepareDirectory(dir) {
69
+ try {
70
+ if (process.platform === 'win32' || !isAbsolute(dir) || !safeEntry(dir, true))
71
+ return false;
72
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
73
+ if (!safeEntry(dir, true))
74
+ return false;
75
+ chmodSync(dir, 0o700);
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ export function writeHbcliCredentials(data, options) {
83
+ const path = hbcliCredentialPath(options);
84
+ return readHbcliCredentials(options).ok && prepareDirectory(hbcliCredentialRoot(options)) && atomicWriteJson(path, data).ok;
85
+ }
86
+ export function writeHbcliVerification(data, verdict, options) {
87
+ const hash = fingerprint(data), home = options.homeDir ?? homedir();
88
+ if (!hash || !prepareDirectory(join(home, '.gotry')))
89
+ return false;
90
+ const entry = { schema: 'gotry.hbcli-verification.v1', fingerprint: hash, verdict, at: new Date().toISOString() };
91
+ return safeEntry(hbcliVerificationPath(home), false) && atomicWriteJson(hbcliVerificationPath(home), entry).ok;
92
+ }
93
+ export function removeHbcliVerification(homeDir) {
94
+ if (safeEntry(join(homeDir, '.gotry'), true) && safeEntry(hbcliVerificationPath(homeDir), false)) {
95
+ try {
96
+ rmSync(hbcliVerificationPath(homeDir), { force: true });
97
+ }
98
+ catch { /* best effort */ }
99
+ }
100
+ }
101
+ export function withHbcliCredentials(data, appKey, appSecret) {
102
+ return { ...data, [ENTRY]: { appKey, appSecret } };
103
+ }
104
+ export function withoutHbcliCredentials(data) {
105
+ const next = { ...data };
106
+ delete next[ENTRY];
107
+ return next;
108
+ }
109
+ /** Verify through the existing CLI bridge, with a private disposable credential root.
110
+ * An upstream no-suppliers verdict is retained separately from a completed query.
111
+ */
112
+ export async function verifyHbcliCandidate(appKey, appSecret, options, signal) {
113
+ const root = mkdtempSync(join(tmpdir(), 'gotry-hbcli-verify-'));
114
+ try {
115
+ if (!atomicWriteJson(join(root, 'credentials.json'), withHbcliCredentials({}, appKey, appSecret)).ok) {
116
+ return { verdict: 'error', error: '无法创建隔离的验证环境。' };
117
+ }
118
+ const checkIn = new Date();
119
+ checkIn.setUTCDate(checkIn.getUTCDate() + 1);
120
+ const checkOut = new Date(checkIn);
121
+ checkOut.setUTCDate(checkOut.getUTCDate() + 1);
122
+ const args = ['--json', '--env', ENVIRONMENT, 'search', 'hotel-list', '--destination-name', '上海',
123
+ '--check-in', checkIn.toISOString().slice(0, 10), '--check-out', checkOut.toISOString().slice(0, 10),
124
+ '--room-occupancies', '[{"adultCount":2,"childrenAges":[]}]', '--page-size', '1'];
125
+ const env = { ...(options.env ?? process.env), STAICLI_HOME: root, HOTELBYTE_ENV: ENVIRONMENT };
126
+ delete env.HOTELBYTE_TOKEN;
127
+ let result;
128
+ for (const bin of hbcliBinCandidates(options.hbcliBin ?? options.env?.GOTRY_HBCLI_BIN ?? 'hbcli', options.homeDir)) {
129
+ result = await spawnBounded(bin, args, { env, signal, timeoutMs: 20_000 });
130
+ if (!/ENOENT/.test(result.error ?? '') || signal.aborted)
131
+ break;
132
+ }
133
+ if (signal.aborted || result?.aborted)
134
+ return { verdict: 'cancelled', error: '验证已取消,旧配置保留。' };
135
+ if (!result || result.error || result.timedOut || result.drainTimedOut || result.groupReaped === false || result.code !== 0) {
136
+ return { verdict: 'error', error: 'HotelByte 验证未完成,请检查 hbcli 安装、网络和凭证;旧配置保留。' };
137
+ }
138
+ let value;
139
+ try {
140
+ value = JSON.parse(result.stdout);
141
+ }
142
+ catch {
143
+ return { verdict: 'error', error: 'HotelByte 回包无法识别,旧配置保留。' };
144
+ }
145
+ if (value?.code === 300010002 || value?.code === '300010002')
146
+ return { verdict: 'no-suppliers' };
147
+ if (!value || (value.code !== undefined && value.code !== 0 && value.code !== '0') || !Array.isArray(value.list)
148
+ || value.list.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
149
+ return { verdict: 'error', error: 'HotelByte 未接受凭证或返回了无效的查询结果;旧配置保留。' };
150
+ }
151
+ return { verdict: value.list.length ? 'hit' : 'miss' };
152
+ }
153
+ finally {
154
+ rmSync(root, { recursive: true, force: true });
155
+ }
156
+ }
@@ -65,6 +65,24 @@ async function attemptHbcli(bin, args, opts) {
65
65
  }
66
66
  catch { /* 非 JSON 输出,留给调用方处理 */ }
67
67
  }
68
+ // CLI exit status describes the process, not the API verdict. hbcli can exit
69
+ // zero with an API error envelope (for example an unconfigured supplier pool).
70
+ const code = result?.code;
71
+ const invalid = result === null;
72
+ const upstreamFailure = code !== undefined && code !== 0 && code !== '0';
73
+ if (invalid || upstreamFailure) {
74
+ const msg = result?.msg
75
+ ?? result?.message;
76
+ const detail = code === 300010002 || code === '300010002'
77
+ ? '当前账号没有可用供应商;库存查询未完成'
78
+ : typeof msg === 'string' ? msg.slice(0, 200) : 'API request failed';
79
+ return {
80
+ via: 'hbcli-error', exitCode: 0, result: null,
81
+ evidence: `[实时API:hbcli@error@${ts}]`,
82
+ stdout: r.stdout.slice(0, 2000), latencyMs,
83
+ error: invalid ? 'failed to parse hbcli output as JSON' : `hbcli upstream code ${String(code)}: ${detail}`,
84
+ };
85
+ }
68
86
  return {
69
87
  via: 'hbcli-realtime', exitCode: 0, result,
70
88
  evidence: `[实时API:hbcli@${ts}]`,
@@ -138,7 +156,7 @@ export async function searchHotels(query, opts = {}) {
138
156
  hbArgs.push('--check-out', query.checkOut);
139
157
  if (query.adults)
140
158
  hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]));
141
- const live = await callHbcliJson(hbArgs, opts);
159
+ let live = await callHbcliJson(hbArgs, opts);
142
160
  // 取消立即结束:不读静态包、不产出命中/无结果/估算,也不计上游故障
143
161
  if (opts.signal?.aborted || /abort/i.test(live.error ?? '')) {
144
162
  const ts = new Date().toISOString();
@@ -149,6 +167,15 @@ export async function searchHotels(query, opts = {}) {
149
167
  latencyMs: live.latencyMs, error: `aborted by host signal${cleanupError}`, summary: `酒店「${query.destination}」检索已取消,未产生事实`,
150
168
  };
151
169
  }
170
+ const list = live.result?.list;
171
+ if (live.via === 'hbcli-realtime' && (!Array.isArray(list)
172
+ || list.some(item => item === null || typeof item !== 'object' || Array.isArray(item)))) {
173
+ live = {
174
+ ...live, via: 'hbcli-error', result: null,
175
+ evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
176
+ error: 'invalid hotel-list response: expected list array of hotels; inventory not observed',
177
+ };
178
+ }
152
179
  if (live.via === 'hbcli-realtime') {
153
180
  // summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
154
181
  // 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
@@ -247,7 +274,7 @@ export async function checkAvail(query, opts = {}) {
247
274
  * ~/.staicli/credentials.json)——hbcli 侧 customer 档是最低优先级凭据(无
248
275
  * portal/API-key 凭据时才生效),登录不劫持既有凭据。本层不透出 token 值
249
276
  * (CLI 只回 token_saved,凭据留在 CLI 仓内,不进 gotry 会话/日志)。
250
- * auth 命令族要求 hbcli 带 register 支持(npm 0.0.4 起;0.0.3 报 unknown
277
+ * auth 命令族要求 hbcli 带 register 支持(官方原生 0.0.4 起;0.0.3 报 unknown
251
278
  * command,经 hbcli-error 面优雅失败)。
252
279
  */
253
280
  export async function customerSendCode(email, opts = {}) {
@@ -108,7 +108,7 @@ export async function withRetry(fn, policy, onBackoff) {
108
108
  export class CircuitBreaker {
109
109
  o;
110
110
  consecutiveFailures = 0;
111
- openedAt = 0;
111
+ openedAt = null;
112
112
  generation = 0;
113
113
  nextTokenId = 0;
114
114
  probeToken = null;
@@ -122,9 +122,9 @@ export class CircuitBreaker {
122
122
  return (this.o.now ?? Date.now)();
123
123
  }
124
124
  state() {
125
- if (this.openedAt > 0 && this.now() - this.openedAt >= this.o.openMs)
125
+ if (this.openedAt !== null && this.now() - this.openedAt >= this.o.openMs)
126
126
  return 'half-open';
127
- if (this.openedAt > 0)
127
+ if (this.openedAt !== null)
128
128
  return 'open';
129
129
  return 'closed';
130
130
  }
@@ -134,7 +134,7 @@ export class CircuitBreaker {
134
134
  if (s === 'closed')
135
135
  return { allowed: true, state: s, token: this.makeToken(false) };
136
136
  if (s === 'open')
137
- return { allowed: false, state: s };
137
+ return { allowed: false, state: s, retryAfterMs: Math.max(0, Math.min(this.o.openMs, this.o.openMs - (this.now() - this.openedAt))) };
138
138
  // half-open:单探测语义——已有探测在途拒其余并发
139
139
  if (this.probeToken)
140
140
  return { allowed: false, state: s };
@@ -158,7 +158,7 @@ export class CircuitBreaker {
158
158
  if (!this.owns(token))
159
159
  return;
160
160
  this.consecutiveFailures = 0;
161
- this.openedAt = 0;
161
+ this.openedAt = null;
162
162
  if (token?.probe === true || (!token && this.probeToken !== null)) {
163
163
  this.probeToken = null;
164
164
  this.generation += 1;
@@ -171,7 +171,7 @@ export class CircuitBreaker {
171
171
  if (token?.probe === true || (!token && this.probeToken !== null))
172
172
  this.probeToken = null;
173
173
  this.consecutiveFailures += 1;
174
- if (this.openedAt > 0 || this.consecutiveFailures >= this.threshold()) {
174
+ if (this.openedAt !== null || this.consecutiveFailures >= this.threshold()) {
175
175
  this.openedAt = this.now();
176
176
  this.generation += 1;
177
177
  }