carthagent 1.0.7 → 1.1.0-beta.1

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 (294) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,371 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { createServer } from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { once } from 'node:events';
8
+ import { gzipSync } from 'node:zlib';
9
+ import { afterEach, test } from 'node:test';
10
+ import {
11
+ REQUIRED_RUNTIME_TARGETS,
12
+ validateRuntimeReleaseManifest,
13
+ verifyOwnedRuntimeRelease,
14
+ } from '../tools/verify-owned-runtime-release.mjs';
15
+ import { createRuntimeGithubFetch } from '../lib/opencode/runtime-github-transport.mjs';
16
+
17
+ const roots = [];
18
+ afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))));
19
+
20
+ test('validates required five-platform manifest then supplies every trusted digest to acquisition', async () => {
21
+ const { root, manifestPath, manifest } = await fixture();
22
+ const calls = [];
23
+ await verifyOwnedRuntimeRelease({
24
+ releaseVersion: manifest.releaseVersion,
25
+ manifestPath,
26
+ assetsBaseUrl: 'https://github.example/releases/v9.8.7',
27
+ repoRoot: root,
28
+ acquireRuntime: async (options) => { calls.push(options); return `/verified/${options.platform}-${options.arch}`; },
29
+ });
30
+ assert.equal(root.length > 0, true);
31
+ assert.equal(calls.length, 5);
32
+ for (const [key, [platform, arch, name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
33
+ const call = calls.find((item) => `${item.platform}-${item.arch}` === key);
34
+ assert.ok(call, `acquired ${key}`);
35
+ assert.equal(call.baseUrl, 'https://github.example/releases/v9.8.7');
36
+ assert.equal(call.expectedArchiveSha256, manifest.assets[key].archiveSha256);
37
+ assert.equal(call.expectedExecutableSha256, manifest.assets[key].executableSha256);
38
+ assert.equal(call.expectedSourceRevision, manifest.sourceRevision);
39
+ assert.equal(call.expectedPatchSha256, manifest.patchSha256);
40
+ assert.equal(call.expectedLockSha256, manifest.lockSha256);
41
+ assert.equal(call.expectedCompatibilityVersion, manifest.compatibilityVersion);
42
+ assert.equal(call.expectedRuntimeVersion, manifest.runtimeVersion);
43
+ assert.equal(call.expectedChannel, `ctg.${manifest.compatibilityVersion}`);
44
+ assert.equal(manifest.assets[key].name, name);
45
+ assert.equal(call.platform, platform);
46
+ assert.equal(call.arch, arch);
47
+ }
48
+ });
49
+
50
+ test('verifier wires the opt-in credential transport to every production acquisition', async () => {
51
+ const { root, manifestPath, manifest } = await fixture();
52
+ const calls = [];
53
+ const fakeFetch = async (url, init) => {
54
+ calls.push({ url: String(url), init });
55
+ return new Response(JSON.stringify([]), { status: 200 });
56
+ };
57
+ await verifyOwnedRuntimeRelease({
58
+ releaseVersion: manifest.releaseVersion, manifestPath,
59
+ assetsBaseUrl: `https://github.com/B-icy/Carthagent/releases/download/v${manifest.releaseVersion}`,
60
+ repoRoot: root, runtimeToken: 'fixture-token-never-real', fetchImpl: fakeFetch,
61
+ acquireRuntime: async (options) => {
62
+ assert.equal(typeof options.fetchImpl, 'function');
63
+ // Exercise the injected fetch adapter without initiating external I/O.
64
+ await assert.rejects(options.fetchImpl(`${options.baseUrl}/${manifest.assets['linux-x64'].name}`), /release was not found/);
65
+ return '/verified';
66
+ },
67
+ });
68
+ assert.equal(calls.length, 5);
69
+ assert.ok(calls.every(({ url, init }) => url.startsWith('https://api.github.com/repos/B-icy/Carthagent/releases?')));
70
+ assert.ok(calls.every(({ init }) => init.headers.authorization === 'Bearer fixture-token-never-real'));
71
+ });
72
+
73
+ test('rejects absent Linux arm64 and a release-version mismatch before acquiring anything', async () => {
74
+ const { root, manifestPath, manifest } = await fixture();
75
+ delete manifest.assets['linux-arm64'];
76
+ await writeFile(manifestPath, JSON.stringify(manifest));
77
+ await assert.rejects(verifyOwnedRuntimeRelease({
78
+ releaseVersion: manifest.releaseVersion,
79
+ manifestPath,
80
+ assetsBaseUrl: 'https://example.test/release',
81
+ repoRoot: root,
82
+ acquireRuntime: async () => assert.fail('invalid manifest must not acquire assets'),
83
+ }), /must contain exactly/);
84
+ await assert.rejects(verifyOwnedRuntimeRelease({
85
+ releaseVersion: '9.8.8', manifestPath, assetsBaseUrl: 'https://example.test/release', repoRoot: root,
86
+ acquireRuntime: async () => assert.fail('wrong release version must not acquire assets'),
87
+ }), /does not match/);
88
+ });
89
+
90
+ test('rejects malformed digests and noncanonical platform asset names', async () => {
91
+ const { manifest } = await fixture();
92
+ manifest.assets['windows-x64'].archiveSha256 = 'not-a-digest';
93
+ assert.throws(() => validateRuntimeReleaseManifest(manifest, manifest.releaseVersion), /archiveSha256/);
94
+ manifest.assets['windows-x64'].archiveSha256 = 'a'.repeat(64);
95
+ manifest.assets['windows-x64'].name = 'arbitrary.tar.gz';
96
+ assert.throws(() => validateRuntimeReleaseManifest(manifest, manifest.releaseVersion), /asset name/);
97
+ });
98
+
99
+ test('verifies all five USTAR assets through the production acquirer over loopback', async (t) => {
100
+ const { root, manifestPath, manifest } = await fixture();
101
+ const assets = new Map();
102
+ for (const [key, [platform, arch, name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
103
+ const executable = Buffer.from(`fixture executable for ${key}\n`);
104
+ const executablePath = `cli-${key}/bin/${key.startsWith('windows-') ? 'opencode.exe' : 'opencode'}`;
105
+ const runtime = {
106
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
107
+ sourceRevision: manifest.sourceRevision, patchSha256: manifest.patchSha256,
108
+ lockSha256: manifest.lockSha256, compatibilityVersion: manifest.compatibilityVersion,
109
+ runtimeVersion: manifest.runtimeVersion, channel: `ctg.${manifest.compatibilityVersion}`,
110
+ platform: `${platform}-${arch}`, webUI: false,
111
+ };
112
+ const archive = makeUstarArchive([
113
+ [executablePath, executable, 0o755],
114
+ ['SHA256SUMS', Buffer.from(`${sha(executable)} ${executablePath}\n`), 0o644],
115
+ ['ctg-runtime.json', Buffer.from(JSON.stringify(runtime)), 0o644],
116
+ ['LICENSE', Buffer.from('fixture license\n'), 0o644],
117
+ ]);
118
+ manifest.assets[key].archiveSha256 = sha(archive);
119
+ manifest.assets[key].executableSha256 = sha(executable);
120
+ assets.set(`/releases/${manifest.releaseVersion}/${name}`, archive);
121
+ }
122
+ await writeFile(manifestPath, JSON.stringify(manifest));
123
+ const server = createServer((request, response) => {
124
+ const body = assets.get(new URL(request.url, 'http://127.0.0.1').pathname);
125
+ if (!body) { response.writeHead(404).end(); return; }
126
+ response.writeHead(200, { 'content-length': body.length }).end(body);
127
+ });
128
+ server.listen(0, '127.0.0.1');
129
+ await once(server, 'listening');
130
+ t.after(() => new Promise((resolve) => server.close(resolve)));
131
+ const assetsBaseUrl = `http://127.0.0.1:${server.address().port}/releases/${manifest.releaseVersion}`;
132
+
133
+ await verifyOwnedRuntimeRelease({
134
+ releaseVersion: manifest.releaseVersion,
135
+ manifestPath,
136
+ assetsBaseUrl,
137
+ repoRoot: root,
138
+ cacheRoot: path.join(root, 'acquirer-cache'),
139
+ });
140
+ assert.equal(assets.size, 5);
141
+ });
142
+
143
+ test('verifies five draft assets through API metadata and asset-id downloads with the production acquirer', async (t) => {
144
+ const { root, manifestPath, manifest } = await fixture();
145
+ const archiveByName = new Map();
146
+ const assetsById = new Map();
147
+ let nextId = 101;
148
+ for (const [key, [platform, arch, name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
149
+ const executable = Buffer.from(`draft fixture executable ${key}`);
150
+ const executablePath = `cli-${key}/bin/${key.startsWith('windows-') ? 'opencode.exe' : 'opencode'}`;
151
+ const runtime = {
152
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
153
+ sourceRevision: manifest.sourceRevision, patchSha256: manifest.patchSha256,
154
+ lockSha256: manifest.lockSha256, compatibilityVersion: manifest.compatibilityVersion,
155
+ runtimeVersion: manifest.runtimeVersion, channel: `ctg.${manifest.compatibilityVersion}`,
156
+ platform: `${platform}-${arch}`, webUI: false,
157
+ };
158
+ const archive = makeUstarArchive([
159
+ [executablePath, executable, 0o755],
160
+ ['SHA256SUMS', Buffer.from(`${sha(executable)} ${executablePath}\n`), 0o644],
161
+ ['ctg-runtime.json', Buffer.from(JSON.stringify(runtime)), 0o644],
162
+ ['LICENSE', Buffer.from('fixture license\n'), 0o644],
163
+ ]);
164
+ manifest.assets[key].archiveSha256 = sha(archive);
165
+ manifest.assets[key].executableSha256 = sha(executable);
166
+ const id = nextId++;
167
+ archiveByName.set(name, archive);
168
+ assetsById.set(id, { name, archive });
169
+ }
170
+ await writeFile(manifestPath, JSON.stringify(manifest));
171
+ const token = 'fixture-token-never-real';
172
+ const requests = [];
173
+ const fakeFetch = async (input, init = {}) => {
174
+ const url = new URL(input);
175
+ requests.push({ url, init });
176
+ if (url.origin !== 'https://api.github.com') throw new Error('Unexpected external fixture request');
177
+ if (url.pathname === '/repos/B-icy/Carthagent/releases') {
178
+ return Response.json([{ id: 88, tag_name: `v${manifest.releaseVersion}` }]);
179
+ }
180
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/88/assets') {
181
+ return Response.json([...assetsById].map(([id, asset]) => ({
182
+ id, name: asset.name, state: 'uploaded', size: asset.archive.length,
183
+ url: `https://api.github.com/repos/B-icy/Carthagent/releases/assets/${id}`,
184
+ })));
185
+ }
186
+ const assetId = Number(url.pathname.match(/\/releases\/assets\/(\d+)$/)?.[1]);
187
+ if (assetId && assetsById.has(assetId)) return new Response(assetsById.get(assetId).archive);
188
+ throw new Error(`Unexpected fixture API path ${url.pathname}`);
189
+ };
190
+ await verifyOwnedRuntimeRelease({
191
+ releaseVersion: manifest.releaseVersion,
192
+ manifestPath,
193
+ assetsBaseUrl: `https://github.com/B-icy/Carthagent/releases/download/v${manifest.releaseVersion}`,
194
+ repoRoot: root,
195
+ cacheRoot: path.join(root, 'draft-acquirer-cache'),
196
+ runtimeToken: token,
197
+ fetchImpl: fakeFetch,
198
+ });
199
+ assert.equal(requests.filter(({ url }) => url.pathname.includes('/releases/assets/')).length, 5);
200
+ assert.ok(requests.every(({ init }) => init.headers.authorization === `Bearer ${token}`));
201
+ assert.equal(archiveByName.size, 5);
202
+ });
203
+
204
+ test('draft archive bytes with a wrong trusted digest fail before extraction or executable acceptance', async () => {
205
+ const { root, manifestPath, manifest } = await fixture();
206
+ const key = 'linux-x64';
207
+ const [, , name] = REQUIRED_RUNTIME_TARGETS[key];
208
+ const executable = Buffer.from('must not be accepted');
209
+ const archive = makeUstarArchive([
210
+ [`cli-${key}/bin/opencode`, executable, 0o755],
211
+ ['SHA256SUMS', Buffer.from(`${sha(executable)} cli-${key}/bin/opencode\n`), 0o644],
212
+ ['ctg-runtime.json', Buffer.from('{}'), 0o644],
213
+ ]);
214
+ manifest.assets[key].archiveSha256 = 'f'.repeat(64);
215
+ await writeFile(manifestPath, JSON.stringify(manifest));
216
+ let assetRequested = false;
217
+ const token = 'fixture-token-never-real';
218
+ const fetchImpl = async (input) => {
219
+ const url = new URL(input);
220
+ if (url.pathname === '/repos/B-icy/Carthagent/releases') return Response.json([{ id: 88, tag_name: `v${manifest.releaseVersion}` }]);
221
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/88/assets') return Response.json([{ id: 777, name, state: 'uploaded', size: archive.length, url: 'https://api.github.com/repos/B-icy/Carthagent/releases/assets/777' }]);
222
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/assets/777') { assetRequested = true; return new Response(archive); }
223
+ assert.fail(`Unexpected API URL ${url.href}`);
224
+ };
225
+ const acquire = await import('../lib/opencode/runtime-artifact.mjs');
226
+ await assert.rejects(acquire.acquireOwnedRuntime({
227
+ releaseVersion: manifest.releaseVersion, platform: 'linux', arch: 'x64', assetName: name,
228
+ baseUrl: `https://github.com/B-icy/Carthagent/releases/download/v${manifest.releaseVersion}`,
229
+ expectedArchiveSha256: manifest.assets[key].archiveSha256,
230
+ expectedExecutableSha256: sha(executable), expectedSourceRevision: manifest.sourceRevision,
231
+ expectedPatchSha256: manifest.patchSha256, expectedLockSha256: manifest.lockSha256,
232
+ expectedCompatibilityVersion: manifest.compatibilityVersion, expectedRuntimeVersion: manifest.runtimeVersion,
233
+ expectedChannel: `ctg.${manifest.compatibilityVersion}`, cacheRoot: path.join(root, 'bad-draft-cache'),
234
+ fetchImpl: createRuntimeGithubFetch({ token, fetchImpl }),
235
+ }), /archive SHA-256 mismatch/);
236
+ assert.equal(assetRequested, true);
237
+ await assert.rejects(import('node:fs/promises').then(({ access }) => access(path.join(root, 'bad-draft-cache', manifest.releaseVersion, key))));
238
+ });
239
+
240
+ test('production acquisition rejects an embedded channel mismatch against the pinned compatibility version', async (t) => {
241
+ const { root, manifestPath, manifest } = await fixture();
242
+ const [key, [platform, arch]] = Object.entries(REQUIRED_RUNTIME_TARGETS)[0];
243
+ const executable = Buffer.from('channel mismatch fixture executable');
244
+ const executablePath = `cli-${key}/bin/opencode`;
245
+ const runtime = {
246
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
247
+ sourceRevision: manifest.sourceRevision, patchSha256: manifest.patchSha256,
248
+ lockSha256: manifest.lockSha256, compatibilityVersion: manifest.compatibilityVersion,
249
+ runtimeVersion: manifest.runtimeVersion, channel: 'ctg.wrong-version',
250
+ platform: `${platform}-${arch}`, webUI: false,
251
+ };
252
+ const archive = makeUstarArchive([
253
+ [executablePath, executable, 0o755],
254
+ ['SHA256SUMS', Buffer.from(`${sha(executable)} ${executablePath}\n`), 0o644],
255
+ ['ctg-runtime.json', Buffer.from(JSON.stringify(runtime)), 0o644],
256
+ ['LICENSE', Buffer.from('fixture license\n'), 0o644],
257
+ ]);
258
+ manifest.assets[key].archiveSha256 = sha(archive);
259
+ manifest.assets[key].executableSha256 = sha(executable);
260
+ await writeFile(manifestPath, JSON.stringify(manifest));
261
+ const server = createServer((_request, response) => response.writeHead(200, { 'content-length': archive.length }).end(archive));
262
+ server.listen(0, '127.0.0.1');
263
+ await once(server, 'listening');
264
+ t.after(() => new Promise((resolve) => server.close(resolve)));
265
+
266
+ await assert.rejects(verifyOwnedRuntimeRelease({
267
+ releaseVersion: manifest.releaseVersion,
268
+ manifestPath,
269
+ assetsBaseUrl: `http://127.0.0.1:${server.address().port}/releases/${manifest.releaseVersion}`,
270
+ repoRoot: root,
271
+ cacheRoot: path.join(root, 'wrong-channel-cache'),
272
+ }), /channel mismatch/);
273
+ });
274
+
275
+ test('verifier pins the recipe-selected combined patch rather than the preserved base patch', async () => {
276
+ const { root, manifestPath, manifest } = await fixture();
277
+ const calls = [];
278
+ await verifyOwnedRuntimeRelease({
279
+ releaseVersion: manifest.releaseVersion, manifestPath,
280
+ assetsBaseUrl: 'https://example.test/release', repoRoot: root,
281
+ acquireRuntime: async (options) => { calls.push(options); return '/verified'; },
282
+ });
283
+ assert.equal(calls.length, 5);
284
+ assert.equal(calls[0].expectedPatchSha256, manifest.patchSha256);
285
+ });
286
+
287
+ test('verifier rejects missing or tampered recipe-selected patch before acquiring assets', async (t) => {
288
+ await t.test('missing selected patch does not fall back to preserved base', async () => {
289
+ const { root, manifestPath, manifest } = await fixture();
290
+ await rm(path.join(root, 'docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch'));
291
+ let calls = 0;
292
+ await assert.rejects(verifyOwnedRuntimeRelease({
293
+ releaseVersion: manifest.releaseVersion, manifestPath,
294
+ assetsBaseUrl: 'https://example.test/release', repoRoot: root,
295
+ acquireRuntime: async () => { calls += 1; return '/verified'; },
296
+ }), /ENOENT/);
297
+ assert.equal(calls, 0);
298
+ });
299
+ await t.test('tampered selected patch fails its recipe digest', async () => {
300
+ const { root, manifestPath, manifest } = await fixture();
301
+ await writeFile(
302
+ path.join(root, 'docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch'),
303
+ 'tampered combined patch',
304
+ );
305
+ let calls = 0;
306
+ await assert.rejects(verifyOwnedRuntimeRelease({
307
+ releaseVersion: manifest.releaseVersion, manifestPath,
308
+ assetsBaseUrl: 'https://example.test/release', repoRoot: root,
309
+ acquireRuntime: async () => { calls += 1; return '/verified'; },
310
+ }), /Pinned patch bytes do not match the build recipe/);
311
+ assert.equal(calls, 0);
312
+ });
313
+ });
314
+
315
+ async function fixture() {
316
+ const root = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-release-manifest-'));
317
+ roots.push(root);
318
+ await mkdir(path.join(root, 'script/ctg'), { recursive: true });
319
+ await mkdir(path.join(root, 'docs/patches'), { recursive: true });
320
+ const patch = Buffer.from('fixture pinned patch');
321
+ const patchSha256 = createHash('sha256').update(patch).digest('hex');
322
+ const sourceRevision = '0123456789abcdef0123456789abcdef01234567';
323
+ const lockSha256 = 'b'.repeat(64);
324
+ await writeFile(path.join(root, 'script/ctg/build-owned-runtime.sh'), [
325
+ `EXPECTED_SOURCE="${sourceRevision}"`, `EXPECTED_PATCH="${patchSha256}"`, `EXPECTED_LOCK="${lockSha256}"`,
326
+ 'patch_file="$ROOT/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch"',
327
+ ].join('\n'));
328
+ await writeFile(path.join(root, 'docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch'), patch);
329
+ await writeFile(path.join(root, 'docs/patches/opencode-v2.0.32-session-ui.patch'), Buffer.from('preserved base patch with different bytes'));
330
+ const manifest = {
331
+ version: 1,
332
+ releaseVersion: '9.8.7',
333
+ compatibilityVersion: '2.0.32',
334
+ runtimeVersion: '0.0.0-ctg.2.0.32',
335
+ sourceRevision,
336
+ patchSha256,
337
+ lockSha256,
338
+ assets: Object.fromEntries(Object.entries(REQUIRED_RUNTIME_TARGETS).map(([key, [, , name]], index) => [key, {
339
+ name,
340
+ archiveSha256: (index + 1).toString(16).repeat(64),
341
+ executableSha256: (index + 6).toString(16).repeat(64),
342
+ }])),
343
+ };
344
+ const manifestPath = path.join(root, 'runtime-release.json');
345
+ await writeFile(manifestPath, JSON.stringify(manifest));
346
+ return { root, manifestPath, manifest };
347
+ }
348
+
349
+ function sha(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
350
+ function octal(value, width) { return Buffer.from(`${value.toString(8).padStart(width - 1, '0')}\0`); }
351
+ function tarEntry(name, data, mode) {
352
+ const header = Buffer.alloc(512);
353
+ header.write(name, 0, 100);
354
+ octal(mode, 8).copy(header, 100);
355
+ octal(0, 8).copy(header, 108);
356
+ octal(0, 8).copy(header, 116);
357
+ octal(data.length, 12).copy(header, 124);
358
+ octal(0, 12).copy(header, 136);
359
+ header.fill(32, 148, 156);
360
+ header[156] = '0'.charCodeAt(0);
361
+ header.write('ustar\0', 257, 6);
362
+ header.write('00', 263, 2);
363
+ let checksum = 0;
364
+ for (const byte of header) checksum += byte;
365
+ octal(checksum, 8).copy(header, 148);
366
+ return Buffer.concat([header, data, Buffer.alloc((512 - data.length % 512) % 512)]);
367
+ }
368
+ function makeUstarArchive(files) {
369
+ const entries = files.map(([name, data, mode]) => tarEntry(name, data, mode));
370
+ return gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]));
371
+ }
@@ -0,0 +1,127 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const { createJiti } = require('jiti');
7
+ const jiti = createJiti(import.meta.url);
8
+ const { createLatestRequestGate, onWorkspaceUpdated, deliveryReadiness, renderDeliveryReadiness } = await jiti.import('../plugins/carthagent/src/ui/workspace-live.ts');
9
+
10
+ function deferred() {
11
+ let resolve;
12
+ const promise = new Promise((done) => { resolve = done; });
13
+ return { promise, resolve };
14
+ }
15
+
16
+ test('overlapping async workspace refreshes cannot publish stale results', async () => {
17
+ const gate = createLatestRequestGate();
18
+ const older = deferred();
19
+ const newer = deferred();
20
+ let visible = 'initial';
21
+
22
+ const olderRequest = gate.begin();
23
+ const oldRefresh = older.promise.then((value) => {
24
+ if (olderRequest.isCurrent()) visible = value;
25
+ });
26
+ const newerRequest = gate.begin();
27
+ const newRefresh = newer.promise.then((value) => {
28
+ if (newerRequest.isCurrent()) visible = value;
29
+ });
30
+
31
+ newer.resolve('new result');
32
+ await newRefresh;
33
+ older.resolve('stale result');
34
+ await oldRefresh;
35
+ assert.equal(visible, 'new result');
36
+ });
37
+
38
+ test('a real workspace RPC event invalidates the active view and stale deferred refresh cannot overwrite it', async () => {
39
+ const gate = createLatestRequestGate();
40
+ const older = deferred();
41
+ const updated = deferred();
42
+ let visible = 'before event';
43
+ const publish = (promise) => {
44
+ const request = gate.begin();
45
+ return promise.then((value) => {
46
+ if (request.isCurrent()) visible = value;
47
+ });
48
+ };
49
+ const oldRefresh = publish(older.promise);
50
+ let listener;
51
+ const events = { on(name, handler) { assert.equal(name, 'updated'); listener = handler; return () => { listener = undefined; }; } };
52
+ let refreshes = 0;
53
+ const stop = onWorkspaceUpdated(events, () => '/workspace/project', () => {
54
+ gate.invalidate();
55
+ refreshes++;
56
+ void publish(updated.promise);
57
+ });
58
+
59
+ // This is the promise RPC event payload shape: location is on the envelope,
60
+ // while declared data is only { revision }.
61
+ listener({ type: 'rpc.carthagent.workspace.updated', location: { directory: '/workspace/project' }, data: { revision: '42' } });
62
+ listener({ type: 'rpc.carthagent.workspace.updated', location: { directory: '/other/project' }, data: { revision: '43' } });
63
+ assert.equal(refreshes, 1);
64
+ updated.resolve('event-refreshed view');
65
+ await Promise.resolve();
66
+ older.resolve('late stale response');
67
+ await oldRefresh;
68
+ assert.equal(visible, 'event-refreshed view');
69
+ stop();
70
+ });
71
+
72
+ test('bounded delivery DTO preserves stale incomplete status and authoritative locked gate with capped evidence', () => {
73
+ const checks = { returned: 100, total: 101 };
74
+ const delivery = {
75
+ status: 'stale', freshness: 'stale', completion: 'incomplete', gate: 'locked',
76
+ evidence: Array.from({ length: 100 }, (_, index) => ({ id: `check_${index}`, status: index === 99 ? 'stale' : 'fresh' })),
77
+ truncation: { checks, acceptance: { returned: 1, total: 1 }, steps: { returned: 1, total: 1 } },
78
+ };
79
+ const readiness = deliveryReadiness(delivery);
80
+ const rendered = renderDeliveryReadiness(delivery);
81
+ assert.equal(readiness.complete, false);
82
+ assert.match(readiness.status, /completion incomplete · gate locked · freshness stale/);
83
+ assert.match(readiness.status, /partial coverage: checks 100\/101/);
84
+ assert.equal(rendered.gate, 'locked');
85
+ assert.match(rendered.label, /Readiness: incomplete/);
86
+ assert.equal(rendered.coverage, 'partial');
87
+ assert.ok(rendered.sections.includes('checks: 100 of 101 returned'));
88
+ assert.match(rendered.receipts.at(-1), /check_99: stale/);
89
+ });
90
+
91
+ test('capped presentation coverage does not downgrade authoritative ready status or gate', () => {
92
+ const delivery = {
93
+ status: 'verified', freshness: 'current', completion: 'ready', gate: 'open',
94
+ truncation: { architecture: { returned: 100, total: 101 }, checks: { returned: 4, total: 4 } },
95
+ };
96
+ const readiness = deliveryReadiness(delivery);
97
+ const rendered = renderDeliveryReadiness(delivery);
98
+ assert.equal(readiness.complete, true);
99
+ assert.equal(readiness.coverage, 'partial');
100
+ assert.equal(rendered.gate, 'open');
101
+ assert.match(rendered.label, /Readiness: ready/);
102
+ assert.ok(rendered.sections.includes('architecture: 100 of 101 returned'));
103
+ });
104
+
105
+ test('stale check outside the returned cap remains blocked by the authoritative DTO gate', () => {
106
+ const delivery = {
107
+ status: 'stale', freshness: 'stale', completion: 'incomplete', gate: 'locked',
108
+ evidence: Array.from({ length: 100 }, (_, index) => ({ id: `check_${index}`, status: 'fresh' })),
109
+ truncation: { checks: { returned: 100, total: 101 } },
110
+ };
111
+ const rendered = renderDeliveryReadiness(delivery);
112
+ assert.equal(rendered.gate, 'locked');
113
+ assert.equal(rendered.coverage, 'partial');
114
+ assert.match(rendered.label, /Readiness: incomplete/);
115
+ assert.match(rendered.label, /freshness stale/);
116
+ });
117
+
118
+ test('missing coverage metadata and gate are unknown rather than fabricated incomplete or locked', () => {
119
+ const delivery = { completion: 'ready', freshness: 'current' };
120
+ const readiness = deliveryReadiness(delivery);
121
+ const rendered = renderDeliveryReadiness(delivery);
122
+ assert.equal(readiness.complete, true);
123
+ assert.equal(readiness.coverage, 'unknown');
124
+ assert.equal(rendered.gate, 'unknown');
125
+ assert.match(rendered.label, /gate unknown/);
126
+ assert.ok(rendered.sections.includes('Coverage metadata unavailable: completeness cannot be confirmed'));
127
+ });
@@ -1,40 +1,63 @@
1
- // Offline only: reconstruct the final request using the installed serializer.
2
- // No tool execution, model generation or candidate/session mutation.
3
- import { readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
1
+ // Offline inspection of captured native OpenAI-compatible gateway requests.
2
+ // Never creates a session, runs tools, reconstructs a Pi payload, or sends network traffic.
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { pathToFileURL, fileURLToPath } from 'node:url';
6
- import { ENGINE_ROOT, sha } from './runtime.mjs';
7
- export async function reconstruct(run) {
8
- const { buildSystemPrompt } = await import(pathToFileURL(join(ENGINE_ROOT,'dist/core/system-prompt.js')));
9
- const { createToolDefinition } = await import(pathToFileURL(join(ENGINE_ROOT,'dist/core/tools/index.js')));
10
- const { stream } = await import(pathToFileURL(join(ENGINE_ROOT,'node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js')));
11
- const invocation=JSON.parse(readFileSync(join(run,'invocation.json'),'utf8'));
12
- if(invocation.arm!=='plain')throw Error('Reconstruction currently supports plain arm only');
13
- const workspace=join(run,'workspace'), toolNames=['read','bash','edit','write','grep','find','ls'];
14
- const definitions=toolNames.map(name=>createToolDefinition(name,workspace));
15
- const systemPrompt=buildSystemPrompt({cwd:workspace,selectedTools:toolNames,toolSnippets:Object.fromEntries(definitions.map(t=>[t.name,t.promptSnippet])),promptGuidelines:definitions.flatMap(t=>t.promptGuidelines || []),skills:[],contextFiles:[]});
16
- const files=readdirSync(join(run,'sessions')).filter(f=>f.endsWith('.jsonl'));if(files.length!==1)throw Error('Expected one session');
17
- const entries=readFileSync(join(run,'sessions',files[0]),'utf8').trim().split('\n').map(JSON.parse);
18
- const messages=entries.filter(e=>e.type==='message').map(e=>e.message).filter(m=>!(m.role==='assistant'&&['error','aborted'].includes(m.stopReason)));
19
- const config=JSON.parse(readFileSync(join(run,'config/models.json'),'utf8')).providers.benchmark;
20
- const model={...config.models[0],provider:'benchmark',api:config.api,baseUrl:config.baseUrl};
21
- let raw;
22
- const s=stream(model,{systemPrompt,messages,tools:definitions},{apiKey:'offline-placeholder',onPayload:payload=>{raw=payload;throw Error('OFFLINE_CAPTURE_ONLY');},fetch:()=>{throw Error('NETWORK_FORBIDDEN');}});
23
- await s.result();if(!raw)throw Error('Serializer did not produce payload');
24
- const payload={model:raw.model,messages:raw.messages,tools:raw.tools,tool_choice:raw.tool_choice,stream:true,stream_options:{include_usage:true},max_tokens:16384,reasoning:{effort:'high'},provider:{max_price:{prompt:.30,completion:1.20},require_parameters:true}};
25
- const receipts=readFileSync(join(run,'gateway.jsonl'),'utf8').trim().split('\n').map(JSON.parse),last=receipts.filter(r=>r.type==='request'&&r.kind==='generation').at(-1);
26
- return {payload,report:{originalHash:last.requestHash,reconstructedHash:sha(JSON.stringify(payload)),hashMatches:sha(JSON.stringify(payload))===last.requestHash,originalBytes:last.bytes,reconstructedBytes:Buffer.byteLength(JSON.stringify(payload)),messages:payload.messages.length,...inspectRequest(payload)}};
5
+ import { fileURLToPath } from 'node:url';
6
+ import { sha } from './runtime.mjs';
7
+
8
+ export function inspectRequest(payload) {
9
+ const issues = [], pending = new Set(), seen = new Set();
10
+ let toolCalls = 0, toolResults = 0;
11
+ for (const [index, message] of payload.messages.entries()) {
12
+ if (message.role === 'tool') {
13
+ toolResults++;
14
+ if (!pending.delete(message.tool_call_id)) issues.push(`message ${index}: unmatched tool result`);
15
+ continue;
16
+ }
17
+ if (pending.size) issues.push(`message ${index}: missing tool results before ${message.role}`);
18
+ for (const call of message.tool_calls || []) {
19
+ toolCalls++;
20
+ if (seen.has(call.id)) issues.push(`message ${index}: duplicate tool id`);
21
+ seen.add(call.id);
22
+ pending.add(call.id);
23
+ try { JSON.parse(call.function.arguments); } catch { issues.push(`message ${index}: invalid tool arguments`); }
24
+ }
25
+ }
26
+ if (pending.size) issues.push('Missing final tool results');
27
+ return {
28
+ toolCalls, toolResults, issues,
29
+ model: payload.model,
30
+ stream: payload.stream,
31
+ maxTokens: payload.max_tokens,
32
+ toolChoice: payload.tool_choice,
33
+ tools: payload.tools?.map(tool => tool.function?.name),
34
+ };
27
35
  }
28
- export function inspectRequest(payload){
29
- const issues=[],pending=new Set(),seen=new Set();let toolCalls=0,toolResults=0;
30
- for(const [i,m] of payload.messages.entries()){
31
- if(m.role==='tool'){toolResults++;if(!pending.delete(m.tool_call_id))issues.push(`message ${i}: unmatched tool result`);}
32
- else {if(pending.size)issues.push(`message ${i}: missing tool results before ${m.role}`);for(const t of m.tool_calls || []){toolCalls++;if(seen.has(t.id))issues.push(`message ${i}: duplicate tool id`);seen.add(t.id);pending.add(t.id);try{JSON.parse(t.function.arguments);}catch{issues.push(`message ${i}: invalid tool arguments`);}}}
33
- }
34
- if(pending.size)issues.push('Missing final tool results');
35
- return {toolCalls,toolResults,issues,reasoningMessages:payload.messages.filter(m=>m.reasoning_details).length,tools:payload.tools?.map(t=>t.function?.name)};
36
+
37
+ export async function inspectCapturedRequest(capturePath) {
38
+ const source = readFileSync(capturePath, 'utf8');
39
+ const payload = JSON.parse(source);
40
+ if (!payload || typeof payload !== 'object' || typeof payload.model !== 'string' || !Array.isArray(payload.messages)) {
41
+ throw Error('Captured OpenAI-compatible request must include a model and messages array');
42
+ }
43
+ const report = {
44
+ runtime: 'opencode-2',
45
+ sourceHash: sha(source),
46
+ normalizedHash: sha(JSON.stringify(payload)),
47
+ sourceBytes: Buffer.byteLength(source),
48
+ normalizedBytes: Buffer.byteLength(JSON.stringify(payload)),
49
+ messages: payload.messages.length,
50
+ ...inspectRequest(payload),
51
+ };
52
+ return { payload, report };
36
53
  }
37
- if(process.argv[1]===fileURLToPath(import.meta.url)){
38
- const [run,out]=process.argv.slice(2);if(!run||!out)throw Error('Usage: diagnose-request.mjs RUN NEW_OUTPUT_DIRECTORY');
39
- const {payload,report}=await reconstruct(run);mkdirSync(out,{mode:0o700});writeFileSync(join(out,'payload.json'),JSON.stringify(payload),{mode:0o600});writeFileSync(join(out,'inspection.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
54
+
55
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
56
+ const [capturePath, output] = process.argv.slice(2);
57
+ if (!capturePath || !output) throw Error('Usage: diagnose-request.mjs CAPTURED_REQUEST.json NEW_OUTPUT_DIRECTORY');
58
+ const { payload, report } = await inspectCapturedRequest(capturePath);
59
+ mkdirSync(output, { mode: 0o700 });
60
+ writeFileSync(join(output, 'payload.json'), JSON.stringify(payload), { mode: 0o600 });
61
+ writeFileSync(join(output, 'inspection.json'), JSON.stringify(report, null, 2), { mode: 0o600 });
62
+ console.log(JSON.stringify(report));
40
63
  }
@@ -22,11 +22,11 @@ export async function serve(cmd,args,cwd,env,port,logPath){
22
22
  export async function json(url,path,body){const r=await fetch(url+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(5000)});return {status:r.status,body:await r.json()};}
23
23
  export async function browser(root){
24
24
  mkdirSync(root,{recursive:true});const port=await freePort();
25
- const driver=spawn('geckodriver',['--port',String(port),'--profile-root',root],{detached:true,stdio:['ignore','ignore','pipe'],env:safeEnv({MOZ_HEADLESS:'1'})});let log='';driver.stderr.on('data',c=>log=(log+c).slice(-1000000));let failure;driver.on('error',e=>failure=e);
25
+ const driver=spawn('geckodriver',['--host','127.0.0.1','--port',String(port),'--profile-root',root],{detached:true,stdio:['ignore','pipe','pipe'],env:safeEnv({MOZ_HEADLESS:'1'})});let log='';const append=c=>log=(log+c).slice(-1000000);driver.stdout.on('data',append);driver.stderr.on('data',append);let failure;driver.on('error',e=>failure=e);driver.on('exit',(code,signal)=>{if(code!==null&&code!==0)failure ||= Error(`geckodriver exited ${code}${signal?` (${signal})`:''}`);});
26
26
  const base=`http://127.0.0.1:${port}`;
27
- async function call(path,body,method=body===undefined?'GET':'POST'){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(45000)});const d=await r.json();if(!r.ok||d.value?.error)throw Error(JSON.stringify(d.value));return d.value;}
27
+ async function call(path,body,method=body===undefined?'GET':'POST',timeout=45000){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(timeout)});const d=await r.json();if(!r.ok||d.value?.error)throw Error(JSON.stringify(d.value));return d.value;}
28
28
  let session;
29
- try{for(let i=0;i<100;i++){if(failure)throw failure;try{await call('/status');break;}catch{}await wait(100);}session=(await call('/session',{capabilities:{alwaysMatch:{browserName:'firefox','moz:firefoxOptions':{args:['-headless']}}}})).sessionId;}catch(e){await stopGroup(driver);writeFileSync(root+'/driver.log',log);throw Error('Browser infrastructure: '+e.message);}
29
+ try{let ready=false;for(let i=0;i<60;i++){if(failure)throw failure;try{await call('/status',undefined,'GET',1000);ready=true;break;}catch{}await wait(250);}if(!ready)throw Error('geckodriver readiness timeout after 15 seconds');session=(await call('/session',{capabilities:{alwaysMatch:{browserName:'firefox','moz:firefoxOptions':{args:['-headless']}}}},'POST',30000)).sessionId;}catch(e){await stopGroup(driver);writeFileSync(root+'/driver.log',log);throw Error(`Browser infrastructure: ${e.message}${log?`\ngeckodriver log: ${log.slice(-4000)}`:''}`);}
30
30
  const prefix='/session/'+session;
31
31
  return {call:(p,b,m)=>call(prefix+p,b,m),exec:script=>call(prefix+'/execute/sync',{script:'return eval(arguments[0]);',args:[script]}),async shot(path){writeFileSync(path,Buffer.from(await call(prefix+'/screenshot'),'base64'));},async close(){try{await call(prefix,undefined,'DELETE');}finally{await stopGroup(driver);writeFileSync(root+'/driver.log',log);}}};
32
32
  }
@@ -1,13 +1,13 @@
1
1
  import test from 'node:test';import assert from 'node:assert/strict';
2
- import{mkdtempSync,mkdirSync,existsSync,cpSync}from'node:fs';import{join,dirname}from'node:path';import{fileURLToPath}from'node:url';
2
+ import{mkdtempSync,existsSync,cpSync}from'node:fs';import{join,dirname}from'node:path';import{fileURLToPath}from'node:url';import{homedir}from'node:os';
3
3
  import{evaluateShop}from'./eval-shop.mjs';import{evaluateVoxel}from'./eval-voxel.mjs';import{command}from'./eval-common.mjs';
4
4
  const fixtures=join(dirname(fileURLToPath(import.meta.url)),'fixtures/shop');
5
5
  test('full shop evaluator passes reference and rejects rounding/idempotency mutants over real HTTP/Firefox',async()=>{
6
- const base='/home/baissi/benchmarks/infrastructure-tests';mkdirSync(base,{recursive:true});const root=mkdtempSync(join(base,'shop-'));
6
+ const root=mkdtempSync(join(homedir(),'ctg-benchmark-shop-'));
7
7
  for(const mutant of ['', 'round','idempotency']){const score=await evaluateShop(fixtures,join(root,mutant||'reference'),{environment:{FIXTURE_MUTANT:mutant}});if(!mutant)assert.equal(score.passed,score.total,JSON.stringify(score));else assert.ok(score.results.some(r=>!r.passed&&r.name.includes(mutant==='round'?'exact-cent':'idempotent')),JSON.stringify(score));}
8
8
  });
9
9
  test('voxel evaluator regression on a copy of the retained v2 candidate, not an independent reference',async(t)=>{
10
- const original='/home/baissi/benchmarks/deepseek-jev-v2/voxel-plain/workspace';if(!existsSync(original)){t.skip('Historical candidate unavailable');return;}
11
- const base='/home/baissi/benchmarks/infrastructure-tests';mkdirSync(base,{recursive:true});const root=mkdtempSync(join(base,'voxel-')),workspace=join(root,'workspace');cpSync(original,workspace,{recursive:true,filter:path=>!['target','.git','.harness'].includes(path.split('/').at(-1))});const score=await evaluateVoxel(workspace,join(root,'evaluation'));assert.equal(score.passed,score.total,JSON.stringify(score));
10
+ const original=process.env.CTG_VOXEL_REGRESSION_WORKSPACE;if(!original||!existsSync(original)){t.skip('Set CTG_VOXEL_REGRESSION_WORKSPACE to the retained candidate workspace');return;}
11
+ const root=mkdtempSync(join(homedir(),'ctg-benchmark-voxel-')),workspace=join(root,'workspace');cpSync(original,workspace,{recursive:true,filter:path=>!['target','.git','.harness'].includes(path.split('/').at(-1))});const score=await evaluateVoxel(workspace,join(root,'evaluation'));assert.equal(score.passed,score.total,JSON.stringify(score));
12
12
  });
13
13
  test('evaluator children do not inherit supervisor credentials or NODE_OPTIONS',async()=>{process.env.BENCH_FAKE_SECRET='secret';const r=await command(process.execPath,['-e','console.log(process.env.BENCH_FAKE_SECRET || "clean")'],process.cwd());assert.equal(r.stdout.trim(),'clean');delete process.env.BENCH_FAKE_SECRET;});