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
@@ -10,6 +10,7 @@ import {
10
10
  fetchLatestRelease,
11
11
  latestReleaseCached,
12
12
  normalizeRelease,
13
+ npmInstallSpec,
13
14
  staleGlobalInstallPaths,
14
15
  updateCheckEnabled,
15
16
  } from '../lib/update.mjs';
@@ -82,6 +83,16 @@ test('normalizeRelease rejects install specs that do not install the release tag
82
83
  assert.equal(normalizeRelease(manifest).installSpec, 'github:B-icy/Carthagent#v0.3.0');
83
84
  });
84
85
 
86
+ test('normalizeRelease binds the install ref exactly to the validated version', () => {
87
+ for (const tag of ['main', 'v0.3.1', 'v0.3.0%2Fmain', ' v0.3.0', 'v0.3.0\n']) {
88
+ assert.equal(normalizeRelease({ version: '0.3.0', tag }), null, `rejected tag ${JSON.stringify(tag)}`);
89
+ }
90
+ assert.equal(normalizeRelease({ version: '1.2.3-beta.2', tag_name: 'v1.2.3-beta.2' })?.installSpec,
91
+ 'github:B-icy/Carthagent#v1.2.3-beta.2');
92
+ assert.equal(normalizeRelease({ version: '1.2.3-beta.2', tag_name: 'v1.2.3' }), null);
93
+ assert.equal(normalizeRelease({ version: '01.2.3', tag: 'v01.2.3' }), null);
94
+ });
95
+
85
96
  test('fetchLatestRelease falls back to GitHub when the manifest fails validation', async () => {
86
97
  // A control-plane 200 whose installSpec is rejected must not dead-end the
87
98
  // update — the GitHub release still resolves.
@@ -93,13 +104,30 @@ test('fetchLatestRelease falls back to GitHub when the manifest fails validation
93
104
  }
94
105
  return { status: 200, json: async () => ({ tag_name: 'v0.3.1', published_at: '2026-03-01T00:00:00Z' }) };
95
106
  };
96
- const { release, source } = await fetchLatestRelease({ cloudUrl: 'https://cloud.test', fetchImpl });
107
+ const { release, source } = await fetchLatestRelease({ cloudUrl: 'https://cloud.test', fetchImpl, channel: 'stable' });
97
108
  assert.equal(source, 'github');
98
109
  assert.equal(release.version, '0.3.1');
99
110
  assert.equal(release.installSpec, 'github:B-icy/Carthagent#v0.3.1');
100
111
  assert.equal(urls.length, 2);
101
112
  });
102
113
 
114
+ test('fetchLatestRelease forwards cancellation and does not fall back after an abort', async () => {
115
+ const controller = new AbortController();
116
+ let calls = 0;
117
+ await assert.rejects(fetchLatestRelease({
118
+ cloudUrl: 'https://cloud.test',
119
+ channel: 'stable',
120
+ signal: controller.signal,
121
+ fetchImpl: async (_url, options) => {
122
+ calls++;
123
+ assert.equal(options.signal.aborted, false);
124
+ controller.abort(new Error('cancelled'));
125
+ throw controller.signal.reason;
126
+ },
127
+ }), /cancelled/);
128
+ assert.equal(calls, 1, 'an aborted control-plane request must not trigger GitHub fallback');
129
+ });
130
+
103
131
  // ---- latestReleaseCached
104
132
 
105
133
  test('latestReleaseCached fetches once, then serves the cache', () => withTempDir(async dir => {
@@ -108,11 +136,11 @@ test('latestReleaseCached fetches once, then serves the cache', () => withTempDi
108
136
  const fetchImpl = async () => { calls++; return { status: 200, json: async () => manifest }; };
109
137
  // Pinned clock: a slow runner must not outrun the cache TTL mid-test.
110
138
  const t0 = Date.now();
111
- const first = await latestReleaseCached({ cachePath, intervalMs: 1000, now: t0, fetchImpl });
139
+ const first = await latestReleaseCached({ cachePath, intervalMs: 1000, now: t0, fetchImpl, channel: 'stable' });
112
140
  assert.equal(first.release.version, '0.3.0');
113
141
  assert.equal(first.fresh, false);
114
142
  assert.equal(calls, 1);
115
- const second = await latestReleaseCached({ cachePath, intervalMs: 1000, now: t0 + 500, fetchImpl });
143
+ const second = await latestReleaseCached({ cachePath, intervalMs: 1000, now: t0 + 500, fetchImpl, channel: 'stable' });
116
144
  assert.equal(second.release.version, '0.3.0');
117
145
  assert.equal(second.fresh, true);
118
146
  assert.equal(calls, 1);
@@ -121,13 +149,13 @@ test('latestReleaseCached fetches once, then serves the cache', () => withTempDi
121
149
  test('latestReleaseCached returns stale data when refresh fails', () => withTempDir(async dir => {
122
150
  const cachePath = join(dir, 'update-check.json');
123
151
  writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now() - 60_000, release: manifest }));
124
- const result = await latestReleaseCached({ cachePath, intervalMs: 1000, fetchImpl: async () => { throw new Error('offline'); } });
152
+ const result = await latestReleaseCached({ cachePath, intervalMs: 1000, channel: 'stable', fetchImpl: async () => { throw new Error('offline'); } });
125
153
  assert.equal(result.release.version, '0.3.0');
126
154
  assert.equal(result.fresh, false);
127
155
  }));
128
156
 
129
157
  test('latestReleaseCached swallows a cold-cache failure', () => withTempDir(async dir => {
130
- const result = await latestReleaseCached({ cachePath: join(dir, 'update-check.json'), intervalMs: 1000, fetchImpl: async () => { throw new Error('offline'); } });
158
+ const result = await latestReleaseCached({ cachePath: join(dir, 'update-check.json'), intervalMs: 1000, channel: 'stable', fetchImpl: async () => { throw new Error('offline'); } });
131
159
  assert.equal(result.release, null);
132
160
  }));
133
161
 
@@ -147,6 +175,15 @@ test('detectInstallMethod distinguishes clones from packed installs', () => with
147
175
  assert.equal(detectInstallMethod(dir), 'clone');
148
176
  }));
149
177
 
178
+ test('npmInstallSpec prefers an exact registry version and falls back when lookup fails', async () => {
179
+ const release = { version: '1.2.3', installSpec: 'github:B-icy/Carthagent#v1.2.3' };
180
+ let lookedUp;
181
+ assert.equal(await npmInstallSpec(release, async spec => { lookedUp = spec; return true; }), 'carthagent@1.2.3');
182
+ assert.equal(lookedUp, 'carthagent@1.2.3');
183
+ assert.equal(await npmInstallSpec(release, async () => false), release.installSpec);
184
+ assert.equal(await npmInstallSpec(release, async () => { throw new Error('npm unavailable'); }), release.installSpec);
185
+ });
186
+
150
187
  // ---- staleGlobalInstallPaths / cleanStaleGlobalInstall
151
188
 
152
189
  test('staleGlobalInstallPaths flags non-directory carthagent and stash leftovers', () => withTempDir(dir => {
@@ -0,0 +1,280 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { REQUIRED_RELEASE_ASSETS, verifyDraftRuntimeGate } from '../tools/verify-draft-runtime-gate.mjs';
9
+
10
+ const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
11
+ const tag = `v${version}`;
12
+ const sha = 'a'.repeat(40);
13
+ const producerReleaseAssets = [
14
+ 'opencode-linux-x64.tar.gz',
15
+ 'opencode-linux-arm64.tar.gz',
16
+ 'opencode-darwin-x64.tar.gz',
17
+ 'opencode-darwin-arm64.tar.gz',
18
+ 'opencode-windows-x64.tar.gz',
19
+ 'runtime-release.json',
20
+ 'runtime-release-SHA256SUMS.txt',
21
+ ].map((name) => ({ name }));
22
+ const release = { id: 77, tag_name: tag, draft: true, assets: producerReleaseAssets };
23
+
24
+ function mockRun({ releaseValue = release, remoteSha = sha, checkoutSha = sha, annotated = false } = {}) {
25
+ const calls = [];
26
+ const run = (command, args) => {
27
+ calls.push([command, args]);
28
+ if (command === 'git' && args[0] === 'rev-parse') return `${checkoutSha}\n`;
29
+ if (command !== 'gh') throw new Error(`Unexpected fake command ${command} ${args.join(' ')}`);
30
+ if (args[0] === 'api' && args[1] === `repos/B-icy/Carthagent/git/ref/tags/${tag}`) return JSON.stringify({ object: { type: annotated ? 'tag' : 'commit', sha: annotated ? 'c'.repeat(40) : remoteSha } });
31
+ if (args[0] === 'api' && args[1] === `repos/B-icy/Carthagent/git/tags/${'c'.repeat(40)}`) return JSON.stringify({ object: { type: 'commit', sha: remoteSha } });
32
+ if (args[0] === 'api' && args[1] === '--paginate' && args[2] === '--slurp' && args[3] === 'repos/B-icy/Carthagent/releases?per_page=100') return JSON.stringify([[releaseValue]]);
33
+ throw new Error(`Unexpected fake GitHub API call ${args.join(' ')}`);
34
+ };
35
+ return { run, calls };
36
+ }
37
+
38
+ test('accepts only the exact draft and returns its release ID and pinned commit', async () => {
39
+ const { run, calls } = mockRun();
40
+ const result = await verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture', run });
41
+ assert.deepEqual(result, { releaseId: 77, sha, version });
42
+ assert.equal(calls.filter(([command]) => command === 'gh').length, 2);
43
+ });
44
+
45
+ test('resolves an annotated tag object to the exact commit before acceptance', async () => {
46
+ const { run, calls } = mockRun({ annotated: true });
47
+ const result = await verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture', run });
48
+ assert.equal(result.sha, sha);
49
+ assert.equal(calls.some(([, args]) => args?.[1] === `repos/B-icy/Carthagent/git/tags/${'c'.repeat(40)}`), true);
50
+ });
51
+
52
+ test('rejects a malformed tag before invoking git or GitHub', async () => {
53
+ const { run, calls } = mockRun();
54
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: `not-${tag}`, repo: 'B-icy/Carthagent', run }), /must be exactly/);
55
+ assert.equal(calls.length, 0);
56
+ });
57
+
58
+ test('rejects a stale/moved tag before installed acceptance', async () => {
59
+ const { run, calls } = mockRun({ remoteSha: 'b'.repeat(40) });
60
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), /but this checkout is/);
61
+ assert.equal(calls.some(([command]) => command === 'git'), true);
62
+ });
63
+
64
+ test('rejects a published release before installed acceptance', async () => {
65
+ const { run, calls } = mockRun({ releaseValue: { ...release, draft: false } });
66
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', run }), /must remain a draft/);
67
+ assert.equal(calls.some(([, args]) => args?.[1]?.includes('/git/ref/')), false);
68
+ });
69
+
70
+ test('requires the exact seven-asset producer contract: five archives, manifest, and combined checksums', async () => {
71
+ const expectedAssets = [
72
+ 'opencode-linux-x64.tar.gz',
73
+ 'opencode-linux-arm64.tar.gz',
74
+ 'opencode-darwin-x64.tar.gz',
75
+ 'opencode-darwin-arm64.tar.gz',
76
+ 'opencode-windows-x64.tar.gz',
77
+ 'runtime-release.json',
78
+ 'runtime-release-SHA256SUMS.txt',
79
+ ];
80
+ assert.deepEqual(REQUIRED_RELEASE_ASSETS, expectedAssets);
81
+ const result = await verifyDraftRuntimeGate({
82
+ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', cwd: '/fixture',
83
+ run: mockRun({ releaseValue: { ...release, assets: expectedAssets.map((name) => ({ name })) } }).run,
84
+ });
85
+ assert.deepEqual(result, { releaseId: 77, sha, version });
86
+
87
+ for (const [label, assets, expected] of [
88
+ ['missing manifest', release.assets.filter(({ name }) => name !== 'runtime-release.json'), /exactly one runtime-release\.json asset/],
89
+ ['duplicate platform archive', [...release.assets, { name: REQUIRED_RELEASE_ASSETS[0] }], /exactly one opencode-linux-x64\.tar\.gz asset/],
90
+ ['unexpected extra asset', [...release.assets, { name: 'unreviewed-runtime.tar.gz' }], /unexpected assets: unreviewed-runtime\.tar\.gz/],
91
+ ['renamed checksum asset', release.assets.map((asset) => asset.name === 'runtime-release-SHA256SUMS.txt' ? { name: 'SHA256SUMS.txt' } : asset), /exactly one runtime-release-SHA256SUMS\.txt asset/],
92
+ ]) {
93
+ const { run } = mockRun({ releaseValue: { ...release, assets } });
94
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'before', releaseTag: tag, repo: 'B-icy/Carthagent', run }), expected, label);
95
+ }
96
+ });
97
+
98
+ test('the workflow precheck command fails before acceptance for malformed tag, moved tag, or published release', async (t) => {
99
+ const temp = await mkdtemp(path.join(tmpdir(), 'ctg-draft-gate-fake-gh-'));
100
+ t.after(() => rm(temp, { recursive: true, force: true }));
101
+ const requestsLog = path.join(temp, 'gh-requests');
102
+ const releaseFile = path.join(temp, 'release.json');
103
+ const targetFile = path.join(temp, 'target-sha');
104
+ const preloadFile = path.join(temp, 'fake-gh-preload.mjs');
105
+ const currentSha = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim();
106
+ await writeFile(targetFile, currentSha);
107
+ await writeFile(releaseFile, JSON.stringify(release));
108
+ await writeFile(preloadFile, `
109
+ import fs from 'node:fs';
110
+ import childProcess from 'node:child_process';
111
+ import { syncBuiltinESMExports } from 'node:module';
112
+ const originalSpawnSync = childProcess.spawnSync;
113
+ childProcess.spawnSync = function(command, args, options) {
114
+ if (command !== 'gh') return originalSpawnSync.call(this, command, args, options);
115
+ fs.appendFileSync(process.env.FAKE_GH_LOG, JSON.stringify(args) + '\\n');
116
+ let stdout;
117
+ if (args.some(value => value.includes('/releases?per_page=100'))) {
118
+ stdout = JSON.stringify([[JSON.parse(fs.readFileSync(process.env.FAKE_RELEASE_JSON, 'utf8'))]]);
119
+ } else if (args.some(value => value.includes('/git/ref/tags/'))) {
120
+ stdout = JSON.stringify({ object: { type: 'commit', sha: fs.readFileSync(process.env.FAKE_TARGET_SHA, 'utf8').trim() } });
121
+ } else {
122
+ return { status: 2, stdout: '', stderr: 'Unexpected fake GitHub API call', error: undefined };
123
+ }
124
+ return { status: 0, stdout, stderr: '', error: undefined };
125
+ };
126
+ syncBuiltinESMExports();
127
+ `);
128
+ const tool = fileURLToPath(new URL('../tools/verify-draft-runtime-gate.mjs', import.meta.url));
129
+ const invoke = (tagValue) => spawnSync(process.execPath, [tool, 'before', tagValue], {
130
+ cwd: path.resolve('.'), encoding: 'utf8',
131
+ env: {
132
+ ...process.env,
133
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${pathToFileURL(preloadFile).href}`.trim(),
134
+ GITHUB_REPOSITORY: 'B-icy/Carthagent', GITHUB_OUTPUT: path.join(temp, 'github-output'),
135
+ FAKE_GH_LOG: requestsLog, FAKE_RELEASE_JSON: releaseFile, FAKE_TARGET_SHA: targetFile,
136
+ },
137
+ });
138
+
139
+ const malformed = invoke('not-a-version');
140
+ assert.notEqual(malformed.status, 0);
141
+ await assert.rejects(readFile(requestsLog));
142
+
143
+ await writeFile(targetFile, 'b'.repeat(40));
144
+ const stale = invoke(tag);
145
+ assert.notEqual(stale.status, 0);
146
+ assert.match(stale.stderr, /but this checkout is/);
147
+
148
+ await writeFile(targetFile, currentSha);
149
+ await writeFile(releaseFile, JSON.stringify({ ...release, draft: false }));
150
+ const published = invoke(tag);
151
+ assert.notEqual(published.status, 0);
152
+ assert.match(published.stderr, /must remain a draft/);
153
+ });
154
+
155
+ test('trusted inline bootstrap accepts a main tag and rejects non-main dispatch and a foreign tag', async (t) => {
156
+ const workflow = (await readFile(new URL('../.github/workflows/accept-draft-release.yml', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
157
+ const bootstrap = workflow.match(/- name: Require main dispatch and a release tag descended from origin\/main[\s\S]*?\n run: \|\n((?: .*\n)+)/)?.[1];
158
+ const postcheck = workflow.match(/- name: Reverify release tag ancestry against approved main after acceptance[\s\S]*?\n run: \|\n((?: .*\n?)+?)(?=\n - |\n\s*$)/)?.[1];
159
+ assert.ok(bootstrap, 'trusted ancestry bootstrap block must be extractable');
160
+ assert.ok(postcheck, 'post-acceptance trusted ancestry block must be extractable');
161
+
162
+ const temp = await mkdtemp(path.join(tmpdir(), 'ctg-main-ancestry-'));
163
+ t.after(() => rm(temp, { recursive: true, force: true }));
164
+ const source = path.join(temp, 'source');
165
+ const bare = path.join(temp, 'origin.git');
166
+ const output = path.join(temp, 'github-output');
167
+ const fixtureVersion = '1.0.3';
168
+ const fixtureTag = `v${fixtureVersion}`;
169
+ await mkdir(source);
170
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim();
171
+ git(source, 'init', '-b', 'main');
172
+ git(source, 'config', 'user.email', 'gate-fixture@example.invalid');
173
+ git(source, 'config', 'user.name', 'Gate Fixture');
174
+ await writeFile(path.join(source, 'package.json'), JSON.stringify({ version: fixtureVersion }));
175
+ git(source, 'add', 'package.json');
176
+ git(source, 'commit', '-m', 'approved main release commit');
177
+ const approvedSha = git(source, 'rev-parse', 'HEAD');
178
+ git(source, 'tag', '-a', fixtureTag, '-m', 'approved annotated draft release', approvedSha);
179
+ execFileSync('git', ['init', '--bare', bare], { stdio: 'pipe' });
180
+ git(source, 'remote', 'add', 'origin', bare);
181
+ git(source, 'push', 'origin', 'main', '--tags');
182
+
183
+ const bashPath = (value) => {
184
+ if (process.platform !== 'win32') return value;
185
+ return execFileSync('cygpath', ['-u', value], { encoding: 'utf8' }).trim();
186
+ };
187
+ const invoke = (script, ref, expectedSha = '') => spawnSync('bash', ['-euo', 'pipefail', '-c', script], {
188
+ cwd: source, encoding: 'utf8',
189
+ env: {
190
+ ...process.env,
191
+ GITHUB_REF: ref,
192
+ GITHUB_OUTPUT: bashPath(output),
193
+ RELEASE_TAG: fixtureTag,
194
+ TRUSTED_REPOSITORY_URL: bashPath(bare),
195
+ EXPECTED_SHA: expectedSha,
196
+ },
197
+ });
198
+
199
+ const accepted = invoke(bootstrap, 'refs/heads/main');
200
+ assert.equal(accepted.status, 0, accepted.stderr);
201
+ assert.match(await readFile(output, 'utf8'), new RegExp(`sha=${approvedSha}`));
202
+ assert.equal(invoke(bootstrap, 'refs/heads/release-candidate').status, 1);
203
+ assert.equal(invoke(postcheck, 'refs/heads/main', approvedSha).status, 0);
204
+
205
+ git(source, 'checkout', '-b', 'foreign/release');
206
+ await writeFile(path.join(source, 'foreign.txt'), 'not in main');
207
+ git(source, 'add', 'foreign.txt');
208
+ git(source, 'commit', '-m', 'unapproved foreign release commit');
209
+ git(source, 'tag', '-f', fixtureTag, 'HEAD');
210
+ git(source, 'push', 'origin', `+refs/tags/${fixtureTag}:refs/tags/${fixtureTag}`);
211
+ const rejectedForeign = invoke(bootstrap, 'refs/heads/main');
212
+ assert.equal(rejectedForeign.status, 1, rejectedForeign.stderr);
213
+ assert.match(rejectedForeign.stderr, /must point to a commit descended from approved origin\/main/);
214
+ const rejectedMovedTag = invoke(postcheck, 'refs/heads/main', approvedSha);
215
+ assert.equal(rejectedMovedTag.status, 1, rejectedMovedTag.stderr);
216
+ assert.match(rejectedMovedTag.stderr, /moved during acceptance/);
217
+ });
218
+
219
+ test('post-check requires the same release ID and the pre-acceptance tested SHA', async () => {
220
+ const { run: goodRun } = mockRun();
221
+ assert.deepEqual(await verifyDraftRuntimeGate({ phase: 'after', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run: goodRun }), {
222
+ releaseId: 77, sha, version,
223
+ });
224
+ const { run: movedRun } = mockRun({ remoteSha: 'b'.repeat(40) });
225
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'after', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run: movedRun }), /but this checkout is/);
226
+ const { run: idRun } = mockRun({ releaseValue: { ...release, id: 78 } });
227
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'after', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', run: idRun }), /identity changed/);
228
+ });
229
+
230
+ test('immediate pre-acceptance recheck pins the original SHA and release ID', async () => {
231
+ const { run } = mockRun();
232
+ assert.deepEqual(await verifyDraftRuntimeGate({ phase: 'recheck', releaseTag: tag, expectedSha: sha, expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), {
233
+ releaseId: 77, sha, version,
234
+ });
235
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'recheck', releaseTag: tag, expectedSha: 'b'.repeat(40), expectedReleaseId: 77, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), /changed during acceptance/);
236
+ await assert.rejects(verifyDraftRuntimeGate({ phase: 'recheck', releaseTag: tag, expectedSha: sha, expectedReleaseId: 76, repo: 'B-icy/Carthagent', cwd: '/fixture', run }), /identity changed/);
237
+ });
238
+
239
+ test('draft workflow grants required push capability but performs only read operations', async () => {
240
+ const workflow = await readFile(new URL('../.github/workflows/accept-draft-release.yml', import.meta.url), 'utf8');
241
+ assert.match(workflow, /permissions:\s*\n\s+contents:\s+read/);
242
+ assert.match(workflow, /jobs:[\s\S]*?permissions:\s*\n\s+contents:\s+write/);
243
+ assert.match(workflow, /draft releases only to callers with push access/i);
244
+ assert.match(workflow, /minimum GITHUB_TOKEN capability/i);
245
+ assert.match(workflow, /token only for GETs/i);
246
+ const acceptance = workflow.match(/- name: Run the current full non-focused installed-package gate through the default resolver([\s\S]*?\n\s+run: npm run test:native-package)/)?.[0];
247
+ assert.ok(acceptance);
248
+ assert.match(acceptance, /CTG_REQUIRE_OWNED_RUNTIME:\s*['"]?1/);
249
+ assert.match(acceptance, /CTG_RUNTIME_GITHUB_TOKEN:\s*\$\{\{ github\.token \}\}/);
250
+ assert.match(acceptance, /GH_TOKEN:\s*['"]{2}/);
251
+ assert.match(workflow, /run: npm run test:native-package/);
252
+ assert.doesNotMatch(workflow, /gh release (?:create|upload|edit)|releases\/assets|curl .*cli\/releases/);
253
+ assert.doesNotMatch(workflow, /gh api\s+(?:--[^\n]*\s+)?--method\s+(?:POST|PATCH|PUT|DELETE)|gh api\s+-X\s+(?:POST|PATCH|PUT|DELETE)/i);
254
+ assert.match(workflow, /npm ci/);
255
+ assert.match(workflow, /npm run quality/);
256
+ assert.match(workflow, /verify-draft-runtime-gate\.mjs before/);
257
+ assert.match(workflow, /verify-draft-runtime-gate\.mjs recheck/);
258
+ assert.match(workflow, /verify-draft-runtime-gate\.mjs after/);
259
+ assert.match(workflow, /verify release tag ancestry against approved main after acceptance/);
260
+ const bootstrapIndex = workflow.indexOf('Require main dispatch and a release tag descended from origin/main');
261
+ const checkoutIndex = workflow.indexOf('uses: actions/checkout@v4');
262
+ const verifierIndex = workflow.indexOf('verify-draft-runtime-gate.mjs before');
263
+ assert.ok(bootstrapIndex >= 0 && bootstrapIndex < checkoutIndex && checkoutIndex < verifierIndex,
264
+ 'trusted inline ancestry bootstrap must run before release checkout and tag-sourced verifier');
265
+ assert.match(workflow, /ref:\s*\$\{\{ steps\.trusted-ancestry\.outputs\.sha \}\}/,
266
+ 'checkout must use the trusted ancestry-checked SHA, not resolve the mutable tag again');
267
+ const docs = (await readFile(new URL('../docs/opencode-owned-runtime.md', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
268
+ assert.match(docs, /current full,\s+non-focused installed-package gate/);
269
+ assert.match(docs, /exactly those seven assets/);
270
+ assert.match(docs, /independently reviewed host-controls, glyph,\s+controlled-terminal semantics, and visual-clear\/reopen probes are now composed\s+into that gate/);
271
+ assert.match(docs, /Their live final-artifact results remain pending/);
272
+ });
273
+
274
+ test('published release workflow explicitly empties optional draft credentials for public acceptance', async () => {
275
+ const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
276
+ const acceptance = workflow.match(/- name: Accept the installed package with its owned runtime required([\s\S]*?)(?=\n - |\n\s+run:)/)?.[1];
277
+ const verify = workflow.match(/- name: Verify runtime archives against the packaged immutable manifest([\s\S]*?)(?=\n - |\n\s+run:)/)?.[1];
278
+ assert.match(acceptance ?? '', /CTG_RUNTIME_GITHUB_TOKEN:\s*['"]{2}/);
279
+ assert.match(verify ?? '', /CTG_RUNTIME_GITHUB_TOKEN:\s*['"]{2}/);
280
+ });