carthagent 1.0.7 → 1.1.0-beta.2

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 (303) 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/cloud-onboarding-acceptance.md +65 -0
  6. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  7. package/docs/glyph-review/test-isolation-v1.md +26 -0
  8. package/docs/glyph-review/typecheck-base.log +1 -0
  9. package/docs/glyph-review/typecheck-v4.log +1 -0
  10. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  11. package/docs/help-dispatch-review.md +69 -0
  12. package/docs/launch-readiness-live-results.md +65 -0
  13. package/docs/legacy-cli.md +150 -0
  14. package/docs/live-theme-plugin-api.md +18 -0
  15. package/docs/native-cli.md +102 -0
  16. package/docs/native-compatibility.md +16 -0
  17. package/docs/native-daily-quests.md +132 -0
  18. package/docs/native-glyph-pty.md +59 -0
  19. package/docs/native-manual-testing.md +336 -0
  20. package/docs/native-slash-acceptance-handoff.md +49 -0
  21. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  22. package/docs/native-terminal-performance.md +310 -0
  23. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  24. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  25. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  34. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  35. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  36. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  37. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  38. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  39. package/docs/native-terminal-semantics.md +89 -0
  40. package/docs/native-upgrade.md +175 -0
  41. package/docs/opencode-composed-source-review.md +89 -0
  42. package/docs/opencode-migration.md +225 -9
  43. package/docs/opencode-owned-runtime.md +241 -0
  44. package/docs/opencode-runtime.md +12 -0
  45. package/docs/opencode-theme-provider-proof/README.md +55 -0
  46. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  47. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  48. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  49. package/docs/owned-host-controls-proof.md +39 -0
  50. package/docs/owned-runtime-release-manifest.md +42 -0
  51. package/docs/owned-visual-clear-proof.md +40 -0
  52. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  53. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  54. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  55. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  61. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  62. package/docs/release-notes-beta.1.md +60 -0
  63. package/docs/release-notes-beta.2.md +60 -0
  64. package/docs/release-notes-native-draft.md +73 -0
  65. package/docs/release-notes-preview.10.md +121 -0
  66. package/docs/release-notes-preview.11.md +76 -0
  67. package/docs/release-notes-preview.12.md +55 -0
  68. package/docs/release-notes-preview.13.md +68 -0
  69. package/docs/release-notes-preview.9.md +130 -0
  70. package/docs/source-preview.md +120 -0
  71. package/evaluate.mjs +28 -1
  72. package/lib/budget.mjs +1 -1
  73. package/lib/cloud/client.mjs +13 -7
  74. package/lib/codegraph.mjs +1 -1
  75. package/lib/delivery-review.mjs +19 -6
  76. package/lib/delivery.mjs +3 -2
  77. package/lib/environment.mjs +8 -0
  78. package/lib/guidance.mjs +1 -1
  79. package/lib/opencode/cli-runtime.mjs +1188 -0
  80. package/lib/opencode/cli.mjs +253 -0
  81. package/lib/opencode/credentials.mjs +63 -8
  82. package/lib/opencode/demo.mjs +53 -0
  83. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  84. package/lib/opencode/evaluation.mjs +32 -12
  85. package/lib/opencode/headless.mjs +22 -5
  86. package/lib/opencode/provider-policy.mjs +23 -0
  87. package/lib/opencode/review.mjs +37 -16
  88. package/lib/opencode/runtime-artifact.mjs +335 -0
  89. package/lib/opencode/runtime-github-transport.mjs +121 -0
  90. package/lib/opencode/runtime-release.json +36 -0
  91. package/lib/opencode/runtime.mjs +299 -20
  92. package/lib/opencode/service.mjs +77 -35
  93. package/lib/opencode/session-options.mjs +6 -2
  94. package/lib/opencode/update.mjs +222 -0
  95. package/lib/opencode/workspace.mjs +1 -1
  96. package/lib/planning-advice.mjs +2 -2
  97. package/lib/providers/experiential.mjs +2 -2
  98. package/lib/providers/names.mjs +1 -1
  99. package/lib/tui/framing.mjs +1 -80
  100. package/lib/tui/models.mjs +1 -1
  101. package/lib/update.mjs +10 -1
  102. package/package.json +33 -7
  103. package/plugins/carthagent/README.md +23 -0
  104. package/plugins/carthagent/src/bootstrap.ts +1 -1
  105. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  106. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  107. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  108. package/plugins/carthagent/src/index.ts +71 -24
  109. package/plugins/carthagent/src/rpc.ts +51 -6
  110. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  111. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  112. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  113. package/plugins/carthagent/src/server/delivery.ts +227 -112
  114. package/plugins/carthagent/src/server/framing.mjs +80 -0
  115. package/plugins/carthagent/src/server/policy.ts +1 -1
  116. package/plugins/carthagent/src/server/providers.ts +65 -36
  117. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  118. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  119. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  120. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  121. package/plugins/carthagent/src/server/ship-rpc.ts +71 -0
  122. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  123. package/plugins/carthagent/src/testing.ts +5 -0
  124. package/plugins/carthagent/src/tui.tsx +506 -0
  125. package/plugins/carthagent/src/ui/client.ts +127 -0
  126. package/plugins/carthagent/src/ui/commands.ts +70 -0
  127. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  128. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  129. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  130. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  131. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  132. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  133. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  134. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  135. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  136. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  137. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  138. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  139. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  140. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  141. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  142. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  143. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  144. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  145. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  146. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  147. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  148. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  149. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  150. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  151. package/plugins/carthagent/tests/commands.test.ts +107 -0
  152. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  153. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  154. package/plugins/carthagent/themes/carthage.json +67 -0
  155. package/plugins/carthagent/tui.ts +26 -1
  156. package/script/ctg/build-owned-runtime.sh +166 -0
  157. package/tests/budget-engine.test.mjs +4 -3
  158. package/tests/cli.test.mjs +52 -96
  159. package/tests/cloud-client.test.mjs +2 -2
  160. package/tests/daily-evidence.test.mjs +62 -0
  161. package/tests/delivery-review.test.mjs +57 -2
  162. package/tests/delivery.test.mjs +22 -1
  163. package/tests/experiential-extension.test.mjs +2 -2
  164. package/tests/experiential.test.mjs +1 -1
  165. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  166. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  167. package/tests/installed-runtime-contract.test.mjs +43 -0
  168. package/tests/native-cli-host-smoke.mjs +457 -0
  169. package/tests/native-daily-editor-focus.test.mjs +213 -0
  170. package/tests/native-daily-editor.test.mjs +43 -0
  171. package/tests/native-daily-quests-layout.test.mjs +55 -0
  172. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  173. package/tests/native-daily-quests-ui.test.mjs +778 -0
  174. package/tests/native-daily-source.test.mjs +525 -0
  175. package/tests/native-demo-ui.test.mjs +48 -0
  176. package/tests/native-evaluation-admission.test.mjs +24 -0
  177. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  178. package/tests/native-fixture-runtime.test.mjs +111 -0
  179. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  180. package/tests/native-glyph-pty.test.mjs +107 -0
  181. package/tests/native-manual-projects.test.mjs +170 -0
  182. package/tests/native-model-command.test.mjs +67 -0
  183. package/tests/native-owned-runtime-env.test.mjs +140 -0
  184. package/tests/native-paired-guards.test.mjs +464 -0
  185. package/tests/native-provider-policy.test.mjs +33 -0
  186. package/tests/native-pty-invocation.test.mjs +114 -0
  187. package/tests/native-pty-runtime.test.mjs +45 -0
  188. package/tests/native-resume-session.test.mjs +46 -0
  189. package/tests/native-review-consumer.test.mjs +286 -0
  190. package/tests/native-runtime-identity.test.mjs +214 -0
  191. package/tests/native-select-mouse.test.mjs +23 -0
  192. package/tests/native-ship-guidance.test.mjs +16 -0
  193. package/tests/native-ship-parity.test.mjs +526 -0
  194. package/tests/native-ship-plan.test.mjs +85 -0
  195. package/tests/native-ship-rpc.test.mjs +83 -0
  196. package/tests/native-ui-commands.test.mjs +6 -0
  197. package/tests/native-voyages-layout.test.mjs +28 -0
  198. package/tests/native-voyages-navigation.test.mjs +169 -0
  199. package/tests/native-voyages-rpc.test.mjs +397 -0
  200. package/tests/native-voyages-ui.test.mjs +307 -0
  201. package/tests/opencode-atomic-write.test.mjs +55 -0
  202. package/tests/opencode-cli.test.mjs +1312 -0
  203. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  204. package/tests/opencode-delivery.test.mjs +16 -0
  205. package/tests/opencode-providers.test.mjs +248 -20
  206. package/tests/opencode-runtime.test.mjs +597 -8
  207. package/tests/opencode-session-controls.test.mjs +15 -0
  208. package/tests/opencode-ship-rpc.test.mjs +71 -0
  209. package/tests/opencode-update.test.mjs +475 -0
  210. package/tests/opencode-workspace.test.mjs +51 -2
  211. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  212. package/tests/release-workflow.test.mjs +46 -0
  213. package/tests/runtime-artifact.test.mjs +269 -0
  214. package/tests/runtime-development-trust.test.mjs +231 -0
  215. package/tests/runtime-github-transport.test.mjs +132 -0
  216. package/tests/runtime-token-env.test.mjs +23 -0
  217. package/tests/server.test.mjs +3 -1
  218. package/tests/ship-telemetry.test.mjs +1 -1
  219. package/tests/source-preview.test.mjs +253 -0
  220. package/tests/test_native_terminal_probe.py +76 -0
  221. package/tests/test_native_terminal_semantics.py +232 -0
  222. package/tests/tui.test.mjs +2 -2
  223. package/tests/update.test.mjs +42 -5
  224. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  225. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  226. package/tests/workspace-live.test.mjs +127 -0
  227. package/tools/benchmark/diagnose-request.mjs +59 -36
  228. package/tools/benchmark/eval-common.mjs +3 -3
  229. package/tools/benchmark/evaluators.test.mjs +4 -4
  230. package/tools/benchmark/guard.test.mjs +35 -2
  231. package/tools/benchmark/guard.ts +20 -7
  232. package/tools/benchmark/planning-startup.test.mjs +30 -34
  233. package/tools/benchmark/runner.mjs +12 -7
  234. package/tools/benchmark/runner.test.mjs +23 -7
  235. package/tools/benchmark/upstream-error.test.mjs +16 -2
  236. package/tools/generate-owned-runtime-release.mjs +217 -0
  237. package/tools/installed-runtime-contract.mjs +16 -0
  238. package/tools/manual-runtime-manifest.mjs +39 -0
  239. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  240. package/tools/native-fixture-stop-diagnostics.mjs +45 -0
  241. package/tools/native-fixture.mjs +199 -40
  242. package/tools/native-glyph-helpers.py +127 -0
  243. package/tools/native-glyph-pty.py +144 -0
  244. package/tools/native-manual-projects.mjs +259 -0
  245. package/tools/native-manual.mjs +338 -0
  246. package/tools/native-model-picker-acceptance.py +306 -0
  247. package/tools/native-owned-runtime-env.sh +47 -0
  248. package/tools/native-paired-guards.mjs +282 -0
  249. package/tools/native-paired-manifest.mjs +72 -0
  250. package/tools/native-paired-orchestration.sh +51 -0
  251. package/tools/native-plugin-readiness.mjs +33 -0
  252. package/tools/native-plugin-readiness.test.mjs +53 -0
  253. package/tools/native-pty-invocation.mjs +26 -0
  254. package/tools/native-pty-runtime.mjs +83 -0
  255. package/tools/native-pty-smoke.py +147 -24
  256. package/tools/native-public-repo-shell-result.mjs +13 -0
  257. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  258. package/tools/native-runtime-identity.mjs +201 -0
  259. package/tools/native-slash-acceptance-env.py +38 -0
  260. package/tools/native-terminal-semantics.py +882 -0
  261. package/tools/owned-shell-observer/index.ts +16 -0
  262. package/tools/owned-shell-observer/package.json +1 -0
  263. package/tools/owned-visual-clear-env.py +48 -0
  264. package/tools/owned-visual-clear-model.py +21 -0
  265. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  266. package/tools/prepare-native-manual.mjs +122 -0
  267. package/tools/run-native-terminal-paired.sh +403 -0
  268. package/tools/runtime-token-env.mjs +8 -0
  269. package/tools/seed-native-switch-fixture.mjs +79 -0
  270. package/tools/test-native-controls.mjs +3 -1
  271. package/tools/test-native-daily-quests.mjs +65 -0
  272. package/tools/test-native-default-cli.mjs +106 -0
  273. package/tools/test-native-delivery-host.mjs +102 -20
  274. package/tools/test-native-evaluation.mjs +7 -3
  275. package/tools/test-native-glyph-pty.mjs +94 -0
  276. package/tools/test-native-host.mjs +91 -65
  277. package/tools/test-native-outcomes.mjs +3 -1
  278. package/tools/test-native-package.mjs +206 -38
  279. package/tools/test-native-performance.mjs +26 -0
  280. package/tools/test-native-product.mjs +90 -0
  281. package/tools/test-native-pty.mjs +34 -9
  282. package/tools/test-native-public-repo.mjs +224 -0
  283. package/tools/test-native-refresh-reentry.mjs +106 -0
  284. package/tools/test-native-report.mjs +27 -20
  285. package/tools/test-native-review.mjs +75 -0
  286. package/tools/test-native-runtime.mjs +120 -0
  287. package/tools/test-native-ship-models.mjs +176 -0
  288. package/tools/test-native-ship-walkthrough.mjs +96 -0
  289. package/tools/test-native-slash-commands.mjs +125 -0
  290. package/tools/test-native-slash-commands.py +242 -0
  291. package/tools/test-native-swe-host.mjs +224 -55
  292. package/tools/test-native-tasks.mjs +77 -0
  293. package/tools/test-native-terminal-performance.py +887 -0
  294. package/tools/test-native-terminal-readiness.sh +85 -0
  295. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  296. package/tools/test-native-visual-clear-installed.mjs +48 -0
  297. package/tools/test-owned-host-controls.mjs +238 -0
  298. package/tools/test-owned-opencode-runtime.mjs +35 -0
  299. package/tools/test-owned-visual-clear-env.py +120 -0
  300. package/tools/test-owned-visual-clear.py +373 -0
  301. package/tools/verify-draft-runtime-gate.mjs +138 -0
  302. package/tools/verify-owned-runtime-release.mjs +114 -0
  303. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,269 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { createServer } from 'node:http';
5
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, chmod } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { once } from 'node:events';
9
+ import { promisify } from 'node:util';
10
+ import { gunzipSync, gzipSync } from 'node:zlib';
11
+ import { after, before, test } from 'node:test';
12
+ import { acquireOwnedRuntime } from '../lib/opencode/runtime-artifact.mjs';
13
+
14
+ const sourceRevision = '0123456789abcdef0123456789abcdef01234567';
15
+ const patchSha256 = 'a'.repeat(64);
16
+ const lockSha256 = 'b'.repeat(64);
17
+ const version = '9.8.7';
18
+ const targetKey = 'linux-x64';
19
+ const executableRelative = `cli-${targetKey}/bin/opencode`;
20
+ const executableBytes = Buffer.from('#!/bin/sh\necho opencode 9.8.7\n');
21
+ const execFileAsync = promisify(execFile);
22
+ let temp;
23
+ let server;
24
+ let baseUrl;
25
+ let assets = new Map();
26
+ let gets = 0;
27
+ let truncateResponse = false;
28
+
29
+ before(async () => {
30
+ temp = await mkdtemp(path.join(os.tmpdir(), 'ctg-runtime-artifact-test-'));
31
+ server = createServer((request, response) => {
32
+ gets++;
33
+ if (request.url === `/releases/${version}/redirect/opencode-${targetKey}.tar.gz`) { response.writeHead(302, { location: `/releases/${version}/opencode-${targetKey}.tar.gz` }).end(); return; }
34
+ const asset = assets.get(new URL(request.url, 'http://localhost').pathname);
35
+ if (!asset) { response.writeHead(404).end('not found'); return; }
36
+ response.writeHead(200, { 'content-length': asset.length });
37
+ if (truncateResponse) { response.write(asset.subarray(0, Math.floor(asset.length / 2))); response.socket.destroy(); return; }
38
+ response.end(asset);
39
+ });
40
+ server.listen(0, '127.0.0.1');
41
+ await once(server, 'listening');
42
+ baseUrl = `http://127.0.0.1:${server.address().port}/releases/${version}`;
43
+ });
44
+ after(async () => {
45
+ server?.close();
46
+ await rm(temp, { recursive: true, force: true });
47
+ });
48
+
49
+ function sha(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
50
+ function octal(value, width) { return Buffer.from(`${value.toString(8).padStart(width - 1, '0')}\0`); }
51
+ function tarEntry(name, data, type = '0', mode = 0o644) {
52
+ const header = Buffer.alloc(512);
53
+ header.write(name, 0, 100);
54
+ octal(mode, 8).copy(header, 100);
55
+ octal(0, 8).copy(header, 108); octal(0, 8).copy(header, 116);
56
+ octal(data.length, 12).copy(header, 124); octal(0, 12).copy(header, 136);
57
+ header.fill(32, 148, 156); header[156] = type.charCodeAt(0);
58
+ header.write('ustar\0', 257, 6); header.write('00', 263, 2);
59
+ let checksum = 0; for (const byte of header) checksum += byte;
60
+ octal(checksum, 8).copy(header, 148);
61
+ const padding = Buffer.alloc((512 - (data.length % 512)) % 512);
62
+ return Buffer.concat([header, data, padding]);
63
+ }
64
+ function makeArchive(entries = [], { key = targetKey, platform = 'linux-x64', executableMode = 0o755 } = {}) {
65
+ const manifest = Buffer.from(JSON.stringify({
66
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
67
+ sourceRevision, lockSha256, patchSha256,
68
+ compatibilityVersion: '2.0.32', runtimeVersion: '0.0.0-ctg.2.0.32',
69
+ channel: 'ctg.2.0.32', platform, webUI: false,
70
+ }));
71
+ const executablePath = `cli-${key}/bin/${key.startsWith('windows-') ? 'opencode.exe' : 'opencode'}`;
72
+ const sums = Buffer.from(`${sha(executableBytes)} ${executablePath}\n`);
73
+ const all = [
74
+ tarEntry('./', Buffer.alloc(0), '5'),
75
+ tarEntry(`./cli-${key}/`, Buffer.alloc(0), '5'),
76
+ tarEntry(`./cli-${key}/bin/`, Buffer.alloc(0), '5'),
77
+ tarEntry(`./${executablePath}`, executableBytes, '0', executableMode),
78
+ tarEntry('SHA256SUMS', sums),
79
+ tarEntry('ctg-runtime.json', manifest),
80
+ ...entries,
81
+ Buffer.alloc(1024),
82
+ ];
83
+ return gzipSync(Buffer.concat(all));
84
+ }
85
+ function setArchive(archive) {
86
+ assets = new Map([[`/releases/${version}/opencode-${targetKey}.tar.gz`, archive]]);
87
+ }
88
+ async function makeSystemTarArchive() {
89
+ const fixture = path.join(temp, 'system-tar-fixture');
90
+ const executable = path.join(fixture, executableRelative);
91
+ await mkdir(path.dirname(executable), { recursive: true });
92
+ await writeFile(executable, executableBytes);
93
+ await chmod(executable, 0o755);
94
+ await writeFile(path.join(fixture, 'SHA256SUMS'), `${sha(executableBytes)} ${executableRelative}\n`);
95
+ await writeFile(path.join(fixture, 'ctg-runtime.json'), JSON.stringify({
96
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
97
+ sourceRevision, lockSha256, patchSha256,
98
+ compatibilityVersion: '2.0.32', runtimeVersion: '0.0.0-ctg.2.0.32',
99
+ channel: 'ctg.2.0.32', platform: 'linux-x64', webUI: false,
100
+ }));
101
+ const archivePath = path.join(temp, 'system-ustar.tar.gz');
102
+ await execFileAsync('tar', ['-czf', archivePath, '--format=ustar', '-C', fixture, '.']);
103
+ // Windows tar implementations cannot preserve POSIX source-file mode. Set
104
+ // the declared USTAR mode on this Unix-target fixture directly so the test
105
+ // always covers a valid release header, independent of the host filesystem.
106
+ const tar = gunzipSync(await readFile(archivePath));
107
+ for (let offset = 0; offset + 512 <= tar.length; offset += 512) {
108
+ const header = tar.subarray(offset, offset + 512);
109
+ if (header.every((byte) => byte === 0)) break;
110
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
111
+ const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, '');
112
+ if (`${prefix ? `${prefix}/` : ''}${name}`.replace(/^\.\//, '') !== executableRelative) {
113
+ const size = Number.parseInt(header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim(), 8) || 0;
114
+ offset += Math.ceil(size / 512) * 512;
115
+ continue;
116
+ }
117
+ octal(0o755, 8).copy(header, 100);
118
+ header.fill(32, 148, 156);
119
+ let checksum = 0; for (const byte of header) checksum += byte;
120
+ octal(checksum, 8).copy(header, 148);
121
+ break;
122
+ }
123
+ return gzipSync(tar);
124
+ }
125
+ function args(extra = {}) {
126
+ const key = extra.platform === 'windows' ? `windows-${extra.arch ?? 'x64'}` : targetKey;
127
+ return {
128
+ releaseVersion: version, platform: 'linux', arch: 'x64', expectedArchiveSha256: assets.size ? sha(assets.values().next().value) : 'd'.repeat(64),
129
+ expectedSourceRevision: sourceRevision, expectedPatchSha256: patchSha256,
130
+ expectedLockSha256: lockSha256, expectedCompatibilityVersion: '2.0.32',
131
+ expectedRuntimeVersion: '0.0.0-ctg.2.0.32', expectedChannel: 'ctg.2.0.32',
132
+ expectedExecutableSha256: sha(executableBytes),
133
+ ...(key !== targetKey ? { platform: 'windows', arch: 'x64', assetName: `opencode-${key}.tar.gz` } : {}),
134
+ baseUrl, cacheRoot: path.join(temp, 'cache'), ...extra,
135
+ };
136
+ }
137
+
138
+ test('downloads, verifies and installs the pinned archive, then uses the revalidated offline cache', async () => {
139
+ const archive = makeArchive(); setArchive(archive); gets = 0;
140
+ const destination = await acquireOwnedRuntime(args());
141
+ assert.equal(path.basename(destination), targetKey);
142
+ assert.deepEqual(await readFile(path.join(destination, 'cli-linux-x64', 'bin', 'opencode')), executableBytes);
143
+ assert.deepEqual((await readdir(destination)).sort(), ['SHA256SUMS', 'cli-linux-x64', 'ctg-runtime.json']);
144
+ assert.equal(gets, 1);
145
+ assets.clear();
146
+ assert.equal(await acquireOwnedRuntime(args()), destination);
147
+ assert.equal(gets, 1);
148
+ });
149
+
150
+ test('follows a normal release-asset redirect while retaining the pinned digest check', async () => {
151
+ setArchive(makeArchive());
152
+ const redirectedUrl = `${baseUrl}/redirect`;
153
+ const destination = await acquireOwnedRuntime(args({ baseUrl: redirectedUrl, cacheRoot: path.join(temp, 'redirect-cache') }));
154
+ assert.equal(await readFile(path.join(destination, 'SHA256SUMS'), 'utf8'), `${sha(executableBytes)} ${executableRelative}\n`);
155
+ });
156
+
157
+ test('extracts a system tar USTAR artifact in the resolver-compatible root layout', async (t) => {
158
+ let archive;
159
+ try { archive = await makeSystemTarArchive(); }
160
+ catch (error) {
161
+ if (error.code === 'ENOENT') return t.skip('system tar executable is unavailable');
162
+ throw error;
163
+ }
164
+ setArchive(archive);
165
+ const destination = await acquireOwnedRuntime(args({ cacheRoot: path.join(temp, 'system-tar-cache') }));
166
+ assert.deepEqual(await readFile(path.join(destination, 'cli-linux-x64', 'bin', 'opencode')), executableBytes);
167
+ assert.deepEqual((await readdir(destination)).sort(), ['SHA256SUMS', 'cli-linux-x64', 'ctg-runtime.json']);
168
+ });
169
+
170
+ test('rejects PAX extensions because release assets are required to use USTAR format', async () => {
171
+ setArchive(makeArchive([tarEntry('PaxHeaders/entry', Buffer.from('path=cli-linux-x64/bin/opencode\n'), 'x')]));
172
+ await assert.rejects(acquireOwnedRuntime(args({ cacheRoot: path.join(temp, 'pax-cache') })), /must use USTAR format/);
173
+ });
174
+
175
+ test('rejects a Unix executable without its USTAR execute bit, regardless of extraction host', async () => {
176
+ setArchive(makeArchive([], { executableMode: 0o644 }));
177
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform');
178
+ try {
179
+ Object.defineProperty(process, 'platform', { ...platform, value: 'win32' });
180
+ await assert.rejects(acquireOwnedRuntime(args({ cacheRoot: path.join(temp, 'non-executable-cache') })), /not marked executable/);
181
+ assert.deepEqual(await readdir(path.join(temp, 'non-executable-cache', '.staging')), []);
182
+ } finally {
183
+ Object.defineProperty(process, 'platform', platform);
184
+ }
185
+ });
186
+
187
+ test('does not require a POSIX execute bit for a Windows-target executable', async () => {
188
+ const archive = makeArchive([], { key: 'windows-x64', platform: 'windows-x64', executableMode: 0o644 });
189
+ assets = new Map([[`/releases/${version}/opencode-windows-x64.tar.gz`, archive]]);
190
+ const destination = await acquireOwnedRuntime(args({ platform: 'windows', arch: 'x64', cacheRoot: path.join(temp, 'windows-target-cache') }));
191
+ assert.deepEqual(await readFile(path.join(destination, 'cli-windows-x64', 'bin', 'opencode.exe')), executableBytes);
192
+ });
193
+
194
+ test('revalidates Unix cached executable mode only on hosts with POSIX file modes', async () => {
195
+ const archive = makeArchive(); setArchive(archive);
196
+ const cacheRoot = path.join(temp, 'host-mode-cache');
197
+ const destination = await acquireOwnedRuntime(args({ cacheRoot }));
198
+ const executable = path.join(destination, executableRelative);
199
+ await chmod(executable, 0o600);
200
+ assets.clear();
201
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform');
202
+ try {
203
+ Object.defineProperty(process, 'platform', { ...platform, value: 'win32' });
204
+ assert.equal(await acquireOwnedRuntime(args({ cacheRoot })), destination);
205
+ } finally {
206
+ Object.defineProperty(process, 'platform', platform);
207
+ }
208
+ });
209
+
210
+ test('rejects tampered cached executables and archive digest mismatches without publishing', async () => {
211
+ const archive = makeArchive(); setArchive(archive);
212
+ const destination = await acquireOwnedRuntime(args());
213
+ await writeFile(path.join(destination, 'cli-linux-x64', 'bin', 'opencode'), 'altered');
214
+ await writeFile(path.join(destination, 'SHA256SUMS'), `${sha(Buffer.from('altered'))} ${executableRelative}\n`);
215
+ assets.clear();
216
+ await assert.rejects(acquireOwnedRuntime(args()), /no system Carthagent runtime fallback was attempted/);
217
+ setArchive(archive);
218
+ await assert.rejects(acquireOwnedRuntime(args({ expectedArchiveSha256: 'c'.repeat(64), cacheRoot: path.join(temp, 'bad-digest-cache') })), /SHA-256 mismatch/);
219
+ await acquireOwnedRuntime(args());
220
+ assert.deepEqual(await readFile(path.join(destination, 'cli-linux-x64', 'bin', 'opencode')), executableBytes);
221
+ assert.deepEqual(await readdir(path.join(temp, 'cache', '.staging')), []);
222
+ });
223
+
224
+ test('rejects traversal and symlink archive entries and cleans staging', async (t) => {
225
+ for (const [label, badEntry] of [
226
+ ['traversal', tarEntry('../escape', Buffer.from('escape'))],
227
+ ['symlink', tarEntry('cli-linux-x64/bin/evil', Buffer.from('../escape'), '2')],
228
+ ]) {
229
+ await t.test(label, async () => {
230
+ setArchive(makeArchive([badEntry]));
231
+ await assert.rejects(acquireOwnedRuntime(args({ cacheRoot: path.join(temp, `${label}-cache`) })), /unsafe archive path|unsupported link or special entry/);
232
+ assert.deepEqual(await readdir(path.join(temp, `${label}-cache`, '.staging')), []);
233
+ });
234
+ }
235
+ });
236
+
237
+ test('rejects malformed release coordinates before requesting any asset', async () => {
238
+ gets = 0;
239
+ await assert.rejects(acquireOwnedRuntime(args({ releaseVersion: '../latest' })), /exact semantic version/);
240
+ await assert.rejects(acquireOwnedRuntime(args({ platform: 'plan9' })), /Unsupported/);
241
+ assert.equal(gets, 0);
242
+ });
243
+
244
+ test('serializes concurrent acquisition and publishes a single verified cache entry', async () => {
245
+ setArchive(makeArchive()); gets = 0;
246
+ const sharedCache = path.join(temp, 'parallel-cache');
247
+ const [first, second] = await Promise.all([
248
+ acquireOwnedRuntime(args({ cacheRoot: sharedCache })),
249
+ acquireOwnedRuntime(args({ cacheRoot: sharedCache })),
250
+ ]);
251
+ assert.equal(first, second);
252
+ assert.equal(gets, 1);
253
+ assert.deepEqual((await readdir(path.join(sharedCache, version, targetKey))).sort(), ['SHA256SUMS', 'cli-linux-x64', 'ctg-runtime.json']);
254
+ });
255
+
256
+ test('cleans staging after a failed HTTP request and reports no fallback', async () => {
257
+ assets.clear();
258
+ await assert.rejects(acquireOwnedRuntime(args({ cacheRoot: path.join(temp, 'http-fail-cache') })), /no system Carthagent runtime fallback was attempted/);
259
+ assert.deepEqual(await readdir(path.join(temp, 'http-fail-cache', '.staging')), []);
260
+ });
261
+
262
+ test('cleans staging after a truncated transfer', async () => {
263
+ setArchive(makeArchive());
264
+ truncateResponse = true;
265
+ try {
266
+ await assert.rejects(acquireOwnedRuntime(args({ cacheRoot: path.join(temp, 'truncated-cache') })), /no system Carthagent runtime fallback was attempted/);
267
+ assert.deepEqual(await readdir(path.join(temp, 'truncated-cache', '.staging')), []);
268
+ } finally { truncateResponse = false; }
269
+ });
@@ -0,0 +1,231 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { EventEmitter } from 'node:events';
4
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { PassThrough } from 'node:stream';
8
+ import { pathToFileURL } from 'node:url';
9
+ import test from 'node:test';
10
+ import { CTG_RUNTIME_PATCH_SHA256, readRuntimeReleaseManifest } from '../lib/opencode/runtime.mjs';
11
+ import { preflightNativeTestRuntimeArtifact } from '../tools/native-runtime-identity.mjs';
12
+
13
+ const developmentPatch = 'd'.repeat(64);
14
+ const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
15
+ const executableName = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
16
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
17
+
18
+ // Rewrite just the development pin in a private package copy. Release and
19
+ // development are intentionally distinct even while the checked-in pins match.
20
+ // No process environment or built-in module mocks affect other test suites.
21
+ async function fixture(t, patchSha256 = developmentPatch) {
22
+ const root = await mkdtemp(path.join(tmpdir(), 'ctg-development-trust-'));
23
+ t.after(() => rm(root, { recursive: true, force: true }));
24
+ const packageRoot = path.join(root, 'package');
25
+ const moduleRoot = path.join(packageRoot, 'lib/opencode');
26
+ await mkdir(moduleRoot, { recursive: true });
27
+ for (const name of ['runtime.mjs', 'runtime-artifact.mjs', 'runtime-github-transport.mjs']) {
28
+ let source = await readFile(new URL(`../lib/opencode/${name}`, import.meta.url), 'utf8');
29
+ if (name === 'runtime.mjs') {
30
+ const declaration = /export const CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 = '[a-f0-9]{64}';/g;
31
+ assert.equal([...source.matchAll(declaration)].length, 1);
32
+ source = source.replace(declaration, `export const CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 = '${developmentPatch}';`);
33
+ }
34
+ await writeFile(path.join(moduleRoot, name), source);
35
+ }
36
+ await writeFile(path.join(packageRoot, 'package.json'), await readFile(new URL('../package.json', import.meta.url)));
37
+ const runtime = await import(pathToFileURL(path.join(moduleRoot, 'runtime.mjs')).href);
38
+ assert.notEqual(runtime.CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, runtime.CTG_RUNTIME_PATCH_SHA256);
39
+ const ownedRoot = path.join(root, 'owned');
40
+ const relativeExecutable = `cli-${target}/bin/${executableName}`;
41
+ const binary = path.join(ownedRoot, relativeExecutable);
42
+ const payload = Buffer.from('independently hashed runtime fixture');
43
+ const digest = sha256(payload);
44
+ const manifest = {
45
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode',
46
+ sourceRevision: runtime.CTG_RUNTIME_SOURCE, lockSha256: runtime.CTG_RUNTIME_LOCK_SHA256,
47
+ patchSha256, compatibilityVersion: runtime.OPENCODE_VERSION,
48
+ runtimeVersion: runtime.CTG_RUNTIME_VERSION, channel: `ctg.${runtime.OPENCODE_VERSION}`,
49
+ platform: target, webUI: false,
50
+ };
51
+ await mkdir(path.dirname(binary), { recursive: true });
52
+ await writeFile(binary, payload, { mode: 0o755 });
53
+ const writeManifest = value => writeFile(path.join(ownedRoot, 'ctg-runtime.json'), JSON.stringify(value));
54
+ const writeSums = (hash = digest, name = relativeExecutable) => writeFile(path.join(ownedRoot, 'SHA256SUMS'), `${hash} ${name}\n`);
55
+ await writeManifest(manifest);
56
+ await writeSums();
57
+ const release = JSON.parse(await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url), 'utf8'));
58
+ for (const asset of Object.values(release.assets)) asset.executableSha256 = digest;
59
+ const releasePath = path.join(moduleRoot, 'runtime-release.json');
60
+ await writeFile(releasePath, JSON.stringify(release));
61
+ const calls = [];
62
+ const spawns = [];
63
+ const spawnVersion = version => (command, args, options) => {
64
+ spawns.push({ command, args, options });
65
+ const child = new EventEmitter();
66
+ child.stdout = new PassThrough();
67
+ child.stderr = new PassThrough();
68
+ child.kill = () => {};
69
+ queueMicrotask(() => {
70
+ child.stdout.end(`${version}\n`);
71
+ child.stderr.end();
72
+ child.emit('close', 0);
73
+ });
74
+ return child;
75
+ };
76
+ const spawnImpl = spawnVersion(`opencode v${runtime.CTG_RUNTIME_VERSION}`);
77
+ const publicOptions = {
78
+ spawnImpl,
79
+ acquireOwnedRuntime: async input => { calls.push(input); return ownedRoot; },
80
+ };
81
+ return { root, runtime, ownedRoot, binary, payload, digest, relativeExecutable, manifest, release, releasePath,
82
+ writeManifest, writeSums, calls, spawns, spawnVersion, spawnImpl, publicOptions };
83
+ }
84
+
85
+ test('packaged release trust stays valid independently of the development patch pin', async t => {
86
+ assert.equal(CTG_RUNTIME_PATCH_SHA256, 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975');
87
+ const published = await readRuntimeReleaseManifest();
88
+ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
89
+ assert.equal(published.releaseVersion, packageJson.version);
90
+ const f = await fixture(t, CTG_RUNTIME_PATCH_SHA256);
91
+ assert.deepEqual(await f.runtime.readRuntimeReleaseManifest(f.releasePath), f.release);
92
+ assert.equal(await f.runtime.resolveOpenCode(f.publicOptions), f.binary);
93
+ const [acquisition] = f.calls;
94
+ assert.equal(acquisition.expectedPatchSha256, CTG_RUNTIME_PATCH_SHA256);
95
+ assert.equal(acquisition.expectedSourceRevision, f.runtime.CTG_RUNTIME_SOURCE);
96
+ assert.equal(acquisition.expectedLockSha256, f.runtime.CTG_RUNTIME_LOCK_SHA256);
97
+ assert.equal(acquisition.expectedArchiveSha256, f.release.assets[target].archiveSha256);
98
+ assert.equal(acquisition.expectedExecutableSha256, f.digest);
99
+ assert.equal(acquisition.expectedCompatibilityVersion, f.runtime.OPENCODE_VERSION);
100
+ assert.equal(acquisition.expectedRuntimeVersion, f.runtime.CTG_RUNTIME_VERSION);
101
+ assert.equal(acquisition.expectedChannel, f.manifest.channel);
102
+ assert.equal(acquisition.baseUrl, `https://github.com/B-icy/Carthagent/releases/download/v${packageJson.version}`);
103
+ const identity = await f.runtime.runtimeIdentity(f.binary, f.publicOptions);
104
+ assert.equal(identity.kind, 'ctg-owned');
105
+ assert.equal(identity.patchSha256, CTG_RUNTIME_PATCH_SHA256);
106
+ assert.equal(f.publicOptions.ownedRuntime, undefined);
107
+ });
108
+
109
+ test('default acquisition rejects development provenance even with matching pinned executable bytes', async t => {
110
+ const f = await fixture(t);
111
+ await assert.rejects(f.runtime.resolveOpenCode(f.publicOptions), /not compatible with the pinned/);
112
+ assert.equal(f.calls[0].expectedPatchSha256, CTG_RUNTIME_PATCH_SHA256);
113
+ assert.equal(f.spawns.length, 0, 'development provenance is rejected before executing --version');
114
+ });
115
+
116
+ test('public release manifest cannot promote the development pin or unpinned provenance', async t => {
117
+ const f = await fixture(t);
118
+ for (const changes of [
119
+ { patchSha256: developmentPatch }, { sourceRevision: 'e'.repeat(40) },
120
+ { lockSha256: 'e'.repeat(64) }, { runtimeVersion: '0.0.0-ctg.2.0.33' },
121
+ { compatibilityVersion: '2.0.33' }, { releaseVersion: '1.1.0-preview.14' },
122
+ ]) {
123
+ await writeFile(f.releasePath, JSON.stringify({ ...f.release, ...changes }));
124
+ await assert.rejects(f.runtime.resolveOpenCode(f.publicOptions), /Invalid or incompatible.*release manifest/);
125
+ }
126
+ assert.equal(f.calls.length, 0, 'invalid release metadata never reaches acquisition');
127
+ assert.equal(f.spawns.length, 0);
128
+ });
129
+
130
+ test('default resolution rechecks the release executable pin, not a rewritten adjacent SHA256SUMS', async t => {
131
+ const f = await fixture(t, CTG_RUNTIME_PATCH_SHA256);
132
+ assert.equal(await f.runtime.resolveOpenCode(f.publicOptions), f.binary);
133
+ await writeFile(f.binary, 'development executable bytes');
134
+ await f.writeSums(sha256('development executable bytes'));
135
+ await assert.rejects(f.runtime.resolveOpenCode(f.publicOptions), /pinned executable SHA-256 mismatch/);
136
+ assert.equal(f.calls.length, 2);
137
+ assert.equal(f.spawns.length, 1, 'changed bytes rejected before a second version execution');
138
+ assert.equal(f.publicOptions.ownedRuntime, undefined);
139
+ });
140
+
141
+ test('explicit roots accept release and development provenance without consulting public release metadata', async t => {
142
+ for (const patchSha256 of [CTG_RUNTIME_PATCH_SHA256, developmentPatch]) {
143
+ const f = await fixture(t, patchSha256);
144
+ // An explicit root does not turn this adjacent test record into a pin.
145
+ await writeFile(f.releasePath, JSON.stringify({ patchSha256: 'f'.repeat(64) }));
146
+ const options = { ...f.publicOptions, ownedRuntime: f.ownedRoot };
147
+ assert.equal(await f.runtime.resolveOpenCode(options), f.binary);
148
+ assert.equal(await f.runtime.resolveOwnedOpenCode(f.ownedRoot, { spawnImpl: f.spawnImpl }), f.binary);
149
+ const identity = await f.runtime.runtimeIdentity(f.binary, options);
150
+ assert.equal(identity.patchSha256, patchSha256);
151
+ assert.equal(identity.binarySha256, f.digest);
152
+ assert.equal(identity.kind, 'ctg-owned');
153
+ assert.equal(f.calls.length, 0);
154
+ }
155
+ });
156
+
157
+ test('explicit development roots retain every pinned provenance and host-platform check', async t => {
158
+ const f = await fixture(t);
159
+ for (const [field, value, error] of [
160
+ ['owner', 'Other', /not compatible/],
161
+ ['sourceRepository', 'https://example.test/opencode', /not compatible/],
162
+ ['sourceRevision', 'f'.repeat(40), /not compatible/],
163
+ ['patchSha256', 'f'.repeat(64), /not compatible/],
164
+ ['lockSha256', 'f'.repeat(64), /not compatible/],
165
+ ['compatibilityVersion', '2.0.33', /not compatible/],
166
+ ['runtimeVersion', '0.0.0-ctg.2.0.33', /not compatible/],
167
+ ['channel', 'latest', /not compatible/],
168
+ ['webUI', true, /not compatible/],
169
+ ['platform', 'other-platform', /does not match this host/],
170
+ ]) {
171
+ await f.writeManifest({ ...f.manifest, [field]: value });
172
+ // A matching adjacent "trusted" record must not bless arbitrary metadata.
173
+ await writeFile(f.releasePath, JSON.stringify({ ...f.release, [field]: value }));
174
+ await assert.rejects(f.runtime.resolveOwnedOpenCode(f.ownedRoot, { spawnImpl: f.spawnImpl }), error, field);
175
+ }
176
+ assert.equal(f.spawns.length, 0);
177
+ });
178
+
179
+ test('explicit development roots require exact executable layout, actual hash, checksum entry, and version', async t => {
180
+ const f = await fixture(t);
181
+ const options = { ownedRuntime: f.ownedRoot, spawnImpl: f.spawnImpl };
182
+ await writeFile(f.binary, 'tampered bytes');
183
+ await assert.rejects(f.runtime.resolveOpenCode(options), /executable SHA-256 mismatch/);
184
+ await writeFile(f.binary, f.payload);
185
+ await f.writeSums(f.digest, `cli-${target}/bin/not-opencode`);
186
+ await assert.rejects(f.runtime.resolveOpenCode(options), /checksum does not list/);
187
+ await rm(path.join(f.ownedRoot, 'SHA256SUMS'));
188
+ await assert.rejects(f.runtime.resolveOpenCode(options), /checksums are missing/);
189
+ await f.writeSums();
190
+ await writeFile(path.join(path.dirname(f.binary), 'not-opencode'), f.payload);
191
+ await rm(f.binary);
192
+ await assert.rejects(f.runtime.resolveOpenCode(options), /executable is missing/);
193
+ assert.equal(f.spawns.length, 0);
194
+ await writeFile(f.binary, f.payload);
195
+ for (const version of ['opencode v2.0.32', 'opencode v9.0.0-ctg.2.0.32', `opencode v${f.runtime.CTG_RUNTIME_VERSION} extra`]) {
196
+ await assert.rejects(f.runtime.resolveOpenCode({ ...options, spawnImpl: f.spawnVersion(version) }), /identity mismatch/);
197
+ }
198
+ });
199
+
200
+ test('explicit binary remains upstream-compatible and takes precedence over an owned root', async t => {
201
+ const f = await fixture(t);
202
+ await f.writeManifest({ ...f.manifest, patchSha256: 'f'.repeat(64) });
203
+ const options = { binary: f.binary, ownedRuntime: f.ownedRoot,
204
+ spawnImpl: f.spawnVersion(`opencode v${f.runtime.OPENCODE_VERSION}`) };
205
+ assert.equal(await f.runtime.resolveOpenCode(options), f.binary);
206
+ const identity = await f.runtime.runtimeIdentity(f.binary, options);
207
+ assert.equal(identity.kind, 'upstream-compatible');
208
+ assert.equal(identity.version, f.runtime.OPENCODE_VERSION);
209
+ });
210
+
211
+ test('native preflight requires independently supplied development provenance and bytes, not released metadata', async t => {
212
+ const f = await fixture(t);
213
+ const testManifestPath = path.join(f.root, 'trusted-development-test.json');
214
+ const options = { ownedRoot: f.ownedRoot, expectedManifestPath: testManifestPath, env: {}, spawnImpl: f.spawnImpl };
215
+ await writeFile(testManifestPath, JSON.stringify(f.release));
216
+ await assert.rejects(preflightNativeTestRuntimeArtifact(options), /patchSha256 mismatch/);
217
+ assert.equal(f.spawns.length, 0);
218
+ const developmentManifest = { ...f.release, patchSha256: developmentPatch };
219
+ await writeFile(testManifestPath, JSON.stringify(developmentManifest));
220
+ const result = await preflightNativeTestRuntimeArtifact(options);
221
+ assert.deepEqual(result.expectedManifest, developmentManifest);
222
+ assert.equal(result.identity.patchSha256, developmentPatch);
223
+ assert.equal(result.identity.binarySha256, f.digest);
224
+ assert.equal(await f.runtime.resolveOpenCode({ ownedRuntime: f.ownedRoot, spawnImpl: f.spawnImpl }), f.binary);
225
+ await writeFile(testManifestPath, JSON.stringify({ ...developmentManifest, patchSha256: 'f'.repeat(64) }));
226
+ await assert.rejects(preflightNativeTestRuntimeArtifact({ ...options, env: { CTG_TEST_RUNTIME_SHA256: f.digest } }), /patchSha256 mismatch/);
227
+ await writeFile(testManifestPath, JSON.stringify(developmentManifest));
228
+ await writeFile(f.binary, 'unreviewed rebuild');
229
+ await assert.rejects(preflightNativeTestRuntimeArtifact(options), /binary SHA-256 mismatch/);
230
+ assert.equal((await readRuntimeReleaseManifest()).patchSha256, CTG_RUNTIME_PATCH_SHA256);
231
+ });
@@ -0,0 +1,132 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createRuntimeGithubFetch } from '../lib/opencode/runtime-github-transport.mjs';
4
+
5
+ const token = 'fixture-token-never-real';
6
+ const base = 'https://github.com/B-icy/Carthagent/releases/download/v9.8.7';
7
+ const api = 'https://api.github.com/repos/B-icy/Carthagent';
8
+ const runtimeAsset = 'opencode-linux-x64.tar.gz';
9
+
10
+ test('draft transport uses bounded paginated exact-tag and exact-asset API GETs', async () => {
11
+ const requests = [];
12
+ const fetchImpl = async (input, init) => {
13
+ const url = new URL(input);
14
+ requests.push({ url, init });
15
+ if (url.pathname === '/repos/B-icy/Carthagent/releases') {
16
+ if (url.searchParams.get('page') === '1') return Response.json(Array.from({ length: 100 }, (_, index) => ({ id: index + 1, tag_name: `v0.0.${index}` })));
17
+ return Response.json([{ id: 88, tag_name: 'v9.8.7' }]);
18
+ }
19
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/88/assets') {
20
+ if (url.searchParams.get('page') === '1') return Response.json(Array.from({ length: 100 }, (_, index) => ({ id: index + 200, name: `other-${index}` })));
21
+ return Response.json([{ id: 777, name: runtimeAsset, state: 'uploaded', size: 5, url: `${api}/releases/assets/777` }]);
22
+ }
23
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/assets/777') return new Response('bytes');
24
+ assert.fail(`Unexpected URL ${url.href}`);
25
+ };
26
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl });
27
+ const response = await fetch(`${base}/${runtimeAsset}`, { redirect: 'manual' });
28
+ assert.equal(await response.text(), 'bytes');
29
+ assert.equal(requests.length, 5);
30
+ assert.deepEqual(requests.map(({ url }) => url.searchParams.get('page')), ['1', '2', '1', '2', null]);
31
+ for (const { init } of requests) {
32
+ assert.equal(init.method, 'GET');
33
+ assert.equal(init.headers.authorization, `Bearer ${token}`);
34
+ assert.equal(init.redirect, 'manual');
35
+ }
36
+ });
37
+
38
+ test('asset redirect is fetched without forwarding GitHub authorization', async () => {
39
+ const requests = [];
40
+ const fetchImpl = async (input, init) => {
41
+ const url = new URL(input);
42
+ requests.push({ url, init });
43
+ if (url.pathname === '/repos/B-icy/Carthagent/releases') return Response.json([{ id: 88, tag_name: 'v9.8.7' }]);
44
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/88/assets') return Response.json([{ id: 777, name: runtimeAsset, state: 'uploaded', size: 5, url: `${api}/releases/assets/777` }]);
45
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/assets/777') return new Response(null, { status: 302, headers: { location: 'https://cdn.example.test/signed?id=1' } });
46
+ if (url.origin === 'https://cdn.example.test') return new Response('bytes');
47
+ assert.fail(`Unexpected URL ${url.href}`);
48
+ };
49
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl });
50
+ const first = await fetch(`${base}/${runtimeAsset}`, { redirect: 'manual' });
51
+ assert.equal(first.status, 302);
52
+ const redirected = await fetch(first.headers.get('location'), { redirect: 'manual' });
53
+ assert.equal(await redirected.text(), 'bytes');
54
+ assert.equal(requests.length, 4);
55
+ assert.ok(requests.slice(0, 3).every(({ init }) => init.headers.authorization === `Bearer ${token}`));
56
+ assert.equal(requests[3].init.headers?.authorization, undefined);
57
+ });
58
+
59
+ test('explicit transport fails closed for absent, duplicate, malformed, or unavailable exact release', async () => {
60
+ const cases = [
61
+ { releases: [], message: /release was not found/ },
62
+ { releases: [{ id: 1, tag_name: 'v9.8.7' }, { id: 2, tag_name: 'v9.8.7' }], message: /duplicate exact-version/ },
63
+ { releases: [{ id: '88', tag_name: 'v9.8.7' }], message: /invalid exact-version/ },
64
+ { failure: true, message: /failed before a response/ },
65
+ ];
66
+ for (const item of cases) {
67
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl: async () => {
68
+ if (item.failure) throw new Error(`request leaked ${token}`);
69
+ return Response.json(item.releases);
70
+ } });
71
+ await assert.rejects(fetch(`${base}/${runtimeAsset}`), (error) => {
72
+ assert.match(error.message, item.message);
73
+ assert.equal(error.message.includes(token), false);
74
+ return true;
75
+ });
76
+ }
77
+ });
78
+
79
+ test('rejects unexpected and duplicate exact assets without downloading bytes', async () => {
80
+ for (const [assets, message] of [
81
+ [[], /asset .* was not found/],
82
+ [[777, 778].map((id) => ({ id, name: runtimeAsset, state: 'uploaded', size: 5, url: `${api}/releases/assets/${id}` })), /duplicate .* assets/],
83
+ ]) {
84
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl: async (input) => {
85
+ const url = new URL(input);
86
+ if (url.pathname === '/repos/B-icy/Carthagent/releases') return Response.json([{ id: 88, tag_name: 'v9.8.7' }]);
87
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/88/assets') return Response.json(assets);
88
+ assert.fail('asset body must not be requested for invalid metadata');
89
+ } });
90
+ await assert.rejects(fetch(`${base}/${runtimeAsset}`), message);
91
+ }
92
+ });
93
+
94
+ test('rejects malformed asset id, URL, state, and size before requesting the asset body', async () => {
95
+ for (const asset of [
96
+ { id: 0, url: `${api}/releases/assets/0`, state: 'uploaded', size: 5 },
97
+ { id: 777, url: 'https://attacker.example/asset', state: 'uploaded', size: 5 },
98
+ { id: 777, url: `${api}/releases/assets/777`, state: 'starter', size: 5 },
99
+ { id: 777, url: `${api}/releases/assets/777`, state: 'uploaded', size: -1 },
100
+ ]) {
101
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl: async (input) => {
102
+ const url = new URL(input);
103
+ if (url.pathname === '/repos/B-icy/Carthagent/releases') return Response.json([{ id: 88, tag_name: 'v9.8.7' }]);
104
+ if (url.pathname === '/repos/B-icy/Carthagent/releases/88/assets') return Response.json([{ ...asset, name: runtimeAsset }]);
105
+ assert.fail('malformed asset metadata must block the binary request');
106
+ } });
107
+ await assert.rejects(fetch(`${base}/${runtimeAsset}`), /invalid metadata/);
108
+ }
109
+ });
110
+
111
+ test('rejects API metadata redirects without following or forwarding authorization', async () => {
112
+ const calls = [];
113
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl: async (input, init) => {
114
+ calls.push({ url: String(input), init });
115
+ return new Response(null, { status: 302, headers: { location: 'https://api.github.com/elsewhere' } });
116
+ } });
117
+ await assert.rejects(fetch(`${base}/${runtimeAsset}`), /unexpectedly redirected/);
118
+ assert.equal(calls.length, 1);
119
+ assert.equal(calls[0].init.headers.authorization, `Bearer ${token}`);
120
+ });
121
+
122
+ test('only canonical fixed-repository runtime asset URLs enter authenticated transport', async () => {
123
+ const calls = [];
124
+ const fetch = createRuntimeGithubFetch({ token, fetchImpl: async (url, init) => {
125
+ calls.push({ url: String(url), init });
126
+ return new Response('ordinary');
127
+ } });
128
+ await fetch('https://example.test/asset');
129
+ await fetch(`${base}/untrusted.tar.gz`);
130
+ assert.equal(calls.length, 2);
131
+ assert.ok(calls.every(({ init }) => init.headers?.authorization === undefined));
132
+ });
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { withoutRuntimeGithubToken } from '../tools/runtime-token-env.mjs';
4
+
5
+ test('npm child environment removes runtime download token case-insensitively and preserves PATH', () => {
6
+ const original = {
7
+ PATH: '/fixture/path',
8
+ Path: '/fixture/windows-path',
9
+ LANG: 'C.UTF-8',
10
+ CTG_RUNTIME_GITHUB_TOKEN: 'fake-uppercase-token',
11
+ ctg_runtime_github_token: 'fake-lowercase-token',
12
+ Ctg_Runtime_Github_Token: 'fake-mixedcase-token',
13
+ CTG_RUNTIME_OTHER: 'unrelated-runtime-value',
14
+ };
15
+ const result = withoutRuntimeGithubToken(original);
16
+ assert.deepEqual(result, {
17
+ PATH: '/fixture/path',
18
+ Path: '/fixture/windows-path',
19
+ LANG: 'C.UTF-8',
20
+ CTG_RUNTIME_OTHER: 'unrelated-runtime-value',
21
+ });
22
+ assert.equal(original.ctg_runtime_github_token, 'fake-lowercase-token', 'sanitizer must not mutate the caller environment');
23
+ });