carthagent 1.0.7 → 1.1.0-preview.12

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 (275) hide show
  1. package/README.md +80 -79
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  4. package/docs/glyph-review/test-isolation-v1.md +26 -0
  5. package/docs/glyph-review/typecheck-base.log +1 -0
  6. package/docs/glyph-review/typecheck-v4.log +1 -0
  7. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  8. package/docs/help-dispatch-review.md +69 -0
  9. package/docs/legacy-cli.md +150 -0
  10. package/docs/live-theme-plugin-api.md +18 -0
  11. package/docs/native-cli.md +102 -0
  12. package/docs/native-compatibility.md +16 -0
  13. package/docs/native-daily-quests.md +132 -0
  14. package/docs/native-glyph-pty.md +59 -0
  15. package/docs/native-manual-testing.md +314 -0
  16. package/docs/native-slash-acceptance-handoff.md +49 -0
  17. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  18. package/docs/native-terminal-performance.md +310 -0
  19. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  20. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  21. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  22. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  23. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  24. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  31. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  32. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  33. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  35. package/docs/native-terminal-semantics.md +89 -0
  36. package/docs/native-upgrade.md +177 -0
  37. package/docs/opencode-composed-source-review.md +89 -0
  38. package/docs/opencode-migration.md +225 -9
  39. package/docs/opencode-owned-runtime.md +241 -0
  40. package/docs/opencode-runtime.md +12 -0
  41. package/docs/opencode-theme-provider-proof/README.md +55 -0
  42. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  43. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  44. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  45. package/docs/owned-host-controls-proof.md +39 -0
  46. package/docs/owned-runtime-release-manifest.md +42 -0
  47. package/docs/owned-visual-clear-proof.md +40 -0
  48. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  49. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  50. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  51. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  52. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  53. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  54. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  57. package/docs/release-notes-native-draft.md +73 -0
  58. package/docs/release-notes-preview.10.md +121 -0
  59. package/docs/release-notes-preview.11.md +76 -0
  60. package/docs/release-notes-preview.12.md +55 -0
  61. package/docs/release-notes-preview.9.md +130 -0
  62. package/evaluate.mjs +28 -1
  63. package/lib/budget.mjs +1 -1
  64. package/lib/codegraph.mjs +1 -1
  65. package/lib/delivery.mjs +3 -2
  66. package/lib/environment.mjs +8 -0
  67. package/lib/guidance.mjs +1 -1
  68. package/lib/opencode/cli-runtime.mjs +1171 -0
  69. package/lib/opencode/cli.mjs +252 -0
  70. package/lib/opencode/credentials.mjs +63 -8
  71. package/lib/opencode/demo.mjs +53 -0
  72. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  73. package/lib/opencode/evaluation.mjs +32 -12
  74. package/lib/opencode/headless.mjs +8 -4
  75. package/lib/opencode/provider-policy.mjs +23 -0
  76. package/lib/opencode/review.mjs +37 -16
  77. package/lib/opencode/runtime-artifact.mjs +335 -0
  78. package/lib/opencode/runtime-github-transport.mjs +121 -0
  79. package/lib/opencode/runtime-release.json +36 -0
  80. package/lib/opencode/runtime.mjs +298 -20
  81. package/lib/opencode/service.mjs +77 -35
  82. package/lib/opencode/session-options.mjs +6 -2
  83. package/lib/opencode/update.mjs +222 -0
  84. package/lib/opencode/workspace.mjs +1 -1
  85. package/lib/planning-advice.mjs +2 -2
  86. package/lib/tui/framing.mjs +1 -80
  87. package/lib/update.mjs +10 -1
  88. package/package.json +33 -7
  89. package/plugins/carthagent/README.md +23 -0
  90. package/plugins/carthagent/src/bootstrap.ts +1 -1
  91. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  92. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  93. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  94. package/plugins/carthagent/src/index.ts +71 -24
  95. package/plugins/carthagent/src/rpc.ts +51 -6
  96. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  97. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  98. package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
  99. package/plugins/carthagent/src/server/delivery.ts +217 -106
  100. package/plugins/carthagent/src/server/framing.mjs +80 -0
  101. package/plugins/carthagent/src/server/policy.ts +1 -1
  102. package/plugins/carthagent/src/server/providers.ts +58 -30
  103. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  104. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  105. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  106. package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
  107. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  108. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  109. package/plugins/carthagent/src/testing.ts +5 -0
  110. package/plugins/carthagent/src/tui.tsx +492 -0
  111. package/plugins/carthagent/src/ui/client.ts +125 -0
  112. package/plugins/carthagent/src/ui/commands.ts +70 -0
  113. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  114. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  115. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  116. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  117. package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
  118. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  119. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  120. package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
  121. package/plugins/carthagent/src/ui/native-review.mjs +154 -0
  122. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  123. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  124. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  125. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  126. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  127. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  128. package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
  129. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  130. package/plugins/carthagent/src/ui/voyages.tsx +127 -0
  131. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  132. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  133. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  134. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  135. package/plugins/carthagent/tests/commands.test.ts +107 -0
  136. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  137. package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
  138. package/plugins/carthagent/themes/carthage.json +67 -0
  139. package/plugins/carthagent/tui.ts +26 -1
  140. package/script/ctg/build-owned-runtime.sh +166 -0
  141. package/tests/budget-engine.test.mjs +4 -3
  142. package/tests/cli.test.mjs +52 -96
  143. package/tests/daily-evidence.test.mjs +62 -0
  144. package/tests/delivery.test.mjs +22 -1
  145. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  146. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  147. package/tests/installed-runtime-contract.test.mjs +43 -0
  148. package/tests/native-cli-host-smoke.mjs +457 -0
  149. package/tests/native-daily-editor-focus.test.mjs +213 -0
  150. package/tests/native-daily-editor.test.mjs +43 -0
  151. package/tests/native-daily-quests-layout.test.mjs +53 -0
  152. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  153. package/tests/native-daily-quests-ui.test.mjs +778 -0
  154. package/tests/native-daily-source.test.mjs +426 -0
  155. package/tests/native-demo-ui.test.mjs +48 -0
  156. package/tests/native-evaluation-admission.test.mjs +24 -0
  157. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  158. package/tests/native-fixture-runtime.test.mjs +111 -0
  159. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  160. package/tests/native-glyph-pty.test.mjs +107 -0
  161. package/tests/native-manual-projects.test.mjs +170 -0
  162. package/tests/native-owned-runtime-env.test.mjs +140 -0
  163. package/tests/native-paired-guards.test.mjs +464 -0
  164. package/tests/native-provider-policy.test.mjs +31 -0
  165. package/tests/native-pty-invocation.test.mjs +114 -0
  166. package/tests/native-pty-runtime.test.mjs +45 -0
  167. package/tests/native-resume-session.test.mjs +46 -0
  168. package/tests/native-review-consumer.test.mjs +267 -0
  169. package/tests/native-runtime-identity.test.mjs +214 -0
  170. package/tests/native-ship-parity.test.mjs +526 -0
  171. package/tests/native-ship-plan.test.mjs +75 -0
  172. package/tests/native-ship-rpc.test.mjs +71 -0
  173. package/tests/native-ui-commands.test.mjs +6 -0
  174. package/tests/native-voyages-layout.test.mjs +28 -0
  175. package/tests/native-voyages-navigation.test.mjs +169 -0
  176. package/tests/native-voyages-rpc.test.mjs +395 -0
  177. package/tests/native-voyages-ui.test.mjs +277 -0
  178. package/tests/opencode-atomic-write.test.mjs +55 -0
  179. package/tests/opencode-cli.test.mjs +1312 -0
  180. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  181. package/tests/opencode-providers.test.mjs +245 -18
  182. package/tests/opencode-runtime.test.mjs +546 -8
  183. package/tests/opencode-session-controls.test.mjs +15 -0
  184. package/tests/opencode-ship-rpc.test.mjs +71 -0
  185. package/tests/opencode-update.test.mjs +475 -0
  186. package/tests/opencode-workspace.test.mjs +51 -2
  187. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  188. package/tests/release-workflow.test.mjs +46 -0
  189. package/tests/runtime-artifact.test.mjs +269 -0
  190. package/tests/runtime-development-trust.test.mjs +230 -0
  191. package/tests/runtime-github-transport.test.mjs +132 -0
  192. package/tests/runtime-token-env.test.mjs +23 -0
  193. package/tests/test_native_terminal_probe.py +76 -0
  194. package/tests/test_native_terminal_semantics.py +232 -0
  195. package/tests/update.test.mjs +36 -0
  196. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  197. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  198. package/tests/workspace-live.test.mjs +127 -0
  199. package/tools/benchmark/diagnose-request.mjs +59 -36
  200. package/tools/benchmark/eval-common.mjs +3 -3
  201. package/tools/benchmark/evaluators.test.mjs +4 -4
  202. package/tools/benchmark/guard.test.mjs +35 -2
  203. package/tools/benchmark/guard.ts +20 -7
  204. package/tools/benchmark/planning-startup.test.mjs +30 -34
  205. package/tools/benchmark/runner.mjs +12 -7
  206. package/tools/benchmark/runner.test.mjs +23 -7
  207. package/tools/benchmark/upstream-error.test.mjs +16 -2
  208. package/tools/generate-owned-runtime-release.mjs +217 -0
  209. package/tools/installed-runtime-contract.mjs +16 -0
  210. package/tools/manual-runtime-manifest.mjs +39 -0
  211. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  212. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  213. package/tools/native-fixture.mjs +199 -40
  214. package/tools/native-glyph-helpers.py +127 -0
  215. package/tools/native-glyph-pty.py +144 -0
  216. package/tools/native-manual-projects.mjs +259 -0
  217. package/tools/native-manual.mjs +338 -0
  218. package/tools/native-model-picker-acceptance.py +306 -0
  219. package/tools/native-owned-runtime-env.sh +47 -0
  220. package/tools/native-paired-guards.mjs +282 -0
  221. package/tools/native-paired-manifest.mjs +72 -0
  222. package/tools/native-paired-orchestration.sh +51 -0
  223. package/tools/native-plugin-readiness.mjs +33 -0
  224. package/tools/native-plugin-readiness.test.mjs +53 -0
  225. package/tools/native-pty-invocation.mjs +26 -0
  226. package/tools/native-pty-runtime.mjs +83 -0
  227. package/tools/native-pty-smoke.py +147 -24
  228. package/tools/native-public-repo-shell-result.mjs +13 -0
  229. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  230. package/tools/native-runtime-identity.mjs +201 -0
  231. package/tools/native-slash-acceptance-env.py +38 -0
  232. package/tools/native-terminal-semantics.py +876 -0
  233. package/tools/owned-shell-observer/index.ts +16 -0
  234. package/tools/owned-shell-observer/package.json +1 -0
  235. package/tools/owned-visual-clear-env.py +48 -0
  236. package/tools/owned-visual-clear-model.py +21 -0
  237. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  238. package/tools/prepare-native-manual.mjs +122 -0
  239. package/tools/run-native-terminal-paired.sh +403 -0
  240. package/tools/runtime-token-env.mjs +8 -0
  241. package/tools/seed-native-switch-fixture.mjs +79 -0
  242. package/tools/test-native-controls.mjs +3 -1
  243. package/tools/test-native-daily-quests.mjs +65 -0
  244. package/tools/test-native-default-cli.mjs +106 -0
  245. package/tools/test-native-delivery-host.mjs +100 -18
  246. package/tools/test-native-evaluation.mjs +7 -3
  247. package/tools/test-native-glyph-pty.mjs +94 -0
  248. package/tools/test-native-host.mjs +83 -61
  249. package/tools/test-native-outcomes.mjs +3 -1
  250. package/tools/test-native-package.mjs +206 -38
  251. package/tools/test-native-performance.mjs +26 -0
  252. package/tools/test-native-product.mjs +90 -0
  253. package/tools/test-native-pty.mjs +34 -9
  254. package/tools/test-native-public-repo.mjs +224 -0
  255. package/tools/test-native-refresh-reentry.mjs +106 -0
  256. package/tools/test-native-report.mjs +27 -20
  257. package/tools/test-native-review.mjs +75 -0
  258. package/tools/test-native-runtime.mjs +120 -0
  259. package/tools/test-native-ship-models.mjs +176 -0
  260. package/tools/test-native-ship-walkthrough.mjs +96 -0
  261. package/tools/test-native-slash-commands.mjs +125 -0
  262. package/tools/test-native-slash-commands.py +242 -0
  263. package/tools/test-native-swe-host.mjs +224 -55
  264. package/tools/test-native-tasks.mjs +77 -0
  265. package/tools/test-native-terminal-performance.py +887 -0
  266. package/tools/test-native-terminal-readiness.sh +85 -0
  267. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  268. package/tools/test-native-visual-clear-installed.mjs +48 -0
  269. package/tools/test-owned-host-controls.mjs +238 -0
  270. package/tools/test-owned-opencode-runtime.mjs +35 -0
  271. package/tools/test-owned-visual-clear-env.py +120 -0
  272. package/tools/test-owned-visual-clear.py +373 -0
  273. package/tools/verify-draft-runtime-gate.mjs +138 -0
  274. package/tools/verify-owned-runtime-release.mjs +114 -0
  275. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,111 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { resolveFixtureRuntime } from '../tools/native-fixture.mjs';
8
+
9
+ test('owned staging hash mismatch is rejected before product resolver can execute --version', async t => {
10
+ const root = await mkdtemp(join(tmpdir(), 'ctg-fixture-runtime-'));
11
+ t.after(() => rm(root, { recursive: true, force: true }));
12
+ const platform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
13
+ const ownedRoot = join(root, 'owned');
14
+ const binary = join(ownedRoot, `cli-${platform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
15
+ const marker = join(root, 'version-executed');
16
+ await mkdir(join(ownedRoot, `cli-${platform}`, 'bin'), { recursive: true });
17
+ await writeFile(binary, `#!/bin/sh\nprintf 'opencode v0.0.0-ctg.2.0.32\\n'\nprintf ran > '${marker}'\n`);
18
+ if (process.platform !== 'win32') await chmod(binary, 0o755);
19
+ const digest = createHash('sha256').update(await (await import('node:fs/promises')).readFile(binary)).digest('hex');
20
+ const releasePath = join(root, 'lib/opencode/runtime-release.json');
21
+ await mkdir(join(root, 'lib/opencode'), { recursive: true });
22
+ await writeFile(releasePath, JSON.stringify({
23
+ version: 1,
24
+ releaseVersion: '1.2.3',
25
+ compatibilityVersion: '2.0.32',
26
+ runtimeVersion: '0.0.0-ctg.2.0.32',
27
+ sourceRevision: '843a6c514e606b436a7fe713422ace0a93ad3d61',
28
+ patchSha256: 'b'.repeat(64),
29
+ lockSha256: 'c'.repeat(64),
30
+ assets: { [platform]: {
31
+ name: `opencode-${platform}.tar.gz`, archiveSha256: 'd'.repeat(64),
32
+ executableSha256: digest === 'a'.repeat(64) ? 'b'.repeat(64) : 'a'.repeat(64),
33
+ } },
34
+ }));
35
+ let productResolverCalled = false;
36
+ await assert.rejects(resolveFixtureRuntime({
37
+ packageRoot: root,
38
+ env: { CTG_OWNED_OPENCODE_RUNTIME: ownedRoot },
39
+ productRuntime: {
40
+ async resolveOpenCode() { productResolverCalled = true; throw Error('resolver should not be reached'); },
41
+ async runtimeIdentity() { throw Error('identity should not be reached'); },
42
+ },
43
+ }), /binary SHA-256 mismatch/);
44
+ assert.equal(productResolverCalled, false);
45
+ await assert.rejects(import('node:fs/promises').then(fs => fs.access(marker)), { code: 'ENOENT' });
46
+ });
47
+
48
+ test('product runtime selection is reverified by the trusted helper and preserves separate versions', async t => {
49
+ const root = await mkdtemp(join(tmpdir(), 'ctg-fixture-runtime-selected-'));
50
+ t.after(() => rm(root, { recursive: true, force: true }));
51
+ const platform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
52
+ const ownedRoot = join(root, 'owned');
53
+ const binary = join(ownedRoot, `cli-${platform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
54
+ await mkdir(join(ownedRoot, `cli-${platform}`, 'bin'), { recursive: true });
55
+ await writeFile(binary, 'already resolver-verified fixture bytes');
56
+ const expectedManifest = { trusted: true };
57
+ const releasePath = join(root, 'release.json');
58
+ await writeFile(releasePath, JSON.stringify(expectedManifest));
59
+ const options = { sentinel: Symbol('same-options-object') };
60
+ const calls = [];
61
+ const env = {
62
+ CTG_OWNED_OPENCODE_RUNTIME: ownedRoot,
63
+ CTG_TEST_RUNTIME_RELEASE_MANIFEST: releasePath,
64
+ CTG_TEST_RUNTIME_SHA256: 'e'.repeat(64),
65
+ };
66
+ const verified = {
67
+ binary,
68
+ compatibilityVersion: '2.0.32',
69
+ runtimeVersion: '0.0.0-ctg.2.0.32',
70
+ binarySha256: 'f'.repeat(64),
71
+ sourceRevision: '843a6c514e606b436a7fe713422ace0a93ad3d61',
72
+ patchSha256: 'b'.repeat(64),
73
+ lockSha256: 'c'.repeat(64),
74
+ platform,
75
+ };
76
+ let resolverCalled = false;
77
+ const result = await resolveFixtureRuntime({
78
+ packageRoot: root,
79
+ env,
80
+ options,
81
+ productRuntime: {
82
+ async resolveOpenCode(received) {
83
+ resolverCalled = true;
84
+ assert.equal(received, options);
85
+ return binary;
86
+ },
87
+ async runtimeIdentity(receivedBinary, received) {
88
+ assert.equal(receivedBinary, binary);
89
+ assert.equal(received, options);
90
+ return {
91
+ kind: 'ctg-owned', binaryPath: binary, binarySha256: verified.binarySha256,
92
+ version: verified.runtimeVersion, sourceRevision: verified.sourceRevision,
93
+ patchSha256: verified.patchSha256, lockSha256: verified.lockSha256,
94
+ };
95
+ },
96
+ },
97
+ testRuntimeLoader: async input => {
98
+ calls.push(input);
99
+ return verified;
100
+ },
101
+ });
102
+ assert.equal(resolverCalled, true);
103
+ assert.equal(calls.length, 2, 'preflight selected staging bytes and reverify selected product identity');
104
+ assert.equal(calls[0].env.CTG_TEST_RUNTIME_SHA256, 'e'.repeat(64));
105
+ assert.deepEqual(calls[0].expectedManifest, expectedManifest);
106
+ assert.equal(result.binary, binary);
107
+ assert.equal(result.runtimeIdentity.compatibilityVersion, '2.0.32');
108
+ assert.equal(result.runtimeIdentity.runtimeVersion, '0.0.0-ctg.2.0.32');
109
+ assert.equal(result.runtimeIdentity.binarySha256, verified.binarySha256);
110
+ assert.equal(result.runtimeIdentity.kind, 'ctg-owned');
111
+ });
@@ -0,0 +1,67 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { fixtureStopDiagnostics, sanitizedNativeOutputTail } from '../tools/native-fixture-stop-diagnostics.mjs';
4
+
5
+ const child = { pid: 21359, exitCode: null, signalCode: null, killed: false };
6
+ const input = { child, output: 'INFO stopping\nERROR shutdown timeout', elapsedMs: 5001.4 };
7
+
8
+ test('native output tail is bounded and emits only fixed metadata, never raw secrets', () => {
9
+ const secrets = ['0123456789abcdef', 'Basic b3BlbmNvZGU6c2VjcmV0', 'Bearer fixture.token.fixture', 'offline-refresh', 'verify-secret', 'unknown-unlabelled-secret'];
10
+ const output = `${'DEBUG old\n'.repeat(3000)}${secrets.map(secret => `ERROR shutdown authorization password=${secret}`).join('\n')}`;
11
+ const tail = sanitizedNativeOutputTail(output);
12
+ assert.equal(tail.length, 20);
13
+ const serialized = JSON.stringify(tail);
14
+ for (const secret of secrets) assert.ok(!serialized.includes(secret));
15
+ assert.deepEqual(tail.at(-1), { level: 'ERROR', markers: ['shutdown', 'error'] });
16
+ assert.ok(serialized.length < 4096);
17
+ });
18
+
19
+ test('failure evidence snapshots child state and queries only exact PID without argv or environment', async () => {
20
+ const evidence = await fixtureStopDiagnostics(input, { platform: 'darwin', run: async (command, args, options) => {
21
+ assert.equal(command, 'ps');
22
+ assert.deepEqual(args, ['-p', '21359', '-o', 'pid=,ppid=,stat=,etime=,time=']);
23
+ assert.deepEqual(options, { timeout: 2000, maxBuffer: 4096, windowsHide: true });
24
+ child.exitCode = 0;
25
+ return { stdout: ' 21359 123 Z+ 00:05 0:00.01\n' };
26
+ } });
27
+ child.exitCode = null;
28
+ assert.equal(evidence.elapsedMs, 5001);
29
+ assert.equal(evidence.child.exitCode, null);
30
+ assert.deepEqual(evidence.processState, { status: 'observed', state: '21359 123 Z+ 00:05 0:00.01' });
31
+ });
32
+
33
+ test('query errors and unexpected output cannot leak command output or secrets', async () => {
34
+ for (const run of [
35
+ async () => ({ stdout: 'password=secret' }),
36
+ async () => { throw Object.assign(new Error('secret'), { stderr: 'Bearer secret', code: 1 }); },
37
+ async () => { throw Object.assign(new Error('secret'), { code: 'ENOENT', killed: true }); },
38
+ ]) {
39
+ const evidence = await fixtureStopDiagnostics(input, { run });
40
+ assert.ok(!JSON.stringify(evidence).includes('secret'));
41
+ assert.ok(['unrecognized-output', 'query-failed'].includes(evidence.processState.status));
42
+ }
43
+ });
44
+
45
+ test('Windows queries exact PID and accepts only fixed boolean state', async () => {
46
+ const evidence = await fixtureStopDiagnostics(input, { platform: 'win32', run: async (command, args) => {
47
+ assert.equal(command, 'powershell.exe');
48
+ assert.match(args.at(-1), /Get-Process -Id 21359 /);
49
+ assert.doesNotMatch(args.at(-1), /CommandLine|Environment/);
50
+ return { stdout: 'pid=21359 responding=True\r\n' };
51
+ } });
52
+ assert.equal(evidence.processState.status, 'observed');
53
+ });
54
+
55
+ test('missing PID skips OS query, empty result records absence, exited child state is retained', async () => {
56
+ const missing = await fixtureStopDiagnostics({ ...input, child: { ...child, pid: undefined } }, { run: () => assert.fail('must not run') });
57
+ assert.equal(missing.processState.status, 'no-child-pid');
58
+ const exited = await fixtureStopDiagnostics({ ...input, child: { ...child, signalCode: 'SIGTERM' } }, { run: async () => ({ stdout: '' }) });
59
+ assert.equal(exited.child.signalCode, 'SIGTERM');
60
+ assert.equal(exited.processState.status, 'not-found');
61
+ });
62
+
63
+ test('real read-only process query observes this exact test process', async () => {
64
+ const evidence = await fixtureStopDiagnostics({ ...input, child: { ...child, pid: process.pid } });
65
+ assert.equal(evidence.processState.status, 'observed');
66
+ assert.match(evidence.processState.state, new RegExp(`^(?:pid=)?${process.pid}\\s`));
67
+ });
@@ -0,0 +1,107 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { delimiter, dirname, join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const helpers = join(root, 'tools/native-glyph-helpers.py');
10
+ const candidates = [
11
+ process.env.PYTHON,
12
+ join(root, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'),
13
+ process.platform === 'win32' ? 'python.exe' : 'python3',
14
+ 'python',
15
+ ].filter(Boolean);
16
+ const resolveExecutable = candidate => {
17
+ if (existsSync(candidate)) return candidate;
18
+ for (const directory of (process.env.PATH || '').split(delimiter)) {
19
+ const path = join(directory, candidate);
20
+ if (existsSync(path)) return path;
21
+ }
22
+ return null;
23
+ };
24
+ const python = candidates.map(resolveExecutable).find(Boolean);
25
+ const skipPython = python ? false : 'Python was not found; set PYTHON to an interpreter path';
26
+
27
+ test('glyph child environment does not inherit the draft-runtime release token', { skip: skipPython }, () => {
28
+ const result = spawnSync(python, ['-c', `import importlib.util, pathlib
29
+ spec=importlib.util.spec_from_file_location('helpers', ${JSON.stringify(helpers)})
30
+ module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
31
+ root=pathlib.Path('/tmp/isolated')
32
+ env=module.child_environment(root, root/'config', 'ascii', {'PATH':'/bin', 'CTG_RUNTIME_GITHUB_TOKEN':'sentinel'})
33
+ assert 'CTG_RUNTIME_GITHUB_TOKEN' not in env
34
+ assert env['CTG_TUI_GLYPHS']=='ascii'
35
+ assert env['HOME']==str(root/'home')
36
+ print('PASS')`], { encoding: 'utf8' });
37
+ assert.equal(result.status, 0, result.stderr);
38
+ assert.match(result.stdout, /PASS/);
39
+ });
40
+
41
+ test('composer assertion requires the exact ordered Unicode payload on its rendered row', { skip: skipPython }, () => {
42
+ const result = spawnSync(python, ['-c', `import importlib.util
43
+ spec=importlib.util.spec_from_file_location('helpers', ${JSON.stringify(helpers)})
44
+ module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
45
+ expected=module.USER_TEXT
46
+ assert module.assert_composer_payload('logo █▀▀ OpenCode\\n┃ '+expected+'\\n')
47
+ mutants=[
48
+ 'logo '+expected+'\\n┃ CTG_GLYPH_PAYLOAD 漢字 café → • −',
49
+ 'logo '+expected+'\\n┃ '+expected.replace('漢','?'),
50
+ 'logo '+expected+'\\n┃ '+expected.replace('→ •','• →'),
51
+ 'logo '+expected+'\\n┃ '+expected.replace('café','cafe'),
52
+ ]
53
+ for screen in mutants:
54
+ try: module.assert_composer_payload(screen)
55
+ except AssertionError: pass
56
+ else: raise AssertionError('mutated composer unexpectedly passed')
57
+ print('PASS')`], { encoding: 'utf8' });
58
+ assert.equal(result.status, 0, result.stderr);
59
+ assert.match(result.stdout, /PASS/);
60
+ });
61
+
62
+ test('glyph screen tracks CJK wide cells and combining marks without losing exact composer text', { skip: skipPython }, () => {
63
+ const result = spawnSync(python, ['-c', `import importlib.util
64
+ spec=importlib.util.spec_from_file_location('helpers', ${JSON.stringify(helpers)})
65
+ module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
66
+ screen=module.GlyphScreen(rows=3, columns=50)
67
+ text=module.USER_TEXT
68
+ screen.feed(('\\x1b[1;1H┃ '+text).encode())
69
+ row=screen.visible().splitlines()[0]
70
+ assert text in row, repr(row)
71
+ assert screen.x == 3 + sum(module.GlyphScreen.char_width(ch) for ch in text)
72
+ assert module.GlyphScreen.char_width('漢') == 2
73
+ assert module.GlyphScreen.char_width('\\u0301') == 0
74
+ print('PASS')`], { encoding: 'utf8' });
75
+ assert.equal(result.status, 0, result.stderr);
76
+ assert.match(result.stdout, /PASS/);
77
+ });
78
+
79
+ test('native glyph PTY entrypoint imports its hyphenated sibling helper', {
80
+ skip: skipPython || (process.platform === 'win32' ? 'The PTY entrypoint depends on Unix fcntl; portable helper contracts are tested above' : false),
81
+ }, () => {
82
+ const result = spawnSync(python, ['-c', `import importlib.util, pathlib
83
+ entry=pathlib.Path(${JSON.stringify(join(root, 'tools/native-glyph-pty.py'))})
84
+ spec=importlib.util.spec_from_file_location('native_glyph_pty_entry', entry)
85
+ module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
86
+ assert module.USER_TEXT == 'CTG_GLYPH_PAYLOAD 漢字 café → • − …'
87
+ assert module.GlyphScreen.char_width('漢') == 2
88
+ assert callable(module.assert_composer_payload)
89
+ print('PASS')`], { encoding: 'utf8' });
90
+ assert.equal(result.status, 0, result.stderr);
91
+ assert.match(result.stdout, /PASS/);
92
+ });
93
+
94
+
95
+ test('glyph screen records fragmented OSC titles for final-binary branding assertions', { skip: skipPython }, () => {
96
+ const result = spawnSync(python, ['-c', `import importlib.util
97
+ spec=importlib.util.spec_from_file_location('helpers', ${JSON.stringify(helpers)})
98
+ module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
99
+ screen=module.GlyphScreen()
100
+ for chunk in [b'\\x1b]2;Cart', b'hagent\\x07', b'\\x1b]0;CTG | Ship\\x1b', b'\\\\']:
101
+ screen.feed(chunk)
102
+ assert screen.title_updates == ['Carthagent', 'CTG | Ship'], screen.title_updates
103
+ assert screen.visible().strip() == ''
104
+ print('PASS')`], { encoding: 'utf8' });
105
+ assert.equal(result.status, 0, result.stderr);
106
+ assert.match(result.stdout, /PASS/);
107
+ });
@@ -0,0 +1,170 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import { CTG_RUNTIME_SOURCE, CTG_RUNTIME_PATCH_SHA256, CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, CTG_RUNTIME_LOCK_SHA256 } from '../lib/opencode/runtime.mjs';
9
+ import { createManualProjects } from '../tools/native-manual-projects.mjs';
10
+ import { readManualRuntimeManifest } from '../tools/manual-runtime-manifest.mjs';
11
+ import { readRuntimeReleaseManifest } from '../lib/opencode/runtime.mjs';
12
+ import { prepareManualKit } from '../tools/prepare-native-manual.mjs';
13
+
14
+ test('manual fixtures have green baselines, red feature acceptance, clean git, and no dependencies', async (t) => {
15
+ const temp = await mkdtemp(join(tmpdir(), 'ctg-manual-projects-'));
16
+ t.after(() => rm(temp, { recursive: true, force: true }));
17
+ const result = await createManualProjects(join(temp, 'projects'), {
18
+ packages: 3,
19
+ });
20
+ const env = { ...process.env };
21
+ delete env.NODE_TEST_CONTEXT;
22
+ for (const name of ['tiny', 'product', 'scale']) {
23
+ const cwd = join(result.root, name);
24
+ const manifest = JSON.parse(
25
+ await readFile(join(cwd, 'package.json'), 'utf8'),
26
+ );
27
+ assert.equal(manifest.dependencies, undefined);
28
+ const baseline = spawnSync(
29
+ process.execPath,
30
+ ['--test', 'test/*.test.mjs'],
31
+ { cwd, env, encoding: 'utf8' },
32
+ );
33
+ assert.equal(baseline.status, 0, baseline.stdout + baseline.stderr);
34
+ const acceptance = spawnSync(
35
+ process.execPath,
36
+ ['--test', 'acceptance/*.test.mjs'],
37
+ { cwd, env, encoding: 'utf8' },
38
+ );
39
+ assert.equal(
40
+ acceptance.status,
41
+ 1,
42
+ 'new-feature acceptance must fail against the baseline',
43
+ );
44
+ const status = spawnSync('git', ['status', '--porcelain'], {
45
+ cwd,
46
+ encoding: 'utf8',
47
+ });
48
+ assert.equal(status.status, 0, status.stderr);
49
+ assert.equal(status.stdout, '');
50
+ assert.ok(result.projects[name].files > 3);
51
+ }
52
+ const sentinel = join(result.root, 'keep.txt');
53
+ await writeFile(sentinel, 'keep');
54
+ await assert.rejects(createManualProjects(result.root), { code: 'EEXIST' });
55
+ assert.equal(await readFile(sentinel, 'utf8'), 'keep');
56
+ await assert.rejects(
57
+ createManualProjects(join(temp, 'invalid'), { packages: 0 }),
58
+ /integer from 2 to 1024/,
59
+ );
60
+ });
61
+
62
+ test('manual kit preparation refuses existing directories and removes only its own failed staging', async (t) => {
63
+ const temp = await mkdtemp(join(tmpdir(), 'ctg-manual-kit-safety-'));
64
+ t.after(() => rm(temp, { recursive: true, force: true }));
65
+ const sentinel = join(temp, 'keep.txt');
66
+ await writeFile(sentinel, 'keep');
67
+ await assert.rejects(prepareManualKit(temp, { assetsDirectory: temp }), {
68
+ code: 'EEXIST',
69
+ });
70
+ assert.equal(await readFile(sentinel, 'utf8'), 'keep');
71
+ const runtimeManifest = join(temp, 'runtime-release.json');
72
+ const manifest = JSON.parse(await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url), 'utf8'));
73
+ manifest.releaseVersion = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
74
+ Object.assign(manifest, { sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256 });
75
+ const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
76
+ manifest.assets = { [target]: manifest.assets[target] };
77
+ const bytes = JSON.stringify(manifest);
78
+ await writeFile(runtimeManifest, bytes);
79
+ const manifestSha256 = createHash('sha256').update(bytes).digest('hex');
80
+ const missing = join(temp, 'failed-kit');
81
+ await assert.rejects(
82
+ prepareManualKit(missing, { assetsDirectory: temp, runtimeManifest, manifestSha256 }),
83
+ /Could not acquire/,
84
+ );
85
+ await assert.rejects(readFile(join(missing, 'manual-kit.json')), {
86
+ code: 'ENOENT',
87
+ });
88
+ assert.equal(await readFile(sentinel, 'utf8'), 'keep');
89
+ await assert.rejects(
90
+ prepareManualKit(join(temp, 'invalid'), { packages: 0 }),
91
+ /integer from 2 to 1024/,
92
+ );
93
+ });
94
+
95
+
96
+ test('packaged manual kits retain published pins after the development recipe advances', async () => {
97
+ const bytes = await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url));
98
+ const published = JSON.parse(bytes);
99
+ const current = await readManualRuntimeManifest();
100
+ assert.deepEqual(current.manifest, published);
101
+ assert.equal(current.manifest.patchSha256, CTG_RUNTIME_PATCH_SHA256);
102
+ assert.equal(current.manifestSha256, createHash('sha256').update(bytes).digest('hex'));
103
+ await assert.rejects(readManualRuntimeManifest({ manifestSha256: '0'.repeat(64) }), /checksum mismatch/i);
104
+ });
105
+
106
+ test('default manual preparation reaches verified acquisition without requiring development provenance', async t => {
107
+ const temp = await mkdtemp(join(tmpdir(), 'ctg-manual-published-'));
108
+ t.after(() => rm(temp, { recursive: true, force: true }));
109
+ const destination = join(temp, 'kit');
110
+ await assert.rejects(prepareManualKit(destination, { assetsDirectory: temp }), /Could not acquire/);
111
+ await assert.rejects(readFile(join(destination, 'manual-kit.json')), { code: 'ENOENT' });
112
+ });
113
+
114
+ test('explicit manual candidates cannot borrow the packaged-release recipe exception', async t => {
115
+ const temp = await mkdtemp(join(tmpdir(), 'ctg-manual-explicit-release-'));
116
+ t.after(() => rm(temp, { recursive: true, force: true }));
117
+ const runtimeManifest = join(temp, 'published-copy.json');
118
+ const manifest = JSON.parse(await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url), 'utf8'));
119
+ manifest.releaseVersion = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
120
+ manifest.patchSha256 = '0'.repeat(64);
121
+ const bytes = JSON.stringify(manifest);
122
+ await writeFile(runtimeManifest, bytes);
123
+ await assert.rejects(readManualRuntimeManifest({ runtimeManifest, manifestSha256: createHash('sha256').update(bytes).digest('hex') }), /patchSha256.*build recipe/i);
124
+ });
125
+
126
+ test('manual candidate overrides require an independent digest and reject altered or incompatible manifests', async t => {
127
+ const temp = await mkdtemp(join(tmpdir(), 'ctg-manual-kit-trust-'));
128
+ t.after(() => rm(temp, { recursive: true, force: true }));
129
+ const runtimeManifest = join(temp, 'candidate.json');
130
+ const destination = join(temp, 'kit');
131
+ await writeFile(runtimeManifest, '{}');
132
+ await assert.rejects(prepareManualKit(destination, { runtimeManifest }), /manifest.*SHA-256|digest/i);
133
+ await assert.rejects(prepareManualKit(destination, { runtimeManifest, manifestSha256: '0'.repeat(64) }), /manifest.*SHA-256|checksum|digest/i);
134
+ const manifestSha256 = createHash('sha256').update('{}').digest('hex');
135
+ await assert.rejects(prepareManualKit(destination, { runtimeManifest, manifestSha256 }), /incompatible|invalid/i);
136
+ await assert.rejects(readFile(join(destination, 'manual-kit.json')), { code: 'ENOENT' });
137
+ await writeFile(runtimeManifest, '{"altered":true}');
138
+ await assert.rejects(prepareManualKit(destination, { runtimeManifest, manifestSha256 }), /manifest.*SHA-256|checksum|digest/i);
139
+ await assert.rejects(readFile(join(destination, 'manual-kit.json')), { code: 'ENOENT' });
140
+ });
141
+
142
+ test('single-platform manual candidates bind current recipe bytes without weakening complete release manifests', async t => {
143
+ const temp = await mkdtemp(join(tmpdir(), 'ctg-manual-candidate-'));
144
+ t.after(() => rm(temp, { recursive: true, force: true }));
145
+ const runtimeManifest = join(temp, 'candidate.json');
146
+ const manifest = JSON.parse(await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url), 'utf8'));
147
+ manifest.releaseVersion = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
148
+ Object.assign(manifest, { sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256 });
149
+ const asset = manifest.assets['linux-x64'];
150
+ manifest.assets = { 'linux-x64': asset };
151
+ const save = async () => {
152
+ const bytes = JSON.stringify(manifest);
153
+ await writeFile(runtimeManifest, bytes);
154
+ return { runtimeManifest, manifestSha256: createHash('sha256').update(bytes).digest('hex') };
155
+ };
156
+ const current = await readManualRuntimeManifest(await save());
157
+ assert.deepEqual(Object.keys(current.manifest.assets), ['linux-x64']);
158
+ await assert.rejects(readRuntimeReleaseManifest(runtimeManifest), /incompatible|invalid/i);
159
+ for (const field of ['sourceRevision', 'patchSha256', 'lockSha256']) {
160
+ const original = manifest[field];
161
+ manifest[field] = '0'.repeat(original.length);
162
+ await assert.rejects(readManualRuntimeManifest(await save()), /build recipe/i);
163
+ manifest[field] = original;
164
+ }
165
+ const inherited = Object.fromEntries([['__proto__', { ...asset, name: undefined }]]);
166
+ for (const assets of [{}, { unknown: asset }, { 'linux-x64': { ...asset, executableSha256: 'invalid' } }, inherited]) {
167
+ manifest.assets = assets;
168
+ await assert.rejects(readManualRuntimeManifest(await save()), /invalid|incompatible/i);
169
+ }
170
+ });
@@ -0,0 +1,140 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmod, mkdtemp, rm, readFile, writeFile, realpath } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import { pathToFileURL } from 'node:url';
8
+ import test from 'node:test';
9
+
10
+ const helper = resolve(import.meta.dirname, '../tools/native-owned-runtime-env.sh');
11
+
12
+ function shellPath(path) {
13
+ return process.platform === 'win32' ? execFileSync('cygpath', ['-u', path], { encoding: 'utf8' }).trim() : path;
14
+ }
15
+
16
+ function testBash() {
17
+ if (process.env.CTG_TEST_BASH) return process.env.CTG_TEST_BASH;
18
+ if (process.platform !== 'win32') return 'bash';
19
+ return execFileSync('where.exe', ['bash.exe'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean);
20
+ }
21
+
22
+ function nativeNodePath(path) {
23
+ return path.replaceAll('\\', '/');
24
+ }
25
+
26
+ async function writeSpawnMock(root) {
27
+ const modulePath = nativeNodePath(join(root, 'spawn-mock.mjs'));
28
+ const marker = nativeNodePath(join(root, 'version-spawned'));
29
+ await writeFile(modulePath, `
30
+ import childProcess from 'node:child_process';
31
+ import { EventEmitter } from 'node:events';
32
+ import { PassThrough } from 'node:stream';
33
+ import { writeFileSync } from 'node:fs';
34
+ import { syncBuiltinESMExports } from 'node:module';
35
+ childProcess.spawn = (binary, args) => {
36
+ const child = new EventEmitter();
37
+ child.stdout = new PassThrough(); child.stderr = new PassThrough();
38
+ child.kill = () => true;
39
+ writeFileSync(${JSON.stringify(marker)}, String(binary));
40
+ queueMicrotask(() => {
41
+ child.stdout.end('opencode v0.0.0-ctg.2.0.32\\n');
42
+ child.stderr.end();
43
+ child.emit('close', 0);
44
+ });
45
+ return child;
46
+ };
47
+ syncBuiltinESMExports();
48
+ `);
49
+ return { options: `--import=${pathToFileURL(modulePath).href}`, marker };
50
+ }
51
+
52
+ async function makeOwnedRuntime(t) {
53
+ const root = await mkdtemp(join(tmpdir(), 'ctg-owned-env-'));
54
+ t.after(() => rm(root, { recursive: true, force: true }));
55
+ const targetPlatform = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
56
+ const owned = join(root, 'owned');
57
+ const binary = join(owned, `cli-${targetPlatform}`, 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode');
58
+ const manifest = join(root, 'runtime-release.json');
59
+ await import('node:fs/promises').then(fs => fs.mkdir(join(owned, `cli-${targetPlatform}`, 'bin'), { recursive: true }));
60
+ const sourceRevision = 'a'.repeat(40);
61
+ const patchSha256 = 'b'.repeat(64);
62
+ const lockSha256 = 'c'.repeat(64);
63
+ const runtimeVersion = '0.0.0-ctg.2.0.32';
64
+ await writeFile(binary, `unexecutable-mock-payload-${runtimeVersion}\n`);
65
+ if (process.platform !== 'win32') await chmod(binary, 0o755);
66
+ const digest = createHash('sha256').update(await readFile(binary)).digest('hex');
67
+ const provenance = {
68
+ owner: 'Carthagent', sourceRepository: 'https://github.com/anomalyco/opencode', sourceRevision,
69
+ patchSha256, lockSha256, compatibilityVersion: '2.0.32', runtimeVersion,
70
+ channel: 'ctg.2.0.32', platform: targetPlatform, webUI: false,
71
+ };
72
+ await writeFile(join(owned, 'ctg-runtime.json'), JSON.stringify(provenance));
73
+ await writeFile(manifest, JSON.stringify({
74
+ version: 1, releaseVersion: '1.2.3', compatibilityVersion: '2.0.32', runtimeVersion,
75
+ sourceRevision, patchSha256, lockSha256,
76
+ assets: { [targetPlatform]: { name: `opencode-${targetPlatform}.tar.gz`, archiveSha256: 'd'.repeat(64), executableSha256: digest } },
77
+ }));
78
+ return { root, owned, binary, manifest, digest };
79
+ }
80
+
81
+ test('shell child verifies owned runtime before passing trusted selectors to fixture seeder', async t => {
82
+ const fixture = await makeOwnedRuntime(t);
83
+ const spawnMock = await writeSpawnMock(fixture.root);
84
+ const result = execFileSync(testBash(), ['-c', `
85
+ source "$1"
86
+ CTG_OPENCODE_BIN="$2" configure_native_owned_runtime "$2" "$3" "$4"
87
+ test -z "\${CTG_OPENCODE_BIN+x}"
88
+ printf 'selectors:%s\\n%s\\n' "$CTG_OWNED_OPENCODE_RUNTIME" "$CTG_TEST_RUNTIME_RELEASE_MANIFEST"
89
+ `, 'test', shellPath(helper), shellPath(fixture.binary), shellPath(fixture.owned), shellPath(fixture.manifest)], {
90
+ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: spawnMock.options },
91
+ }).trim().split('\n');
92
+ assert.equal(result.length, 2, 'shell handoff emits only the owned-root and release-manifest selectors');
93
+ const [selectors, reportedManifest] = result;
94
+ assert.ok(selectors.startsWith('selectors:'), 'first output line identifies the owned-root selector');
95
+ assert.equal(await realpath(selectors.slice('selectors:'.length)), await realpath(fixture.owned));
96
+ assert.equal(await realpath(reportedManifest), await realpath(fixture.manifest));
97
+ assert.equal(await realpath(await readFile(spawnMock.marker, 'utf8')), await realpath(fixture.binary), 'identity helper checked the exact owned executable with --version after hashing');
98
+ });
99
+
100
+ test('wrong expected hash prevents version execution and seeder launch', async t => {
101
+ const fixture = await makeOwnedRuntime(t);
102
+ const spawnMock = await writeSpawnMock(fixture.root);
103
+ const seederMarker = join(fixture.root, 'seeder-launched');
104
+ const child = spawnSync(testBash(), ['-c', `
105
+ source "$1"
106
+ configure_native_owned_runtime "$2" "$3" "$4" && touch "$5"
107
+ `, 'test', shellPath(helper), shellPath(fixture.binary), shellPath(fixture.owned), shellPath(fixture.manifest), shellPath(seederMarker)], {
108
+ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: spawnMock.options, CTG_TEST_RUNTIME_SHA256: 'e'.repeat(64) },
109
+ });
110
+ assert.notEqual(child.status, 0);
111
+ assert.match(child.stderr, /binary SHA-256 mismatch/);
112
+ await assert.rejects(readFile(spawnMock.marker), { code: 'ENOENT' });
113
+ await assert.rejects(readFile(seederMarker), { code: 'ENOENT' });
114
+ });
115
+
116
+ test('binary outside the declared owned root is rejected before version execution', async t => {
117
+ const fixture = await makeOwnedRuntime(t);
118
+ const spawnMock = await writeSpawnMock(fixture.root);
119
+ const outside = join(fixture.root, `outside-opencode${process.platform === 'win32' ? '.exe' : ''}`);
120
+ await writeFile(outside, await readFile(fixture.binary));
121
+ if (process.platform !== 'win32') await chmod(outside, 0o755);
122
+ const child = spawnSync(testBash(), ['-c', `
123
+ source "$1"
124
+ configure_native_owned_runtime "$2" "$3" "$4"
125
+ `, 'test', shellPath(helper), shellPath(outside), shellPath(fixture.owned), shellPath(fixture.manifest)], {
126
+ encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: spawnMock.options },
127
+ });
128
+ assert.notEqual(child.status, 0);
129
+ assert.match(child.stderr, /must be/);
130
+ await assert.rejects(readFile(spawnMock.marker), { code: 'ENOENT' });
131
+ });
132
+
133
+ test('readiness and paired wrappers use the shared owned-runtime environment handoff', async () => {
134
+ const readiness = await import('node:fs/promises').then(fs => fs.readFile(resolve(import.meta.dirname, '../tools/test-native-terminal-readiness.sh'), 'utf8'));
135
+ const paired = await import('node:fs/promises').then(fs => fs.readFile(resolve(import.meta.dirname, '../tools/run-native-terminal-paired.sh'), 'utf8'));
136
+ assert.match(readiness, /source "\$harness_worktree\/tools\/native-owned-runtime-env\.sh"/);
137
+ assert.match(readiness, /configure_native_owned_runtime "\$binary" "\$owned_root" "\$release_manifest"/);
138
+ assert.match(paired, /source "\$repo_root\/tools\/native-owned-runtime-env\.sh"/);
139
+ assert.match(paired, /configure_native_owned_runtime "\$binary" "\$owned_root" "\$release_manifest"/);
140
+ });