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.
- package/README.md +6 -6
- package/package.json +19 -9
- package/scripts/agent-parallel-smoke.mjs +4 -1
- package/scripts/agent-route-batch-test.mjs +40 -0
- package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
- package/scripts/code-graph-description-contract.mjs +185 -0
- package/scripts/code-graph-disk-hit-test.mjs +55 -0
- package/scripts/code-graph-dispatch-test.mjs +96 -0
- package/scripts/compact-pressure-test.mjs +40 -0
- package/scripts/deferred-tool-loading-test.mjs +233 -0
- package/scripts/embedding-worker-exit-test.mjs +76 -0
- package/scripts/execution-completion-dedup-test.mjs +48 -0
- package/scripts/explore-prompt-policy-test.mjs +88 -3
- package/scripts/live-worker-smoke.mjs +68 -16
- package/scripts/memory-core-input-test.mjs +33 -13
- package/scripts/native-edit-wire-test.mjs +152 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
- package/scripts/patch-binary-cache-test.mjs +181 -0
- package/scripts/prompt-immediate-render-test.mjs +89 -0
- package/scripts/provider-toolcall-test.mjs +280 -15
- package/scripts/shell-failure-diagnostics-test.mjs +211 -0
- package/scripts/smoke-loop.mjs +9 -3
- package/scripts/smoke.mjs +5 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
- package/scripts/streaming-tail-window-test.mjs +29 -0
- package/scripts/tool-failures.mjs +21 -3
- package/scripts/tool-smoke.mjs +263 -38
- package/scripts/tool-tui-presentation-test.mjs +17 -1
- package/scripts/tui-perf-run.ps1 +26 -0
- package/scripts/tui-transcript-perf-test.mjs +7 -1
- package/scripts/verify-release-assets-test.mjs +281 -0
- package/scripts/verify-release-assets.mjs +293 -0
- package/scripts/windows-hide-spawn-options-test.mjs +19 -0
- package/src/cli.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
- package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
- package/src/runtime/memory/tool-defs.mjs +1 -2
- package/src/session-runtime/context-status.mjs +25 -16
- package/src/session-runtime/model-route-api.mjs +2 -0
- package/src/session-runtime/runtime-core.mjs +2 -2
- package/src/session-runtime/session-turn-api.mjs +4 -1
- package/src/session-runtime/tool-catalog.mjs +113 -19
- package/src/session-runtime/tool-defs.mjs +1 -0
- package/src/standalone/agent-tool/helpers.mjs +25 -6
- package/src/standalone/agent-tool.mjs +75 -41
- package/src/tui/App.jsx +4 -0
- package/src/tui/app/input-parsers.mjs +8 -9
- package/src/tui/components/Markdown.jsx +6 -1
- package/src/tui/components/Message.jsx +11 -2
- package/src/tui/components/PromptInput.jsx +19 -21
- package/src/tui/components/Spinner.jsx +4 -4
- package/src/tui/components/StatusLine.jsx +6 -6
- package/src/tui/components/TranscriptItem.jsx +2 -2
- package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
- package/src/tui/dist/index.mjs +130 -45
- package/src/tui/engine/agent-job-feed.mjs +21 -2
- package/src/tui/engine/turn.mjs +12 -1
- package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +20 -0
- package/src/ui/statusline.mjs +5 -5
- 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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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(
|
|
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';
|