@ckanner/flywheel 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -0
  3. package/README.zh-CN.md +2 -0
  4. package/THIRD_PARTY_NOTICES.md +5 -1
  5. package/bin/flywheel-kimi-native-worker.js +5 -0
  6. package/bin/flywheel.js +8 -1
  7. package/bin/runtime.js +5 -1
  8. package/dist/bridge.mjs +89 -7
  9. package/dist/claude-code-pro-bridge.mjs +55 -6
  10. package/dist/client.js +107 -10
  11. package/dist/codex-pro-bridge.mjs +12 -5
  12. package/dist/control.mjs +1 -1
  13. package/dist/kimi-native-bridge.mjs +429 -0
  14. package/dist/kimi-native-runtime.mjs +233 -0
  15. package/dist/kimi-native-worker.mjs +77 -0
  16. package/dist/kimi-tower-candidate.mjs +227 -0
  17. package/dist/machine-worker.mjs +39 -5
  18. package/dist/managed-lcu.mjs +223 -0
  19. package/dist/native-doctor.mjs +36 -0
  20. package/dist/native-jobs.mjs +72 -0
  21. package/dist/native-policy.mjs +371 -0
  22. package/dist/native-resources.mjs +144 -0
  23. package/dist/native.mjs +251 -0
  24. package/dist/policy.mjs +13 -3
  25. package/dist/prewarm.mjs +4 -1
  26. package/dist/protocol.mjs +8 -1
  27. package/dist/worker.mjs +16 -9
  28. package/docs/README.md +9 -0
  29. package/docs/README.zh-CN.md +3 -0
  30. package/docs/design.md +34 -12
  31. package/docs/en/claude-code-pro.md +2 -2
  32. package/docs/en/configuration.md +17 -5
  33. package/docs/en/contributing.md +5 -3
  34. package/docs/en/flow-console.md +1 -1
  35. package/docs/en/getting-started.md +2 -2
  36. package/docs/en/model-policy.md +2 -2
  37. package/docs/en/native-capabilities.md +185 -0
  38. package/docs/en/releasing.md +2 -2
  39. package/docs/en/session-recovery.md +2 -2
  40. package/docs/plans/2026-11-13-native-harness-capabilities.en.md +258 -0
  41. package/docs/plans/2026-11-13-native-harness-capabilities.md +276 -0
  42. package/docs/plans/README.md +1 -0
  43. package/docs/validation/2026-11-14-desktop-native-configuration.md +128 -0
  44. package/docs/validation/2026-11-14-native-harness-implementation.md +215 -0
  45. package/docs/validation/2026-11-14-published-companions.md +15 -0
  46. package/docs/validation/2026-11-14-release-0.33.0.md +49 -0
  47. package/docs/validation/2026-11-14-release-0.33.1.md +78 -0
  48. package/docs/validation/README.md +4 -0
  49. package/docs/validation/evidence/2026-11-14-native-harness-candidate.json +255 -0
  50. package/docs/validation/evidence/2026-11-14-native-harness-sessions.json +6009 -0
  51. package/docs/validation/evidence/2026-11-14-native-harness-tower-candidate.json +192 -0
  52. package/docs/zh-CN/claude-code-pro.md +2 -2
  53. package/docs/zh-CN/configuration.md +21 -8
  54. package/docs/zh-CN/contributing.md +5 -3
  55. package/docs/zh-CN/flow-console.md +1 -1
  56. package/docs/zh-CN/getting-started.md +2 -2
  57. package/docs/zh-CN/model-policy.md +2 -2
  58. package/docs/zh-CN/native-capabilities.md +184 -0
  59. package/docs/zh-CN/releasing.md +2 -2
  60. package/docs/zh-CN/session-recovery.md +1 -1
  61. package/native/darwin-amd64/flywheel +0 -0
  62. package/native/darwin-arm64/flywheel +0 -0
  63. package/native/linux-amd64/flywheel +0 -0
  64. package/native/linux-arm64/flywheel +0 -0
  65. package/native/manifest.json +9 -9
  66. package/node_modules/dsh-preset-generator/CHANGELOG.md +77 -0
  67. package/node_modules/dsh-preset-generator/LICENSE +21 -0
  68. package/node_modules/dsh-preset-generator/README.md +149 -0
  69. package/node_modules/dsh-preset-generator/docs/README.zh.md +95 -0
  70. package/node_modules/dsh-preset-generator/package.json +43 -0
  71. package/node_modules/dsh-preset-generator/src/composition.mjs +150 -0
  72. package/node_modules/dsh-preset-generator/src/gen-presets.mjs +367 -0
  73. package/node_modules/dsh-preset-generator/src/policy.mjs +17 -0
  74. package/node_modules/dsh-subagent-codex-pro/CHANGELOG.md +16 -1
  75. package/node_modules/dsh-subagent-codex-pro/README.md +57 -4
  76. package/node_modules/dsh-subagent-codex-pro/assets/native-client.js +118 -0
  77. package/node_modules/dsh-subagent-codex-pro/cordis.patch.yml +6 -0
  78. package/node_modules/dsh-subagent-codex-pro/docs/README.zh.md +49 -2
  79. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle-validation.md +1 -1
  80. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.md +1 -1
  81. package/node_modules/dsh-subagent-codex-pro/docs/machine-lifecycle.zh.md +1 -1
  82. package/node_modules/dsh-subagent-codex-pro/docs/machine.md +2 -2
  83. package/node_modules/dsh-subagent-codex-pro/docs/native-capabilities.md +254 -0
  84. package/node_modules/dsh-subagent-codex-pro/docs/native-capabilities.zh.md +180 -0
  85. package/node_modules/dsh-subagent-codex-pro/lib/config.js +56 -0
  86. package/node_modules/dsh-subagent-codex-pro/lib/config.js.map +1 -0
  87. package/node_modules/dsh-subagent-codex-pro/lib/desktop-native.js +109 -0
  88. package/node_modules/dsh-subagent-codex-pro/lib/desktop-native.js.map +1 -0
  89. package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js +3 -4
  90. package/node_modules/dsh-subagent-codex-pro/lib/dsh-types.js.map +1 -1
  91. package/node_modules/dsh-subagent-codex-pro/lib/index.js +64 -15
  92. package/node_modules/dsh-subagent-codex-pro/lib/index.js.map +1 -1
  93. package/node_modules/dsh-subagent-codex-pro/lib/jobs.js +6 -2
  94. package/node_modules/dsh-subagent-codex-pro/lib/jobs.js.map +1 -1
  95. package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js +2 -2
  96. package/node_modules/dsh-subagent-codex-pro/lib/machine-cli.js.map +1 -1
  97. package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js +5 -2
  98. package/node_modules/dsh-subagent-codex-pro/lib/machine-local.js.map +1 -1
  99. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js +11 -0
  100. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervision.js.map +1 -1
  101. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js +1 -0
  102. package/node_modules/dsh-subagent-codex-pro/lib/machine-supervisor.js.map +1 -1
  103. package/node_modules/dsh-subagent-codex-pro/lib/machine.js +20 -3
  104. package/node_modules/dsh-subagent-codex-pro/lib/machine.js.map +1 -1
  105. package/node_modules/dsh-subagent-codex-pro/lib/native.js +297 -0
  106. package/node_modules/dsh-subagent-codex-pro/lib/native.js.map +1 -0
  107. package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js +11 -1
  108. package/node_modules/dsh-subagent-codex-pro/lib/process-guardian.js.map +1 -1
  109. package/node_modules/dsh-subagent-codex-pro/lib/run.js +19 -1
  110. package/node_modules/dsh-subagent-codex-pro/lib/run.js.map +1 -1
  111. package/node_modules/dsh-subagent-codex-pro/lib/tool.js +123 -57
  112. package/node_modules/dsh-subagent-codex-pro/lib/tool.js.map +1 -1
  113. package/node_modules/dsh-subagent-codex-pro/lib/types/config.d.ts +31 -0
  114. package/node_modules/dsh-subagent-codex-pro/lib/types/config.d.ts.map +1 -0
  115. package/node_modules/dsh-subagent-codex-pro/lib/types/desktop-native.d.ts +30 -0
  116. package/node_modules/dsh-subagent-codex-pro/lib/types/desktop-native.d.ts.map +1 -0
  117. package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts +7 -5
  118. package/node_modules/dsh-subagent-codex-pro/lib/types/dsh-types.d.ts.map +1 -1
  119. package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts +31 -0
  120. package/node_modules/dsh-subagent-codex-pro/lib/types/index.d.ts.map +1 -1
  121. package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts +2 -0
  122. package/node_modules/dsh-subagent-codex-pro/lib/types/jobs.d.ts.map +1 -1
  123. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-local.d.ts.map +1 -1
  124. package/node_modules/dsh-subagent-codex-pro/lib/types/machine-supervision.d.ts.map +1 -1
  125. package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts +7 -0
  126. package/node_modules/dsh-subagent-codex-pro/lib/types/machine.d.ts.map +1 -1
  127. package/node_modules/dsh-subagent-codex-pro/lib/types/native.d.ts +120 -0
  128. package/node_modules/dsh-subagent-codex-pro/lib/types/native.d.ts.map +1 -0
  129. package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts +11 -0
  130. package/node_modules/dsh-subagent-codex-pro/lib/types/run.d.ts.map +1 -1
  131. package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts +11 -0
  132. package/node_modules/dsh-subagent-codex-pro/lib/types/tool.d.ts.map +1 -1
  133. package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts +7 -1
  134. package/node_modules/dsh-subagent-codex-pro/lib/types/wire.d.ts.map +1 -1
  135. package/node_modules/dsh-subagent-codex-pro/lib/wire.js +34 -6
  136. package/node_modules/dsh-subagent-codex-pro/lib/wire.js.map +1 -1
  137. package/node_modules/dsh-subagent-codex-pro/package.json +28 -4
  138. package/npm/install.js +2 -2
  139. package/package.json +25 -10
  140. package/plugins/dsh/KIMI_NATIVE_PROTOCOL.en.md +76 -0
  141. package/plugins/dsh/KIMI_NATIVE_PROTOCOL.md +76 -0
  142. package/plugins/dsh/PROTOCOL.md +151 -17
  143. package/plugins/dsh/README.md +15 -12
  144. package/plugins/dsh/README.zh-CN.md +8 -8
  145. package/plugins/dsh/THIRD_PARTY_NOTICES.md +5 -1
  146. package/plugins/dsh/vendor/README.md +40 -11
  147. package/plugins/dsh/vendor/chatgpt.json +21 -0
  148. package/plugins/dsh/vendor/claude-code-pro.json +21 -0
  149. package/plugins/dsh/vendor/codex-pro.json +13 -8
  150. package/plugins/dsh/vendor/lcu.json +21 -0
  151. package/plugins/dsh/vendor/preset-generator.json +16 -0
@@ -0,0 +1,233 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn, execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { readdir, readFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const execute = promisify(execFile);
9
+ export const digest = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
10
+ export const error = (code, message) => Object.assign(new Error(message), { code });
11
+ export const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
12
+ export const checkAbort = signal => { if (signal?.aborted) throw typeof signal.reason?.code === 'string' ? signal.reason : error('CANCELLED', 'The owned Kimi invocation was cancelled'); };
13
+
14
+ async function processTable() {
15
+ const { stdout } = await execute('ps', ['-axo', 'pid=,ppid=,lstart=,stat='], {
16
+ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, timeout: 2000, env: { ...process.env, LC_ALL: 'C' },
17
+ });
18
+ return stdout.trim().split('\n').map(line => {
19
+ const fields = line.trim().split(/\s+/);
20
+ return { pid: Number(fields[0]), parent: Number(fields[1]), birth: fields.slice(2, 7).join(' '), state: fields[7] };
21
+ }).filter(row => Number.isSafeInteger(row.pid) && row.birth && row.state);
22
+ }
23
+
24
+ /** One foreground local server, sharing only the user's existing credential home. */
25
+ export class OwnedKimiRuntime {
26
+ constructor(env, mode) {
27
+ this.env = { ...env, KIMI_CODE_EXPERIMENTAL_TOWER: mode === 'tower' ? '1' : '0' };
28
+ this.home = env.KIMI_CODE_HOME || join(env.HOME || homedir(), '.kimi-code');
29
+ this.processes = new Map();
30
+ this.processInspection = false;
31
+ this.closed = false;
32
+ }
33
+ async sampleProcesses() {
34
+ const table = await processTable();
35
+ let changed = true;
36
+ const root = table.find(row => row.pid === this.child.pid);
37
+ // Never adopt a reused PID after the owned process has exited.
38
+ const owned = new Set();
39
+ if (root && (!this.closed || this.processes.get(root.pid)?.birth === root.birth)) {
40
+ owned.add(root.pid); this.observedRoot = true;
41
+ }
42
+ for (const row of table) if (this.processes.get(row.pid)?.birth === row.birth) owned.add(row.pid);
43
+ while (changed) {
44
+ changed = false;
45
+ for (const row of table) if (!owned.has(row.pid) && owned.has(row.parent)) { owned.add(row.pid); changed = true; }
46
+ }
47
+ for (const row of table) if (owned.has(row.pid)) this.processes.set(row.pid, row);
48
+ this.processInspection = true;
49
+ return table.filter(row => this.processes.get(row.pid)?.birth === row.birth && !row.state.startsWith('Z'));
50
+ }
51
+ async start(signal) {
52
+ checkAbort(signal);
53
+ const started = Date.now();
54
+ this.child = spawn(this.env.FLYWHEEL_KIMI_BIN || 'kimi', ['web', '--no-open', '--host', '127.0.0.1', '--port', '0'], {
55
+ env: this.env, stdio: ['ignore', 'pipe', 'pipe'], detached: false,
56
+ });
57
+ // The CLI banner includes the bearer token. Neither stdout nor stderr is a
58
+ // machine protocol, and no raw startup bytes are forwarded or persisted.
59
+ this.child.stdout.on('data', () => {});
60
+ this.child.stderr.on('data', () => {});
61
+ this.exit = new Promise(resolve => {
62
+ this.child.once('error', cause => { this.spawnError = cause; this.closed = true; resolve(); });
63
+ this.child.once('exit', (code, signalName) => { this.closed = true; this.exitCode = code; this.exitSignal = signalName; resolve(); });
64
+ });
65
+ this.sampler = setInterval(() => { this.sampleProcesses().catch(() => { this.processInspection = false; }); }, 500);
66
+ this.sampler.unref();
67
+ await this.sampleProcesses();
68
+ const directory = join(this.home, 'server', 'instances');
69
+ while (Date.now() - started < 20_000) {
70
+ checkAbort(signal);
71
+ if (this.closed) throw error('RUNTIME_START', this.spawnError ? 'Cannot start the configured Kimi CLI' : 'Kimi web exited before registering its owned server');
72
+ let names = [];
73
+ try { names = await readdir(directory); } catch (cause) { if (cause.code !== 'ENOENT') throw error('RUNTIME_REGISTRY', 'Cannot read the Kimi server instance registry'); }
74
+ for (const name of names.filter(name => name.endsWith('.json'))) {
75
+ let entry;
76
+ try { entry = JSON.parse(await readFile(join(directory, name), 'utf8')); } catch { continue; }
77
+ if (entry.pid !== this.child.pid || entry.host !== '127.0.0.1' || !Number.isSafeInteger(entry.port) || entry.port < 1 || entry.port > 65535
78
+ || typeof entry.server_id !== 'string' || entry.started_at < started - 2000) continue;
79
+ const token = (await readFile(join(this.home, 'server.token'), 'utf8')).trim();
80
+ if (!token || /[\s,]/.test(token) || token.length > 4096) throw error('RUNTIME_AUTH', 'The existing Kimi server token is missing or invalid');
81
+ this.token = token;
82
+ this.base = `http://127.0.0.1:${entry.port}`;
83
+ const meta = await this.request('/api/v1/meta', undefined, { signal });
84
+ if (meta.server_id !== entry.server_id || meta.dangerous_bypass_auth !== false || meta.backend !== 'v2' || meta.capabilities?.websocket !== true) {
85
+ throw error('RUNTIME_IDENTITY', 'The authenticated server does not match the newly owned local runtime');
86
+ }
87
+ this.meta = meta;
88
+ await this.sampleProcesses();
89
+ return;
90
+ }
91
+ await pause(40);
92
+ }
93
+ throw error('RUNTIME_START', 'Kimi web did not register an authenticated local server in time');
94
+ }
95
+ async request(path, body, { signal, raw = false, timeout = 5000 } = {}) {
96
+ const bounded = AbortSignal.timeout(timeout);
97
+ const response = await fetch(this.base + path, {
98
+ method: body === undefined ? 'GET' : 'POST', headers: { Authorization: `Bearer ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
99
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, bounded]) : bounded,
100
+ });
101
+ const reader = response.body.getReader();
102
+ const chunks = []; let bytes = 0;
103
+ try {
104
+ for (;;) {
105
+ const { done, value } = await reader.read();
106
+ if (done) break;
107
+ bytes += value.byteLength;
108
+ if (bytes > 8 * 1024 * 1024) { await reader.cancel(); throw error('RESPONSE_LIMIT', 'Kimi response exceeds the bounded adapter limit'); }
109
+ chunks.push(Buffer.from(value));
110
+ }
111
+ } finally { reader.releaseLock(); }
112
+ const text = Buffer.concat(chunks).toString('utf8');
113
+ let json;
114
+ try { json = JSON.parse(text); } catch { throw error('RUNTIME_PROTOCOL', 'Kimi returned a non-JSON response'); }
115
+ if (!response.ok || (!raw && json.code !== 0)) {
116
+ // Server errors can include prompts, account details and attachment paths.
117
+ // Stable machine code is sufficient for the receipt; do not echo bodies.
118
+ throw error('KIMI_API_' + (json.code ?? response.status), `Kimi rejected ${path.split('?')[0]} (code ${json.code ?? response.status})`);
119
+ }
120
+ return raw ? json : json.data;
121
+ }
122
+ async stop() {
123
+ if (this.sampler) clearInterval(this.sampler);
124
+ if (!this.child) return { exited: true, processesConfirmed: true, forced: false };
125
+ let inspected = this.processInspection, forced = false;
126
+ try { await this.sampleProcesses(); inspected = true; } catch { inspected = false; }
127
+ if (!this.closed && this.base) {
128
+ try { await this.request('/api/v1/shutdown', {}, { timeout: 2000 }); } catch { /* Exit below remains authoritative. */ }
129
+ }
130
+ let deadline = Date.now() + 3500;
131
+ while (!this.closed && Date.now() < deadline) await pause(30);
132
+ if (!this.closed) { forced = true; this.child.kill('SIGTERM'); }
133
+ deadline = Date.now() + 1500;
134
+ while (!this.closed && Date.now() < deadline) await pause(30);
135
+ if (!this.closed) { forced = true; this.child.kill('SIGKILL'); await Promise.race([this.exit, pause(1000)]); }
136
+ let live = [];
137
+ try { live = await this.sampleProcesses(); } catch { inspected = false; }
138
+ for (const row of live) {
139
+ forced = true;
140
+ try { process.kill(row.pid, 'SIGTERM'); } catch { /* Recheck by birth before escalation. */ }
141
+ }
142
+ if (live.length) await pause(200);
143
+ try { live = await this.sampleProcesses(); } catch { inspected = false; }
144
+ for (const row of live) { try { process.kill(row.pid, 'SIGKILL'); } catch { /* Final table determines certainty. */ } }
145
+ if (live.length) await pause(100);
146
+ try { live = await this.sampleProcesses(); } catch { inspected = false; }
147
+ return { exited: this.closed, processesConfirmed: inspected && (!this.child.pid || Boolean(this.spawnError) || this.observedRoot === true) && live.length === 0, forced,
148
+ observedProcesses: this.processes.size, exitCode: this.exitCode ?? null, signal: this.exitSignal ?? null };
149
+ }
150
+ }
151
+
152
+ function dereference(document, schema, seen = new Set()) {
153
+ if (!schema || typeof schema !== 'object') return {};
154
+ if (schema.$ref?.startsWith('#/') && !seen.has(schema.$ref)) {
155
+ seen.add(schema.$ref);
156
+ return dereference(document, schema.$ref.slice(2).split('/').reduce((node, key) => node?.[key], document), seen);
157
+ }
158
+ const properties = { ...(schema.properties || {}) };
159
+ for (const item of [...(schema.allOf || []), ...(schema.anyOf || []), ...(schema.oneOf || [])]) Object.assign(properties, dereference(document, item, new Set(seen)).properties);
160
+ return { ...schema, properties };
161
+ }
162
+
163
+ /** Match actual runtime contracts; no semantic-version inference is used. */
164
+ export function inspectKimiSchemas(openapi, asyncapi, mode) {
165
+ const paths = ['/api/v1/meta', '/api/v1/sessions', '/api/v1/sessions/{session_id}', '/api/v1/sessions/{session_id}/profile',
166
+ '/api/v1/sessions/{session_id}/environment', '/api/v1/sessions/{session_id}/prompts',
167
+ '/api/v1/sessions/{session_id}/agents/{agent_id}/turns', '/api/v1/shutdown', '/api/v1/remote-control'];
168
+ if (!String(openapi.openapi).startsWith('3.') || paths.some(path => !openapi.paths?.[path])) throw error('UNSUPPORTED_SCHEMA', 'Kimi OpenAPI is missing a required local lifecycle route');
169
+ const properties = (path, method = 'post') => dereference(openapi, openapi.paths[path]?.[method]?.requestBody?.content?.['application/json']?.schema).properties || {};
170
+ const creation = properties('/api/v1/sessions');
171
+ const config = dereference(openapi, creation.agent_config).properties || {};
172
+ const prompt = properties('/api/v1/sessions/{session_id}/prompts');
173
+ const profile = dereference(openapi, properties('/api/v1/sessions/{session_id}/profile').agent_config).properties || {};
174
+ if (['model', 'thinking', 'permission_mode', 'swarm_mode', 'profile'].some(key => !config[key])
175
+ || ['content', 'prompt_id', 'disabled_tools'].some(key => !prompt[key])
176
+ || (mode === 'tower' && ['tower_mode', 'tower_base'].some(key => !profile[key]))) {
177
+ throw error('UNSUPPORTED_SCHEMA', 'Kimi request schemas do not describe the selected native profile');
178
+ }
179
+ const messages = new Set(Object.values(asyncapi.components?.messages || {}).map(item => item.name));
180
+ const required = ['subscribe', 'response', 'agent', 'task', 'task.list', 'task.stop', 'turns', 'turn.started', 'turn.ended', 'interaction', 'ping', 'pong'];
181
+ if (asyncapi.channels?.kimiCodeWebSocket?.address !== '/api/v1/ws' || required.some(name => !messages.has(name))) {
182
+ throw error('UNSUPPORTED_SCHEMA', 'Kimi AsyncAPI does not describe the required task and conversation lifecycle');
183
+ }
184
+ return { protocol: 'kimi-server/v1', openapiDigest: digest(openapi), asyncapiDigest: digest(asyncapi), inspectedRoutes: paths, inspectedMessages: required };
185
+ }
186
+
187
+ export class KimiSocket {
188
+ constructor(runtime, receive) { this.runtime = runtime; this.receive = receive; this.pending = new Map(); this.sequence = 0; this.lastMessage = Date.now(); }
189
+ async open(signal) {
190
+ this.socket = new WebSocket(this.runtime.base.replace('http:', 'ws:') + '/api/v1/ws', ['kimi-code.bearer.' + this.runtime.token]);
191
+ this.socket.addEventListener('message', event => {
192
+ this.lastMessage = Date.now();
193
+ try {
194
+ if (typeof event.data !== 'string' || Buffer.byteLength(event.data) > 2 * 1024 * 1024) throw error('WS_PROTOCOL', 'Kimi sent an invalid or oversized WebSocket frame');
195
+ const frame = JSON.parse(event.data);
196
+ if (frame.type === 'ping') { this.socket.send(JSON.stringify({ type: 'pong' })); return; }
197
+ if (frame.type === 'response') {
198
+ const waiter = this.pending.get(frame.request_id);
199
+ if (waiter) { clearTimeout(waiter.timer); this.pending.delete(frame.request_id); frame.code === 0 ? waiter.resolve(frame) : waiter.reject(error('KIMI_WS_' + frame.code, 'Kimi WebSocket command was rejected')); }
200
+ return;
201
+ }
202
+ this.receive(frame);
203
+ } catch (cause) { this.failure = cause; }
204
+ });
205
+ this.socket.addEventListener('close', () => {
206
+ this.closed = true;
207
+ for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(error('WS_CLOSED', 'The owned Kimi socket closed')); }
208
+ this.pending.clear();
209
+ });
210
+ this.socket.addEventListener('error', () => { this.failure = error('WS_ERROR', 'The authenticated Kimi socket failed'); });
211
+ const deadline = Date.now() + 5000;
212
+ while (this.socket.readyState !== WebSocket.OPEN) {
213
+ checkAbort(signal);
214
+ if (this.failure || this.closed) throw this.failure || error('WS_CLOSED', 'Kimi socket closed before admission');
215
+ if (Date.now() > deadline) throw error('WS_TIMEOUT', 'The authenticated Kimi socket did not open');
216
+ await pause(20);
217
+ }
218
+ }
219
+ command(type, fields = {}, timeout = 3000) {
220
+ if (this.socket?.readyState !== WebSocket.OPEN) return Promise.reject(error('WS_CLOSED', 'Kimi socket is unavailable'));
221
+ const request_id = `flywheel-${++this.sequence}`;
222
+ return new Promise((resolve, reject) => {
223
+ const timer = setTimeout(() => { this.pending.delete(request_id); reject(error('WS_TIMEOUT', 'Kimi did not acknowledge ' + type)); }, timeout);
224
+ this.pending.set(request_id, { resolve, reject, timer });
225
+ this.socket.send(JSON.stringify({ type, request_id, ...fields }));
226
+ });
227
+ }
228
+ check() {
229
+ if (this.failure) throw this.failure;
230
+ if (this.closed || Date.now() - this.lastMessage > 25_000) throw error('WS_LOST', 'Kimi conversation continuity was lost; the prompt will not be retried');
231
+ }
232
+ close() { this.socket?.close(); }
233
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { runKimiWorker } from './kimi-native-bridge.mjs';
4
+
5
+ const controller = new AbortController();
6
+ let finished = false, inputError, request = { requestId: 'invalid-request' }, outputError;
7
+ const cancel = () => { if (!finished) controller.abort(); };
8
+ for (const name of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(name, cancel);
9
+ process.stdout.on('error', () => { outputError = true; cancel(); });
10
+
11
+ function write(frame) {
12
+ const line = JSON.stringify(frame) + '\n';
13
+ if (Buffer.byteLength(line) > 2 * 1024 * 1024) throw new Error('Kimi native receipt exceeds the output frame limit');
14
+ if (!outputError) process.stdout.write(line);
15
+ }
16
+
17
+ async function doctor() {
18
+ const child = spawn(process.env.FLYWHEEL_KIMI_BIN || 'kimi', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], detached: false });
19
+ let output = '', bytes = 0;
20
+ child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes < 4096) output += chunk; });
21
+ child.stderr.on('data', () => {});
22
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
23
+ const ok = await new Promise(resolve => { child.once('error', () => resolve(false)); child.once('exit', code => resolve(code === 0)); });
24
+ clearTimeout(timer);
25
+ const version = output.match(/\b\d+\.\d+\.\d+(?:[-+][\w.-]+)?\b/)?.[0] || null;
26
+ write({ type: 'doctor', protocol: 1, available: ok && version !== null, cliVersion: version,
27
+ backendProtocol: 'kimi-server/v1', inferenceTested: false, nativeAdmission: 'requires-actual-server-schema-and-effective-session-probe', recoverySupported: false });
28
+ process.exitCode = ok && version ? 0 : 1;
29
+ }
30
+
31
+ async function input() {
32
+ return new Promise((resolve, reject) => {
33
+ let buffer = Buffer.alloc(0), admitted = false;
34
+ const fail = message => { inputError ||= message; clearTimeout(timer); controller.abort(); reject(new Error(message)); };
35
+ const timer = setTimeout(() => fail('No complete Kimi native request arrived within 10 seconds'), 10_000);
36
+ process.stdin.on('data', chunk => {
37
+ if (finished) return;
38
+ if (admitted) { if (chunk.toString('utf8').trim()) fail('Only one start request is accepted'); return; }
39
+ buffer = Buffer.concat([buffer, chunk]);
40
+ if (buffer.length > 1024 * 1024) { fail('Kimi native request exceeds the 1 MiB limit'); return; }
41
+ const end = buffer.indexOf(10);
42
+ if (end < 0) return;
43
+ try {
44
+ request = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, end)));
45
+ if (buffer.subarray(end + 1).toString('utf8').trim()) throw new Error();
46
+ admitted = true; clearTimeout(timer); resolve(request);
47
+ } catch { fail('Expected one valid UTF-8 JSON start request'); }
48
+ });
49
+ process.stdin.on('end', () => { if (!admitted) fail('Parent input closed before a start request'); else cancel(); });
50
+ process.stdin.on('error', () => fail('Parent input failed'));
51
+ });
52
+ }
53
+
54
+ try {
55
+ if (process.argv.includes('--doctor')) await doctor();
56
+ else {
57
+ let value;
58
+ try { value = await input(); } catch { value = request; }
59
+ const result = await runKimiWorker(value, { emit: write, signal: controller.signal,
60
+ stderr: message => process.stderr.write('[Kimi native] ' + message) });
61
+ if (inputError) {
62
+ result.status = 'error'; result.error = { code: 'INPUT_ERROR', message: inputError };
63
+ result.lifecycle.resumeCandidate = false;
64
+ }
65
+ finished = true;
66
+ write(result);
67
+ process.exitCode = result.status === 'ok' ? 0 : result.status === 'cancelled' ? 130 : 1;
68
+ }
69
+ } catch {
70
+ // A broken consumer/oversized receipt must fail the outer transport gate;
71
+ // never emit a second plausible final after partial output.
72
+ process.exitCode = 1;
73
+ } finally {
74
+ finished = true;
75
+ process.stdin.pause();
76
+ for (const name of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.removeListener(name, cancel);
77
+ }
@@ -0,0 +1,227 @@
1
+ // Trusted workspace transport. Tower receives a separate .git; Go still owns
2
+ // validation, protected-path decisions, final commits and task acceptance.
3
+ import { createHash } from 'node:crypto';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { mkdtemp, mkdir, lstat, readFile, writeFile, readlink, symlink, chmod, readdir, realpath } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
+
10
+ const execute = promisify(execFile);
11
+ const sha = value => createHash('sha256').update(value).digest('hex');
12
+ const MAX_BYTES = 256 * 1024 * 1024;
13
+
14
+ function fail(message) { return Object.assign(new Error(message), { code: 'INVALID_REQUEST' }); }
15
+
16
+ export async function towerGit(cwd, args) {
17
+ // A host shell may itself be inside a temporary index/worktree operation.
18
+ // Those variables must not redirect the trusted copy or transfer commands.
19
+ const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
20
+ const { stdout } = await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.autocrlf=false', ...args], {
21
+ cwd, encoding: 'buffer', maxBuffer: MAX_BYTES,
22
+ env: { ...inherited, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
23
+ GIT_AUTHOR_NAME: 'Flywheel', GIT_AUTHOR_EMAIL: 'flywheel@localhost',
24
+ GIT_COMMITTER_NAME: 'Flywheel', GIT_COMMITTER_EMAIL: 'flywheel@localhost' },
25
+ });
26
+ return stdout;
27
+ }
28
+
29
+ /** Only the actual task's changing engine records are outside the candidate. */
30
+ function runtimeTaskPath(name) {
31
+ if (['.sessions/', '.control/', 'reports/native/'].some(prefix => name.startsWith(prefix))) return true;
32
+ return /^(?:metrics\.jsonl|orchestrator\.(?:log|pid)|\.state\.lock|\.run\.lock|\.stop-request)$/.test(name)
33
+ || /^state\.json(?:\.tmp(?:[.-].*)?)?$/.test(name);
34
+ }
35
+
36
+ export function towerExcluded(path, cwd, taskDir) {
37
+ const parts = path.split('/');
38
+ if (parts.includes('.git') || parts[0] === '.tower') return true;
39
+ const ledger = relative(cwd, taskDir).split(sep).join('/');
40
+ if (ledger === '..' || ledger.startsWith('../') || isAbsolute(ledger) || (ledger && !path.startsWith(ledger + '/'))) return false;
41
+ return runtimeTaskPath(ledger ? path.slice(ledger.length + 1) : path);
42
+ }
43
+
44
+ async function externalTaskSnapshot(taskDir, byteLimit) {
45
+ const entries = []; let total = 0;
46
+ const walk = async (directory, prefix = '') => {
47
+ for (const name of (await readdir(directory)).sort()) {
48
+ const path = prefix + name, absolute = join(directory, name);
49
+ // Skip known runtime names before lstat: an atomic state/session rename
50
+ // may remove a temporary entry between readdir and metadata inspection.
51
+ if (runtimeTaskPath(path) || runtimeTaskPath(path + '/')) continue;
52
+ const stat = await lstat(absolute);
53
+ if (stat.isDirectory()) await walk(absolute, path + '/');
54
+ else if (stat.isFile()) {
55
+ total += stat.size;
56
+ if (total > byteLimit) throw fail('Tower source and external contract exceed the 256 MiB snapshot limit');
57
+ entries.push({ path, kind: 'file', executable: Boolean(stat.mode & 0o111), digest: sha(await readFile(absolute)) });
58
+ } else {
59
+ // A frozen external contract cannot be faithfully snapshotted by
60
+ // following an unconstrained symlink tree or a special-file stream.
61
+ throw fail('Tower external task contracts must contain regular files and directories');
62
+ }
63
+ }
64
+ };
65
+ await walk(taskDir);
66
+ return { path: resolve(taskDir), entries, digest: sha(JSON.stringify(entries)) };
67
+ }
68
+
69
+ async function noSymlinkAncestor(cwd, path) {
70
+ let cursor = cwd;
71
+ for (const segment of path.split('/').slice(0, -1)) {
72
+ cursor = join(cursor, segment);
73
+ try { if ((await lstat(cursor)).isSymbolicLink()) throw fail('Tower input traverses a symlink directory'); }
74
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
75
+ }
76
+ }
77
+
78
+ /** HEAD plus exact tracked/unignored bytes, deletions, executable bits and links. */
79
+ export async function towerSnapshot(cwd, taskDir, { externalContract = true } = {}) {
80
+ cwd = resolve(cwd);
81
+ const head = (await towerGit(cwd, ['rev-parse', '--verify', 'HEAD'])).toString().trim();
82
+ const top = (await towerGit(cwd, ['rev-parse', '--show-toplevel'])).toString().trim();
83
+ if (resolve(top) !== cwd) throw fail('Tower requires the repository root as its execution directory');
84
+ const paths = [...new Set((await towerGit(cwd, ['ls-files', '-c', '-o', '--exclude-standard', '-z'])).toString().split('\0').filter(Boolean))].sort();
85
+ const entries = [];
86
+ let total = 0;
87
+ for (const path of paths) {
88
+ if (isAbsolute(path) || path.split('/').some(part => part === '..' || part === '')) throw fail('Invalid Tower source path');
89
+ if (towerExcluded(path, cwd, taskDir)) continue;
90
+ await noSymlinkAncestor(cwd, path);
91
+ let stat;
92
+ try { stat = await lstat(join(cwd, path)); }
93
+ catch (error) { if (error.code === 'ENOENT') { entries.push({ path, kind: 'missing' }); continue; } throw error; }
94
+ if (stat.isSymbolicLink()) {
95
+ entries.push({ path, kind: 'link', target: await readlink(join(cwd, path)) });
96
+ } else if (stat.isFile()) {
97
+ total += stat.size;
98
+ if (total > MAX_BYTES) throw fail('Tower source exceeds the 256 MiB tracked/unignored snapshot limit');
99
+ entries.push({ path, kind: 'file', executable: Boolean(stat.mode & 0o111), digest: sha(await readFile(join(cwd, path))) });
100
+ } else {
101
+ throw fail('Tower input contains a submodule, directory entry or unsupported special file');
102
+ }
103
+ }
104
+ const location = relative(cwd, taskDir);
105
+ const outside = location === '..' || location.startsWith('..' + sep) || isAbsolute(location);
106
+ const contract = externalContract && outside ? await externalTaskSnapshot(taskDir, MAX_BYTES - total) : undefined;
107
+ return { version: 1, head, entries, ...(contract ? { externalContract: contract } : {}), digest: sha(JSON.stringify({ head, entries, externalContract: contract })) };
108
+ }
109
+
110
+ export async function createTowerCandidate(input) {
111
+ const source = await towerSnapshot(input.cwd, input.managed.taskDir);
112
+ const directory = await realpath(await mkdtemp(join(tmpdir(), 'flywheel-kimi-tower-')));
113
+ await chmod(directory, 0o700);
114
+ const path = join(directory, 'repository');
115
+ const branch = input.native.tower?.branch ?? `flywheel-tower-${sha(input.requestId).slice(0, 16)}`;
116
+ await towerGit(input.cwd, ['check-ref-format', '--branch', branch]);
117
+ // --no-local and --no-hardlinks avoid shared objects/admin data even when the
118
+ // source itself is a linked worktree. Nothing writes the source's .git.
119
+ await towerGit(input.cwd, ['clone', '--quiet', '--no-local', '--no-hardlinks', '--no-checkout', '--', input.cwd, path]);
120
+ await towerGit(path, ['remote', 'remove', 'origin']);
121
+ await towerGit(path, ['reset', '--mixed', source.head]);
122
+ for (const entry of source.entries) {
123
+ if (entry.kind === 'missing') continue;
124
+ const target = join(path, entry.path);
125
+ await mkdir(dirname(target), { recursive: true });
126
+ if (entry.kind === 'link') await symlink(entry.target, target);
127
+ else {
128
+ await writeFile(target, await readFile(join(input.cwd, entry.path)));
129
+ await chmod(target, entry.executable ? 0o755 : 0o644);
130
+ }
131
+ }
132
+ if ((await towerSnapshot(input.cwd, input.managed.taskDir)).digest !== source.digest) {
133
+ throw fail(`Tower source changed while copying; partial candidate retained at ${path}`);
134
+ }
135
+ await towerGit(path, ['add', '-A', '--', '.']);
136
+ await towerGit(path, ['commit', '--quiet', '--allow-empty', '-m', 'Snapshot Flywheel candidate for isolated Tower execution']);
137
+ const baseHead = (await towerGit(path, ['rev-parse', 'HEAD'])).toString().trim();
138
+ await towerGit(path, ['checkout', '-B', branch]);
139
+ const candidate = { kind: 'tower-repository', path, branch, baseHead, sourceHead: source.head,
140
+ sourceSnapshotDigest: source.digest, patchPath: join(directory, 'candidate.patch'), source };
141
+ await writeFile(join(directory, 'source-snapshot.json'), JSON.stringify(source), { mode: 0o600 });
142
+ return candidate;
143
+ }
144
+
145
+ /** Freeze the candidate only after the owned runtime is quiescent. */
146
+ export async function freezeTowerCandidate(candidate, input) {
147
+ // Exclude Tower's administrative worktrees/comms, while retaining ordinary
148
+ // contract edits so Go's existing boundary guard can reject them explicitly.
149
+ const paths = (await towerGit(candidate.path, ['ls-files', '-c', '-o', '--exclude-standard', '-z'])).toString().split('\0').filter(Boolean);
150
+ const excluded = [...new Set(paths.filter(path => towerExcluded(path, input.cwd, input.managed.taskDir)))];
151
+ await towerGit(candidate.path, ['add', '-A', '--', '.', ':(exclude).tower', ...excluded.map(path => `:(exclude,literal)${path}`)]);
152
+ const patch = await towerGit(candidate.path, ['diff', '--cached', '--binary', '--full-index', candidate.baseHead, '--', '.', ':(exclude).tower', ...excluded.map(path => `:(exclude,literal)${path}`)]);
153
+ await writeFile(candidate.patchPath, patch, { mode: 0o600 });
154
+ const tree = (await towerGit(candidate.path, ['write-tree'])).toString().trim();
155
+ const snapshot = await towerSnapshot(candidate.path, join(candidate.path, relative(input.cwd, input.managed.taskDir)), { externalContract: false });
156
+ return { ...candidate, tree, patchDigest: sha(patch), snapshot };
157
+ }
158
+
159
+ /** Apply no candidate after uncertainty, to another role, or to a changed source. */
160
+ export async function transferTowerCandidate(candidate, input, cleanupConfirmed) {
161
+ if (!cleanupConfirmed) throw fail('Tower candidate transfer requires confirmed runtime cleanup');
162
+ if (input.managed.role !== 'execute') return { applied: false, reason: 'role-does-not-own-workspace-transfer' };
163
+ const current = await towerSnapshot(input.cwd, input.managed.taskDir);
164
+ if (current.digest !== candidate.sourceSnapshotDigest) throw fail('Tower source or contract changed; candidate retained without transfer');
165
+ const patch = await readFile(candidate.patchPath);
166
+ if (sha(patch) !== candidate.patchDigest) throw fail('Tower candidate patch changed after freezing');
167
+ if (patch.length === 0) return { applied: true, empty: true, sourceSnapshotDigest: current.digest, afterDigest: current.digest };
168
+ await towerGit(input.cwd, ['apply', '--check', '--binary', '--', candidate.patchPath]);
169
+ // A second read catches edits made while checking the patch. There is no
170
+ // claim of an OS-wide lock over unrelated editors; the postcondition below
171
+ // also rejects a concurrent write instead of reporting a matching candidate.
172
+ if ((await towerSnapshot(input.cwd, input.managed.taskDir)).digest !== current.digest) throw fail('Tower source changed during transfer admission');
173
+ await towerGit(input.cwd, ['apply', '--binary', '--', candidate.patchPath]);
174
+ const after = await towerSnapshot(input.cwd, input.managed.taskDir);
175
+ const existing = entries => entries.filter(entry => entry.kind !== 'missing');
176
+ if (after.head !== current.head || after.externalContract?.digest !== candidate.source.externalContract?.digest
177
+ || JSON.stringify(existing(after.entries)) !== JSON.stringify(existing(candidate.snapshot.entries))) {
178
+ throw fail('Tower transfer postcondition differs from the frozen candidate; artifacts retained');
179
+ }
180
+ return { applied: true, empty: false, sourceSnapshotDigest: current.digest, afterDigest: after.digest };
181
+ }
182
+
183
+ export function towerCandidateReceipt(candidate, transfer) {
184
+ const { kind, path, branch, baseHead, sourceHead, sourceSnapshotDigest, patchPath, patchDigest, tree } = candidate;
185
+ return { kind, path, branch, baseHead, sourceHead, sourceSnapshotDigest, patchPath,
186
+ ...(candidate.source.externalContract ? { externalContractDigest: candidate.source.externalContract.digest } : {}),
187
+ ...(patchDigest ? { patchDigest, tree } : {}), ...(transfer ? { transfer } : {}) };
188
+ }
189
+
190
+ /** Remap exact project path references, never substrings in another path/URL. */
191
+ export function towerPrompt(input, candidate, declaredCwd = input.cwd) {
192
+ const source = input.cwd, target = candidate.path;
193
+ // Admission has already resolved the declared directory to source. Match
194
+ // either spelling in the original prompt, preferring a longer prefix at the
195
+ // same position; never re-scan text inserted from the candidate path.
196
+ // Removing trailing separators/dot components is equivalent for a directory;
197
+ // do not collapse '..', whose meaning can change across a symlink.
198
+ const prefixes = [...new Set([source, declaredCwd, declaredCwd.replace(/(?:\/+\.?)+$/, '') || '/'])].sort((a, b) => b.length - a.length);
199
+ const urls = [...input.prompt.matchAll(/(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/[^\s"'`<>]+/g)]
200
+ .map(match => ({ start: match.index, end: match.index + match[0].length }));
201
+ let cursor = 0, urlIndex = 0, mapped = '', replacements = 0;
202
+ for (;;) {
203
+ let index = -1;
204
+ for (const value of prefixes) {
205
+ const position = input.prompt.indexOf(value, cursor);
206
+ if (position >= 0 && (index < 0 || position < index)) index = position;
207
+ }
208
+ if (index < 0) { mapped += input.prompt.slice(cursor); break; }
209
+ while (urls[urlIndex]?.end <= index) urlIndex++;
210
+ const inUrl = urls[urlIndex] && index >= urls[urlIndex].start && index < urls[urlIndex].end;
211
+ const before = index === 0 ? '' : input.prompt[index - 1];
212
+ const startsPath = !inUrl && (before === '' || /[\s"'`([{=:]/.test(before));
213
+ const prefix = startsPath && prefixes.find(value => {
214
+ const after = input.prompt[index + value.length] || '';
215
+ return input.prompt.startsWith(value, index) && (after === '' || /[\/\s"'`)\]},;:#?]/.test(after));
216
+ });
217
+ if (!prefix) { mapped += input.prompt.slice(cursor, index + 1); cursor = index + 1; continue; }
218
+ mapped += input.prompt.slice(cursor, index) + target;
219
+ replacements++;
220
+ cursor = index + prefix.length;
221
+ }
222
+ const prompt = `Flywheel Tower candidate directory: ${target}\n` +
223
+ 'Run project edits and Git operations in this independent repository. External task contract files are read-only evidence at their supplied paths. ' +
224
+ 'Flywheel transfers this candidate after cleanup and owns validation, review and final commits.\n\n' + mapped;
225
+ return { prompt, mapping: { sourceCwd: source, declaredSourceCwd: declaredCwd, candidateCwd: target, replacements,
226
+ rule: 'bounded-literal-absolute-path-prefix', originalPromptDigest: sha(input.prompt), effectivePromptDigest: sha(prompt), filesystemSandbox: false } };
227
+ }
@@ -8,10 +8,24 @@ function identifier(value) {
8
8
  return typeof value === 'string' && value.trim() !== '' && value.length <= 256 && !value.includes('\0');
9
9
  }
10
10
 
11
+ /** Host-owned attribution, not provider arguments. External callers cannot supply leases. */
12
+ export function validateNativeAdmission(value, requestId, WorkerError) {
13
+ if (value === undefined) return;
14
+ const fields = ['version', 'runId', 'invocationId', 'role', 'scope', 'candidateId', 'channel', 'taskDir'];
15
+ const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 4096 && !value.includes('\0');
16
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.version !== 1 ||
17
+ Object.keys(value).some(key => !fields.includes(key)) || value.invocationId !== requestId ||
18
+ !['invocationId', 'role', 'scope', 'channel', 'taskDir'].every(key => text(value[key])) ||
19
+ typeof value.runId !== 'string' || value.runId.length > 4096 || value.runId.includes('\0') ||
20
+ (value.candidateId !== undefined && !text(value.candidateId)) || !isAbsolute(value.taskDir)) {
21
+ throw new WorkerError('INVALID_REQUEST', 'The managed native identity must match this request and contain only host attribution fields');
22
+ }
23
+ }
24
+
11
25
  /** Recovery carries only the old non-secret binding, never a prompt. */
12
26
  export function recoveryRequest(value, WorkerError) {
13
27
  const keys = new Set(['type', 'protocol', 'requestId', 'runId', 'recoveryDirectory', 'original']);
14
- const originalKeys = new Set(['cwd', 'model', 'reasoningEffort', 'permissionMode', 'threadMode', 'threadId']);
28
+ const originalKeys = new Set(['cwd', 'model', 'reasoningEffort', 'permissionMode', 'threadMode', 'threadId', 'native', 'nativeOperation', 'managed']);
15
29
  const o = value?.original;
16
30
  if (!value || typeof value !== 'object' || Array.isArray(value) || value.type !== 'recover' || value.protocol !== 3 ||
17
31
  Object.keys(value).some(key => !keys.has(key)) || !identifier(value.requestId) || !identifier(value.runId) ||
@@ -23,13 +37,16 @@ export function recoveryRequest(value, WorkerError) {
23
37
  (o.threadId !== undefined && (o.threadMode !== 'persistent' || !identifier(o.threadId)))) {
24
38
  throw new WorkerError('INVALID_REQUEST', 'Expected a protocol 3 read-only recovery request with the original non-secret binding');
25
39
  }
40
+ validateNativeAdmission(o.managed, value.requestId, WorkerError);
26
41
  // No cwd resolution, login, runMachine, thread/resume or model request here.
27
42
  return { protocol: 1, requestId: value.requestId, runId: value.runId, recoveryDirectory: value.recoveryDirectory };
28
43
  }
29
44
 
30
45
  // Parent lifeline and bounded transport; adapters own backend semantics.
31
46
  export async function runMachineWorker({ ADAPTER, LIMITS, WorkerError, loadAdapter, machineRequest, doctorResult,
32
- label, commandName, attestAdmission = false, runtimeOptions = () => ({}) }) {
47
+ label, commandName, attestAdmission = false, runtimeOptions = () => ({}),
48
+ invokeAdapter = (api, request, options) => api.runMachine(request, options),
49
+ recoverAdapter = (api, request) => api.recoverMachine(request) }) {
33
50
  const controller = new AbortController();
34
51
  let cancelledBy, inputFailure, outputFailure, requestId = 'invalid-request', protocol = 1, finished = false, adapterInvoked = false;
35
52
  let recovery = false, pendingAck, lifecycleSequence = 0, lifecycleRunId, lifecycleBytes = 0;
@@ -190,7 +207,7 @@ export async function runMachineWorker({ ADAPTER, LIMITS, WorkerError, loadAdapt
190
207
  }
191
208
  if (recovery) {
192
209
  const query = recoveryRequest(value, WorkerError);
193
- const evidence = await api.recoverMachine(query);
210
+ const evidence = await recoverAdapter(api, query, value.original);
194
211
  finished = true;
195
212
  lifeline.stop();
196
213
  const error = inputFailure || outputFailure;
@@ -211,13 +228,30 @@ export async function runMachineWorker({ ADAPTER, LIMITS, WorkerError, loadAdapt
211
228
  outputFailure ??= new WorkerError('OUTPUT_LIMIT', label + ' progress exceeded the 1 MiB stream limit');
212
229
  controller.abort();
213
230
  } else write(frame);
231
+ },
232
+ onNativeEvent(event) {
233
+ if (controller.signal.aborted) return;
234
+ if (!event || typeof event !== 'object' || Array.isArray(event) || event.version !== 1 ||
235
+ !Number.isSafeInteger(event.sequence) || event.sequence < 1 || !identifier(event.id) ||
236
+ !identifier(event.kind) || typeof event.state !== 'string' ||
237
+ (event.parentId !== null && !identifier(event.parentId))) {
238
+ outputFailure ??= new WorkerError('INVALID_NATIVE_EVENT', 'The adapter emitted invalid native child metadata');
239
+ controller.abort(); return;
240
+ }
241
+ const frame = { protocol, type: 'native-event', requestId, adapter: ADAPTER, native: event,
242
+ ...(value.managed === undefined ? {} : { managed: value.managed }) };
243
+ progressBytes += Buffer.byteLength(JSON.stringify(frame) + '\n');
244
+ if (progressBytes > LIMITS.progressBytes) {
245
+ outputFailure ??= new WorkerError('OUTPUT_LIMIT', label + ' native evidence exceeded the 1 MiB stream limit');
246
+ controller.abort();
247
+ } else write(frame);
214
248
  } };
215
249
  adapterInvoked = true;
216
- const receipt = await api.runMachine(request, options);
250
+ const receipt = await invokeAdapter(api, request, options);
217
251
  finished = true;
218
252
  lifeline.stop();
219
253
  const error = inputFailure || outputFailure;
220
- if (outputFailure?.code === 'OUTPUT_LIMIT') outputFailure = undefined;
254
+ if (['OUTPUT_LIMIT', 'INVALID_NATIVE_EVENT'].includes(outputFailure?.code)) outputFailure = undefined;
221
255
  write({ protocol, type: 'result', requestId, adapter: ADAPTER, receipt,
222
256
  error: error ? { code: error.code, message: error.message } : null });
223
257
  process.exitCode = receipt.ok && !error && !controller.signal.aborted ? 0 : 1;