@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +47 -3
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/doctor.js +11 -4
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +71 -1
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +37 -1
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +603 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +37 -1
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +160 -1
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +175 -2
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ Trust is structural, not promised:
|
|
|
153
153
|
## Project Status and Roadmap
|
|
154
154
|
Current boundary: deterministic planning and evidence-bound read capabilities are available; booking and real-user acceptance remain outside the shipped claim. See the [architecture](docs/architecture.md) and [roadmap](docs/roadmap.md).
|
|
155
155
|
|
|
156
|
-
**v0.0
|
|
156
|
+
**v0.2.0-rc.27** on npm (`latest`). Pre-1.0: the core loop works end to end; evaluation is still at deterministic contracts and validators, with no external scores or uplift claims. External W2A events are contract-only and inert; no real sensor bridge or consumer is active yet.
|
|
157
157
|
|
|
158
158
|
**Working today**
|
|
159
159
|
|
|
@@ -209,7 +209,7 @@ Documents ship as bilingual pairs (`x.md` English + `x.zh-CN.md` 中文); diverg
|
|
|
209
209
|
|
|
210
210
|
**Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
|
|
211
211
|
|
|
212
|
-
**Version baseline: `v0.0
|
|
212
|
+
**Version baseline: `v0.2.0-rc.27` (npm `latest`).** Verification gates: `scripts/run-all-tests.sh`; release flow: `scripts/publish-npm.sh`.
|
|
213
213
|
|
|
214
214
|
<a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
|
|
215
215
|
<picture>
|
package/README.zh-CN.md
CHANGED
|
@@ -155,7 +155,7 @@ npx @danceiny/gotry "我想从深圳休整两天,预算 3000" # headless 一
|
|
|
155
155
|
## 状态与路线图
|
|
156
156
|
当前边界:确定性规划与有证据边界的只读能力已经可用;预订与真实用户验收仍不属于当前交付声明。见[架构权威文档](docs/architecture.zh-CN.md)与[路线图](docs/roadmap.zh-CN.md)。
|
|
157
157
|
|
|
158
|
-
npm `latest`:**v0.0
|
|
158
|
+
npm `latest`:**v0.2.0-rc.27**。未到 1.0:核心链路已端到端可用;评测仍停留在确定性合同与校验器阶段,无外部分数、无 uplift 声明。外部 W2A 事件仍是合同层、默认 inert;尚无真实 sensor 桥或 consumer 激活。
|
|
159
159
|
|
|
160
160
|
**今天可用**
|
|
161
161
|
|
|
@@ -211,7 +211,7 @@ AI agent:[`AGENTS.md`](AGENTS.zh-CN.md) 是绑定契约——入场先清扫
|
|
|
211
211
|
|
|
212
212
|
**Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
|
|
213
213
|
|
|
214
|
-
**版本基线:`v0.0
|
|
214
|
+
**版本基线:`v0.2.0-rc.27`(npm `latest`)。** 验证闸:`scripts/run-all-tests.sh`;发布流程:`scripts/publish-npm.sh`。
|
|
215
215
|
|
|
216
216
|
<a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
|
|
217
217
|
<picture>
|
package/bin/gotry-bootstrap.js
CHANGED
|
@@ -634,6 +634,42 @@ function probe(cmd, args, timeoutMs = 10_000) {
|
|
|
634
634
|
})
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
+
/**
|
|
638
|
+
* 探测命令 stdout,失败(spawn 错误/非零退出/超时)返回 null。
|
|
639
|
+
* 在 'close' 而非 'exit' 结算:'exit' 触发时 stdout 管道可能还没读完,输出会丢成空串
|
|
640
|
+
* (与 ts/capabilities/doctor.ts 的 probeStdout 同一处修复,#622)。
|
|
641
|
+
*/
|
|
642
|
+
function probeStdout(cmd, args, timeoutMs = 10_000) {
|
|
643
|
+
return new Promise((resolve) => {
|
|
644
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
645
|
+
let out = ''
|
|
646
|
+
let done = false
|
|
647
|
+
const timer = setTimeout(() => {
|
|
648
|
+
if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(null) }
|
|
649
|
+
}, timeoutMs)
|
|
650
|
+
child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(null) } })
|
|
651
|
+
child.stdout?.on('data', (d) => { out += d.toString() })
|
|
652
|
+
child.on('close', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0 ? out.trim() : null) } })
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 按 `hbcli --json auth whoami` 的输出判凭证(hotelbyte-cli 三档 api_key/portal/customer 任一
|
|
658
|
+
* configured 即有效),不看退出码——whoami 没有凭证时也退 0,按退出码判会把「没登录」报成「凭证有效」(#623)。
|
|
659
|
+
* 与工具层 ts/capabilities/doctor.ts 同语义:输出不可解析(超旧版/异常)保守视为有效;取不到输出(null)视为未确认。
|
|
660
|
+
* 两处实现的一致性由 bootstrap-tests 的 CLI↔工具层对拍守住。
|
|
661
|
+
*/
|
|
662
|
+
function classifyHbcliWhoami(out) {
|
|
663
|
+
if (out === null) return { ok: false, customer: false }
|
|
664
|
+
try {
|
|
665
|
+
const w = JSON.parse(out)
|
|
666
|
+
const customer = !!w?.customer?.configured
|
|
667
|
+
return { ok: !!(w?.api_key?.configured || w?.portal?.configured || customer), customer }
|
|
668
|
+
} catch {
|
|
669
|
+
return { ok: true, customer: false }
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
637
673
|
const say = (s) => console.log(s)
|
|
638
674
|
|
|
639
675
|
/** hbcli 最低版本(可用 GOTRY_MIN_HBCLI_VERSION 覆盖)。
|
|
@@ -664,7 +700,7 @@ async function hbcliVersion(bin) {
|
|
|
664
700
|
const timer = setTimeout(() => { if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(null) } }, 10_000)
|
|
665
701
|
child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(null) } })
|
|
666
702
|
child.stdout?.on('data', (d) => { out += d.toString() })
|
|
667
|
-
child.on('
|
|
703
|
+
child.on('close', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0 ? parseVersion(out.trim()) : null) } })
|
|
668
704
|
})
|
|
669
705
|
}
|
|
670
706
|
|
|
@@ -872,8 +908,15 @@ async function doctorChecks() {
|
|
|
872
908
|
if (v && !versionAtLeast(v, MIN_HBCLI_VERSION)) {
|
|
873
909
|
items.push({ label: 'hbcli(酒店实时源)', ok: false, level: 'missing', detail: `版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——trade.* / search/checkAvail 会 401(issue #142)`, fix: 'npm install -g staicli --registry=https://registry.npmjs.org/' })
|
|
874
910
|
} else {
|
|
875
|
-
|
|
876
|
-
|
|
911
|
+
// 凭证按 whoami 的 JSON 三档判定,不再看退出码(#623:whoami 无凭证也退 0,旧判法恒报「凭证有效」)
|
|
912
|
+
const cred = classifyHbcliWhoami(await probeStdout(hbBin === 'hbcli(PATH)' ? 'hbcli' : hbBin, ['--json', 'auth', 'whoami']))
|
|
913
|
+
items.push({
|
|
914
|
+
label: 'hbcli(酒店实时源)', ok: cred.ok, level: cred.ok ? 'ok' : 'degraded',
|
|
915
|
+
detail: cred.ok
|
|
916
|
+
? `已安装且凭证有效(${hbBin}, v${v ? v.join('.') : '?'}${cred.customer ? ',customer 档在用(客户邮箱验证码登录)' : ''})`
|
|
917
|
+
: '二进制在,但凭证未配置/失效——酒店检索将降级静态包(非实时)',
|
|
918
|
+
fix: cred.ok ? undefined : 'hbcli auth customer-send-code --email you@mail.com 收码后 hbcli auth customer-login --email you@mail.com --code <收件码>(客户邮箱验证码登录,新邮箱即注册,需 npm staicli ≥ 0.0.4);B 端租户注册走 hbcli auth register(先 auth send-code);或 hbcli auth set-credentials --app-key hotelbyte_api_demo --app-secret hotelbyte_api_demo(快速试用沙箱;正式 key 向 HotelByte 申请)',
|
|
919
|
+
})
|
|
877
920
|
}
|
|
878
921
|
} else {
|
|
879
922
|
items.push({ label: 'hbcli(酒店实时源)', ok: false, level: 'missing', detail: '未安装——酒店检索降级静态包(公开渠道估算,非实时,仅覆盖内置场景)', fix: 'npx @danceiny/gotry doctor --fix' })
|
|
@@ -1741,4 +1784,5 @@ export {
|
|
|
1741
1784
|
classifyDoctorGap, buildOnboardingPlan, onboardingSkipReason,
|
|
1742
1785
|
runOnboardingFix, promptOnboarding, renderClassifiedPlan, runOnboarding, orchestrateWebLaunch,
|
|
1743
1786
|
doctorFixAutoSupported, platformAutoUnavailableReason, installerEnabled,
|
|
1787
|
+
classifyHbcliWhoami,
|
|
1744
1788
|
}
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* - miss: hbcli exit 0,但 candidates 为空(查无结果)
|
|
20
20
|
* - err: hbcli exit ≠ 0 或超时(降级,降级产物 标 [实时API:hbcli-anything@error@ts])
|
|
21
21
|
*
|
|
22
|
+
* 离线开关(issue #617;与会话通道 GOTRY_SESSION_LIVE 同形,方向相反以保持产品默认不变):
|
|
23
|
+
* GOTRY_HBCLI_LIVE=0|false|off(不分大小写)→ 零 spawn、不碰凭据与后端,走同一条降级面
|
|
24
|
+
* (verdict=error,证据标 [实时API:hbcli-anything@offline@ts]);未设或任何其他取值 = 与此前
|
|
25
|
+
* 完全一致(hbcli 可用即 live)。判定见 hbcliLiveEnabled;mock/dry-run harness(nightly-evidence --dry-run、
|
|
26
|
+
* persona-sim、replay*)缺省把它置 0,operator 显式设置的值不覆盖。
|
|
27
|
+
*
|
|
22
28
|
* 契约(与 hbcli/weather/opensky 同构,L4 不变量):
|
|
23
29
|
* - 永不抛错:网络/超时/解析失败一律降级返回;
|
|
24
30
|
* - 证据链标注:成功 [实时API:hbcli-anything@ts];失败 [实时API:hbcli-anything@error@ts];
|
|
@@ -84,6 +90,15 @@ function failureReason(r, cleanupError) {
|
|
|
84
90
|
? 'hbcli 未返回可判定结果(进程异常结束,本轮按降级处理)'
|
|
85
91
|
: `hbcli 退出码 ${r.code},未返回可判定结果(本轮按降级处理)`) + suffix;
|
|
86
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* 实时 hbcli 通道是否开启(issue #617)。只有显式写 0 / false / off(不分大小写,忽略首尾空白)
|
|
95
|
+
* 才关闭;未设或任何其他取值都视为开启——终端用户依赖的实时源缺省不丢。调用时读取,
|
|
96
|
+
* 测试与 harness 可在进程内切换。
|
|
97
|
+
*/
|
|
98
|
+
export function hbcliLiveEnabled(env = process.env) {
|
|
99
|
+
const v = (env.GOTRY_HBCLI_LIVE ?? '').trim().toLowerCase();
|
|
100
|
+
return v !== '0' && v !== 'false' && v !== 'off';
|
|
101
|
+
}
|
|
87
102
|
/** Anything 通用搜索 — 任何搜索失败走降级;不抛错 */
|
|
88
103
|
export async function anythingSearch(q) {
|
|
89
104
|
const started = Date.now();
|
|
@@ -107,6 +122,17 @@ export async function anythingSearch(q) {
|
|
|
107
122
|
latencyMs: 0, verdict: 'error', error: 'aborted by host signal',
|
|
108
123
|
};
|
|
109
124
|
}
|
|
125
|
+
// 离线开关:位置在入参校验与 pre-abort 之后,既有入参的既有结果/原因串逐字节不变;
|
|
126
|
+
// 关闭时与「未装配 hbcli」同形降级(ok:false / hbcli-anything-error / verdict:error + 人话原因,
|
|
127
|
+
// 无进程噪音,也不含 timeout 字样——效应层只重试 timeout 类),且不 spawn、不重试。
|
|
128
|
+
if (!hbcliLiveEnabled()) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false, via: 'hbcli-anything-error',
|
|
131
|
+
evidence: `[实时API:hbcli-anything@offline@${ts}] GOTRY_HBCLI_LIVE switched off; live channel closed, zero spawn`,
|
|
132
|
+
latencyMs: Date.now() - started, verdict: 'error',
|
|
133
|
+
error: 'hbcli 实时通道已关闭(GOTRY_HBCLI_LIVE 离线开关;本轮按降级处理,未发起查询)',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
110
136
|
let r = { code: null, stdout: '', stderr: '', aborted: false, timedOut: false };
|
|
111
137
|
for (let i = 0; i < candidates.length; i++) {
|
|
112
138
|
r = await sh(candidates[i], args, { timeoutMs: q.timeoutMs ?? 12_000, env: process.env, signal: q.signal });
|
|
@@ -52,10 +52,17 @@ function probe(bin, args, timeoutMs = 8_000) {
|
|
|
52
52
|
} });
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 探测命令 stdout(--version),失败返回 null。
|
|
57
|
+
*
|
|
58
|
+
* 在 'close' 而非 'exit' 结算:'exit' 触发时 stdout 管道可能还没读完(Node 文档明言),
|
|
59
|
+
* 秒退进程的输出会丢成空串,而空串走「不可解析 = 保守视为有效」分支,把无凭证的 hbcli
|
|
60
|
+
* 报成 ok。'close' 在 stdio 全部关闭后才触发;超时仍由下方定时器兜底。
|
|
61
|
+
* `spawnImpl` 仅供测试注入假子进程以确定性复现「exit 先于 data」的次序。
|
|
62
|
+
*/
|
|
63
|
+
export function probeStdout(bin, args, timeoutMs = 8_000, spawnImpl = spawn) {
|
|
57
64
|
return new Promise((resolveProbe) => {
|
|
58
|
-
const child =
|
|
65
|
+
const child = spawnImpl(bin, args, { stdio: ['ignore', 'pipe', 'ignore'], env: process.env });
|
|
59
66
|
let out = '';
|
|
60
67
|
let done = false;
|
|
61
68
|
const timer = setTimeout(() => {
|
|
@@ -74,7 +81,7 @@ function probeStdout(bin, args, timeoutMs = 8_000) {
|
|
|
74
81
|
resolveProbe(null);
|
|
75
82
|
} });
|
|
76
83
|
child.stdout?.on('data', (d) => { out += d.toString(); });
|
|
77
|
-
child.on('
|
|
84
|
+
child.on('close', (code) => { if (!done) {
|
|
78
85
|
done = true;
|
|
79
86
|
clearTimeout(timer);
|
|
80
87
|
resolveProbe(code === 0 ? out.trim() : null);
|
|
@@ -13,14 +13,52 @@
|
|
|
13
13
|
* layer. Cache, freshness, and fallback are isolated per direction so a
|
|
14
14
|
* miss / error / stale / mismatch in one direction never borrows the
|
|
15
15
|
* dynamic value of the other.
|
|
16
|
+
*
|
|
17
|
+
* A provider's OWN claims about its answer — declared `mode`, resolved
|
|
18
|
+
* origin/destination — are verified against the request whenever the provider
|
|
19
|
+
* makes them: a contradiction is refused with the conformance vocabulary
|
|
20
|
+
* (`mode_mismatch` / `direction_mismatch`) and falls back to the static estimate,
|
|
21
|
+
* so a transit duration is never bound as driving and a route between two other
|
|
22
|
+
* places is never bound as the airport transfer (issue #429 GAP-429-1/2). All
|
|
23
|
+
* three fields are OPTIONAL and absent means unverifiable: see
|
|
24
|
+
* `PublicMapDrivingRouteResult` for why absent is still accepted today.
|
|
25
|
+
*
|
|
26
|
+
* A provider failure becomes a `fallbackReason`, and `exposeGroundTransferEvidence`
|
|
27
|
+
* carries that text into the tool result and each matching verdict's
|
|
28
|
+
* `transfer_evidence`. It is therefore an EXPOSED surface: every provider-authored
|
|
29
|
+
* failure string passes through the shared `sanitizeFaultDetail` first, so markup,
|
|
30
|
+
* credential assignments and URLs from a provider response body can never reach it
|
|
31
|
+
* (issue #429 GAP-429-3). The historical 400-character bound and the historical
|
|
32
|
+
* empty-case wording are preserved, so existing `GROUND_TRANSFER_*` /
|
|
33
|
+
* `map_driving_route` reason strings are unchanged byte for byte.
|
|
16
34
|
*/
|
|
17
35
|
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
36
|
+
import { sanitizeFaultDetail } from './provider-detail-sanitize.js';
|
|
18
37
|
export const GROUND_TRANSFER_MODE = 'driving';
|
|
19
38
|
export const GROUND_TRANSFER_POSITION = 'destination';
|
|
20
39
|
export const GROUND_TRANSFER_STATIC_MODE = 'taxi';
|
|
21
40
|
export const GROUND_TRANSFER_STATIC_MODE_MISMATCH = 'ground_transfer_static_mode_mismatch';
|
|
22
41
|
export const GROUND_TRANSFER_STATIC_PRICE_EVIDENCE = '[静态包:估算]';
|
|
23
42
|
export const GROUND_TRANSFER_DEFAULT_MAX_AGE_S = 15 * 60;
|
|
43
|
+
/**
|
|
44
|
+
* Refusal classifications for a provider that contradicts its own request. The
|
|
45
|
+
* two names are the #429 conformance gate's vocabulary verbatim
|
|
46
|
+
* (`RouteErrorCode`), reused here as the `<code>:<direction>:<detail>` reason
|
|
47
|
+
* prefix this module already uses for `map_driving_route_provider_error` and
|
|
48
|
+
* `stale_route_requery_error`. They are duplicated as literals on purpose: the
|
|
49
|
+
* gate must stay free of product callers, so nothing here imports it.
|
|
50
|
+
*/
|
|
51
|
+
export const GROUND_TRANSFER_ROUTE_MODE_MISMATCH = 'mode_mismatch';
|
|
52
|
+
export const GROUND_TRANSFER_ROUTE_DIRECTION_MISMATCH = 'direction_mismatch';
|
|
53
|
+
/**
|
|
54
|
+
* Tolerance for verifying a provider's echoed endpoint against the requested
|
|
55
|
+
* one: 1e-4 degrees ≈ 11 m of latitude. A provider legitimately snaps a
|
|
56
|
+
* coordinate to the nearest routable road node, so an exact string match would
|
|
57
|
+
* refuse honest answers; anything beyond this bound is a different place, not a
|
|
58
|
+
* snap. Deliberately tight — refusing falls back to the static estimate, which
|
|
59
|
+
* is the safe direction.
|
|
60
|
+
*/
|
|
61
|
+
export const GROUND_TRANSFER_ECHO_TOLERANCE_DEG = 1e-4;
|
|
24
62
|
const GROUND_TRANSFER_CACHE_LIMIT = 32;
|
|
25
63
|
function asRecord(value) {
|
|
26
64
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -49,6 +87,50 @@ function coordinatePair(value) {
|
|
|
49
87
|
function coordinateText(value) {
|
|
50
88
|
return `${String(value.longitude)},${String(value.latitude)}`;
|
|
51
89
|
}
|
|
90
|
+
/** Parse a canonical `"lon,lat"` text back into a validated coordinate. */
|
|
91
|
+
function coordinateFromText(value) {
|
|
92
|
+
const parts = value.split(',');
|
|
93
|
+
if (parts.length !== 2)
|
|
94
|
+
return null;
|
|
95
|
+
const [longitudeText, latitudeText] = parts;
|
|
96
|
+
if (longitudeText.trim() === '' || latitudeText.trim() === '')
|
|
97
|
+
return null;
|
|
98
|
+
return coordinate({ longitude: Number(longitudeText), latitude: Number(latitudeText) });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Does a provider's echoed endpoint name the place that was requested?
|
|
102
|
+
*
|
|
103
|
+
* Compared within `GROUND_TRANSFER_ECHO_TOLERANCE_DEG` so a road-node snap still
|
|
104
|
+
* matches. An echo that cannot be parsed is NOT a match: present-but-unverifiable
|
|
105
|
+
* fails closed rather than being accepted as the requested pair.
|
|
106
|
+
*/
|
|
107
|
+
function sameEndpoint(echoed, requested) {
|
|
108
|
+
const left = coordinateFromText(echoed);
|
|
109
|
+
const right = coordinateFromText(requested);
|
|
110
|
+
if (left === null || right === null)
|
|
111
|
+
return false;
|
|
112
|
+
return Math.abs(left.longitude - right.longitude) <= GROUND_TRANSFER_ECHO_TOLERANCE_DEG
|
|
113
|
+
&& Math.abs(left.latitude - right.latitude) <= GROUND_TRANSFER_ECHO_TOLERANCE_DEG;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Normalize one of the provider's OWN optional claims into a comparable string.
|
|
117
|
+
*
|
|
118
|
+
* Absent (`undefined` / `null`) stays absent — that is the wired driving tool and
|
|
119
|
+
* its behaviour is unchanged. A present-but-unusable value is NOT dropped: it is
|
|
120
|
+
* carried as a non-empty string so the enforcement below can refuse it, because
|
|
121
|
+
* silently dropping a contradicting claim is exactly the relabel this seam
|
|
122
|
+
* forbids. A `{ longitude, latitude }` object is accepted as the canonical text.
|
|
123
|
+
*/
|
|
124
|
+
function declaredClaim(value) {
|
|
125
|
+
if (value === undefined || value === null)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (typeof value === 'string')
|
|
128
|
+
return value.trim() === '' ? '(blank)' : value.trim();
|
|
129
|
+
const asCoordinate = coordinate(value);
|
|
130
|
+
if (asCoordinate !== null)
|
|
131
|
+
return coordinateText(asCoordinate);
|
|
132
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
133
|
+
}
|
|
52
134
|
function cacheKey(direction) {
|
|
53
135
|
return JSON.stringify({
|
|
54
136
|
mode: GROUND_TRANSFER_MODE,
|
|
@@ -66,9 +148,33 @@ function nowMs(clock) {
|
|
|
66
148
|
function asOf(clock) {
|
|
67
149
|
return new Date(nowMs(clock)).toISOString();
|
|
68
150
|
}
|
|
151
|
+
/** Historical bound for an exposed provider failure string (unchanged). */
|
|
152
|
+
const GROUND_TRANSFER_FAULT_DETAIL_MAX_CHARS = 400;
|
|
153
|
+
/**
|
|
154
|
+
* Normalize a provider failure into an exposable label. The text reaches the tool
|
|
155
|
+
* result via `exposeGroundTransferEvidence`, so it is scrubbed of markup,
|
|
156
|
+
* credential assignments and URLs before anything else (#429 GAP-429-3); the
|
|
157
|
+
* historical length bound and empty-case wording are preserved.
|
|
158
|
+
*/
|
|
69
159
|
function safeErrorMessage(value) {
|
|
70
160
|
const message = value instanceof Error ? value.message : String(value);
|
|
71
|
-
return message
|
|
161
|
+
return sanitizeFaultDetail(message, {
|
|
162
|
+
maxChars: GROUND_TRANSFER_FAULT_DETAIL_MAX_CHARS,
|
|
163
|
+
emptyPlaceholder: 'unknown provider error',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Bound for a provider-authored claim quoted inside a refusal reason. */
|
|
167
|
+
const GROUND_TRANSFER_CLAIM_DETAIL_MAX_CHARS = 120;
|
|
168
|
+
/**
|
|
169
|
+
* A provider's own claim (mode / echoed endpoint / provider id) is provider-authored
|
|
170
|
+
* text, and a refusal reason reaches the tool result via
|
|
171
|
+
* `exposeGroundTransferEvidence`. Quote it only through the shared sanitizer.
|
|
172
|
+
*/
|
|
173
|
+
function safeClaim(value) {
|
|
174
|
+
return sanitizeFaultDetail(value, {
|
|
175
|
+
maxChars: GROUND_TRANSFER_CLAIM_DETAIL_MAX_CHARS,
|
|
176
|
+
emptyPlaceholder: '(absent)',
|
|
177
|
+
});
|
|
72
178
|
}
|
|
73
179
|
function staticCache(status = 'bypass') {
|
|
74
180
|
return { status, ageS: null, asOf: null };
|
|
@@ -189,14 +295,58 @@ function parseRouteResult(value) {
|
|
|
189
295
|
// zero minutes into the solver.
|
|
190
296
|
|| (distanceM > 0 && durationS === 0))
|
|
191
297
|
throw new Error('map_driving_route returned an invalid public result');
|
|
298
|
+
// The provider's own claims are CARRIED, never dropped: `routeClaimRefusal`
|
|
299
|
+
// below checks them when they exist. Absent stays absent (the wired driving
|
|
300
|
+
// tool sends none of the three), and re-parsing an already-parsed result is
|
|
301
|
+
// idempotent because the carried values are already canonical.
|
|
302
|
+
const declaredMode = declaredClaim(record['mode']);
|
|
303
|
+
const resolvedOrigin = declaredClaim(record['resolvedOrigin']);
|
|
304
|
+
const resolvedDestination = declaredClaim(record['resolvedDestination']);
|
|
192
305
|
return {
|
|
193
306
|
provider,
|
|
194
307
|
distanceM,
|
|
195
308
|
durationS,
|
|
196
309
|
...(typeof record['polyline'] === 'string' ? { polyline: record['polyline'] } : {}),
|
|
197
310
|
...(Array.isArray(record['steps']) ? { steps: record['steps'] } : {}),
|
|
311
|
+
...(declaredMode !== undefined ? { mode: declaredMode } : {}),
|
|
312
|
+
...(resolvedOrigin !== undefined ? { resolvedOrigin } : {}),
|
|
313
|
+
...(resolvedDestination !== undefined ? { resolvedDestination } : {}),
|
|
198
314
|
};
|
|
199
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Verify the provider's OWN claims against the request for this direction, and
|
|
318
|
+
* return a typed refusal when one contradicts it (#429 GAP-429-1/2).
|
|
319
|
+
*
|
|
320
|
+
* ABSENT = UNVERIFIABLE, and absent is ACCEPTED — not because an unverified
|
|
321
|
+
* answer is as good as a verified one, but because the only wired provider (the
|
|
322
|
+
* registered `map_driving_route` tool) is driving by construction and echoes no
|
|
323
|
+
* origin/destination, so for it nothing changes byte for byte. Any future
|
|
324
|
+
* non-driving or address-resolving adapter must supply these fields; the #429
|
|
325
|
+
* conformance gate already refuses an adapter that does not.
|
|
326
|
+
*
|
|
327
|
+
* PRESENT and contradicting = REFUSAL. The caller falls back to the static
|
|
328
|
+
* transfer estimate with its original price label, so a mislabelled or
|
|
329
|
+
* misrouted answer never reaches the solver.
|
|
330
|
+
*/
|
|
331
|
+
function routeClaimRefusal(direction, request, route) {
|
|
332
|
+
if (route.mode !== undefined && route.mode !== GROUND_TRANSFER_MODE) {
|
|
333
|
+
return {
|
|
334
|
+
code: GROUND_TRANSFER_ROUTE_MODE_MISMATCH,
|
|
335
|
+
detail: `provider ${safeClaim(route.provider)} returned mode '${safeClaim(route.mode)}' for a '${GROUND_TRANSFER_MODE}' request; a declared mode is never relabelled as ${GROUND_TRANSFER_MODE}; static transfer preserved`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const { resolvedOrigin, resolvedDestination } = route;
|
|
339
|
+
if (resolvedOrigin === undefined && resolvedDestination === undefined)
|
|
340
|
+
return null;
|
|
341
|
+
if ((resolvedOrigin !== undefined && !sameEndpoint(resolvedOrigin, request.origin))
|
|
342
|
+
|| (resolvedDestination !== undefined && !sameEndpoint(resolvedDestination, request.destination))) {
|
|
343
|
+
return {
|
|
344
|
+
code: GROUND_TRANSFER_ROUTE_DIRECTION_MISMATCH,
|
|
345
|
+
detail: `provider ${safeClaim(route.provider)} resolved ${safeClaim(resolvedOrigin ?? '(absent)')}>${safeClaim(resolvedDestination ?? '(absent)')} but the ${direction.direction} request was ${request.origin}>${request.destination}; direction binding is verified against the response, not assumed from the request; static transfer preserved`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
200
350
|
function buildRouteFact(direction, route, clock, cache) {
|
|
201
351
|
return {
|
|
202
352
|
direction: direction.direction,
|
|
@@ -272,6 +422,23 @@ async function resolveDirection(direction, staticMinutes, provider, clock, cache
|
|
|
272
422
|
execution: context.execution,
|
|
273
423
|
});
|
|
274
424
|
const route = parseRouteResult(raw);
|
|
425
|
+
const refusal = routeClaimRefusal(direction, requestForProvider, route);
|
|
426
|
+
if (refusal !== null) {
|
|
427
|
+
// Fail closed: no fact is built and nothing is written to the cache, so a
|
|
428
|
+
// refused answer can never be served later as a cache hit. The static
|
|
429
|
+
// minutes, price and `[静态包:估算]` label are left untouched.
|
|
430
|
+
return {
|
|
431
|
+
applied: false,
|
|
432
|
+
direction: direction.direction,
|
|
433
|
+
staticMinutes,
|
|
434
|
+
provenance: 'static-transfer-pack',
|
|
435
|
+
asOf: null,
|
|
436
|
+
freshness: 'fallback',
|
|
437
|
+
cache: staticCache(cached ? 'stale' : 'bypass'),
|
|
438
|
+
evidenceClass: 'static_transfer_estimate',
|
|
439
|
+
fallbackReason: `${refusal.code}:${direction.direction}:${refusal.detail}`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
275
442
|
const cacheInfo = {
|
|
276
443
|
status: cached ? 'requery' : 'miss',
|
|
277
444
|
ageS: 0,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider fault-detail sanitizer (shared, pure, zero imports).
|
|
3
|
+
*
|
|
4
|
+
* A provider's failure text is a LABEL for operators, never a channel for a
|
|
5
|
+
* provider response body. Any string that may reach the evidence/verdict surface
|
|
6
|
+
* passes through `sanitizeFaultDetail` first: markup, credential assignments and
|
|
7
|
+
* URLs are redacted, whitespace is collapsed, and the result is length-bounded.
|
|
8
|
+
* `faultDetailLeak` is the mechanical check a gate (or a test) can run over a
|
|
9
|
+
* string that is about to be exposed.
|
|
10
|
+
*
|
|
11
|
+
* Extracted so that exactly one implementation serves both
|
|
12
|
+
* `ts/capabilities/route-provider-conformance.ts` (the #429 conformance gate,
|
|
13
|
+
* which re-exports it) and `ts/capabilities/ground-transfer.ts` (the live
|
|
14
|
+
* product path, issue #429 GAP-429-3 fix). Keeping it in its own file means the
|
|
15
|
+
* conformance gate itself stays free of product callers.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately pure: zero imports, zero IO, zero network, zero clock.
|
|
18
|
+
*
|
|
19
|
+
* @module capabilities/provider-detail-sanitize
|
|
20
|
+
*/
|
|
21
|
+
/** Default bound for a provider fragment embedded in a gate refusal. */
|
|
22
|
+
export const FAULT_DETAIL_MAX_CHARS = 120;
|
|
23
|
+
const DETAIL_SCRUB_PATTERNS = [
|
|
24
|
+
{ re: /<[^>]*>/g, label: 'markup' },
|
|
25
|
+
{ re: /\b(?:cookie|set-cookie|authorization|api[_-]?key|token|secret|password|passwd|sid)\s*[:=]\s*\S+/gi, label: 'credential' },
|
|
26
|
+
{ re: /\bbearer\s+[A-Za-z0-9._-]{8,}/gi, label: 'credential' },
|
|
27
|
+
{ re: /https?:\/\/\S+/gi, label: 'url' },
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Sanitize a provider fault detail.
|
|
31
|
+
*
|
|
32
|
+
* `maxChars` lets a caller keep its own historical bound (ground-transfer keeps
|
|
33
|
+
* 400 so no existing `fallbackReason` text changes); `emptyPlaceholder` lets a
|
|
34
|
+
* caller keep its own historical empty-case wording.
|
|
35
|
+
*/
|
|
36
|
+
export function sanitizeFaultDetail(detail, options) {
|
|
37
|
+
const maxChars = options?.maxChars ?? FAULT_DETAIL_MAX_CHARS;
|
|
38
|
+
const placeholder = options?.emptyPlaceholder ?? '(no detail)';
|
|
39
|
+
let text = typeof detail === 'string' ? detail : '';
|
|
40
|
+
for (const { re, label } of DETAIL_SCRUB_PATTERNS)
|
|
41
|
+
text = text.replace(re, `[${label}-redacted]`);
|
|
42
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (text.length > maxChars)
|
|
44
|
+
text = `${text.slice(0, maxChars - 1)}…`;
|
|
45
|
+
return text === '' ? placeholder : text;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Mechanical leak check for any string about to reach the evidence surface.
|
|
49
|
+
*
|
|
50
|
+
* Shape-only by design: it looks for provider-response-body tells (markup,
|
|
51
|
+
* credential assignments, URLs) and deliberately does NOT bound length — the
|
|
52
|
+
* length bound belongs to `sanitizeFaultDetail`, which caps the PROVIDER
|
|
53
|
+
* fragment, whereas a gate's own refusal explanation is host-authored prose and
|
|
54
|
+
* may legitimately be longer.
|
|
55
|
+
*/
|
|
56
|
+
export function faultDetailLeak(detail) {
|
|
57
|
+
if (/<[^>]*>/.test(detail))
|
|
58
|
+
return 'fault detail carries markup (a provider response body must never reach the evidence surface)';
|
|
59
|
+
if (/\b(?:cookie|authorization|api[_-]?key|token|secret|password|passwd|sid)\s*[:=]\s*\S+/i.test(detail))
|
|
60
|
+
return 'fault detail carries a credential assignment';
|
|
61
|
+
if (/https?:\/\//i.test(detail))
|
|
62
|
+
return 'fault detail carries a URL';
|
|
63
|
+
return null;
|
|
64
|
+
}
|