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

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.
@@ -0,0 +1,114 @@
1
+ /** Isolated Web setup regressions: real child verifier, official files, and Host-owned roots. */
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ const moduleUrl = new URL('../src/gotry-web-api.js', import.meta.url).href;
7
+ const api = await import(moduleUrl).catch(() => undefined);
8
+ assert.equal(typeof api?.createGotryWebHandlers, 'function', 'GoTry must expose authenticated Web configuration and artifact handlers');
9
+ const root = mkdtempSync(join(tmpdir(), 'gotry-web-api-'));
10
+ try {
11
+ const home = join(root, 'home'), workspace = join(root, 'workspace'), outside = join(root, 'outside');
12
+ for (const dir of [home, workspace, outside])
13
+ mkdirSync(dir);
14
+ const verifier = join(root, 'verifier.cjs');
15
+ writeFileSync(verifier, `#!/usr/bin/env node
16
+ const fs = require('node:fs');
17
+ fs.writeFileSync(process.env.VERIFIER_CAPTURE, JSON.stringify({ candidate: process.env.FLYAI_API_KEY, debug: process.env.DEBUG_FLYAI_API_KEY }));
18
+ if (process.env.FLYAI_API_KEY === 'sk-bad-candidate') { process.stderr.write('HTTP 401 ' + process.env.FLYAI_API_KEY); process.exit(1); }
19
+ if (process.env.FLYAI_API_KEY === 'sk-pending-candidate') { setTimeout(() => process.stdout.write(JSON.stringify({data:{itemList:[]}})), 1000); }
20
+ else process.stdout.write(JSON.stringify({data:{itemList:[]}}));
21
+ `);
22
+ chmodSync(verifier, 0o700);
23
+ const env = { PATH: process.env.PATH, HOME: home, GOTRY_FLYAI_CLI_BIN: verifier,
24
+ VERIFIER_CAPTURE: join(root, 'capture.json'), GOTRY_FLYAI_VERIFY_TIMEOUT_MS: '5000' };
25
+ const handlers = api.createGotryWebHandlers({ homeDir: home, env, stateRoot: workspace,
26
+ workspaceForSession: async (id) => id === 'known-session' ? workspace : undefined });
27
+ const request = (action, key, signal) => new Request('http://localhost/api/gotry/flyai', {
28
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action, ...(key === undefined ? {} : { key }) }), signal,
29
+ });
30
+ const status = () => handlers.flyai(new Request('http://localhost/api/gotry/flyai')).then((r) => r.json());
31
+ assert.deepEqual(await status(), { ok: true, configured: false, verified: false, writable: true, source: 'none', endpoint: 'https://flyai.open.fliggy.com/mcp', endpointDebug: false });
32
+ let response = await handlers.flyai(request('save', 'sk-good-candidate-1234'));
33
+ assert.equal(response.status, 200);
34
+ let body = await response.json();
35
+ assert.equal(body.ok, true);
36
+ assert.equal(body.verified, true);
37
+ assert.equal(body.source, 'config');
38
+ assert.ok(!JSON.stringify(body).includes('sk-good-candidate'));
39
+ const config = join(home, '.flyai/config.json'), receipt = join(home, '.gotry/flyai-verification.json');
40
+ assert.equal(JSON.parse(readFileSync(config, 'utf8')).FLYAI_API_KEY, 'sk-good-candidate-1234');
41
+ assert.equal(statSync(config).mode & 0o777, 0o600);
42
+ const previous = readFileSync(config, 'utf8'), previousReceipt = readFileSync(receipt, 'utf8');
43
+ response = await handlers.flyai(request('save', 'sk-bad-candidate'));
44
+ body = await response.json();
45
+ assert.equal(body.ok, false);
46
+ assert.equal(body.verdict, 'auth-error');
47
+ assert.ok(!JSON.stringify(body).includes('sk-bad-candidate'));
48
+ assert.equal(readFileSync(config, 'utf8'), previous);
49
+ assert.equal(readFileSync(receipt, 'utf8'), previousReceipt);
50
+ assert.equal((await status()).verified, true);
51
+ const cancelled = new AbortController();
52
+ const pending = handlers.flyai(request('save', 'sk-pending-candidate', cancelled.signal));
53
+ setTimeout(() => cancelled.abort(), 100);
54
+ assert.equal((await (await pending).json()).ok, false);
55
+ assert.equal(readFileSync(config, 'utf8'), previous);
56
+ env.FLYAI_API_KEY = 'sk-environment-key';
57
+ assert.equal((await status()).writable, false);
58
+ response = await handlers.flyai(request('save', 'sk-good-candidate-5678'));
59
+ assert.equal(response.status, 409);
60
+ assert.equal(readFileSync(config, 'utf8'), previous);
61
+ delete env.FLYAI_API_KEY;
62
+ response = await handlers.flyai(request('save', 'x'.repeat(9000)));
63
+ assert.equal(response.status, 413);
64
+ assert.equal(readFileSync(config, 'utf8'), previous);
65
+ const concurrent = handlers.flyai(request('save', 'sk-pending-candidate'));
66
+ setTimeout(() => writeFileSync(config, JSON.stringify({ FLYAI_API_KEY: 'sk-external-change' })), 150);
67
+ assert.equal((await concurrent).status, 409);
68
+ assert.equal(JSON.parse(readFileSync(config, 'utf8')).FLYAI_API_KEY, 'sk-external-change');
69
+ response = await handlers.flyai(request('clear'));
70
+ assert.equal((await response.json()).configured, false);
71
+ assert.ok(!existsSync(receipt));
72
+ writeFileSync(config, '{corrupt configuration', { mode: 0o600 });
73
+ assert.equal((await handlers.flyai(request('save', 'sk-good-candidate-1234'))).status, 409);
74
+ assert.equal(readFileSync(config, 'utf8'), '{corrupt configuration');
75
+ writeFileSync(config, previous);
76
+ // A receipt cannot be published when its parent is a file. A successful
77
+ // candidate must not replace the previously usable key in this case.
78
+ rmSync(join(home, '.gotry'), { recursive: true, force: true });
79
+ writeFileSync(join(home, '.gotry'), 'receipt directory unavailable');
80
+ assert.equal((await handlers.flyai(request('save', 'sk-good-candidate-5678'))).status, 409);
81
+ assert.equal(JSON.parse(readFileSync(config, 'utf8')).FLYAI_API_KEY, 'sk-good-candidate-1234');
82
+ rmSync(join(home, '.gotry'));
83
+ const queuedSave = handlers.flyai(request('save', 'sk-pending-candidate'));
84
+ const queuedClear = handlers.flyai(request('clear'));
85
+ const queuedSaved = await queuedSave;
86
+ assert.equal(queuedSaved.status, 200, await queuedSaved.text());
87
+ assert.equal((await (await queuedClear).json()).configured, false);
88
+ writeFileSync(join(workspace, 'trip-one.md'), '# A trip\n');
89
+ writeFileSync(join(workspace, 'trip-two.html'), '<h1>Another trip</h1>');
90
+ writeFileSync(join(outside, 'private-trip.md'), '# Outside\n');
91
+ response = await handlers.artifacts(new Request('http://localhost/api/gotry/artifacts?sessionId=known-session&search=trip&limit=1'));
92
+ body = await response.json();
93
+ assert.equal(body.total, 2);
94
+ assert.equal(body.artifacts.length, 1);
95
+ assert.equal(body.nextOffset, 1);
96
+ response = await handlers.artifacts(new Request('http://localhost/api/gotry/artifacts?sessionId=known-session&search=two'));
97
+ assert.ok((await response.json()).artifacts[0].path.endsWith('trip-two.html'));
98
+ response = await handlers.artifacts(new Request('http://localhost/api/gotry/artifacts?sessionId=unknown'));
99
+ assert.equal(response.status, 404);
100
+ response = await handlers.artifacts(new Request('http://localhost/api/gotry/artifacts?sessionId=known-session&cwd=' + encodeURIComponent(outside)));
101
+ assert.equal(response.status, 400);
102
+ const handoff = join(outside, 'gotry-state', 'turn-handoffs');
103
+ mkdirSync(handoff, { recursive: true });
104
+ writeFileSync(join(handoff, 'host-ticket.json'), JSON.stringify({ schema: 'gotry_turn_handoff.v1', id: 'host-ticket',
105
+ status: 'settled', objective: 'Host-owned handoff', deliverableFile: 'host-ticket.deliverable.md' }));
106
+ writeFileSync(join(handoff, 'host-ticket.deliverable.md'), '# Handoff delivery\n');
107
+ env.GOTRY_TURN_HANDOFF_ROOT = outside;
108
+ response = await handlers.artifacts(new Request('http://localhost/api/gotry/artifacts?sessionId=known-session&search=Host-owned'));
109
+ assert.equal((await response.json()).artifacts[0]?.id, 'host-ticket', 'Web must use the same configured Host handoff root as conversational listing');
110
+ console.log('GoTry Web API: verified save, rollback, cancellation, override, bounds, conflict, clear and Session-owned artifact search PASS');
111
+ }
112
+ finally {
113
+ rmSync(root, { recursive: true, force: true });
114
+ }
@@ -0,0 +1,28 @@
1
+ /** Native Web entry registration, without a model invocation or additional UI shell. */
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import vm from 'node:vm';
6
+ const registrations = [], slots = [], tabs = [];
7
+ const source = readFileSync(join(import.meta.dirname, '../../client/client.js'), 'utf8');
8
+ vm.runInNewContext(source, { window: { __ModuleLoader__: { load: (r) => registrations.push(r) } }, console, fetch, AbortController });
9
+ const react = { createElement: (type, props, ...children) => ({ type, props, children }) };
10
+ const primitives = { SettingsForm: () => { }, SettingsSecretField: () => { }, Button: () => { }, Input: () => { }, Tag: () => { }, FileTypeIcon: () => { } };
11
+ const plugin = registrations[0].factory((name) => name === 'react' ? react : primitives);
12
+ const context = {
13
+ locale: { bind: () => (key) => key, register: () => () => { } },
14
+ slots: { inject: (_name, f) => f(), register: (definition, component) => { slots.push({ definition, component }); return () => { }; } },
15
+ sidebarRightTabs: { register: (definition) => { tabs.push(definition); return () => { }; } },
16
+ effect: (f) => f(),
17
+ inject: (names, f) => { if (names.every(name => name in context))
18
+ f(context); },
19
+ };
20
+ plugin.apply(context);
21
+ assert.ok(slots.some(s => s.definition.name === 'plugins.item' && s.definition.id === 'gotry'), 'Plugins must contain a directly visible GoTry configuration entry');
22
+ assert.ok(tabs.some(t => t.kind === 'gotry-artifacts' && t.guide?.length), 'right Sidebar must contain an always discoverable GoTry artifacts page');
23
+ assert.ok(slots.some(s => s.definition.name === 'sidebar.right.pane.tab' && s.definition.key === '@danceiny/gotry:artifacts'));
24
+ const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '../../package.json'), 'utf8'));
25
+ for (const dependency of ['@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-plugin-manager', '@deepseek-ai/dsh-client-ui-sidebar-right']) {
26
+ assert.ok(pkg.dsh.client.inject.includes(dependency), `the shipped client graph must include ${dependency}`);
27
+ }
28
+ console.log('GoTry native Plugins and artifact Sidebar entries PASS');
@@ -32,6 +32,8 @@ function privateEnv(root) {
32
32
  TMPDIR: root,
33
33
  TEMP: root,
34
34
  TMP: root,
35
+ NODE_OPTIONS: `--import=${pathToFileURL(join(repoRoot, 'scripts', 'doctor-test-isolation.mjs')).href}`,
36
+ GOTRY_TEST_DOCTOR_PORT: '0',
35
37
  };
36
38
  }
37
39
  function waitForClose(child, timeoutMs) {
@@ -300,7 +302,7 @@ function writeProductFixture(root, options = { writeTerminalOutput: false, mode:
300
302
  mkdirSync(presentDir, { recursive: true });
301
303
  writeFileSync(join(presentDir, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-tool-present', main: 'index.js' }));
302
304
  writeFileSync(join(presentDir, 'index.js'), 'module.exports = {}\n');
303
- for (const file of ['gotry-inner.js', 'gotry-bootstrap.js', 'gotry-runtime-resolution.js', 'gotry-process-liveness.js']) {
305
+ for (const file of ['gotry-inner.js', 'gotry-bootstrap.js', 'gotry-flyai-verification.js', 'gotry-extension-status.js', 'gotry-runtime-resolution.js', 'gotry-process-liveness.js']) {
304
306
  copyFileSync(join(repoRoot, 'bin', file), join(binDir, file));
305
307
  }
306
308
  copyFileSync(join(repoRoot, 'ts', 'capabilities', 'incident-log.ts'), join(tsCapabilitiesDir, 'incident-log.ts'));
@@ -0,0 +1,26 @@
1
+ import { realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, sep } from 'node:path';
3
+ export function registerArtifactDeliveries(ctx) {
4
+ // Tool-registration-only hosts have no conversation event surface.
5
+ if (typeof ctx.on !== 'function')
6
+ return;
7
+ ctx.on('tools/result', (exec, result) => {
8
+ if (result.isError || exec.signal.aborted || !['gotry_itinerary_render', 'gotry_itinerary_deck_render'].includes(exec.name))
9
+ return;
10
+ const value = result.value;
11
+ const session = exec.agent?.session, cwd = session?.header.cwd;
12
+ if (!session || !cwd || !isAbsolute(cwd) || value?.ok !== true || typeof value.path !== 'string' || !isAbsolute(value.path))
13
+ return;
14
+ const boundary = ctx.get('sessionProjections')?.stateOf(session, 'turnBoundary');
15
+ if (!boundary || boundary.openTurnStartSeq === null)
16
+ return;
17
+ try {
18
+ const path = realpathSync(value.path), root = realpathSync(cwd), inside = relative(root, path);
19
+ if (inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside) || !statSync(path).isFile())
20
+ return;
21
+ session.append('deliverables/presented', { turn: boundary.lastTurn, callId: exec.callId,
22
+ files: [{ path: value.path, description: 'GoTry itinerary' }] });
23
+ }
24
+ catch { /* A deleted or escaped file cannot become a delivery. */ }
25
+ });
26
+ }
@@ -0,0 +1,157 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute } from 'node:path';
4
+ import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js';
5
+ import { listArtifacts } from '../capabilities/artifacts.js';
6
+ import { atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath, isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification, removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey, sha256Hex, writeFlyaiVerification, } from '../capabilities/flyai-config.js';
7
+ const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
8
+ const rejection = (error, status = 400) => json({ ok: false, error }, status);
9
+ const BODY_LIMIT = 8192;
10
+ async function readInput(request) {
11
+ if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json')
12
+ return rejection('需要 JSON 请求。', 415);
13
+ const reader = request.body?.getReader();
14
+ if (!reader)
15
+ return rejection('请求为空。');
16
+ const chunks = [];
17
+ let bytes = 0;
18
+ try {
19
+ while (true) {
20
+ const chunk = await reader.read();
21
+ if (chunk.done)
22
+ break;
23
+ bytes += chunk.value.byteLength;
24
+ if (bytes > BODY_LIMIT) {
25
+ await reader.cancel();
26
+ return rejection('请求过大。', 413);
27
+ }
28
+ chunks.push(chunk.value);
29
+ }
30
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
31
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : rejection('请求无效。');
32
+ }
33
+ catch {
34
+ return rejection('请求无效。');
35
+ }
36
+ finally {
37
+ reader.releaseLock();
38
+ }
39
+ }
40
+ export function createGotryWebHandlers(options) {
41
+ const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env;
42
+ const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir);
43
+ let mutations = Promise.resolve();
44
+ const status = () => {
45
+ const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir);
46
+ const matches = current.key && receipt?.keySha256 === sha256Hex(current.key) && receipt.source === current.source
47
+ && receipt.endpointFingerprint === endpointFingerprint(endpoint.url) && receipt.endpointDebug === endpoint.debug;
48
+ return { ok: true, configured: Boolean(current.key), verified: Boolean(matches && receipt?.verdict === 'verified'),
49
+ writable: current.source !== 'env' && current.source !== 'env-debug' && process.platform !== 'win32', source: current.source,
50
+ endpoint: displayEndpoint(endpoint.url), endpointDebug: endpoint.debug,
51
+ ...(matches && receipt ? { checkedAt: receipt.at, verdict: receipt.verdict } : {}) };
52
+ };
53
+ const bytesAt = () => existsSync(configPath) ? readFileSync(configPath, 'utf8') : undefined;
54
+ async function mutate(input, signal) {
55
+ if (signal.aborted)
56
+ return rejection('操作已取消。', 409);
57
+ if (!status().writable)
58
+ return rejection('当前 Key 由环境变量提供或此平台不支持安全写入。', 409);
59
+ if (input.action === 'clear') {
60
+ const result = clearFlyaiKey(paths);
61
+ if (!result.ok)
62
+ return rejection('无法清除配置,请检查本机配置文件及权限。', 409);
63
+ removeFlyaiVerification(homeDir);
64
+ return json(status());
65
+ }
66
+ const key = typeof input.key === 'string' ? input.key.trim() : '';
67
+ if (!key || key.length > 2048 || /[\r\n\0]/.test(key) || isPlaceholderFlyaiKey(key))
68
+ return rejection('请粘贴控制台中的真实 API Key。');
69
+ const before = readFlyaiConfig(configPath);
70
+ if (!before.ok)
71
+ return rejection('原配置损坏,已保留原文件,请先修复。', 409);
72
+ const previousBytes = bytesAt(), endpoint = resolveFlyaiEndpoint(paths), fingerprint = endpointFingerprint(endpoint.url);
73
+ if (!fingerprint)
74
+ return rejection('当前服务地址无效。', 409);
75
+ const verify = await verifyFlyaiCandidate(key, { env: { ...env, HOME: homeDir }, signal });
76
+ if (signal.aborted || verify.verdict !== 'hit')
77
+ return json({ ok: false, verdict: signal.aborted ? 'cancelled' : verify.verdict,
78
+ error: signal.aborted ? '验证已取消,旧配置保留。' : verify.error ?? '验证失败,旧配置保留。' }, 422);
79
+ if (!status().writable || bytesAt() !== previousBytes || fingerprint !== endpointFingerprint(resolveFlyaiEndpoint(paths).url)) {
80
+ return rejection('配置在验证期间发生变化,请重新读取后再试。', 409);
81
+ }
82
+ const saved = saveFlyaiKey(key, paths);
83
+ if (!saved.ok)
84
+ return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409);
85
+ const receiptSaved = writeFlyaiVerification({ schema: 'gotry.flyai-verification.v1', source: 'config',
86
+ keySha256: sha256Hex(key), maskedKey: maskFlyaiKey(key), verdict: 'verified', endpointFingerprint: fingerprint,
87
+ endpointDebug: endpoint.debug, at: new Date().toISOString() }, homeDir);
88
+ if (!receiptSaved) {
89
+ const restored = previousBytes === undefined
90
+ ? (() => { try {
91
+ rmSync(configPath, { force: true });
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ } })()
97
+ : atomicWriteJson(configPath, before.data).ok;
98
+ return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,恢复旧配置失败,请检查本机文件。', 409);
99
+ }
100
+ return json(status());
101
+ }
102
+ return {
103
+ async flyai(request) {
104
+ if (request.method === 'GET')
105
+ return json(status());
106
+ if (request.method !== 'POST')
107
+ return rejection('不支持此操作。', 405);
108
+ const input = await readInput(request);
109
+ if (input instanceof Response)
110
+ return input;
111
+ if (!['save', 'clear'].includes(String(input.action)) || Object.keys(input).some(key => key !== 'action' && key !== 'key')
112
+ || (input.action === 'clear' && 'key' in input))
113
+ return rejection('请求无效。');
114
+ const operation = mutations.then(() => mutate(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500));
115
+ mutations = operation.then(() => undefined);
116
+ return operation;
117
+ },
118
+ async artifacts(request) {
119
+ const query = new URL(request.url).searchParams;
120
+ if ([...query.keys()].some(key => !['sessionId', 'search', 'offset', 'limit'].includes(key)))
121
+ return rejection('查询字段无效。');
122
+ const sessionId = query.get('sessionId') ?? '';
123
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(sessionId))
124
+ return rejection('需要有效会话。');
125
+ const cwd = await options.workspaceForSession(sessionId);
126
+ if (!cwd || !isAbsolute(cwd))
127
+ return rejection('会话工作目录不可用。', 404);
128
+ try {
129
+ return json({ ok: true, ...await listArtifacts({ stateRoot: options.stateRoot, cwd,
130
+ handoffRoot: env.GOTRY_TURN_HANDOFF_ROOT ?? (options.stateRoot !== '.' ? options.stateRoot : cwd),
131
+ search: query.get('search') ?? undefined, offset: query.has('offset') ? Number(query.get('offset')) : undefined,
132
+ limit: query.has('limit') ? Number(query.get('limit')) : 20 }) });
133
+ }
134
+ catch {
135
+ return rejection('查询参数无效或产物暂时不可读取。');
136
+ }
137
+ },
138
+ };
139
+ }
140
+ export function registerGotryWebApi(ctx, stateRoot) {
141
+ if (typeof ctx.inject !== 'function')
142
+ return;
143
+ ctx.inject(['connection', 'sessions'], scope => {
144
+ const connection = scope.get('connection');
145
+ const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async (sessionId) => {
146
+ const id = sessionId;
147
+ const live = scope.sessions.get(id)?.header;
148
+ const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId);
149
+ return (live ?? stored?.header)?.cwd;
150
+ } });
151
+ // DSH's streaming adapter always attaches a request body; GET cannot use
152
+ // it. Exact routes have one body mode, so reads and bounded writes differ.
153
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }));
154
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }));
155
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }));
156
+ });
157
+ }
package/dist/src/index.js CHANGED
@@ -42,6 +42,8 @@ import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-expo
42
42
  import { interpretEffect, declinedObservation } from '../capabilities/effect.js';
43
43
  import { customerSendCode, customerLogin } from '../capabilities/hbcli.js';
44
44
  import { FLYAI_POI_CATEGORIES } from '../capabilities/flyai.js';
45
+ import { registerGotryWebApi } from './gotry-web-api.js';
46
+ import { registerArtifactDeliveries } from './artifact-delivery.js';
45
47
  import { createFlyaiSetupTool } from './flyai-setup-tool.js';
46
48
  import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.js';
47
49
  import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.js';
@@ -429,6 +431,10 @@ export function apply(ctx, config, seams = {}) {
429
431
  // 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
430
432
  // 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
431
433
  // 必须产出最终交付物,不得再次 handoff(否则无限递归)。
434
+ if (!rawBenchmarkEnvironmentConfigPath) {
435
+ registerGotryWebApi(ctx, config.stateRoot ?? '.');
436
+ registerArtifactDeliveries(ctx);
437
+ }
432
438
  const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
433
439
  installTurnDeadline(ctx, handoffChild
434
440
  ? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stai Travel Bridge",
4
- "version": "0.2.0.30",
4
+ "version": "0.2.0.31",
5
5
  "description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -56,5 +56,5 @@
56
56
  "run_at": "document_start"
57
57
  }
58
58
  ],
59
- "version_name": "0.2.0-rc.30"
59
+ "version_name": "0.2.0-rc.31"
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.30",
3
+ "version": "0.2.0-rc.31",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -44,7 +44,10 @@
44
44
  "client": {
45
45
  "platform": "web",
46
46
  "inject": [
47
- "@deepseek-ai/dsh-client-ui-tool"
47
+ "@deepseek-ai/dsh-client-ui-tool",
48
+ "@deepseek-ai/dsh-client-ui-primitives",
49
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
50
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
48
51
  ]
49
52
  }
50
53
  },
@@ -57,6 +60,8 @@
57
60
  "bin/",
58
61
  "ts/src/index.ts",
59
62
  "ts/src/flyai-setup-tool.ts",
63
+ "ts/src/gotry-web-api.ts",
64
+ "ts/src/artifact-delivery.ts",
60
65
  "ts/src/subagent-job-id-guard.ts",
61
66
  "ts/src/benchmark-environment-bridge.ts",
62
67
  "ts/src/benchmark-tool-isolation.ts",
@@ -0,0 +1,25 @@
1
+ /** Bridge successful GoTry render results to DSH's native present/delivery protocol. */
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import type {} from '@deepseek-ai/dsh-tool-present/types'
4
+ import type {} from '@deepseek-ai/dsh-tools'
5
+ import { realpathSync, statSync } from 'node:fs'
6
+ import { isAbsolute, relative, sep } from 'node:path'
7
+
8
+ export function registerArtifactDeliveries(ctx: Context): void {
9
+ // Tool-registration-only hosts have no conversation event surface.
10
+ if (typeof ctx.on !== 'function') return
11
+ ctx.on('tools/result', (exec, result) => {
12
+ if (result.isError || exec.signal.aborted || !['gotry_itinerary_render', 'gotry_itinerary_deck_render'].includes(exec.name)) return
13
+ const value = result.value as { ok?: boolean; path?: unknown } | null
14
+ const session = exec.agent?.session, cwd = session?.header.cwd
15
+ if (!session || !cwd || !isAbsolute(cwd) || value?.ok !== true || typeof value.path !== 'string' || !isAbsolute(value.path)) return
16
+ const boundary = ctx.get('sessionProjections')?.stateOf(session, 'turnBoundary')
17
+ if (!boundary || boundary.openTurnStartSeq === null) return
18
+ try {
19
+ const path = realpathSync(value.path), root = realpathSync(cwd), inside = relative(root, path)
20
+ if (inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside) || !statSync(path).isFile()) return
21
+ session.append('deliverables/presented', { turn: boundary.lastTurn, callId: exec.callId,
22
+ files: [{ path: value.path, description: 'GoTry itinerary' }] })
23
+ } catch { /* A deleted or escaped file cannot become a delivery. */ }
24
+ })
25
+ }
@@ -0,0 +1,140 @@
1
+ /** Native Connection routes. Secrets never enter model tools or conversation events. */
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
4
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { isAbsolute } from 'node:path'
7
+ import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js'
8
+ import { listArtifacts } from '../capabilities/artifacts.ts'
9
+ import {
10
+ atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath,
11
+ isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification,
12
+ removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey,
13
+ sha256Hex, writeFlyaiVerification,
14
+ } from '../capabilities/flyai-config.ts'
15
+
16
+ interface WebOptions {
17
+ stateRoot: string
18
+ homeDir?: string
19
+ env?: NodeJS.ProcessEnv
20
+ workspaceForSession: (sessionId: string) => Promise<string | undefined>
21
+ }
22
+
23
+ const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
24
+ const rejection = (error: string, status = 400) => json({ ok: false, error }, status)
25
+ const BODY_LIMIT = 8192
26
+
27
+ async function readInput(request: Request): Promise<Record<string, unknown> | Response> {
28
+ if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') return rejection('需要 JSON 请求。', 415)
29
+ const reader = request.body?.getReader()
30
+ if (!reader) return rejection('请求为空。')
31
+ const chunks: Uint8Array[] = []
32
+ let bytes = 0
33
+ try {
34
+ while (true) {
35
+ const chunk = await reader.read()
36
+ if (chunk.done) break
37
+ bytes += chunk.value.byteLength
38
+ if (bytes > BODY_LIMIT) { await reader.cancel(); return rejection('请求过大。', 413) }
39
+ chunks.push(chunk.value)
40
+ }
41
+ const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
42
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : rejection('请求无效。')
43
+ } catch { return rejection('请求无效。') }
44
+ finally { reader.releaseLock() }
45
+ }
46
+
47
+ export function createGotryWebHandlers(options: WebOptions) {
48
+ const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env
49
+ const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir)
50
+ let mutations: Promise<unknown> = Promise.resolve()
51
+ const status = () => {
52
+ const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir)
53
+ const matches = current.key && receipt?.keySha256 === sha256Hex(current.key) && receipt.source === current.source
54
+ && receipt.endpointFingerprint === endpointFingerprint(endpoint.url) && receipt.endpointDebug === endpoint.debug
55
+ return { ok: true, configured: Boolean(current.key), verified: Boolean(matches && receipt?.verdict === 'verified'),
56
+ writable: current.source !== 'env' && current.source !== 'env-debug' && process.platform !== 'win32', source: current.source,
57
+ endpoint: displayEndpoint(endpoint.url), endpointDebug: endpoint.debug,
58
+ ...(matches && receipt ? { checkedAt: receipt.at, verdict: receipt.verdict } : {}) }
59
+ }
60
+ const bytesAt = () => existsSync(configPath) ? readFileSync(configPath, 'utf8') : undefined
61
+ async function mutate(input: Record<string, unknown>, signal: AbortSignal): Promise<Response> {
62
+ if (signal.aborted) return rejection('操作已取消。', 409)
63
+ if (!status().writable) return rejection('当前 Key 由环境变量提供或此平台不支持安全写入。', 409)
64
+ if (input.action === 'clear') {
65
+ const result = clearFlyaiKey(paths)
66
+ if (!result.ok) return rejection('无法清除配置,请检查本机配置文件及权限。', 409)
67
+ removeFlyaiVerification(homeDir)
68
+ return json(status())
69
+ }
70
+ const key = typeof input.key === 'string' ? input.key.trim() : ''
71
+ if (!key || key.length > 2048 || /[\r\n\0]/.test(key) || isPlaceholderFlyaiKey(key)) return rejection('请粘贴控制台中的真实 API Key。')
72
+ const before = readFlyaiConfig(configPath)
73
+ if (!before.ok) return rejection('原配置损坏,已保留原文件,请先修复。', 409)
74
+ const previousBytes = bytesAt(), endpoint = resolveFlyaiEndpoint(paths), fingerprint = endpointFingerprint(endpoint.url)
75
+ if (!fingerprint) return rejection('当前服务地址无效。', 409)
76
+ const verify = await verifyFlyaiCandidate(key, { env: { ...env, HOME: homeDir }, signal })
77
+ if (signal.aborted || verify.verdict !== 'hit') return json({ ok: false, verdict: signal.aborted ? 'cancelled' : verify.verdict,
78
+ error: signal.aborted ? '验证已取消,旧配置保留。' : verify.error ?? '验证失败,旧配置保留。' }, 422)
79
+ if (!status().writable || bytesAt() !== previousBytes || fingerprint !== endpointFingerprint(resolveFlyaiEndpoint(paths).url)) {
80
+ return rejection('配置在验证期间发生变化,请重新读取后再试。', 409)
81
+ }
82
+ const saved = saveFlyaiKey(key, paths)
83
+ if (!saved.ok) return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409)
84
+ const receiptSaved = writeFlyaiVerification({ schema: 'gotry.flyai-verification.v1', source: 'config',
85
+ keySha256: sha256Hex(key), maskedKey: maskFlyaiKey(key), verdict: 'verified', endpointFingerprint: fingerprint,
86
+ endpointDebug: endpoint.debug, at: new Date().toISOString() }, homeDir)
87
+ if (!receiptSaved) {
88
+ const restored = previousBytes === undefined
89
+ ? (() => { try { rmSync(configPath, { force: true }); return true } catch { return false } })()
90
+ : atomicWriteJson(configPath, before.data).ok
91
+ return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,恢复旧配置失败,请检查本机文件。', 409)
92
+ }
93
+ return json(status())
94
+ }
95
+ return {
96
+ async flyai(request: Request): Promise<Response> {
97
+ if (request.method === 'GET') return json(status())
98
+ if (request.method !== 'POST') return rejection('不支持此操作。', 405)
99
+ const input = await readInput(request)
100
+ if (input instanceof Response) return input
101
+ if (!['save', 'clear'].includes(String(input.action)) || Object.keys(input).some(key => key !== 'action' && key !== 'key')
102
+ || (input.action === 'clear' && 'key' in input)) return rejection('请求无效。')
103
+ const operation = mutations.then(() => mutate(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500))
104
+ mutations = operation.then(() => undefined)
105
+ return operation
106
+ },
107
+ async artifacts(request: Request): Promise<Response> {
108
+ const query = new URL(request.url).searchParams
109
+ if ([...query.keys()].some(key => !['sessionId', 'search', 'offset', 'limit'].includes(key))) return rejection('查询字段无效。')
110
+ const sessionId = query.get('sessionId') ?? ''
111
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(sessionId)) return rejection('需要有效会话。')
112
+ const cwd = await options.workspaceForSession(sessionId)
113
+ if (!cwd || !isAbsolute(cwd)) return rejection('会话工作目录不可用。', 404)
114
+ try {
115
+ return json({ ok: true, ...await listArtifacts({ stateRoot: options.stateRoot, cwd,
116
+ handoffRoot: env.GOTRY_TURN_HANDOFF_ROOT ?? (options.stateRoot !== '.' ? options.stateRoot : cwd),
117
+ search: query.get('search') ?? undefined, offset: query.has('offset') ? Number(query.get('offset')) : undefined,
118
+ limit: query.has('limit') ? Number(query.get('limit')) : 20 }) })
119
+ } catch { return rejection('查询参数无效或产物暂时不可读取。') }
120
+ },
121
+ }
122
+ }
123
+
124
+ export function registerGotryWebApi(ctx: Context, stateRoot: string): void {
125
+ if (typeof ctx.inject !== 'function') return
126
+ ctx.inject(['connection', 'sessions'], scope => {
127
+ const connection = scope.get('connection') as HostConnectionHandle
128
+ const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async sessionId => {
129
+ const id = sessionId as Parameters<typeof scope.sessions.get>[0]
130
+ const live = scope.sessions.get(id)?.header
131
+ const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId)
132
+ return (live ?? stored?.header)?.cwd
133
+ } })
134
+ // DSH's streaming adapter always attaches a request body; GET cannot use
135
+ // it. Exact routes have one body mode, so reads and bounded writes differ.
136
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }))
137
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }))
138
+ scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }))
139
+ })
140
+ }
package/ts/src/index.ts CHANGED
@@ -59,6 +59,8 @@ import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-expo
59
59
  import { interpretEffect, declinedObservation, type EffectInterpreter } from '../capabilities/effect.ts'
60
60
  import { customerSendCode, customerLogin } from '../capabilities/hbcli.ts'
61
61
  import { FLYAI_POI_CATEGORIES, type FlyaiKind, type FlyaiQuery, type FlyaiResult } from '../capabilities/flyai.ts'
62
+ import { registerGotryWebApi } from './gotry-web-api.ts'
63
+ import { registerArtifactDeliveries } from './artifact-delivery.ts'
62
64
  import { createFlyaiSetupTool } from './flyai-setup-tool.ts'
63
65
  import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.ts'
64
66
  import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.ts'
@@ -520,6 +522,10 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
520
522
  // 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
521
523
  // 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
522
524
  // 必须产出最终交付物,不得再次 handoff(否则无限递归)。
525
+ if (!rawBenchmarkEnvironmentConfigPath) {
526
+ registerGotryWebApi(ctx, config.stateRoot ?? '.')
527
+ registerArtifactDeliveries(ctx)
528
+ }
523
529
  const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1'
524
530
  installTurnDeadline(ctx, handoffChild
525
531
  ? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }