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,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
+ });
@@ -0,0 +1,464 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { access, chmod, mkdtemp, mkdir, rm, writeFile, symlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execFileSync, spawnSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import { aggregateConditionExit, assertCompletePluginSource, assertWorkspacePluginResolution, assertDependencyPackageIdentity, assertPluginCondition, assertPluginImportDependencies, assertSelectedSourceHead, assertSnapshotProvenance, hashDependencyPackages, resolveFixtureSourceRoot, verifySnapshotIdentity, isPathWithinRoot } from '../tools/native-paired-guards.mjs';
10
+
11
+ function shellPath(path) {
12
+ return process.platform === 'win32'
13
+ ? execFileSync('cygpath', ['-u', path], { encoding: 'utf8' }).trim()
14
+ : path;
15
+ }
16
+
17
+ function testBash() {
18
+ if (process.env.CTG_TEST_BASH) return process.env.CTG_TEST_BASH;
19
+ if (process.platform !== 'win32') return 'bash';
20
+ return execFileSync('where.exe', ['bash.exe'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean);
21
+ }
22
+
23
+ test('plugin identity accepts npm hoisting but rejects a shadow workspace dependency', async t => {
24
+ const root = await mkdtemp(join(tmpdir(), 'native-hoisted-plugin-'));
25
+ t.after(() => rm(root, { recursive: true, force: true }));
26
+ await mkdir(join(root, 'plugins/carthagent'), { recursive: true });
27
+ await mkdir(join(root, 'node_modules/@opencode/plugin'), { recursive: true });
28
+ assert.equal(assertWorkspacePluginResolution(root), true);
29
+ await mkdir(join(root, 'plugins/carthagent/node_modules/@opencode/plugin'), { recursive: true });
30
+ assert.throws(() => assertWorkspacePluginResolution(root), /dependency identity mismatch/);
31
+ });
32
+
33
+ test('paired dependency provenance accepts the OpenCode CLI pin from devDependencies', async t => {
34
+ const root = await mkdtemp(join(tmpdir(), 'native-dev-cli-pin-'));
35
+ t.after(() => rm(root, { recursive: true, force: true }));
36
+ const modules = join(root, 'node_modules');
37
+ await mkdir(join(root, 'plugins/carthagent'), { recursive: true });
38
+ for (const name of ['@opencode/cli', '@opencode/client', '@opencode/plugin']) {
39
+ await mkdir(join(modules, name), { recursive: true });
40
+ await writeFile(join(modules, name, 'package.json'), JSON.stringify({ name, version: '2.0.32' }));
41
+ }
42
+ await writeFile(join(root, 'package.json'), JSON.stringify({
43
+ dependencies: { '@opencode/client': '2.0.32' },
44
+ devDependencies: { '@opencode/cli': '2.0.32' },
45
+ }));
46
+ await writeFile(join(root, 'plugins/carthagent/package.json'), JSON.stringify({
47
+ dependencies: { '@opencode/plugin': '2.0.32' },
48
+ }));
49
+ await writeFile(join(root, 'package-lock.json'), JSON.stringify({
50
+ packages: {
51
+ '': { dependencies: { '@opencode/client': '2.0.32' }, devDependencies: { '@opencode/cli': '2.0.32' } },
52
+ 'node_modules/@opencode/cli': { version: '2.0.32' },
53
+ 'node_modules/@opencode/client': { version: '2.0.32' },
54
+ 'node_modules/@opencode/plugin': { version: '2.0.32' },
55
+ },
56
+ }));
57
+ assert.equal(Object.keys(hashDependencyPackages(modules, join(root, 'package.json'), root)).length, 3);
58
+ });
59
+
60
+ test('paired CTG fixture fails closed unless inventory says active', () => {
61
+ assert.deepEqual(assertPluginCondition([{ id: 'carthagent', state: { status: 'active' } }], true),
62
+ { expected: 'active', observed: 'active', pluginId: 'carthagent' });
63
+ assert.throws(() => assertPluginCondition([{ id: 'carthagent', state: { status: 'error', error: "Cannot find module '../../lib/ship-auth.mjs'" } }], true), /Expected native Carthagent plugin to be active.*Cannot find module/);
64
+ assert.throws(() => assertPluginCondition([{ id: 'carthagent.runtime', state: { status: 'active' } }], true), /Expected native Carthagent plugin to be active/);
65
+ assert.throws(() => assertPluginCondition([], true), /Expected native Carthagent plugin to be active/);
66
+ });
67
+
68
+ test('plugin source without the pinned native runtime import is rejected', async () => {
69
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-missing-runtime-'));
70
+ try {
71
+ const sourceRoot = join(root, 'snapshot');
72
+ const entry = join(sourceRoot, 'plugins/carthagent/src/index.ts');
73
+ await mkdir(dirname(entry), { recursive: true });
74
+ await writeFile(entry, "import { rpc } from './rpc';\nexport { rpc };\n");
75
+ await writeFile(join(dirname(entry), 'rpc.ts'), 'export const rpc = {};\n');
76
+ assert.throws(() => assertPluginImportDependencies(entry), /does not import the pinned @opencode\/plugin runtime/);
77
+ } finally {
78
+ await rm(root, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test('paired runner rejects the old partial plugin staging and accepts a complete source snapshot', async () => {
83
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-source-test-'));
84
+ try {
85
+ const sourceRoot = join(root, 'snapshot');
86
+ await mkdir(join(sourceRoot, 'plugins/carthagent/src'), { recursive: true });
87
+ await mkdir(join(sourceRoot, 'node_modules/@opencode/plugin'), { recursive: true });
88
+ await mkdir(join(sourceRoot, 'plugins/carthagent/node_modules/@opencode'), { recursive: true });
89
+ await writeFile(join(sourceRoot, 'plugins/carthagent/index.ts'), "export { default } from './src/index';\n");
90
+ await writeFile(join(sourceRoot, 'plugins/carthagent/src/index.ts'), '');
91
+ assert.throws(() => assertCompletePluginSource(sourceRoot), /Incomplete native plugin source snapshot.*package-lock.json.*lib\/ship-auth.mjs/);
92
+ for (const file of ['package-lock.json', 'lib/ship-auth.mjs', 'plugins/carthagent/src/rpc.ts', 'plugins/carthagent/package.json']) {
93
+ await mkdir(dirname(join(sourceRoot, file)), { recursive: true });
94
+ await writeFile(join(sourceRoot, file), '');
95
+ }
96
+ for (const file of ['plugins/carthagent/src/server/session-controls.ts', 'plugins/carthagent/src/server/providers.ts', 'plugins/carthagent/src/server/device-auth.ts', 'plugins/carthagent/src/server/workspace-rpc.ts', 'plugins/carthagent/src/server/pull-request-rpc.ts', 'plugins/carthagent/src/server/archive-rpc.ts']) {
97
+ await mkdir(dirname(join(sourceRoot, file)), { recursive: true });
98
+ await writeFile(join(sourceRoot, file), '');
99
+ }
100
+ await writeFile(join(sourceRoot, 'plugins/carthagent/src/index.ts'), "import { Plugin } from '@opencode/plugin';\nexport const id = 'carthagent';\n");
101
+ await symlink(join(sourceRoot, 'node_modules/@opencode/plugin'), join(sourceRoot, 'plugins/carthagent/node_modules/@opencode/plugin'), process.platform === 'win32' ? 'junction' : 'dir');
102
+ assert.equal(assertCompletePluginSource(sourceRoot).sourceRoot, sourceRoot);
103
+ } finally {
104
+ await rm(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('complete plugin staging resolves extensionless native TypeScript imports within the source snapshot', async () => {
109
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-plugin-import-test-'));
110
+ try {
111
+ const sourceRoot = join(root, 'snapshot');
112
+ const entry = join(sourceRoot, 'plugins/carthagent/src/index.ts');
113
+ await mkdir(dirname(entry), { recursive: true });
114
+ await writeFile(entry, "import { Plugin } from '@opencode/plugin';\nimport { rpc } from './rpc';\n");
115
+ await writeFile(join(dirname(entry), 'rpc.ts'), 'export const rpc = {};\n');
116
+ assert.deepEqual(assertPluginImportDependencies(entry), { sourceImports: ['./rpc'], workspaceImports: ['@opencode/plugin'] });
117
+ await rm(join(dirname(entry), 'rpc.ts'));
118
+ assert.throws(() => assertPluginImportDependencies(entry), /Plugin source import is missing from immutable tree.*\.\/rpc/);
119
+ } finally {
120
+ await rm(root, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('plugin import containment rejects traversal and cross-drive targets', async () => {
125
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-import-containment-'));
126
+ try {
127
+ const sourceRoot = join(root, 'snapshot');
128
+ const entry = join(sourceRoot, 'plugins/carthagent/src/index.ts');
129
+ await mkdir(dirname(entry), { recursive: true });
130
+ await writeFile(entry, "import { outside } from '../../../../outside';\nexport { outside };\n");
131
+ await writeFile(join(root, 'outside.ts'), 'export const outside = true;\n');
132
+ assert.throws(() => assertPluginImportDependencies(entry), /missing from immutable tree.*\.\.\/\.\.\/\.\.\/\.\.\/outside/);
133
+
134
+ const win32Path = await import('node:path').then(({ win32 }) => win32);
135
+ assert.equal(isPathWithinRoot('C:\\immutable\\snapshot', 'C:\\immutable\\snapshot\\plugins\\rpc.ts', win32Path), true);
136
+ assert.equal(isPathWithinRoot('C:\\immutable\\snapshot', 'C:\\immutable\\outside\\rpc.ts', win32Path), false);
137
+ assert.equal(isPathWithinRoot('C:\\immutable\\snapshot', 'D:\\outside\\rpc.ts', win32Path), false);
138
+ } finally {
139
+ await rm(root, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test('plugin import containment resolves symlinks and permits only canonical in-root targets', async () => {
144
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-import-symlinks-'));
145
+ try {
146
+ const sourceRoot = join(root, 'snapshot');
147
+ const entry = join(sourceRoot, 'plugins/carthagent/src/index.ts');
148
+ const inRootTarget = join(sourceRoot, 'shared');
149
+ const inRootLink = join(dirname(entry), 'inside-link');
150
+ const outsideTarget = join(root, 'outside');
151
+ const outsideLink = join(dirname(entry), 'outside-link');
152
+ await mkdir(dirname(entry), { recursive: true });
153
+ await Promise.all([mkdir(inRootTarget), mkdir(outsideTarget)]);
154
+ await writeFile(join(inRootTarget, 'rpc.ts'), 'export const rpc = {};\n');
155
+ await writeFile(join(outsideTarget, 'rpc.ts'), 'export const outside = true;\n');
156
+ await symlink(inRootTarget, inRootLink, process.platform === 'win32' ? 'junction' : 'dir');
157
+ await writeFile(entry, "import { Plugin } from '@opencode/plugin';\nimport { rpc } from './inside-link/rpc';\nexport { rpc, Plugin };\n");
158
+ assert.deepEqual(assertPluginImportDependencies(entry), { sourceImports: ['./inside-link/rpc'], workspaceImports: ['@opencode/plugin'] });
159
+
160
+ await symlink(outsideTarget, outsideLink, process.platform === 'win32' ? 'junction' : 'dir');
161
+ await writeFile(entry, "import { Plugin } from '@opencode/plugin';\nimport { outside } from './outside-link/rpc';\nexport { outside, Plugin };\n");
162
+ assert.throws(() => assertPluginImportDependencies(entry), /missing from immutable tree.*outside-link/);
163
+
164
+ const danglingLink = join(dirname(entry), 'dangling-link');
165
+ await symlink(join(root, 'missing-target'), danglingLink, process.platform === 'win32' ? 'junction' : 'dir');
166
+ await writeFile(entry, "import { Plugin } from '@opencode/plugin';\nimport { missing } from './dangling-link/rpc';\nexport { missing, Plugin };\n");
167
+ assert.throws(() => assertPluginImportDependencies(entry), /missing from immutable tree.*dangling-link/);
168
+ } finally {
169
+ await rm(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ test('paired baseline condition requires Carthagent to be absent', () => {
174
+ assert.deepEqual(assertPluginCondition([{ id: 'other', state: { status: 'active' } }], false),
175
+ { expected: 'absent', observed: 'absent', pluginId: 'carthagent' });
176
+ assert.throws(() => assertPluginCondition([{ id: 'carthagent', state: { status: 'active' } }], false), /Baseline must have no Carthagent plugin/);
177
+ });
178
+
179
+ test('ABBA exit aggregation fails if any condition fails or is missing', () => {
180
+ assert.equal(aggregateConditionExit({ 'baseline-1': 0, 'ctg-1': 0, 'ctg-2': 0, 'baseline-2': 0 }), 0);
181
+ assert.equal(aggregateConditionExit({ 'baseline-1': 0, 'ctg-1': 1, 'ctg-2': 0, 'baseline-2': 0 }), 1);
182
+ assert.equal(aggregateConditionExit({ 'baseline-1': 0 }), 1);
183
+ assert.equal(aggregateConditionExit({ 'baseline-1': 0, 'ctg-1': 0, 'ctg-2': 0, 'baseline-2': 0 }, true), 1);
184
+ // A child process failure must survive aggregation, even if the other passes succeeded.
185
+ assert.equal(aggregateConditionExit({ 'baseline-1': 0, 'ctg-1': 127, 'ctg-2': 0, 'baseline-2': 0 }), 1);
186
+ });
187
+
188
+ test('actual offline ABBA driver runs all four conditions and retains failures with nonzero aggregate exit', async () => {
189
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-abba-driver-'));
190
+ const helper = shellPath(fileURLToPath(new URL('../tools/native-paired-orchestration.sh', import.meta.url)));
191
+ const guardModule = fileURLToPath(new URL('../tools/native-paired-guards.mjs', import.meta.url));
192
+ const statuses = join(root, 'statuses.tsv');
193
+ const calls = join(root, 'calls.txt');
194
+ try {
195
+ const program = `source "$1"
196
+ CALLS="$2"
197
+ STATUSES="$3"
198
+ GUARDS="$4"
199
+ fake_condition() {
200
+ local label="$1" mode="$2" status=0
201
+ [[ "$label" == ctg-1 ]] && status=17
202
+ printf '%s %s\\n' "$label" "$mode" >> "$CALLS"
203
+ printf '%s\\t0\\n' "$label" >> "$STATUSES"
204
+ return "$status"
205
+ }
206
+ run_paired_abba fake_condition "$STATUSES" "$GUARDS" 0
207
+ printf 'aggregate=%s\\n' "$paired_exit_status"
208
+ exit "$paired_exit_status"`;
209
+ const bash = testBash();
210
+ const run = spawnSync(bash, ['-c', program, 'test-abba', helper, shellPath(calls), shellPath(statuses), shellPath(guardModule)], { encoding: 'utf8' });
211
+ assert.equal(run.status, 1, run.stderr);
212
+ assert.match(run.stdout, /aggregate=1/);
213
+ assert.deepEqual((await (await import('node:fs/promises')).readFile(calls, 'utf8')).trim().split('\n'), [
214
+ 'baseline-1 baseline', 'ctg-1 ctg', 'ctg-2 ctg', 'baseline-2 baseline',
215
+ ]);
216
+ assert.deepEqual((await (await import('node:fs/promises')).readFile(statuses, 'utf8')).trim().split('\n'), [
217
+ 'baseline-1\t0', 'ctg-1\t17', 'ctg-2\t0', 'baseline-2\t0',
218
+ ]);
219
+ } finally {
220
+ await rm(root, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ test('actual paired cleanup trap removes preflight work after an early failure', async () => {
225
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-preflight-cleanup-'));
226
+ const helper = shellPath(fileURLToPath(new URL('../tools/native-paired-orchestration.sh', import.meta.url)));
227
+ const workPathFile = join(root, 'work-path');
228
+ try {
229
+ const program = `source "$1"
230
+ work=''
231
+ cleanup() { remove_paired_workdir "$work"; }
232
+ register_paired_exit_cleanup cleanup
233
+ work="$(mktemp -d /tmp/ctg-native-paired.XXXXXX)"
234
+ printf '%s' "$work" > "$2"
235
+ exit 23`;
236
+ const bash = testBash();
237
+ const run = spawnSync(bash, ['-c', program, 'test-preflight-cleanup', helper, shellPath(workPathFile)], { encoding: 'utf8' });
238
+ assert.equal(run.status, 23, run.stderr);
239
+ const work = (await (await import('node:fs/promises')).readFile(workPathFile, 'utf8'));
240
+ await assert.rejects(access(work));
241
+ } finally {
242
+ await rm(root, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test('scheduled source commit must be the current integration checkout HEAD', () => {
247
+ const head = 'a'.repeat(40);
248
+ assert.equal(assertSelectedSourceHead(head, head), true);
249
+ // Older commit has the same lockfile identity but is not current acceptance source.
250
+ assert.throws(() => assertSelectedSourceHead('b'.repeat(40), head), /must equal the current integration checkout HEAD/);
251
+ assert.throws(() => assertSelectedSourceHead(head, 'malformed'), /must equal the current integration checkout HEAD/);
252
+ });
253
+
254
+ test('actual runner rejects a stale same-lockfile commit before creating output or staging artifacts', async () => {
255
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-stale-head-runner-'));
256
+ const source = join(root, 'integration');
257
+ const modules = join(source, 'node_modules');
258
+ const binary = join(modules, '@opencode/cli/bin/opencode.exe');
259
+ const output = join(root, 'should-not-exist');
260
+ const runner = shellPath(fileURLToPath(new URL('../tools/run-native-terminal-paired.sh', import.meta.url)));
261
+ try {
262
+ await mkdir(join(source, 'plugins/carthagent'), { recursive: true });
263
+ await mkdir(join(modules, '@opencode/client'), { recursive: true });
264
+ await mkdir(dirname(binary), { recursive: true });
265
+ await writeFile(join(source, 'package.json'), JSON.stringify({ dependencies: {
266
+ '@opencode/cli': '2.0.32', '@opencode/client': '2.0.32', '@opencode/plugin': '2.0.32',
267
+ } }));
268
+ await writeFile(join(source, 'package-lock.json'), '{"lockfileVersion":3}\n');
269
+ await writeFile(join(source, 'plugins/carthagent/package.json'), '{"dependencies":{"@opencode/plugin":"2.0.32"}}\n');
270
+ await writeFile(join(modules, '@opencode/cli/package.json'), '{"version":"2.0.32"}\n');
271
+ await writeFile(join(modules, '@opencode/client/package.json'), '{"version":"2.0.32"}\n');
272
+ await writeFile(binary, '#!/bin/sh\nprintf "opencode 2.0.32\\n"\n');
273
+ await chmod(binary, 0o755);
274
+ execFileSync('git', ['init', '-q', source]);
275
+ execFileSync('git', ['-C', source, 'config', 'user.email', 'offline@example.invalid']);
276
+ execFileSync('git', ['-C', source, 'config', 'user.name', 'Offline Fixture']);
277
+ execFileSync('git', ['-C', source, 'add', '.']);
278
+ execFileSync('git', ['-C', source, 'commit', '-qm', 'older source']);
279
+ const oldCommit = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
280
+ await writeFile(join(source, 'lib-current.txt'), 'advance without lockfile change\n');
281
+ execFileSync('git', ['-C', source, 'add', 'lib-current.txt']);
282
+ execFileSync('git', ['-C', source, 'commit', '-qm', 'current source']);
283
+ const lockAtHead = execFileSync('git', ['-C', source, 'show', 'HEAD:package-lock.json'], { encoding: 'utf8' });
284
+ assert.equal(lockAtHead, await (await import('node:fs/promises')).readFile(join(source, 'package-lock.json'), 'utf8'));
285
+ const bash = testBash();
286
+ const result = spawnSync(bash, [runner], { encoding: 'utf8', env: {
287
+ ...process.env, CTG_SOURCE_ROOT: shellPath(source), CTG_SOURCE_COMMIT: oldCommit,
288
+ CTG_OPENCODE_BIN: shellPath(binary), OUTPUT_DIR: shellPath(output), SAMPLES: '30',
289
+ } });
290
+ assert.notEqual(result.status, 0);
291
+ assert.match(result.stderr, /Selected source commit must equal the current integration checkout HEAD/);
292
+ await assert.rejects(access(output));
293
+ assert.deepEqual((await (await import('node:fs/promises')).readdir(root)).sort(), ['integration']);
294
+ } finally {
295
+ await rm(root, { recursive: true, force: true });
296
+ }
297
+ });
298
+
299
+ test('dependency identity comparison fails closed when the physical install changes', () => {
300
+ const expected = { realpath: '/pinned/node_modules', treeSha256: 'a'.repeat(64) };
301
+ assert.equal(assertDependencyPackageIdentity(expected, { ...expected }), true);
302
+ assert.throws(() => assertDependencyPackageIdentity(expected, { ...expected, treeSha256: 'b'.repeat(64) }), /Dependency install identity mismatch/);
303
+ assert.throws(() => assertDependencyPackageIdentity(expected, { ...expected, realpath: '/other/node_modules' }), /Dependency install identity mismatch/);
304
+ });
305
+
306
+ test('baseline absence rejects plugin-provided namespaces, not only exact id', () => {
307
+ assert.throws(() => assertPluginCondition([{ id: 'carthagent.session', state: { status: 'active' } }], false), /Baseline must have no Carthagent plugin/);
308
+ });
309
+
310
+ test('baseline fixture dependency provenance resolves from the immutable execution root when plugin is null', () => {
311
+ const executionRoot = resolve('archived', 'execution');
312
+ assert.equal(resolveFixtureSourceRoot(null, executionRoot), executionRoot);
313
+ assert.equal(resolveFixtureSourceRoot(join(executionRoot, 'plugins/carthagent'), '/unused'), executionRoot);
314
+ });
315
+
316
+ test('paired provenance requires the immutable source snapshot and pinned binary identity', () => {
317
+ const valid = { binarySha256: 'a'.repeat(64), binaryVersion: '2.0.32', sourceCommit: 'f'.repeat(40),
318
+ sourceCheckoutHead: 'f'.repeat(40), sourceTree: 'e'.repeat(40), sourceDirty: false, sourceSnapshotSha256: 'b'.repeat(64),
319
+ sourceCheckoutDirtyStatus: '', sourceCheckoutStatusSha256: createHash('sha256').update('').digest('hex'),
320
+ sourceCheckoutDirty: false, harnessDirtyStatus: '', harnessDirty: false,
321
+ packageLockSha256: 'c'.repeat(64), installedPackageLockSha256: 'c'.repeat(64), dependencyNodeModulesRealpath: '/pinned/node_modules',
322
+ sourceComponentSha256: 'd'.repeat(64), harnessComponentSha256: 'e'.repeat(64),
323
+ sourceArchiveSha256: 'f'.repeat(64),
324
+ executionSnapshotTar: '/snapshot/execution.tar', executionSnapshotSha256: 'f'.repeat(64),
325
+ sourceSnapshotTar: '/snapshot/source.tar', binary: '/pinned/opencode',
326
+ dependencyInstallSha256: 'a'.repeat(64), dependencyInstallRealpath: '/pinned/node_modules',
327
+ dependencyPackages: Object.fromEntries(['@opencode/cli', '@opencode/client', '@opencode/plugin'].map(name =>
328
+ [name, { version: '2.0.32', realpath: `/pinned/node_modules/${name}`, packageJsonSha256: 'a'.repeat(64), treeSha256: 'b'.repeat(64) }])) };
329
+ assert.equal(assertSnapshotProvenance(valid), true);
330
+ assert.equal(verifySnapshotIdentity(valid, { ...valid }, { verifyArtifactFiles: false }), true);
331
+ assert.throws(() => verifySnapshotIdentity(valid, { ...valid, sourceSnapshotSha256: 'd'.repeat(64) }, { verifyArtifactFiles: false }), /Snapshot identity mismatch for sourceSnapshotSha256/);
332
+ assert.throws(() => assertSnapshotProvenance({ ...valid, sourceSnapshotSha256: undefined }), /Missing provenance field/);
333
+ assert.throws(() => assertSnapshotProvenance({ ...valid, binaryVersion: 'latest' }), /provenance is invalid/);
334
+ assert.throws(() => assertSnapshotProvenance({ ...valid, sourceDirty: 'false' }), /Checkout dirty-state provenance is invalid/);
335
+ assert.throws(() => assertSnapshotProvenance({ ...valid, sourceDirty: true }), /Source snapshot must be created from a clean immutable commit/);
336
+ });
337
+
338
+ test('native paired manifest preserves input, switch, and rendered workspace targets plus reviewed memory gate', async () => {
339
+ const { createPairedManifest } = await import('../tools/native-paired-manifest.mjs');
340
+ const manifest = createPairedManifest({});
341
+ assert.deepEqual(manifest.thresholds, { inputP95Ms: 50, switchP95Ms: 150, workspaceRenderMs: 300 });
342
+ assert.match(manifest.workspaceUiAcceptance, /post-submit PTY route\/data-render/);
343
+ assert.match(manifest.sustainedMemoryAcceptance, /<=128 MiB/);
344
+ assert.match(manifest.sustainedMemoryAcceptance, /<=32 MiB/);
345
+ assert.match(manifest.sustainedMemoryAcceptance, /max D<=128 MiB/);
346
+ assert.match(manifest.sustainedMemoryAcceptance, /<=32 MiB/);
347
+ assert.match(manifest.sustainedMemoryAcceptance, /1500 planned 1-second monotonic-index slots/);
348
+ assert.match(manifest.sustainedMemoryAcceptance, />250ms lateness/);
349
+ assert.match(manifest.sustainedMemoryAcceptance, /identical byte-for-byte between conditions/);
350
+ assert.match(manifest.sustainedMemoryAcceptance, /not mathematical long-term guarantees/);
351
+ });
352
+
353
+ test('actual manifest generator emits valid typed JSON for clean and dirty quoted checkout metadata', () => {
354
+ const dirtyStatus = ' M "quoted path\\name"\n?? file with newline\ninside\n';
355
+ const fields = {
356
+ createdAt: '2026-10-17T00:00:00.000Z', binary: '/pinned/opencode', binaryVersion: '2.0.32', binarySha256: 'a'.repeat(64),
357
+ sourceCheckout: '/tmp/source "checkout"\nline', sourceCheckoutHead: '2'.repeat(40), sourceCheckoutDirtyStatus: '',
358
+ sourceCheckoutStatusSha256: 'b'.repeat(64), sourceCommit: '2'.repeat(40), sourceTree: '3'.repeat(40),
359
+ sourceSnapshotTar: '/tmp/source.tar', sourceArchiveSha256: 'c'.repeat(64), executionSnapshotTar: '/tmp/execution.tar',
360
+ executionSnapshotSha256: 'd'.repeat(64), sourceSnapshotSha256: 'd'.repeat(64), packageLockSha256: 'e'.repeat(64),
361
+ installedPackageLockSha256: 'e'.repeat(64), dependencyNodeModulesRealpath: '/pinned/node_modules',
362
+ dependencyInstallSha256: 'f'.repeat(64), dependencyInstallRealpath: '/pinned/node_modules',
363
+ dependencyPackages: Object.fromEntries(['@opencode/cli', '@opencode/client', '@opencode/plugin'].map(name =>
364
+ [name, { version: '2.0.32', realpath: `/pinned/node_modules/${name}`, packageJsonSha256: 'a'.repeat(64), treeSha256: 'b'.repeat(64) }])),
365
+ harnessWorktree: '/tmp/harness', harnessCommit: '4'.repeat(40), harnessDirtyStatus: dirtyStatus,
366
+ harnessSourceManifestSha256: '5'.repeat(64), samplesPerRun: 30, sourceComponentSha256: '6'.repeat(64),
367
+ harnessComponentSha256: '7'.repeat(64),
368
+ };
369
+ const generator = join(dirname(fileURLToPath(import.meta.url)), '../tools/native-paired-manifest.mjs');
370
+ for (const checkoutStatus of ['', dirtyStatus]) {
371
+ const input = { ...fields, sourceCheckoutDirtyStatus: checkoutStatus };
372
+ const generated = spawnSync(process.execPath, [generator], { input: JSON.stringify(input), encoding: 'utf8' });
373
+ assert.equal(generated.status, 0, generated.stderr);
374
+ const manifest = JSON.parse(generated.stdout);
375
+ assert.equal(manifest.sourceCheckoutDirtyStatus, checkoutStatus);
376
+ assert.equal(manifest.sourceCheckoutDirty, checkoutStatus.length > 0);
377
+ assert.equal(manifest.sourceCheckout, fields.sourceCheckout);
378
+ assert.equal(manifest.sourceCheckoutHead, fields.sourceCheckoutHead);
379
+ assert.equal(manifest.sourceCommit, fields.sourceCommit);
380
+ assert.equal(manifest.harnessDirtyStatus, dirtyStatus);
381
+ assert.equal(manifest.harnessDirty, true);
382
+ assert.equal(manifest.sourceDirty, false);
383
+ assert.equal(manifest.sourceCheckoutStatusSha256, createHash('sha256').update(checkoutStatus).digest('hex'));
384
+ assert.throws(() => assertSnapshotProvenance({ ...manifest, sourceCheckoutDirty: !manifest.sourceCheckoutDirty }), /Checkout dirty-state provenance is invalid/);
385
+ assert.throws(() => assertSnapshotProvenance({ ...manifest, sourceCheckoutHead: '9'.repeat(40) }), /provenance is invalid/);
386
+ assert.equal(assertSnapshotProvenance(manifest), true);
387
+ }
388
+ });
389
+
390
+ test('artifact identity is tied to the archived selected git commit, not its mutable checkout', async () => {
391
+ const root = await mkdtemp(join(tmpdir(), 'native-paired-immutable-source-'));
392
+ const archive = join(root, 'source.tar');
393
+ try {
394
+ execFileSync('git', ['init', '-q', root]);
395
+ execFileSync('git', ['-C', root, 'config', 'user.email', 'offline@example.invalid']);
396
+ execFileSync('git', ['-C', root, 'config', 'user.name', 'Offline Fixture']);
397
+ for (const [file, contents] of Object.entries({
398
+ 'package.json': '{"dependencies":{"@opencode/cli":"2.0.32","@opencode/client":"2.0.32"},"workspaces":["plugins/carthagent"]}\n',
399
+ 'package-lock.json': '{"lockfileVersion":3}\n',
400
+ 'lib/ship-auth.mjs': 'export const shipAgentDir = () => "/tmp";\n',
401
+ 'plugins/carthagent/index.ts': "export { default } from './src/index';\n",
402
+ 'plugins/carthagent/src/index.ts': "import { Plugin } from '@opencode/plugin';\nexport const id = 'carthagent';\n",
403
+ 'plugins/carthagent/src/rpc.ts': 'export const rpc = {};\n',
404
+ 'plugins/carthagent/package.json': '{"name":"carthagent-plugin","dependencies":{"@opencode/plugin":"2.0.32"}}\n',
405
+ 'plugins/carthagent/src/server/session-controls.ts': 'export const sessionControls = {};\n',
406
+ 'plugins/carthagent/src/server/providers.ts': 'export const providers = {};\n',
407
+ 'plugins/carthagent/src/server/device-auth.ts': 'export const deviceAuth = {};\n',
408
+ 'plugins/carthagent/src/server/workspace-rpc.ts': 'export const workspaceRpc = {};\n',
409
+ 'plugins/carthagent/src/server/pull-request-rpc.ts': 'export const pullRequests = {};\n',
410
+ 'plugins/carthagent/src/server/archive-rpc.ts': 'export const archiveRpc = {};\n',
411
+ })) {
412
+ await mkdir(dirname(join(root, file)), { recursive: true });
413
+ await writeFile(join(root, file), contents);
414
+ }
415
+ await mkdir(join(root, 'node_modules/@opencode/plugin'), { recursive: true });
416
+ await mkdir(join(root, 'plugins/carthagent/node_modules/@opencode'), { recursive: true });
417
+ execFileSync('git', ['-C', root, 'add', '.']);
418
+ execFileSync('git', ['-C', root, 'commit', '-qm', 'immutable source fixture']);
419
+ const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
420
+ const committedLockHash = createHash('sha256').update(await (await import('node:fs/promises')).readFile(join(root, 'package-lock.json'))).digest('hex');
421
+ const tree = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD^{tree}'], { encoding: 'utf8' }).trim();
422
+ const tar = execFileSync('git', ['-C', root, 'archive', '--format=tar', commit]);
423
+ await writeFile(archive, tar);
424
+ const snapshotHash = createHash('sha256').update(tar).digest('hex');
425
+ const archived = await mkdtemp(join(tmpdir(), 'native-paired-archived-tree-'));
426
+ try {
427
+ execFileSync('tar', ['-xf', archive, '-C', archived]);
428
+ await mkdir(join(archived, 'node_modules/@opencode/plugin'), { recursive: true });
429
+ await mkdir(join(archived, 'plugins/carthagent/node_modules/@opencode'), { recursive: true });
430
+ await symlink(join(archived, 'node_modules/@opencode/plugin'), join(archived, 'plugins/carthagent/node_modules/@opencode/plugin'), process.platform === 'win32' ? 'junction' : 'dir');
431
+ assert.equal(assertCompletePluginSource(archived).sourceRoot, archived);
432
+ const provenance = {
433
+ binarySha256: 'a'.repeat(64), binaryVersion: '2.0.32', sourceCommit: commit, sourceCheckoutHead: commit, sourceTree: tree,
434
+ sourceDirty: false, sourceSnapshotSha256: snapshotHash,
435
+ sourceCheckoutDirty: false, sourceCheckoutDirtyStatus: '',
436
+ sourceCheckoutStatusSha256: createHash('sha256').update('').digest('hex'), harnessDirty: false, harnessDirtyStatus: '',
437
+ sourceArchiveSha256: snapshotHash,
438
+ executionSnapshotTar: archive, executionSnapshotSha256: snapshotHash,
439
+ sourceSnapshotTar: archive, binary: '/offline/opencode',
440
+ dependencyInstallSha256: 'a'.repeat(64), dependencyInstallRealpath: '/offline/node_modules',
441
+ packageLockSha256: createHash('sha256').update(await (await import('node:fs/promises')).readFile(join(archived, 'package-lock.json'))).digest('hex'),
442
+ installedPackageLockSha256: createHash('sha256').update(await (await import('node:fs/promises')).readFile(join(archived, 'package-lock.json'))).digest('hex'),
443
+ dependencyNodeModulesRealpath: '/offline/node_modules', sourceComponentSha256: 'd'.repeat(64),
444
+ harnessComponentSha256: 'e'.repeat(64),
445
+ dependencyPackages: Object.fromEntries(['@opencode/cli', '@opencode/client', '@opencode/plugin'].map(name =>
446
+ [name, { version: '2.0.32', realpath: `/offline/node_modules/${name}`, packageJsonSha256: 'a'.repeat(64), treeSha256: 'b'.repeat(64) }]))
447
+ };
448
+ assertSnapshotProvenance(provenance);
449
+ await writeFile(join(root, 'lib/ship-auth.mjs'), 'export const shipAgentDir = () => "/changed";\n');
450
+ execFileSync('git', ['-C', root, 'add', 'lib/ship-auth.mjs']);
451
+ execFileSync('git', ['-C', root, 'commit', '-qm', 'advance source without changing lockfile']);
452
+ const currentHead = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
453
+ assert.equal(createHash('sha256').update(await (await import('node:fs/promises')).readFile(join(root, 'package-lock.json'))).digest('hex'), committedLockHash);
454
+ assert.throws(() => assertSelectedSourceHead(commit, currentHead), /must equal the current integration checkout HEAD/);
455
+ const afterCheckoutMutation = execFileSync('git', ['-C', root, 'archive', '--format=tar', commit]);
456
+ assert.equal(createHash('sha256').update(afterCheckoutMutation).digest('hex'), snapshotHash);
457
+ assert.throws(() => verifySnapshotIdentity(provenance, { ...provenance, sourceSnapshotSha256: 'f'.repeat(64) }, { verifyArtifactFiles: false }), /Snapshot identity mismatch for sourceSnapshotSha256/);
458
+ } finally {
459
+ await rm(archived, { recursive: true, force: true });
460
+ }
461
+ } finally {
462
+ await rm(root, { recursive: true, force: true });
463
+ }
464
+ });
@@ -0,0 +1,31 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { NATIVE_LOGIN_PROVIDERS, nativeLoginProvider, nativeLoginProviders, nativeProviderName } from '../lib/opencode/provider-policy.mjs';
4
+ import { cliHelp } from '../lib/opencode/cli.mjs';
5
+
6
+ test('native login policy stays small, named, ordered and excludes upstream providers', () => {
7
+ assert.deepEqual(NATIVE_LOGIN_PROVIDERS.map(provider => provider.id), ['experiential-labs', 'anthropic', 'xai', 'openai', 'google']);
8
+ assert.equal(nativeLoginProvider('opencode'), undefined);
9
+ assert.equal(nativeLoginProvider('opencode-go'), undefined);
10
+ assert.equal(nativeLoginProvider('openrouter'), undefined);
11
+ assert.equal(nativeProviderName('ship'), 'Carthagent Ship');
12
+ assert.equal(nativeLoginProvider('claudecode').id, 'anthropic');
13
+ assert.equal(nativeLoginProvider('codex').id, 'openai');
14
+ assert.equal(nativeLoginProvider('gemini').id, 'google');
15
+ assert.equal(nativeProviderName('xai-code'), 'xAI / Grok');
16
+ });
17
+
18
+ test('curated login rows preserve actual methods and do not invent subscription support', () => {
19
+ const anthropic = { id: 'anthropic', name: 'Anthropic', methods: [{ type: 'key' }] };
20
+ const ship = { id: 'experiential-labs', name: 'internal', methods: [{ type: 'oauth', id: 'carthagent-device' }] };
21
+ const rows = nativeLoginProviders([{ id: 'opencode', methods: [] }, anthropic, ship]);
22
+ assert.deepEqual(rows.map(row => row.id), ['experiential-labs', 'anthropic']);
23
+ assert.equal(rows[0].name, 'Carthagent Ship');
24
+ assert.deepEqual(rows[1].methods, [{ type: 'key' }]);
25
+ assert.equal(anthropic.name, 'Anthropic');
26
+ assert.equal(ship.name, 'internal');
27
+ });
28
+
29
+ test('native CLI help uses Carthagent branding', () => {
30
+ assert.doesNotMatch(cliHelp('test'), /opencode/i);
31
+ });