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,83 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadNativeTestRuntime, preflightNativeTestRuntimeArtifact } from './native-runtime-identity.mjs';
6
+
7
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
8
+
9
+ /** Select the production runtime, binding every owned path to trusted release metadata. */
10
+ export async function selectNativePtyRuntime({ runtime, env = process.env, root = PACKAGE_ROOT, identityTools = { loadNativeTestRuntime, preflightNativeTestRuntimeArtifact } }) {
11
+ if (env.CTG_TEST_RUNTIME_ARTIFACT_ROOT && (env.CTG_OPENCODE_BIN || env.OPENCODE_BIN)) {
12
+ throw new Error('Do not combine an injected owned artifact root with an explicit binary override.');
13
+ }
14
+
15
+ let options = {};
16
+ let expectedManifest;
17
+ let ownedRoot;
18
+ if (env.CTG_TEST_RUNTIME_ARTIFACT_ROOT) {
19
+ ownedRoot = resolve(env.CTG_TEST_RUNTIME_ARTIFACT_ROOT);
20
+ const expectedManifestPath = env.CTG_TEST_RUNTIME_RELEASE_MANIFEST;
21
+ // This verifies pinned source/patch/lock/platform provenance and the
22
+ // executable digest before resolveOpenCode is allowed to execute it.
23
+ const preflight = await identityTools.preflightNativeTestRuntimeArtifact({
24
+ ownedRoot,
25
+ expectedManifestPath,
26
+ env,
27
+ });
28
+ expectedManifest = preflight.expectedManifest;
29
+ options = { ownedRuntime: ownedRoot };
30
+ } else if (env.CTG_OWNED_OPENCODE_RUNTIME) {
31
+ throw new Error('CTG_OWNED_OPENCODE_RUNTIME is a developer selector, not an acceptance trust root; use CTG_TEST_RUNTIME_ARTIFACT_ROOT and CTG_TEST_RUNTIME_RELEASE_MANIFEST.');
32
+ } else if (env.CTG_OPENCODE_BIN || env.OPENCODE_BIN) {
33
+ const binary = env.CTG_OPENCODE_BIN ?? env.OPENCODE_BIN;
34
+ if (!binary.includes('/') && !binary.includes('\\')) throw new Error('Explicit native PTY binary must be a filesystem path.');
35
+ const expectedHash = env.CTG_NATIVE_PTY_EXPECTED_SHA256;
36
+ if (!/^[a-f0-9]{64}$/i.test(expectedHash ?? '')) throw new Error('Explicit native PTY binary requires CTG_NATIVE_PTY_EXPECTED_SHA256.');
37
+ const actualHash = createHash('sha256').update(await readFile(resolve(binary))).digest('hex');
38
+ if (actualHash !== expectedHash.toLowerCase()) throw new Error(`Explicit native PTY binary SHA-256 mismatch before runtime selection: ${actualHash}`);
39
+ options = { binary };
40
+ }
41
+
42
+ if (typeof runtime.resolveOpenCode !== 'function' || typeof runtime.runtimeIdentity !== 'function') {
43
+ throw new Error('Native PTY acceptance requires resolveOpenCode(options) and runtimeIdentity(binary, options) from the production runtime selector.');
44
+ }
45
+ const binary = await runtime.resolveOpenCode(options);
46
+ const identity = await runtime.runtimeIdentity(binary, options);
47
+ if (!identity.binaryPath || resolve(identity.binaryPath) !== resolve(binary)) {
48
+ throw new Error(`Runtime identity does not bind the selected executable: selected ${binary}, identity ${identity.binaryPath ?? 'missing'}.`);
49
+ }
50
+
51
+ if (identity.kind === 'ctg-owned') {
52
+ if (!ownedRoot) {
53
+ const executableName = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
54
+ ownedRoot = resolve(dirname(dirname(dirname(binary))));
55
+ const expectedBinary = resolve(ownedRoot, `cli-${identity.platform}`, 'bin', executableName);
56
+ if (resolve(binary) !== expectedBinary) throw new Error(`Selected owned runtime is outside its canonical artifact layout: ${binary}`);
57
+ const manifestPath = resolve(root, 'lib/opencode/runtime-release.json');
58
+ try { expectedManifest = JSON.parse(await readFile(manifestPath, 'utf8')); }
59
+ catch (error) { throw new Error(`Default owned runtime has no independently pinned package release manifest at ${manifestPath}: ${error.message}`); }
60
+ }
61
+ const verified = await identityTools.loadNativeTestRuntime({ binary, ownedRoot, expectedManifest, env });
62
+ if (verified.binarySha256 !== identity.binarySha256
63
+ || verified.sourceRevision !== identity.sourceRevision
64
+ || verified.patchSha256 !== identity.patchSha256
65
+ || verified.lockSha256 !== identity.lockSha256) {
66
+ throw new Error('Independent owned runtime verification does not match the production runtime identity.');
67
+ }
68
+ return Object.freeze({ binary, identity, verified, binarySha256: verified.binarySha256 });
69
+ }
70
+
71
+ if (identity.kind !== 'upstream-compatible') throw new Error(`Unrecognized selected native runtime kind: ${identity.kind}`);
72
+ if (env.CTG_TEST_RUNTIME_ARTIFACT_ROOT) throw new Error('Injected owned artifact resolved as upstream-compatible; refusing the selected runtime.');
73
+ const verified = await identityTools.loadNativeTestRuntime({ binary, env });
74
+ if (verified.runtimeVersion !== identity.version || verified.runtimeVersion !== '2.0.32') {
75
+ throw new Error('Selected upstream-compatible runtime does not match the pinned compatibility version.');
76
+ }
77
+ const actual = createHash('sha256').update(await readFile(binary)).digest('hex');
78
+ if (actual !== identity.binarySha256) throw new Error(`Selected upstream-compatible runtime changed during runtime selection: ${actual}`);
79
+ const expectedHash = env.CTG_NATIVE_PTY_EXPECTED_SHA256;
80
+ if (expectedHash && actual !== expectedHash.toLowerCase()) throw new Error(`Explicit native PTY binary SHA-256 mismatch: ${actual}`);
81
+ const binarySha256 = actual;
82
+ return Object.freeze({ binary, identity, verified, binarySha256 });
83
+ }
@@ -2,6 +2,7 @@
2
2
  """Exercise a native CTG plugin command in a disposable real terminal host."""
3
3
  import argparse
4
4
  import fcntl
5
+ import hashlib
5
6
  import json
6
7
  import os
7
8
  import pathlib
@@ -17,32 +18,80 @@ import termios
17
18
  import time
18
19
 
19
20
 
21
+ def resolve_binary(value):
22
+ """Resolve explicit developer paths; default binary selection belongs to the JS runtime gate."""
23
+ if value and (os.path.isabs(value) or os.sep in value or (os.altsep and os.altsep in value)):
24
+ candidate = pathlib.Path(value).expanduser().resolve()
25
+ return str(candidate) if candidate.is_file() else None
26
+ if value:
27
+ found = shutil.which(value)
28
+ if found:
29
+ return str(pathlib.Path(found).resolve())
30
+ return None
31
+
32
+
20
33
  def main():
21
34
  parser = argparse.ArgumentParser()
22
- parser.add_argument('--binary', default=shutil.which('opencode'))
23
- parser.add_argument('--plugin', default=str(pathlib.Path(__file__).resolve().parents[1] / 'plugins/carthagent/test-plugin'))
24
- parser.add_argument('--delivery-plugin', default=str(pathlib.Path(__file__).resolve().parents[1] / 'plugins/carthagent/test-plugin'))
35
+ parser.add_argument('--binary', required=True, help='Binary selected and validated by the production runtime resolver')
36
+ parser.add_argument('--expected-sha256', required=True, help='Independently verified binary identity supplied by the JS acceptance gate')
37
+ parser.add_argument('--plugin', default=str(pathlib.Path(__file__).resolve().parents[1] / 'plugins/carthagent'))
25
38
  parser.add_argument('--command', default='/ctg-status')
26
- parser.add_argument('--expect', default=None)
27
- parser.add_argument('--completion', default='Carthagent runtime status')
39
+ parser.add_argument('--after-command', action='append', help='Submit another command after expected output; may be repeated')
40
+ parser.add_argument('--after-expect', action='append', help='Expected output for each additional command')
41
+ parser.add_argument('--expect', default='Carthagent 2.0.32')
42
+ parser.add_argument('--capture', help='Save the full raw PTY transcript for diagnosis')
43
+ parser.add_argument('--expect-color', help='Require a truecolor ANSI RGB sequence after the command, e.g. 230,196,128')
44
+ parser.add_argument('--initial-theme', help='Select a provisioned native theme before launching the TUI')
45
+ parser.add_argument('--expect-theme', help='Verify the persisted native theme after command dispatch')
46
+ parser.add_argument('--ready', help='Wait for this marker before typing')
47
+ parser.add_argument('--confirm', action='store_true', help='Wait for command submission and confirmation handling to settle')
48
+ parser.add_argument('--second-enter', action='store_true', help='Wait for command submission and confirmation handling to settle')
49
+ parser.add_argument('--dismiss-completion', action='store_true', help='Dismiss slash suggestions before submitting the explicitly typed command')
50
+ parser.add_argument('--post-submit-marker', help='Require an additional unique marker after submission')
28
51
  args = parser.parse_args()
52
+ args.binary = resolve_binary(args.binary)
29
53
  if not args.binary:
30
- parser.error('OpenCode binary is required')
31
- args.binary = str(pathlib.Path(args.binary).resolve())
32
- with tempfile.TemporaryDirectory(prefix='ctg-pty-') as temp:
54
+ parser.error('The selected OpenCode binary is missing; select and validate it through the production runtime resolver first')
55
+ if not re.fullmatch(r'[a-f0-9]{64}', args.expected_sha256):
56
+ parser.error('--expected-sha256 must be a lowercase SHA-256 digest')
57
+ actual = hashlib.sha256(pathlib.Path(args.binary).read_bytes()).hexdigest()
58
+ if actual != args.expected_sha256:
59
+ parser.error(f'Selected OpenCode binary SHA-256 changed before PTY execution: {actual}')
60
+ preferred_temp = pathlib.Path('/tmp/opencode')
61
+ with tempfile.TemporaryDirectory(prefix='ctg-pty-', dir=preferred_temp if preferred_temp.is_dir() else None) as temp:
33
62
  root = pathlib.Path(temp)
34
63
  for name in ['home', 'project', 'config/opencode', 'data', 'state', 'cache']:
35
64
  (root / name).mkdir(parents=True, exist_ok=True)
36
65
  (root / 'config/opencode/opencode.json').write_text(json.dumps({
37
- 'plugins': list(dict.fromkeys([str(pathlib.Path(args.plugin).resolve()), str(pathlib.Path(args.delivery_plugin).resolve())])), 'update': 'disable',
66
+ 'plugins': [str(pathlib.Path(args.plugin).resolve())], 'update': 'disable',
67
+ 'model': 'ctg-pty/fixture',
68
+ 'providers': {'ctg-pty': {
69
+ 'package': '@opencode/ai/providers/openai-compatible',
70
+ 'settings': {'baseURL': 'http://127.0.0.1:1/v1', 'apiKey': 'offline-fixture'},
71
+ 'models': {'fixture': {'limit': {'context': 32000, 'output': 1024}}},
72
+ }},
38
73
  }))
39
- expected = args.expect or 'OpenCode 2.0.32 ·'
74
+ # Mirror the launcher contract: custom themes are installed before the
75
+ # native TUI scans its profile. Installing from inside the running TUI
76
+ # cannot make a newly-created theme selectable in that same process.
77
+ provisioner = pathlib.Path(args.plugin).resolve() / 'src/ui/theme-provision.mjs'
78
+ subprocess.run([
79
+ 'node', '--input-type=module', '-e',
80
+ "const { provisionDefaultNativeTheme } = await import(process.argv[1]); await provisionDefaultNativeTheme(process.argv[2]);",
81
+ str(provisioner), str(root / 'config/opencode'),
82
+ ], check=True)
83
+ if args.initial_theme:
84
+ (root / 'config/opencode/cli.json').write_text(json.dumps({'theme': {'name': args.initial_theme}}))
40
85
  env = {key: os.environ[key] for key in ['PATH', 'LANG', 'LC_ALL'] if key in os.environ}
41
86
  env.update({
42
87
  'HOME': str(root / 'home'), 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor',
43
88
  'XDG_CONFIG_HOME': str(root / 'config'), 'XDG_DATA_HOME': str(root / 'data'),
44
89
  'XDG_STATE_HOME': str(root / 'state'), 'XDG_CACHE_HOME': str(root / 'cache'),
90
+ 'OPENCODE_CONFIG_DIR': str(root / 'config/opencode'),
45
91
  'OPENCODE_DB': str(root / 'database.sqlite'),
92
+ 'OPENCODE_CONFIG_PROJECT_DISABLE': 'true',
93
+ 'OPENCODE_DISABLE_MODELS_FETCH': 'true',
94
+ 'OPENCODE_DISABLE_AUTOUPDATE': 'true',
46
95
  })
47
96
  master, slave = pty.openpty()
48
97
  fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 36, 120, 0, 0))
@@ -50,10 +99,18 @@ def main():
50
99
  stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
51
100
  os.close(slave)
52
101
  output = bytearray()
102
+ transcript = bytearray()
53
103
  started = time.monotonic()
54
104
  sent = False
55
- completion_accepted = False
105
+ sent_at = None
56
106
  submitted = False
107
+ confirmation_complete = False
108
+ completion_dismissed = False
109
+ submission_at = None
110
+ enter_count = 0
111
+ after_index = 0
112
+ after_typed_at = None
113
+ after_completion_dismissed = False
57
114
  try:
58
115
  while time.monotonic() - started < 30:
59
116
  if child.poll() is not None:
@@ -61,29 +118,93 @@ def main():
61
118
  if select.select([master], [], [], 0.1)[0]:
62
119
  chunk = os.read(master, 65536)
63
120
  output.extend(chunk)
121
+ transcript.extend(chunk)
64
122
  # Terminal capability requests need replies on a headless PTY.
65
123
  if b'\x1b[6n' in chunk:
66
124
  os.write(master, b'\x1b[1;1R')
67
125
  if b'\x1b[c' in chunk:
68
126
  os.write(master, b'\x1b[?1;2c')
69
- if not sent and time.monotonic() - started > 8:
127
+ ready = args.ready.encode() in transcript if args.ready else time.monotonic() - started > 8
128
+ if not sent and ready:
129
+ output.clear()
130
+ sent_at = time.monotonic()
70
131
  os.write(master, args.command.encode())
71
132
  sent = True
72
- # The completion row is evidence that the TUI plugin has
73
- # registered its command. Enter submits the highlighted slash
74
- # command; inspect only output produced after that submission.
75
- if sent and not completion_accepted and args.completion.encode() in output:
76
- completion_accepted = True
133
+ if sent and args.dismiss_completion and not completion_dismissed and time.monotonic() - sent_at > 1:
134
+ output.clear()
135
+ os.write(master, b'\x1b')
136
+ completion_dismissed = True
137
+ sent_at = time.monotonic()
138
+ if sent and not submitted and after_index == 0 and time.monotonic() - sent_at > (0.5 if args.dismiss_completion and completion_dismissed else 1):
139
+ # Typed slash text has usually populated autocomplete now.
140
+ # Discard that frame before accepting the completion so it
141
+ # cannot satisfy the post-command assertion.
142
+ output.clear()
143
+ os.write(master, b'\r')
144
+ enter_count += 1
145
+ submitted = True
146
+ submission_at = time.monotonic()
147
+ confirmation_prompt = re.search(rb'(?i)(?:are you sure|confirm\?|\[y/n\]|\(y/n\)|press enter to confirm)', output)
148
+ if submitted and submission_at is not None and not confirmation_complete:
149
+ if confirmation_prompt:
150
+ # A second Enter must never be speculative. Keep the
151
+ # test blocked until an explicit confirmation UI is
152
+ # handled by a purpose-built confirmation action.
153
+ raise RuntimeError('Command opened a confirmation prompt; no explicit confirmation handler is configured')
154
+ if time.monotonic() - submission_at > 2:
155
+ confirmation_complete = True
156
+ after_commands = args.after_command or []
157
+ after_expects = args.after_expect or []
158
+ if len(after_commands) != len(after_expects):
159
+ raise ValueError('--after-command and --after-expect must be supplied in matching counts')
160
+ expected_output = args.expect if after_index == 0 else after_expects[after_index - 1]
161
+ if submitted and expected_output.encode() in output and after_index < len(after_commands):
162
+ output.clear()
163
+ os.write(master, after_commands[after_index].encode())
164
+ after_typed_at = time.monotonic()
165
+ after_completion_dismissed = False
166
+ after_index += 1
167
+ submitted = False
168
+ confirmation_complete = False
169
+ submission_at = None
170
+ continue
171
+ if after_index and not submitted and not after_completion_dismissed and time.monotonic() - after_typed_at > 1:
172
+ # Follow-up commands must use the same explicit submit path
173
+ # as the top-level probe. In particular, the theme command
174
+ # may leave the host's slash completion active; the first
175
+ # Enter could select a completion instead of submitting.
176
+ output.clear()
177
+ os.write(master, b'\x1b')
178
+ after_completion_dismissed = True
179
+ after_typed_at = time.monotonic()
180
+ if after_index and not submitted and after_completion_dismissed and time.monotonic() - after_typed_at > 0.5:
77
181
  os.write(master, b'\r')
78
182
  submitted = True
183
+ sent_at = time.monotonic()
184
+ submission_at = sent_at
79
185
  output.clear()
80
- plain_output = re.sub(rb'\x1b\[[0-?]*[ -/]*[@-~]', b'', bytes(output))
81
- if submitted and expected.encode() in plain_output:
82
- if b'runtime unavailable' in plain_output or args.completion.encode() in plain_output:
186
+ command_submitted = submitted and (confirmation_complete or not (args.confirm or args.second_enter))
187
+ result_output = command_submitted and expected_output.encode() in output and (not args.post_submit_marker or args.post_submit_marker.encode() in output)
188
+ if result_output:
189
+ if args.expect_theme:
190
+ theme_config = json.loads((root / 'config/opencode/cli.json').read_text())
191
+ assert theme_config['theme']['name'] == args.expect_theme, theme_config
192
+ if b'runtime unavailable' in output or b'No matching commands' in output:
83
193
  raise RuntimeError('Command was unavailable or its RPC failed')
84
- print(json.dumps({'command': args.command, 'observed': expected,
85
- 'completionAccepted': completion_accepted,
86
- 'commandSubmitted': submitted,
194
+ if args.expect_color and args.initial_theme:
195
+ theme_config = json.loads((root / 'config/opencode/cli.json').read_text())
196
+ assert theme_config['theme']['name'] == args.initial_theme, theme_config
197
+ channels = [part.strip() for part in args.expect_color.split(',')]
198
+ if len(channels) != 3 or any(not part.isdigit() or int(part) > 255 for part in channels):
199
+ raise ValueError('--expect-color must be an RGB triple such as 242,198,109')
200
+ ansi = f"\x1b[38;2;{';'.join(channels)}m".encode()
201
+ if ansi not in transcript:
202
+ print(f'Expected rendered foreground color {ansi!r} was not observed; recent raw PTY bytes: {output[-12000:]!r}')
203
+ raise AssertionError('Rendered accent color did not match the selected native theme')
204
+ print(json.dumps({'command': args.command, 'observed': expected_output,
205
+ 'postSubmission': True, 'enterCount': enter_count,
206
+ 'theme': args.initial_theme if args.expect_color else None,
207
+ 'ansiColor': args.expect_color,
87
208
  'elapsedMs': round((time.monotonic() - started) * 1000),
88
209
  'terminalBytes': len(output)}))
89
210
  return
@@ -92,8 +213,10 @@ def main():
92
213
  lines = [line for line in log.read_text(errors='replace').splitlines()
93
214
  if 'plugin' in line.lower() or 'error' in line.lower()]
94
215
  print('\n'.join(lines[-30:]))
95
- raise RuntimeError(f'Expected post-command response {expected!r} not rendered (sent={sent}, completionAccepted={completion_accepted}, submitted={submitted}): {plain[-8000:].decode(errors="replace")}')
216
+ raise RuntimeError(f'Expected {args.expect!r} not rendered: {plain[-8000:].decode(errors="replace")}')
96
217
  finally:
218
+ if args.capture:
219
+ pathlib.Path(args.capture).write_bytes(transcript)
97
220
  if child.poll() is None:
98
221
  os.killpg(child.pid, signal.SIGTERM)
99
222
  try:
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ /** Validate the explicit status trailer emitted by a wrapped native shell call. */
4
+ export function assertNativeShellResult(output, { status, passingTests } = {}) {
5
+ const match = /(?:^|\n)CTG_NATIVE_EXIT_STATUS=(\d+)\s*(?:\nExited with code \d+\s*)?$/.exec(output);
6
+ assert.ok(match, 'native shell result must end with its explicit CTG_NATIVE_EXIT_STATUS trailer');
7
+ assert.equal(Number(match[1]), status, `native shell exit status must be ${status}`);
8
+ if (passingTests !== undefined) {
9
+ assert.match(output, new RegExp(`(?:^|\\n)\\s*${passingTests} passing(?:\\s|\\()`, 'i'), `Mocha output must report ${passingTests} passing tests`);
10
+ assert.doesNotMatch(output, /(?:^|\n)\s*[1-9]\d* failing(?:\s|\()/i, 'Mocha output must not report any failing tests');
11
+ }
12
+ return Number(match[1]);
13
+ }
@@ -0,0 +1,28 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { assertNativeShellResult } from './native-public-repo-shell-result.mjs';
4
+
5
+ test('accepts exact zero-status Mocha result with expected passing count', () => {
6
+ assert.equal(assertNativeShellResult(' 111 passing (10ms)\nCTG_NATIVE_EXIT_STATUS=0', { status: 0, passingTests: 111 }), 0);
7
+ });
8
+
9
+ test('rejects Mocha output reporting passing tests and any failing tests despite a zero-status trailer', () => {
10
+ assert.throws(
11
+ () => assertNativeShellResult(' 111 passing (10ms)\n 1 failing\nCTG_NATIVE_EXIT_STATUS=0', { status: 0, passingTests: 111 }),
12
+ /must not report any failing tests/,
13
+ );
14
+ });
15
+
16
+ test('rejects nonzero native status even when output claims all package tests passed', () => {
17
+ assert.throws(
18
+ () => assertNativeShellResult(' 111 passing (10ms)\n 1 failing\nCTG_NATIVE_EXIT_STATUS=1\nExited with code 1', { status: 0, passingTests: 111 }),
19
+ /exit status must be 0/,
20
+ );
21
+ });
22
+
23
+ test('fails closed when native result has no explicit status trailer', () => {
24
+ assert.throws(
25
+ () => assertNativeShellResult(' 111 passing (10ms)', { status: 0, passingTests: 111 }),
26
+ /must end with its explicit/,
27
+ );
28
+ });
@@ -0,0 +1,201 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import { resolve, join } from 'node:path';
5
+
6
+ export const NATIVE_TEST_COMPATIBILITY_VERSION = '2.0.32';
7
+
8
+ const SHA256 = /^[a-f0-9]{64}$/;
9
+ const REVISION = /^[a-f0-9]{40,64}$/;
10
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
11
+
12
+ /**
13
+ * Resolve and verify the executable used by native acceptance tests.
14
+ *
15
+ * `expectedManifest` is the caller's trusted, package-pinned runtime-release
16
+ * record (not the adjacent ctg-runtime.json). Its `assets[platform]` entry
17
+ * pins the executable digest. Developers may override that digest with
18
+ * CTG_TEST_RUNTIME_SHA256 while retaining the expected source, patch, lock,
19
+ * compatibility, and runtime identity from the trusted release record.
20
+ *
21
+ * Upstream runtimes are accepted only when --version is exactly
22
+ * `opencode v2.0.32`. The owned CTG build is accepted only when its exact
23
+ * runtime version, provenance manifest, platform path, and independently
24
+ * expected binary digest all match. Provenance and bytes are checked before
25
+ * executing the binary. No version normalization or substring matching is
26
+ * used.
27
+ */
28
+ export async function loadNativeTestRuntime({
29
+ binary = process.env.CTG_OPENCODE_BIN,
30
+ ownedRoot,
31
+ expectedManifest,
32
+ platform = nativePlatform(),
33
+ env = process.env,
34
+ spawnImpl = spawn,
35
+ } = {}) {
36
+ if (typeof binary !== 'string' || !binary.trim()) {
37
+ throw new Error('Pass the native test executable as `binary` or CTG_OPENCODE_BIN');
38
+ }
39
+ const executable = resolve(binary);
40
+ if (!ownedRoot) {
41
+ const versionOutput = await captureVersion(executable, spawnImpl, env);
42
+ if (versionOutput !== `opencode v${NATIVE_TEST_COMPATIBILITY_VERSION}`) {
43
+ throw new Error(`Unsupported native test runtime version: ${JSON.stringify(versionOutput)}`);
44
+ }
45
+ return Object.freeze({
46
+ binary: executable,
47
+ compatibilityVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
48
+ runtimeVersion: NATIVE_TEST_COMPATIBILITY_VERSION,
49
+ });
50
+ }
51
+
52
+ if (typeof ownedRoot !== 'string' || !ownedRoot.trim()) throw new Error('ownedRoot must be a non-empty path');
53
+ const root = resolve(ownedRoot);
54
+ const trusted = validateTrustedRelease(expectedManifest, platform);
55
+ const target = trusted.assets[platform];
56
+ const executableName = platform.startsWith('windows-') ? 'opencode.exe' : 'opencode';
57
+ const expectedBinary = resolve(root, `cli-${platform}`, 'bin', executableName);
58
+ if (executable !== expectedBinary) throw new Error(`Owned runtime executable must be ${expectedBinary}`);
59
+
60
+ const actualSha256 = createHash('sha256').update(await readFile(executable)).digest('hex');
61
+ const expectedSha256 = env.CTG_TEST_RUNTIME_SHA256 ?? target.executableSha256;
62
+ if (!SHA256.test(expectedSha256)) throw new Error('Expected owned runtime executable SHA-256 is invalid');
63
+ if (actualSha256 !== expectedSha256) {
64
+ throw new Error(`Owned runtime binary SHA-256 mismatch: expected ${expectedSha256}, got ${actualSha256}`);
65
+ }
66
+
67
+ const manifestPath = join(root, 'ctg-runtime.json');
68
+ let manifest;
69
+ try {
70
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
71
+ } catch (error) {
72
+ throw new Error(`Could not read owned runtime manifest at ${manifestPath}: ${error.message}`, { cause: error });
73
+ }
74
+ const expectedInner = {
75
+ owner: 'Carthagent',
76
+ sourceRepository: 'https://github.com/anomalyco/opencode',
77
+ sourceRevision: trusted.sourceRevision,
78
+ patchSha256: trusted.patchSha256,
79
+ lockSha256: trusted.lockSha256,
80
+ compatibilityVersion: trusted.compatibilityVersion,
81
+ runtimeVersion: trusted.runtimeVersion,
82
+ channel: `ctg.${trusted.compatibilityVersion}`,
83
+ platform,
84
+ webUI: false,
85
+ };
86
+ for (const [key, expected] of Object.entries(expectedInner)) {
87
+ if (manifest?.[key] !== expected) throw new Error(`Owned runtime ${key} mismatch: expected ${String(expected)}, got ${String(manifest?.[key])}`);
88
+ }
89
+
90
+ const versionOutput = await captureVersion(executable, spawnImpl, env);
91
+ if (versionOutput !== `opencode v${trusted.runtimeVersion}`) {
92
+ throw new Error(`Unsupported owned native test runtime version: expected opencode v${trusted.runtimeVersion}, got ${JSON.stringify(versionOutput)}`);
93
+ }
94
+ return Object.freeze({
95
+ binary: executable,
96
+ compatibilityVersion: trusted.compatibilityVersion,
97
+ runtimeVersion: trusted.runtimeVersion,
98
+ binarySha256: actualSha256,
99
+ sourceRevision: manifest.sourceRevision,
100
+ patchSha256: manifest.patchSha256,
101
+ lockSha256: manifest.lockSha256,
102
+ platform,
103
+ });
104
+ }
105
+
106
+ /** Verify a test-supplied artifact against explicit trusted metadata before a resolver can execute it. */
107
+ export async function preflightNativeTestRuntimeArtifact({
108
+ ownedRoot,
109
+ expectedManifestPath,
110
+ env = process.env,
111
+ platform = nativePlatform(),
112
+ spawnImpl = spawn,
113
+ } = {}) {
114
+ if (typeof ownedRoot !== 'string' || !ownedRoot.trim()) throw new Error('ownedRoot must be a non-empty test artifact path');
115
+ if (typeof expectedManifestPath !== 'string' || !expectedManifestPath.trim()) {
116
+ throw new Error('A trusted CTG_TEST_RUNTIME_RELEASE_MANIFEST is required with CTG_TEST_RUNTIME_ARTIFACT_ROOT');
117
+ }
118
+ let expectedManifest;
119
+ try {
120
+ expectedManifest = JSON.parse(await readFile(expectedManifestPath, 'utf8'));
121
+ } catch (error) {
122
+ throw new Error(`Could not read trusted native runtime release manifest at ${expectedManifestPath}: ${error.message}`, { cause: error });
123
+ }
124
+ const executableName = platform.startsWith('windows-') ? 'opencode.exe' : 'opencode';
125
+ const binary = join(resolve(ownedRoot), `cli-${platform}`, 'bin', executableName);
126
+ const identity = await loadNativeTestRuntime({ binary, ownedRoot, expectedManifest, env, platform, spawnImpl });
127
+ return Object.freeze({ identity, expectedManifest });
128
+ }
129
+
130
+ function validateTrustedRelease(release, platform) {
131
+ const invalid = reason => { throw new Error(`Invalid trusted native runtime release manifest: ${reason}`); };
132
+ if (!release || release.version !== 1 || !RELEASE_VERSION.test(release.releaseVersion ?? '')
133
+ || release.compatibilityVersion !== NATIVE_TEST_COMPATIBILITY_VERSION
134
+ || typeof release.runtimeVersion !== 'string' || !release.runtimeVersion.startsWith('0.0.0-ctg.')
135
+ || !REVISION.test(release.sourceRevision ?? '')
136
+ || !SHA256.test(release.patchSha256 ?? '') || !SHA256.test(release.lockSha256 ?? '')
137
+ || !release.assets || typeof release.assets !== 'object' || Array.isArray(release.assets)) invalid('required pinned identity fields are missing or invalid');
138
+ const asset = release.assets[platform];
139
+ if (!asset || asset.name !== `opencode-${platform}.tar.gz` || !SHA256.test(asset.executableSha256 ?? '') || !SHA256.test(asset.archiveSha256 ?? '')) {
140
+ invalid(`no exact asset digest is pinned for ${platform}`);
141
+ }
142
+ return release;
143
+ }
144
+
145
+ function nativePlatform() {
146
+ const os = process.platform === 'win32' ? 'windows' : process.platform;
147
+ return `${os}-${process.arch}`;
148
+ }
149
+
150
+ function captureVersion(binary, spawnImpl, env) {
151
+ return new Promise((resolveOutput, reject) => {
152
+ let child;
153
+ try {
154
+ const childEnv = { ...env };
155
+ for (const key of Object.keys(childEnv)) {
156
+ if (key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN') delete childEnv[key];
157
+ }
158
+ child = spawnImpl(binary, ['--version'], { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
159
+ } catch (error) {
160
+ reject(error);
161
+ return;
162
+ }
163
+ let stdout = '';
164
+ let stderr = '';
165
+ let settled = false;
166
+ const finish = callback => value => {
167
+ if (settled) return;
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ callback(value);
171
+ };
172
+ const onOutput = (target, chunk) => {
173
+ if (settled) return;
174
+ target.value += chunk;
175
+ if (target.value.length > 64 * 1024) {
176
+ child.kill?.('SIGKILL');
177
+ finish(reject)(new Error('Native test runtime --version output exceeded 64 KiB'));
178
+ }
179
+ };
180
+ const stdoutBuffer = { value: stdout };
181
+ const stderrBuffer = { value: stderr };
182
+ const timer = setTimeout(() => {
183
+ child.kill?.('SIGKILL');
184
+ finish(reject)(new Error('Native test runtime --version timed out after 5 seconds'));
185
+ }, 5000);
186
+ child.stdout?.setEncoding?.('utf8');
187
+ child.stderr?.setEncoding?.('utf8');
188
+ child.stdout?.on('data', chunk => onOutput(stdoutBuffer, chunk));
189
+ child.stderr?.on('data', chunk => onOutput(stderrBuffer, chunk));
190
+ child.once('error', finish(reject));
191
+ child.once('close', code => {
192
+ stdout = stdoutBuffer.value;
193
+ stderr = stderrBuffer.value;
194
+ if (code !== 0) {
195
+ finish(reject)(new Error(`Native test runtime --version exited ${code}: ${stderr.trim()}`));
196
+ return;
197
+ }
198
+ finish(resolveOutput)(stdout.trim());
199
+ });
200
+ });
201
+ }
@@ -0,0 +1,38 @@
1
+ """Strict child-environment allowlists for the native slash acceptance probe."""
2
+ from pathlib import Path
3
+
4
+ SAFE_PARENT_ENV = ('PATH', 'LANG', 'LC_ALL')
5
+ SAFE_WINDOWS_ENV = ('SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT')
6
+ TUI_ENV_KEYS = frozenset((*SAFE_PARENT_ENV, *SAFE_WINDOWS_ENV, 'HOME', 'USERPROFILE',
7
+ 'APPDATA', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME',
8
+ 'XDG_CACHE_HOME', 'OPENCODE_CONFIG_PROJECT_DISABLE', 'OPENCODE_PASSWORD',
9
+ 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME', 'TERM', 'COLORTERM'))
10
+ FIXTURE_ENV_KEYS = TUI_ENV_KEYS | frozenset((
11
+ 'CTG_OWNED_OPENCODE_RUNTIME', 'CTG_TEST_RUNTIME_RELEASE_MANIFEST'))
12
+
13
+
14
+ def fixture_environment(source, home, owned_root, release_manifest):
15
+ result = {key: source[key] for key in (*SAFE_PARENT_ENV, *SAFE_WINDOWS_ENV)
16
+ if key in source}
17
+ result['PATH'] = result.get('PATH', '/usr/bin:/bin')
18
+ result['LANG'] = result.get('LANG', 'C.UTF-8')
19
+ result.update({
20
+ 'HOME': str(home), 'USERPROFILE': str(home),
21
+ 'CTG_OWNED_OPENCODE_RUNTIME': str(owned_root),
22
+ 'CTG_TEST_RUNTIME_RELEASE_MANIFEST': str(release_manifest),
23
+ 'XDG_CONFIG_HOME': str(Path(home) / 'config'),
24
+ 'XDG_DATA_HOME': str(Path(home) / 'data'),
25
+ 'XDG_STATE_HOME': str(Path(home) / 'state'),
26
+ 'XDG_CACHE_HOME': str(Path(home) / 'cache'),
27
+ })
28
+ return result
29
+
30
+
31
+ def assert_clean_child_environment(env, allowed=TUI_ENV_KEYS):
32
+ forbidden = {'ctg_runtime_github_token', 'anthropic_api_key', 'openai_api_key',
33
+ 'openrouter_api_key', 'opencode_api_key'}
34
+ if any(key.casefold() in forbidden for key in env):
35
+ raise AssertionError('child environment contains a provider/runtime credential')
36
+ unexpected = sorted(set(env) - allowed)
37
+ if unexpected:
38
+ raise AssertionError(f'child environment contains non-allowlisted names: {unexpected}')