@danceiny/gotry 0.0.1-rc.20 → 0.0.1-rc.21

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.
@@ -384,9 +384,11 @@ function recoverFinalResponseDecision(response, task) {
384
384
  }).slice(0, 600));
385
385
  return null;
386
386
  }
387
- repairPlannerFactRefs(action);
387
+ const repairedRefs = repairPlannerFactRefs(action);
388
+ const repairedValidation = validateBookingReadAction(action);
389
+ if (!repairedValidation.ok) return null;
388
390
  try {
389
- assertPlannerSafeRefs(action);
391
+ assertPlannerSafeRefs(action, repairedRefs);
390
392
  } catch (error) {
391
393
  console.error('[booking-copilot] finalResponse recovery rejected (unsafe ref):', JSON.stringify({
392
394
  actionId: action.actionId,
@@ -411,10 +413,17 @@ function recoverFinalResponseDecision(response, task) {
411
413
  const PLANNER_SAFE_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._-]*$/;
412
414
  function repairPlannerFactRefs(action) {
413
415
  const factRefs = action.factRefs;
414
- if (!Array.isArray(factRefs)) return;
415
- action.factRefs = factRefs.map((ref)=>typeof ref === 'string' ? ref.replace(/#/g, ':').replace(/[^A-Za-z0-9:._-]/g, '.') : ref);
416
+ const repairedRefs = new Set();
417
+ if (!Array.isArray(factRefs)) return repairedRefs;
418
+ action.factRefs = factRefs.map((ref)=>{
419
+ if (typeof ref !== 'string' || PLANNER_SAFE_REF_PATTERN.test(ref)) return ref;
420
+ const alias = `modelref:${createHash('sha256').update(ref, 'utf8').digest('hex')}`;
421
+ repairedRefs.add(alias);
422
+ return alias;
423
+ });
424
+ return repairedRefs;
416
425
  }
417
- function assertPlannerSafeRefs(action) {
426
+ function assertPlannerSafeRefs(action, repairedRefs) {
418
427
  const actionId = action.actionId;
419
428
  if (typeof actionId !== 'string' || !PLANNER_SAFE_REF_PATTERN.test(actionId)) {
420
429
  throw new Error(`planner_invalid_action:unsafe_action_id:${String(actionId).slice(0, 60)}`);
@@ -422,7 +431,7 @@ function assertPlannerSafeRefs(action) {
422
431
  const factRefs = action.factRefs;
423
432
  if (Array.isArray(factRefs)) {
424
433
  for (const ref of factRefs){
425
- if (typeof ref !== 'string' || !PLANNER_SAFE_REF_PATTERN.test(ref)) {
434
+ if (typeof ref !== 'string' || !PLANNER_SAFE_REF_PATTERN.test(ref) || ref.startsWith('modelref:') && !repairedRefs.has(ref) || ref.length > 512) {
426
435
  throw new Error(`planner_invalid_action:unsafe_fact_ref:${String(ref).slice(0, 60)}`);
427
436
  }
428
437
  }
@@ -483,8 +492,15 @@ function parseToolDecision(event, task) {
483
492
  }).slice(0, 1200));
484
493
  throw new Error(`planner_invalid_action:${validation.errors.join('; ')}`);
485
494
  }
486
- repairPlannerFactRefs(decision.action);
487
- assertPlannerSafeRefs(decision.action);
495
+ const repairedRefs = repairPlannerFactRefs(decision.action);
496
+ const repairedValidation = validateBookingReadAction(decision.action);
497
+ if (!repairedValidation.ok) {
498
+ console.error(`[booking-copilot] repaired action rejected:`, JSON.stringify({
499
+ errors: repairedValidation.errors.slice(0, 8)
500
+ }).slice(0, 800));
501
+ throw new Error(`planner_invalid_action:${repairedValidation.errors.join('; ')}`);
502
+ }
503
+ assertPlannerSafeRefs(decision.action, repairedRefs);
488
504
  const action = decision.action;
489
505
  const capability = TOOL_TO_CAPABILITY.get(name);
490
506
  if (!capability || !actionsForEmbeddedCapability(capability).includes(action.kind)) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "GoTry Session Bridge",
4
- "version": "0.0.1.20",
4
+ "version": "0.0.1.21",
5
5
  "description": "GoTry 会话检索桥(issue #21):在你自己的登录态里只读嗅探检索回包(机票/酒店/火车)与登录票据 cookie 名;零凭证经手,登录在官网由你完成,检索只读不写。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -46,5 +46,5 @@
46
46
  "run_at": "document_start"
47
47
  }
48
48
  ],
49
- "version_name": "0.0.1-rc.20"
49
+ "version_name": "0.0.1-rc.21"
50
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.20",
3
+ "version": "0.0.1-rc.21",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -90,6 +90,7 @@
90
90
  "extension/",
91
91
  "ts/package.json",
92
92
  "ts/dsh-runtime/vendor/dsh-map-tools",
93
+ "ts/scripts/map-tools-vendor-package-proof.ts",
93
94
  "cordis.gotry-patch.yml",
94
95
  "README.md",
95
96
  "README.zh-CN.md",
@@ -155,8 +155,15 @@ export function startExtensionHealthWatch(opts: HealthWatchOptions = {}): Health
155
155
  const probe = opts.probe ?? (async () => {
156
156
  // 先确保桥存在 + keepBridge
157
157
  const bridge = await import('./extension-bridge.ts')
158
- await bridge.getOrCreateSessionBridge({ keepBridge: opts.keepBridge === true })
159
- return combinedProbe(opts, businessProbe)
158
+ const created = await bridge.getOrCreateSessionBridge({ keepBridge: opts.keepBridge === true, ports: opts.ports })
159
+ // The default path must keep scanning the canonical pool: another GoTry
160
+ // process may already own the extension-connected bridge. An explicit
161
+ // port override is an isolation boundary, so probe only the bridge that
162
+ // this process actually bound (including an ephemeral `0` request).
163
+ const probeOpts = created.ok && opts.ports
164
+ ? { ...opts, ports: [created.bridge.port] }
165
+ : opts
166
+ return combinedProbe(probeOpts, businessProbe)
160
167
  })
161
168
  const now = opts.now ?? Date.now
162
169
  const state: InternalState = { cancelled: false, readyCount: 0, resolveOutcome: null }
package/ts/package.json CHANGED
@@ -18,19 +18,27 @@
18
18
  "better-sqlite3": "^13.0.3",
19
19
  "z3-solver": "^5.2.0"
20
20
  },
21
- "devDependencies": {
22
- "@types/node": "^22.0.0",
23
- "playwright-core": "^1.62.1",
24
- "puppeteer-core": "^25.9.0",
25
- "tsx": "^4.19.0",
26
- "typescript": "^5.6.0"
27
- },
28
21
  "overrides": {
22
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
29
23
  "@deepseek-ai/dsh-brand": "0.1.2-alpha.3",
24
+ "@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.3",
25
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.3",
30
26
  "@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
27
+ "@deepseek-ai/dsh-scope": "0.1.2-alpha.3",
28
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.3",
29
+ "@deepseek-ai/dsh-session-projection": "0.1.2-alpha.3",
30
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.3",
31
31
  "@deepseek-ai/dsh-timeout": "0.1.2-alpha.3",
32
32
  "@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.3",
33
+ "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.3",
33
34
  "@deepseek-ai/dsh-util-crypto": "0.1.2-alpha.3",
34
35
  "@deepseek-ai/dsh-util-values": "0.1.2-alpha.3"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "playwright-core": "^1.62.1",
40
+ "puppeteer-core": "^25.9.0",
41
+ "tsx": "^4.19.0",
42
+ "typescript": "^5.6.0"
35
43
  }
36
44
  }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Package proof for issue #202.
3
+ *
4
+ * The full suite passes the executable from its clean tarball install; focused
5
+ * runs pack/unpack locally and attach the already-installed root closure. Both
6
+ * paths load the plugin from the artifact, never from the checkout vendor path.
7
+ * No map provider or paid endpoint is contacted; inline-coordinate geocoding is
8
+ * asserted with fetch disabled.
9
+ */
10
+
11
+ import { execFileSync } from 'node:child_process'
12
+ import { createHash } from 'node:crypto'
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
14
+ import { createRequire } from 'node:module'
15
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
+ import assert from 'node:assert/strict'
18
+
19
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
20
+ const upstreamLicenseSha256 = 'b6bbb0c73a02cf8d2c304e9f208b41c32f0a810fabe366986e2b14ac3338f618'
21
+ const upstreamVendorFileCount = 32
22
+ const upstreamVendorAggregateSha256 = 'd82a38adace8bfe574dcecb4222da5c994a1b9e8792f78dc2dad3a7f50c9925a'
23
+ const expectedTools = [
24
+ 'map_bicycling_route',
25
+ 'map_driving_route',
26
+ 'map_geocode',
27
+ 'map_poi_search',
28
+ 'map_reverse_geocode',
29
+ 'map_transit_route',
30
+ 'map_walking_route',
31
+ ]
32
+
33
+ function sha256(path: string): string {
34
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
35
+ }
36
+
37
+ function vendorAggregate(root: string): { count: number; sha256: string } {
38
+ const files: string[] = []
39
+ const walk = (directory: string) => {
40
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
41
+ const path = join(directory, entry.name)
42
+ if (entry.isDirectory()) walk(path)
43
+ else files.push(path.slice(root.length + 1).replaceAll('\\', '/'))
44
+ }
45
+ }
46
+ walk(root)
47
+ files.sort()
48
+ const digest = createHash('sha256')
49
+ for (const relative of files) {
50
+ digest.update(relative)
51
+ digest.update('\0')
52
+ digest.update(readFileSync(join(root, relative)))
53
+ digest.update('\0')
54
+ }
55
+ return { count: files.length, sha256: digest.digest('hex') }
56
+ }
57
+
58
+ function assertAlpha3LockClosure(): { packages: number; version: string } {
59
+ const manifest = JSON.parse(readFileSync(join(repoRoot, 'ts/package.json'), 'utf8')) as {
60
+ dependencies?: Record<string, string>
61
+ }
62
+ assert.equal(manifest.dependencies?.['dsh-map-tools'], undefined, 'external map-tools dependency must stay removed')
63
+
64
+ const lock = JSON.parse(readFileSync(join(repoRoot, 'ts/package-lock.json'), 'utf8')) as {
65
+ packages?: Record<string, { version?: string; dependencies?: Record<string, string> }>
66
+ }
67
+ assert.equal(lock.packages?.['']?.dependencies?.['dsh-map-tools'], undefined, 'lock root must not resolve external map-tools')
68
+ assert.equal(lock.packages?.['node_modules/dsh-map-tools'], undefined, 'lock must not carry external map-tools')
69
+ const dshEntries = Object.entries(lock.packages ?? {}).filter(([path]) =>
70
+ /(?:^|\/)node_modules\/@deepseek-ai\/(?:dsh|dsh-[^/]+)$/.test(path),
71
+ )
72
+ assert.ok(dshEntries.length >= 10, 'expected dsh-tools peer closure in the ts lock')
73
+ for (const [path, entry] of dshEntries) {
74
+ assert.equal(entry.version, '0.1.2-alpha.3', `${path} drifted outside the alpha.3 closure`)
75
+ }
76
+ return { packages: dshEntries.length, version: '0.1.2-alpha.3' }
77
+ }
78
+
79
+ function findPackageRoot(entry: string): string {
80
+ let cursor = dirname(entry)
81
+ for (;;) {
82
+ const manifestPath = join(cursor, 'package.json')
83
+ if (existsSync(manifestPath)) {
84
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name?: string }
85
+ if (manifest.name === '@danceiny/gotry') return cursor
86
+ }
87
+ const parent = dirname(cursor)
88
+ if (parent === cursor) throw new Error(`cannot locate @danceiny/gotry package root from ${entry}`)
89
+ cursor = parent
90
+ }
91
+ }
92
+
93
+ function assertBinTargetsPackage(binPath: string, expectedTarget: string): void {
94
+ const resolvedExpectedTarget = realpathSync(expectedTarget)
95
+ if (realpathSync(binPath) === resolvedExpectedTarget) return
96
+
97
+ // pnpm writes an executable shell shim instead of a symlink. Its final
98
+ // machine-readable marker is safer to verify than accepting any occurrence
99
+ // of the package path in an otherwise unrelated executable.
100
+ const shim = readFileSync(binPath, 'utf8')
101
+ assert.ok(Buffer.byteLength(shim, 'utf8') <= 32_768, 'clean-install gotry shim is unexpectedly large')
102
+ const lines = shim.trimEnd().split(/\r?\n/)
103
+ const markers = lines.filter(line => line.startsWith('# cmd-shim-target='))
104
+ assert.equal(markers.length, 1, 'pnpm gotry shim must contain exactly one target marker')
105
+ assert.equal(lines.at(-1), markers[0], 'pnpm gotry shim target marker must be the final line')
106
+ const markerTarget = markers[0].slice('# cmd-shim-target='.length)
107
+ assert.ok(isAbsolute(markerTarget), 'pnpm gotry shim target must be absolute')
108
+ assert.equal(
109
+ realpathSync(markerTarget),
110
+ resolvedExpectedTarget,
111
+ 'GOTRY_MAP_TOOLS_E2E_BIN shim must target this installed GoTry package',
112
+ )
113
+ }
114
+
115
+ function prepareProofPackage(cleanInstalledBin?: string): { packageRoot: string; proofRoot?: string } {
116
+ if (cleanInstalledBin) {
117
+ const binPath = resolve(cleanInstalledBin)
118
+ assert.equal(basename(binPath), 'gotry', 'clean-install executable must use the gotry bin name')
119
+ assert.equal(basename(dirname(binPath)), '.bin', 'clean-install executable must come from node_modules/.bin')
120
+ assert.equal(basename(dirname(dirname(binPath))), 'node_modules', 'clean-install executable must come from a consumer install')
121
+ const consumerRoot = dirname(dirname(dirname(binPath)))
122
+ const packageMain = createRequire(join(consumerRoot, 'package.json')).resolve('@danceiny/gotry')
123
+ const packageRoot = findPackageRoot(packageMain)
124
+ const installedManifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as {
125
+ name?: string
126
+ bin?: string | Record<string, string>
127
+ }
128
+ assert.equal(installedManifest.name, '@danceiny/gotry', 'clean-install executable must belong to GoTry')
129
+ const declaredBin = typeof installedManifest.bin === 'string'
130
+ ? installedManifest.bin
131
+ : installedManifest.bin?.gotry
132
+ assert.equal(declaredBin, 'bin/gotry-inner.js', 'installed package must declare the production gotry bin')
133
+ assertBinTargetsPackage(binPath, join(packageRoot, declaredBin))
134
+ return { packageRoot }
135
+ }
136
+
137
+ const proofRoot = mkdtempSync(join(repoRoot, '.tmp-map-tools-proof-'))
138
+ try {
139
+ const packOutput = execFileSync('npm', [
140
+ 'pack', '--ignore-scripts', '--json', '--pack-destination', proofRoot,
141
+ ], { cwd: repoRoot, encoding: 'utf8' })
142
+ const packInfo = JSON.parse(packOutput) as Array<{ filename: string }>
143
+ assert.equal(packInfo.length, 1, 'npm pack must produce one artifact')
144
+ const tarball = join(proofRoot, packInfo[0].filename)
145
+ assert.ok(existsSync(tarball), `packed artifact missing: ${tarball}`)
146
+ const unpackRoot = join(proofRoot, 'unpacked')
147
+ mkdirSync(unpackRoot)
148
+ execFileSync('tar', ['-xzf', tarball, '-C', unpackRoot])
149
+ const packageRoot = join(unpackRoot, 'package')
150
+ symlinkSync(join(repoRoot, 'node_modules'), join(packageRoot, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
151
+ return { packageRoot, proofRoot }
152
+ } catch (error) {
153
+ rmSync(proofRoot, { recursive: true, force: true })
154
+ throw error
155
+ }
156
+ }
157
+
158
+ const cleanInstalledBin = process.env.GOTRY_MAP_TOOLS_E2E_BIN
159
+ const { packageRoot, proofRoot } = prepareProofPackage(cleanInstalledBin)
160
+
161
+ try {
162
+ const lockedDsh = assertAlpha3LockClosure()
163
+ const vendorRoot = join(packageRoot, 'ts/dsh-runtime/vendor/dsh-map-tools')
164
+ const requiredFiles = [
165
+ 'LICENSE',
166
+ 'package.json',
167
+ 'cordis.patch.yml',
168
+ 'lib/index.js',
169
+ 'lib/settings-ns.js',
170
+ 'lib/config.js',
171
+ 'lib/tools/geocode.js',
172
+ 'lib/tools/routes.js',
173
+ 'lib/tools/poi.js',
174
+ ]
175
+ for (const relative of requiredFiles) {
176
+ assert.ok(existsSync(join(vendorRoot, relative)), `required vendor file missing: ${relative}`)
177
+ }
178
+ const vendorTree = vendorAggregate(vendorRoot)
179
+ assert.equal(vendorTree.count, upstreamVendorFileCount, 'vendored payload file count drifted')
180
+ assert.equal(vendorTree.sha256, upstreamVendorAggregateSha256, 'vendored payload aggregate drifted')
181
+ const vendorPackage = JSON.parse(readFileSync(join(vendorRoot, 'package.json'), 'utf8')) as {
182
+ name?: string
183
+ version?: string
184
+ license?: string
185
+ }
186
+ assert.equal(vendorPackage.name, 'dsh-map-tools')
187
+ assert.equal(vendorPackage.version, '0.5.1')
188
+ assert.equal(vendorPackage.license, 'MIT')
189
+ assert.equal(sha256(join(vendorRoot, 'LICENSE')), upstreamLicenseSha256, 'upstream MIT license changed')
190
+
191
+ const packageRequire = createRequire(join(packageRoot, 'package.json'))
192
+ const dshTools = JSON.parse(readFileSync(packageRequire.resolve('@deepseek-ai/dsh-tools/package.json'), 'utf8')) as { version: string }
193
+ const dshSettings = JSON.parse(readFileSync(packageRequire.resolve('@deepseek-ai/dsh-settings/package.json'), 'utf8')) as { version: string }
194
+ assert.equal(dshTools.version, '0.1.2-alpha.3')
195
+ assert.equal(dshSettings.version, '0.1.2-alpha.3')
196
+ const settingsApi = await import(pathToFileURL(packageRequire.resolve('@deepseek-ai/dsh-settings')).href) as {
197
+ SettingsProvider?: { prototype?: { installSection?: unknown } }
198
+ }
199
+ assert.equal(
200
+ typeof settingsApi.SettingsProvider?.prototype?.installSection,
201
+ 'function',
202
+ 'real alpha.3 SettingsProvider.installSection API must be present',
203
+ )
204
+
205
+ const plugin = await import(pathToFileURL(join(vendorRoot, 'lib/index.js')).href)
206
+ const registered: Array<Record<string, unknown>> = []
207
+ const settingsCalls: unknown[][] = []
208
+ const context = {
209
+ tools: {
210
+ register(tool: Record<string, unknown>) {
211
+ registered.push(tool)
212
+ return () => undefined
213
+ },
214
+ },
215
+ effect(effect: () => (() => void) | void) {
216
+ effect()
217
+ },
218
+ inject(dependencies: string[], callback: (scope: Record<string, unknown>) => void) {
219
+ if (dependencies.includes('settings')) {
220
+ callback({ settings: { installSection: (...args: unknown[]) => { settingsCalls.push(args) } } })
221
+ } else if (dependencies.includes('webServer')) {
222
+ callback({ webServer: { register: () => undefined } })
223
+ } else {
224
+ throw new Error(`unexpected dependency injection: ${dependencies.join(',')}`)
225
+ }
226
+ },
227
+ }
228
+ plugin.apply(context, {
229
+ provider: 'osm', amapKey: '', timeoutMs: 1000, maxQps: 2,
230
+ defaultMode: 'driving', language: 'zh',
231
+ })
232
+ assert.deepEqual(registered.map(tool => tool.name).sort(), expectedTools, 'exactly seven map tools must register')
233
+ assert.equal(settingsCalls.length, 1, 'alpha.3 settings section must be installed once')
234
+ assert.equal(settingsCalls[0][1], 'dsh-map-tools', 'settings namespace must be stable')
235
+ assert.equal(typeof settingsCalls[0][4], 'object', 'alpha.3 installSection hooks must be supplied')
236
+
237
+ const originalFetch = globalThis.fetch
238
+ globalThis.fetch = (async () => { throw new Error('network forbidden by package proof') }) as typeof fetch
239
+ try {
240
+ const geocode = registered.find(tool => tool.name === 'map_geocode')
241
+ assert.ok(geocode, 'map_geocode must be registered')
242
+ const result = await (geocode.execute as (args: unknown, execution: unknown) => Promise<unknown>)(
243
+ { address: '116.397428,39.90923' },
244
+ { signal: new AbortController().signal },
245
+ ) as { provider?: string; location?: number[] }
246
+ assert.equal(result.provider, 'inline')
247
+ assert.deepEqual(result.location, [116.397428, 39.90923])
248
+ } finally {
249
+ globalThis.fetch = originalFetch
250
+ }
251
+
252
+ console.log(JSON.stringify({
253
+ proof: 'map-tools-vendor-package',
254
+ status: 'PASS',
255
+ artifactMode: cleanInstalledBin ? 'clean-installed-tarball' : 'packed-unpacked-focused',
256
+ vendored: {
257
+ sourcePackage: 'dsh-map-tools@0.5.1',
258
+ licenseSha256: upstreamLicenseSha256, vendorFileCount: vendorTree.count,
259
+ vendorAggregateSha256: vendorTree.sha256,
260
+ },
261
+ artifactVendor: vendorRoot,
262
+ dshClosure: { tools: dshTools.version, settings: dshSettings.version, ...lockedDsh },
263
+ tools: registered.map(tool => tool.name).sort(),
264
+ inlineCoordinate: { provider: 'inline', network: 'forbidden' },
265
+ }, null, 2))
266
+ } finally {
267
+ if (proofRoot) rmSync(proofRoot, { recursive: true, force: true })
268
+ }
@@ -25,9 +25,35 @@ const MAX_TERMINAL_BYTES = 1024 * 1024
25
25
  const BENCHMARK_TOOL_RESULT_SCHEMA_VERSION = 'gotry_benchmark_tool_result_v1'
26
26
  const BENCHMARK_DOMAIN_RECOVERIES = new Set(['none', 'retry_same', 'revise_arguments', 'choose_alternative'])
27
27
 
28
+ // Round 12(issue #215):terminal body schema 是「无数据值的 closed 结构合同」——
29
+ // 只允许结构关键字(type/properties/required/additionalProperties/items),enum/
30
+ // const/example/default 等可夹带数据值的注解面一律禁止,对象必须 additionalProperties:
31
+ // false,大小/深度/节点数有界。校验语义 fail-closed:不补键、不删多余键、不转类型。
32
+ const TERMINAL_BODY_SCHEMA_MAX_BYTES = 16 * 1024
33
+ const TERMINAL_BODY_SCHEMA_MAX_DEPTH = 8
34
+ const TERMINAL_BODY_SCHEMA_MAX_NODES = 256
35
+ const TERMINAL_BODY_SCHEMA_MAX_PROPERTIES = 256
36
+ const TERMINAL_BODY_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
37
+ const TERMINAL_BODY_VALUE_MAX_NODES = 10_000
38
+ const TERMINAL_BODY_VALUE_MAX_DEPTH = 24
39
+
40
+ /** Structural-only, closed JSON schema for the terminal body (no data-bearing keywords). */
41
+ export type TerminalBodySchema = {
42
+ type: 'object'
43
+ properties: Record<string, TerminalBodySchema>
44
+ required: string[]
45
+ additionalProperties: false
46
+ } | {
47
+ type: 'array'
48
+ items: TerminalBodySchema
49
+ } | {
50
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'null'
51
+ }
52
+
28
53
  export interface TerminalOutputConfig {
29
54
  tag: string
30
55
  max_bytes: number
56
+ body_schema: TerminalBodySchema
31
57
  }
32
58
 
33
59
  export type TerminalOutputValue =
@@ -80,13 +106,113 @@ function plainObject(value: unknown): value is Record<string, unknown> {
80
106
  export function validateTerminalOutputConfig(value: unknown): value is TerminalOutputConfig {
81
107
  if (!plainObject(value)) return false
82
108
  const keys = Object.keys(value).sort()
83
- return JSON.stringify(keys) === JSON.stringify(['max_bytes', 'tag'])
109
+ return JSON.stringify(keys) === JSON.stringify(['body_schema', 'max_bytes', 'tag'])
84
110
  && typeof value.tag === 'string'
85
111
  && IDENTIFIER.test(value.tag)
86
112
  && typeof value.max_bytes === 'number'
87
113
  && Number.isInteger(value.max_bytes)
88
114
  && value.max_bytes >= 1
89
115
  && value.max_bytes <= MAX_TERMINAL_BYTES
116
+ && validateTerminalBodySchema(value.body_schema)
117
+ }
118
+
119
+ /** Accept only the closed structural dialect: exact keyword sets per node, bounded size. */
120
+ export function validateTerminalBodySchema(value: unknown): value is TerminalBodySchema {
121
+ let serialized: string
122
+ try {
123
+ serialized = JSON.stringify(value)
124
+ } catch {
125
+ return false
126
+ }
127
+ if (typeof serialized !== 'string' || Buffer.byteLength(serialized, 'utf8') > TERMINAL_BODY_SCHEMA_MAX_BYTES) return false
128
+ const pending: Array<{ node: unknown; depth: number }> = [{ node: value, depth: 0 }]
129
+ const seen = new Set<object>()
130
+ let nodes = 0
131
+ let properties = 0
132
+ while (pending.length > 0) {
133
+ const { node, depth } = pending.pop()!
134
+ if (!plainObject(node) || seen.has(node) || ++nodes > TERMINAL_BODY_SCHEMA_MAX_NODES || depth > TERMINAL_BODY_SCHEMA_MAX_DEPTH) return false
135
+ seen.add(node)
136
+ const type = node.type
137
+ if (type === 'object') {
138
+ if (!exactKeys(node, ['type', 'properties', 'required', 'additionalProperties'])
139
+ || !plainObject(node.properties)
140
+ || !Array.isArray(node.required)
141
+ || node.additionalProperties !== false) return false
142
+ const entries = Object.entries(node.properties)
143
+ properties += entries.length
144
+ if (properties > TERMINAL_BODY_SCHEMA_MAX_PROPERTIES
145
+ || entries.some(([key, child]) => key.length === 0 || key.length > 64 || !plainObject(child))) return false
146
+ if (node.required.length > entries.length
147
+ || new Set(node.required).size !== node.required.length
148
+ || node.required.some(key => typeof key !== 'string' || !Object.hasOwn(node.properties as Record<string, unknown>, key))) return false
149
+ for (const [, child] of entries) pending.push({ node: child, depth: depth + 1 })
150
+ continue
151
+ }
152
+ if (type === 'array') {
153
+ if (!exactKeys(node, ['type', 'items']) || !plainObject(node.items)) return false
154
+ pending.push({ node: node.items, depth: depth + 1 })
155
+ continue
156
+ }
157
+ if (typeof type !== 'string' || !TERMINAL_BODY_SCALAR_TYPES.has(type) || !exactKeys(node, ['type'])) return false
158
+ }
159
+ return plainObject(value) && value.type === 'object'
160
+ }
161
+
162
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
163
+ return Object.keys(value).every(key => allowed.includes(key))
164
+ }
165
+
166
+ /** Strict structural validation: exact keys, exact types, no coercion, no autofix. */
167
+ export function validateTerminalBodyValue(value: unknown, schema: TerminalBodySchema): boolean {
168
+ const pending: Array<{ value: unknown; schema: TerminalBodySchema; depth: number }> = [{ value, schema, depth: 0 }]
169
+ let nodes = 0
170
+ while (pending.length > 0) {
171
+ const current = pending.pop()!
172
+ if (++nodes > TERMINAL_BODY_VALUE_MAX_NODES || current.depth > TERMINAL_BODY_VALUE_MAX_DEPTH) return false
173
+ const { schema: node } = current
174
+ if (node.type === 'object') {
175
+ const record = current.value
176
+ if (!plainObject(record)) return false
177
+ const properties = node.properties as Record<string, TerminalBodySchema>
178
+ for (const key of node.required) {
179
+ if (!Object.hasOwn(record, key)) return false
180
+ }
181
+ for (const [key, child] of Object.entries(record)) {
182
+ const childSchema = properties[key]
183
+ if (!childSchema) return false
184
+ pending.push({ value: child, schema: childSchema, depth: current.depth + 1 })
185
+ }
186
+ continue
187
+ }
188
+ if (node.type === 'array') {
189
+ if (!Array.isArray(current.value)) return false
190
+ for (const item of current.value) pending.push({ value: item, schema: node.items, depth: current.depth + 1 })
191
+ continue
192
+ }
193
+ if (node.type === 'string') { if (typeof current.value !== 'string') return false; continue }
194
+ if (node.type === 'boolean') { if (typeof current.value !== 'boolean') return false; continue }
195
+ if (node.type === 'null') { if (current.value !== null) return false; continue }
196
+ if (node.type === 'integer') { if (typeof current.value !== 'number' || !Number.isSafeInteger(current.value)) return false; continue }
197
+ if (typeof current.value !== 'number' || !Number.isFinite(current.value)) return false
198
+ }
199
+ return true
200
+ }
201
+
202
+ /** Deterministic single-source structural outline projected into system prompt and correction alike. */
203
+ export function terminalSchemaOutline(schema: TerminalBodySchema): string {
204
+ const render = (node: TerminalBodySchema, depth: number): string => {
205
+ if (depth > TERMINAL_BODY_SCHEMA_MAX_DEPTH) return '…'
206
+ if (node.type === 'object') {
207
+ const properties = node.properties as Record<string, TerminalBodySchema>
208
+ const required = new Set(node.required)
209
+ const parts = Object.entries(properties).map(([key, child]) => `${required.has(key) ? '' : '?'}${key}:${render(child, depth + 1)}`)
210
+ return `object{${parts.join(',')}}`
211
+ }
212
+ if (node.type === 'array') return `array<${render(node.items, depth + 1)}>`
213
+ return node.type
214
+ }
215
+ return render(schema, 0)
90
216
  }
91
217
 
92
218
  function invalidTerminal(): TerminalOutputValue {
@@ -116,7 +242,11 @@ export function parseBenchmarkTerminal(raw: string, config: TerminalOutputConfig
116
242
 
117
243
  try {
118
244
  const value: unknown = JSON.parse(body)
119
- return plainObject(value) ? { ok: true, value } : invalidTerminal()
245
+ if (!plainObject(value)) return invalidTerminal()
246
+ // Round 12(#215):同一份 closed body schema 在接受终态前 fail-closed 校验——
247
+ // 结构不合法的终态永不进入正式计分链,不做任何 autofix。
248
+ if (!validateTerminalBodyValue(value, config.body_schema)) return invalidTerminal()
249
+ return { ok: true, value }
120
250
  } catch {
121
251
  return invalidTerminal()
122
252
  }
@@ -363,7 +493,7 @@ function requireDisposer(value: unknown, capability: string): Disposer {
363
493
  function correctionMessage(mode: Exclude<RetryMode, 'none'>, projection: BenchmarkBridgeProjection) {
364
494
  const text = mode === 'call'
365
495
  ? `BENCHMARK_CONFORMANCE_CALL: Execute exactly one native ${projection.toolName} action:"call" now. Describing an intended CLI, shell, or Python command does not execute it.`
366
- : `BENCHMARK_CONFORMANCE_TERMINAL: Reuse the existing successful tool result. Do not call any tool. Reply only <${projection.terminal.tag}>{"result":"..."}</${projection.terminal.tag}> with one JSON object.`
496
+ : `BENCHMARK_CONFORMANCE_TERMINAL: Reuse the existing successful tool result. Do not call any tool. Reply only <${projection.terminal.tag}> with one JSON object matching exactly ${terminalSchemaOutline(projection.terminal.body_schema)} no extra keys, no missing keys, exact types.`
367
497
  return createUserMessage({
368
498
  content: [{ type: 'text' as const, text }],
369
499
  source: { kind: 'plugin' as const, plugin: 'gotry-benchmark-agent-conformance' },
@@ -372,6 +502,7 @@ function correctionMessage(mode: Exclude<RetryMode, 'none'>, projection: Benchma
372
502
 
373
503
  function systemSection(projection: BenchmarkBridgeProjection): { name: string; text: string } {
374
504
  const allowed = projection.allowedTools.join(', ')
505
+ const outline = terminalSchemaOutline(projection.terminal.body_schema)
375
506
  return {
376
507
  name: 'benchmark:agent-conformance',
377
508
  text: [
@@ -379,7 +510,7 @@ function systemSection(projection: BenchmarkBridgeProjection): { name: string; t
379
510
  `- Translate every task instruction to use a CLI, shell, Python, or agent_env.cli into the native tool ${projection.toolName}; do not merely describe the intended command.`,
380
511
  `- Call it with exactly {"action":"call","tool":"<one of: ${allowed}>","arguments":{...}}.`,
381
512
  '- action:"tools" is discovery only and does not satisfy the required environment call.',
382
- `- After a successful tool result, reply only <${projection.terminal.tag}>{...one JSON object...}</${projection.terminal.tag}> with no prose or code fence.`,
513
+ `- After a successful tool result, reply only <${projection.terminal.tag}> with one JSON object matching exactly ${outline} no extra keys, no missing keys, exact types; no prose or code fence.`,
383
514
  '- If a terminal-format correction arrives, reuse the existing result and do not call the tool again.',
384
515
  ].join('\n'),
385
516
  }