carthagent 1.0.7 → 1.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """Behavioral proof for native TUI /clear on a trusted owned runtime (Linux PTY)."""
3
+ import argparse
4
+ import importlib.util
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import signal
9
+ import sys
10
+ import time
11
+
12
+
13
+ _env_spec = importlib.util.spec_from_file_location(
14
+ 'owned_visual_clear_env', Path(__file__).with_name('owned-visual-clear-env.py'))
15
+ _env_module = importlib.util.module_from_spec(_env_spec)
16
+ _env_spec.loader.exec_module(_env_module)
17
+ SAFE_PARENT_ENV = _env_module.SAFE_PARENT_ENV
18
+ TUI_ENV_KEYS = _env_module.TUI_ENV_KEYS
19
+ FIXTURE_ENV_KEYS = _env_module.FIXTURE_ENV_KEYS
20
+ fixture_environment = _env_module.fixture_environment
21
+ assert_clean_child_environment = _env_module.assert_clean_child_environment
22
+ _model_spec = importlib.util.spec_from_file_location(
23
+ 'owned_visual_clear_model', Path(__file__).with_name('owned-visual-clear-model.py'))
24
+ _model_module = importlib.util.module_from_spec(_model_spec)
25
+ _model_spec.loader.exec_module(_model_module)
26
+ canonical_conversation = _model_module.canonical_conversation
27
+ latest_message = _model_module.latest_message
28
+
29
+
30
+ def preflight(binary, owned_root, manifest, env):
31
+ import subprocess
32
+
33
+ helper = Path(__file__).with_name('native-runtime-identity.mjs')
34
+ script = r'''
35
+ import { readFile } from 'node:fs/promises';
36
+ const {loadNativeTestRuntime}=await import(process.argv[1]);
37
+ const expectedManifest=JSON.parse(await readFile(process.argv[4],'utf8'));
38
+ const identity=await loadNativeTestRuntime({binary:process.argv[2],ownedRoot:process.argv[3],expectedManifest,env:process.env});
39
+ process.stdout.write(JSON.stringify(identity));
40
+ '''
41
+ result = subprocess.run(['node', '--input-type=module', '-e', script,
42
+ str(helper), str(Path(binary).resolve()),
43
+ str(Path(owned_root).resolve()), str(Path(manifest).resolve())],
44
+ capture_output=True, text=True, env=env)
45
+ if result.returncode:
46
+ raise RuntimeError(f'trusted owned-runtime preflight failed: {result.stderr.strip()}')
47
+ return json.loads(result.stdout)
48
+
49
+
50
+ class Tui:
51
+ active = set()
52
+
53
+ def __init__(self, binary, fixture, session_id, env):
54
+ import subprocess
55
+
56
+ self.master, slave = pty.openpty()
57
+ rows, columns = 60, 140
58
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, columns, 0, 0))
59
+ child_env = {**env, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor',
60
+ 'OPENCODE_PASSWORD': fixture['password']}
61
+ assert_clean_child_environment(child_env)
62
+ self.child = subprocess.Popen([binary, '--server', fixture['baseUrl'],
63
+ '--session', session_id], cwd=fixture['cwd'],
64
+ env=child_env, stdin=slave, stdout=slave,
65
+ stderr=slave, start_new_session=True)
66
+ os.close(slave)
67
+ self.screen = Screen(rows=rows, columns=columns)
68
+ self.active.add(self)
69
+
70
+ def drain(self, seconds=.2):
71
+ end = time.monotonic() + seconds
72
+ while time.monotonic() < end:
73
+ ready, _, _ = select.select([self.master], [], [], .05)
74
+ if ready:
75
+ try:
76
+ data = os.read(self.master, 65536)
77
+ except OSError:
78
+ return
79
+ if not data:
80
+ return
81
+ self.screen.feed(data)
82
+ elif self.child.poll() is not None:
83
+ raise AssertionError(f'TUI exited early: {self.child.returncode}')
84
+
85
+ def wait_for(self, predicate, what, timeout=20):
86
+ end = time.monotonic() + timeout
87
+ while time.monotonic() < end:
88
+ self.drain(.1)
89
+ if predicate():
90
+ self.drain(.2)
91
+ return
92
+ raise AssertionError(f'timed out waiting for {what}:\n{self.screen.visible()}')
93
+
94
+ def submit(self, text):
95
+ os.write(self.master, text.encode())
96
+ os.write(self.master, b'\r')
97
+
98
+ def submit_slash(self, text):
99
+ os.write(self.master, text.encode())
100
+ self.drain(.5) # native slash autocomplete
101
+ os.write(self.master, b'\r') # select registered command
102
+ self.drain(.3)
103
+ os.write(self.master, b'\r') # dispatch
104
+
105
+ def stop(self):
106
+ import subprocess
107
+
108
+ if self not in self.active:
109
+ return
110
+ if self.child.poll() is None:
111
+ os.killpg(self.child.pid, signal.SIGTERM)
112
+ try:
113
+ self.child.wait(timeout=5)
114
+ except subprocess.TimeoutExpired:
115
+ os.killpg(self.child.pid, signal.SIGKILL)
116
+ self.child.wait(timeout=5)
117
+ os.close(self.master)
118
+ self.active.remove(self)
119
+
120
+
121
+ def main():
122
+ # PTY and terminal semantics are POSIX-only; importing this runner is safe
123
+ # on platforms that only execute its child-environment unit tests.
124
+ global pty, fcntl, select, struct, termios, Screen
125
+ import pty
126
+ import fcntl
127
+ import select
128
+ import struct
129
+ import termios
130
+ import subprocess
131
+
132
+ _spec = importlib.util.spec_from_file_location(
133
+ 'terminal_semantics', Path(__file__).with_name('native-terminal-semantics.py'))
134
+ _module = importlib.util.module_from_spec(_spec)
135
+ _spec.loader.exec_module(_module)
136
+ Screen = _module.Screen
137
+
138
+ parser = argparse.ArgumentParser()
139
+ parser.add_argument('--binary', required=True)
140
+ parser.add_argument('--owned-root', required=True)
141
+ parser.add_argument('--release-manifest', required=True)
142
+ parser.add_argument('--artifact')
143
+ args = parser.parse_args()
144
+
145
+ binary = str(Path(args.binary).resolve())
146
+ owned_root = str(Path(args.owned_root).resolve())
147
+ root = Path(__file__).resolve().parents[1]
148
+ work = Path(args.artifact).resolve().parent if args.artifact else Path('/tmp')
149
+ resolver_env = fixture_environment(os.environ, work / 'ctg-visual-clear-runner',
150
+ owned_root, Path(args.release_manifest).resolve())
151
+ assert_clean_child_environment(resolver_env, FIXTURE_ENV_KEYS)
152
+ # This test selects a local artifact and performs no download. No runtime
153
+ # token is needed by preflight or any child, so the allowlist excludes it.
154
+ if any(key.casefold() == 'ctg_runtime_github_token' for key in resolver_env):
155
+ raise AssertionError('resolver environment retains a reserved runtime token')
156
+ preflight_env = dict(resolver_env)
157
+ print('Preflighting pinned executable...', file=sys.stderr, flush=True)
158
+ identity = preflight(binary, owned_root, args.release_manifest, preflight_env)
159
+
160
+ # Make the trusted resolver invocation and launch arguments explicit. The
161
+ # fixture will repeat identity checks before its host process is started.
162
+ fixture_script = r'''
163
+ const {nativeFixture}=await import((await import('node:url')).pathToFileURL(process.argv[1]));
164
+ const host=await nativeFixture({plugin:null,verifyInventory:false,preserveTemp:true,
165
+ respond(body,res){res.writeHead(200,{'content-type':'text/event-stream','connection':'close'});
166
+ const latestUser=[...(body.messages??[])].reverse().find(message=>message.role==='user');
167
+ const prompt=typeof latestUser?.content==='string'?latestUser.content:
168
+ (latestUser?.content??[]).map(part=>part.text??'').join(' ');
169
+ const reply=prompt.includes('VISUAL_CLEAR_CONTINUED_PROMPT')?
170
+ 'CLEAR_FOLLOWUP_ASSISTANT_REPLY':
171
+ prompt.includes('CLEAR_SESSION_A_UNIQUE_MARKER')?'CLEAR_A_INITIAL_ASSISTANT_REPLY':
172
+ prompt.includes('UNCLEARED_CONTROL_OLD_MARKER')?'CLEAR_CONTROL_INITIAL_ASSISTANT_REPLY':
173
+ 'CLEAR_NEIGHBOR_ASSISTANT_REPLY';
174
+ const chunk=(delta,finish_reason=null)=>`data: ${JSON.stringify({id:'offline-clear',object:'chat.completion.chunk',created:1,model:'fixture',choices:[{index:0,delta,finish_reason}]})}\n\n`;
175
+ res.end(chunk({role:'assistant',content:reply})+chunk({},'stop')+'data: [DONE]\n\n');}});
176
+ try {
177
+ const sessions={};
178
+ for(const [key,marker] of [['a','CLEAR_SESSION_A_UNIQUE_MARKER'],['b','NEIGHBOR_SESSION_B_UNIQUE_MARKER'],['control','UNCLEARED_CONTROL_OLD_MARKER']]){
179
+ const s=await host.client.session.create({location:host.location,model:host.model,title:`clear ${key}`},host.request());
180
+ await host.client.session.prompt({sessionID:s.id,text:`Remember this visible marker: ${marker}`},host.request());
181
+ await host.client.session.wait({sessionID:s.id},host.request()); sessions[key]={id:s.id,marker}; }
182
+ await host.client.session.prompt({sessionID:sessions.control.id,text:'VISUAL_CLEAR_CONTINUED_PROMPT'},host.request());
183
+ await host.client.session.wait({sessionID:sessions.control.id},host.request());
184
+ process.stdout.write(JSON.stringify({baseUrl:host.baseUrl,password:host.password,temp:host.temp,cwd:host.cwd,sessions})+'\n');
185
+ process.once('SIGTERM',async()=>{await host.close({preserveTemp:false});process.exit(0)});
186
+ await new Promise(()=>{});
187
+ } catch(e){await host.close({preserveTemp:false});throw e}
188
+ '''
189
+ child = subprocess.Popen(['node', '--input-type=module', '-e', fixture_script,
190
+ str(root / 'tools/native-fixture.mjs')], cwd=root,
191
+ env=resolver_env, stdout=subprocess.PIPE,
192
+ stderr=subprocess.PIPE, text=True)
193
+ tui = None
194
+ try:
195
+ line = child.stdout.readline()
196
+ if not line:
197
+ raise RuntimeError(f'fixture failed: {child.stderr.read()}')
198
+ fixture = json.loads(line)
199
+ bridge = r'''
200
+ import {OpenCode} from '@opencode/client';
201
+ const [url,password,id]=process.argv.slice(1);const c=OpenCode.make({baseUrl:url,headers:{authorization:`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`}});
202
+ process.stdout.write(JSON.stringify(await c.message.list({sessionID:id},{signal:AbortSignal.timeout(10000)})));
203
+ '''
204
+ def durable(session_id):
205
+ return json.loads(subprocess.run(['node','--input-type=module','-e',bridge,
206
+ fixture['baseUrl'],fixture['password'],session_id],cwd=root,check=True,
207
+ capture_output=True,text=True,env=resolver_env).stdout)
208
+ def conversation(value):
209
+ return [x for x in value.get('data',[]) if x.get('type') in ('user','assistant','tool')]
210
+ def stable(session_id):
211
+ previous=durable(session_id); same=0; end=time.monotonic()+10
212
+ while time.monotonic()<end:
213
+ time.sleep(.25); current=durable(session_id)
214
+ if current==previous:
215
+ same+=1
216
+ if same==2:return current
217
+ else:previous=current;same=0
218
+ raise AssertionError('durable message projection did not settle')
219
+
220
+ def durable_messages(session_id):
221
+ return stable(session_id)
222
+
223
+ def durable_snapshot(session_id):
224
+ return canonical_conversation(conversation(durable_messages(session_id)))
225
+
226
+ def wait_for_file_value(path, predicate, description, timeout=10):
227
+ end = time.monotonic() + timeout
228
+ last_value = None
229
+ while time.monotonic() < end:
230
+ try:
231
+ value = json.loads(path.read_text())
232
+ except (OSError, json.JSONDecodeError):
233
+ value = None
234
+ last_value = value
235
+ if value is not None and predicate(value):
236
+ return value
237
+ time.sleep(.05)
238
+ raise AssertionError(
239
+ f'timed out waiting for persisted {description}; '
240
+ f'last state was {json.dumps(last_value, sort_keys=True)}')
241
+
242
+ a,b,control=(fixture['sessions'][key] for key in ('a','b','control'))
243
+ before_b=durable_snapshot(b['id'])
244
+ before_control=durable_snapshot(control['id'])
245
+ if not any(x.get('text') == 'Remember this visible marker: UNCLEARED_CONTROL_OLD_MARKER'
246
+ for x in before_control if x.get('type') == 'user') or not any(
247
+ x.get('text') == 'VISUAL_CLEAR_CONTINUED_PROMPT'
248
+ for x in before_control if x.get('type') == 'user'):
249
+ raise AssertionError('positive-control transcript was not seeded with both user turns')
250
+ profile_script="import {runtimeEnvironment} from './lib/opencode/runtime.mjs';process.stdout.write(JSON.stringify(runtimeEnvironment({root:process.argv[1],env:process.env})))"
251
+ env=json.loads(subprocess.run(['node','--input-type=module','-e',profile_script,fixture['temp']],
252
+ cwd=root,env=resolver_env,check=True,capture_output=True,text=True).stdout)
253
+ env={key:value for key,value in env.items() if key in TUI_ENV_KEYS}
254
+ env.update({key:value for key,value in resolver_env.items() if key in SAFE_PARENT_ENV})
255
+ env['OPENCODE_PASSWORD']=fixture['password']
256
+ assert_clean_child_environment(env)
257
+
258
+ for key, value in env.items():
259
+ if key.casefold() in ('opencode_password', 'opencode_server_password'):
260
+ continue
261
+ if value in (fixture['password'], fixture['baseUrl']):
262
+ raise AssertionError(f'synthetic fixture credential unexpectedly occupies allowlisted variable {key}')
263
+ env['OPENCODE_SERVER_PASSWORD'] = fixture['password']
264
+ env['OPENCODE_SERVER_USERNAME'] = 'opencode'
265
+ assert_clean_child_environment(env)
266
+
267
+ tui=Tui(binary,fixture,a['id'],env)
268
+ tui.wait_for(lambda:a['marker'] in tui.screen.visible(),'A marker')
269
+ tui.wait_for(lambda:'CLEAR_A_INITIAL_ASSISTANT_REPLY' in tui.screen.visible(),'A initial assistant reply')
270
+ before_a_response=durable_messages(a['id'])
271
+ before_a=canonical_conversation(conversation(before_a_response))
272
+ cutoff_message=latest_message(before_a_response)
273
+ tui.submit_slash('/clear')
274
+ tui.wait_for(lambda:a['marker'] not in tui.screen.visible() and
275
+ 'CLEAR_A_INITIAL_ASSISTANT_REPLY' not in tui.screen.visible(),
276
+ '/clear visual removal')
277
+ after_clear_durable = durable_snapshot(a['id'])
278
+ if after_clear_durable != before_a:
279
+ raise AssertionError('/clear changed durable conversational messages')
280
+ storage_matches = list((Path(fixture['temp']) / 'state').rglob('transcript-presentation.json'))
281
+ if len(storage_matches) != 1:
282
+ raise AssertionError(f'expected one isolated transcript-presentation store, found {len(storage_matches)}')
283
+ storage_path = storage_matches[0]
284
+ state_after_clear = wait_for_file_value(storage_path,
285
+ lambda state: state.get(a['id'], {}).get('before') == cutoff_message['id'],
286
+ f'A transcript cutoff for session {a["id"]} at message {cutoff_message["id"]}')
287
+ tui.submit('VISUAL_CLEAR_CONTINUED_PROMPT')
288
+ tui.wait_for(lambda:'CLEAR_FOLLOWUP_ASSISTANT_REPLY' in tui.screen.visible(),
289
+ 'follow-up assistant reply')
290
+ after_followup=durable_snapshot(a['id'])
291
+ if len(after_followup)<=len(before_a) or not any(x.get('type')=='user' and x.get('text')=='VISUAL_CLEAR_CONTINUED_PROMPT' for x in after_followup):
292
+ raise AssertionError('follow-up prompt was not durably submitted')
293
+ state_after_followup = wait_for_file_value(storage_path,
294
+ lambda state: state.get(a['id'], {}).get('before') == cutoff_message['id'],
295
+ 'cutoff unchanged after follow-up')
296
+ if state_after_followup.get(a['id'], {}).get('before') != cutoff_message['id']:
297
+ raise AssertionError('follow-up turn changed the persisted transcript cutoff')
298
+ tui.stop();tui=None
299
+
300
+ neighbor=Tui(binary,fixture,b['id'],env)
301
+ neighbor.wait_for(lambda:b['marker'] in neighbor.screen.visible(),'B marker')
302
+ if a['marker'] in neighbor.screen.visible() or durable_snapshot(b['id']) != before_b:
303
+ raise AssertionError('neighbor session B was affected by clearing A')
304
+ neighbor.stop()
305
+
306
+ # Positive control: same short two-turn transcript, no clear command.
307
+ # At the same terminal dimensions and initial tail position, both old
308
+ # and new markers must remain visible without the presentation cutoff.
309
+ control_tui=Tui(binary,fixture,control['id'],env)
310
+ control_tui.wait_for(lambda:control['marker'] in control_tui.screen.visible(),
311
+ 'uncleared positive-control initial marker')
312
+ control_tui.wait_for(lambda:'VISUAL_CLEAR_CONTINUED_PROMPT' in control_tui.screen.visible(),
313
+ 'uncleared positive-control follow-up marker')
314
+ control_tui.wait_for(lambda:'CLEAR_FOLLOWUP_ASSISTANT_REPLY' in control_tui.screen.visible(),
315
+ 'uncleared positive-control follow-up assistant reply')
316
+ control_visible = control_tui.screen.visible()
317
+ if (control['marker'] not in control_visible or
318
+ 'VISUAL_CLEAR_CONTINUED_PROMPT' not in control_visible or
319
+ 'CLEAR_FOLLOWUP_ASSISTANT_REPLY' not in control_visible):
320
+ raise AssertionError('uncleared control old marker, new marker and assistant reply must all be visible')
321
+ control_tui.stop()
322
+
323
+ reopened=Tui(binary,fixture,a['id'],env)
324
+ reopened.wait_for(lambda:'VISUAL_CLEAR_CONTINUED_PROMPT' in reopened.screen.visible(),
325
+ 'post-clear marker visible after reopening A')
326
+ reopened.wait_for(lambda:'CLEAR_FOLLOWUP_ASSISTANT_REPLY' in reopened.screen.visible(),
327
+ 'post-clear assistant reply visible after reopening A')
328
+ reopened_visible = reopened.screen.visible()
329
+ if a['marker'] in reopened_visible:
330
+ raise AssertionError('old A marker remains visible; transcript presentation cutoff was not restored')
331
+ if ('VISUAL_CLEAR_CONTINUED_PROMPT' not in reopened_visible or
332
+ 'CLEAR_FOLLOWUP_ASSISTANT_REPLY' not in reopened_visible):
333
+ raise AssertionError('reopened A must show both the post-clear user prompt and assistant reply')
334
+ if (control['marker'] not in control_visible or
335
+ 'VISUAL_CLEAR_CONTINUED_PROMPT' not in control_visible or
336
+ 'CLEAR_FOLLOWUP_ASSISTANT_REPLY' not in control_visible):
337
+ raise AssertionError('positive control did not show old and post-turn content at tested PTY position')
338
+ reopened_durable = durable_snapshot(a['id'])
339
+ if reopened_durable != after_followup:
340
+ raise AssertionError('durable A conversation changed on reopening')
341
+ reopened_state = wait_for_file_value(storage_path,
342
+ lambda state: state.get(a['id'], {}).get('before') == cutoff_message['id'],
343
+ 'cutoff restored on reopen')
344
+ if reopened_state.get(a['id'], {}).get('before') != cutoff_message['id']:
345
+ raise AssertionError('reopened TUI lost the persisted presentation cutoff')
346
+ reopened.stop()
347
+ tui=None
348
+ result={'status':'PASS','scope':'Linux POSIX PTY; explicit trusted-preflighted owned staging artifact; offline loopback provider',
349
+ 'runtimeIdentity':identity,'sessionIDs':{'a':a['id'],'b':b['id']},
350
+ 'command':'native slash autocomplete selected /clear; Enter dispatched it',
351
+ 'checks':{'A initial user and assistant markers disappeared from parsed PTY screen':True,
352
+ 'A conversational message IDs/content unchanged by clear':True,
353
+ 'follow-up prompt submitted and unique assistant reply visibly rendered':True,
354
+ 'neighbor B unaffected':True,
355
+ 'A old marker hidden while post-clear user and assistant markers are visible on reopen':True,
356
+ 'uncleared same-length positive control visibly shows old user, follow-up user, and follow-up assistant reply at same PTY size':True,
357
+ 'presentation cutoff ID persisted and unchanged across follow-up/reopen':True,
358
+ 'session.revert.clear used':False,
359
+ 'ambient credentials excluded by child environment allowlists':True},
360
+ 'binarySha256':identity['binarySha256'],'cutoffMessageID':cutoff_message['id'],
361
+ 'messagesBeforeClear':len(before_a),'messagesAfterFollowup':len(after_followup)}
362
+ rendered=json.dumps(result,indent=2)+'\n';print(rendered,end='')
363
+ if args.artifact:Path(args.artifact).write_text(rendered)
364
+ finally:
365
+ for running_tui in list(Tui.active):
366
+ running_tui.stop()
367
+ child.terminate()
368
+ try:child.wait(timeout=5)
369
+ except subprocess.TimeoutExpired:child.kill();child.wait()
370
+
371
+
372
+ if __name__=='__main__':
373
+ main()
@@ -0,0 +1,138 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+
6
+ export const REQUIRED_RUNTIME_ASSETS = Object.freeze([
7
+ 'opencode-linux-x64.tar.gz',
8
+ 'opencode-linux-arm64.tar.gz',
9
+ 'opencode-darwin-x64.tar.gz',
10
+ 'opencode-darwin-arm64.tar.gz',
11
+ 'opencode-windows-x64.tar.gz',
12
+ ]);
13
+ export const REQUIRED_RELEASE_ASSETS = Object.freeze([
14
+ ...REQUIRED_RUNTIME_ASSETS,
15
+ 'runtime-release.json',
16
+ 'runtime-release-SHA256SUMS.txt',
17
+ ]);
18
+
19
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const apiRepository = 'B-icy/Carthagent';
21
+
22
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
23
+ await main(process.argv.slice(2));
24
+ }
25
+
26
+ /** Check the exact remote draft/tag and local checkout before or after acceptance. */
27
+ export async function verifyDraftRuntimeGate({
28
+ phase,
29
+ releaseTag,
30
+ expectedSha,
31
+ expectedReleaseId,
32
+ repo = apiRepository,
33
+ cwd = process.cwd(),
34
+ env = process.env,
35
+ run = runCommand,
36
+ }) {
37
+ if (!['before', 'recheck', 'after'].includes(phase)) throw new Error('phase must be before, recheck, or after');
38
+ if (repo !== apiRepository) throw new Error(`Draft runtime gate is restricted to ${apiRepository}.`);
39
+ const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
40
+ if (releaseTag !== `v${packageJson.version}`) {
41
+ throw new Error(`Release tag must be exactly v${packageJson.version}, received ${JSON.stringify(releaseTag)}.`);
42
+ }
43
+ const version = packageJson.version;
44
+ const releases = await listReleases({ repo, env, run });
45
+ const matches = releases.filter((release) => release?.tag_name === releaseTag);
46
+ if (matches.length !== 1) throw new Error(matches.length ? 'GitHub returned duplicate exact-tag releases.' : `No release exists for ${releaseTag}.`);
47
+ const release = matches[0];
48
+ if (release.draft !== true) throw new Error(`Release ${releaseTag} must remain a draft during installed acceptance.`);
49
+ if (!Number.isSafeInteger(release.id) || release.id <= 0) throw new Error('GitHub returned invalid draft release identity.');
50
+ if (expectedReleaseId !== undefined && String(release.id) !== String(expectedReleaseId)) {
51
+ throw new Error(`Draft release identity changed during acceptance (expected ${expectedReleaseId}, found ${release.id}).`);
52
+ }
53
+ assertRuntimeAssets(release.assets, releaseTag);
54
+
55
+ const remoteSha = await resolveRemoteTag({ repo, releaseTag, env, run });
56
+ const checkoutSha = run('git', ['rev-parse', 'HEAD'], { cwd, env }).trim().toLowerCase();
57
+ if (!/^[a-f0-9]{40}$/.test(checkoutSha)) throw new Error('Could not identify the checked-out release commit.');
58
+ if (remoteSha !== checkoutSha) throw new Error(`Release tag ${releaseTag} resolves to ${remoteSha}, but this checkout is ${checkoutSha}.`);
59
+ if (phase === 'recheck' || phase === 'after') {
60
+ if (!/^[a-f0-9]{40}$/.test(expectedSha ?? '')) throw new Error(`${phase} check requires the pre-acceptance tested SHA.`);
61
+ if (remoteSha !== expectedSha.toLowerCase()) throw new Error(`Release tag changed during acceptance: expected ${expectedSha}, found ${remoteSha}.`);
62
+ if (!/^[1-9][0-9]*$/.test(String(expectedReleaseId ?? ''))) throw new Error(`${phase} check requires the pre-acceptance draft release ID.`);
63
+ }
64
+ return Object.freeze({ releaseId: release.id, sha: remoteSha, version });
65
+ }
66
+
67
+ function assertRuntimeAssets(assets, tag) {
68
+ if (!Array.isArray(assets)) throw new Error(`Draft release ${tag} has no asset list.`);
69
+ const names = assets.map((asset) => asset?.name);
70
+ for (const name of REQUIRED_RELEASE_ASSETS) {
71
+ const count = names.filter((candidate) => candidate === name).length;
72
+ if (count !== 1) throw new Error(`Draft release ${tag} must contain exactly one ${name} asset.`);
73
+ }
74
+ const unexpected = names.filter((name) => !REQUIRED_RELEASE_ASSETS.includes(name));
75
+ if (unexpected.length) throw new Error(`Draft release ${tag} contains unexpected assets: ${unexpected.join(', ')}.`);
76
+ if (names.length !== REQUIRED_RELEASE_ASSETS.length) {
77
+ throw new Error(`Draft release ${tag} must contain exactly the seven reviewed runtime release assets.`);
78
+ }
79
+ }
80
+
81
+ async function listReleases({ repo, env, run }) {
82
+ const output = run('gh', ['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`], { env });
83
+ let pages;
84
+ try { pages = JSON.parse(output); }
85
+ catch { throw new Error('GitHub returned invalid release-list JSON.'); }
86
+ if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) throw new Error('GitHub returned invalid paginated release metadata.');
87
+ return pages.flat();
88
+ }
89
+
90
+ async function resolveRemoteTag({ repo, releaseTag, env, run }) {
91
+ let ref;
92
+ try { ref = JSON.parse(run('gh', ['api', `repos/${repo}/git/ref/tags/${releaseTag}`], { env })); }
93
+ catch (error) { throw new Error(`Could not resolve remote tag ${releaseTag}: ${error.message}`); }
94
+ let type = ref?.object?.type;
95
+ let sha = ref?.object?.sha;
96
+ for (let depth = 0; type === 'tag'; depth++) {
97
+ if (depth >= 5) throw new Error(`Release tag ${releaseTag} has excessive nested annotated tags.`);
98
+ if (!/^[a-f0-9]{40}$/i.test(sha ?? '')) throw new Error(`Release tag ${releaseTag} has an invalid annotated tag object.`);
99
+ let annotated;
100
+ try { annotated = JSON.parse(run('gh', ['api', `repos/${repo}/git/tags/${sha}`], { env })); }
101
+ catch (error) { throw new Error(`Could not resolve annotated tag object for ${releaseTag}: ${error.message}`); }
102
+ type = annotated?.object?.type;
103
+ sha = annotated?.object?.sha;
104
+ }
105
+ if (type !== 'commit' || !/^[a-f0-9]{40}$/i.test(sha ?? '')) {
106
+ throw new Error(`Release tag ${releaseTag} must resolve to a commit.`);
107
+ }
108
+ return sha.toLowerCase();
109
+ }
110
+
111
+ async function main(args) {
112
+ const [phase, releaseTag] = args;
113
+ if (!['before', 'recheck', 'after'].includes(phase) || !releaseTag) {
114
+ console.error('Usage: node tools/verify-draft-runtime-gate.mjs <before|recheck|after> <release-tag>');
115
+ process.exit(2);
116
+ }
117
+ const result = await verifyDraftRuntimeGate({
118
+ phase,
119
+ releaseTag,
120
+ expectedSha: process.env.EXPECTED_SHA,
121
+ expectedReleaseId: process.env.EXPECTED_RELEASE_ID,
122
+ repo: process.env.GITHUB_REPOSITORY,
123
+ cwd: process.cwd(),
124
+ });
125
+ if (phase === 'before') {
126
+ if (!process.env.GITHUB_OUTPUT) throw new Error('GITHUB_OUTPUT is required for the pre-acceptance gate.');
127
+ const { appendFile } = await import('node:fs/promises');
128
+ await appendFile(process.env.GITHUB_OUTPUT, `tested_sha=${result.sha}\nrelease_id=${result.releaseId}\n`);
129
+ }
130
+ console.log(`Verified draft release ${releaseTag} at ${result.sha} (${phase} installed acceptance).`);
131
+ }
132
+
133
+ function runCommand(command, args, { cwd, env } = {}) {
134
+ const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
135
+ if (result.error) throw result.error;
136
+ if (result.status !== 0) throw new Error(`${command} exited ${result.status}: ${(result.stderr || result.stdout).trim().slice(-2000)}`);
137
+ return result.stdout;
138
+ }
@@ -0,0 +1,114 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { createRuntimeGithubFetch } from '../lib/opencode/runtime-github-transport.mjs';
5
+ import { readPinnedOwnedRuntimeInputs } from './pinned-owned-runtime-inputs.mjs';
6
+
7
+ export const REQUIRED_RUNTIME_TARGETS = Object.freeze({
8
+ 'linux-x64': ['linux', 'x64', 'opencode-linux-x64.tar.gz'],
9
+ 'linux-arm64': ['linux', 'arm64', 'opencode-linux-arm64.tar.gz'],
10
+ 'darwin-x64': ['darwin', 'x64', 'opencode-darwin-x64.tar.gz'],
11
+ 'darwin-arm64': ['darwin', 'arm64', 'opencode-darwin-arm64.tar.gz'],
12
+ 'windows-x64': ['windows', 'x64', 'opencode-windows-x64.tar.gz'],
13
+ });
14
+
15
+ const HEX_40 = /^[a-f0-9]{40}$/i;
16
+ const HEX_64 = /^[a-f0-9]{64}$/i;
17
+
18
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
19
+ const [releaseVersion, manifestPath, assetsBaseUrl] = process.argv.slice(2);
20
+ if (!releaseVersion || !manifestPath || !assetsBaseUrl) {
21
+ console.error('Usage: node tools/verify-owned-runtime-release.mjs <version> <trusted-manifest.json> <release-assets-base-url>');
22
+ process.exit(2);
23
+ }
24
+ await verifyOwnedRuntimeRelease({ releaseVersion, manifestPath, assetsBaseUrl, repoRoot: process.cwd() });
25
+ }
26
+
27
+ /** Validate the packaged trusted outer manifest and acquire/verify each exact asset. */
28
+ export async function verifyOwnedRuntimeRelease({
29
+ releaseVersion,
30
+ manifestPath,
31
+ assetsBaseUrl,
32
+ repoRoot,
33
+ acquireRuntime,
34
+ cacheRoot,
35
+ fetchImpl,
36
+ runtimeToken = process.env.CTG_RUNTIME_GITHUB_TOKEN,
37
+ }) {
38
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
39
+ validateRuntimeReleaseManifest(manifest, releaseVersion);
40
+ await validateRuntimeBuildInputs(manifest, path.resolve(repoRoot ?? process.cwd()));
41
+ const acquireOwnedRuntime = acquireRuntime ?? (await import('../lib/opencode/runtime-artifact.mjs')).acquireOwnedRuntime;
42
+ const runtimeFetch = runtimeToken
43
+ ? createRuntimeGithubFetch({ token: runtimeToken, fetchImpl: fetchImpl ?? globalThis.fetch })
44
+ : fetchImpl;
45
+
46
+ for (const [key, [platform, arch, name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
47
+ const asset = manifest.assets[key];
48
+ const installed = await acquireOwnedRuntime({
49
+ releaseVersion,
50
+ platform,
51
+ arch,
52
+ baseUrl: assetsBaseUrl,
53
+ expectedArchiveSha256: asset.archiveSha256,
54
+ expectedExecutableSha256: asset.executableSha256,
55
+ expectedSourceRevision: manifest.sourceRevision,
56
+ expectedPatchSha256: manifest.patchSha256,
57
+ expectedLockSha256: manifest.lockSha256,
58
+ expectedCompatibilityVersion: manifest.compatibilityVersion,
59
+ expectedRuntimeVersion: manifest.runtimeVersion,
60
+ expectedChannel: `ctg.${manifest.compatibilityVersion}`,
61
+ ...(cacheRoot ? { cacheRoot } : {}),
62
+ ...(runtimeFetch ? { fetchImpl: runtimeFetch } : {}),
63
+ });
64
+ if (typeof installed !== 'string' || !installed) throw new Error(`Acquisition did not verify ${name}`);
65
+ }
66
+ return manifest;
67
+ }
68
+
69
+ export function validateRuntimeReleaseManifest(value, releaseVersion) {
70
+ if (value?.version !== 1 || value.releaseVersion !== releaseVersion) {
71
+ throw new Error('Trusted runtime release manifest version does not match the release tag');
72
+ }
73
+ if (typeof value.compatibilityVersion !== 'string' || !value.compatibilityVersion.trim()) {
74
+ throw new Error('Runtime compatibilityVersion is required');
75
+ }
76
+ if (typeof value.runtimeVersion !== 'string' || !value.runtimeVersion.trim()) {
77
+ throw new Error('Runtime runtimeVersion is required');
78
+ }
79
+ if (typeof value.sourceRevision !== 'string' || !HEX_40.test(value.sourceRevision)) {
80
+ throw new Error('Runtime sourceRevision must be a 40-character commit SHA');
81
+ }
82
+ for (const field of ['patchSha256', 'lockSha256']) {
83
+ if (typeof value[field] !== 'string' || !HEX_64.test(value[field])) {
84
+ throw new Error(`Runtime ${field} must be a 64-character SHA-256 digest`);
85
+ }
86
+ }
87
+ if (!value.assets || typeof value.assets !== 'object' || Array.isArray(value.assets)) {
88
+ throw new Error('Runtime assets must be a platform map');
89
+ }
90
+ const actual = Object.keys(value.assets).sort();
91
+ const required = Object.keys(REQUIRED_RUNTIME_TARGETS).sort();
92
+ if (JSON.stringify(actual) !== JSON.stringify(required)) {
93
+ throw new Error(`Trusted runtime manifest must contain exactly: ${required.join(', ')}`);
94
+ }
95
+ for (const [key, [, , name]] of Object.entries(REQUIRED_RUNTIME_TARGETS)) {
96
+ const asset = value.assets[key];
97
+ if (asset?.name !== name) throw new Error(`Runtime manifest asset name for ${key} must be ${name}`);
98
+ for (const field of ['archiveSha256', 'executableSha256']) {
99
+ if (typeof asset[field] !== 'string' || !HEX_64.test(asset[field])) {
100
+ throw new Error(`${key}.${field} must be a 64-character SHA-256 digest`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Bind trusted artifact provenance to the exact recipe and patch in the tagged tree. */
107
+ export async function validateRuntimeBuildInputs(value, repoRoot) {
108
+ const pinnedInputs = await readPinnedOwnedRuntimeInputs(repoRoot);
109
+ for (const field of ['sourceRevision', 'patchSha256', 'lockSha256']) {
110
+ if (pinnedInputs[field] !== value[field].toLowerCase()) {
111
+ throw new Error(`Runtime ${field} does not match the tagged build recipe`);
112
+ }
113
+ }
114
+ }
@@ -34,4 +34,24 @@ if (packInfo.length !== 1 || packInfo[0].name !== packageJson.name || packInfo[0
34
34
  throw new Error(`Packed npm manifest must be ${packageJson.name}@${version}`);
35
35
  }
36
36
 
37
- console.log(`Verified release tag ${tag}, lockfile, and packed package ${packageJson.name}@${version}`);
37
+ // npm publish silently drops bin entries it considers invalid (npm 11 rejects
38
+ // "./bin/ctg.mjs"), so a published version could ship without the ctg command.
39
+ // Check the publish-time manifest npm itself would upload, not package.json.
40
+ const published = spawnSync('npm', ['publish', '--dry-run', '--json', '--tag', 'next'], { encoding: 'utf8' });
41
+ if (published.status !== 0) {
42
+ process.stderr.write(published.stderr);
43
+ process.exit(published.status ?? 1);
44
+ }
45
+ const corrections = published.stderr.split('\n').filter(line => /auto-corrected|was invalid and removed/.test(line));
46
+ if (corrections.length) {
47
+ throw new Error(`npm publish would rewrite package.json; fix it before releasing:\n${published.stderr}`);
48
+ }
49
+ const binPath = packageJson.bin?.ctg;
50
+ if (typeof binPath !== 'string' || binPath.startsWith('./')) {
51
+ throw new Error(`package.json bin.ctg must be a plain relative path npm publish keeps (got ${JSON.stringify(binPath)})`);
52
+ }
53
+ if (!packInfo[0].files.some(file => file.path === binPath && (file.mode & 0o111))) {
54
+ throw new Error(`Packed npm package must contain executable ${binPath} for the ctg command`);
55
+ }
56
+
57
+ console.log(`Verified release tag ${tag}, lockfile, and packed package ${packageJson.name}@${version} with ctg -> ${binPath}`);