mixdog 0.9.47 → 0.9.50

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 (88) hide show
  1. package/README.md +6 -6
  2. package/package.json +19 -9
  3. package/scripts/agent-parallel-smoke.mjs +4 -1
  4. package/scripts/agent-route-batch-test.mjs +40 -0
  5. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  6. package/scripts/code-graph-description-contract.mjs +185 -0
  7. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  8. package/scripts/code-graph-dispatch-test.mjs +96 -0
  9. package/scripts/compact-pressure-test.mjs +40 -0
  10. package/scripts/deferred-tool-loading-test.mjs +233 -0
  11. package/scripts/embedding-worker-exit-test.mjs +76 -0
  12. package/scripts/execution-completion-dedup-test.mjs +48 -0
  13. package/scripts/explore-prompt-policy-test.mjs +88 -3
  14. package/scripts/live-worker-smoke.mjs +68 -16
  15. package/scripts/memory-core-input-test.mjs +33 -13
  16. package/scripts/native-edit-wire-test.mjs +152 -0
  17. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  18. package/scripts/patch-binary-cache-test.mjs +181 -0
  19. package/scripts/prompt-immediate-render-test.mjs +89 -0
  20. package/scripts/provider-toolcall-test.mjs +280 -15
  21. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  22. package/scripts/smoke-loop.mjs +9 -3
  23. package/scripts/smoke.mjs +5 -106
  24. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  25. package/scripts/streaming-tail-window-test.mjs +29 -0
  26. package/scripts/tool-failures.mjs +21 -3
  27. package/scripts/tool-smoke.mjs +263 -38
  28. package/scripts/tool-tui-presentation-test.mjs +17 -1
  29. package/scripts/tui-perf-run.ps1 +26 -0
  30. package/scripts/tui-transcript-perf-test.mjs +7 -1
  31. package/scripts/verify-release-assets-test.mjs +281 -0
  32. package/scripts/verify-release-assets.mjs +293 -0
  33. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  34. package/src/cli.mjs +1 -0
  35. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  38. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  41. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  43. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  44. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  45. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  46. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  48. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  52. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  53. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  54. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  55. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  57. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  58. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  59. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  61. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  62. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  63. package/src/runtime/memory/tool-defs.mjs +1 -2
  64. package/src/session-runtime/context-status.mjs +25 -16
  65. package/src/session-runtime/model-route-api.mjs +2 -0
  66. package/src/session-runtime/runtime-core.mjs +2 -2
  67. package/src/session-runtime/session-turn-api.mjs +4 -1
  68. package/src/session-runtime/tool-catalog.mjs +113 -19
  69. package/src/session-runtime/tool-defs.mjs +1 -0
  70. package/src/standalone/agent-tool/helpers.mjs +25 -6
  71. package/src/standalone/agent-tool.mjs +75 -41
  72. package/src/tui/App.jsx +4 -0
  73. package/src/tui/app/input-parsers.mjs +8 -9
  74. package/src/tui/components/Markdown.jsx +6 -1
  75. package/src/tui/components/Message.jsx +11 -2
  76. package/src/tui/components/PromptInput.jsx +19 -21
  77. package/src/tui/components/Spinner.jsx +4 -4
  78. package/src/tui/components/StatusLine.jsx +6 -6
  79. package/src/tui/components/TranscriptItem.jsx +2 -2
  80. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  81. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  82. package/src/tui/dist/index.mjs +130 -45
  83. package/src/tui/engine/agent-job-feed.mjs +21 -2
  84. package/src/tui/engine/turn.mjs +12 -1
  85. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  86. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  87. package/src/ui/statusline.mjs +5 -5
  88. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -0,0 +1,281 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+
8
+ import {
9
+ GRAPH_PLATFORMS,
10
+ PATCH_PLATFORMS,
11
+ validateGraphManifest,
12
+ validatePatchManifest,
13
+ validateRuntimeManifest,
14
+ verifyAssetDownloads,
15
+ verifyReleaseAssets,
16
+ } from './verify-release-assets.mjs';
17
+
18
+ const VERSION = '1.2.3';
19
+ const APP_VERSION = '0.9.49';
20
+ const GRAPH_VERSION = '0.1.0';
21
+ const bytes = Buffer.from('local release fixture');
22
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
23
+
24
+ function patchFixture() {
25
+ return {
26
+ version: VERSION,
27
+ _comment: 'test fixture',
28
+ assets: Object.fromEntries(Object.entries(PATCH_PLATFORMS).map(([platform, filename]) => [
29
+ platform,
30
+ {
31
+ url: `https://github.com/tribgames/mixdog/releases/download/patch-v${VERSION}/${filename}`,
32
+ sha256,
33
+ },
34
+ ])),
35
+ };
36
+ }
37
+
38
+ function runtimeFixture() {
39
+ return {
40
+ release_tag: 'runtime-v1.2.3',
41
+ assets: Object.fromEntries(Object.keys(PATCH_PLATFORMS).map((platform) => [
42
+ platform,
43
+ { url: `https://fixtures.invalid/${platform}`, sha256, size: bytes.length },
44
+ ])),
45
+ };
46
+ }
47
+
48
+ function graphFixture() {
49
+ return {
50
+ version: GRAPH_VERSION,
51
+ _comment: 'test fixture',
52
+ assets: Object.fromEntries(Object.entries(GRAPH_PLATFORMS).map(([platform, filename]) => [
53
+ platform,
54
+ {
55
+ url: `https://github.com/tribgames/mixdog/releases/download/graph-v${GRAPH_VERSION}/${filename}`,
56
+ sha256,
57
+ },
58
+ ])),
59
+ };
60
+ }
61
+
62
+ test('accepts independent strict patch, runtime, app, and graph versions', () => {
63
+ assert.equal(validatePatchManifest(patchFixture(), `[package]\nversion = "${VERSION}"\n`).version, VERSION);
64
+ assert.equal(validateRuntimeManifest(runtimeFixture()).release_tag, 'runtime-v1.2.3');
65
+ assert.equal(validateGraphManifest(graphFixture(), { version: APP_VERSION }).version, GRAPH_VERSION);
66
+ });
67
+
68
+ test('rejects stale Cargo version, partial schema, and wrong patch tag URL', () => {
69
+ assert.throws(
70
+ () => validatePatchManifest(patchFixture(), '[package]\nversion = "1.2.2"\n'),
71
+ /does not match manifest version/,
72
+ );
73
+
74
+ const partial = patchFixture();
75
+ delete partial.assets['linux-arm64'];
76
+ assert.throws(
77
+ () => validatePatchManifest(partial, `[package]\nversion = "${VERSION}"\n`),
78
+ /keys must be exactly/,
79
+ );
80
+
81
+ const wrongTag = patchFixture();
82
+ wrongTag.assets['linux-x64'].url = wrongTag.assets['linux-x64'].url.replace('patch-v1.2.3', 'patch-v1.2.2');
83
+ assert.throws(
84
+ () => validatePatchManifest(wrongTag, `[package]\nversion = "${VERSION}"\n`),
85
+ /patch-v1\.2\.3/,
86
+ );
87
+
88
+ const extraPatch = patchFixture();
89
+ extraPatch.assets['freebsd-x64'] = extraPatch.assets['linux-x64'];
90
+ assert.throws(
91
+ () => validatePatchManifest(extraPatch, `[package]\nversion = "${VERSION}"\n`),
92
+ /keys must be exactly/,
93
+ );
94
+
95
+ const extraRuntime = runtimeFixture();
96
+ extraRuntime.assets['freebsd-x64'] = extraRuntime.assets['linux-x64'];
97
+ assert.throws(() => validateRuntimeManifest(extraRuntime), /keys must be exactly/);
98
+ });
99
+
100
+ test('rejects stale, noncanonical, partial, and malformed graph manifests', () => {
101
+ const partial = graphFixture();
102
+ delete partial.assets['darwin-arm64'];
103
+ assert.throws(() => validateGraphManifest(partial, { version: APP_VERSION }), /keys must be exactly/);
104
+
105
+ for (const replacement of [
106
+ 'https://github.com/tribgames/mixdog/releases/download/v0.7.18/mixdog-graph-linux-x64',
107
+ 'https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-linux-x64',
108
+ 'https://example.com/tribgames/mixdog/releases/download/graph-v0.1.0/mixdog-graph-linux-x64',
109
+ 'https://github.com/tribgames/mixdog/releases/download/graph-v0.1.0/mixdog-graph-wrong',
110
+ ]) {
111
+ const noncanonical = graphFixture();
112
+ noncanonical.assets['linux-x64'].url = replacement;
113
+ assert.throws(
114
+ () => validateGraphManifest(noncanonical, { version: APP_VERSION }),
115
+ /graph asset URL must be/,
116
+ );
117
+ }
118
+
119
+ const malformedDigest = graphFixture();
120
+ malformedDigest.assets['win32-x64'].sha256 = 'not-a-digest';
121
+ assert.throws(
122
+ () => validateGraphManifest(malformedDigest, { version: APP_VERSION }),
123
+ /invalid graph asset sha256/,
124
+ );
125
+
126
+ const malformedVersion = graphFixture();
127
+ malformedVersion.version = '0.1';
128
+ assert.throws(
129
+ () => validateGraphManifest(malformedVersion, { version: APP_VERSION }),
130
+ /not strict MAJOR\.MINOR\.PATCH/,
131
+ );
132
+ });
133
+
134
+ test('downloads local fixture responses, retries, and checks sha256 without network', async () => {
135
+ let calls = 0;
136
+ await verifyAssetDownloads(
137
+ { fixture: { url: 'https://fixtures.invalid/asset', sha256, size: bytes.length } },
138
+ {
139
+ fetchImpl: async () => {
140
+ calls += 1;
141
+ if (calls === 1) throw new Error('fixture transient failure');
142
+ return new Response(bytes);
143
+ },
144
+ retryDelay: async () => {},
145
+ },
146
+ );
147
+ assert.equal(calls, 2);
148
+
149
+ calls = 0;
150
+ await assert.rejects(
151
+ verifyAssetDownloads(
152
+ { fixture: { url: 'https://fixtures.invalid/asset', sha256: '0'.repeat(64) } },
153
+ {
154
+ fetchImpl: async () => {
155
+ calls += 1;
156
+ return new Response(bytes);
157
+ },
158
+ retryDelay: async () => {},
159
+ },
160
+ ),
161
+ /verification failed after 3 attempts: sha256 mismatch/,
162
+ );
163
+ assert.equal(calls, 3);
164
+ });
165
+
166
+ test('cancels an undeclared-size patch stream immediately at the absolute ceiling', async () => {
167
+ let chunksProduced = 0;
168
+ let cancellations = 0;
169
+ let aborts = 0;
170
+ const stream = new ReadableStream(
171
+ {
172
+ pull(controller) {
173
+ chunksProduced += 1;
174
+ controller.enqueue(Buffer.from('abc'));
175
+ },
176
+ cancel() {
177
+ cancellations += 1;
178
+ },
179
+ },
180
+ { highWaterMark: 0 },
181
+ );
182
+
183
+ await assert.rejects(
184
+ verifyAssetDownloads(
185
+ { fixture: { url: 'https://fixtures.invalid/asset', sha256 } },
186
+ {
187
+ attempts: 1,
188
+ maxAssetBytes: 5,
189
+ fetchImpl: async (_url, { signal }) => {
190
+ signal.addEventListener('abort', () => { aborts += 1; });
191
+ return { ok: true, status: 200, body: stream };
192
+ },
193
+ },
194
+ ),
195
+ /byte ceiling exceeded \(5 bytes\)/,
196
+ );
197
+ assert.equal(chunksProduced, 2);
198
+ assert.equal(cancellations, 1);
199
+ assert.equal(aborts, 1);
200
+ });
201
+
202
+ test('cancels immediately when a stream exceeds its declared size below the absolute ceiling', async () => {
203
+ let chunksProduced = 0;
204
+ let cancellations = 0;
205
+ let aborts = 0;
206
+ const stream = new ReadableStream(
207
+ {
208
+ pull(controller) {
209
+ chunksProduced += 1;
210
+ controller.enqueue(Buffer.from('abc'));
211
+ },
212
+ cancel() {
213
+ cancellations += 1;
214
+ },
215
+ },
216
+ { highWaterMark: 0 },
217
+ );
218
+
219
+ await assert.rejects(
220
+ verifyAssetDownloads(
221
+ { fixture: { url: 'https://fixtures.invalid/asset', sha256, size: 5 } },
222
+ {
223
+ attempts: 1,
224
+ maxAssetBytes: 10,
225
+ fetchImpl: async (_url, { signal }) => {
226
+ signal.addEventListener('abort', () => { aborts += 1; });
227
+ return { ok: true, status: 200, body: stream };
228
+ },
229
+ },
230
+ ),
231
+ /byte ceiling exceeded \(5 bytes\)/,
232
+ );
233
+ assert.equal(chunksProduced, 2);
234
+ assert.equal(cancellations, 1);
235
+ assert.equal(aborts, 1);
236
+ });
237
+
238
+ test('full guard reads deterministic fixtures and downloads every declared asset', async () => {
239
+ const dir = await mkdtemp(join(tmpdir(), 'mixdog-release-assets-'));
240
+ const patch = patchFixture();
241
+ const runtime = runtimeFixture();
242
+ const graph = graphFixture();
243
+ const expectedUrls = new Set(
244
+ [...Object.values(patch.assets), ...Object.values(runtime.assets), ...Object.values(graph.assets)]
245
+ .map(({ url }) => url),
246
+ );
247
+ const paths = {
248
+ patchManifestPath: join(dir, 'patch.json'),
249
+ cargoPath: join(dir, 'Cargo.toml'),
250
+ runtimeManifestPath: join(dir, 'runtime.json'),
251
+ graphManifestPath: join(dir, 'graph.json'),
252
+ packagePath: join(dir, 'package.json'),
253
+ };
254
+ await Promise.all([
255
+ writeFile(paths.patchManifestPath, JSON.stringify(patch)),
256
+ writeFile(paths.cargoPath, `[package]\nversion = "${VERSION}"\n`),
257
+ writeFile(paths.runtimeManifestPath, JSON.stringify(runtime)),
258
+ writeFile(paths.graphManifestPath, JSON.stringify(graph)),
259
+ writeFile(paths.packagePath, JSON.stringify({ version: APP_VERSION })),
260
+ ]);
261
+ let downloads = 0;
262
+ const requestedUrls = [];
263
+ try {
264
+ await verifyReleaseAssets({
265
+ ...paths,
266
+ downloadOptions: {
267
+ attempts: 1,
268
+ timeoutMs: 1000,
269
+ fetchImpl: async (url) => {
270
+ downloads += 1;
271
+ requestedUrls.push(url);
272
+ return new Response(bytes);
273
+ },
274
+ },
275
+ });
276
+ } finally {
277
+ await rm(dir, { recursive: true, force: true });
278
+ }
279
+ assert.equal(downloads, 15);
280
+ assert.deepEqual(new Set(requestedUrls), expectedUrls);
281
+ });
@@ -0,0 +1,293 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ export const PATCH_PLATFORMS = {
6
+ 'darwin-arm64': 'mixdog-patch-darwin-arm64',
7
+ 'darwin-x64': 'mixdog-patch-darwin-x64',
8
+ 'linux-arm64': 'mixdog-patch-linux-arm64',
9
+ 'linux-x64': 'mixdog-patch-linux-x64',
10
+ 'win32-x64': 'mixdog-patch-win32-x64.exe',
11
+ };
12
+
13
+ export const GRAPH_PLATFORMS = {
14
+ 'darwin-arm64': 'mixdog-graph-darwin-arm64',
15
+ 'darwin-x64': 'mixdog-graph-darwin-x64',
16
+ 'linux-arm64': 'mixdog-graph-linux-arm64',
17
+ 'linux-x64': 'mixdog-graph-linux-x64',
18
+ 'win32-x64': 'mixdog-graph-win32-x64.exe',
19
+ };
20
+
21
+ const RUNTIME_PLATFORMS = [
22
+ 'linux-x64',
23
+ 'linux-arm64',
24
+ 'darwin-x64',
25
+ 'darwin-arm64',
26
+ 'win32-x64',
27
+ ];
28
+ const STRICT_VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
29
+ const SHA256 = /^[a-f0-9]{64}$/i;
30
+ export const MAX_ASSET_BYTES = 256 * 1024 * 1024;
31
+ export const MAX_DOWNLOAD_TIMEOUT_MS = 300_000;
32
+ const PATCH_MANIFEST_PATH = 'src/runtime/agent/orchestrator/tools/patch-manifest.json';
33
+ const PATCH_CARGO_PATH = 'native/mixdog-patch/Cargo.toml';
34
+ const RUNTIME_MANIFEST_PATH = 'src/runtime/memory/data/runtime-manifest.json';
35
+ const GRAPH_MANIFEST_PATH = 'src/runtime/agent/orchestrator/tools/graph-manifest.json';
36
+ const PACKAGE_PATH = 'package.json';
37
+
38
+ function assertPlainObject(value, label) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
40
+ throw new Error(`${label} must be an object`);
41
+ }
42
+ }
43
+
44
+ function assertExactKeys(value, expected, label) {
45
+ const actual = Object.keys(value).sort();
46
+ const wanted = [...expected].sort();
47
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
48
+ throw new Error(`${label} keys must be exactly: ${wanted.join(', ')}`);
49
+ }
50
+ }
51
+
52
+ export function validatePatchManifest(manifest, cargoToml) {
53
+ assertPlainObject(manifest, 'Patch manifest');
54
+ assertExactKeys(manifest, ['version', '_comment', 'assets'], 'Patch manifest');
55
+ if (typeof manifest.version !== 'string' || !STRICT_VERSION.test(manifest.version)) {
56
+ throw new Error(`Patch manifest version is not strict MAJOR.MINOR.PATCH: ${manifest.version}`);
57
+ }
58
+ if (typeof manifest._comment !== 'string' || !manifest._comment) {
59
+ throw new Error('Patch manifest _comment must be a non-empty string');
60
+ }
61
+ assertPlainObject(manifest.assets, 'Patch manifest assets');
62
+ assertExactKeys(manifest.assets, Object.keys(PATCH_PLATFORMS), 'Patch manifest assets');
63
+
64
+ let inPackage = false;
65
+ let cargoVersion;
66
+ for (const line of String(cargoToml).split(/\r?\n/)) {
67
+ const section = line.match(/^\s*\[([^\]]+)\]\s*$/)?.[1];
68
+ if (section) {
69
+ inPackage = section === 'package';
70
+ continue;
71
+ }
72
+ if (inPackage) {
73
+ const version = line.match(/^\s*version\s*=\s*"([^"]+)"\s*$/)?.[1];
74
+ if (version) {
75
+ cargoVersion = version;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ if (!cargoVersion) throw new Error('Could not read [package] version from native/mixdog-patch/Cargo.toml');
81
+ if (cargoVersion !== manifest.version) {
82
+ throw new Error(`Patch Cargo version ${cargoVersion} does not match manifest version ${manifest.version}`);
83
+ }
84
+
85
+ for (const [platform, filename] of Object.entries(PATCH_PLATFORMS)) {
86
+ const asset = manifest.assets[platform];
87
+ assertPlainObject(asset, `Patch asset ${platform}`);
88
+ assertExactKeys(asset, ['url', 'sha256'], `Patch asset ${platform}`);
89
+ if (typeof asset.url !== 'string') throw new Error(`${platform}: patch asset URL must be a string`);
90
+ let url;
91
+ try {
92
+ url = new URL(asset.url);
93
+ } catch {
94
+ throw new Error(`${platform}: invalid patch asset URL`);
95
+ }
96
+ const expectedPath = `/tribgames/mixdog/releases/download/patch-v${manifest.version}/${filename}`;
97
+ if (
98
+ url.protocol !== 'https:'
99
+ || url.hostname !== 'github.com'
100
+ || url.port
101
+ || url.username
102
+ || url.password
103
+ || url.search
104
+ || url.hash
105
+ || url.pathname !== expectedPath
106
+ ) {
107
+ throw new Error(`${platform}: patch asset URL must be https://github.com${expectedPath}`);
108
+ }
109
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) {
110
+ throw new Error(`${platform}: invalid patch asset sha256`);
111
+ }
112
+ }
113
+ return manifest;
114
+ }
115
+
116
+ export function validateRuntimeManifest(manifest) {
117
+ assertPlainObject(manifest, 'Runtime manifest');
118
+ if (!manifest.release_tag || !manifest.assets || typeof manifest.assets !== 'object') {
119
+ throw new Error(`${RUNTIME_MANIFEST_PATH} is missing release_tag or assets`);
120
+ }
121
+ assertPlainObject(manifest.assets, 'Runtime manifest assets');
122
+ assertExactKeys(manifest.assets, RUNTIME_PLATFORMS, 'Runtime manifest assets');
123
+ for (const [platform, asset] of Object.entries(manifest.assets)) {
124
+ if (typeof asset?.url !== 'string' || new URL(asset.url).protocol !== 'https:') {
125
+ throw new Error(`${platform}: invalid HTTPS asset URL`);
126
+ }
127
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) {
128
+ throw new Error(`${platform}: invalid sha256`);
129
+ }
130
+ if (!Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > MAX_ASSET_BYTES) {
131
+ throw new Error(`${platform}: invalid size`);
132
+ }
133
+ }
134
+ return manifest;
135
+ }
136
+
137
+ export function validateGraphManifest(manifest, packageJson) {
138
+ assertPlainObject(manifest, 'Graph manifest');
139
+ assertExactKeys(manifest, ['version', '_comment', 'assets'], 'Graph manifest');
140
+ if (typeof manifest.version !== 'string' || !STRICT_VERSION.test(manifest.version)) {
141
+ throw new Error(`Graph manifest version is not strict MAJOR.MINOR.PATCH: ${manifest.version}`);
142
+ }
143
+ if (typeof manifest._comment !== 'string' || !manifest._comment) {
144
+ throw new Error('Graph manifest _comment must be a non-empty string');
145
+ }
146
+ assertPlainObject(packageJson, 'package.json');
147
+ if (typeof packageJson.version !== 'string' || !STRICT_VERSION.test(packageJson.version)) {
148
+ throw new Error(`package.json version is not strict MAJOR.MINOR.PATCH: ${packageJson.version}`);
149
+ }
150
+ assertPlainObject(manifest.assets, 'Graph manifest assets');
151
+ assertExactKeys(manifest.assets, Object.keys(GRAPH_PLATFORMS), 'Graph manifest assets');
152
+
153
+ for (const [platform, filename] of Object.entries(GRAPH_PLATFORMS)) {
154
+ const asset = manifest.assets[platform];
155
+ assertPlainObject(asset, `Graph asset ${platform}`);
156
+ assertExactKeys(asset, ['url', 'sha256'], `Graph asset ${platform}`);
157
+ if (typeof asset.url !== 'string') throw new Error(`${platform}: graph asset URL must be a string`);
158
+ let url;
159
+ try {
160
+ url = new URL(asset.url);
161
+ } catch {
162
+ throw new Error(`${platform}: invalid graph asset URL`);
163
+ }
164
+ const expectedPath = `/tribgames/mixdog/releases/download/graph-v${manifest.version}/${filename}`;
165
+ if (
166
+ url.protocol !== 'https:'
167
+ || url.hostname !== 'github.com'
168
+ || url.port
169
+ || url.username
170
+ || url.password
171
+ || url.search
172
+ || url.hash
173
+ || url.pathname !== expectedPath
174
+ ) {
175
+ throw new Error(`${platform}: graph asset URL must be https://github.com${expectedPath}`);
176
+ }
177
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) {
178
+ throw new Error(`${platform}: invalid graph asset sha256`);
179
+ }
180
+ }
181
+ return manifest;
182
+ }
183
+
184
+ async function downloadAndVerify(label, asset, fetchImpl, timeoutMs, maxAssetBytes) {
185
+ console.log(`Downloading and verifying ${label}`);
186
+ const controller = new AbortController();
187
+ const response = await fetchImpl(asset.url, {
188
+ redirect: 'follow',
189
+ headers: { 'User-Agent': 'mixdog-release-asset-guard' },
190
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]),
191
+ });
192
+ if (!response.ok || !response.body) {
193
+ await response.body?.cancel();
194
+ throw new Error(`download failed with HTTP ${response.status}`);
195
+ }
196
+ const hash = createHash('sha256');
197
+ let downloaded = 0;
198
+ const byteCeiling = Math.min(asset.size ?? Number.POSITIVE_INFINITY, maxAssetBytes);
199
+ const reader = response.body.getReader();
200
+ try {
201
+ while (true) {
202
+ const { done, value: chunk } = await reader.read();
203
+ if (done) break;
204
+ if (downloaded + chunk.byteLength > byteCeiling) {
205
+ const error = new Error(`byte ceiling exceeded (${byteCeiling} bytes)`);
206
+ controller.abort(error);
207
+ await reader.cancel(error).catch(() => {});
208
+ throw error;
209
+ }
210
+ hash.update(chunk);
211
+ downloaded += chunk.byteLength;
212
+ }
213
+ } finally {
214
+ reader.releaseLock();
215
+ }
216
+ const digest = hash.digest('hex');
217
+ if (asset.size !== undefined && downloaded !== asset.size) {
218
+ throw new Error(`size mismatch (manifest ${asset.size}, downloaded ${downloaded})`);
219
+ }
220
+ if (digest.toLowerCase() !== asset.sha256.toLowerCase()) {
221
+ throw new Error(`sha256 mismatch (manifest ${asset.sha256}, downloaded ${digest})`);
222
+ }
223
+ }
224
+
225
+ export async function verifyAssetDownloads(
226
+ assets,
227
+ {
228
+ fetchImpl = globalThis.fetch,
229
+ attempts = 3,
230
+ timeoutMs = 300_000,
231
+ maxAssetBytes = MAX_ASSET_BYTES,
232
+ retryDelay = (attempt) => (
233
+ new Promise((resolve) => setTimeout(resolve, attempt === 1 ? 2_000 : 6_000))
234
+ ),
235
+ } = {},
236
+ ) {
237
+ if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 5) {
238
+ throw new Error(`Download attempts must be between 1 and 5, got ${attempts}`);
239
+ }
240
+ if (!Number.isSafeInteger(maxAssetBytes) || maxAssetBytes < 1 || maxAssetBytes > MAX_ASSET_BYTES) {
241
+ throw new Error(`Asset byte ceiling must be between 1 and ${MAX_ASSET_BYTES}, got ${maxAssetBytes}`);
242
+ }
243
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_DOWNLOAD_TIMEOUT_MS) {
244
+ throw new Error(`Download timeout must be between 1 and ${MAX_DOWNLOAD_TIMEOUT_MS}, got ${timeoutMs}`);
245
+ }
246
+ for (const [label, asset] of Object.entries(assets)) {
247
+ let lastError;
248
+ for (let attempt = 1; attempt <= attempts; attempt++) {
249
+ try {
250
+ await downloadAndVerify(label, asset, fetchImpl, timeoutMs, maxAssetBytes);
251
+ lastError = undefined;
252
+ break;
253
+ } catch (error) {
254
+ lastError = error;
255
+ console.warn(`${label}: attempt ${attempt}/${attempts} failed: ${error.message}`);
256
+ if (attempt < attempts) await retryDelay(attempt);
257
+ }
258
+ }
259
+ if (lastError) {
260
+ throw new Error(`${label}: verification failed after ${attempts} attempts: ${lastError.message}`);
261
+ }
262
+ }
263
+ }
264
+
265
+ export async function verifyReleaseAssets({
266
+ patchManifestPath = PATCH_MANIFEST_PATH,
267
+ cargoPath = PATCH_CARGO_PATH,
268
+ runtimeManifestPath = RUNTIME_MANIFEST_PATH,
269
+ graphManifestPath = GRAPH_MANIFEST_PATH,
270
+ packagePath = PACKAGE_PATH,
271
+ downloadOptions,
272
+ } = {}) {
273
+ const [patchSource, cargoToml, runtimeSource, graphSource, packageSource] = await Promise.all([
274
+ readFile(patchManifestPath, 'utf8'),
275
+ readFile(cargoPath, 'utf8'),
276
+ readFile(runtimeManifestPath, 'utf8'),
277
+ readFile(graphManifestPath, 'utf8'),
278
+ readFile(packagePath, 'utf8'),
279
+ ]);
280
+ const patchManifest = validatePatchManifest(JSON.parse(patchSource), cargoToml);
281
+ const runtimeManifest = validateRuntimeManifest(JSON.parse(runtimeSource));
282
+ const graphManifest = validateGraphManifest(JSON.parse(graphSource), JSON.parse(packageSource));
283
+ await verifyAssetDownloads(patchManifest.assets, downloadOptions);
284
+ console.log(`Verified all bundled patch assets for patch-v${patchManifest.version}.`);
285
+ await verifyAssetDownloads(runtimeManifest.assets, downloadOptions);
286
+ console.log(`Verified all bundled runtime assets for ${runtimeManifest.release_tag}.`);
287
+ await verifyAssetDownloads(graphManifest.assets, downloadOptions);
288
+ console.log(`Verified all bundled graph assets for graph-v${graphManifest.version}.`);
289
+ }
290
+
291
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
292
+ await verifyReleaseAssets();
293
+ }
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import test from 'node:test';
6
+
7
+ const root = fileURLToPath(new URL('..', import.meta.url));
8
+
9
+ function source(relativePath) {
10
+ return readFileSync(join(root, relativePath), 'utf8');
11
+ }
12
+
13
+ test('Windows-sensitive Node re-execs keep their windows hidden', () => {
14
+ const cli = source('src/cli.mjs');
15
+ const jitRebuild = source('src/tui/dev/jit-rebuild.mjs');
16
+
17
+ assert.match(cli, /spawnSync\(process\.execPath, \[fileURLToPath\(import\.meta\.url\), \.\.\.argv\], \{\r?\n\s*stdio: 'inherit',\r?\n\s*env: \{ \.\.\.process\.env, MIXDOG_SWAP_REEXEC: '1' \},\r?\n\s*windowsHide: true,\r?\n\s*\}\)/);
18
+ assert.match(jitRebuild, /spawnSync\(process\.execPath, \[script\], \{\r?\n\s*stdio: process\.env\.MIXDOG_TUI_DEV_VERBOSE \? 'inherit' : 'ignore',\r?\n\s*windowsHide: true,\r?\n\s*\}\)/);
19
+ });
package/src/cli.mjs CHANGED
@@ -35,6 +35,7 @@ async function main() {
35
35
  const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...argv], {
36
36
  stdio: 'inherit',
37
37
  env: { ...process.env, MIXDOG_SWAP_REEXEC: '1' },
38
+ windowsHide: true,
38
39
  });
39
40
  if (!r.error) return Number.isInteger(r.status) ? r.status : 0;
40
41
  } catch { /* fall through to in-place run */ }
@@ -245,15 +245,26 @@ export function parseGrepCoverage(resultText, toolName, toolArgs, resultKind) {
245
245
  }
246
246
 
247
247
  function classifyToolFailure(resultText, toolName) {
248
- const text = String(resultText ?? '').toLowerCase();
249
- if (/\[shell-tool-failed\]/i.test(String(resultText ?? ''))) return 'tool-call/failure';
250
- if (/\[shell-run-failed\]/i.test(String(resultText ?? ''))) {
251
- if (/timeout|timed out|aborted|interrupted/.test(text)) return 'timeout/abort';
248
+ const raw = String(resultText ?? '');
249
+ const text = raw.toLowerCase();
250
+ // Shell renderers put the machine-readable status on the leading line.
251
+ // Only inspect that marker: command text and stderr frequently contain
252
+ // words such as "timeout" or "aborted" and must not rewrite a real exit
253
+ // code into a runtime failure category.
254
+ const leading = raw.split(/\r?\n/)
255
+ .map((line) => line.trim())
256
+ .find((line) => line && !line.startsWith('⚠️ '))
257
+ ?.replace(/^Error:\s*/i, '') || '';
258
+ if (/^\[shell-tool-failed\](?:\s|$)/i.test(leading)) return 'tool-call/failure';
259
+ if (/^\[shell-run-failed\](?:\s|$)/i.test(leading)) {
260
+ if (/\[timeout:|cause:\s*(?:timeout|cancellation)\b/i.test(leading)) return 'timeout/abort';
261
+ if (/cause:\s*(?:output-limit|output-capture-error|background-adoption-failed)\b/i.test(leading)) return 'runtime/failure';
262
+ if (/\[signal:\s*[^\]]+/i.test(leading)) return 'process/signal';
252
263
  return 'command-exit';
253
264
  }
254
265
  if (/requires either|invalid arguments|unknown parameter|must be|schema|expected|required|old_string is .*>=/.test(text)) return 'schema/args';
255
266
  if (/not in allow-list|not allowed/.test(text)) return 'permission';
256
- if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(String(resultText ?? ''))) return 'command-exit';
267
+ if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(raw)) return 'command-exit';
257
268
  if (/enoent|cannot find|not found at this path|path does not exist|no such file|file not found in graph|unreadable/.test(text)) return 'path/enoent';
258
269
  if (/timed out|timeout|interrupted|aborted/.test(text)) return 'timeout/abort';
259
270
  if (/hunk rejected|patch failed|context mismatch|expected first old\/context|context not found/.test(text)) return 'patch/context';